commit af8cc155b52e3cd452e61dd36e84929a1c80e679 Author: Sven Wappler Date: Mon Aug 10 22:31:09 2026 +0200 TYPO3 v15 dev-main snapshot () diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Adapter/EventDispatcherAdapter.php b/Classes/Adapter/EventDispatcherAdapter.php new file mode 100644 index 0000000..a8e733e --- /dev/null +++ b/Classes/Adapter/EventDispatcherAdapter.php @@ -0,0 +1,33 @@ +eventDispatcher->dispatch($event); + } +} diff --git a/Classes/Attribute/AsAllowedCallable.php b/Classes/Attribute/AsAllowedCallable.php new file mode 100644 index 0000000..b391e58 --- /dev/null +++ b/Classes/Attribute/AsAllowedCallable.php @@ -0,0 +1,27 @@ + $before + * @param list $after + */ + public function __construct( + public string $identifier, + public array $before = [], + public array $after = [], + ) {} +} diff --git a/Classes/Attribute/AsNonSchedulableCommand.php b/Classes/Attribute/AsNonSchedulableCommand.php new file mode 100644 index 0000000..5dc4d35 --- /dev/null +++ b/Classes/Attribute/AsNonSchedulableCommand.php @@ -0,0 +1,29 @@ +pObj = $pObj; + // Sub type + $this->mode = $mode; + $this->login = $loginData; + $this->authInfo = $authInfo; + $this->db_user = $this->getServiceOption('db_user', $authInfo['db_user'] ?? [], false); + $this->writeAttemptLog = $this->pObj->writeAttemptLog ?? true; + } + + /** + * Writes to log database table in pObj + * + * @param int $type denotes which module that has submitted the entry. This is the current list: 1=tce_db; 2=tce_file; 3=system (eg. sys_history save); 4=modules; 254=Personal settings changed; 255=login / out action: 1=login, 2=logout, 3=failed login (+ errorcode 3), 4=failure_warning_email sent + * @param int $action denotes which specific operation that wrote the entry (eg. 'delete', 'upload', 'update' and so on...). Specific for each $type. Also used to trigger update of the interface. (see the log-module for the meaning of each number !!) + * @param int $error flag. 0 = message, 1 = error (user problem), 2 = System Error (which should not happen), 3 = security notice (admin) + * @param null $_ unused + * @param string $details Default text that follows the message + * @param array $data Data that follows the log. Might be used to carry special information. If an array the first 5 entries (0-4) will be sprintf'ed the details-text... + * @param string $tablename Special field used by tce_main.php. These ($tablename, $recuid) holds the reference to the record which the log-entry is about. + * @param int|string $recuid Special field used by tce_main.php. These ($tablename, $recuid) holds the reference to the record which the log-entry is about. + */ + public function writelog($type, $action, $error, $_, $details, $data, $tablename = '', $recuid = '') + { + if ($this->writeAttemptLog) { + $this->pObj->writelog($type, $action, $error, null, $details, $data, $tablename, $recuid); + } + } + + /** + * Get a user from DB by username + * + * @param string $username User name + * @param string $extraWhere Additional WHERE clause: " AND ... + * @param array|string $dbUserSetup User db table definition, or empty string for $this->db_user + * @return array|false User array or FALSE + */ + public function fetchUserRecord($username, $extraWhere = '', $dbUserSetup = '') + { + $dbUser = is_array($dbUserSetup) ? $dbUserSetup : $this->db_user; + $user = false; + if ($username || $extraWhere) { + $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($dbUser['table']); + $query->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $constraints = array_filter([ + QueryHelper::stripLogicalOperatorPrefix($dbUser['enable_clause']), + QueryHelper::stripLogicalOperatorPrefix($extraWhere), + ]); + if (!empty($username)) { + array_unshift( + $constraints, + $query->expr()->eq( + $dbUser['username_column'], + $query->createNamedParameter($username) + ) + ); + } + $user = $query->select('*') + ->from($dbUser['table']) + ->where(...$constraints) + ->executeQuery() + ->fetchAssociative(); + } + return $user; + } + + /** + * Initialization of the service. + * This is a stub as needed by GeneralUtility::makeInstanceService() + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function init(): bool + { + return true; + } + + /** + * Resets the service. + * This is a stub as needed by GeneralUtility::makeInstanceService() + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function reset() + { + // nothing to do + } + + /** + * Returns the service key of the service + * + * @return string Service key + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function getServiceKey() + { + return $this->info['serviceKey']; + } + + /** + * Returns the title of the service + * + * @return string Service title + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function getServiceTitle() + { + return $this->info['title']; + } + + /** + * Returns service configuration values from the $TYPO3_CONF_VARS['SVCONF'] array + * + * @param string $optionName Name of the config option + * @param mixed $defaultValue Default configuration if no special config is available + * @param bool $includeDefaultConfig If set the 'default' config will be returned if no special config for this service is available (default: TRUE) + * @return mixed Configuration value for the service + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function getServiceOption($optionName, $defaultValue = '', $includeDefaultConfig = true) + { + $config = null; + $serviceType = $this->info['serviceType'] ?? ''; + $serviceKey = $this->info['serviceKey'] ?? ''; + $svOptions = $GLOBALS['TYPO3_CONF_VARS']['SVCONF'][$serviceType] ?? []; + if (isset($svOptions[$serviceKey][$optionName])) { + $config = $svOptions[$serviceKey][$optionName]; + } elseif ($includeDefaultConfig && isset($svOptions['default'][$optionName])) { + $config = $svOptions['default'][$optionName]; + } + if (!isset($config)) { + $config = $defaultValue; + } + return $config; + } + + /** + * @internal this is part of the Service API which should be avoided to be used and only used within TYPO3 internally + */ + public function getLastErrorArray(): array + { + return []; + } +} diff --git a/Classes/Authentication/AbstractUserAuthentication.php b/Classes/Authentication/AbstractUserAuthentication.php new file mode 100644 index 0000000..ee20c5d --- /dev/null +++ b/Classes/Authentication/AbstractUserAuthentication.php @@ -0,0 +1,1289 @@ + '', + // Boolean: If TRUE, 'AND pid=0' will be a part of the query... + 'disabled' => '', + 'starttime' => '', + 'endtime' => '', + 'deleted' => '', + ]; + + /** + * Form field with login-name + * @var string + * @internal + */ + protected $formfield_uname = ''; + + /** + * Form field with password + * @var string + * @internal + */ + protected $formfield_uident = ''; + + /** + * Form field with status: *'login', 'logout'. If empty login is not verified. + * @var string + * @internal + */ + protected $formfield_status = ''; + + /** + * Decides if the writelog() function is called at login and logout + * @var bool + */ + public $writeStdLog = false; + + /** + * Log failed login attempts + * @var bool + */ + public $writeAttemptLog = false; + + /** + * If set, the user-record must be stored at the page defined by $checkPid_value + * @var bool + */ + public $checkPid = true; + + /** + * The page id the user record must be stored at, can also hold a comma separated list of pids + * @var int|string|null + */ + public $checkPid_value = 0; + + /** + * Will be set to TRUE if the login session is actually written during auth-check. + * @var bool + * @internal + */ + protected $loginSessionStarted = false; + + /** + * @var array|null contains user- AND session-data from database (joined tables) + * @internal + */ + public $user; + + /** + * This array will hold the groups that the user is a member of + */ + public array $userGroups = []; + + /** + * Will prevent the setting of the session cookie + * @var bool + * @internal + */ + protected $dontSetCookie = false; + + /** + * Login type, used for services. + * @var string + */ + public $loginType = ''; + + /** + * User Settings (= preferences) + */ + public array $uc = []; + + protected ?UserSession $userSession = null; + + protected UserSessionManager $userSessionManager; + + /** + * If set, this cookie will be set to the response. + */ + protected SetCookieBehavior $setCookie = SetCookieBehavior::None; + + /** + * Initialize some important variables + * + * @throws Exception + */ + public function __construct() + { + // Backend or frontend login - used for auth services + if (empty($this->loginType)) { + throw new Exception('No loginType defined, must be set explicitly by subclass', 1476045345); + } + } + + /** + * Currently needed for various unit tests, until start() and checkAuthentication() methods + * are smaller and extracted from this class. + * + * @internal + */ + public function initializeUserSessionManager(?UserSessionManager $userSessionManager = null): void + { + $this->userSessionManager = $userSessionManager ?? UserSessionManager::create($this->loginType); + $this->createAnonymousSession(); + } + + /** + * Creates an anonymous user session. + * This method should be avoided, as it is only a workaround due to the ugly setup of this class + * and the authentication / logout behavior. + */ + public function createAnonymousSession(): void + { + if (!empty($this->userSessionManager)) { + $this->userSession = $this->userSessionManager->createAnonymousSession(); + } + } + + /** + * Starts a user session + * Typical configurations will: + * a) check if session cookie was set and if not, set one, + * b) check if a password/username was sent and if so, try to authenticate the user + * c) Lookup a session attached to a user and check timeout etc. + * d) Garbage collection, setting of no-cache headers. + * If a user is authenticated the database record of the user (array) will be set in the ->user internal variable. + */ + public function start(ServerRequestInterface $request) + { + $this->logger->debug('## Beginning of auth logging.'); + + // Make certain that NO user is set initially + $this->user = null; + + if (!isset($this->userSessionManager)) { + $this->initializeUserSessionManager(); + } + $this->userSession = $this->userSessionManager->createFromRequestOrAnonymous($request, $this->name); + + // Load user session, check to see if anyone has submitted login-information and if so authenticate + // the user with the session. $this->user[uid] may be used to write log... + try { + $this->checkAuthentication($request); + } catch (MfaRequiredException $mfaRequiredException) { + // Ensure the cookie is still set to keep the user session available + if ($this->shallSetSessionCookie()) { + $this->setSessionCookie(); + } + throw $mfaRequiredException; + } + if ($this->shallSetSessionCookie()) { + $this->setSessionCookie(); + } + // Hook for alternative ways of filling the $this->user array + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postUserLookUp'] ?? [] as $funcName) { + $_params = [ + 'pObj' => $this, + ]; + GeneralUtility::callUserFunction($funcName, $_params, $this); + } + } + + /** + * Used to apply a cookie to a PSR-7 Response. + * + * @todo: should go into a middleware? + * @internal + */ + public function appendCookieToResponse(ResponseInterface $response, ?NormalizedParams $normalizedParams = null): ResponseInterface + { + if ($this->setCookie === SetCookieBehavior::None) { + return $response; + } + if ($normalizedParams === null) { + $normalizedParams = NormalizedParams::createFromRequest($GLOBALS['TYPO3_REQUEST']); + } + $setCookieService = SetCookieService::create($this->name, $this->loginType); + if ($this->setCookie === SetCookieBehavior::Send) { + $cookieObject = $setCookieService->setSessionCookie($this->userSession, $normalizedParams); + if ($cookieObject) { + $response = $response->withAddedHeader('Set-Cookie', $cookieObject->__toString()); + } + } + if ($this->setCookie === SetCookieBehavior::Remove) { + $cookieObject = $setCookieService->removeCookie($normalizedParams); + $response = $response->withAddedHeader('Set-Cookie', $cookieObject->__toString()); + } + return $response; + } + + /** + * Sets the setCookie directive to "Send", which will then result in appending + * a new cookie to the PSR-7 response, see appendCookieToResponse(). + * In case this method is called, the cookie needs to be set later. + */ + protected function setSessionCookie() + { + $this->setCookie = SetCookieBehavior::Send; + } + + /** + * Determines whether setting the session cookie is generally enabled, + * or the current session is a non-session cookie (FE permalogin). + */ + protected function shallSetSessionCookie(): bool + { + return !$this->dontSetCookie + || SetCookieService::create($this->name, $this->loginType)->isRefreshTimeBasedCookie($this->userSession); + } + + /** + * Determine whether a session cookie needs to be set (lifetime=0) + * + * @return bool + * @internal + */ + protected function isSetSessionCookie() + { + return SetCookieService::create($this->name, $this->loginType)->isSetSessionCookie($this->userSession); + } + + /** + * Determine whether a non-session cookie needs to be set (lifetime>0) + * + * @return bool + * @internal + */ + protected function isRefreshTimeBasedCookie() + { + return SetCookieService::create($this->name, $this->loginType)->isRefreshTimeBasedCookie($this->userSession); + } + + /** + * "auth" services configuration array from $GLOBALS['TYPO3_CONF_VARS']['SVCONF']['auth'] + */ + protected function getAuthServiceConfiguration(): array + { + if (is_array($GLOBALS['TYPO3_CONF_VARS']['SVCONF']['auth']['setup'] ?? null)) { + return $GLOBALS['TYPO3_CONF_VARS']['SVCONF']['auth']['setup']; + } + return []; + } + + /** + * Checks if a submission of username and password is present or use other authentication by auth services + * + * @throws MfaRequiredException + * @internal + */ + public function checkAuthentication(ServerRequestInterface $request) + { + $authConfiguration = $this->getAuthServiceConfiguration(); + if (!empty($authConfiguration)) { + $this->logger->debug('Authentication Service Configuration found.', ['auth_configuration' => $authConfiguration]); + } + $userRecordCandidate = false; + // User is not authenticated by default + $authenticated = false; + // User want to login with passed login data (name/password) + $activeLogin = false; + $this->logger->debug('Login type: {type}', ['type' => $this->loginType]); + // Get Login/Logout data submitted by a form or params + $loginData = $this->getLoginFormData($request); + $this->logger->debug('Login data', $this->removeSensitiveLoginDataForLoggingInfo($loginData)); + $type = LoginType::tryFrom($loginData['status'] ?? ''); + // Active logout (eg. with "logout" button) + if ($type === LoginType::LOGOUT) { + if ($this->writeStdLog) { + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::LOGOUT, SystemLogErrorClassification::MESSAGE, null, 'User %s logged out', [$this->user['username']], '', 0); + } + $this->logger->info('User logged out. Id: {session}', ['session' => sha1($this->userSession->getIdentifier())]); + $this->logoff(); + } + // Determine whether we need to skip session update. + // This is used mainly for checking session timeout in advance without refreshing the current session's timeout. + $skipSessionUpdate = (bool)($request->getQueryParams()['skipSessionUpdate'] ?? false); + $isExistingSession = false; + $anonymousSession = false; + $authenticatedUserFromSession = null; + if (!$this->userSession->isNew()) { + // Read user data if this is bound to a user + // However, if the user data is not valid, or the session has timed out we'll recreate a new anonymous session + if ($this->userSession->getUserId() > 0) { + $authenticatedUserFromSession = $this->fetchValidUserFromSessionOrDestroySession($skipSessionUpdate); + } + $isExistingSession = !$this->userSession->isNew(); + $anonymousSession = $isExistingSession && $this->userSession->isAnonymous(); + } + + // Active login (eg. with login form). + if ($type === LoginType::LOGIN) { + if (!$isExistingSession) { + $activeLogin = true; + $this->logger->debug('Active login (eg. with login form)'); + // check referrer for submitted login values + if ($this->formfield_status && $loginData['uident'] && $loginData['uname']) { + // Delete old user session if any + $this->logoff(); + } + // Refuse login for _CLI users, if not processing a CLI request type + // (although we shouldn't be here in case of a CLI request type) + if (stripos($loginData['uname'], '_CLI_') === 0 && !Environment::isCli()) { + throw new \RuntimeException('TYPO3 Fatal Error: You have tried to login using a CLI user. Access prohibited!', 1270853931); + } + } + // Cause elevation of privilege, make sure regenerateSessionId is called later on + // Note for further research: $anonymousSession actually implies having $isExistingSession = true + // allowing to further simplify this concern. + if ($anonymousSession) { + $activeLogin = true; + } + } + + if ($isExistingSession && $authenticatedUserFromSession !== null) { + $this->logger->debug('User found in session', [ + $this->userid_column => $authenticatedUserFromSession[$this->userid_column] ?? null, + $this->username_column => $authenticatedUserFromSession[$this->username_column] ?? null, + ]); + } else { + $this->logger->debug('No user session found'); + } + + if ($activeLogin) { + $context = GeneralUtility::makeInstance(Context::class); + $securityAspect = SecurityAspect::provideIn($context); + $requestToken = $securityAspect->getReceivedRequestToken(); + + $event = new BeforeRequestTokenProcessedEvent($this, $request, $requestToken); + GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch($event); + $requestToken = $event->getRequestToken(); + + $requestTokenScopeMatches = ($requestToken->scope ?? null) === 'core/user-auth/' . strtolower($this->loginType); + if (!$requestTokenScopeMatches) { + $this->logger->debug('Missing or invalid request token during login', ['requestToken' => $requestToken]); + // important: disable `$activeLogin` state + $activeLogin = false; + } elseif ($requestToken instanceof RequestToken && $requestToken->getSigningSecretIdentifier() !== null) { + $securityAspect->getSigningSecretResolver()->revokeIdentifier( + $requestToken->getSigningSecretIdentifier() + ); + } + } + + // Fetch users from the database (or somewhere else) + $possibleUsers = $this->fetchPossibleUsers($loginData, $activeLogin, $isExistingSession, $authenticatedUserFromSession, $request); + + // If no new user was set we use the already found user session + if (empty($possibleUsers) && $isExistingSession && !$anonymousSession) { + // Check if the previous services returned a proper user + if (is_array($authenticatedUserFromSession)) { + $possibleUsers[] = $authenticatedUserFromSession; + $userRecordCandidate = $authenticatedUserFromSession; + // User is authenticated because we found a user session + $authenticated = true; + $this->logger->debug('User session used', [ + $this->userid_column => $authenticatedUserFromSession[$this->userid_column] ?? '', + $this->username_column => $authenticatedUserFromSession[$this->username_column] ?? '', + ]); + } + } + + // Re-auth user when 'auth'-service option is set + if (!empty($authConfiguration[$this->loginType . '_alwaysAuthUser'])) { + $authenticated = false; + $this->logger->debug('alwaysAuthUser option is enabled'); + } + // Authenticate the user if needed + if (!empty($possibleUsers) && !$authenticated) { + foreach ($possibleUsers as $userRecordCandidate) { + // Use 'auth' service to authenticate the user + // If one service returns FALSE then authentication failed + // a service might return 100 which means there's no reason to stop but the user can't be authenticated by that service + $this->logger->debug('Auth user', $this->removeSensitiveLoginDataForLoggingInfo($userRecordCandidate, true)); + $subType = 'authUser' . $this->loginType; + + /** @var AuthenticationService $serviceObj */ + foreach ($this->getAuthServices($subType, $loginData, $authenticatedUserFromSession, $request) as $serviceObj) { + if (($ret = (int)$serviceObj->authUser($userRecordCandidate)) > 0) { + // If the service returns >=200 then no more checking is needed - useful for IP checking without password + if ($ret >= 200) { + $authenticated = true; + break; + } + if ($ret < 100) { + $authenticated = true; + } + // $ret is between 100 and 199 which means "I'm not responsible, ask others" + } else { + // $ret is < 0 + $authenticated = false; + break; + } + } + + if ($authenticated) { + // Leave foreach() because a user is authenticated + break; + } + } + // mimic user authentication to mitigate observable timing discrepancies + // @link https://cwe.mitre.org/data/definitions/208.html + } elseif ($activeLogin) { + $subType = 'authUser' . $this->loginType; + foreach ($this->getAuthServices($subType, $loginData, $authenticatedUserFromSession, $request) as $serviceObj) { + if ($serviceObj instanceof MimicServiceInterface && $serviceObj->mimicAuthUser() === false) { + break; + } + } + } + + // If user is authenticated, then a valid user is found in $userRecordCandidate + if ($authenticated) { + // Insert session record if needed + if (!$isExistingSession + || $anonymousSession + || (int)($userRecordCandidate[$this->userid_column] ?? 0) !== $this->userSession->getUserId() + ) { + $sessionData = $this->userSession->getData(); + // Create a new session with a fixated user + $this->userSession = $this->createUserSession($userRecordCandidate); + + // Preserve session data on login + if ($anonymousSession || $isExistingSession) { + $this->userSession->overrideData($sessionData); + } + + $this->user = array_merge($userRecordCandidate, $this->user ?? []); + + // The login session is started. + $this->loginSessionStarted = true; + $this->logger->debug('User session finally read', [ + $this->userid_column => $this->getUserId(), + $this->username_column => $this->getUserName(), + ]); + } else { + // if we come here the current session is for sure not anonymous as this is a pre-condition for $authenticated = true + $this->user = $authenticatedUserFromSession; + } + + if ($activeLogin && !$this->userSession->isNew()) { + $this->regenerateSessionId(); + } + + // Since the user is not fully authenticated we need to unpack UC here to be + // able to retrieve a possible defined default (preferred) MFA provider. + $this->unpack_uc(); + + if ($activeLogin) { + // User logged in - write that to the log! + if ($this->writeStdLog) { + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::LOGIN, SystemLogErrorClassification::MESSAGE, null, 'User %s logged in from ###IP###', [$userRecordCandidate[$this->username_column]], '', ''); + } + $this->logger->info('User {username} logged in from {ip}', [ + 'username' => $userRecordCandidate[$this->username_column], + 'ip' => $request->getAttribute('normalizedParams')->getRemoteAddress(), + ]); + } else { + $this->logger->debug('User {username} authenticated from {ip}', [ + 'username' => $userRecordCandidate[$this->username_column], + 'ip' => $request->getAttribute('normalizedParams')->getRemoteAddress(), + ]); + } + // Check if multi-factor authentication is required + $this->evaluateMfaRequirements(); + } else { + // Mark the current login attempt as failed + if (empty($possibleUsers) && $activeLogin) { + $this->logger->debug('Login failed', [ + 'loginData' => $this->removeSensitiveLoginDataForLoggingInfo($loginData), + ]); + } elseif (!empty($possibleUsers)) { + $this->logger->debug('Login failed', [ + $this->userid_column => $userRecordCandidate[$this->userid_column], + $this->username_column => $userRecordCandidate[$this->username_column], + ]); + } + + // If there were a login failure, check to see if a warning email should be sent + if ($activeLogin) { + GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch( + new LoginAttemptFailedEvent($this, $request, $this->removeSensitiveLoginDataForLoggingInfo($loginData)) + ); + } + } + } + + /** + * Loads users from various sources (= authentication services) as an array of arrays. + * + * @param array|null $authenticatedUserFromSession if we have a user from an existing session, this is set here, otherwise null + */ + protected function fetchPossibleUsers(array $loginData, bool $activeLogin, bool $isExistingSession, ?array $authenticatedUserFromSession, ServerRequestInterface $request): array + { + $possibleUsers = []; + $authConfiguration = $this->getAuthServiceConfiguration(); + $alwaysFetchUsers = !empty($authConfiguration[$this->loginType . '_alwaysFetchUser']); + $fetchUsersIfNoSessionIsGiven = !empty($authConfiguration[$this->loginType . '_fetchUserIfNoSession']); + if ( + $activeLogin + || $alwaysFetchUsers + || (!$isExistingSession && $fetchUsersIfNoSessionIsGiven) + ) { + // Use 'auth' service to find the user + // First found user will be used + $subType = 'getUser' . $this->loginType; + /** @var AuthenticationService $serviceObj */ + foreach ($this->getAuthServices($subType, $loginData, $authenticatedUserFromSession, $request) as $serviceObj) { + $row = $serviceObj->getUser(); + if (is_array($row)) { + $possibleUsers[] = $row; + $this->logger->debug('User found', [ + $this->userid_column => $row[$this->userid_column], + $this->username_column => $row[$this->username_column], + ]); + // User found, just stop to search for more if not configured to go on + if (empty($authConfiguration[$this->loginType . '_fetchAllUsers'])) { + break; + } + } + } + + if ($alwaysFetchUsers) { + $this->logger->debug($this->loginType . '_alwaysFetchUser option is enabled'); + } + if (empty($possibleUsers)) { + $this->logger->debug('No user found by services'); + } else { + $this->logger->debug('{count} user records found by services', ['count' => count($possibleUsers)]); + } + } + return $possibleUsers; + } + + /** + * This method checks if the user is authenticated but has not succeeded in + * passing his MFA challenge. This method can therefore only be used if a user + * has been authenticated against his first authentication method (username+password + * or any other authentication token). + * + * @throws MfaRequiredException + * @internal + */ + protected function evaluateMfaRequirements(): void + { + // MFA has been validated already, nothing to do + if ($this->getSessionData('mfa')) { + return; + } + // If the user session does not contain the 'mfa' key - indicating that MFA is already + // passed - get the first provider for authentication, which is either the default provider + // or the first active provider (based on the providers configured ordering). + $provider = GeneralUtility::makeInstance(MfaProviderRegistry::class)->getFirstAuthenticationAwareProvider($this); + // Throw an exception (hopefully caught in a middleware) when an active provider for the user exists + if ($provider !== null) { + throw new MfaRequiredException($provider, 1613687097); + } + } + + /** + * Whether the user is required to set up MFA + * + * @internal + */ + public function isMfaSetupRequired(): bool + { + return false; + } + + /** + * Initializes authentication services to be used in a foreach loop + * + * @param string $subType e.g. getUserFE + * @param array|null $authenticatedUserFromSession the user which was loaded from the session, or null if none was found + * @return \Traversable A generator of service objects + */ + protected function getAuthServices(string $subType, array $loginData, ?array $authenticatedUserFromSession, ServerRequestInterface $request): \Traversable + { + $serviceChain = []; + // The info array provide additional information for auth services + $authInfo = $this->getAuthInfoArray($request); + if ($authenticatedUserFromSession !== null) { + $authInfo['user'] = $authenticatedUserFromSession; + } + while (is_object($serviceObj = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain))) { + $serviceChain[] = $serviceObj->getServiceKey(); + $serviceObj->initAuth($subType, $loginData, $authInfo, $this); + yield $serviceObj; + } + if (!empty($serviceChain)) { + $this->logger->debug('{subtype} auth services called: {chain}', [ + 'subtype' => $subType, + 'chain' => implode(',', $serviceChain), + ]); + } + } + + /** + * Regenerate the session ID and transfer the session to new ID + * Call this method whenever a user proceeds to a higher authorization level + * e.g. when an anonymous session is now authenticated. + */ + protected function regenerateSessionId() + { + $this->userSession = $this->userSessionManager->regenerateSession($this->userSession->getIdentifier()); + } + + /************************* + * + * User Sessions + * + *************************/ + + /** + * Creates a user session record and returns its values. + * + * @param array $userRecordCandidate User data array + * @return UserSession The session data for the newly created session. + */ + public function createUserSession(array $userRecordCandidate): UserSession + { + // Needed for testing framework + if (!isset($this->userSessionManager)) { + $this->initializeUserSessionManager(); + } + $userRecordCandidateId = (int)($userRecordCandidate[$this->userid_column] ?? 0); + $session = $this->userSessionManager->elevateToFixatedUserSession($this->userSession, $userRecordCandidateId); + // Updating lastLogin_column carrying information about last login. + $this->updateLoginTimestamp($userRecordCandidateId); + return $session; + } + + /** + * Updates the last login column in the user with the given id + */ + protected function updateLoginTimestamp(int $userId) + { + if ($this->lastLogin_column) { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user_table); + $connection->update( + $this->user_table, + [$this->lastLogin_column => $GLOBALS['EXEC_TIME']], + [$this->userid_column => $userId] + ); + $this->user[$this->lastLogin_column] = $GLOBALS['EXEC_TIME']; + } + } + + /** + * If the session is bound to a user, this method fetches the user record, and returns it. + * If the session has a timeout, the session date is extended if needed. Also the ìs_online + * flag is updated for the user. + * + * However, if the session has expired the session is removed and the request is treated as an anonymous session. + * + * @param bool $skipSessionUpdate + */ + protected function fetchValidUserFromSessionOrDestroySession(bool $skipSessionUpdate = false): ?array + { + if ($this->userSession->isAnonymous()) { + return null; + } + // Fetch the user from the DB + $userRecord = $this->getRawUserByUid($this->userSession->getUserId() ?? 0); + if ($userRecord) { + // A user was found + $userRecord['is_online'] = $this->userSession->getLastUpdated(); + if (!$this->userSessionManager->hasExpired($this->userSession)) { + if (!$skipSessionUpdate) { + $this->userSession = $this->userSessionManager->updateSessionTimestamp($this->userSession); + } + } else { + // Delete any user set... + $this->logoff(); + $userRecord = false; + $this->createAnonymousSession(); + } + } + return is_array($userRecord) ? $userRecord : null; + } + + /** + * Regenerates the session ID and sets the cookie again. + * + * @internal + */ + public function enforceNewSessionId() + { + $this->regenerateSessionId(); + $this->setSessionCookie(); + } + + /** + * Log out current user! + * Removes the current session record, sets the internal ->user array to a blank string; + * Thereby the current user (if any) is effectively logged out! + */ + public function logoff() + { + $this->logger->debug('logoff: ses_id = {session}', ['session' => sha1($this->userSession->getIdentifier())]); + + $dispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + + $event = new BeforeUserLogoutEvent($this, $this->userSession); + $event = $dispatcher->dispatch($event); + + if ($event->shouldLogout()) { + $this->performLogoff(); + } + + $dispatcher->dispatch(new AfterUserLoggedOutEvent($this)); + } + + /** + * Perform the logoff action. Called from logoff() as a way to allow subclasses to override + * what happens when a user logs off, without needing to reproduce the hook calls and logging + * that happens in the public logoff() API method. + */ + protected function performLogoff() + { + if ($this->userSession) { + $this->userSessionManager->removeSession($this->userSession); + } + $this->createAnonymousSession(); + $this->user = null; + if ($this->isCookieSet()) { + $this->removeCookie(); + } + } + + /** + * Empty / unset the cookie + * + * @param string|null $cookieName usually, this is $this->name + * @internal + */ + public function removeCookie($cookieName = null) + { + $this->setCookie = SetCookieBehavior::Remove; + } + + /** + * Returns whether this request is going to set a cookie + * or a cookie was already found in the system + * + * @return bool Returns TRUE if a cookie is set + * @internal + */ + protected function isCookieSet() + { + return SetCookieService::create($this->name, $this->loginType)->isCookieSet( + $GLOBALS['TYPO3_REQUEST'] ?? null, + $this->userSession + ); + } + + /************************* + * + * SQL Functions + * + *************************/ + /** + * This returns the restrictions needed to select the user respecting + * enable columns and flags like deleted, hidden, starttime, endtime + * and rootLevel + * + * @internal + */ + protected function userConstraints(): QueryRestrictionContainerInterface + { + $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class); + + if (empty($this->enablecolumns['disabled'])) { + $restrictionContainer->removeByType(HiddenRestriction::class); + } + + if (empty($this->enablecolumns['deleted'])) { + $restrictionContainer->removeByType(DeletedRestriction::class); + } + + if (empty($this->enablecolumns['starttime'])) { + $restrictionContainer->removeByType(StartTimeRestriction::class); + } + + if (empty($this->enablecolumns['endtime'])) { + $restrictionContainer->removeByType(EndTimeRestriction::class); + } + + if (!empty($this->enablecolumns['rootLevel'])) { + $restrictionContainer->add(GeneralUtility::makeInstance(RootLevelRestriction::class, [$this->user_table])); + } + + if ($this->checkPid && $this->checkPid_value !== null && $this->checkPid_value !== '') { + $restrictionContainer->add( + GeneralUtility::makeInstance( + PageIdListRestriction::class, + [$this->user_table], + GeneralUtility::intExplode(',', (string)$this->checkPid_value, true) + ) + ); + } + + return $restrictionContainer; + } + + /************************* + * + * Session and Configuration Handling + * + *************************/ + /** + * This writes $this->>uc to the user-record. This is a way of providing session-data. + * You can fetch the data again through $this->uc in this class! + */ + public function writeUC() + { + $userId = $this->getUserId(); + if ($userId) { + $this->logger->debug('writeUC: {userid_column}={value}', [ + 'userid_column' => $this->userid_column, + 'value' => $userId, + ]); + GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user_table)->update( + $this->user_table, + ['uc' => serialize($this->uc)], + [$this->userid_column => $userId], + ['uc' => Connection::PARAM_LOB] + ); + } + } + + /** + * Unserializes the user configuration from the user record into $this->>uc + * @internal + */ + protected function unpack_uc() + { + if (isset($this->user['uc'])) { + $theUC = unserialize($this->user['uc'], ['allowed_classes' => false]); + if (is_array($theUC)) { + $this->uc = $theUC; + } + } + } + + /** + * Stores data for a module. + * The data is stored with the session ID, so you can even check upon retrieval + * if the module data is from a previous session or from the current session. + * + * @param string $module Is the identifier of the module, e.g. "content_status" + * @param mixed $data Is the data you want to store for that module (array, string, ...) + * @param bool $dontPersistImmediately If set, then the ->uc array (which carries all kinds of user data) is NOT written immediately, but must be written by some subsequent call. + */ + public function pushModuleData(string $module, mixed $data, bool $dontPersistImmediately = false): void + { + $hashService = GeneralUtility::makeInstance(HashService::class); + $sessionHash = $hashService->hmac( + $this->userSession->getIdentifier(), + 'core-session-hash' + ); + $this->uc['moduleData'][$module] = $data; + $this->uc['moduleSessionID'][$module] = $sessionHash; + if ($dontPersistImmediately === false) { + $this->writeUC(); + } + } + + /** + * Gets module data for a module (from a loaded ->uc array) + * + * @param string $module Is the identifier of the module, e.g. "content_status" + * @param string $type If $type = 'ses' then module data is returned only if it was stored in the current session, otherwise data from a previous session will be returned (if available). + * @return mixed The module data if available: $this->uc['moduleData'][$module]; + */ + public function getModuleData(string $module, string $type = ''): mixed + { + $hashService = GeneralUtility::makeInstance(HashService::class); + $sessionHash = $hashService->hmac( + $this->userSession->getIdentifier(), + 'core-session-hash' + ); + $sessionData = $this->uc['moduleData'][$module] ?? null; + $moduleSessionIdHash = $this->uc['moduleSessionID'][$module] ?? null; + if ($type !== 'ses' || ($sessionData !== null && $moduleSessionIdHash === $sessionHash)) { + return $sessionData; + } + return null; + } + + /** + * Returns the session data stored for $key. + * The data will last only for this login session since it is stored in the user session. + * + * @param string $key The key associated with the session data + * @return mixed + */ + public function getSessionData($key) + { + return $this->userSession ? $this->userSession->get($key) : ''; + } + + /** + * Set session data by key. + * The data will last only for this login session since it is stored in the user session. + * + * @param string $key A non empty string to store the data under + * @param mixed $data Data store store in session + */ + public function setSessionData($key, $data) + { + $this->userSession->set($key, $data); + } + + /** + * Sets the session data ($data) for $key and writes all session data (from ->user['ses_data']) to the database. + * The data will last only for this login session since it is stored in the session table. + * + * @param string $key Pointer to an associative key in the session data array which is stored serialized in the field "ses_data" of the session table. + * @param mixed $data The data to store in index $key + */ + public function setAndSaveSessionData($key, $data) + { + $this->userSession->set($key, $data); + $this->logger->debug('setAndSaveSessionData: ses_id = {session}', ['session' => sha1($this->userSession->getIdentifier())]); + $this->userSession = $this->userSessionManager->updateSession($this->userSession); + } + + /************************* + * + * Misc + * + *************************/ + /** + * Returns an info array with Login/Logout data submitted by a form or params + * + * @return array + * @internal + */ + public function getLoginFormData(ServerRequestInterface $request) + { + $parsedBody = $request->getParsedBody(); + $queryParams = $request->getQueryParams(); + $loginData = [ + 'status' => StringUtility::filter($parsedBody[$this->formfield_status] ?? $queryParams[$this->formfield_status] ?? null), + 'uname' => StringUtility::filter($parsedBody[$this->formfield_uname] ?? '', ''), + 'uident' => StringUtility::filter($parsedBody[$this->formfield_uident] ?? '', ''), + ]; + // Only process the login data if a login is requested + if (LoginType::tryFrom($loginData['status'] ?? '') === LoginType::LOGIN) { + $loginData = $this->processLoginData($loginData, $request); + } + return $loginData; + } + + public function isActiveLogin(ServerRequestInterface $request): bool + { + $status = $request->getParsedBody()[$this->formfield_status] ?? $request->getQueryParams()[$this->formfield_status] ?? ''; + return LoginType::tryFrom($status) === LoginType::LOGIN; + } + + /** + * Processes Login data submitted by a form or params + * + * @param array $loginData Login data array + * @param ServerRequestInterface $request + * @return array + * @internal + */ + public function processLoginData(array $loginData, ServerRequestInterface $request): array + { + $this->logger->debug('Login data before processing', $this->removeSensitiveLoginDataForLoggingInfo($loginData)); + $subType = 'processLoginData' . $this->loginType; + $isLoginDataProcessed = false; + $processedLoginData = $loginData; + /** @var AuthenticationService $serviceObject */ + foreach ($this->getAuthServices($subType, $loginData, null, $request) as $serviceObject) { + $serviceResult = $serviceObject->processLoginData($processedLoginData); + if (!empty($serviceResult)) { + $isLoginDataProcessed = true; + // If the service returns >=200 then no more processing is needed + if ((int)$serviceResult >= 200) { + break; + } + } + } + if ($isLoginDataProcessed) { + $loginData = $processedLoginData; + $this->logger->debug('Processed login data', $this->removeSensitiveLoginDataForLoggingInfo($processedLoginData)); + } + return $loginData; + } + + /** + * Removes any sensitive data from the incoming data (either from loginData, processedLogin data + * or the user record from the DB). + * + * No type hinting is added because it might be possible that the incoming data is of any other type. + * + * @param mixed|array $data + * @param bool $isUserRecord + * @return mixed + */ + protected function removeSensitiveLoginDataForLoggingInfo($data, bool $isUserRecord = false) + { + if ($isUserRecord && is_array($data)) { + $fieldNames = ['uid', 'pid', 'tstamp', 'crdate', 'deleted', 'disabled', 'starttime', 'endtime', 'username', 'admin', 'usergroup', 'db_mountpoints', 'file_mountpoints', 'file_permissions', 'workspace_perms', 'lastlogin', 'workspace_id', 'category_perms']; + $data = array_intersect_key($data, array_combine($fieldNames, $fieldNames)); + } + if (isset($data['uident'])) { + $data['uident'] = '********'; + } + if (isset($data['uident_text'])) { + $data['uident_text'] = '********'; + } + if (isset($data['password'])) { + $data['password'] = '********'; + } + return $data; + } + + /** + * Returns an info array which provides additional information for auth services + * + * @return array + * @internal + */ + public function getAuthInfoArray(ServerRequestInterface $request) + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->user_table); + $expressionBuilder = $queryBuilder->expr(); + $authInfo = []; + $authInfo['loginType'] = $this->loginType; + $authInfo['request'] = $request; + $normalizedParams = $request->getAttribute('normalizedParams'); + $authInfo['refInfo'] = $normalizedParams ? parse_url($normalizedParams->getHttpReferer()) : null; + $authInfo['HTTP_HOST'] = $normalizedParams?->getHttpHost(); + $authInfo['REMOTE_ADDR'] = $normalizedParams?->getRemoteAddress(); + $authInfo['REMOTE_HOST'] = $normalizedParams?->getRemoteHost(); + // Can be overridden in localconf by SVCONF: + $authInfo['db_user']['table'] = $this->user_table; + $authInfo['db_user']['userid_column'] = $this->userid_column; + $authInfo['db_user']['username_column'] = $this->username_column; + $authInfo['db_user']['userident_column'] = $this->userident_column; + $authInfo['db_user']['enable_clause'] = $this->userConstraints()->buildExpression( + [$this->user_table => $this->user_table], + $expressionBuilder + ); + return $authInfo; + } + + /** + * DUMMY: Writes to log database table (in some extension classes) + * + * @param int $type denotes which module that has submitted the entry. This is the current list: 1=tce_db; 2=tce_file; 3=system (eg. sys_history save); 4=modules; 254=Personal settings changed; 255=login / out action: 1=login, 2=logout, 3=failed login (+ errorcode 3), 4=failure_warning_email sent + * @param int $action denotes which specific operation that wrote the entry (eg. 'delete', 'upload', 'update' and so on...). Specific for each $type. Also used to trigger update of the interface. (see the log-module for the meaning of each number !!) + * @param int $error flag. 0 = message, 1 = error (user problem), 2 = System Error (which should not happen), 3 = security notice (admin) + * @param null $_ unused + * @param string $details Default text that follows the message + * @param array $data Data that follows the log. Might be used to carry special information. If an array the first 5 entries (0-4) will be sprintf'ed the details-text... + * @param string $tablename Special field used by tce_main.php. These ($tablename, $recuid) hold the reference to the record which the log-entry is about. + * @param int|string $recuid Special field used by tce_main.php. These ($tablename, $recuid) hold the reference to the record which the log-entry is about. + */ + public function writelog($type, $action, $error, $_, $details, $data, $tablename, $recuid) {} + + /** + * Raw initialization of the be_user with uid=$uid + * This will circumvent all login procedures and select a be_users record from the + * database and set the content of ->user to the record selected. + * Thus the BE_USER object will appear like if a user was authenticated - however without + * a session id and the fields from the session table of course. + * Will check the users for disabled, start/endtime, etc. ($this->user_where_clause()) + * + * @param int $uid The UID of the backend user to set in ->user + * @internal + */ + public function setBeUserByUid($uid) + { + $this->user = $this->getRawUserByUid($uid); + } + + /** + * Raw initialization of the be_user with username=$name + * + * @param string $name The username to look up. + * @see \TYPO3\CMS\Core\Authentication\AbstractUserAuthentication::setBeUserByUid() + * @internal + */ + public function setBeUserByName($name) + { + $this->user = $this->getRawUserByName($name) ?: null; + } + + /** + * Fetching raw user record with uid=$uid + * + * @param int $uid The UID of the backend user to set in ->user + * @return array user record or FALSE + * @internal + */ + public function getRawUserByUid($uid) + { + $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->user_table); + $query->setRestrictions($this->userConstraints()); + $query->select('*') + ->from($this->user_table) + ->where($query->expr()->eq($this->userid_column, $query->createNamedParameter($uid, Connection::PARAM_INT))); + + return $query->executeQuery()->fetchAssociative(); + } + + /** + * Fetching raw user record with username=$name + * + * @param string $name The username to look up. + * @return array user record or FALSE + * @see \TYPO3\CMS\Core\Authentication\AbstractUserAuthentication::getUserByUid() + * @internal + */ + public function getRawUserByName($name) + { + $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->user_table); + $query->setRestrictions($this->userConstraints()); + $query->select('*') + ->from($this->user_table) + ->where($query->expr()->eq($this->username_column, $query->createNamedParameter($name))); + + return $query->executeQuery()->fetchAssociative(); + } + + public function getUserId(): ?int + { + if (isset($this->user[$this->userid_column])) { + return (int)$this->user[$this->userid_column]; + } + return null; + } + + public function getUserName(): ?string + { + if (isset($this->user[$this->username_column])) { + return (string)$this->user[$this->username_column]; + } + return null; + } + + public function getSession(): UserSession + { + return $this->userSession; + } +} diff --git a/Classes/Authentication/AccessCheckResult.php b/Classes/Authentication/AccessCheckResult.php new file mode 100644 index 0000000..db33400 --- /dev/null +++ b/Classes/Authentication/AccessCheckResult.php @@ -0,0 +1,34 @@ += 200) in order to stop loginData processing of other + * services in the authentication service chain. + * + * @param array $loginData Credentials that are submitted and potentially modified by other services + */ + public function processLoginData(array &$loginData): bool|int + { + $loginData = array_map(trim(...), $loginData); + $loginData['uident_text'] = $loginData['uident']; + return true; + } + + /** + * Find a user (eg. look up the user record in database when a login is sent) + * + * @return array|false User array or FALSE + */ + public function getUser() + { + if (LoginType::tryFrom($this->login['status'] ?? '') !== LoginType::LOGIN) { + return false; + } + if ((string)$this->login['uident_text'] === '') { + // Failed Login attempt (no password given) + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP### for username \'%s\' with an empty password!', [ + $this->login['uname'], + ]); + $this->logger->warning('Login-attempt from {ip}, for username "{username}" with an empty password!', [ + 'ip' => $this->authInfo['REMOTE_ADDR'], + 'username' => $this->login['uname'], + ]); + return false; + } + + $user = $this->fetchUserRecord($this->login['uname']); + if (!is_array($user)) { + // Failed login attempt (no username found) + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP###, username \'%s\' not found!', [$this->login['uname']]); + $this->logger->info('Login-attempt from username "{username}" not found!', [ + 'username' => $this->login['uname'], + 'REMOTE_ADDR' => $this->authInfo['REMOTE_ADDR'], + ]); + } else { + $this->logger->debug('User found', [ + $this->db_user['userid_column'] => $user[$this->db_user['userid_column']], + $this->db_user['username_column'] => $user[$this->db_user['username_column']], + ]); + } + return $user; + } + + /** + * Authenticate a user: Check submitted user credentials against stored hashed password. + * + * Returns one of the following status codes: + * >= 200: User authenticated successfully. No more checking is needed by other auth services. + * >= 100: User not authenticated; this service is not responsible. Other auth services will be asked. + * > 0: User authenticated successfully. Other auth services will still be asked. + * <= 0: Authentication failed, no more checking needed by other auth services. + * + * @param array $user User data + * @return int Authentication status code, one of 0, 100, 200 + */ + public function authUser(array $user): int + { + // Early 100 "not responsible, check other services" if username or password is empty + if (!isset($this->login['uident_text']) || (string)$this->login['uident_text'] === '' + || !isset($this->login['uname']) || (string)$this->login['uname'] === '') { + return 100; + } + + if (empty($this->db_user['table'])) { + throw new \RuntimeException('User database table not set', 1533159150); + } + + $submittedUsername = (string)$this->login['uname']; + $submittedPassword = (string)$this->login['uident_text']; + $passwordHashInDatabase = $user['password']; + $userDatabaseTable = $this->db_user['table']; + + $isReHashNeeded = false; + + $saltFactory = GeneralUtility::makeInstance(PasswordHashFactory::class); + + // Get a hashed password instance for the hash stored in db of this user + try { + $hashInstance = $saltFactory->get($passwordHashInDatabase, $this->pObj->loginType); + } catch (InvalidPasswordHashException $exception) { + // Could not find a responsible hash algorithm for given password. This is unusual since other + // authentication services would usually be called before this one with higher priority. We thus log + // the failed login but still return '100' to proceed with other services that may follow. + $message = 'Login-attempt from ###IP###, username \'%s\', no suitable hash method found!'; + $this->writeLogMessage($message, $submittedUsername); + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]); + // Not responsible, check other services + return 100; + } + + // An instance of the currently configured salted password mechanism + // Don't catch InvalidPasswordHashException here: Only install tool should handle those configuration failures + $defaultHashInstance = $saltFactory->getDefaultHashInstance($this->pObj->loginType); + + // We found a hash class that can handle this type of hash + $isValidPassword = $hashInstance->checkPassword($submittedPassword, $passwordHashInDatabase); + if ($isValidPassword) { + if ($hashInstance->isHashUpdateNeeded($passwordHashInDatabase) + || $defaultHashInstance != $hashInstance + ) { + // Lax object comparison intended: Rehash if old and new salt objects are not + // instances of the same class. + $isReHashNeeded = true; + } + } + + if (!$isValidPassword) { + // Failed login attempt - wrong password + $message = 'Login-attempt from ###IP###, username \'%s\', password not accepted!'; + $this->writeLogMessage($message, $submittedUsername); + $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]); + // Responsible, authentication failed, do NOT check other services + return 0; + } + + if ($isReHashNeeded) { + // Given password validated but a re-hash is needed. Do so. + $this->updatePasswordHashInDatabase( + $userDatabaseTable, + (int)$user['uid'], + $defaultHashInstance->getHashedPassword($submittedPassword) + ); + } + + // Responsible, authentication ok. Log successful login and return 'auth ok, do NOT check other services' + $this->writeLogMessage($this->pObj->loginType . ' Authentication successful for username \'%s\'', $submittedUsername); + return 200; + } + + /** + * Mimics password hashing for invalid authentication requests to mitigate + * @link https://cwe.mitre.org/data/definitions/208.html: CWE-208: Observable Timing Discrepancy + */ + public function mimicAuthUser(): bool + { + try { + $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class); + $defaultHashInstance = $hashFactory->getDefaultHashInstance($this->pObj->loginType); + $defaultHashInstance->getHashedPassword(random_bytes(10)); + } catch (\Exception) { + // no further processing here + } + return false; + } + + /** + * Method updates a FE/BE user record - in this case a new password string will be set. + * + * @param string $table Database table of this user, usually 'be_users' or 'fe_users' + * @param int $uid uid of user record that will be updated + * @param string $newPassword Field values as key=>value pairs to be updated in database + */ + protected function updatePasswordHashInDatabase(string $table, int $uid, string $newPassword): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); + $connection->update( + $table, + ['password' => $newPassword], + ['uid' => $uid] + ); + $this->logger->notice('Automatic password update for user record in {table} with uid {uid}', [ + 'table' => $table, + 'uid' => $uid, + ]); + } + + /** + * Writes log message. Destination log depends on the current system mode. + * + * This function accepts variable number of arguments and can format + * parameters. The syntax is the same as for sprintf() + * If a marker ###IP### is present in the message, it is automatically replaced with the REMOTE_ADDR + * + * @param string $message Message to output + * @param array $params + */ + protected function writeLogMessage(string $message, ...$params): void + { + if (!empty($params)) { + $message = vsprintf($message, $params); + } + $message = str_replace('###IP###', (string)($this->authInfo['REMOTE_ADDR'] ?? ''), $message); + if ($this->pObj->loginType === 'FE') { + $timeTracker = GeneralUtility::makeInstance(TimeTracker::class); + $timeTracker->setTSlogMessage($message, LogLevel::INFO); + } + $this->logger->notice($message); + } +} diff --git a/Classes/Authentication/BackendUserAuthentication.php b/Classes/Authentication/BackendUserAuthentication.php new file mode 100644 index 0000000..633a828 --- /dev/null +++ b/Classes/Authentication/BackendUserAuthentication.php @@ -0,0 +1,2173 @@ + '', + 'tables_select' => '', + 'tables_modify' => '', + 'pagetypes_select' => '', + 'non_exclude_fields' => '', + 'explicit_allowdeny' => '', + 'custom_options' => '', + 'file_permissions' => '', + ]; + + /** + * This array holds the uid's of the groups in the listed order + * @var array + */ + public $userGroupsUID = []; + + /** + * User workspace. + * -99 is ERROR (none available) + * 0 is online + * >0 is custom workspaces + */ + public int $workspace = -99; + + /** + * Custom workspace record if any + * @var array + */ + public $workspaceRec = []; + + protected ?UserTsConfig $userTsConfig = null; + + /** + * Cached user settings object + */ + private ?UserSettings $userSettings = null; + + /** + * True if the user TSconfig was parsed and needs to be cached. + * @todo: Should vanish, see todo below. + */ + protected bool $userTSUpdated = false; + + /** + * Cache for checkWorkspaceCurrent() + * @var array|null + */ + protected $checkWorkspaceCurrent_cache; + + /** + * @var \TYPO3\CMS\Core\Resource\ResourceStorage[] + */ + protected $fileStorages; + + /** + * @var array|null + */ + protected $filePermissions; + + /** + * Table in database with user data + * @var string + */ + public $user_table = 'be_users'; + + /** + * Column for login-name + * @var string + */ + public $username_column = 'username'; + + /** + * Column for password + * @var string + */ + public $userident_column = 'password'; + + /** + * Column for user-id + * @var string + */ + public $userid_column = 'uid'; + + /** + * @var string + */ + public $lastLogin_column = 'lastlogin'; + + /** + * Enable field columns of user table + * @var array + */ + public $enablecolumns = [ + 'rootLevel' => 1, + 'deleted' => 'deleted', + 'disabled' => 'disable', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ]; + + /** + * Form field with login-name + * @var string + * @internal + */ + protected $formfield_uname = 'username'; + + /** + * Form field with password + * @var string + * @internal + */ + protected $formfield_uident = 'userident'; + + /** + * Form field with status: *'login', 'logout' + * @var string + * @internal + */ + protected $formfield_status = 'login_status'; + + /** + * Decides if the writelog() function is called at login and logout + * @var bool + */ + public $writeStdLog = true; + + /** + * If the writelog() functions is called if a login-attempt has be tried without success + * @var bool + */ + public $writeAttemptLog = true; + + /** + * @var int + * @internal should only be used from within TYPO3 Core + */ + public $firstMainGroup = 0; + + /** + * User Config Default values: + * The array may contain other fields for configuration. + * For this, see "setup" extension and "TSconfig" document (User TSconfig, "setup.[xxx]....") + * Reserved keys for other storage of session data: + * moduleData + * moduleSessionID + * @var array + * @internal should only be used from within TYPO3 Core + */ + public $uc_default = [ + // serialized content that is used to store interface pane and menu positions. Set by the logout.php-script + 'moduleData' => [], + // user-data for the modules + 'emailMeAtLogin' => 0, + 'titleLen' => 50, + 'edit_docModuleUpload' => '1', + ]; + + /** + * Login type, used for services. + * @var string + */ + public $loginType = 'BE'; + + /** + * Constructor + */ + public function __construct() + { + $this->name = self::getCookieName(); + parent::__construct(); + } + + public function getUserSettings(): UserSettings + { + if ($this->userSettings === null) { + $factory = GeneralUtility::makeInstance(UserSettingsFactory::class); + $this->userSettings = $factory->createFromUserRecord($this->user ?? [], $this->uc); + } + return $this->userSettings; + } + + protected function resetUserSettingsCache(): void + { + $this->userSettings = null; + } + + /** + * Returns TRUE if user is admin + * Basically this function evaluates if the ->user[admin] field has bit 0 set. If so, user is admin. + * + * @return bool + */ + public function isAdmin() + { + return is_array($this->user) && (($this->user['admin'] ?? 0) & 1) == 1; + } + + /** + * Returns TRUE if the current user is a member of group $groupId + * $groupId must be set. $this->userGroupsUID must contain groups + * Will return TRUE also if the user is a member of a group through subgroups. + * + * @internal should only be used from within TYPO3 Core, use Context API for quicker access + */ + protected function isMemberOfGroup(int $groupId): bool + { + if (!empty($this->userGroupsUID) && $groupId) { + return in_array($groupId, $this->userGroupsUID, true); + } + return false; + } + + /** + * Checks if the permissions is granted based on a page-record ($row) and $perms (binary and'ed) + * + * Bits for permissions, see $perms variable: + * + * 1 - Show: See/Copy page and the pagecontent. + * 2 - Edit page: Change/Move the page, eg. change title, startdate, hidden. + * 4 - Delete page: Delete the page and pagecontent. + * 8 - New pages: Create new pages under the page. + * 16 - Edit pagecontent: Change/Add/Delete/Move pagecontent. + * + * @param array $row Is the pagerow for which the permissions is checked + * @param int $perms Is the binary representation of the permission we are going to check. Every bit in this number represents a permission that must be set. See function explanation. + * @param bool $useDeleteClause Use the delete clause to check if a record is deleted + * @return bool + */ + public function doesUserHaveAccess($row, $perms, bool $useDeleteClause = true) + { + $userPerms = $this->calcPerms($row, $useDeleteClause); + return ($userPerms & $perms) == $perms; + } + + /** + * Checks if the page id or page record ($idOrRow) is found within the webmounts set up for the user. + * This should ALWAYS be checked for any page id a user works with, whether it's about reading, writing or whatever. + * The point is that this will add the security that a user can NEVER touch parts outside his mounted + * pages in the page tree. This is otherwise possible if the raw page permissions allows for it. + * So this security check just makes it easier to make safe user configurations. + * If the user is admin then it returns "1" right away + * Otherwise the function will return the uid of the webmount which was first found in the rootline of the input page $id + * + * @param int|array $idOrRow Page ID or full page record to check + * @param string $readPerms Content of "->getPagePermsClause(1)" (read-permissions). If not set, they will be internally calculated (but if you have the correct value right away you can save that database lookup!) + * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE) + * @throws \RuntimeException + * @return int|null The page UID of a page in the rootline that matched a mount point + */ + public function isInWebMount($idOrRow, $readPerms = '', bool $useDeleteClause = true) + { + if ($this->isAdmin()) { + return 1; + } + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages'); + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageFieldName = $languageCapability->getLanguageField()->getName(); + $transOrigPointerFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + $checkRec = []; + $fetchPageFromDatabase = true; + if (is_array($idOrRow)) { + if (!isset($idOrRow['uid'])) { + throw new \RuntimeException('The given page record is invalid. Missing uid.', 1578950324); + } + $checkRec = $idOrRow; + $id = (int)$idOrRow['uid']; + // ensure the required fields are present on the record + if (isset($checkRec['t3ver_oid'], $checkRec[$languageFieldName], $checkRec[$transOrigPointerFieldName])) { + $fetchPageFromDatabase = false; + } + } else { + $id = (int)$idOrRow; + } + if ($fetchPageFromDatabase) { + // Check if input id is an offline version page in which case we will map id to the online version: + $checkRec = BackendUtility::getRecord( + 'pages', + $id, + 't3ver_oid,' . $transOrigPointerFieldName . ',' . $languageFieldName, + '', + $useDeleteClause, + ); + } + if (!is_array($checkRec)) { + return null; + } + if ((int)($checkRec['t3ver_oid'] ?? 0) > 0) { + $id = (int)$checkRec['t3ver_oid']; + } + // if current rec is a translation then get uid from l10n_parent instead + // because web mounts point to pages in default language and rootline returns uids of default languages + if ((int)($checkRec[$languageFieldName]) !== 0 && (int)($checkRec[$transOrigPointerFieldName]) !== 0) { + $id = (int)$checkRec[$transOrigPointerFieldName]; + } + if (!$readPerms) { + $readPerms = $this->getPagePermsClause(Permission::PAGE_SHOW); + } + if ($id > 0) { + $wM = $this->getWebmounts(); + $rL = BackendUtility::BEgetRootLine($id, ' AND ' . $readPerms, true, [], $useDeleteClause); + foreach ($rL as $v) { + if ($v['uid'] && in_array($v['uid'], $wM)) { + return $v['uid']; + } + } + } + return null; + } + + /** + * Checks if the user is in the valid list of allowed system maintainers. if the list is not set, + * then all admins are system maintainers. If the list is empty, no one is system maintainer (good for production + * systems). If the currently logged in user is in "switch user" mode, this method will return false. + * + * @param bool $pure Whether to apply pure behavior (ignore development & skip fallback for empty setting) + */ + public function isSystemMaintainer(bool $pure = false): bool + { + if (!$this->isAdmin()) { + return false; + } + + if (!$pure && $GLOBALS['BE_USER']->getOriginalUserIdWhenInSwitchUserMode()) { + return false; + } + if (!$pure && Environment::getContext()->isDevelopment()) { + return true; + } + $systemMaintainers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []; + $systemMaintainers = array_map(intval(...), $systemMaintainers); + if (!empty($systemMaintainers)) { + return in_array((int)$this->user['uid'], $systemMaintainers, true); + } + // No system maintainers set up yet, so any admin is allowed to access the modules + // but explicitly no system maintainers allowed (empty string in TYPO3_CONF_VARS). + // @todo: this needs to be adjusted once system maintainers can log into the install tool with their credentials + if (!$pure && !isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'])) { + return true; + } + return false; + } + + public function getRole(): PrincipalRole + { + if ($this->isSystemMaintainer()) { + return PrincipalRole::MAINTAINER; + } + if ($this->isAdmin()) { + return PrincipalRole::ADMIN; + } + return PrincipalRole::USER; + } + + /** + * Returns a WHERE-clause for the pages-table where user permissions according to input argument, $perms, is validated. + * $perms is the "mask" used to select. Fx. if $perms is 1 then you'll get all pages that a user can actually see! + * 2^0 = show (1) + * 2^1 = edit (2) + * 2^2 = delete (4) + * 2^3 = new (8) + * If the user is 'admin' " 1=1" is returned (no effect) + * If the user is not set at all (->user is not an array), then " 1=0" is returned (will cause no selection results at all) + * The 95% use of this function is "->getPagePermsClause(1)" which will + * return WHERE clauses for *selecting* pages in backend listings - in other words this will check read permissions. + * + * @param int $perms Permission mask to use, see function description + * @return string Part of where clause. Prefix " AND " to this. + * @internal should only be used from within TYPO3 Core, use PagePermissionDatabaseRestriction instead. + */ + public function getPagePermsClause($perms) + { + if (is_array($this->user)) { + if ($this->isAdmin()) { + return ' 1=1'; + } + // Make sure it's integer. + $perms = (int)$perms; + $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages') + ->expr(); + + // User + $constraint = $expressionBuilder->or( + $expressionBuilder->comparison( + $expressionBuilder->bitAnd('pages.perms_everybody', $perms), + ExpressionBuilder::EQ, + $perms + ), + $expressionBuilder->and( + $expressionBuilder->eq('pages.perms_userid', (int)$this->user['uid']), + $expressionBuilder->comparison( + $expressionBuilder->bitAnd('pages.perms_user', $perms), + ExpressionBuilder::EQ, + $perms + ) + ) + ); + + // Group (if any is set) + if (!empty($this->userGroupsUID)) { + $constraint = $constraint->with( + $expressionBuilder->and( + $expressionBuilder->in( + 'pages.perms_groupid', + $this->userGroupsUID + ), + $expressionBuilder->comparison( + $expressionBuilder->bitAnd('pages.perms_group', $perms), + ExpressionBuilder::EQ, + $perms + ) + ) + ); + } + + $constraint = ' (' . (string)$constraint . ')'; + + // **************** + // getPagePermsClause-HOOK + // **************** + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['getPagePermsClause'] ?? [] as $_funcRef) { + $_params = ['currentClause' => $constraint, 'perms' => $perms]; + $constraint = GeneralUtility::callUserFunction($_funcRef, $_params, $this); + } + return $constraint; + } + return ' 1=0'; + } + + /** + * Returns a combined binary representation of the current users permissions for the page-record, $row. + * The perms for user, group and everybody is OR'ed together (provided that the page-owner is the user + * and for the groups that the user is a member of the group. + * If the user is admin, 31 is returned (full permissions for all five flags) + * + * @param array $row Input page row with all perms_* fields available. + * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE) + * @return int Bitwise representation of the users permissions in relation to input page row, $row + */ + public function calcPerms($row, bool $useDeleteClause = true) + { + // Return 31 for admin users. + if ($this->isAdmin()) { + return Permission::ALL; + } + // Return 0 if page is not within the allowed web mount + if (!$this->isInWebMount($row, '', $useDeleteClause)) { + return Permission::NOTHING; + } + $out = Permission::NOTHING; + if ( + isset($row['perms_userid']) && isset($row['perms_user']) && isset($row['perms_groupid']) + && isset($row['perms_group']) && isset($row['perms_everybody']) && !empty($this->userGroupsUID) + ) { + if ($this->user['uid'] == $row['perms_userid']) { + $out |= $row['perms_user']; + } + if ($this->isMemberOfGroup((int)$row['perms_groupid'])) { + $out |= $row['perms_group']; + } + $out |= $row['perms_everybody']; + } + // **************** + // CALCPERMS hook + // **************** + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['calcPerms'] ?? [] as $_funcRef) { + $_params = [ + 'row' => $row, + 'outputPermissions' => $out, + ]; + $out = GeneralUtility::callUserFunction($_funcRef, $_params, $this); + } + return $out; + } + + /** + * Returns TRUE if the $value is found in the list in a $this->groupData[] index pointed to by $type (array key). + * Can thus be users to check for modules, exclude-fields, select/modify permissions for tables etc. + * If user is admin TRUE is also returned + * + * @param string $type The type value; "webmounts", "filemounts", "pagetypes_select", "tables_select", "tables_modify", "non_exclude_fields", "modules", "available_widgets", "mfa_providers" + * @param string|int $value String to search for in the groupData-list, can also be an integer for "pagetypes_select" or "allowed_languages") + * @return bool TRUE if permission is granted (that is, the value was found in the groupData list - or the BE_USER is "admin") + */ + public function check($type, $value) + { + return isset($this->groupData[$type]) + && ($this->isAdmin() || GeneralUtility::inList($this->groupData[$type], (string)$value)); + } + + /** + * Checking the authMode of a select field with authMode set + * + * @param string $table Table name + * @param string $field Field name (must be configured in TCA and of type "select" with authMode set!) + * @param string $value Value to evaluation (single value, must not contain any of the chars ":,|") + * @return bool Whether access is granted or not + */ + public function checkAuthMode($table, $field, $value) + { + // Admin users can do anything: + if ($this->isAdmin()) { + return true; + } + // Allow all blank values: + if ((string)$value === '') { + return true; + } + // Allow dividers: + if ($value === '--div--') { + return true; + } + // Certain characters are not allowed in the value + if (preg_match('/[:|,]/', $value)) { + return false; + } + // Initialize: + $testValue = $table . ':' . $field . ':' . $value; + $out = true; + if (!GeneralUtility::inList($this->groupData['explicit_allowdeny'], $testValue)) { + $out = false; + } + return $out; + } + + /** + * Checking if a language value (-1, 0 and >0) is allowed to be edited by the user. + * + * @param int|SiteLanguage|string $langValue Language value to evaluate + * @return bool Returns TRUE if the language value is allowed, otherwise FALSE. + */ + public function checkLanguageAccess($langValue) + { + // The users language list must be non-blank - otherwise all languages are allowed. + if (trim($this->groupData['allowed_languages']) === '') { + return true; + } + if ($langValue instanceof SiteLanguage) { + $langValue = $langValue->getLanguageId(); + } else { + $langValue = (int)$langValue; + } + // Language must either be explicitly allowed OR the lang Value be "-1" (all languages) + if ($langValue !== -1 && !$this->check('allowed_languages', (string)$langValue)) { + return false; + } + return true; + } + + /** + * Check if user has access to all existing localizations for a certain record + * + * @param string|TcaSchema $table The table/schema + * @param array $record The current record + * @return bool + */ + public function checkFullLanguagesAccess(string|TcaSchema $table, array $record): bool + { + if (!$this->checkLanguageAccess(0)) { + return false; + } + if ($table instanceof TcaSchema) { + $schema = $table; + $table = $table->getName(); + } else { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($table); + } + + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $pointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + $pointerValue = $record[$pointerField] > 0 ? $record[$pointerField] : $record['uid']; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->workspace)); + $recordLocalizations = $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + $pointerField, + $queryBuilder->createNamedParameter($pointerValue, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + + foreach ($recordLocalizations as $recordLocalization) { + if (!$this->checkLanguageAccess($recordLocalization[$languageField])) { + return false; + } + } + } + return true; + } + + /** + * Check if a user has editing access to a record from a $GLOBALS['TCA'] table. + * Returns a result object with access status and error message. + * + * The checks do not take page permissions and other "environmental" things into account. + * It only deals with record internals; If any values in the record fields disallows it. + * For instance languages settings, authMode selector boxes are evaluated (and maybe more in the future). + * It will check for workspace-dependent access. + * + * @param string $table Table name + * @param array|RecordInterface $row Full record row + * @param bool $newRecord Set, if testing a new (non-existing) record array. Will disable certain checks that doesn't make much sense in that context. + * @param bool $checkFullLanguageAccess Set, whenever access to all translations of the record is required + * @return AccessCheckResult Result object with access decision and error message + * @internal should only be used from within TYPO3 Core + */ + public function checkRecordEditAccess( + string $table, + array|RecordInterface $row, + bool $newRecord = false, + bool $checkFullLanguageAccess = false + ): AccessCheckResult { + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + if (!$schemaFactory->has($table)) { + return new AccessCheckResult(false); + } + if ($row instanceof RecordInterface) { + $row = $row->getRawRecord()->toArray(); + } + $schema = $schemaFactory->get($table); + // Always return TRUE for Admin users. + if ($this->isAdmin()) { + return new AccessCheckResult(true); + } + // Checking languages: + if ($table === 'pages' && $checkFullLanguageAccess && !$this->checkFullLanguagesAccess($schema, $row)) { + return new AccessCheckResult(false); + } + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + + // Language field must be found in input row - otherwise it does not make sense. + if (isset($row[$languageField])) { + if (!$this->checkLanguageAccess($row[$languageField])) { + return new AccessCheckResult(false, 'ERROR: Language was not allowed.'); + } + if ( + $checkFullLanguageAccess && $row[$languageField] == 0 + && !$this->checkFullLanguagesAccess($table, $row) + ) { + return new AccessCheckResult(false, 'ERROR: Related/affected language was not allowed.'); + } + } else { + return new AccessCheckResult(false, 'ERROR: The "languageField" field named "' . $languageField . '" was not found in testing record!'); + } + } + // Checking authMode fields: + foreach ($schema->getFields() as $fieldName => $fieldType) { + if (isset($row[$fieldName]) + && $fieldType->isType(TableColumnType::SELECT) + && ($fieldType->getConfiguration()['authMode'] ?? false) + && !$this->checkAuthMode($table, $fieldName, $row[$fieldName])) { + return new AccessCheckResult( + false, + 'ERROR: authMode "' . $fieldType->getConfiguration()['authMode'] + . '" failed for field "' . $fieldName . '" with value "' + . $row[$fieldName] . '" evaluated' + ); + } + } + // Checking "editlock" feature (doesn't apply to new records) + if (!$newRecord && $schema->hasCapability(TcaSchemaCapability::EditLock)) { + $editLockFieldName = $schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName(); + if (isset($row[$editLockFieldName])) { + if ($row[$editLockFieldName]) { + return new AccessCheckResult(false, 'ERROR: Record was locked for editing. Only admin users can change this state.'); + } + } else { + return new AccessCheckResult(false, 'ERROR: The "editLock" field named "' . $editLockFieldName + . '" was not found in testing record!'); + } + } + // Checking record permissions + // THIS is where we can include a check for "perms_" fields for other records than pages... + // Process any hooks + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['recordEditAccessInternals'] ?? [] as $funcRef) { + $params = [ + 'table' => $table, + 'idOrRow' => $row, + 'newRecord' => $newRecord, + ]; + if (!GeneralUtility::callUserFunction($funcRef, $params, $this)) { + return new AccessCheckResult(false); + } + } + // Finally, return TRUE if all is well. + return new AccessCheckResult(true); + } + + /** + * Returns TRUE if the BE_USER is allowed to *create* shortcuts in the backend modules + * + * @return bool + */ + public function mayMakeShortcut() + { + return ($this->getTSConfig()['options.']['enableBookmarks'] ?? false) + && !($this->getTSConfig()['options.']['mayNotCreateEditBookmarks'] ?? false); + } + + /** + * Checks if a record is allowed to be edited in the current workspace. + * This is not bound to an actual record, but to the mere fact if the user is in a workspace + * and depending on the table settings. + * + * @internal should only be used from within TYPO3 Core + */ + public function workspaceAllowsLiveEditingInTable(string $table): bool + { + // In live workspace the record can be added/modified + if ($this->workspace === 0) { + return true; + } + // Workspace setting allows to "live edit" records of tables without versioning + if (($this->workspaceRec['live_edit'] ?? false) + && !$this->getTcaSchema($table)?->isWorkspaceAware() + ) { + return true; + } + // Always for Live workspace, AND if live-edit is enabled + // and tables are completely without versioning it is ok as well. + if ($this->getTcaSchema($table)?->getRawConfiguration()['versioningWS_alwaysAllowLiveEdit'] ?? false) { + return true; + } + // If the answer is FALSE it means the only valid way to create or edit records by creating records in the workspace + return false; + } + + /** + * Evaluates if a record from $table can be created. If the table is not set up for versioning, + * and the "live edit" flag of the page is set, return false. In live workspace this is always true, + * as all records can be created in live workspace + * + * @param string $table Table name + * @internal should only be used from within TYPO3 Core + */ + public function workspaceCanCreateNewRecord(string $table): bool + { + // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record + if (!$this->workspaceAllowsLiveEditingInTable($table) && !$this->getTcaSchema($table)?->isWorkspaceAware()) { + return false; + } + return true; + } + + /** + * Checks if an element stage allows access for the user in the current workspace + * In live workspace (= 0) access is always granted for any stage. + * Admins are always allowed. + * An option for custom workspaces allows members to also edit when the stage is "Review" + * + * @param int $stage Stage id from an element: -1,0 = editing, 1 = reviewer, >1 = owner + * @return bool TRUE if user is allowed access + * @internal should only be used from within TYPO3 Core + */ + public function workspaceCheckStageForCurrent($stage): bool + { + // Always allow for admins + if ($this->isAdmin()) { + return true; + } + // Always OK for live workspace + if ($this->workspace === 0 || $this->getTcaSchema('sys_workspace') === null) { + return true; + } + $stage = (int)$stage; + $stat = $this->checkWorkspaceCurrent(); + $accessType = $stat['_ACCESS']; + // Workspace owners are always allowed for stage change + if ($accessType === 'owner') { + return true; + } + + // Check if custom staging is activated + $workspaceRec = BackendUtility::getRecord('sys_workspace', $stat['uid']); + if ($workspaceRec['custom_stages'] > 0 && $stage !== 0 && $stage !== -10) { + // Get custom stage record + $workspaceStageRec = BackendUtility::getRecord('sys_workspace_stage', $stage); + // Check if the user is responsible for the current stage + if ( + $accessType === 'member' + && GeneralUtility::inList($workspaceStageRec['responsible_persons'] ?? '', 'be_users_' . $this->user['uid']) + ) { + return true; + } + // Check if the user is in a group which is responsible for the current stage + foreach ($this->userGroupsUID as $groupUid) { + if ( + $accessType === 'member' + && GeneralUtility::inList($workspaceStageRec['responsible_persons'] ?? '', 'be_groups_' . $groupUid) + ) { + return true; + } + } + } elseif ($stage === -10 || $stage === -20) { + // Nobody is allowed to do that except the owner (which was checked above) + return false; + } elseif ( + $accessType === 'reviewer' && $stage <= 1 + || $accessType === 'member' && $stage <= 0 + ) { + return true; + } + return false; + } + + /** + * Returns full parsed user TSconfig array, merged with TSconfig from groups. + * + * Example: + * [ + * 'options.' => [ + * 'fooEnabled' => '0', + * 'fooEnabled.' => [ + * 'tt_content' => 1, + * ], + * ], + * ] + * + * @return array Parsed and merged user TSconfig array + */ + public function getTSConfig(): array + { + return $this->getUserTsConfig()?->getUserTsConfigArray() ?? []; + } + + /** + * Return the full user TSconfig object instead of just the array as in getTSConfig() + * + * @internal for now until API stabilized + */ + public function getUserTsConfig(): ?UserTsConfig + { + return $this->userTsConfig; + } + + /** + * Returns an unique array with the webmounts. + * If no webmounts, and empty array is returned. + * Webmounts permissions are checked in fetchGroupData() + * + * @return list of web mounts uids (may include 0) + */ + public function getWebmounts(): array + { + $webMounts = $this->groupData['webmounts'] ?? null; + return is_string($webMounts) && $webMounts !== '' + ? array_unique(GeneralUtility::intExplode(',', $webMounts)) + : []; + } + + /** + * Initializes the given mount points for the current Backend user. + * + * @param array $mountPointUids Page UIDs that should be used as web mountpoints + * @param bool $append If TRUE the given mount point will be appended. Otherwise the current mount points will be replaced. + */ + public function setWebmounts(array $mountPointUids, $append = false) + { + if (empty($mountPointUids)) { + return; + } + if ($append) { + $currentWebMounts = GeneralUtility::intExplode(',', (string)($this->groupData['webmounts'] ?? '')); + $mountPointUids = array_merge($currentWebMounts, $mountPointUids); + } + $this->groupData['webmounts'] = implode(',', array_unique($mountPointUids)); + } + + /** + * Checks for alternative web mount points for the element browser. + * + * If there is a temporary mount point active in the page tree it will be used. + * + * If the user TSconfig options.pageTree.altElementBrowserMountPoints is not empty the pages configured + * there are used as web mounts If options.pageTree.altElementBrowserMountPoints.append is enabled, + * they are appended to the existing webmounts. + * + * @internal - do not use in your own extension + */ + public function initializeWebmountsForElementBrowser() + { + $alternativeWebmountPoint = (int)$this->getSessionData('pageTree_temporaryMountPoint'); + if ($alternativeWebmountPoint) { + $alternativeWebmountPoint = GeneralUtility::intExplode(',', (string)$alternativeWebmountPoint); + $this->setWebmounts($alternativeWebmountPoint); + return; + } + + $alternativeWebmountPoints = trim($this->getTSConfig()['options.']['pageTree.']['altElementBrowserMountPoints'] ?? ''); + $appendAlternativeWebmountPoints = $this->getTSConfig()['options.']['pageTree.']['altElementBrowserMountPoints.']['append'] ?? ''; + if ($alternativeWebmountPoints) { + $alternativeWebmountPoints = GeneralUtility::intExplode(',', $alternativeWebmountPoints); + $this->setWebmounts($alternativeWebmountPoints, $appendAlternativeWebmountPoints); + } + } + + /** + * Returns TRUE or FALSE, depending if an alert popup (a javascript confirmation) should be shown + * call like $GLOBALS['BE_USER']->jsConfirmation($BITMASK). + * + * @param int $bitmask Bitmask, one of \TYPO3\CMS\Core\Authentication\JsConfirmation + * @return bool TRUE if the confirmation should be shown + * @see JsConfirmation + */ + public function jsConfirmation(int $bitmask): bool + { + $alertPopupsSetting = trim((string)($this->getTSConfig()['options.']['alertPopups'] ?? '')); + $alertPopupsSetting = MathUtility::canBeInterpretedAsInteger($alertPopupsSetting) + ? MathUtility::forceIntegerInRange((int)$alertPopupsSetting, 0, JsConfirmation::ALL) + : JsConfirmation::ALL; + + return (new JsConfirmation($alertPopupsSetting))->get($bitmask); + } + + /** + * Initializes a lot of stuff like the access-lists, database-mountpoints and filemountpoints + * This method is called by ->backendCheckLogin() (from extending BackendUserAuthentication) + * if the backend user login has verified OK. + * Generally this is required initialization of a backend user. + * + * @internal + */ + public function fetchGroupData() + { + if ($this->user['uid']) { + // Get lists for the be_user record and set them as default/primary values. + // Enabled Backend Modules + $this->groupData['modules'] = $this->user['userMods'] ?? ''; + // Add available widgets + $this->groupData['available_widgets'] = $this->user['available_widgets'] ?? ''; + // Add allowed mfa providers + $this->groupData['mfa_providers'] = $this->user['mfa_providers'] ?? ''; + // Add Allowed Languages + $this->groupData['allowed_languages'] = $this->user['allowed_languages'] ?? ''; + // Set user value for workspace permissions. + $this->groupData['workspace_perms'] = $this->user['workspace_perms'] ?? 0; + // Database mountpoints + $this->groupData['webmounts'] = $this->user['db_mountpoints'] ?? ''; + // File mountpoints + $this->groupData['filemounts'] = $this->user['file_mountpoints'] ?? ''; + // Fileoperation permissions + $this->groupData['file_permissions'] = $this->user['file_permissions'] ?? ''; + // Category mounts + $this->groupData['category_perms'] = $this->user['category_perms'] ?? ''; + + // Get the groups and accumulate their permission settings + $mountOptions = new BackendGroupMountOption((int)($this->user['options'] ?? 0)); + $groupResolver = GeneralUtility::makeInstance(GroupResolver::class); + $resolvedGroups = $groupResolver->resolveGroupsForUser($this->user, $this->usergroup_table); + foreach ($resolvedGroups as $groupInfo) { + $groupInfo += [ + 'uid' => 0, + 'db_mountpoints' => '', + 'file_mountpoints' => '', + 'groupMods' => '', + 'availableWidgets' => '', + 'mfa_providers' => '', + 'tables_select' => '', + 'tables_modify' => '', + 'pagetypes_select' => '', + 'non_exclude_fields' => '', + 'explicit_allowdeny' => '', + 'allowed_languages' => '', + 'custom_options' => '', + 'file_permissions' => '', + 'category_perms' => '', + 'workspace_perms' => 0, // Bitflag. + ]; + // Add the group uid to internal arrays. + $this->userGroupsUID[] = (int)$groupInfo['uid']; + $this->userGroups[(int)$groupInfo['uid']] = $groupInfo; + // Mount group database-mounts + if ($mountOptions->shouldUserIncludePageMountsFromAssociatedGroups()) { + $this->groupData['webmounts'] .= ',' . $groupInfo['db_mountpoints']; + } + // Mount group file-mounts + if ($mountOptions->shouldUserIncludeFileMountsFromAssociatedGroups()) { + $this->groupData['filemounts'] .= ',' . $groupInfo['file_mountpoints']; + } + // Gather permission detail fields + $this->groupData['modules'] .= ',' . $groupInfo['groupMods']; + $this->groupData['available_widgets'] .= ',' . $groupInfo['availableWidgets']; + $this->groupData['mfa_providers'] .= ',' . $groupInfo['mfa_providers']; + $this->groupData['tables_select'] .= ',' . $groupInfo['tables_select']; + $this->groupData['tables_modify'] .= ',' . $groupInfo['tables_modify']; + $this->groupData['pagetypes_select'] .= ',' . $groupInfo['pagetypes_select']; + $this->groupData['non_exclude_fields'] .= ',' . $groupInfo['non_exclude_fields']; + $this->groupData['explicit_allowdeny'] .= ',' . $groupInfo['explicit_allowdeny']; + $this->groupData['allowed_languages'] .= ',' . $groupInfo['allowed_languages']; + $this->groupData['custom_options'] .= ',' . $groupInfo['custom_options']; + $this->groupData['file_permissions'] .= ',' . $groupInfo['file_permissions']; + $this->groupData['category_perms'] .= ',' . $groupInfo['category_perms']; + // Setting workspace permissions: + $this->groupData['workspace_perms'] |= $groupInfo['workspace_perms']; + if (!$this->firstMainGroup) { + $this->firstMainGroup = (int)$groupInfo['uid']; + } + } + + // Populating the $this->userGroupsUID -array with the groups in the order in which they were LAST included. + // Finally, this is the list of group_uid's in the order they are parsed (including subgroups) + // and without duplicates (duplicates are presented with their last entrance in the list, + // which thus reflects the order of the TypoScript in TSconfig) + $this->userGroupsUID = array_reverse(array_unique(array_reverse($this->userGroupsUID))); + + $this->prepareUserTsConfig(); + + // Processing webmounts + // Admin's always have the root mounted + if ($this->isAdmin() && !($this->getTSConfig()['options.']['dontMountAdminMounts'] ?? false)) { + $this->groupData['webmounts'] = '0,' . $this->groupData['webmounts']; + } + // The lists are cleaned for duplicates + $this->groupData['webmounts'] = StringUtility::uniqueList($this->groupData['webmounts'] ?? ''); + $this->groupData['filemounts'] = StringUtility::uniqueList($this->groupData['filemounts'] ?? ''); + $this->groupData['pagetypes_select'] = StringUtility::uniqueList($this->groupData['pagetypes_select'] ?? ''); + $this->groupData['tables_select'] = StringUtility::uniqueList(($this->groupData['tables_modify'] ?? '') . ',' . ($this->groupData['tables_select'] ?? '')); + $this->groupData['tables_modify'] = StringUtility::uniqueList($this->groupData['tables_modify'] ?? ''); + $this->groupData['non_exclude_fields'] = StringUtility::uniqueList($this->groupData['non_exclude_fields'] ?? ''); + $this->groupData['explicit_allowdeny'] = StringUtility::uniqueList($this->groupData['explicit_allowdeny'] ?? ''); + $this->groupData['allowed_languages'] = StringUtility::uniqueList($this->groupData['allowed_languages'] ?? ''); + $this->groupData['custom_options'] = StringUtility::uniqueList($this->groupData['custom_options'] ?? ''); + $this->groupData['modules'] = StringUtility::uniqueList($this->groupData['modules'] ?? ''); + $this->groupData['available_widgets'] = StringUtility::uniqueList($this->groupData['available_widgets'] ?? ''); + $this->groupData['mfa_providers'] = StringUtility::uniqueList($this->groupData['mfa_providers'] ?? ''); + $this->groupData['file_permissions'] = StringUtility::uniqueList($this->groupData['file_permissions'] ?? ''); + $this->groupData['category_perms'] = StringUtility::uniqueList($this->groupData['category_perms'] ?? ''); + + // Check if the user access to all web mounts set + if (!empty(trim($this->groupData['webmounts']))) { + $validWebMounts = $this->filterValidWebMounts($this->groupData['webmounts']); + $this->groupData['webmounts'] = implode(',', $validWebMounts); + } + // Setting up workspace situation (after webmounts are processed!): + $this->workspaceInit(); + } + } + + /** + * Checking read access to web mounts, but keeps "0" or empty strings. + * In any case, checks if the list of pages is visible for the backend user but also + * if the page is not deleted. + * + * @param string $listOfWebMounts a comma-separated list of webmounts, could also be empty, or contain "0" + * @return array a list of all valid web mounts the user has access to + */ + protected function filterValidWebMounts(string $listOfWebMounts): array + { + // Checking read access to web mounts if there are mounts points (not empty string, false or 0) + $allWebMounts = explode(',', $listOfWebMounts); + // Selecting all web mounts with permission clause for reading + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $readablePagesOfWebMounts = $queryBuilder->select('uid') + ->from('pages') + // @todo DOCTRINE: check how to make getPagePermsClause() portable + ->where( + $this->getPagePermsClause(Permission::PAGE_SHOW), + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter( + GeneralUtility::intExplode(',', $listOfWebMounts), + Connection::PARAM_INT_ARRAY + ) + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + $readablePagesOfWebMounts = array_column(($readablePagesOfWebMounts ?: []), 'uid', 'uid'); + foreach ($allWebMounts as $key => $mountPointUid) { + // If the mount ID is NOT found among selected pages, unset it: + if ($mountPointUid > 0 && !isset($readablePagesOfWebMounts[$mountPointUid])) { + unset($allWebMounts[$key]); + } + } + return $allWebMounts; + } + + /** + * Parse user TSconfig from current user and its groups and set it as $this->userTS. + */ + protected function prepareUserTsConfig(): void + { + $tsConfigFactory = GeneralUtility::makeInstance(UserTsConfigFactory::class); + $this->userTsConfig = $tsConfigFactory->create($this); + if (!empty($this->getUserTsConfig()->getUserTsConfigArray()['setup.']['override.'])) { + // @todo: This logic is ugly. user TSconfig "setup.override." is used to force options + // in the user settings module, along with "setup.fields." and "setup.default.". + // See the docs about this. + // The fun part is, this is merged into user UC. As such, whenever these setup + // options are used, user UC has to be updated. The toggle below triggers this + // and initiates an update query of this users UC. + // Before v12, this was only triggered if user TSconfig could not be fetched from + // cache, but this was flawed, too: When two users had the same user TSconfig, UC + // of one user would be updated, but not UC of the other user if caches were not + // cleared in between their two calls. + // This toggle and UC overriding should vanish altogether: It would be better if + // user TSconfig no longer overlays UC, instead the settings / setup module + // controller should look at user TSconfig "setup." on the fly when rendering, and + // consumers that access user setup / settings values should get values overloaded + // on the fly as well using some helper or a late init logic or similar. + $this->userTSUpdated = true; + } + } + + /** + * Sets up all file storages for a user. + * Needs to be called AFTER the groups have been loaded. + */ + protected function initializeFileStorages() + { + $this->fileStorages = []; + $storageRepository = GeneralUtility::makeInstance(StorageRepository::class); + // Admin users have all file storages visible, without any filters + if ($this->isAdmin()) { + $storageObjects = $storageRepository->findAll(); + foreach ($storageObjects as $storageObject) { + $this->fileStorages[$storageObject->getUid()] = $storageObject; + } + } else { + // Regular users only have storages that are defined in their file mounts + // Permissions and file mounts for the storage are added in StoragePermissionAspect + foreach ($this->getFileMountRecords() as $row) { + if (!str_contains($row['identifier'] ?? '', ':')) { + // Skip record since the file mount identifier is invalid, this usually happens + // when file storages are selected. file mounts and groupHomePath and userHomePath should go through + continue; + } + [$base] = GeneralUtility::trimExplode(':', $row['identifier'], true); + $base = (int)$base; + if (!array_key_exists($base, $this->fileStorages)) { + $storageObject = $storageRepository->findByUid($base); + if ($storageObject) { + $this->fileStorages[$storageObject->getUid()] = $storageObject; + } + } + } + } + + // This has to be called always in order to set certain filters + $this->evaluateUserSpecificFileFilterSettings(); + } + + /** + * Returns an array of category mount points. The category permissions from BE Groups + * are also taken into consideration and are merged into User permissions. + * + * @return array + */ + public function getCategoryMountPoints() + { + $categoryMountPoints = ''; + + // Category mounts of the groups + foreach ($this->userGroups as $group) { + if ($group['category_perms']) { + $categoryMountPoints .= ',' . $group['category_perms']; + } + } + + // Category mounts of the user record + if ($this->user['category_perms']) { + $categoryMountPoints .= ',' . $this->user['category_perms']; + } + + // Make the ids unique + $categoryMountPoints = GeneralUtility::trimExplode(',', $categoryMountPoints); + $categoryMountPoints = array_filter($categoryMountPoints); // remove empty value + $categoryMountPoints = array_unique($categoryMountPoints); // remove unique value + + return $categoryMountPoints; + } + + /** + * Returns an array of file mount records, taking workspaces and user home and group home directories into account + * Needs to be called AFTER the groups have been loaded. + * + * @return array + * @internal + */ + public function getFileMountRecords() + { + $runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'); + $fileMountRecordCache = $runtimeCache->get('backendUserAuthenticationFileMountRecords') ?: []; + + if (!empty($fileMountRecordCache)) { + return $fileMountRecordCache; + } + + $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); + + // Processing file mounts (both from the user and the groups) + $fileMounts = array_unique(GeneralUtility::intExplode(',', (string)($this->groupData['filemounts'] ?? ''), true)); + + // Limit file mounts if set in workspace record + if ($this->workspace > 0 && !empty($this->workspaceRec['file_mountpoints'])) { + $workspaceFileMounts = GeneralUtility::intExplode(',', (string)$this->workspaceRec['file_mountpoints'], true); + $fileMounts = array_intersect($fileMounts, $workspaceFileMounts); + } + + if ($fileMounts !== []) { + $schema = $this->getTcaSchema('sys_filemounts'); + $orderBy = $schema->hasCapability(TcaSchemaCapability::DefaultSorting) + ? $schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue() + : 'sorting'; + + $queryBuilder = $connectionPool->getQueryBuilderForTable('sys_filemounts'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(HiddenRestriction::class)) + ->add(GeneralUtility::makeInstance(RootLevelRestriction::class)); + + $queryBuilder->select('*') + ->from('sys_filemounts') + ->where( + $queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($fileMounts, Connection::PARAM_INT_ARRAY)) + ); + + foreach (QueryHelper::parseOrderBy($orderBy) as $fieldAndDirection) { + $queryBuilder->addOrderBy(...$fieldAndDirection); + } + + $fileMountRecords = $queryBuilder->executeQuery()->fetchAllAssociative(); + foreach ($fileMountRecords as $fileMount) { + $readOnlySuffix = $fileMount['read_only'] ? '-readonly' : ''; + $fileMountRecordCache[$fileMount['identifier'] . $readOnlySuffix] = $fileMount; + } + } + + // Read-only file mounts + $readOnlyMountPoints = trim($this->getTSConfig()['options.']['folderTree.']['altElementBrowserMountPoints'] ?? ''); + if ($readOnlyMountPoints) { + // We cannot use the API here but need to fetch the default storage record directly + // to not instantiate it (which directly applies mount points) before all mount points are resolved! + $queryBuilder = $connectionPool->getQueryBuilderForTable('sys_file_storage'); + $defaultStorageRow = $queryBuilder->select('uid') + ->from('sys_file_storage') + ->where( + $queryBuilder->expr()->eq('is_default', $queryBuilder->createNamedParameter(1, Connection::PARAM_INT)) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + $readOnlyMountPointArray = GeneralUtility::trimExplode(',', $readOnlyMountPoints); + foreach ($readOnlyMountPointArray as $readOnlyMountPoint) { + $readOnlyMountPointConfiguration = GeneralUtility::trimExplode(':', $readOnlyMountPoint); + if (count($readOnlyMountPointConfiguration) === 2) { + // A storage is passed in the configuration + $storageUid = (int)$readOnlyMountPointConfiguration[0]; + $path = $readOnlyMountPointConfiguration[1]; + } else { + if (empty($defaultStorageRow)) { + throw new \RuntimeException('Read only mount points have been defined in user TSconfig without specific storage, but a default storage could not be resolved.', 1404472382); + } + // Backwards compatibility: If no storage is passed, we use the default storage + $storageUid = $defaultStorageRow['uid']; + $path = $readOnlyMountPointConfiguration[0]; + } + $fileMountRecordCache[$storageUid . $path . '-readonly'] = [ + 'base' => $storageUid, + 'identifier' => $storageUid . ':' . $path, + 'title' => $path, + 'path' => $path, + 'read_only' => true, + ]; + } + } + + // Personal or Group file mounts are not accessible if file mount list is set in workspace record + if ($this->workspace <= 0 || empty($this->workspaceRec['file_mountpoints'])) { + // If userHomePath is set, we attempt to mount it + if ($GLOBALS['TYPO3_CONF_VARS']['BE']['userHomePath'] ?? false) { + [$userHomeStorageUid, $userHomeFilter] = explode(':', $GLOBALS['TYPO3_CONF_VARS']['BE']['userHomePath'], 2); + $userHomeStorageUid = (int)$userHomeStorageUid; + $userHomeFilter = '/' . ltrim($userHomeFilter, '/'); + if ($userHomeStorageUid > 0) { + // Try and mount with [uid]_[username] + $path = $userHomeFilter . $this->user['uid'] . '_' . $this->user['username'] . $GLOBALS['TYPO3_CONF_VARS']['BE']['userUploadDir']; + $fileMountRecordCache[$userHomeStorageUid . $path] = [ + 'base' => $userHomeStorageUid, + 'identifier' => $userHomeStorageUid . ':' . $path, + 'title' => $this->user['username'], + 'path' => $path, + 'read_only' => false, + 'user_mount' => true, + ]; + // Try and mount with only [uid] + $path = $userHomeFilter . $this->user['uid'] . $GLOBALS['TYPO3_CONF_VARS']['BE']['userUploadDir']; + $fileMountRecordCache[$userHomeStorageUid . $path] = [ + 'base' => $userHomeStorageUid, + 'identifier' => $userHomeStorageUid . ':' . $path, + 'title' => $this->user['username'], + 'path' => $path, + 'read_only' => false, + 'user_mount' => true, + ]; + } + } + + // Mount group home-dirs + $mountOptions = new BackendGroupMountOption((int)($this->user['options'] ?? 0)); + if (($GLOBALS['TYPO3_CONF_VARS']['BE']['groupHomePath'] ?? '') !== '' && $mountOptions->shouldUserIncludeFileMountsFromAssociatedGroups()) { + // If groupHomePath is set, we attempt to mount it + [$groupHomeStorageUid, $groupHomeFilter] = explode(':', $GLOBALS['TYPO3_CONF_VARS']['BE']['groupHomePath'], 2); + $groupHomeStorageUid = (int)$groupHomeStorageUid; + $groupHomeFilter = '/' . ltrim($groupHomeFilter, '/'); + if ($groupHomeStorageUid > 0) { + foreach ($this->userGroups as $groupData) { + $path = $groupHomeFilter . $groupData['uid']; + $fileMountRecordCache[$groupHomeStorageUid . $path] = [ + 'base' => $groupHomeStorageUid, + 'identifier' => $groupHomeStorageUid . ':' . $path, + 'title' => $groupData['title'], + 'path' => $path, + 'read_only' => false, + 'user_mount' => true, + ]; + } + } + } + } + + $runtimeCache->set('backendUserAuthenticationFileMountRecords', $fileMountRecordCache); + return $fileMountRecordCache; + } + + /** + * Returns an array with the file mounts for the user. + * Each file mount is represented with an array of a "name", "path" and "type". + * If no file mounts an empty array is returned. + * + * @return \TYPO3\CMS\Core\Resource\ResourceStorage[] + */ + public function getFileStorages() + { + // Initializing file mounts after the groups are fetched + if ($this->fileStorages === null) { + $this->initializeFileStorages(); + } + return $this->fileStorages; + } + + /** + * Adds filters based on what the user has set + * this should be done in this place, and called whenever needed, + * but only when needed. + */ + public function evaluateUserSpecificFileFilterSettings() + { + // Add the option for also displaying the non-hidden files + if ($this->uc['showHiddenFilesAndFolders'] ?? false) { + FileNameFilter::setShowHiddenFilesAndFolders(true); + } + } + + /** + * Returns the information about file permissions. + * Previously, this was stored in the DB field fileoper_perms now it is file_permissions. + * Besides it can be handled via user TSconfig + * + * permissions.file.default { + * addFile = 1 + * readFile = 1 + * writeFile = 1 + * copyFile = 1 + * moveFile = 1 + * renameFile = 1 + * deleteFile = 1 + * + * addFolder = 1 + * readFolder = 1 + * writeFolder = 1 + * copyFolder = 1 + * moveFolder = 1 + * renameFolder = 1 + * deleteFolder = 1 + * recursivedeleteFolder = 1 + * } + * + * # overwrite settings for a specific storageObject + * permissions.file.storage.StorageUid { + * readFile = 1 + * recursivedeleteFolder = 0 + * } + * + * Please note that these permissions only apply, if the storage has the + * capabilities (browseable, writable), and if the driver allows for writing etc + */ + public function getFilePermissions(): array + { + if ($this->filePermissions === null) { + $filePermissions = [ + // File permissions + 'addFile' => false, + 'readFile' => false, + 'writeFile' => false, + 'copyFile' => false, + 'moveFile' => false, + 'renameFile' => false, + 'deleteFile' => false, + // Folder permissions + 'addFolder' => false, + 'readFolder' => false, + 'writeFolder' => false, + 'copyFolder' => false, + 'moveFolder' => false, + 'renameFolder' => false, + 'deleteFolder' => false, + 'recursivedeleteFolder' => false, + ]; + if ($this->isAdmin()) { + $filePermissions = array_map(is_bool(...), $filePermissions); + } else { + $userGroupRecordPermissions = GeneralUtility::trimExplode(',', $this->groupData['file_permissions'] ?? '', true); + array_walk( + $userGroupRecordPermissions, + static function (string $permission) use (&$filePermissions): void { + $filePermissions[$permission] = true; + } + ); + + // Finally overlay any user TSconfig + $permissionsTsConfig = $this->getTSConfig()['permissions.']['file.']['default.'] ?? []; + if (!empty($permissionsTsConfig)) { + array_walk( + $permissionsTsConfig, + static function (string $value, string $permission) use (&$filePermissions): void { + $filePermissions[$permission] = (bool)$value; + } + ); + } + } + $this->filePermissions = $filePermissions; + } + return $this->filePermissions; + } + + /** + * Initializing workspace settings after all TSconfig has been parsed. + * Called from within fetchGroupData() + * + * @see fetchGroupData() + */ + protected function workspaceInit(): void + { + // Initializing workspace by evaluating and setting the workspace, possibly updating it in the user record! + $this->setWorkspace($this->user['workspace_id']); + // Limiting the Page Tree Entry Points if there any selected in the workspace record + $this->initializeDbMountpointsInWorkspace(); + $allowed_languages = (string)($this->getTSConfig()['options.']['workspaces.']['allowed_languages.'][$this->workspace] ?? ''); + if ($allowed_languages !== '') { + $this->groupData['allowed_languages'] = StringUtility::uniqueList($allowed_languages); + } + } + + /** + * Limiting the Page Tree Entry Points if there are any selected in the workspace record + */ + protected function initializeDbMountpointsInWorkspace() + { + $dbMountpoints = trim($this->workspaceRec['db_mountpoints'] ?? ''); + if ($this->workspace > 0 && $dbMountpoints != '') { + $filteredDbMountpoints = []; + // Notice: We cannot call $this->getPagePermsClause(1); + // as usual because the group-list is not available at this point. + // But bypassing is fine because all we want here is check if the + // workspace mounts are inside the current webmounts rootline. + // The actual permission checking on page level is done elsewhere + // as usual anyway before the page tree is rendered. + $readPerms = '1=1'; + // Traverse mount points of the workspace, add them, + // but make sure they match against the users' Page Tree Entry Points + + $workspaceWebMounts = GeneralUtility::intExplode(',', $dbMountpoints); + $webMountsOfUser = GeneralUtility::intExplode(',', (string)($this->groupData['webmounts'] ?? '')); + $webMountsOfUser = array_combine($webMountsOfUser, $webMountsOfUser) ?: []; + + $entryPointRootLineUids = []; + foreach ($webMountsOfUser as $webMountPageId) { + $rootLine = BackendUtility::BEgetRootLine($webMountPageId, '', true); + $entryPointRootLineUids[$webMountPageId] = array_map(intval(...), array_column($rootLine, 'uid')); + } + foreach ($entryPointRootLineUids as $webMountOfUser => $uidsOfRootLine) { + // Remove the Page Tree Entry Point of the user if the Page Tree Entry Point is not in the list of + // workspace mounts + foreach ($workspaceWebMounts as $webmountOfWorkspace) { + // This workspace's Page Tree Entry Point is somewhere in the rootline of the users' web mount, + // so this is "OK" to be included + if (in_array($webmountOfWorkspace, $uidsOfRootLine, true)) { + continue; + } + // Remove the user's Page Tree Entry Points (possible via array_combine, see above) + unset($webMountsOfUser[$webMountOfUser]); + } + } + $dbMountpoints = array_merge($workspaceWebMounts, $webMountsOfUser); + $dbMountpoints = array_unique($dbMountpoints); + foreach ($dbMountpoints as $mpId) { + if ($this->isInWebMount($mpId, $readPerms)) { + $filteredDbMountpoints[] = $mpId; + } + } + // Re-insert webmounts + $this->groupData['webmounts'] = implode(',', $filteredDbMountpoints); + } + } + + /** + * Checking if a workspace is allowed for backend user + * + * @param int|array $wsRec If integer, workspace record is looked up, if array it is seen as a Workspace record with at least uid, title, members and adminusers columns. Can be faked for workspaces uid 0 (live) + * @return array|false Output will also show how access was granted. Admin users will have a true output regardless of input. + * @internal should only be used from within TYPO3 Core + */ + public function checkWorkspace(int|array $wsRec): array|false + { + // If not array, look up workspace record + if (!is_array($wsRec)) { + if ($wsRec === 0) { + $wsRec = ['uid' => 0]; + } elseif ($this->getTcaSchema('sys_workspace')) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_workspace'); + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(RootLevelRestriction::class)); + $wsRec = $queryBuilder + ->select('*') + ->from('sys_workspace') + ->where($queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($wsRec, Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchAssociative(); + } + } + // If wsRec is set to an array, evaluate it, otherwise return false + if (!is_array($wsRec)) { + return false; + } + if ($this->isAdmin()) { + return array_merge($wsRec, ['_ACCESS' => 'admin']); + } + // User is in live, and be_groups.workspace_perms has bitmask=1 included + if ($wsRec['uid'] === 0) { + return $this->hasEditAccessToLiveWorkspace() + ? array_merge($wsRec, ['_ACCESS' => 'online']) + : false; + } + // Checking if the person is workspace owner + if (GeneralUtility::inList($wsRec['adminusers'], 'be_users_' . $this->user['uid'])) { + return array_merge($wsRec, ['_ACCESS' => 'owner']); + } + // Checking if the editor is owner through an included user group + foreach ($this->userGroupsUID as $groupUid) { + if (GeneralUtility::inList($wsRec['adminusers'], 'be_groups_' . $groupUid)) { + return array_merge($wsRec, ['_ACCESS' => 'owner']); + } + } + // Checking if the user is member of the workspace + if (GeneralUtility::inList($wsRec['members'], 'be_users_' . $this->user['uid'])) { + return array_merge($wsRec, ['_ACCESS' => 'member']); + } + // Checking if the user is member through an included user group + foreach ($this->userGroupsUID as $groupUid) { + if (GeneralUtility::inList($wsRec['members'], 'be_groups_' . $groupUid)) { + return array_merge($wsRec, ['_ACCESS' => 'member']); + } + } + return false; + } + + /** + * Checks if the user (or the group) has the workspace_perms set to 1 in order to allow + * editing records in live workspace. + */ + protected function hasEditAccessToLiveWorkspace(): bool + { + return (bool)(($this->groupData['workspace_perms'] ?? 0) & 1); + } + + /** + * Uses checkWorkspace() to check if current workspace is available for user. + * This function caches the result and so can be called many times with no performance loss. + * + * @see checkWorkspace() + * @internal should only be used from within TYPO3 Core + */ + protected function checkWorkspaceCurrent(): false|array|null + { + if (!isset($this->checkWorkspaceCurrent_cache)) { + $this->checkWorkspaceCurrent_cache = $this->checkWorkspace($this->workspace); + } + return $this->checkWorkspaceCurrent_cache; + } + + /** + * Setting workspace ID + * + * @param int $workspaceId ID of workspace to set for backend user. If not valid the default workspace for BE user is found and set. + * @internal should only be used from within TYPO3 Core + */ + public function setWorkspace($workspaceId) + { + // Check workspace validity and if not found, revert to default workspace. + if (!$this->setTemporaryWorkspace($workspaceId)) { + $this->setDefaultWorkspace(); + } + // Unset access cache: + $this->checkWorkspaceCurrent_cache = null; + // If ID is different from the stored one, change it: + if ((int)$this->workspace !== (int)$this->user['workspace_id']) { + $this->user['workspace_id'] = $this->workspace; + GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('be_users')->update( + 'be_users', + ['workspace_id' => $this->user['workspace_id']], + ['uid' => (int)$this->user['uid']] + ); + $this->writelog(SystemLogType::EXTENSION, SystemLogGenericAction::UNDEFINED, SystemLogErrorClassification::MESSAGE, null, 'User changed workspace to "{workspace}"', ['workspace' => $this->workspace]); + } + } + + /** + * Sets a temporary workspace in the context of the current backend user. + * + * @param int $workspaceId + * @return bool + * @internal should only be used from within TYPO3 Core + */ + public function setTemporaryWorkspace($workspaceId) + { + $workspaceRecord = $this->checkWorkspace((int)$workspaceId); + + if ($workspaceRecord) { + $this->workspaceRec = $workspaceRecord; + $this->workspace = (int)$workspaceId; + return true; + } + return false; + } + + /** + * Sets the default workspace in the context of the current backend user. + * @internal should only be used from within TYPO3 Core + */ + protected function setDefaultWorkspace(): void + { + $this->workspace = $this->getDefaultWorkspace(); + $this->workspaceRec = $this->checkWorkspace($this->workspace); + } + + /** + * Return default workspace ID for user, + * if EXT:workspaces is not installed the user will be pushed to the + * Live workspace, if he has access to. If no workspace is available for the user, the workspace ID is set to "-99" + * + * @return int Default workspace id. + * @internal should only be used from within TYPO3 Core + */ + protected function getDefaultWorkspace(): int + { + if ($this->getTcaSchema('sys_workspace') === null) { + return 0; + } + // Online is default + if ($this->checkWorkspace(0)) { + return 0; + } + // Traverse all workspaces + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_workspace'); + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(RootLevelRestriction::class)); + $result = $queryBuilder->select('*') + ->from('sys_workspace') + ->orderBy('title') + ->executeQuery(); + while ($workspaceRecord = $result->fetchAssociative()) { + if ($this->checkWorkspace($workspaceRecord)) { + return (int)$workspaceRecord['uid']; + } + } + // Otherwise -99 is the fallback + return -99; + } + + /** + * Writes an entry in the logfile/table + * + * @param int $type Denotes which module that has submitted the entry. See "TYPO3 Core API". Use "4" for extensions. + * @param int $action Denotes which specific operation that wrote the entry. Use "0" when no sub-categorizing applies + * @param int $error Flag. 0 = message, 1 = error (user problem), 2 = System Error (which should not happen), 3 = security notice (admin) + * @param null $_ unused + * @param string $details Default text that follows the message (in english!). Possibly translated by identification through type/action + * @param array $data Data that follows the log. Might be used to carry special information. If an array the first 5 entries (0-4) will be sprintf'ed with the details-text + * @param string $tablename Table name. Special field used by tce_main.php. + * @param int|string $recuid Record UID. Special field used by tce_main.php. + * @param null $__ unused + * @param int $event_pid The page_uid (pid) where the event occurred. Used to select log-content for specific pages. + * @param null $___ unused + * @param int $userId Alternative Backend User ID (used for logging login actions where this is not yet known). + * @return int Log entry ID. + */ + public function writelog($type, $action, $error, $_, $details, $data, $tablename = '', $recuid = '', $__ = null, $event_pid = -1, $___ = null, $userId = 0) + { + return GeneralUtility::makeInstance(LogEntryRepository::class)->writeLogEntryForBackendUser( + $this, + (int)$type, + (int)$action, + (int)$error, + (string)$details, + (array)$data, + (string)$tablename, + (int)$recuid, + (int)$event_pid, + ); + } + + /** + * Returns the configured cookie name + */ + public static function getCookieName(): string + { + $configuredCookieName = trim((string)($GLOBALS['TYPO3_CONF_VARS']['BE']['cookieName'] ?? '')); + return $configuredCookieName !== '' ? $configuredCookieName : 'be_typo_user'; + } + + /** + * Check if user is logged in and if so, call ->fetchGroupData() to load group information and + * access lists of all kind, further check IP, set the ->uc array. + * If no user is logged in the default behaviour is to exit with an error message. + * This function is called right after ->start() in fx. the TYPO3 Bootstrap. + * + * @throws \RuntimeException + * @todo deprecate + */ + public function backendCheckLogin(?ServerRequestInterface $request = null) + { + if (empty($this->user['uid'])) { + // @todo: throw a proper AccessDeniedException in TYPO3 v12.0. and handle this functionality in the calling code + $entryPointResolver = GeneralUtility::makeInstance(BackendEntryPointResolver::class); + $url = $entryPointResolver->getUriFromRequest($GLOBALS['TYPO3_REQUEST']); + throw new ImmediateResponseException(new RedirectResponse($url, 303), 1607271747); + } + if ($this->isUserAllowedToLogin()) { + $this->initializeBackendLogin($request); + } else { + // @todo: throw a proper AccessDeniedException in TYPO3 v12.0. + throw new \RuntimeException('Login Error: TYPO3 is in maintenance mode at the moment. Only administrators are allowed access.', 1294585860); + } + } + + /** + * @internal + */ + public function initializeBackendLogin(?ServerRequestInterface $request = null): void + { + // The groups are fetched and ready for permission checking in this initialization. + // Tables.php must be read before this because stuff like the modules has impact in this + $this->fetchGroupData(); + // Setting the UC array. It's needed with fetchGroupData first, due to default/overriding of values. + $this->backendSetUC(); + if ($this->loginSessionStarted && !($this->getSessionData('mfa') ?? false)) { + // Handling user logged in. By checking for the mfa session key, it's ensured, the + // handling is only done once, since MfaController does the handling on its own. + $this->handleUserLoggedIn($request); + } + } + + /** + * Is called after a user has sucesfully logged in. So either by using only one factor + * (e.g. username/password) or after the multi-factor authentication process has been passed. + * + * @internal + */ + public function handleUserLoggedIn(?ServerRequestInterface $request = null): void + { + // Also, if there is a recovery link set, unset it now + // this will be moved into its own Event at a later stage. + // If a token was set previously, this is now unset, as it was now possible to log-in + if ($this->user['password_reset_token'] ?? '') { + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->user_table) + ->update($this->user_table, ['password_reset_token' => ''], ['uid' => $this->user['uid']]); + } + + $event = new AfterUserLoggedInEvent($this, $request); + GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch($event); + } + + /** + * Initialize the internal ->uc array for the backend user (UC - user configuration + * is a serialized array inside the user object). Will make the overrides if necessary, + * and write the UC back to the be_users record if changes has happened. + * + * @internal + */ + public function backendSetUC() + { + // Setting defaults if uc is empty + $updated = false; + if (empty($this->uc)) { + $this->uc = array_merge( + $this->uc_default, + (array)$GLOBALS['TYPO3_CONF_VARS']['BE']['defaultUC'], + GeneralUtility::removeDotsFromTS((array)($this->getTSConfig()['setup.']['default.'] ?? [])) + ); + $this->overrideUC(); + $updated = true; + } + // If TSconfig is updated, update the defaultUC. + if ($this->userTSUpdated) { + $this->overrideUC(); + $updated = true; + } + // Saving if updated. + if ($updated) { + $this->writeUC(); + } + } + + /** + * Override: Call this function every time the uc is updated. + * That is 1) by reverting to default values, 2) in the setup-module, 3) userTS changes (userauthgroup) + * + * @internal + */ + public function overrideUC() + { + $this->uc = array_merge($this->uc, (array)($this->getTSConfig()['setup.']['override.'] ?? [])); + } + + /** + * Clears the user[uc] and ->uc to blank strings. Then calls ->backendSetUC() to fill it again with reset contents + * + * @internal + */ + public function resetUC() + { + $this->user['uc'] = ''; + $this->user['user_settings'] = ''; + $this->uc = []; + $this->resetUserSettingsCache(); + $this->backendSetUC(); + } + + public function writeUC(): void + { + $userId = $this->getUserId(); + if (!$userId) { + return; + } + + $this->logger->debug('writeUC: {userid_column}={value}', [ + 'userid_column' => $this->userid_column, + 'value' => $userId, + ]); + + $schema = GeneralUtility::makeInstance(UserSettingsSchema::class); + $profileSettings = []; + foreach ($schema->getJsonFieldSettingKeys() as $key) { + if (array_key_exists($key, $this->uc)) { + $profileSettings[$key] = $this->uc[$key]; + } + } + + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->user_table); + + $connection->update( + $this->user_table, + [ + 'uc' => serialize($this->uc), + // The array must be passed directly to prevent double JSON encoding. + // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 + 'user_settings' => $profileSettings, + ], + [$this->userid_column => $userId], + [ + 'uc' => Connection::PARAM_LOB, + // @todo This behavior cannot be modified yet; the array value must be passed directly + // until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged, + // otherwise the value will be JSON-encoded twice. + 'user_settings' => Type::getType(Types::JSON), + ], + ); + // Set modified user settings `json_encoded()` to the instance user record to display the correct + // value on the same request without rereading whole user record and group information here. That + // ensures that the next call to `getUserSettings()` creates a new instance from this record with + // the new user settings using `UserSettingsFactory`. + $this->user['user_settings'] = json_encode($profileSettings); + + $this->resetUserSettingsCache(); + } + + /** + * Determines whether a backend user is allowed to access the backend. + * + * The conditions are: + * + backend user is a regular user and adminOnly is not defined + * + backend user is an admin user + * + backend user is used in CLI context and adminOnly is explicitly set to "2" (see CommandLineUserAuthentication) + * + backend user is being controlled by an admin user + * + * @return bool Whether a backend user is allowed to access the backend + * @internal + */ + public function isUserAllowedToLogin() + { + $isUserAllowedToLogin = false; + $adminOnlyMode = (int)$GLOBALS['TYPO3_CONF_VARS']['BE']['adminOnly']; + // Backend user is allowed if adminOnly is not set or user is an admin: + if (!$adminOnlyMode || $this->isAdmin()) { + $isUserAllowedToLogin = true; + } elseif ($backUserId = $this->getOriginalUserIdWhenInSwitchUserMode()) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users'); + $isUserAllowedToLogin = (bool)$queryBuilder->count('uid') + ->from('be_users') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($backUserId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(1, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchOne(); + } + return $isUserAllowedToLogin; + } + + /** + * Logs out the current user and clears the form protection tokens. + */ + public function logoff() + { + if (isset($GLOBALS['BE_USER']) + && $GLOBALS['BE_USER'] instanceof self + && isset($GLOBALS['BE_USER']->user['uid']) + ) { + GeneralUtility::makeInstance(FormProtectionFactory::class)->createForType('backend')->clean(); + // Release the locked records + $this->releaseLockedRecords((int)$GLOBALS['BE_USER']->user['uid']); + + if ($this->isSystemMaintainer()) { + // @todo: This should be turned into a dispatched event EXT:install can listen on. + // This might be useful for others as well. The reasons this has not been + // implemented yet, is, that the method should be refactored to at least + // receive Request and probably be-user object correctly, instead of + // fetching it from globals, before creating API with an event. + $packageManager = GeneralUtility::makeInstance(PackageManager::class); + if ($packageManager->isPackageActive('install')) { + // If user is system maintainer, destroy its possibly valid install tool session. + $session = GeneralUtility::makeInstance(SessionService::class); + // @todo: It's kinda fishy installSessionHandler() is called here. We should be able to skip this. + $session->installSessionHandler($GLOBALS['TYPO3_REQUEST'] ?? null); + $session->destroySession($GLOBALS['TYPO3_REQUEST'] ?? null); + } + } + } + parent::logoff(); + } + + /** + * Remove any "locked records" added for editing for the given user (= current backend user) + */ + protected function releaseLockedRecords(int $userId) + { + if ($userId > 0) { + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_lockedrecords') + ->delete( + 'sys_lockedrecords', + ['userid' => $userId] + ); + } + } + + /** + * Returns the uid of the backend user to return to. + * This is set when the current session is a "switch-user" session. + * + * @return int|null The user id + * @internal should only be used from within TYPO3 Core + */ + public function getOriginalUserIdWhenInSwitchUserMode(): ?int + { + $originalUserId = $this->getSessionData('backuserid'); + return $originalUserId ? (int)$originalUserId : null; + } + + /** + * @internal + */ + protected function evaluateMfaRequirements(): void + { + // In case the current session is a "switch-user" session, MFA is not required + if ($this->getOriginalUserIdWhenInSwitchUserMode() !== null) { + $this->logger->debug('MFA is skipped in switch user mode', [ + $this->userid_column => $this->getUserId(), + $this->username_column => $this->getUserName(), + ]); + return; + } + parent::evaluateMfaRequirements(); + } + + /** + * Evaluate whether the user is required to set up MFA, based on user TSconfig and global configuration + * + * @internal + */ + public function isMfaSetupRequired(): bool + { + $authConfig = $this->getTSConfig()['auth.']['mfa.'] ?? []; + + if (isset($authConfig['required'])) { + // user TSconfig overrules global configuration + return (bool)$authConfig['required']; + } + + $globalConfig = (int)($GLOBALS['TYPO3_CONF_VARS']['BE']['requireMfa'] ?? 0); + if ($globalConfig <= 1) { + // 0 and 1 can directly be used by type-casting to boolean + return (bool)$globalConfig; + } + + // check the system maintainer / admin / non-admin options + $isAdmin = $this->isAdmin(); + return ($globalConfig === 2 && !$isAdmin) + || ($globalConfig === 3 && $isAdmin) + || ($globalConfig === 4 && $this->isSystemMaintainer()); + } + + /** + * Returns if import functionality is available for current user + * + * @internal + */ + public function isImportEnabled(): bool + { + return $this->isAdmin() + || ($this->getTSConfig()['options.']['impexp.']['enableImportForNonAdminUser'] ?? false); + } + + /** + * Returns if export functionality is available for current user + * + * @internal + */ + public function isExportEnabled(): bool + { + return $this->isAdmin() + || ($this->getTSConfig()['options.']['impexp.']['enableExportForNonAdminUser'] ?? false); + } + + /** + * Returns whether debug information shall be displayed to the user + * + * @internal + */ + public function shallDisplayDebugInformation(): bool + { + return ($GLOBALS['TYPO3_CONF_VARS']['BE']['debug'] ?? false) && $this->isAdmin(); + } + + protected function getTcaSchema(string $table): ?TcaSchema + { + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + return $schemaFactory->has($table) ? $schemaFactory->get($table) : null; + } +} diff --git a/Classes/Authentication/CommandLineUserAuthentication.php b/Classes/Authentication/CommandLineUserAuthentication.php new file mode 100644 index 0000000..bc6cabc --- /dev/null +++ b/Classes/Authentication/CommandLineUserAuthentication.php @@ -0,0 +1,122 @@ +isUserAllowedToLogin()) { + throw new \RuntimeException('Login Error: TYPO3 is in maintenance mode at the moment. Only administrators are allowed access.', 1483971855); + } + $this->dontSetCookie = true; + parent::__construct(); + } + + /** + * Replacement for AbstractUserAuthentication::start() + * + * We do not need support for sessions, cookies, $_GET-modes, the postUserLookup hook or + * a database connection during CLI Bootstrap + * + * @param ServerRequestInterface|null $request + */ + public function start(?ServerRequestInterface $request = null) + { + // do nothing + } + + /** + * Replacement for AbstractUserAuthentication::checkAuthentication() + * + * Not required in CLI mode, therefore empty. + */ + public function checkAuthentication(ServerRequestInterface $request) + { + // do nothing + } + + /** + * On CLI there is no session and no switched user + */ + public function getOriginalUserIdWhenInSwitchUserMode(): ?int + { + return null; + } + + /** + * Logs-in the _CLI_ user. It does not need to check for credentials. + * + * @throws \RuntimeException when the user could not log in or it is an admin + */ + public function authenticate() + { + // check if a _CLI_ user exists, if not, create one + $this->setBeUserByName(CommandLineUserCreation::CLI_USERNAME); + if (empty($this->user['uid'])) { + $userCreation = GeneralUtility::makeInstance(CommandLineUserCreation::class); + // create a new BE user in the database + if (!$userCreation->ensureCliUserExists()) { + throw new \RuntimeException('No backend user named "_cli_" could be authenticated, maybe this user is "hidden"?', 1484050401); + } + $this->setBeUserByName(CommandLineUserCreation::CLI_USERNAME); + } + if (empty($this->user['uid'])) { + throw new \RuntimeException('No backend user named "_cli_" could be created.', 1476107195); + } + $this->unpack_uc(); + // The groups are fetched and ready for permission checking in this initialization. + $this->fetchGroupData(); + $this->backendSetUC(); + } + + /** + * Logs in the TYPO3 Backend user "_cli_" + */ + public function backendCheckLogin(?ServerRequestInterface $request = null) + { + $this->authenticate(); + } + + /** + * Determines whether a CLI backend user is allowed to access TYPO3. + * Only when adminOnly is off (=0), and only allowed for admins and CLI users (=2) + * + * @return bool Whether the CLI user is allowed to access TYPO3 + * @internal + */ + public function isUserAllowedToLogin() + { + return in_array((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['adminOnly'], [0, 2], true); + } +} diff --git a/Classes/Authentication/CommandLineUserCreation.php b/Classes/Authentication/CommandLineUserCreation.php new file mode 100644 index 0000000..3c7f096 --- /dev/null +++ b/Classes/Authentication/CommandLineUserCreation.php @@ -0,0 +1,92 @@ +cliUserExists()) { + return false; + } + $userFields = [ + 'username' => self::CLI_USERNAME, + 'password' => $this->generateHashedPassword(), + 'admin' => 1, + 'tstamp' => $GLOBALS['EXEC_TIME'] ?? time(), + 'crdate' => $GLOBALS['EXEC_TIME'] ?? time(), + ]; + + $databaseConnection = $this->connectionPool->getConnectionForTable('be_users'); + $databaseConnection->insert('be_users', $userFields); + return true; + } + + /** + * Check if a user with username "_cli_" exists. Deleted users are left out + * but hidden and start / endtime restricted users are considered. + */ + private function cliUserExists(): bool + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $count = $queryBuilder + ->count('*') + ->from('be_users') + ->where($queryBuilder->expr()->eq('username', $queryBuilder->createNamedParameter(self::CLI_USERNAME))) + ->executeQuery() + ->fetchOne(); + return (bool)$count; + } + + /** + * This function returns a salted hashed key. + */ + private function generateHashedPassword(): string + { + $cryptoService = GeneralUtility::makeInstance(Random::class); + $password = $cryptoService->generateRandomBytes(20); + return $this->passwordHashFactory + ->getDefaultHashInstance('BE') + ->getHashedPassword($password); + } +} diff --git a/Classes/Authentication/Event/AbstractAuthenticationFailedEvent.php b/Classes/Authentication/Event/AbstractAuthenticationFailedEvent.php new file mode 100644 index 0000000..4a84354 --- /dev/null +++ b/Classes/Authentication/Event/AbstractAuthenticationFailedEvent.php @@ -0,0 +1,52 @@ +isBackendAttempt(); + } + + public function isBackendAttempt(): bool + { + return $this->getUser() instanceof BackendUserAuthentication; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Authentication/Event/AfterGroupsResolvedEvent.php b/Classes/Authentication/Event/AfterGroupsResolvedEvent.php new file mode 100644 index 0000000..42a08ed --- /dev/null +++ b/Classes/Authentication/Event/AfterGroupsResolvedEvent.php @@ -0,0 +1,75 @@ +sourceDatabaseTable; + } + + /** + * List of group records including sub groups as resolved by core. + * + * Note order is important: A user with main groups "1,2", where 1 has sub group 3, + * results in "3,1,2" as record list array - sub groups are listed before the group + * that includes the sub group. + */ + public function getGroups(): array + { + return $this->groups; + } + + /** + * List of group records as manipulated by the event. + */ + public function setGroups(array $groups): void + { + $this->groups = $groups; + } + + /** + * List of group uids directly attached to the user + */ + public function getOriginalGroupIds(): array + { + return $this->originalGroupIds; + } + + /** + * Full user record with all fields + */ + public function getUserData(): array + { + return $this->userData; + } +} diff --git a/Classes/Authentication/Event/AfterUserLoggedInEvent.php b/Classes/Authentication/Event/AfterUserLoggedInEvent.php new file mode 100644 index 0000000..ed2d43e --- /dev/null +++ b/Classes/Authentication/Event/AfterUserLoggedInEvent.php @@ -0,0 +1,42 @@ +user; + } + + public function getRequest(): ?ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Authentication/Event/AfterUserLoggedOutEvent.php b/Classes/Authentication/Event/AfterUserLoggedOutEvent.php new file mode 100644 index 0000000..c4a5faa --- /dev/null +++ b/Classes/Authentication/Event/AfterUserLoggedOutEvent.php @@ -0,0 +1,35 @@ +user; + } +} diff --git a/Classes/Authentication/Event/BeforeRequestTokenProcessedEvent.php b/Classes/Authentication/Event/BeforeRequestTokenProcessedEvent.php new file mode 100644 index 0000000..5b169d6 --- /dev/null +++ b/Classes/Authentication/Event/BeforeRequestTokenProcessedEvent.php @@ -0,0 +1,54 @@ +user; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getRequestToken(): RequestToken|false|null + { + return $this->requestToken; + } + + public function setRequestToken(RequestToken|false|null $requestToken): void + { + $this->requestToken = $requestToken; + } +} diff --git a/Classes/Authentication/Event/BeforeUserLogoutEvent.php b/Classes/Authentication/Event/BeforeUserLogoutEvent.php new file mode 100644 index 0000000..ed92533 --- /dev/null +++ b/Classes/Authentication/Event/BeforeUserLogoutEvent.php @@ -0,0 +1,61 @@ +user; + } + + public function disableRegularLogoutProcess(): void + { + $this->shouldLogout = false; + } + + public function enableRegularLogoutProcess(): void + { + $this->shouldLogout = true; + } + + public function shouldLogout(): bool + { + return $this->shouldLogout; + } + + public function getUserSession(): ?UserSession + { + return $this->userSession; + } +} diff --git a/Classes/Authentication/Event/LoginAttemptFailedEvent.php b/Classes/Authentication/Event/LoginAttemptFailedEvent.php new file mode 100644 index 0000000..96da574 --- /dev/null +++ b/Classes/Authentication/Event/LoginAttemptFailedEvent.php @@ -0,0 +1,45 @@ +request); + } + + public function getUser(): AbstractUserAuthentication + { + return $this->user; + } + + public function getLoginData(): array + { + return $this->loginData; + } +} diff --git a/Classes/Authentication/Event/MfaVerificationFailedEvent.php b/Classes/Authentication/Event/MfaVerificationFailedEvent.php new file mode 100644 index 0000000..6b52b68 --- /dev/null +++ b/Classes/Authentication/Event/MfaVerificationFailedEvent.php @@ -0,0 +1,62 @@ +request); + } + + public function getUser(): AbstractUserAuthentication + { + return $this->propertyManager->getUser(); + } + + public function getProvider(): MfaProviderManifestInterface + { + return $this->mfaProvider; + } + + public function getProviderIdentifier(): string + { + return $this->mfaProvider->getIdentifier(); + } + + public function getProviderProperties(): array + { + return $this->propertyManager->getProperties(); + } + + public function isProviderLocked(): bool + { + return $this->mfaProvider->isLocked($this->propertyManager); + } +} diff --git a/Classes/Authentication/Exception/UserSettingsNotFoundException.php b/Classes/Authentication/Exception/UserSettingsNotFoundException.php new file mode 100644 index 0000000..f16f8d6 --- /dev/null +++ b/Classes/Authentication/Exception/UserSettingsNotFoundException.php @@ -0,0 +1,23 @@ +fetchGroupsRecursive($sourceTable, $originalGroupIds); + $event = $this->eventDispatcher->dispatch(new AfterGroupsResolvedEvent($sourceTable, $resolvedGroups, $originalGroupIds, $userRecord)); + return $event->getGroups(); + } + + /** + * This works the other way around: Find all users that belong to some groups. Because groups are nested, + * we need to find all groups and subgroups first, because maybe a user is only part of a higher group, + * instead of a "All editors" group. + * + * @param int[] $groupIds a list of IDs of groups + * @param string $sourceTable e.g. be_groups or fe_groups + * @param string $userSourceTable e.g. be_users or fe_users + * @return array full user records + */ + public function findAllUsersInGroups(array $groupIds, string $sourceTable, string $userSourceTable): array + { + // Ensure the given groups exist + $mainGroups = $this->fetchRowsFromDatabase($sourceTable, $groupIds); + $groupIds = array_map(intval(...), array_column($mainGroups, 'uid')); + if (empty($groupIds)) { + return []; + } + $parentGroupIds = $this->fetchParentGroupsRecursive($sourceTable, $groupIds, $groupIds); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($userSourceTable); + $queryBuilder + ->select('*') + ->from($userSourceTable); + + $constraints = []; + foreach ($groupIds as $groupUid) { + $constraints[] = $queryBuilder->expr()->inSet(self::SOURCE_FIELD, (string)$groupUid); + } + foreach ($parentGroupIds as $groupUid) { + $constraints[] = $queryBuilder->expr()->inSet(self::SOURCE_FIELD, (string)$groupUid); + } + + $users = $queryBuilder + ->where( + $queryBuilder->expr()->or(...$constraints) + ) + ->executeQuery() + ->fetchAllAssociative(); + return !empty($users) ? $users : []; + } + + /** + * Load a list of group uids, and take into account if groups have been loaded before. + * + * @param int[] $groupIds + */ + protected function fetchGroupsRecursive(string $sourceTable, array $groupIds, array $processedGroupIds = []): array + { + if (empty($groupIds)) { + return []; + } + $foundGroups = $this->fetchRowsFromDatabase($sourceTable, $groupIds); + $validGroups = []; + foreach ($groupIds as $groupId) { + // Database did not find the record + if (!is_array($foundGroups[$groupId] ?? null)) { + continue; + } + // Record was already processed, continue to avoid adding this group again + if (in_array($groupId, $processedGroupIds, true)) { + continue; + } + // Add sub groups first + $subgroupIds = GeneralUtility::intExplode(',', (string)($foundGroups[$groupId][self::RECURSIVE_SOURCE_FIELD] ?? ''), true); + if (!empty($subgroupIds)) { + $subgroups = $this->fetchGroupsRecursive($sourceTable, $subgroupIds, array_merge($processedGroupIds, [$groupId])); + $validGroups = array_merge($validGroups, $subgroups); + } + // Add main group after sub groups have been added + $validGroups[] = $foundGroups[$groupId]; + } + return $validGroups; + } + + /** + * Does the database query. Does not care about ordering, this is done by caller. + * + * @return array Full records with record uid as key + */ + protected function fetchRowsFromDatabase(string $sourceTable, array $groupIds): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($sourceTable); + $result = $queryBuilder + ->select('*') + ->from($sourceTable) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter( + $groupIds, + Connection::PARAM_INT_ARRAY + ) + ) + ) + ->executeQuery(); + $groups = []; + while ($row = $result->fetchAssociative()) { + $groups[(int)$row['uid']] = $row; + } + return $groups; + } + + /** + * Load a list of group uids, and take into account if groups have been loaded before as part of recursive detection. + * + * @param int[] $groupIds a list of groups to find THEIR ancestors + * @param array $processedGroupIds helper function to avoid recursive detection + * @return array a list of parent groups and thus, grand grand parent groups as well + */ + protected function fetchParentGroupsRecursive(string $sourceTable, array $groupIds, array $processedGroupIds = []): array + { + if (empty($groupIds)) { + return []; + } + $parentGroups = $this->fetchParentGroupsFromDatabase($sourceTable, $groupIds); + $validParentGroupIds = []; + foreach ($parentGroups as $parentGroup) { + $parentGroupId = (int)$parentGroup['uid']; + // Record was already processed, continue to avoid adding this group again + if (in_array($parentGroupId, $processedGroupIds, true)) { + continue; + } + $processedGroupIds[] = $parentGroupId; + $validParentGroupIds[] = $parentGroupId; + } + + $grandParentGroups = $this->fetchParentGroupsRecursive($sourceTable, $validParentGroupIds, $processedGroupIds); + return array_merge($validParentGroupIds, $grandParentGroups); + } + + /** + * Find all groups that have a FIND_IN_SET(subgroups, [$subgroupIds]) => the parent groups + * via one SQL query. + */ + protected function fetchParentGroupsFromDatabase(string $sourceTable, array $subgroupIds): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($sourceTable); + $queryBuilder + ->select('*') + ->from($sourceTable); + + $constraints = []; + foreach ($subgroupIds as $subgroupId) { + $constraints[] = $queryBuilder->expr()->inSet(self::RECURSIVE_SOURCE_FIELD, (string)$subgroupId); + } + + $result = $queryBuilder + ->where( + $queryBuilder->expr()->or(...$constraints) + ) + ->executeQuery(); + + $groups = []; + while ($row = $result->fetchAssociative()) { + $groups[(int)$row['uid']] = $row; + } + return $groups; + } +} diff --git a/Classes/Authentication/IpLocker.php b/Classes/Authentication/IpLocker.php new file mode 100644 index 0000000..1a73f6a --- /dev/null +++ b/Classes/Authentication/IpLocker.php @@ -0,0 +1,112 @@ +lockIPv4PartCount = $lockIPv4PartCount; + $this->lockIPv6PartCount = $lockIPv6PartCount; + } + + public function getSessionIpLock(string $ipAddress): string + { + if ($this->lockIPv4PartCount === 0 && $this->lockIPv6PartCount === 0) { + return static::DISABLED_LOCK_VALUE; + } + + if ($this->isIpv6Address($ipAddress)) { + return $this->getIpLockPartForIpv6Address($ipAddress); + } + return $this->getIpLockPartForIpv4Address($ipAddress); + } + + public function validateRemoteAddressAgainstSessionIpLock(string $ipAddress, string $sessionIpLock): bool + { + if ($sessionIpLock === static::DISABLED_LOCK_VALUE) { + return true; + } + + $ipToCompare = $this->isIpv6Address($ipAddress) + ? $this->getIpLockPartForIpv6Address($ipAddress) + : $this->getIpLockPartForIpv4Address($ipAddress); + return $ipToCompare === $sessionIpLock; + } + + protected function getIpLockPart(string $ipAddress, int $numberOfParts, int $maxParts, string $delimiter): string + { + if ($numberOfParts >= $maxParts) { + return $ipAddress; + } + + $numberOfParts = MathUtility::forceIntegerInRange($numberOfParts, 1, $maxParts); + $ipParts = explode($delimiter, $ipAddress); + + for ($a = $maxParts; $a > $numberOfParts; $a--) { + $ipPartValue = $delimiter === '.' ? '0' : str_pad('', strlen($ipParts[$a - 1]), '0'); + $ipParts[$a - 1] = $ipPartValue; + } + + return implode($delimiter, $ipParts); + } + + protected function getIpLockPartForIpv4Address(string $ipAddress): string + { + if ($this->lockIPv4PartCount === 0) { + return static::DISABLED_LOCK_VALUE; + } + + return $this->getIpLockPart($ipAddress, $this->lockIPv4PartCount, 4, '.'); + } + + protected function getIpLockPartForIpv6Address(string $ipAddress): string + { + if ($this->lockIPv6PartCount === 0) { + return static::DISABLED_LOCK_VALUE; + } + + // inet_pton also takes care of IPv4-mapped addresses (see https://en.wikipedia.org/wiki/IPv6_address#Representation) + $unpacked = unpack('H*hex', (string)inet_pton($ipAddress)) ?: []; + $expandedAddress = rtrim(chunk_split($unpacked['hex'] ?? '', 4, ':'), ':'); + return $this->getIpLockPart($expandedAddress, $this->lockIPv6PartCount, 8, ':'); + } + + protected function isIpv6Address(string $ipAddress): bool + { + return str_contains($ipAddress, ':'); + } +} diff --git a/Classes/Authentication/JsConfirmation.php b/Classes/Authentication/JsConfirmation.php new file mode 100644 index 0000000..713c5c3 --- /dev/null +++ b/Classes/Authentication/JsConfirmation.php @@ -0,0 +1,38 @@ +identifier; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getIconIdentifier(): string + { + return $this->iconIdentifier; + } + + public function getSetupInstructions(): string + { + return $this->setupInstructions; + } + + public function isDefaultProviderAllowed(): bool + { + return $this->isDefaultProviderAllowed; + } + + public function canProcess(ServerRequestInterface $request): bool + { + return $this->getInstance()->canProcess($request); + } + + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->isActive($propertyManager); + } + + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->isLocked($propertyManager); + } + + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->verify($request, $propertyManager); + } + + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + MfaViewType $type + ): ResponseInterface { + return $this->getInstance()->handleRequest($request, $propertyManager, $type); + } + + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->activate($request, $propertyManager); + } + + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->deactivate($request, $propertyManager); + } + + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->unlock($request, $propertyManager); + } + + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + return $this->getInstance()->update($request, $propertyManager); + } + + private function getInstance(): MfaProviderInterface + { + return $this->instance ?? $this->createInstance(); + } + + private function createInstance(): MfaProviderInterface + { + $this->instance = $this->container->get($this->serviceName); + return $this->instance; + } +} diff --git a/Classes/Authentication/Mfa/MfaProviderManifestInterface.php b/Classes/Authentication/Mfa/MfaProviderManifestInterface.php new file mode 100644 index 0000000..b977a93 --- /dev/null +++ b/Classes/Authentication/Mfa/MfaProviderManifestInterface.php @@ -0,0 +1,56 @@ +mfa = json_decode($user->user[self::DATABASE_FIELD_NAME] ?? '', true) ?? []; + $this->providerProperties = $this->mfa[$this->providerIdentifier] ?? []; + } + + /** + * Check if a provider entry exists for the current user + */ + public function hasProviderEntry(): bool + { + return isset($this->mfa[$this->providerIdentifier]); + } + + /** + * Check if a provider property exists + */ + public function hasProperty(string $key): bool + { + return isset($this->providerProperties[$key]); + } + + /** + * Get a provider specific property value or the defined + * default value if the requested property was not found. + */ + public function getProperty(string $key, mixed $default = null): mixed + { + return $this->providerProperties[$key] ?? $default; + } + + /** + * Get provider specific properties + */ + public function getProperties(): array + { + return $this->providerProperties; + } + + /** + * Update the provider properties + * Note: If no entry exists yet, use createProviderEntry() instead. + * This can be checked with hasProviderEntry(). + */ + public function updateProperties(array $properties): bool + { + // This is to prevent provider data inconsistency + if (!$this->hasProviderEntry()) { + throw new \InvalidArgumentException( + 'No entry for provider ' . $this->providerIdentifier . ' exists yet. Use createProviderEntry() instead.', + 1613993188 + ); + } + + if (!isset($properties['updated'])) { + $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + $this->providerProperties = array_replace($this->providerProperties, $properties); + $this->mfa[$this->providerIdentifier] = $this->providerProperties; + return $this->storeProperties(); + } + + /** + * Create a new provider entry for the current user + * Note: If an entry already exists, use updateProperties() instead. + * This can be checked with hasProviderEntry(). + */ + public function createProviderEntry(array $properties): bool + { + // This is to prevent unintentional overwriting of provider entries + if ($this->hasProviderEntry()) { + throw new \InvalidArgumentException( + 'A entry for provider ' . $this->providerIdentifier . ' already exists. Use updateProperties() instead.', + 1612781782 + ); + } + + if (!isset($properties['created'])) { + $properties['created'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + if (!isset($properties['updated'])) { + $properties['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + } + + $this->providerProperties = $properties; + $this->mfa[$this->providerIdentifier] = $this->providerProperties; + return $this->storeProperties(); + } + + /** + * Delete a provider entry for the current user + * + * @throws \JsonException + */ + public function deleteProviderEntry(): bool + { + $this->providerProperties = []; + unset($this->mfa[$this->providerIdentifier]); + return $this->storeProperties(); + } + + /** + * Stores the updated properties in the user array and the database + * + * @throws \JsonException + */ + protected function storeProperties(): bool + { + // encode the mfa properties to store them in the database and the user array + $mfa = json_encode($this->mfa, JSON_THROW_ON_ERROR) ?: ''; + + // Write back the updated mfa properties to the user array + $this->user->user[self::DATABASE_FIELD_NAME] = $mfa; + + // Log MFA update + $this->logger->debug('MFA properties updated', [ + 'provider' => $this->providerIdentifier, + 'user' => [ + 'uid' => $this->user->getUserId(), + 'username' => $this->user->getUserName(), + ], + ]); + + // Store updated mfa properties in the database + return (bool)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user->user_table)->update( + $this->user->user_table, + [self::DATABASE_FIELD_NAME => $mfa], + [$this->user->userid_column => (int)$this->user->getUserId()], + [self::DATABASE_FIELD_NAME => Connection::PARAM_LOB] + ); + } + + /** + * Return the current user + */ + public function getUser(): AbstractUserAuthentication + { + return $this->user; + } + + /** + * Return the current providers identifier + */ + public function getIdentifier(): string + { + return $this->providerIdentifier; + } + + /** + * Create property manager for the user with the given provider + */ + public static function create(MfaProviderManifestInterface $provider, AbstractUserAuthentication $user): self + { + return GeneralUtility::makeInstance(self::class, $user, $provider->getIdentifier()); + } +} diff --git a/Classes/Authentication/Mfa/MfaProviderRegistry.php b/Classes/Authentication/Mfa/MfaProviderRegistry.php new file mode 100644 index 0000000..5bec18b --- /dev/null +++ b/Classes/Authentication/Mfa/MfaProviderRegistry.php @@ -0,0 +1,138 @@ +providers[$provider->getIdentifier()] = $provider; + } + + public function hasProvider(string $identifier): bool + { + return isset($this->providers[$identifier]); + } + + public function hasProviders(): bool + { + return $this->providers !== []; + } + + public function getProvider(string $identifier): MfaProviderManifestInterface + { + if (!$this->hasProvider($identifier)) { + throw new \InvalidArgumentException('No MFA provider for identifier ' . $identifier . ' found.', 1610994735); + } + return $this->providers[$identifier]; + } + + public function getProviders(): array + { + return $this->providers; + } + + /** + * Whether the given user has active providers + */ + public function hasActiveProviders(AbstractUserAuthentication $user): bool + { + return $this->getActiveProviders($user) !== []; + } + + /** + * Get all active providers for the given user + * + * @return MfaProviderManifestInterface[] + */ + public function getActiveProviders(AbstractUserAuthentication $user): array + { + return array_filter($this->providers, static function (MfaProviderManifestInterface $provider) use ($user): bool { + return $provider->isActive(MfaProviderPropertyManager::create($provider, $user)); + }); + } + + /** + * Get the first provider for the user which can be used for authentication. + * This is either the user specified default provider, or the first active + * provider based on the providers configured ordering. + * + * @return MfaProviderManifestInterface + */ + public function getFirstAuthenticationAwareProvider(AbstractUserAuthentication $user): ?MfaProviderManifestInterface + { + $activeProviders = $this->getActiveProviders($user); + // If the user did not activate any provider yet, authentication is not possible + if ($activeProviders === []) { + return null; + } + // Check if the user has chosen a default (preferred) provider, which is still active + $defaultProvider = (string)($user->uc['mfa']['defaultProvider'] ?? ''); + if ($defaultProvider !== '' && isset($activeProviders[$defaultProvider])) { + return $activeProviders[$defaultProvider]; + } + // If no default provider exists or is not valid, return the first active provider + return array_shift($activeProviders); + } + + /** + * Whether the given user has locked providers + */ + public function hasLockedProviders(AbstractUserAuthentication $user): bool + { + return $this->getLockedProviders($user) !== []; + } + + /** + * Get all locked providers for the given user + * + * @return MfaProviderManifestInterface[] + */ + public function getLockedProviders(AbstractUserAuthentication $user): array + { + return array_filter($this->providers, static function (MfaProviderManifestInterface $provider) use ($user): bool { + return $provider->isLocked(MfaProviderPropertyManager::create($provider, $user)); + }); + } + + public function allowedProvidersItemsProcFunc(array &$parameters): void + { + foreach ($this->providers as $provider) { + $parameters['items'][] = [ + 'label' => $provider->getTitle(), + 'value' => $provider->getIdentifier(), + 'icon' => $provider->getIconIdentifier(), + 'description' => $provider->getDescription(), + ]; + } + } +} diff --git a/Classes/Authentication/Mfa/MfaRequiredException.php b/Classes/Authentication/Mfa/MfaRequiredException.php new file mode 100644 index 0000000..9da7c78 --- /dev/null +++ b/Classes/Authentication/Mfa/MfaRequiredException.php @@ -0,0 +1,39 @@ +provider; + } +} diff --git a/Classes/Authentication/Mfa/MfaViewType.php b/Classes/Authentication/Mfa/MfaViewType.php new file mode 100644 index 0000000..45b52bf --- /dev/null +++ b/Classes/Authentication/Mfa/MfaViewType.php @@ -0,0 +1,28 @@ +passwordHashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class); + } + + /** + * Generate plain and hashed recovery codes and return them as key/value + */ + public function generateRecoveryCodes(): array + { + $plainCodes = $this->generatePlainRecoveryCodes(); + return array_combine($plainCodes, $this->generatedHashedRecoveryCodes($plainCodes)); + } + + /** + * Generate given amount of plain recovery codes with the given length + * + * @return list + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + + /** @var list $codes */ + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= (string)random_int(0, 9); + } + // Prevent duplicate codes which is however very unlikely to happen + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + return $codes; + } + + /** + * Hash the given plain recovery codes with the default hash instance and return them + */ + public function generatedHashedRecoveryCodes(array $codes): array + { + // Use the current default hash instance for hashing the recovery codes + $hashInstance = $this->passwordHashFactory->getDefaultHashInstance($this->mode); + + foreach ($codes as &$code) { + $code = $hashInstance->getHashedPassword($code); + } + unset($code); + return $codes; + } + + /** + * Compare given recovery code against all hashed codes and + * unset the corresponding code on success. + */ + public function verifyRecoveryCode(string $recoveryCode, array &$codes): bool + { + if ($codes === []) { + return false; + } + + // Get the hash instance which was initially used to generate these codes. + // This could differ from the current default hash instance. We however only need + // to check the first code since recovery codes can not be generated individually. + $hasInstance = $this->passwordHashFactory->get(reset($codes), $this->mode); + + foreach ($codes as $key => $code) { + // Compare hashed codes + if ($hasInstance->checkPassword($recoveryCode, $code)) { + // Unset the matching code + unset($codes[$key]); + return true; + } + } + return false; + } +} diff --git a/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php b/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php new file mode 100644 index 0000000..2484dee --- /dev/null +++ b/Classes/Authentication/Mfa/Provider/RecoveryCodesProvider.php @@ -0,0 +1,376 @@ +getRecoveryCode($request) !== ''; + } + + /** + * Evaluate if the provider is activated by checking the + * active state from the provider properties. This provider + * furthermore has a mannerism that it only works if at least + * one other MFA provider is activated for the user. + */ + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return $propertyManager->getProperty('active') + && $this->activeProvidersExist($propertyManager); + } + + /** + * Evaluate if the provider is temporarily locked by checking + * the current attempts state from the provider properties and + * if there are still recovery codes left. + */ + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + $attempts = (int)$propertyManager->getProperty('attempts', 0); + $codes = (array)$propertyManager->getProperty('codes', []); + // Assume the provider is locked in case either the maximum attempts are exceeded or no codes + // are available. A provider however can only be locked if set up - an entry exists in database. + return $propertyManager->hasProviderEntry() && ($attempts >= self::MAX_ATTEMPTS || $codes === []); + } + + /** + * Verify the given recovery code and remove it from the + * provider properties if valid. + */ + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) { + // Can not verify an inactive or locked provider + return false; + } + + $recoveryCode = $this->getRecoveryCode($request); + $codes = $propertyManager->getProperty('codes', []); + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager)); + if (!$recoveryCodes->verifyRecoveryCode($recoveryCode, $codes)) { + $attempts = $propertyManager->getProperty('attempts', 0); + $propertyManager->updateProperties(['attempts' => ++$attempts]); + return false; + } + + // Since the codes were passed by reference to the verify method, the matching code was + // unset so we simply need to write the array back. However, if the update fails, we must + // return FALSE even if the authentication was successful to prevent data inconsistency. + return $propertyManager->updateProperties([ + 'codes' => $codes, + 'attempts' => 0, + 'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp'), + ]); + } + + /** + * Render the provider specific response for the given content type + * + * @throws PropagateResponseException + */ + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + MfaViewType $type + ): ResponseInterface { + $viewFactoryData = new ViewFactoryData( + templateRootPaths: ['EXT:core/Resources/Private/Templates'], + partialRootPaths: ['EXT:core/Resources/Private/Partials'], + layoutRootPaths: ['EXT:core/Resources/Private/Layouts'], + request: $request, + ); + switch ($type) { + case MfaViewType::SETUP: + if (!$this->activeProvidersExist($propertyManager)) { + // If no active providers are present for the current user, add a flash message and redirect + $lang = $this->getLanguageService(); + $this->addFlashMessage( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.message'), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.title'), + ContextualFeedbackSeverity::WARNING + ); + if (($normalizedParams = $request->getAttribute('normalizedParams'))) { + $returnUrl = $normalizedParams->getHttpReferer(); + } else { + // @todo this will not work for FE - make this more generic! + $returnUrl = $this->uriBuilder->buildUriFromRoute('mfa'); + } + throw new PropagateResponseException(new RedirectResponse($returnUrl, 303), 1612883326); + } + $codes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatePlainRecoveryCodes(); + $view = $this->viewFactory->create($viewFactoryData); + $view->assignMultiple([ + 'providerIdentifier' => $propertyManager->getIdentifier(), + 'recoveryCodes' => implode(PHP_EOL, $codes), + // Generate hmac of the recovery codes to prevent them from being changed in the setup from + 'checksum' => $this->hashService->hmac(json_encode($codes) ?: '', 'recovery-codes-setup', HashAlgo::SHA3_256), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Setup')); + case MfaViewType::EDIT: + $view = $this->viewFactory->create($viewFactoryData); + $view->assignMultiple([ + 'providerIdentifier' => $propertyManager->getIdentifier(), + 'name' => $propertyManager->getProperty('name'), + 'amountOfCodesLeft' => count($propertyManager->getProperty('codes', [])), + 'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)), + 'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Edit')); + default: // MfaViewType::AUTH + $view = $this->viewFactory->create($viewFactoryData); + $view->assignMultiple([ + 'providerIdentifier' => $propertyManager->getIdentifier(), + 'isLocked' => $this->isLocked($propertyManager), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/RecoveryCodes/Auth')); + } + } + + /** + * Activate the provider by hashing and storing the given recovery codes + */ + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if ($this->isActive($propertyManager)) { + // Can not activate an active provider + return false; + } + + if (!$this->activeProvidersExist($propertyManager)) { + // Can not activate since no other provider is activated yet + return false; + } + + $recoveryCodes = GeneralUtility::trimExplode(PHP_EOL, (string)($request->getParsedBody()['recoveryCodes'] ?? '')); + $checksum = (string)($request->getParsedBody()['checksum'] ?? ''); + if ($recoveryCodes === [] + || !hash_equals($this->hashService->hmac(json_encode($recoveryCodes) ?: '', 'recovery-codes-setup', HashAlgo::SHA3_256), $checksum) + ) { + // Return since the request does not contain the initially created recovery codes + return false; + } + + // Hash given plain recovery codes and prepare the properties array with active state and custom name + $hashedCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatedHashedRecoveryCodes($recoveryCodes); + $properties = ['codes' => $hashedCodes, 'active' => true]; + if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') { + $properties['name'] = $name; + } + + // Usually there should be no entry if the provider is not activated, but to prevent the + // provider from being unable to activate again, we update the existing entry in such case. + return $propertyManager->hasProviderEntry() + ? $propertyManager->updateProperties($properties) + : $propertyManager->createProviderEntry($properties); + } + + /** + * Handle the deactivate action by removing the provider entry + */ + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + // Only check for the active property here to enable bulk deactivation, + // e.g. in FormEngine. Otherwise, it would not be possible to deactivate + // this provider if the last "fully" provider was deactivated before. + if (!(bool)$propertyManager->getProperty('active')) { + // Can not deactivate an inactive provider + return false; + } + // Delete the provider entry + return $propertyManager->deleteProviderEntry(); + } + + /** + * Handle the unlock action by resetting the attempts + * provider property and issuing new codes. + */ + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) { + // Can not unlock an inactive or not locked provider + return false; + } + + // Reset attempts + if ((int)$propertyManager->getProperty('attempts', 0) !== 0 + && !$propertyManager->updateProperties(['attempts' => 0]) + ) { + // Could not reset the attempts, so we can not unlock the provider + return false; + } + + // Regenerate codes + if ($propertyManager->getProperty('codes', []) === []) { + // Generate new codes and store the hashed ones + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes(); + if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) { + // Codes could not be stored, so we can not unlock the provider + return false; + } + // Add the newly generated codes to a flash message so the user can copy them + $lang = $this->getLanguageService(); + $this->addFlashMessage( + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.message'), + implode(' ', array_keys($recoveryCodes)) + ), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.title'), + ContextualFeedbackSeverity::WARNING + ); + } + + return true; + } + + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) { + // Can not update an inactive or locked provider + return false; + } + + $name = (string)($request->getParsedBody()['name'] ?? ''); + if ($name !== '' && !$propertyManager->updateProperties(['name' => $name])) { + return false; + } + + if ((bool)($request->getParsedBody()['regenerateCodes'] ?? false)) { + // Generate new codes and store the hashed ones + $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes(); + if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) { + // Codes could not be stored, so we can not update the provider + return false; + } + // Add the newly generated codes to a flash message so the user can copy them + $lang = $this->getLanguageService(); + $this->addFlashMessage( + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.message'), + implode(' ', array_keys($recoveryCodes)) + ), + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.title'), + ContextualFeedbackSeverity::OK + ); + } + + // Provider properties successfully updated + return true; + } + + /** + * Check if the current user has other active providers + */ + private function activeProvidersExist(MfaProviderPropertyManager $currentPropertyManager): bool + { + $user = $currentPropertyManager->getUser(); + foreach ($this->mfaProviderRegistry->getProviders() as $identifier => $provider) { + $propertyManager = MfaProviderPropertyManager::create($provider, $user); + if ($identifier !== $currentPropertyManager->getIdentifier() && $provider->isActive($propertyManager)) { + return true; + } + } + return false; + } + + /** + * Internal helper method for fetching the recovery code from the request + */ + private function getRecoveryCode(ServerRequestInterface $request): string + { + return trim((string)($request->getQueryParams()['rc'] ?? $request->getParsedBody()['rc'] ?? '')); + } + + /** + * Determine the mode (used for the hash instance) based on the current users table + */ + private function getMode(MfaProviderPropertyManager $propertyManager): string + { + return $propertyManager->getUser()->loginType; + } + + /** + * Add a custom flash message for this provider + * Note: The flash messages added by the main controller are still shown to the user. + */ + private function addFlashMessage(string $message, string $title = '', ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::INFO): void + { + $this->flashMessageService->getMessageQueueByIdentifier()->enqueue( + new FlashMessage($message, $title, $severity, true) + ); + } + + /** + * Return the timestamp as local time (date string) by applying the globally configured format + */ + private function getDateTime(int $timestamp): string + { + if ($timestamp === 0) { + return ''; + } + return date( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], + $timestamp + ) ?: ''; + } + + private function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Authentication/Mfa/Provider/Totp.php b/Classes/Authentication/Mfa/Provider/Totp.php new file mode 100644 index 0000000..01c2a86 --- /dev/null +++ b/Classes/Authentication/Mfa/Provider/Totp.php @@ -0,0 +1,195 @@ +algo, self::ALLOWED_ALGOS, true)) { + throw new \InvalidArgumentException( + $this->algo . ' is not allowed. Allowed algos are: ' . implode(',', self::ALLOWED_ALGOS), + 1611748791 + ); + } + + if ($this->length < self::MIN_LENGTH || $this->length > self::MAX_LENGTH) { + throw new \InvalidArgumentException( + $this->length . ' is not allowed as TOTP length. Must be between ' . self::MIN_LENGTH . ' and ' . self::MAX_LENGTH, + 1611748792 + ); + } + } + + /** + * Generate a time-based one-time password for the given counter according to rfc4226 + * + * @param int $counter A timestamp (counter) according to rfc6238 + * @return string The generated TOTP + */ + public function generateTotp(int $counter): string + { + // Generate a 8-byte counter value (C) from the given counter input + $binary = []; + while ($counter !== 0) { + $binary[] = pack('C*', $counter); + $counter >>= 8; + } + // Implode and fill with NULL values + $binary = str_pad(implode(array_reverse($binary)), 8, "\000", STR_PAD_LEFT); + // Create a 20-byte hash string (HS) with given algo and decoded shared secret (K) + $hash = hash_hmac($this->algo, $binary, $this->getDecodedSecret()); + // Convert hash into hex and generate an array with the decimal values of the hash + $hmac = []; + foreach (str_split($hash, 2) as $hex) { + $hmac[] = hexdec($hex); + } + // Generate a 4-byte string with dynamic truncation (DT) + $offset = $hmac[count($hmac) - 1] & 0xf; + $bits = ((($hmac[$offset + 0] & 0x7f) << 24) | (($hmac[$offset + 1] & 0xff) << 16) | (($hmac[$offset + 2] & 0xff) << 8) | ($hmac[$offset + 3] & 0xff)); + // Compute the TOTP value by reducing the bits modulo 10^Digits and filling it with zeros '0' + return str_pad((string)($bits % (10 ** $this->length)), $this->length, '0', STR_PAD_LEFT); + } + + /** + * Verify the given time-based one-time password + * + * @param string $totp The time-based one-time password to be verified + * @param int|null $gracePeriod The grace period for the TOTP +- (mainly to circumvent transmission delays) + */ + public function verifyTotp(string $totp, ?int $gracePeriod = null): bool + { + $counter = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + + // If no grace period is given, only check once + if ($gracePeriod === null) { + return $this->compare($totp, $this->getTimeCounter($counter)); + } + + // Check the token within the given grace period till it can be verified or the grace period is exhausted + for ($i = 0; $i < $gracePeriod; ++$i) { + $next = $i * $this->step + $counter; + $prev = $counter - $i * $this->step; + if ($this->compare($totp, $this->getTimeCounter($next)) + || $this->compare($totp, $this->getTimeCounter($prev)) + ) { + return true; + } + } + + return false; + } + + /** + * Generate and return the otpauth URL for TOTP + */ + public function getTotpAuthUrl(string $issuer, string $account = '', array $additionalParameters = []): string + { + $parameters = [ + 'secret' => $this->secret, + 'issuer' => htmlspecialchars($issuer), + ]; + + // Common OTP applications expect the following parameters: + // - algo: sha1 + // - period: 30 (in seconds) + // - digits 6 + // - epoch: 0 + // Only if we differ from these assumption, the exact values must be provided. + if ($this->algo !== 'sha1') { + $parameters['algorithm'] = $this->algo; + } + if ($this->step !== 30) { + $parameters['period'] = $this->step; + } + if ($this->length !== 6) { + $parameters['digits'] = $this->length; + } + if ($this->epoch !== 0) { + $parameters['epoch'] = $this->epoch; + } + + // Generate the otpauth URL by providing information like issuer and account + return sprintf( + 'otpauth://totp/%s?%s', + rawurlencode($issuer . ($account !== '' ? ':' . $account : '')), + http_build_query(array_merge($parameters, $additionalParameters), '', '&', PHP_QUERY_RFC3986) + ); + } + + /** + * Compare given time-based one-time password with a time-based one-time + * password generated from the known $counter (the moving factor). + * + * @param string $totp The time-based one-time password to verify + * @param int $counter The counter value, the moving factor + */ + protected function compare(string $totp, int $counter): bool + { + return hash_equals($this->generateTotp($counter), $totp); + } + + /** + * Generate the counter value (moving factor) from the given timestamp + */ + protected function getTimeCounter(int $timestamp): int + { + return (int)floor(($timestamp - $this->epoch) / $this->step); + } + + /** + * Generate the shared secret (K) by using a random and applying + * additional authentication factors like username or email address. + */ + public static function generateEncodedSecret(array $additionalAuthFactors = []): string + { + $secret = ''; + $payload = implode($additionalAuthFactors); + // Prevent secrets with a trailing pad character since this will eventually break the QR-code feature + while ($secret === '' || str_contains($secret, '=')) { + // RFC 4226 (https://tools.ietf.org/html/rfc4226#section-4) suggests 160 bit TOTP secret keys + // HMAC-SHA1 based on static factors and a 160 bit HMAC-key lead again to 160 bits (20 bytes) + // base64-encoding (factor 1.6) 20 bytes lead to 32 uppercase characters + $secret = Base32::encode(hash_hmac('sha1', $payload, random_bytes(20), true)); + } + return $secret; + } + + protected function getDecodedSecret(): string + { + return Base32::decode($this->secret); + } +} diff --git a/Classes/Authentication/Mfa/Provider/TotpProvider.php b/Classes/Authentication/Mfa/Provider/TotpProvider.php new file mode 100644 index 0000000..62bb971 --- /dev/null +++ b/Classes/Authentication/Mfa/Provider/TotpProvider.php @@ -0,0 +1,276 @@ +getTotp($request) !== ''; + } + + /** + * Evaluate if the provider is activated by checking the + * active state and the secret from the provider properties. + */ + public function isActive(MfaProviderPropertyManager $propertyManager): bool + { + return (bool)$propertyManager->getProperty('active') + && $propertyManager->getProperty('secret', '') !== ''; + } + + /** + * Evaluate if the provider is temporarily locked by checking + * the current attempts state from the provider properties. + */ + public function isLocked(MfaProviderPropertyManager $propertyManager): bool + { + $attempts = (int)$propertyManager->getProperty('attempts', 0); + // Assume the provider is locked in case the maximum attempts are exceeded. + // A provider however can only be locked if set up - an entry exists in database. + return $propertyManager->hasProviderEntry() && $attempts >= self::MAX_ATTEMPTS; + } + + /** + * Verify the given TOTP and update the provider properties in case the TOTP is valid. + */ + public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) { + // Can not verify an inactive or locked provider + return false; + } + + $totp = $this->getTotp($request); + $secret = $propertyManager->getProperty('secret', ''); + $verified = GeneralUtility::makeInstance(Totp::class, $secret)->verifyTotp($totp, 2); + if (!$verified) { + $attempts = $propertyManager->getProperty('attempts', 0); + $propertyManager->updateProperties(['attempts' => ++$attempts]); + return false; + } + $propertyManager->updateProperties([ + 'attempts' => 0, + 'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp'), + ]); + return true; + } + + /** + * Activate the provider by checking the necessary parameters, + * verifying the TOTP and storing the provider properties. + */ + public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if ($this->isActive($propertyManager)) { + // Can not activate an active provider + return false; + } + + if (!$this->canProcess($request)) { + // Return since the request can not be processed by this provider + return false; + } + + $secret = (string)($request->getParsedBody()['secret'] ?? ''); + $checksum = (string)($request->getParsedBody()['checksum'] ?? ''); + if ($secret === '' || !hash_equals($this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256), $checksum)) { + // Return since the request does not contain the initially created secret + return false; + } + + $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret); + if (!$totpInstance->verifyTotp($this->getTotp($request), 2)) { + // Return since the given TOTP could not be verified + return false; + } + + // If valid, prepare the provider properties to be stored + $properties = ['secret' => $secret, 'active' => true]; + if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') { + $properties['name'] = $name; + } + + // Usually there should be no entry if the provider is not activated, but to prevent the + // provider from being unable to activate again, we update the existing entry in such case. + return $propertyManager->hasProviderEntry() + ? $propertyManager->updateProperties($properties) + : $propertyManager->createProviderEntry($properties); + } + + /** + * Handle the save action by updating the provider properties + */ + public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) { + // Can not update an inactive or locked provider + return false; + } + $name = (string)($request->getParsedBody()['name'] ?? ''); + if ($name !== '') { + return $propertyManager->updateProperties(['name' => $name]); + } + // Provider properties successfully updated + return true; + } + + /** + * Handle the unlock action by resetting the attempts provider property + */ + public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) { + // Can not unlock an inactive or not locked provider + return false; + } + // Reset the attempts + return $propertyManager->updateProperties(['attempts' => 0]); + } + + /** + * Handle the deactivate action. For security reasons, the provider entry + * is completely deleted and setting up this provider again, will therefore + * create a brand-new entry. + */ + public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool + { + if (!$this->isActive($propertyManager)) { + // Can not deactivate an inactive provider + return false; + } + // Delete the provider entry + return $propertyManager->deleteProviderEntry(); + } + + /** + * Initialize view and forward to the appropriate implementation + * based on the view type to be returned. + */ + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + MfaViewType $type + ): ResponseInterface { + $viewFactoryData = new ViewFactoryData( + templateRootPaths: ['EXT:core/Resources/Private/Templates'], + partialRootPaths: ['EXT:core/Resources/Private/Partials'], + layoutRootPaths: ['EXT:core/Resources/Private/Layouts'], + request: $request, + ); + $view = $this->viewFactory->create($viewFactoryData); + switch ($type) { + case MfaViewType::SETUP: + // Generate a new shared secret, generate the otpauth URL and create a qr-code for improved usability. + $userData = $propertyManager->getUser()->user ?? []; + $secret = Totp::generateEncodedSecret([(string)($userData['uid'] ?? ''), (string)($userData['username'] ?? '')]); + $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret); + $totpAuthUrl = $totpInstance->getTotpAuthUrl( + (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3'), + (string)($userData['email'] ?? '') ?: (string)($userData['username'] ?? '') + ); + $view->assignMultiple([ + 'secret' => $secret, + 'totpAuthUrl' => $totpAuthUrl, + 'qrCode' => $this->getSvgQrCode($totpAuthUrl), + // Generate hmac of the secret to prevent it from being changed in the setup from + 'checksum' => $this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256), + 'providerIdentifier' => $propertyManager->getIdentifier(), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Setup')); + case MfaViewType::EDIT: + $view->assignMultiple([ + 'name' => $propertyManager->getProperty('name'), + 'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)), + 'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)), + 'providerIdentifier' => $propertyManager->getIdentifier(), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Edit')); + default: // MfaViewType::AUTH + $view->assignMultiple([ + 'isLocked' => $this->isLocked($propertyManager), + 'providerIdentifier' => $propertyManager->getIdentifier(), + ]); + return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Auth')); + } + } + + /** + * Internal helper method for fetching the TOTP from the request + */ + private function getTotp(ServerRequestInterface $request): string + { + return trim((string)($request->getQueryParams()['totp'] ?? $request->getParsedBody()['totp'] ?? '')); + } + + /** + * Internal helper method for generating a svg QR-code for TOTP applications + */ + private function getSvgQrCode(string $content): string + { + $qrCodeRenderer = new ImageRenderer(new RendererStyle(225, 4), new SvgImageBackEnd()); + return (new Writer($qrCodeRenderer))->writeString($content); + } + + /** + * Return the timestamp as local time (date string) by applying the globally configured format + */ + private function getDateTime(int $timestamp): string + { + if ($timestamp === 0) { + return ''; + } + return date( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], + $timestamp + ) ?: ''; + } +} diff --git a/Classes/Authentication/MimicServiceInterface.php b/Classes/Authentication/MimicServiceInterface.php new file mode 100644 index 0000000..07f33cc --- /dev/null +++ b/Classes/Authentication/MimicServiceInterface.php @@ -0,0 +1,35 @@ +settings); + } + + public function get(string $id): mixed + { + if (array_key_exists($id, $this->settings)) { + return $this->settings[$id]; + } + throw new UserSettingsNotFoundException( + 'User setting "' . $id . '" is not available.', + 1738500000 + ); + } + + public function toArray(): array + { + return $this->settings; + } + + public function isEmailMeAtLoginEnabled(): bool + { + return (bool)($this->settings['emailMeAtLogin'] ?? false); + } + + public function isUploadFieldsInTopOfEBEnabled(): bool + { + return (bool)($this->settings['edit_docModuleUpload'] ?? true); + } +} diff --git a/Classes/Authentication/UserSettingsFactory.php b/Classes/Authentication/UserSettingsFactory.php new file mode 100644 index 0000000..9d8fd94 --- /dev/null +++ b/Classes/Authentication/UserSettingsFactory.php @@ -0,0 +1,91 @@ +schema = $schema ?? GeneralUtility::makeInstance(UserSettingsSchema::class); + } + + public function createFromUserRecord(array $userRecord, array $uc = []): UserSettings + { + $dbColumnSettings = $this->extractDbColumnSettings($userRecord); + $jsonFieldSettings = $this->extractJsonFieldSettings($userRecord, $uc); + + return new UserSettings(array_merge($dbColumnSettings, $jsonFieldSettings)); + } + + public function createFromUc(array $uc): UserSettings + { + $settings = []; + foreach ($this->schema->getJsonFieldSettingKeys() as $key) { + if (array_key_exists($key, $uc)) { + $settings[$key] = $uc[$key]; + } + } + + return new UserSettings($settings); + } + + private function extractJsonFieldSettings(array $userRecord, array $uc): array + { + $settings = []; + + // Primary source: user_settings JSON field + if (!empty($userRecord['user_settings'])) { + $decoded = is_string($userRecord['user_settings']) + ? json_decode($userRecord['user_settings'], true) + : $userRecord['user_settings']; + if (is_array($decoded)) { + $settings = $decoded; + } + } + + // Fallback: fill missing values from uc (migration period) + foreach ($this->schema->getJsonFieldSettingKeys() as $key) { + if (!array_key_exists($key, $settings) && array_key_exists($key, $uc)) { + $settings[$key] = $uc[$key]; + } + } + + return $settings; + } + + private function extractDbColumnSettings(array $userRecord): array + { + $settings = []; + + foreach ($this->schema->getDbColumnSettingKeys() as $key) { + if (array_key_exists($key, $userRecord)) { + $settings[$key] = $userRecord[$key]; + } + } + + return $settings; + } +} diff --git a/Classes/Authentication/UserSettingsSchema.php b/Classes/Authentication/UserSettingsSchema.php new file mode 100644 index 0000000..110bfb3 --- /dev/null +++ b/Classes/Authentication/UserSettingsSchema.php @@ -0,0 +1,363 @@ + + */ + public function getColumns(): array + { + $columns = []; + + $tcaColumns = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] ?? []; + foreach ($tcaColumns as $fieldName => $tcaConfig) { + $columns[$fieldName] = $this->resolveTcaColumn($fieldName, $tcaConfig); + } + + return $columns; + } + + /** + * Get configuration for a specific field in legacy format. + */ + public function getColumn(string $fieldName): ?array + { + $tcaConfig = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'][$fieldName] ?? null; + if ($tcaConfig !== null) { + return $this->resolveTcaColumn($fieldName, $tcaConfig); + } + + return null; + } + + /** + * Returns a "fake TCA" for the be_users_settings pseudo-table. + */ + public function getTca(): array + { + $columns = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] ?? []; + foreach ($columns as $fieldName => $columnConfig) { + $partitionedFieldName = $this->getTcaFieldName($fieldName); + $columns[$partitionedFieldName] = $this->resolveInheritFromParent($fieldName, $columnConfig); + } + + return [ + 'be_users_settings' => [ + 'ctrl' => [ + 'title' => 'backend.user_profile:user_settings', + ], + 'columns' => $columns, + 'types' => [ + '0' => [ + 'showitem' => $this->getTcaShowitem(), + ], + ], + ], + ]; + } + + /** + * Returns a partitioned field name for use in TCA. + * - e.g. `be_users__password`, reflecting `be_users` values + * - e.g. `user_settings__titleLen`, reflecting JSON values + */ + public function getTcaFieldName(string $fieldName): string + { + $configuration = $this->getColumn($fieldName); + $partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings'; + return $partition . '__' . $fieldName; + } + + private function resolveTcaFieldName(string $fieldName, bool $strict = true): string + { + $configuration = $this->getColumn($fieldName); + if ($configuration !== null) { + $partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings'; + return $partition . '__' . $fieldName; + } + if (!$strict) { + return $fieldName; + } + throw new \LogicException( + sprintf( + 'Column "%s" not found in UserSettingsSchema', + $fieldName + ), + 1776439141 + ); + } + + /** + * Get the partitioned showitem string to be used as virtual TCA. + */ + public function getTcaShowitem(): string + { + $items = GeneralUtility::trimExplode(',', $this->getRawShowitem(), true); + $items = array_map( + fn(string $fieldName): string => $this->resolveTcaFieldName($fieldName, false), + $items + ); + return implode(',', $items); + } + + /** + * Get the raw showitem string. + */ + public function getRawShowitem(): string + { + return trim($GLOBALS['TCA']['be_users']['columns']['user_settings']['showitem'] ?? ''); + } + + /** + * @return list + */ + public function getJsonFieldSettingKeys(): array + { + $keys = []; + foreach ($this->getColumns() as $key => $config) { + // Fields with 'table' => 'be_users' are stored in be_users columns directly + // Also skip non-storable types like 'button' and 'mfa' + $type = $config['type'] ?? 'text'; + if (($config['table'] ?? '') !== 'be_users' + && !in_array($type, ['button', 'mfa'], true) + ) { + $keys[] = $key; + } + } + return $keys; + } + + /** + * @return string[] + */ + public function getDbColumnSettingKeys(): array + { + $keys = []; + foreach ($this->getColumns() as $key => $config) { + $type = $config['type'] ?? 'text'; + if (($config['table'] ?? '') === 'be_users' + && !in_array($type, ['button', 'mfa', 'password'], true) + ) { + $keys[] = $key; + } + } + return $keys; + } + + public function isJsonFieldSetting(string $key): bool + { + return in_array($key, $this->getJsonFieldSettingKeys(), true); + } + + public function isDbColumnSetting(string $key): bool + { + return in_array($key, $this->getDbColumnSettingKeys(), true); + } + + /** + * Returns field names that should trigger a JS persistent storage update + * when their value changes in User Settings, so JS components can react + * immediately without a page reload. + * + * @return string[] + */ + public function getPersistentUpdateFieldNames(): array + { + $keys = []; + foreach ($this->getColumns() as $key => $config) { + if (!empty($config['persistentUpdate'])) { + $keys[] = $key; + } + } + return $keys; + } + + public function getDefault(string $key): mixed + { + $config = $this->getColumn($key); + return $config['default'] ?? null; + } + + /** + * Resolves inheritFromParent for a TCA column by merging with the + * parent be_users TCA column configuration. Returns TCA format + * (without legacy conversion). + * + * @internal + */ + public function resolveInheritFromParent(string $fieldName, array $tcaConfig): array + { + if (!empty($tcaConfig['inheritFromParent'])) { + $parentConfig = $GLOBALS['TCA']['be_users']['columns'][$fieldName] ?? []; + $tcaConfig = array_replace_recursive($parentConfig, $tcaConfig); + unset($tcaConfig['inheritFromParent']); + } + return $tcaConfig; + } + + /** + * Resolves a TCA column configuration, handling inheritFromParent, + * and converts to legacy format. + */ + private function resolveTcaColumn(string $fieldName, array $tcaConfig): array + { + return $this->convertTcaToLegacyFormat($fieldName, $this->resolveInheritFromParent($fieldName, $tcaConfig)); + } + + /** + * Converts a TCA column configuration to legacy format. + */ + private function convertTcaToLegacyFormat(string $fieldName, array $tcaConfig): array + { + $legacyConfig = [ + 'label' => $tcaConfig['label'] ?? '', + ]; + + $config = $tcaConfig['config'] ?? []; + $tcaType = $config['type'] ?? 'input'; + $renderType = $config['renderType'] ?? ''; + + // Determine if this field is stored in be_users table + // Fields with inheritFromParent that exist in be_users columns are table fields + if (isset($GLOBALS['TCA']['be_users']['columns'][$fieldName])) { + $legacyConfig['table'] = 'be_users'; + } + + // Convert TCA type to legacy type + switch ($tcaType) { + case 'input': + $legacyConfig['type'] = 'text'; + if (isset($config['max'])) { + $legacyConfig['max'] = $config['max']; + } + break; + + case 'email': + $legacyConfig['type'] = 'email'; + if (isset($config['max'])) { + $legacyConfig['max'] = $config['max']; + } + break; + + case 'number': + $legacyConfig['type'] = 'number'; + break; + + case 'password': + $legacyConfig['type'] = 'password'; + break; + + case 'check': + $legacyConfig['type'] = 'check'; + break; + + case 'select': + $legacyConfig['type'] = 'select'; + if (isset($config['items'])) { + $legacyConfig['items'] = $this->convertSelectItemsToLegacy($config['items']); + } + if (isset($config['itemsProcFunc'])) { + $legacyConfig['itemsProcFunc'] = $config['itemsProcFunc']; + } + break; + + case 'language': + $legacyConfig['type'] = 'language'; + break; + + case 'file': + $legacyConfig['type'] = 'avatar'; + break; + + case 'button': + $legacyConfig['type'] = 'button'; + if (isset($config['buttonLabel'])) { + $legacyConfig['buttonlabel'] = $config['buttonLabel']; + } + if (isset($config['confirm'])) { + $legacyConfig['confirm'] = $config['confirm']; + } + if (isset($config['confirmData'])) { + $legacyConfig['confirmData'] = $config['confirmData']; + } + break; + + case 'mfa': + $legacyConfig['type'] = 'mfa'; + break; + + case 'user': + $legacyConfig['type'] = 'user'; + if (isset($config['renderType'])) { + $legacyConfig['userFunc'] = $config['renderType']; + } + break; + + default: + $legacyConfig['type'] = 'text'; + } + + // Copy additional properties + if (isset($config['default'])) { + $legacyConfig['default'] = $config['default']; + } + if (isset($tcaConfig['access'])) { + $legacyConfig['access'] = $tcaConfig['access']; + } + if (!empty($tcaConfig['persistentUpdate'])) { + $legacyConfig['persistentUpdate'] = true; + } + + return $legacyConfig; + } + + /** + * Converts TCA select items format to legacy format. + * Handles both TCA format ([['label' => '...', 'value' => '...'], ...]) + * and legacy format (['value' => 'label', ...]). + */ + private function convertSelectItemsToLegacy(array $items): array + { + $legacyItems = []; + foreach ($items as $key => $item) { + if (is_array($item) && isset($item['value']) && isset($item['label'])) { + // TCA format: [['label' => '...', 'value' => '...'], ...] + $legacyItems[$item['value']] = $item['label']; + } elseif (is_string($item)) { + // Legacy format: ['value' => 'label', ...] + $legacyItems[$key] = $item; + } + } + return $legacyItems; + } +} diff --git a/Classes/Cache/Backend/AbstractBackend.php b/Classes/Cache/Backend/AbstractBackend.php new file mode 100644 index 0000000..e92ee2c --- /dev/null +++ b/Classes/Cache/Backend/AbstractBackend.php @@ -0,0 +1,76 @@ + $optionValue) { + $methodName = 'set' . ucfirst($optionKey); + if (method_exists($this, $methodName)) { + $this->{$methodName}($optionValue); + } else { + throw new \InvalidArgumentException('Invalid cache backend option "' . $optionKey . '" for backend of type "' . static::class . '"', 1231267498); + } + } + // Init logger. This is forces, even if $options['logger'] has been set, which shouldn't. + $this->logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(static::class); + } + + public function setCache(FrontendInterface $cache): void + { + $this->cacheIdentifier = $cache->getIdentifier(); + } + + /** + * Sets the default lifetime for this cache backend + * + * @param int $defaultLifetime Default lifetime of this cache backend in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime. + * @internal Misused for testing purposes. + * @todo: Fix tests and protect or remove + */ + public function setDefaultLifetime(int $defaultLifetime): void + { + if ($defaultLifetime < 0) { + throw new \InvalidArgumentException('The default lifetime must be given as a positive integer.', 1233072774); + } + $this->defaultLifetime = $defaultLifetime; + } +} diff --git a/Classes/Cache/Backend/ApcuBackend.php b/Classes/Cache/Backend/ApcuBackend.php new file mode 100644 index 0000000..038387e --- /dev/null +++ b/Classes/Cache/Backend/ApcuBackend.php @@ -0,0 +1,212 @@ + get content by identifier) + * - ident_xxx + * xxx is identifier, value is array of associated tags. This is "reverse" tag + * index. It provides quick access for all tags associated with this identifier + * and used when removing the identifier + * + * Each key is prepended with a prefix. The prefix makes sure keys from the different + * installations do not conflict. By default, prefix consists from two parts + * separated by underscore character and ends in yet another underscore character: + * - "TYPO3" + * - Hash of path to TYPO3 and user running TYPO3 + */ +final class ApcuBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface +{ + /** + * A prefix to separate stored data from other data possible stored in the APC. + */ + private string $identifierPrefix = ''; + + /** + * Constructs this backend + * + * @param array $options Configuration options - unused here + */ + public function __construct(array $options = []) + { + if (!extension_loaded('apcu')) { + throw new Exception('The PHP extension "apcu" must be installed and loaded in order to use the APCu backend.', 1232985914); + } + if (PHP_SAPI === 'cli' && ini_get('apc.enable_cli') == 0) { + throw new Exception('The APCu backend cannot be used because apcu is disabled on CLI.', 1232985915); + } + parent::__construct($options); + } + + public function setCache(FrontendInterface $cache): void + { + parent::setCache($cache); + $this->identifierPrefix = 'TYPO3_' . hash('xxh3', Environment::getProjectPath() . $cache->getIdentifier()) . '_'; + } + + /** + * @param mixed $data The data to be stored. mixed is allowed due to TransientBackendInterface + */ + public function set(string $entryIdentifier, mixed $data, array $tags = [], ?int $lifetime = null): void + { + $lifetime ??= $this->defaultLifetime; + $success = apcu_store($this->identifierPrefix . $entryIdentifier, $data, $lifetime); + if ($success === true) { + $this->removeIdentifierFromAllTags($entryIdentifier); + $this->addIdentifierToTags($entryIdentifier, $tags); + } else { + $this->logger->alert('Error using APCu: Could not save data in the cache.'); + } + } + + /** + * Loads data from the cache. + * + * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded + */ + public function get(string $entryIdentifier): mixed + { + $success = false; + $value = apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); + return $success ? $value : $success; + } + + public function has(string $entryIdentifier): bool + { + $success = false; + apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); + return $success; + } + + /** + * Removes all cache entries matching the specified identifier. + * Usually this only affects one entry but if - for what reason ever - + * old entries for the identifier still exist, they are removed as well. + * + * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found + */ + public function remove(string $entryIdentifier): bool + { + $this->removeIdentifierFromAllTags($entryIdentifier); + return apcu_delete($this->identifierPrefix . $entryIdentifier); + } + + public function findIdentifiersByTag(string $tag): array + { + $success = false; + $identifiers = apcu_fetch($this->identifierPrefix . 'tag_' . $tag, $success); + if ($success === false) { + return []; + } + return (array)$identifiers; + } + + public function flush(): void + { + apcu_delete(new \APCUIterator('/^' . preg_quote($this->identifierPrefix, '/') . '/')); + } + + public function flushByTag(string $tag): void + { + $identifiers = $this->findIdentifiersByTag($tag); + foreach ($identifiers as $identifier) { + $this->remove($identifier); + } + } + + public function flushByTags(array $tags): void + { + array_walk($tags, $this->flushByTag(...)); + } + + public function collectGarbage(): void + { + // Noop, APCu has internal GC + } + + private function addIdentifierToTags(string $entryIdentifier, array $tags): void + { + // Get identifier-to-tag index to look for updates + $existingTags = $this->findTagsByIdentifier($entryIdentifier); + $existingTagsUpdated = false; + + foreach ($tags as $tag) { + // Update tag-to-identifier index + $identifiers = $this->findIdentifiersByTag($tag); + if (!in_array($entryIdentifier, $identifiers, true)) { + $identifiers[] = $entryIdentifier; + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); + } + // Test if identifier-to-tag index needs update + if (!in_array($tag, $existingTags, true)) { + $existingTags[] = $tag; + $existingTagsUpdated = true; + } + } + + // Update identifier-to-tag index if needed + if ($existingTagsUpdated) { + apcu_store($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags); + } + } + + private function removeIdentifierFromAllTags(string $entryIdentifier): void + { + // Get tags for this identifier + $tags = $this->findTagsByIdentifier($entryIdentifier); + // De-associate tags with this identifier + foreach ($tags as $tag) { + $identifiers = $this->findIdentifiersByTag($tag); + // Formally array_search() below should never return FALSE due to + // the behavior of findTagsByIdentifier(). But if reverse index is + // corrupted, we still can get 'FALSE' from array_search(). This is + // not a problem because we are removing this identifier from + // anywhere. + if (($key = array_search($entryIdentifier, $identifiers)) !== false) { + unset($identifiers[$key]); + if (!empty($identifiers)) { + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); + } else { + apcu_delete($this->identifierPrefix . 'tag_' . $tag); + } + } + } + // Clear reverse tag index for this identifier + apcu_delete($this->identifierPrefix . 'ident_' . $entryIdentifier); + } + + private function findTagsByIdentifier(string $identifier): array + { + $success = false; + $tags = apcu_fetch($this->identifierPrefix . 'ident_' . $identifier, $success); + return $success ? (array)$tags : []; + } +} diff --git a/Classes/Cache/Backend/BackendInterface.php b/Classes/Cache/Backend/BackendInterface.php new file mode 100644 index 0000000..6c029a9 --- /dev/null +++ b/Classes/Cache/Backend/BackendInterface.php @@ -0,0 +1,79 @@ +remove($entryIdentifier); + $temporaryCacheEntryPathAndFilename = $this->cacheDirectory . StringUtility::getUniqueId() . '.temp'; + $lifetime ??= $this->defaultLifetime; + $expiryTime = $lifetime === 0 ? 0 : (int)($GLOBALS['EXEC_TIME'] + $lifetime); + $metaData = str_pad((string)$expiryTime, self::EXPIRYTIME_LENGTH) . implode(' ', $tags) . str_pad((string)strlen($data), self::DATASIZE_DIGITS); + $result = GeneralUtility::writeFile($temporaryCacheEntryPathAndFilename, $data . $metaData, true); + if ($result === false) { + throw new Exception('The temporary cache file "' . $temporaryCacheEntryPathAndFilename . '" could not be written.', 1204026251); + } + $i = 0; + $cacheEntryPathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + while (($result = rename($temporaryCacheEntryPathAndFilename, $cacheEntryPathAndFilename)) === false && $i < 5) { + $i++; + } + if ($result === false) { + throw new Exception('The cache file "' . $cacheEntryPathAndFilename . '" could not be written.', 1222361632); + } + if ($this->cacheEntryFileExtension === '.php') { + GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($cacheEntryPathAndFilename); + } + } + + /** + * @return false|string The cache entry's content as a string or FALSE if the cache entry could not be loaded + */ + public function get(string $entryIdentifier): false|string + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073033); + } + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + if ($this->isCacheFileExpired($pathAndFilename)) { + return false; + } + $dataSize = (int)file_get_contents( + $pathAndFilename, + false, + null, + filesize($pathAndFilename) - self::DATASIZE_DIGITS, + self::DATASIZE_DIGITS + ); + return file_get_contents($pathAndFilename, false, null, 0, $dataSize); + } + + public function has(string $entryIdentifier): bool + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073034); + } + return !$this->isCacheFileExpired($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension); + } + + public function findIdentifiersByTag(string $tag): array + { + $entryIdentifiers = []; + $now = $GLOBALS['EXEC_TIME']; + $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension); + for ($directoryIterator = GeneralUtility::makeInstance(\DirectoryIterator::class, $this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) { + if (!$directoryIterator->isFile()) { + continue; + } + $cacheEntryPathAndFilename = $directoryIterator->getPathname(); + $index = (int)file_get_contents( + $cacheEntryPathAndFilename, + false, + null, + filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS, + self::DATASIZE_DIGITS + ); + $metaData = (string)file_get_contents($cacheEntryPathAndFilename, false, null, $index); + $expiryTime = (int)substr($metaData, 0, self::EXPIRYTIME_LENGTH); + if ($expiryTime !== 0 && $expiryTime < $now) { + continue; + } + if (in_array($tag, explode(' ', substr($metaData, self::EXPIRYTIME_LENGTH, -self::DATASIZE_DIGITS)))) { + if ($cacheEntryFileExtensionLength > 0) { + $entryIdentifiers[] = substr((string)$directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength); + } else { + $entryIdentifiers[] = $directoryIterator->getFilename(); + } + } + } + return $entryIdentifiers; + } + + public function flushByTag(string $tag): void + { + $identifiers = $this->findIdentifiersByTag($tag); + foreach ($identifiers as $entryIdentifier) { + $this->remove($entryIdentifier); + } + } + + public function flushByTags(array $tags): void + { + array_walk($tags, $this->flushByTag(...)); + } + + /** + * Checks if the given cache entry files are still valid or if their + * lifetime has exceeded. + */ + protected function isCacheFileExpired(string $cacheEntryPathAndFilename): bool + { + if (file_exists($cacheEntryPathAndFilename) === false) { + return true; + } + $index = (int)file_get_contents( + $cacheEntryPathAndFilename, + false, + null, + filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS, + self::DATASIZE_DIGITS + ); + $expiryTime = (int)file_get_contents($cacheEntryPathAndFilename, false, null, $index, self::EXPIRYTIME_LENGTH); + return $expiryTime !== 0 && $expiryTime < $GLOBALS['EXEC_TIME']; + } + + public function collectGarbage(): void + { + for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) { + if (!$directoryIterator->isFile()) { + continue; + } + if ($this->isCacheFileExpired($directoryIterator->getPathname())) { + $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension); + if ($cacheEntryFileExtensionLength > 0) { + $this->remove(substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength)); + } else { + $this->remove($directoryIterator->getFilename()); + } + } + } + } + + public function requireOnce(string $entryIdentifier): mixed + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073036); + } + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + return $this->isCacheFileExpired($pathAndFilename) ? false : require_once $pathAndFilename; + } + + public function require(string $entryIdentifier): mixed + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1532528246); + } + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + return $this->isCacheFileExpired($pathAndFilename) ? false : require $pathAndFilename; + } +} diff --git a/Classes/Cache/Backend/MemcachedBackend.php b/Classes/Cache/Backend/MemcachedBackend.php new file mode 100644 index 0000000..40a2572 --- /dev/null +++ b/Classes/Cache/Backend/MemcachedBackend.php @@ -0,0 +1,369 @@ + get content by identifier) + * - ident_xxx + * xxx is identifier, value is array of associated tags. This is "reverse" tag + * index. It provides quick access for all tags associated with this identifier + * and used when removing the identifier + * + * Each key is prepended with a prefix. By default prefix consists from two parts + * separated by underscore character and ends in yet another underscore character: + * - "TYPO3" + * - Current site path obtained from Environment::getProjectPath() + * This prefix makes sure that keys from the different installations do not + * conflict. + * + * Note: When using the Memcached backend to store values of more than ~1 MB, + * the data will be split into chunks to make them fit into the memcached limits. + */ +class MemcachedBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface +{ + /** + * Max bucket size, (1024*1024)-42 bytes + */ + protected const MAX_BUCKET_SIZE = 1048534; + + /** + * Instance of the PHP Memcache class + */ + protected \Memcache|\Memcached $memcache; + + /** + * Used PECL module for memcached + */ + protected string $usedPeclModule = ''; + + /** + * Array of Memcache server configurations + */ + protected array $servers = []; + + /** + * Indicates whether the memcache uses compression or not (requires zlib), + * either 0 or \Memcached::OPT_COMPRESSION / MEMCACHE_COMPRESSED + */ + protected int $flags = 0; + + /** + * A prefix to separate stored data from other data possibly stored in the memcache + */ + protected string $identifierPrefix; + + public function __construct(array $options = []) + { + if (!extension_loaded('memcache') && !extension_loaded('memcached')) { + throw new Exception('The PHP extension "memcache" or "memcached" must be installed and loaded in order to use the Memcached backend.', 1213987706); + } + if ($this->usedPeclModule === '') { + if (extension_loaded('memcache')) { + $this->usedPeclModule = 'memcache'; + } elseif (extension_loaded('memcached')) { + $this->usedPeclModule = 'memcached'; + } + } + parent::__construct($options); + } + + /** + * Setter for servers to be used. Expects an array, the values are expected + * to be formatted like "[:]" or "unix://" + * + * @param array $servers An array of servers to add. + */ + protected function setServers(array $servers): void + { + $this->servers = $servers; + } + + /** + * Setter for compression flags bit + */ + protected function setCompression(bool $useCompression): void + { + $compressionFlag = $this->usedPeclModule === 'memcache' ? MEMCACHE_COMPRESSED : \Memcached::OPT_COMPRESSION; + if ($useCompression) { + $this->flags ^= $compressionFlag; + } else { + $this->flags &= ~$compressionFlag; + } + } + + /** + * Getter for compression flag + */ + protected function getCompression(): bool + { + return $this->flags !== 0; + } + + /** + * Initializes the identifier prefix + * + * @throws Exception + */ + public function initializeObject(): void + { + if (empty($this->servers)) { + throw new Exception('No servers were given to Memcache', 1213115903); + } + $memcachedPlugin = '\\' . ucfirst($this->usedPeclModule); + $this->memcache = new $memcachedPlugin(); + $defaultPort = $this->usedPeclModule === 'memcache' ? ini_get('memcache.default_port') : 11211; + foreach ($this->servers as $server) { + if (str_starts_with((string)$server, 'unix://')) { + $host = $server; + $port = 0; + } else { + if (str_starts_with((string)$server, 'tcp://')) { + $server = substr((string)$server, 6); + } + if (str_contains((string)$server, ':')) { + [$host, $port] = explode(':', (string)$server, 2); + } else { + $host = $server; + $port = $defaultPort; + } + } + $this->memcache->addserver($host, (int)$port); + } + if ($this->usedPeclModule === 'memcached') { + $this->memcache->setOption(\Memcached::OPT_COMPRESSION, $this->getCompression()); + } + } + + /** + * Sets the preferred PECL module + */ + public function setPeclModule(string $peclModule): void + { + if ($peclModule !== 'memcache' && $peclModule !== 'memcached') { + throw new Exception('PECL module must be either "memcache" or "memcached".', 1442239768); + } + + $this->usedPeclModule = $peclModule; + } + + public function setCache(FrontendInterface $cache): void + { + parent::setCache($cache); + $identifierHash = substr(md5(Environment::getProjectPath() . $this->cacheIdentifier), 0, 12); + $this->identifierPrefix = 'TYPO3_' . $identifierHash . '_'; + } + + /** + * @param mixed $data The data to be stored. mixed is allowed due to TransientBackendInterface + */ + public function set(string $entryIdentifier, mixed $data, array $tags = [], ?int $lifetime = null): void + { + if (strlen($this->identifierPrefix . $entryIdentifier) > 250) { + throw new \InvalidArgumentException('Could not set value. Key more than 250 characters (' . $this->identifierPrefix . $entryIdentifier . ').', 1232969508); + } + $tags[] = '%MEMCACHEBE%' . $this->cacheIdentifier; + $expiration = $lifetime ?? $this->defaultLifetime; + + // Memcached considers values over 2592000 sec (30 days) as UNIX timestamp + // thus $expiration should be converted from lifetime to UNIX timestamp + if ($expiration > 2592000) { + $expiration += $GLOBALS['EXEC_TIME']; + } + try { + if (is_string($data) && strlen($data) > self::MAX_BUCKET_SIZE) { + $data = str_split($data, 1024 * 1000); + $success = true; + $chunkNumber = 1; + foreach ($data as $chunk) { + $success = $success && $this->setInternal($entryIdentifier . '_chunk_' . $chunkNumber, $chunk, $expiration); + $chunkNumber++; + } + $success = $success && $this->setInternal($entryIdentifier, 'TYPO3*chunked:' . $chunkNumber, $expiration); + } else { + $success = $this->setInternal($entryIdentifier, $data, $expiration); + } + if ($success) { + $this->removeIdentifierFromAllTags($entryIdentifier); + $this->addIdentifierToTags($entryIdentifier, $tags); + } else { + throw new Exception('Could not set data to memcache server.', 1275830266); + } + } catch (\Exception $exception) { + $this->logger->alert('Memcache: could not set value.', ['exception' => $exception]); + } + } + + public function get(string $entryIdentifier): mixed + { + $value = $this->memcache->get($this->identifierPrefix . $entryIdentifier); + if (is_string($value) && str_starts_with($value, 'TYPO3*chunked:')) { + [, $chunkCount] = explode(':', $value); + $value = ''; + for ($chunkNumber = 1; $chunkNumber < $chunkCount; $chunkNumber++) { + $value .= $this->memcache->get($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber); + } + } + return $value; + } + + public function has(string $entryIdentifier): bool + { + if ($this->usedPeclModule === 'memcache') { + return $this->memcache->get($this->identifierPrefix . $entryIdentifier) !== false; + } + // pecl-memcached supports storing literal FALSE + $this->memcache->get($this->identifierPrefix . $entryIdentifier); + return $this->memcache->getResultCode() !== \Memcached::RES_NOTFOUND; + } + + /** + * Removes all cache entries matching the specified identifier. + * Usually this only affects one entry but if - for what reason ever - + * old entries for the identifier still exist, they are removed as well. + * + * @param string $entryIdentifier Specifies the cache entry to remove + * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found + */ + public function remove(string $entryIdentifier): bool + { + $this->removeIdentifierFromAllTags($entryIdentifier); + return $this->memcache->delete($this->identifierPrefix . $entryIdentifier, 0); + } + + public function findIdentifiersByTag(string $tag): array + { + $identifiers = $this->memcache->get($this->identifierPrefix . 'tag_' . $tag); + if ($identifiers !== false) { + return (array)$identifiers; + } + return []; + } + + public function flush(): void + { + $this->flushByTag('%MEMCACHEBE%' . $this->cacheIdentifier); + } + + public function flushByTag(string $tag): void + { + $identifiers = $this->findIdentifiersByTag($tag); + foreach ($identifiers as $identifier) { + $this->remove($identifier); + } + } + + public function flushByTags(array $tags): void + { + array_walk($tags, $this->flushByTag(...)); + } + + /** + * Does nothing, as memcached does GC itself + */ + public function collectGarbage(): void {} + + /** + * Stores the actual data inside memcache/memcached + */ + protected function setInternal(string $entryIdentifier, mixed $data, int $expiration): bool + { + if ($this->usedPeclModule === 'memcache') { + return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $this->flags, $expiration); + } + return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $expiration); + } + + /** + * Associates the identifier with the given tags + */ + protected function addIdentifierToTags(string $entryIdentifier, array $tags): void + { + // Get identifier-to-tag index to look for updates + $existingTags = $this->findTagsByIdentifier($entryIdentifier); + $existingTagsUpdated = false; + + foreach ($tags as $tag) { + // Update tag-to-identifier index + $identifiers = $this->findIdentifiersByTag($tag); + if (!in_array($entryIdentifier, $identifiers, true)) { + $identifiers[] = $entryIdentifier; + $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers); + } + // Test if identifier-to-tag index needs update + if (!in_array($tag, $existingTags, true)) { + $existingTags[] = $tag; + $existingTagsUpdated = true; + } + } + + // Update identifier-to-tag index if needed + if ($existingTagsUpdated) { + $this->memcache->set($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags); + } + } + + /** + * Removes association of the identifier with the given tags + */ + protected function removeIdentifierFromAllTags(string $entryIdentifier): void + { + // Get tags for this identifier + $tags = $this->findTagsByIdentifier($entryIdentifier); + // De-associate tags with this identifier + foreach ($tags as $tag) { + $identifiers = $this->findIdentifiersByTag($tag); + // Formally array_search() below should never return FALSE due to + // the behavior of findTagsByIdentifier(). But if reverse index is + // corrupted, we still can get 'FALSE' from array_search(). This is + // not a problem because we are removing this identifier from + // anywhere. + if (($key = array_search($entryIdentifier, $identifiers)) !== false) { + unset($identifiers[$key]); + if (!empty($identifiers)) { + $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers); + } else { + $this->memcache->delete($this->identifierPrefix . 'tag_' . $tag, 0); + } + } + } + // Clear reverse tag index for this identifier + $this->memcache->delete($this->identifierPrefix . 'ident_' . $entryIdentifier, 0); + } + + /** + * Finds all tags for the given identifier. This function uses reverse tag + * index to search for tags. + * + * @param string $identifier Identifier to find tags by + */ + protected function findTagsByIdentifier(string $identifier): array + { + $tags = $this->memcache->get($this->identifierPrefix . 'ident_' . $identifier); + return $tags === false ? [] : (array)$tags; + } +} diff --git a/Classes/Cache/Backend/NullBackend.php b/Classes/Cache/Backend/NullBackend.php new file mode 100644 index 0000000..0851382 --- /dev/null +++ b/Classes/Cache/Backend/NullBackend.php @@ -0,0 +1,68 @@ +data entries + */ + protected const IDENTIFIER_DATA_PREFIX = 'identData:'; + + /** + * Key prefix for identifier->tags sets + */ + protected const IDENTIFIER_TAGS_PREFIX = 'identTags:'; + + /** + * Key prefix for tag->identifiers sets + */ + protected const TAG_IDENTIFIERS_PREFIX = 'tagIdents:'; + + protected \Redis $redis; + + /** + * Indicates whether the server is connected + */ + protected bool $connected = false; + + /** + * Persistent connection + */ + protected bool $persistentConnection = false; + + /** + * Hostname / IP of the Redis server, defaults to 127.0.0.1. + */ + protected string $hostname = '127.0.0.1'; + + /** + * Port of the Redis server, defaults to 6379 + */ + protected int $port = 6379; + + /** + * Number of selected database, defaults to 0 + */ + protected int $database = 0; + + /** + * Username for authentication + */ + protected ?string $username = null; + + /** + * Password for authentication + */ + protected ?string $password = null; + + /** + * Indicates whether data is compressed or not (requires php zlib) + */ + protected bool $compression = false; + + /** + * -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression + */ + protected int $compressionLevel = -1; + + /** + * limit in seconds (default is 0 meaning unlimited) + */ + protected int $connectionTimeout = 0; + + /** + * Used as prefix for all Redis keys/identifiers + */ + protected string $keyPrefix = ''; + + public function __construct(array $options = []) + { + if (!extension_loaded('redis')) { + throw new Exception('The PHP extension "redis" must be installed and loaded in order to use the redis backend.', 1279462933); + } + parent::__construct($options); + } + + public function initializeObject(): void + { + $this->redis = new \Redis(); + try { + if ($this->persistentConnection) { + $this->connected = $this->redis->pconnect($this->hostname, $this->port, $this->connectionTimeout, (string)$this->database); + } else { + $this->connected = $this->redis->connect($this->hostname, $this->port, $this->connectionTimeout); + } + } catch (\Exception $e) { + $this->logger->alert('Could not connect to redis server.', ['exception' => $e]); + } + if ($this->connected) { + $authentication = $this->getAuthentication(); + if ($authentication !== null) { + $success = $this->redis->auth($this->getAuthentication()); + if (!$success) { + throw new Exception('Authentication to Redis failed”.', 1279765134); + } + } + if ($this->database >= 0) { + $success = $this->redis->select($this->database); + if (!$success) { + throw new Exception('The given database "' . $this->database . '" could not be selected.', 1279765144); + } + } + } + } + + protected function setPersistentConnection(bool $persistentConnection): void + { + $this->persistentConnection = $persistentConnection; + } + + protected function setHostname(string $hostname): void + { + $this->hostname = $hostname; + } + + protected function setPort(int $port): void + { + $this->port = $port; + } + + protected function setDatabase(int $database): void + { + if ($database < 0) { + throw new \InvalidArgumentException('The specified database "' . $database . '" must be greater or equal than zero.', 1279763534); + } + $this->database = $database; + } + + protected function setUsername(string $username): void + { + $this->username = $username; + } + + /** + * Setter for authentication password + */ + protected function setPassword(#[\SensitiveParameter] string $password): void + { + $this->password = $password; + } + + protected function setCompression(bool $compression): void + { + $this->compression = $compression; + } + + /** + * Set data compression level. + * If compression is enabled and this is not set, + * gzcompress default level will be used. + * + * @param int $compressionLevel -1 to 9: Compression level + */ + protected function setCompressionLevel(int $compressionLevel): void + { + if ($compressionLevel >= -1 && $compressionLevel <= 9) { + $this->compressionLevel = $compressionLevel; + } else { + throw new \InvalidArgumentException('The specified compression level must be an integer between -1 and 9.', 1289679155); + } + } + + /** + * Set connection timeout. + * This value in seconds is used as a maximum number + * of seconds to wait if a connection can be established. + * + * @param int $connectionTimeout limit in seconds, a value greater or equal than 0 + */ + protected function setConnectionTimeout(int $connectionTimeout): void + { + if ($connectionTimeout < 0) { + throw new \InvalidArgumentException('The specified connection timeout "' . $connectionTimeout . '" must be greater or equal than zero.', 1487849326); + } + + $this->connectionTimeout = $connectionTimeout; + } + + protected function setKeyPrefix(string $keyPrefix): void + { + $this->keyPrefix = $keyPrefix; + } + + /** + * Save data in the cache + * + * Scales O(1) with number of cache entries + * Scales O(n) with number of tags + */ + public function set(string $entryIdentifier, string $data, array $tags = [], ?int $lifetime = null): void + { + $lifetime ??= $this->defaultLifetime; + if ($lifetime < 0) { + throw new \InvalidArgumentException('The specified lifetime "' . $lifetime . '" must be greater or equal than zero.', 1279487573); + } + if ($this->connected) { + $expiration = $lifetime === 0 ? self::FAKED_UNLIMITED_LIFETIME : $lifetime; + if ($this->compression) { + $data = gzcompress($data, $this->compressionLevel); + } + $this->redis->setex($this->getDataIdentifier($entryIdentifier), $expiration, $data); + $addTags = $tags; + $removeTags = []; + $existingTags = $this->redis->sMembers($this->getTagsIdentifier($entryIdentifier)); + if (!empty($existingTags)) { + $addTags = array_diff($tags, $existingTags); + $removeTags = array_diff($existingTags, $tags); + } + if (!empty($removeTags) || !empty($addTags)) { + $queue = $this->redis->multi(\Redis::PIPELINE); + foreach ($removeTags as $tag) { + $queue->sRem($this->getTagsIdentifier($entryIdentifier), $tag); + $queue->sRem($this->getTagIdentifier($tag), $entryIdentifier); + } + foreach ($addTags as $tag) { + $queue->sAdd($this->getTagsIdentifier($entryIdentifier), $tag); + $queue->sAdd($this->getTagIdentifier($tag), $entryIdentifier); + } + $queue->exec(); + } + } + } + + /** + * Loads data from the cache. + * + * Scales O(1) with number of cache entries + */ + public function get(string $entryIdentifier): mixed + { + $storedEntry = false; + if ($this->connected) { + $storedEntry = $this->redis->get($this->getDataIdentifier($entryIdentifier)); + } + if ($this->compression && (string)$storedEntry !== '') { + return gzuncompress((string)$storedEntry); + } + return $storedEntry; + } + + /** + * Checks if a cache entry with the specified identifier exists. + * + * Scales O(1) with number of cache entries + */ + public function has(string $entryIdentifier): bool + { + return $this->connected && $this->redis->exists($this->getDataIdentifier($entryIdentifier)); + } + + /** + * Removes all cache entries matching the specified identifier. + * + * Scales O(1) with number of cache entries + * Scales O(n) with number of tags + */ + public function remove(string $entryIdentifier): bool + { + if (!$this->connected) { + return false; + } + if (!$this->redis->exists($this->getDataIdentifier($entryIdentifier))) { + return false; + } + $assignedTags = $this->redis->sMembers($this->getTagsIdentifier($entryIdentifier)); + $queue = $this->redis->multi(\Redis::PIPELINE); + foreach ($assignedTags as $tag) { + $queue->sRem($this->getTagIdentifier($tag), $entryIdentifier); + } + $queue->del($this->getDataIdentifier($entryIdentifier), $this->getTagsIdentifier($entryIdentifier)); + $queue->exec(); + return true; + } + + /** + * Finds and returns all cache entry identifiers which are tagged by the specified tag. + * + * Scales O(1) with number of cache entries + * Scales O(n) with number of tag entries + */ + public function findIdentifiersByTag(string $tag): array + { + if (!$this->connected) { + return []; + } + return $this->redis->sMembers($this->getTagIdentifier($tag)); + } + + public function flush(): void + { + if (!$this->connected) { + return; + } + // unless we have a key prefix all data can be flushed + if ($this->keyPrefix === '') { + $this->redis->flushDB(); + return; + } + $keys = $this->redis->keys($this->keyPrefix . '*'); + $queue = $this->redis->multi(); + $queue->del($keys); + $queue->exec(); + } + + /** + * Removes all cache entries of this cache which are tagged with the specified tag. + * + * Scales O(1) with number of cache entries + * Scales O(n^2) with number of tag entries + */ + public function flushByTag(string $tag): void + { + if (!$this->connected) { + return; + } + $identifiers = $this->redis->sMembers($this->getTagIdentifier($tag)); + if (!empty($identifiers)) { + $this->removeIdentifierEntriesAndRelations($identifiers, [$tag]); + } + } + + public function flushByTags(array $tags): void + { + array_walk($tags, $this->flushByTag(...)); + } + + /** + * With the current internal structure, only the identifier to data entries + * have a redis internal lifetime. If an entry expires, attached + * identifier to tags and tag to identifiers entries will be left over. + * This method finds those entries and cleans them up. + * + * Scales O(n*m) with number of cache entries (n) and number of tags (m) + */ + public function collectGarbage(): void + { + $identifierToTagsKeys = $this->redis->keys($this->getTagsIdentifier('*')); + foreach ($identifierToTagsKeys as $identifierToTagsKey) { + [, $identifier] = explode(':', $identifierToTagsKey); + // Check if the data entry still exists + if (!$this->redis->exists($this->getDataIdentifier($identifier))) { + $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey); + $queue = $this->redis->multi(\Redis::PIPELINE); + $queue->del($identifierToTagsKey); + foreach ($tagsToRemoveIdentifierFrom as $tag) { + $queue->sRem($this->getTagIdentifier($tag), $identifier); + } + $queue->exec(); + } + } + } + + /** + * Helper method for flushByTag() + * Gets list of identifiers and tags and removes all relations of those tags + * + * Scales O(1) with number of cache entries + * Scales O(n^2) with number of tags + */ + protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags): void + { + // Set a temporary entry which holds all identifiers that need to be removed from + // the tag to identifiers sets + $uniqueTempKey = 'temp:' . StringUtility::getUniqueId(); + $prefixedKeysToDelete = [$uniqueTempKey]; + $prefixedIdentifierToTagsKeysToDelete = []; + foreach ($identifiers as $identifier) { + $prefixedKeysToDelete[] = $this->getDataIdentifier($identifier); + $prefixedIdentifierToTagsKeysToDelete[] = $this->getTagsIdentifier($identifier); + } + foreach ($tags as $tag) { + $prefixedKeysToDelete[] = $this->getTagIdentifier($tag); + } + $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion(...$prefixedIdentifierToTagsKeysToDelete); + // Remove the tag to identifier set of the given tags, they will be removed anyway + $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags); + // Diff all identifiers that must be removed from tag to identifiers sets off from a + // tag to identifiers set and store result in same tag to identifiers set again + $queue = $this->redis->multi(\Redis::PIPELINE); + foreach ($identifiers as $identifier) { + $queue->sAdd($uniqueTempKey, $identifier); + } + foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) { + $queue->sDiffStore($this->getTagIdentifier($tagToIdentifiersSet), $this->getTagIdentifier($tagToIdentifiersSet), $uniqueTempKey); + } + $queue->del(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete)); + $queue->exec(); + } + + protected function getDataIdentifier(string $identifier): string + { + return $this->keyPrefix . self::IDENTIFIER_DATA_PREFIX . $identifier; + } + + protected function getTagsIdentifier(string $identifier): string + { + return $this->keyPrefix . self::IDENTIFIER_TAGS_PREFIX . $identifier; + } + + protected function getTagIdentifier(string $tag): string + { + return $this->keyPrefix . self::TAG_IDENTIFIERS_PREFIX . $tag; + } + + /** + * Build the authentication value based on the configuration, returning an associative array + * in case `username` and `password` has been configured, the `password` as string if only + * password has been configured or `null` to indicate no-authentication configuration, which + * is also possible to be used with `redis`. + */ + protected function getAuthentication(): array|string|null + { + return match (true) { + // Username and password configured for authentication, build associative array + // out of possible and supported array variants by `php-redis::auth()`. + ($this->username !== null && $this->password !== null) => [ + 'user' => $this->username, + 'pass' => $this->password, + ], + // Password-only authentication configured. + ($this->username === null && $this->password !== null) => $this->password, + // No authentication configured. + default => null, + }; + } +} diff --git a/Classes/Cache/Backend/SimpleFileBackend.php b/Classes/Cache/Backend/SimpleFileBackend.php new file mode 100644 index 0000000..4af7978 --- /dev/null +++ b/Classes/Cache/Backend/SimpleFileBackend.php @@ -0,0 +1,280 @@ +cacheDirectory then. + */ + protected string $temporaryCacheDirectory = ''; + + /** + * A file extension to use for each cache entry. + */ + protected string $cacheEntryFileExtension = ''; + + public function setCache(FrontendInterface $cache): void + { + parent::setCache($cache); + if (empty($this->temporaryCacheDirectory)) { + // If no cache directory was given with cacheDirectory + // configuration option, set it to a path below var/ folder + $temporaryCacheDirectory = Environment::getVarPath() . '/'; + } else { + $temporaryCacheDirectory = $this->temporaryCacheDirectory; + } + $codeOrData = $cache instanceof PhpFrontend ? 'code' : 'data'; + $finalCacheDirectory = $temporaryCacheDirectory . 'cache/' . $codeOrData . '/' . $this->cacheIdentifier . '/'; + $this->createFinalCacheDirectory($finalCacheDirectory); + $this->temporaryCacheDirectory = ''; + $this->cacheDirectory = $finalCacheDirectory; + $this->cacheEntryFileExtension = $cache instanceof PhpFrontend ? '.php' : ''; + if (strlen($this->cacheDirectory) + 23 > PHP_MAXPATHLEN) { + throw new Exception('The length of the temporary cache file path "' . $this->cacheDirectory . '" exceeds the maximum path length of ' . (PHP_MAXPATHLEN - 23) . '. Please consider setting the temporaryDirectoryBase option to a shorter path.', 1248710426); + } + } + + /** + * Sets the directory where the cache files are stored. By default it is + * assumed that the directory is below TYPO3's Project Path. However, an + * absolute path can be selected, too. + * + * This method enables to use a cache path outside of TYPO3's Project Path. The final + * cache path is checked and created in createFinalCacheDirectory(), + * called by setCache() method, which is done _after_ the cacheDirectory + * option was handled. + * + * @internal Misused in tests + * @todo: Fix tests and protect + */ + public function setCacheDirectory(string $cacheDirectory): void + { + $documentRoot = Environment::getProjectPath() . '/'; + if ($open_basedir = ini_get('open_basedir')) { + if (Environment::isWindows()) { + $delimiter = ';'; + $cacheDirectory = str_replace('\\', '/', $cacheDirectory); + if (!preg_match('/[A-Z]:/', substr($cacheDirectory, 0, 2))) { + $cacheDirectory = Environment::getProjectPath() . $cacheDirectory; + } + } else { + $delimiter = ':'; + if ($cacheDirectory[0] !== '/') { + // relative path to cache directory. + $cacheDirectory = Environment::getProjectPath() . $cacheDirectory; + } + } + $basedirs = explode($delimiter, $open_basedir); + $cacheDirectoryInBaseDir = false; + foreach ($basedirs as $basedir) { + if (Environment::isWindows()) { + $basedir = str_replace('\\', '/', $basedir); + } + if ($basedir[strlen($basedir) - 1] !== '/') { + $basedir .= '/'; + } + if (str_starts_with($cacheDirectory, $basedir)) { + $documentRoot = $basedir; + $cacheDirectory = str_replace($basedir, '', $cacheDirectory); + $cacheDirectoryInBaseDir = true; + break; + } + } + if (!$cacheDirectoryInBaseDir) { + throw new Exception( + 'Open_basedir restriction in effect. The directory "' . $cacheDirectory . '" is not in an allowed path.', + 1476045417 + ); + } + } else { + if ($cacheDirectory[0] === '/') { + // Absolute path to cache directory. + $documentRoot = ''; + } + if (Environment::isWindows() && (!empty($documentRoot) && str_starts_with($cacheDirectory, $documentRoot))) { + $documentRoot = ''; + } + } + // After this point all paths have '/' as directory separator + if ($cacheDirectory[strlen($cacheDirectory) - 1] !== '/') { + $cacheDirectory .= '/'; + } + $this->temporaryCacheDirectory = $documentRoot . $cacheDirectory; + } + + /** + * Create the final cache directory if it does not exist. + */ + protected function createFinalCacheDirectory(string $finalCacheDirectory): void + { + if (!is_dir($finalCacheDirectory)) { + try { + GeneralUtility::mkdir_deep($finalCacheDirectory); + } catch (\RuntimeException $e) { + throw new Exception('The directory "' . $finalCacheDirectory . '" can not be created.', 1303669848, $e); + } + } + if (!is_writable($finalCacheDirectory)) { + throw new Exception('The directory "' . $finalCacheDirectory . '" is not writable.', 1203965200); + } + $tmpFilesCacheDirectory = $finalCacheDirectory . 'tmp/'; + if (!is_dir($tmpFilesCacheDirectory)) { + try { + GeneralUtility::mkdir_deep($tmpFilesCacheDirectory); + } catch (\RuntimeException $e) { + throw new Exception('The temporary cache directory "' . $tmpFilesCacheDirectory . '" can not be created.', 1727176780, $e); + } + } + if (!is_writable($tmpFilesCacheDirectory)) { + throw new Exception('The temporary cache directory "' . $tmpFilesCacheDirectory . '" is not writable.', 1727176781); + } + } + + /** + * Returns the directory where the cache files are stored + * + * @return string Full path of the cache directory + * @internal Misused in tests + * @todo: Fix tests and protect + */ + public function getCacheDirectory(): string + { + return $this->cacheDirectory; + } + + public function set(string $entryIdentifier, string $data, array $tags = [], ?int $lifetime = null): void + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1334756735); + } + if ($entryIdentifier === '') { + throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1334756736); + } + $temporaryCacheEntryPathAndFilename = $this->cacheDirectory . 'tmp/' . StringUtility::getUniqueId() . '.temp'; + $result = GeneralUtility::writeFile($temporaryCacheEntryPathAndFilename, $data, true); + if ($result === false) { + throw new Exception('The temporary cache file "' . $temporaryCacheEntryPathAndFilename . '" could not be written.', 1334756737); + } + $cacheEntryPathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + $result = @rename($temporaryCacheEntryPathAndFilename, $cacheEntryPathAndFilename); + if ($result === false) { + throw new Exception('The cache file "' . $cacheEntryPathAndFilename . '" could not be written.', 1727178709); + } + if ($this->cacheEntryFileExtension === '.php') { + GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($cacheEntryPathAndFilename); + } + } + + public function get(string $entryIdentifier): false|string + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1334756877); + } + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + if (!file_exists($pathAndFilename)) { + return false; + } + return file_get_contents($pathAndFilename); + } + + public function has(string $entryIdentifier): bool + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1334756878); + } + return file_exists($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension); + } + + public function remove(string $entryIdentifier): bool + { + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1334756960); + } + if ($entryIdentifier === '') { + throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1334756961); + } + $file = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + return @unlink($file); + } + + public function flush(): void + { + $directoryIterator = new \DirectoryIterator($this->cacheDirectory); + foreach ($directoryIterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + if (@unlink($this->cacheDirectory . $fileInfo->getFilename())) { + continue; + } + $this->logger->error('Failed to unlink cache entry: {filename}', [ + 'filename' => $this->cacheDirectory . $fileInfo->getFilename(), + ]); + } + } + + protected function isCacheFileExpired(string $cacheEntryPathAndFilename): bool + { + return file_exists($cacheEntryPathAndFilename) === false; + } + + /** + * No-op + */ + public function collectGarbage(): void {} + + public function requireOnce(string $entryIdentifier): mixed + { + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073037); + } + return file_exists($pathAndFilename) ? require_once $pathAndFilename : false; + } + + public function require(string $entryIdentifier): mixed + { + $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension; + if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) { + throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1532528267); + } + return file_exists($pathAndFilename) ? require $pathAndFilename : false; + } +} diff --git a/Classes/Cache/Backend/TaggableBackendInterface.php b/Classes/Cache/Backend/TaggableBackendInterface.php new file mode 100644 index 0000000..aa27c2f --- /dev/null +++ b/Classes/Cache/Backend/TaggableBackendInterface.php @@ -0,0 +1,47 @@ +entries[$entryIdentifier] = $data; + foreach ($tags as $tag) { + $this->tagsAndEntries[$tag][$entryIdentifier] = true; + } + } + + public function get(string $entryIdentifier): mixed + { + return $this->entries[$entryIdentifier] ?? false; + } + + public function has(string $entryIdentifier): bool + { + return isset($this->entries[$entryIdentifier]); + } + + public function remove(string $entryIdentifier): bool + { + if (isset($this->entries[$entryIdentifier])) { + unset($this->entries[$entryIdentifier]); + foreach (array_keys($this->tagsAndEntries) as $tag) { + if (isset($this->tagsAndEntries[$tag][$entryIdentifier])) { + unset($this->tagsAndEntries[$tag][$entryIdentifier]); + } + } + return true; + } + return false; + } + + public function findIdentifiersByTag(string $tag): array + { + if (isset($this->tagsAndEntries[$tag])) { + return array_keys($this->tagsAndEntries[$tag]); + } + return []; + } + + public function flush(): void + { + $this->entries = []; + $this->tagsAndEntries = []; + } + + public function flushByTag(string $tag): void + { + $identifiers = $this->findIdentifiersByTag($tag); + foreach ($identifiers as $identifier) { + $this->remove($identifier); + } + } + + public function flushByTags(array $tags): void + { + array_walk($tags, $this->flushByTag(...)); + } + + /** + * No-op + */ + public function collectGarbage(): void {} +} diff --git a/Classes/Cache/Backend/Typo3DatabaseBackend.php b/Classes/Cache/Backend/Typo3DatabaseBackend.php new file mode 100644 index 0000000..5913f02 --- /dev/null +++ b/Classes/Cache/Backend/Typo3DatabaseBackend.php @@ -0,0 +1,374 @@ +cacheTable = 'cache_' . $this->cacheIdentifier; + $this->tagsTable = 'cache_' . $this->cacheIdentifier . '_tags'; + $this->maximumLifetime = self::FAKED_UNLIMITED_EXPIRE - $GLOBALS['EXEC_TIME']; + } + + public function set(string $entryIdentifier, string $data, array $tags = [], $lifetime = null): void + { + if ($lifetime === null) { + $lifetime = $this->defaultLifetime; + } + if ($lifetime === 0 || $lifetime > $this->maximumLifetime) { + $lifetime = $this->maximumLifetime; + } + $expires = $GLOBALS['EXEC_TIME'] + $lifetime; + $this->remove($entryIdentifier); + if ($this->compression) { + $data = gzcompress($data, $this->compressionLevel); + } + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->cacheTable) + ->insert( + $this->cacheTable, + [ + 'identifier' => $entryIdentifier, + 'expires' => $expires, + 'content' => $data, + ], + [ + 'content' => Connection::PARAM_LOB, + ] + ); + if (!empty($tags)) { + $tagRows = []; + foreach ($tags as $tag) { + $tagRows[] = [$entryIdentifier, $tag]; + } + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->tagsTable) + ->bulkInsert($this->tagsTable, $tagRows, ['identifier', 'tag'], ['identifier' => Connection::PARAM_STR, 'tag' => Connection::PARAM_STR]); + } + } + + public function get(string $entryIdentifier): mixed + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->cacheTable); + $cacheRow = $queryBuilder->select('content') + ->from($this->cacheTable) + ->where( + $queryBuilder->expr()->eq( + 'identifier', + $queryBuilder->createNamedParameter($entryIdentifier) + ), + $queryBuilder->expr()->gte( + 'expires', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + $content = ''; + if (!empty($cacheRow)) { + $content = $cacheRow['content']; + } + if ($this->compression && (string)$content !== '') { + $content = gzuncompress($content); + } + return empty($cacheRow) ? false : $content; + } + + public function has(string $entryIdentifier): bool + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->cacheTable); + $count = $queryBuilder->count('*') + ->from($this->cacheTable) + ->where( + $queryBuilder->expr()->eq( + 'identifier', + $queryBuilder->createNamedParameter($entryIdentifier) + ), + $queryBuilder->expr()->gte( + 'expires', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchOne(); + return (bool)$count; + } + + public function remove(string $entryIdentifier): bool + { + $numberOfRowsRemoved = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->cacheTable) + ->delete( + $this->cacheTable, + ['identifier' => $entryIdentifier], + ['identifier' => Connection::PARAM_STR] + ); + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable($this->tagsTable) + ->delete( + $this->tagsTable, + ['identifier' => $entryIdentifier], + ['identifier' => Connection::PARAM_STR] + ); + return (bool)$numberOfRowsRemoved; + } + + public function findIdentifiersByTag(string $tag): array + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tagsTable); + $result = $queryBuilder->select($this->cacheTable . '.identifier') + ->from($this->cacheTable) + ->from($this->tagsTable) + ->where( + $queryBuilder->expr()->eq($this->cacheTable . '.identifier', $queryBuilder->quoteIdentifier($this->tagsTable . '.identifier')), + $queryBuilder->expr()->eq( + $this->tagsTable . '.tag', + $queryBuilder->createNamedParameter($tag) + ), + $queryBuilder->expr()->gte( + $this->cacheTable . '.expires', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + ) + ) + ->groupBy($this->cacheTable . '.identifier') + ->executeQuery(); + $identifiers = $result->fetchFirstColumn(); + return array_combine($identifiers, $identifiers); + } + + public function flush(): void + { + GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable)->truncate($this->cacheTable); + GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->tagsTable)->truncate($this->tagsTable); + } + + public function flushByTags(array $tags): void + { + if (empty($tags)) { + return; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable); + // A large set of tags was detected. Process it in chunks to guard against exceeding + // maximum SQL query limits. + if (count($tags) > 100) { + $chunks = array_chunk($tags, 100); + array_walk($chunks, $this->flushByTags(...)); + return; + } + $queryBuilder = $connection->createQueryBuilder(); + $result = $queryBuilder->select('identifier') + ->from($this->tagsTable) + ->where( + $queryBuilder->expr()->in('tag', $queryBuilder->quoteArrayBasedValueListToStringList($tags)), + ) + // group by is like DISTINCT and used here to suppress possible duplicate identifiers + ->groupBy('identifier') + ->executeQuery(); + $cacheEntryIdentifiers = $result->fetchFirstColumn(); + $this->flushCacheByCacheEntryIdentifiers($cacheEntryIdentifiers); + } + + public function flushByTag(string $tag): void + { + if (empty($tag)) { + return; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable); + $queryBuilder = $connection->createQueryBuilder(); + $result = $queryBuilder->select('identifier') + ->from($this->tagsTable) + ->where( + $queryBuilder->expr()->eq('tag', $queryBuilder->quote($tag)), + ) + // group by is like DISTINCT and used here to suppress possible duplicate identifiers + ->groupBy('identifier') + ->executeQuery(); + $cacheEntryIdentifiers = $result->fetchFirstColumn(); + $this->flushCacheByCacheEntryIdentifiers($cacheEntryIdentifiers); + } + + private function flushCacheByCacheEntryIdentifiers(array $cacheEntryIdentifiers): void + { + if ($cacheEntryIdentifiers === []) { + // Nothing to do, return early. + return; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + foreach (array_chunk($cacheEntryIdentifiers, $maxBindParameters) as $chunk) { + // Don't reuse QueryBuilder instance, create new one. + $queryBuilder = $connection->createQueryBuilder(); + // Using string-list here directly is okay and mitigates additional processing + // for database driver without named placeholder support, which comes with a + // performance penalty we can work around and also do it only once per chunk. + $quotedIdentifiers = $queryBuilder->quoteArrayBasedValueListToStringList($chunk); + $queryBuilder->delete($this->cacheTable) + ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers)) + ->executeStatement(); + // Don't reuse QueryBuilder instance, create new one. + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->delete($this->tagsTable) + ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers)) + ->executeStatement(); + } + } + + public function collectGarbage(): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable); + $queryBuilder = $connection->createQueryBuilder(); + $result = $queryBuilder->select('identifier') + ->from($this->cacheTable) + ->where($queryBuilder->expr()->lt( + 'expires', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + )) + // group by is like DISTINCT and used here to suppress possible duplicate identifiers + ->groupBy('identifier') + ->executeQuery(); + + // Get identifiers of expired cache entries + $cacheEntryIdentifiers = $result->fetchFirstColumn(); + if (!empty($cacheEntryIdentifiers)) { + // Delete tag rows connected to expired cache entries + $this->deleteTagsChunked($cacheEntryIdentifiers); + } + $queryBuilder->delete($this->cacheTable) + ->where($queryBuilder->expr()->lt( + 'expires', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + )) + ->executeStatement(); + + // Find out which "orphaned" tags rows exists that have no cache row and delete those, too. + $queryBuilder = $connection->createQueryBuilder(); + $result = $queryBuilder->select('tags.identifier') + ->from($this->tagsTable, 'tags') + ->leftJoin( + 'tags', + $this->cacheTable, + 'cache', + $queryBuilder->expr()->eq('tags.identifier', $queryBuilder->quoteIdentifier('cache.identifier')) + ) + ->where($queryBuilder->expr()->isNull('cache.identifier')) + ->groupBy('tags.identifier') + ->executeQuery(); + $tagsEntryIdentifiers = $result->fetchFirstColumn(); + + if (!empty($tagsEntryIdentifiers)) { + $this->deleteTagsChunked($tagsEntryIdentifiers); + } + } + + /** + * @param string[] $items + */ + protected function deleteTagsChunked(array $items): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->tagsTable); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + foreach (array_chunk($items, $maxBindParameters, true) as $itemsChunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder + ->delete($this->tagsTable) + ->where($queryBuilder->expr()->in('identifier', $queryBuilder->quoteArrayBasedValueListToStringList($itemsChunk))) + ->executeStatement(); + } + } + + protected function setCompression(bool $compression): void + { + $this->compression = $compression; + } + + /** + * Set data compression level. + * If compression is enabled and this is not set, + * gzcompress default level will be used + * + * @param int $compressionLevel -1 to 9: Compression level + */ + protected function setCompressionLevel(int $compressionLevel): void + { + if ($compressionLevel >= -1 && $compressionLevel <= 9) { + $this->compressionLevel = $compressionLevel; + } + } + + /** + * Calculate needed table definitions for this cache. + * This helper method is used by install tool and extension manager + * and is not part of the public API! + * + * @return string SQL of table definitions + */ + public function getTableDefinitions(): string + { + $cacheTableSql = (string)file_get_contents( + ExtensionManagementUtility::extPath('core') + . 'Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendCache.sql' + ); + $requiredTableStructures = str_replace('###CACHE_TABLE###', $this->cacheTable, $cacheTableSql) . LF . LF; + $tagsTableSql = (string)file_get_contents( + ExtensionManagementUtility::extPath('core') + . 'Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendTags.sql' + ); + return $requiredTableStructures . (str_replace('###TAGS_TABLE###', $this->tagsTable, $tagsTableSql) . LF); + } +} diff --git a/Classes/Cache/CacheDataCollector.php b/Classes/Cache/CacheDataCollector.php new file mode 100644 index 0000000..952a874 --- /dev/null +++ b/Classes/Cache/CacheDataCollector.php @@ -0,0 +1,102 @@ +pageCacheIdentifier = $identifier; + } + + public function getPageCacheIdentifier(): string + { + if ($this->pageCacheIdentifier === null) { + throw new \LogicException('Page cache identifier has not been set. Broken call chain.', 1761315963); + } + return $this->pageCacheIdentifier; + } + + /** + * @return CacheTag[] + */ + public function getCacheTags(): array + { + return array_values($this->cacheTags); + } + + public function addCacheTags(CacheTag ...$cacheTags): void + { + array_walk($cacheTags, fn(CacheTag $cacheTag) => $this->addCacheTag($cacheTag)); + } + + public function removeCacheTags(CacheTag ...$cacheTags): void + { + array_walk($cacheTags, fn(CacheTag $cacheTag) => $this->removeCacheTag($cacheTag)); + } + + public function restrictMaximumLifetime(int $lifetime): void + { + $this->lifetime = min($lifetime, $this->lifetime); + } + + public function resolveLifetime(): int + { + $lifetimes = array_unique( + [$this->lifetime, ...array_map(fn(CacheTag $cacheTag) => $cacheTag->lifetime, $this->cacheTags)] + ); + return min($lifetimes); + } + + public function enqueueCacheEntry(CacheEntry $deferredCacheItem): void + { + $this->cacheEntries[$deferredCacheItem->identifier] = $deferredCacheItem; + } + + /** + * @return CacheEntry[] + */ + public function getCacheEntries(): array + { + return array_values($this->cacheEntries); + } + + private function addCacheTag(CacheTag $cacheTag): void + { + $this->cacheTags[$cacheTag->name] = $cacheTag; + } + + private function removeCacheTag(CacheTag $cacheTag): void + { + unset($this->cacheTags[$cacheTag->name]); + } +} diff --git a/Classes/Cache/CacheDataCollectorInterface.php b/Classes/Cache/CacheDataCollectorInterface.php new file mode 100644 index 0000000..b5cdf51 --- /dev/null +++ b/Classes/Cache/CacheDataCollectorInterface.php @@ -0,0 +1,41 @@ +persist)($request, $this->identifier, $this->content); + } +} diff --git a/Classes/Cache/CacheManager.php b/Classes/Cache/CacheManager.php new file mode 100644 index 0000000..f2c84d0 --- /dev/null +++ b/Classes/Cache/CacheManager.php @@ -0,0 +1,331 @@ + VariableFrontend::class, + 'backend' => Typo3DatabaseBackend::class, + 'options' => [], + 'groups' => ['all'], + ]; + + public function __construct( + protected bool $disableCaching = false + ) {} + + /** + * Sets configurations for caches. The key of each entry specifies the + * cache identifier and the value is an array of configuration options. + * Possible options are: + * + * frontend + * backend + * backendOptions + * + * If one of the options is not specified, the default value is assumed. + * Existing cache configurations are preserved. + * + * @param array $cacheConfigurations The cache configurations to set + * @throws \InvalidArgumentException If $cacheConfigurations is not an array + */ + public function setCacheConfigurations(array $cacheConfigurations): void + { + $newConfiguration = []; + foreach ($cacheConfigurations as $identifier => $configuration) { + if (empty($identifier)) { + throw new \InvalidArgumentException('A cache identifier was not set.', 1596980032); + } + if (!is_array($configuration)) { + throw new \InvalidArgumentException('The cache configuration for cache "' . $identifier . '" was not an array as expected.', 1231259656); + } + $newConfiguration[$identifier] = $configuration; + } + $this->cacheConfigurations = $newConfiguration; + } + + /** + * Registers a cache so it can be retrieved at a later point. + * + * @param array $groups Cache groups to be associated to the cache + * @throws DuplicateIdentifierException if a cache with the given identifier has already been registered. + */ + public function registerCache(FrontendInterface $cache, array $groups = []): void + { + $identifier = $cache->getIdentifier(); + if (isset($this->caches[$identifier])) { + throw new DuplicateIdentifierException('A cache with identifier "' . $identifier . '" has already been registered.', 1203698223); + } + $this->caches[$identifier] = $cache; + foreach ($groups as $groupIdentifier) { + $this->cacheGroups[$groupIdentifier][] = $identifier; + } + } + + /** + * Returns the cache specified by $identifier + * + * @throws NoSuchCacheException + */ + public function getCache(string $identifier): FrontendInterface + { + if ($this->hasCache($identifier) === false) { + throw new NoSuchCacheException('A cache with identifier "' . $identifier . '" does not exist.', 1203699034); + } + if (!isset($this->caches[$identifier])) { + $this->createCache($identifier); + } + return $this->caches[$identifier]; + } + + /** + * Checks if the specified cache has been registered. + */ + public function hasCache(string $identifier): bool + { + return isset($this->caches[$identifier]) || isset($this->cacheConfigurations[$identifier]); + } + + /** + * Flushes all registered caches + */ + public function flushCaches(): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + $cache->flush(); + } + } + + /** + * Flushes all registered caches of a specific group + * + * @throws NoSuchCacheGroupException + */ + public function flushCachesInGroup(string $groupIdentifier): void + { + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390334120); + } + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + $this->caches[$cacheIdentifier]->flush(); + } + } + } + + /** + * Flushes entries tagged by the specified tag of all registered + * caches of a specific group. + * + * @throws NoSuchCacheGroupException + */ + public function flushCachesInGroupByTag(string $groupIdentifier, string $tag): void + { + if (empty($tag)) { + return; + } + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390337129); + } + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + $this->caches[$cacheIdentifier]->flushByTag($tag); + } + } + } + + /** + * Flushes entries tagged by any of the specified tags in all registered + * caches of a specific group. + * + * @throws NoSuchCacheGroupException + */ + public function flushCachesInGroupByTags(string $groupIdentifier, array $tags): void + { + if (empty($tags)) { + return; + } + $this->createAllCaches(); + if (!isset($this->cacheGroups[$groupIdentifier])) { + throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390337130); + } + foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) { + if (isset($this->caches[$cacheIdentifier])) { + $this->caches[$cacheIdentifier]->flushByTags($tags); + } + } + } + + /** + * Flushes entries tagged by the specified tag of all registered caches. + */ + public function flushCachesByTag(string $tag): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + $cache->flushByTag($tag); + } + } + + /** + * Flushes entries tagged by any of the specified tags in all registered caches. + */ + public function flushCachesByTags(array $tags): void + { + $this->createAllCaches(); + foreach ($this->caches as $cache) { + $cache->flushByTags($tags); + } + } + + /** + * @return string[] + * @internal + */ + public function getCacheGroups(): array + { + $groups = array_keys($this->cacheGroups); + foreach ($this->cacheConfigurations as $config) { + foreach ($config['groups'] ?? [] as $group) { + if (!in_array($group, $groups, true)) { + $groups[] = $group; + } + } + } + return $groups; + } + + public function handleCacheFlushEvent(CacheFlushEvent $event): void + { + foreach ($event->getGroups() as $group) { + $this->flushCachesInGroup($group); + } + } + + protected function createAllCaches(): void + { + foreach ($this->cacheConfigurations as $identifier => $_) { + if (!isset($this->caches[$identifier])) { + $this->createCache($identifier); + } + } + } + + /** + * Instantiates the cache for $identifier. + * + * @throws DuplicateIdentifierException + * @throws InvalidBackendException + * @throws InvalidCacheException + */ + protected function createCache(string $identifier): void + { + if (isset($this->cacheConfigurations[$identifier]['frontend'])) { + $frontend = $this->cacheConfigurations[$identifier]['frontend']; + } else { + $frontend = $this->defaultCacheConfiguration['frontend']; + } + if (isset($this->cacheConfigurations[$identifier]['backend'])) { + $backend = $this->cacheConfigurations[$identifier]['backend']; + } else { + $backend = $this->defaultCacheConfiguration['backend']; + } + if (isset($this->cacheConfigurations[$identifier]['options'])) { + $backendOptions = $this->cacheConfigurations[$identifier]['options']; + } else { + $backendOptions = $this->defaultCacheConfiguration['options']; + } + // Normalize legacy non-bool 'compression' values for strictly typed backend setters. + if (isset($backendOptions['compression']) && !is_bool($backendOptions['compression'])) { + $backendOptions['compression'] = (bool)$backendOptions['compression']; + } + + if ($this->disableCaching && $backend !== TransientMemoryBackend::class) { + $backend = NullBackend::class; + $backendOptions = []; + } + + // Add the cache identifier to the groups that it should be attached to, or use the default ones. + if (isset($this->cacheConfigurations[$identifier]['groups']) && is_array($this->cacheConfigurations[$identifier]['groups'])) { + $assignedGroups = $this->cacheConfigurations[$identifier]['groups']; + } else { + $assignedGroups = $this->defaultCacheConfiguration['groups']; + } + foreach ($assignedGroups as $groupIdentifier) { + if (!isset($this->cacheGroups[$groupIdentifier])) { + $this->cacheGroups[$groupIdentifier] = []; + } + $this->cacheGroups[$groupIdentifier][] = $identifier; + } + + // New operator used on purpose: This class is required early during + // bootstrap before makeInstance() is properly set up + $backend = '\\' . ltrim($backend, '\\'); + $backendInstance = new $backend($backendOptions); + if (!$backendInstance instanceof BackendInterface) { + throw new InvalidBackendException('"' . $backend . '" is not a valid cache backend object.', 1464550977); + } + if (is_callable([$backendInstance, 'initializeObject'])) { + $backendInstance->initializeObject(); + } + + // New used on purpose, see comment above + $frontendInstance = new $frontend($identifier, $backendInstance); + if (!$frontendInstance instanceof FrontendInterface) { + throw new InvalidCacheException('"' . $frontend . '" is not a valid cache frontend object.', 1464550984); + } + if (is_callable([$frontendInstance, 'initializeObject'])) { + $frontendInstance->initializeObject(); + } + + $this->registerCache($frontendInstance); + } +} diff --git a/Classes/Cache/CacheTag.php b/Classes/Cache/CacheTag.php new file mode 100644 index 0000000..2261d38 --- /dev/null +++ b/Classes/Cache/CacheTag.php @@ -0,0 +1,26 @@ +addSqlData($this->getCachingFrameworkRequiredDatabaseSchema()); + } + + /** + * Get schema SQL of required cache framework tables. + * + * This method needs ext_localconf loaded! + * + * @return string Cache framework SQL + */ + private function getCachingFrameworkRequiredDatabaseSchema(): string + { + // Use new to circumvent the singleton pattern of CacheManager + $cacheManager = new CacheManager(); + $cacheManager->setCacheConfigurations($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']); + $tableDefinitions = ''; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] as $cacheName => $_) { + $backend = $cacheManager->getCache($cacheName)->getBackend(); + if (method_exists($backend, 'getTableDefinitions')) { + $tableDefinitions .= LF . $backend->getTableDefinitions(); + } + } + return $tableDefinitions; + } +} diff --git a/Classes/Cache/Event/AddCacheTagEvent.php b/Classes/Cache/Event/AddCacheTagEvent.php new file mode 100644 index 0000000..268c68e --- /dev/null +++ b/Classes/Cache/Event/AddCacheTagEvent.php @@ -0,0 +1,37 @@ +getAttribute('frontend.cache.collector')->addCacheTags(...) + * directly. It's really just there to allow passive cache-data signaling, without exactly knowing the + * current context. + * + * @internal This event is a tribute to core places that need to set cache tags but do not have the + * current request yet. The FE CacheDataCollectorAttribute listens on this event. It + * may vanish later without further notice. + */ +final readonly class AddCacheTagEvent +{ + public function __construct( + public CacheTag $cacheTag, + ) {} +} diff --git a/Classes/Cache/Event/CacheFlushEvent.php b/Classes/Cache/Event/CacheFlushEvent.php new file mode 100644 index 0000000..cba7c16 --- /dev/null +++ b/Classes/Cache/Event/CacheFlushEvent.php @@ -0,0 +1,48 @@ +groups; + } + + public function hasGroup(string $group): bool + { + return in_array($group, $this->groups, true); + } + + public function getErrors(): array + { + return $this->errors; + } + + public function addError(string $error): void + { + $this->errors[] = $error; + } +} diff --git a/Classes/Cache/Event/CacheWarmupEvent.php b/Classes/Cache/Event/CacheWarmupEvent.php new file mode 100644 index 0000000..4cc0bcf --- /dev/null +++ b/Classes/Cache/Event/CacheWarmupEvent.php @@ -0,0 +1,48 @@ +groups; + } + + public function hasGroup(string $group): bool + { + return in_array($group, $this->groups, true); + } + + public function getErrors(): array + { + return $this->errors; + } + + public function addError(string $error): void + { + $this->errors[] = $error; + } +} diff --git a/Classes/Cache/Exception.php b/Classes/Cache/Exception.php new file mode 100644 index 0000000..147b868 --- /dev/null +++ b/Classes/Cache/Exception.php @@ -0,0 +1,23 @@ +identifier = $identifier; + $this->backend->setCache($this); + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getBackend(): BackendInterface + { + return $this->backend; + } + + public function has(string $entryIdentifier): bool + { + if (!$this->isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException('"' . $entryIdentifier . '" is not a valid cache entry identifier.', 1233058486); + } + return $this->backend->has($entryIdentifier); + } + + public function remove(string $entryIdentifier): bool + { + if (!$this->isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException('"' . $entryIdentifier . '" is not a valid cache entry identifier.', 1233058495); + } + return $this->backend->remove($entryIdentifier); + } + + public function flush(): void + { + $this->backend->flush(); + } + + public function flushByTags(array $tags): void + { + if (!$this->backend instanceof TaggableBackendInterface) { + return; + } + + foreach ($tags as $tag) { + if (!$this->isValidTag($tag)) { + throw new \InvalidArgumentException('"' . $tag . '" is not a valid tag for a cache entry.', 1233057360); + } + } + + $this->backend->flushByTags($tags); + } + + public function flushByTag(string $tag): void + { + if (!$this->backend instanceof TaggableBackendInterface) { + return; + } + + if (!$this->isValidTag($tag)) { + throw new \InvalidArgumentException('"' . $tag . '" is not a valid tag for a cache entry.', 1233057359); + } + + $this->backend->flushByTag($tag); + } + + public function collectGarbage(): void + { + $this->backend->collectGarbage(); + } + + public function isValidEntryIdentifier(string $identifier): bool + { + return preg_match(self::PATTERN_ENTRYIDENTIFIER, $identifier) === 1; + } + + public function isValidTag(string $tag): bool + { + return preg_match(self::PATTERN_TAG, $tag) === 1; + } +} diff --git a/Classes/Cache/Frontend/FrontendInterface.php b/Classes/Cache/Frontend/FrontendInterface.php new file mode 100644 index 0000000..1ea4436 --- /dev/null +++ b/Classes/Cache/Frontend/FrontendInterface.php @@ -0,0 +1,121 @@ +isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException('"' . $entryIdentifier . '" is not a valid cache entry identifier.', 1264023823); + } + if (!is_string($data)) { + throw new InvalidDataException('The given source code is not a valid string.', 1264023824); + } + foreach ($tags as $tag) { + if (!$this->isValidTag($tag)) { + throw new \InvalidArgumentException('"' . $tag . '" is not a valid tag for a cache entry.', 1264023825); + } + } + $sourceCode = 'backend->set($entryIdentifier, $sourceCode, $tags, $lifetime); + } + + public function get(string $entryIdentifier): mixed + { + if (!$this->isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException('"' . $entryIdentifier . '" is not a valid cache entry identifier.', 1233057753); + } + return $this->backend->get($entryIdentifier); + } + + /** + * Loads PHP code from the cache and require_onces it right away. + * + * @param string $entryIdentifier An identifier which describes the cache entry to load + * @return mixed Potential return value from the include operation + */ + public function requireOnce(string $entryIdentifier): mixed + { + $backend = $this->getBackend(); + if (!($backend instanceof PhpCapableBackendInterface)) { + throw new \RuntimeException('Can not require: Not a PhpCapableBackendInterface', 1763660480); + } + return $backend->requireOnce($entryIdentifier); + } + + /** + * Loads PHP code from the cache and require() it right away. Note require() + * in comparison to requireOnce() is only "safe" if the cache entry only contain stuff + * that can be required multiple times during one request. For instance a class definition + * would fail here. + * + * @param string $entryIdentifier An identifier which describes the cache entry to load + * @return mixed Potential return value from the include operation + */ + public function require(string $entryIdentifier): mixed + { + $backend = $this->getBackend(); + if (!($backend instanceof PhpCapableBackendInterface)) { + throw new \RuntimeException('Can not require: Not a PhpCapableBackendInterface', 1763660481); + } + return $backend->require($entryIdentifier); + } +} diff --git a/Classes/Cache/Frontend/VariableFrontend.php b/Classes/Cache/Frontend/VariableFrontend.php new file mode 100644 index 0000000..d945fff --- /dev/null +++ b/Classes/Cache/Frontend/VariableFrontend.php @@ -0,0 +1,94 @@ +isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException( + '"' . $entryIdentifier . '" is not a valid cache entry identifier.', + 1233058264 + ); + } + foreach ($tags as $tag) { + if (!$this->isValidTag($tag)) { + throw new \InvalidArgumentException('"' . $tag . '" is not a valid tag for a cache entry.', 1233058269); + } + } + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/cache/frontend/class.t3lib_cache_frontend_variablefrontend.php']['set'] ?? [] as $_funcRef) { + $params = [ + 'entryIdentifier' => &$entryIdentifier, + 'variable' => &$data, + 'tags' => &$tags, + 'lifetime' => &$lifetime, + ]; + GeneralUtility::callUserFunction($_funcRef, $params, $this); + } + if (!$this->backend instanceof TransientBackendInterface) { + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + $data = $deserializer->serialize($data, VariableFrontend::class); + } + $this->backend->set($entryIdentifier, $data, $tags, $lifetime); + } + + /** + * Finds and returns a variable value from the cache. + */ + public function get(string $entryIdentifier): mixed + { + if (!$this->isValidEntryIdentifier($entryIdentifier)) { + throw new \InvalidArgumentException( + '"' . $entryIdentifier . '" is not a valid cache entry identifier.', + 1233058294 + ); + } + $rawResult = $this->backend->get($entryIdentifier); + if ($rawResult === false) { + return false; + } + if ($this->backend instanceof TransientBackendInterface) { + return $rawResult; + } + try { + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + return $deserializer->deserialize($rawResult, VariableFrontend::class); + } catch (DeserializerException) { + return false; + } + } +} diff --git a/Classes/Category/Collection/CategoryCollection.php b/Classes/Category/Collection/CategoryCollection.php new file mode 100644 index 0000000..6d5d422 --- /dev/null +++ b/Classes/Category/Collection/CategoryCollection.php @@ -0,0 +1,343 @@ +setItemTableName($tableName); + } elseif (empty($this->itemTableName)) { + throw new \RuntimeException(self::class . ' needs a valid itemTableName.', 1341826168); + } + if (!empty($fieldName)) { + $this->setRelationFieldName($fieldName); + } + } + + /** + * Creates a new collection objects and reconstitutes the + * given database record to the new object. + * + * @param array $collectionRecord Database record + * @param bool $fillItems Populates the entries directly on load, might be bad for memory on large collections + * @return CategoryCollection + */ + public static function create(array $collectionRecord, $fillItems = false) + { + $collection = GeneralUtility::makeInstance( + self::class, + $collectionRecord['table_name'], + $collectionRecord['field_name'] + ); + $collection->fromArray($collectionRecord); + if ($fillItems) { + $collection->loadContents(); + } + return $collection; + } + + /** + * Loads the collections with the given id from persistence + * For memory reasons, per default only f.e. title, database-table, + * identifier (what ever static data is defined) is loaded. + * Entries can be load on first access. + * + * @param int $id Id of database record to be loaded + * @param bool $fillItems Populates the entries directly on load, might be bad for memory on large collections + * @param string $tableName Name of table from which entries should be loaded + * @param string $fieldName Name of the categories relation field + * @return CategoryCollection + */ + public static function load($id, $fillItems = false, $tableName = '', $fieldName = '') + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable(static::$storageTableName); + + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $collectionRecord = $queryBuilder->select('*') + ->from(static::$storageTableName) + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, Connection::PARAM_INT)) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if ($collectionRecord === false) { + return GeneralUtility::makeInstance( + self::class, + $tableName, + $fieldName + ); + } + + $collectionRecord['table_name'] = $tableName; + $collectionRecord['field_name'] = $fieldName; + + return self::create($collectionRecord, $fillItems); + } + + /** + * Selects the collected records in this collection, by + * looking up the MM relations of this record to the + * table name defined in the local field 'table_name'. + * + * @return QueryBuilder + */ + protected function getCollectedRecordsQueryBuilder() + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable(static::$storageTableName); + $queryBuilder->getRestrictions()->removeAll(); + + $queryBuilder->select($this->getItemTableName() . '.*') + ->from(static::$storageTableName) + ->join( + static::$storageTableName, + 'sys_category_record_mm', + 'sys_category_record_mm', + $queryBuilder->expr()->eq( + 'sys_category_record_mm.uid_local', + $queryBuilder->quoteIdentifier(static::$storageTableName . '.uid') + ) + ) + ->join( + 'sys_category_record_mm', + $this->getItemTableName(), + $this->getItemTableName(), + $queryBuilder->expr()->eq( + 'sys_category_record_mm.uid_foreign', + $queryBuilder->quoteIdentifier($this->getItemTableName() . '.uid') + ) + ) + ->where( + $queryBuilder->expr()->eq( + static::$storageTableName . '.uid', + $queryBuilder->createNamedParameter($this->getIdentifier(), Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'sys_category_record_mm.tablenames', + $queryBuilder->createNamedParameter($this->getItemTableName()) + ), + $queryBuilder->expr()->eq( + 'sys_category_record_mm.fieldname', + $queryBuilder->createNamedParameter($this->getRelationFieldName()) + ) + ) + // Add required sorting field. + ->orderBy('sys_category_record_mm.sorting', 'ASC') + // Add foreign uid field to ensure determistic sorting across dbms and dbms versions + ->addOrderBy('sys_category_record_mm.uid_foreign', 'ASC') + ; + + return $queryBuilder; + } + + /** + * Gets the collected records in this collection, by + * using . + * + * @return array + */ + protected function getCollectedRecords() + { + $relatedRecords = []; + + $queryBuilder = $this->getCollectedRecordsQueryBuilder(); + $result = $queryBuilder->executeQuery(); + + while ($record = $result->fetchAssociative()) { + $relatedRecords[] = $record; + } + + return $relatedRecords; + } + + /** + * Populates the content-entries of the storage + * Queries the underlying storage for entries of the collection + * and adds them to the collection data. + * If the content entries of the storage had not been loaded on creation + * ($fillItems = false) this function is to be used for loading the contents + * afterwards. + */ + public function loadContents() + { + $entries = $this->getCollectedRecords(); + $this->removeAll(); + foreach ($entries as $entry) { + $this->add($entry); + } + } + + /** + * Returns an array of the persistable properties and contents + * which are processable by DataHandler. + * for internal usage in persist only. + * + * @return array + */ + protected function getPersistableDataArray() + { + return [ + 'title' => $this->getTitle(), + 'description' => $this->getDescription(), + 'items' => $this->getItemUidList(true), + ]; + } + + /** + * Adds on entry to the collection + * + * @param mixed $data + */ + public function add($data) + { + $this->storage->push($data); + } + + /** + * Adds a set of entries to the collection + */ + public function addAll(CollectionInterface $other) + { + foreach ($other as $value) { + $this->add($value); + } + } + + /** + * Removes the given entry from collection + * Note: not the given "index" + * + * @param mixed $data + */ + public function remove($data) + { + $offset = 0; + foreach ($this->storage as $value) { + if ($value == $data) { + break; + } + $offset++; + } + $this->storage->offsetUnset($offset); + } + + /** + * Removes all entries from the collection + * collection will be empty afterwards + */ + public function removeAll() + { + $this->storage = new \SplDoublyLinkedList(); + } + + /** + * Gets the current available items. + * + * @return array + */ + public function getItems() + { + $itemArray = []; + /** @var File $item */ + foreach ($this->storage as $item) { + $itemArray[] = $item; + } + return $itemArray; + } + + /** + * Sets the name of the categories relation field + * + * @param string $field + */ + public function setRelationFieldName($field) + { + $this->relationFieldName = $field; + } + + /** + * Gets the name of the categories relation field + * + * @return string + */ + public function getRelationFieldName() + { + return $this->relationFieldName; + } + + /** + * Getter for the storage table name + * + * @return string + */ + public static function getStorageTableName() + { + return self::$storageTableName; + } + + /** + * Getter for the storage items field + * + * @return string + */ + public static function getStorageItemsField() + { + return self::$storageItemsField; + } +} diff --git a/Classes/Charset/CharsetConverter.php b/Classes/Charset/CharsetConverter.php new file mode 100644 index 0000000..233cc1a --- /dev/null +++ b/Classes/Charset/CharsetConverter.php @@ -0,0 +1,154 @@ + 127) { + // Since the first byte must have the 7th bit set we check that. Otherwise, we might be in the middle of a byte sequence. + if ($ord & 64) { + // Add first byte + $buf = $chr; + // For each byte in multibyte string... + for ($b = 0; $b < 8; $b++) { + // Shift it left and ... + $ord <<= 1; + // ... and with 8th bit - if that is set, then there are still bytes in sequence. + if ($ord & 128) { + $a++; + // ... and add the next char. + $buf .= $str[$a]; + } else { + break; + } + } + $outArr[] = $buf; + } else { + $outArr[] = self::FALLBACK_CHAR; + } + } else { + $outArr[] = chr($ord); + } + } + return $outArr; + } + + /** + * Converts a UTF-8 Multibyte character to a UNICODE number + * Unit-tested by Kasper + * + * @param string $str UTF-8 multibyte character string + * @param bool $hex If set, then a hex. number is returned. + * @return ($hex is false ? int : string) UNICODE integer + */ + public function utf8CharToUnumber(string $str, bool $hex = false): int|string + { + // First char + $ord = ord($str[0]); + // This verifies that it IS a multibyte string + if (($ord & 192) === 192) { + $binBuf = ''; + $b = 0; + // For each byte in multibyte string... + for (; $b < 8; $b++) { + // Shift it left and ... + $ord <<= 1; + // ... and with 8th bit - if that is set, then there are still bytes in sequence. + if ($ord & 128) { + $binBuf .= substr('00000000' . decbin(ord($str[$b + 1])), -6); + } else { + break; + } + } + $binBuf = substr('00000000' . decbin(ord($str[0])), -(6 - $b)) . $binBuf; + $int = bindec($binBuf); + } else { + $int = $ord; + } + return $hex ? 'x' . dechex((int)$int) : $int; + } + + /** + * Maps all characters of a UTF-8 string. + * + * @param string $str UTF-8 string + */ + public function utf8_char_mapping(string $str): string + { + $out = ''; + for ($i = 0; isset($str[$i]); $i++) { + $c = ord($str[$i]); + $mbc = ''; + // single-byte (0xxxxxx) + if (!($c & 128)) { + $mbc = $str[$i]; + } elseif (($c & 192) === 192) { + $bc = 0; + // multibyte starting byte (11xxxxxx) + for (; $c & 128; $c <<= 1) { + $bc++; + } + // calculate number of bytes + $mbc = substr($str, $i, $bc); + $i += $bc - 1; + } + if ($this->charsetProvider->hasMultibyteChar('utf-8', $mbc)) { + $out .= $this->charsetProvider->getByMultibyteChar('utf-8', $mbc); + } else { + $out .= $mbc; + } + } + return $out; + } +} diff --git a/Classes/Charset/CharsetProvider.php b/Classes/Charset/CharsetProvider.php new file mode 100644 index 0000000..526e9c3 --- /dev/null +++ b/Classes/Charset/CharsetProvider.php @@ -0,0 +1,2516 @@ +> + */ + private const array RAW_DATA = [ + 'utf-8' => [ + ' ' => ' ', + 'ª' => 'a', + '²' => '2', + '³' => '3', + 'µ' => 'u', + '¹' => '1', + 'º' => 'o', + '¼' => '1/4', + '½' => '1/2', + '¾' => '3/4', + 'À' => 'A', + 'Á' => 'A', + 'Â' => 'A', + 'Ã' => 'A', + 'Ä' => 'AE', + 'Å' => 'AA', + 'Ç' => 'C', + 'È' => 'E', + 'É' => 'E', + 'Ê' => 'E', + 'Ë' => 'E', + 'Ì' => 'I', + 'Í' => 'I', + 'Î' => 'I', + 'Ï' => 'I', + 'Ñ' => 'N', + 'Ò' => 'O', + 'Ó' => 'O', + 'Ô' => 'O', + 'Õ' => 'O', + 'Ö' => 'OE', + 'Ø' => 'OE', + 'Ù' => 'U', + 'Ú' => 'U', + 'Û' => 'U', + 'Ü' => 'UE', + 'Ý' => 'Y', + 'à' => 'a', + 'á' => 'a', + 'â' => 'a', + 'ã' => 'a', + 'ä' => 'ae', + 'å' => 'aa', + 'ç' => 'c', + 'è' => 'e', + 'é' => 'e', + 'ê' => 'e', + 'ë' => 'e', + 'ì' => 'i', + 'í' => 'i', + 'î' => 'i', + 'ï' => 'i', + 'ñ' => 'n', + 'ò' => 'o', + 'ó' => 'o', + 'ô' => 'o', + 'õ' => 'o', + 'ö' => 'oe', + 'ø' => 'oe', + 'ù' => 'u', + 'ú' => 'u', + 'û' => 'u', + 'ü' => 'ue', + 'ý' => 'y', + 'ÿ' => 'y', + 'Ā' => 'A', + 'ā' => 'a', + 'Ă' => 'A', + 'ă' => 'a', + 'Ą' => 'A', + 'ą' => 'a', + 'Ć' => 'C', + 'ć' => 'c', + 'Ĉ' => 'C', + 'ĉ' => 'c', + 'Ċ' => 'C', + 'ċ' => 'c', + 'Č' => 'C', + 'č' => 'c', + 'Ď' => 'D', + 'ď' => 'd', + 'Đ' => 'D', + 'đ' => 'd', + 'Ē' => 'E', + 'ē' => 'e', + 'Ĕ' => 'E', + 'ĕ' => 'e', + 'Ė' => 'E', + 'ė' => 'e', + 'Ę' => 'E', + 'ę' => 'e', + 'Ě' => 'E', + 'ě' => 'e', + 'Ĝ' => 'G', + 'ĝ' => 'g', + 'Ğ' => 'G', + 'ğ' => 'g', + 'Ġ' => 'G', + 'ġ' => 'g', + 'Ģ' => 'G', + 'ģ' => 'g', + 'Ĥ' => 'H', + 'ĥ' => 'h', + 'Ħ' => 'H', + 'ħ' => 'h', + 'Ĩ' => 'I', + 'ĩ' => 'i', + 'Ī' => 'I', + 'ī' => 'i', + 'Ĭ' => 'I', + 'ĭ' => 'i', + 'Į' => 'I', + 'į' => 'i', + 'İ' => 'I', + 'IJ' => 'IJ', + 'ij' => 'ij', + 'Ĵ' => 'J', + 'ĵ' => 'j', + 'Ķ' => 'K', + 'ķ' => 'k', + 'Ĺ' => 'L', + 'ĺ' => 'l', + 'Ļ' => 'L', + 'ļ' => 'l', + 'Ľ' => 'L', + 'ľ' => 'l', + 'Ŀ' => 'L*', + 'ŀ' => 'l*', + 'Ł' => 'L', + 'ł' => 'l', + 'Ń' => 'N', + 'ń' => 'n', + 'Ņ' => 'N', + 'ņ' => 'n', + 'Ň' => 'N', + 'ň' => 'n', + 'ʼn' => '\'n', + 'Ō' => 'OO', + 'ō' => 'oo', + 'Ŏ' => 'O', + 'ŏ' => 'o', + 'Ő' => 'O', + 'ő' => 'o', + 'Ŕ' => 'R', + 'ŕ' => 'r', + 'Ŗ' => 'R', + 'ŗ' => 'r', + 'Ř' => 'R', + 'ř' => 'r', + 'Ś' => 'S', + 'ś' => 's', + 'Ŝ' => 'S', + 'ŝ' => 's', + 'Ş' => 'S', + 'ş' => 's', + 'Š' => 'S', + 'š' => 's', + 'Ţ' => 'T', + 'ţ' => 't', + 'Ť' => 'T', + 'ť' => 't', + 'Ŧ' => 'T', + 'ŧ' => 't', + 'Ũ' => 'U', + 'ũ' => 'u', + 'Ū' => 'U', + 'ū' => 'u', + 'Ŭ' => 'U', + 'ŭ' => 'u', + 'Ů' => 'U', + 'ů' => 'u', + 'Ű' => 'U', + 'ű' => 'u', + 'Ų' => 'U', + 'ų' => 'u', + 'Ŵ' => 'W', + 'ŵ' => 'w', + 'Ŷ' => 'Y', + 'ŷ' => 'y', + 'Ÿ' => 'Y', + 'Ź' => 'Z', + 'ź' => 'z', + 'Ż' => 'Z', + 'ż' => 'z', + 'Ž' => 'Z', + 'ž' => 'z', + 'ſ' => 's', + 'ƀ' => 'b', + 'Ɓ' => 'B', + 'Ƃ' => 'B', + 'ƃ' => 'b', + 'Ƈ' => 'C', + 'ƈ' => 'c', + 'Ɗ' => 'D', + 'Ƌ' => 'D', + 'ƌ' => 'd', + 'Ƒ' => 'F', + 'ƒ' => 'f', + 'Ɠ' => 'G', + 'Ɨ' => 'I', + 'Ƙ' => 'K', + 'ƙ' => 'k', + 'ƚ' => 'l', + 'Ɲ' => 'N', + 'ƞ' => 'n', + 'Ɵ' => 'O', + 'Ơ' => 'O', + 'ơ' => 'o', + 'Ƥ' => 'P', + 'ƥ' => 'p', + 'ƫ' => 't', + 'Ƭ' => 'T', + 'ƭ' => 't', + 'Ʈ' => 'T', + 'Ư' => 'U', + 'ư' => 'u', + 'Ʋ' => 'V', + 'Ƴ' => 'Y', + 'ƴ' => 'y', + 'Ƶ' => 'Z', + 'ƶ' => 'z', + 'DŽ' => 'DZ', + 'Dž' => 'Dz', + 'dž' => 'dz', + 'LJ' => 'LJ', + 'Lj' => 'Lj', + 'lj' => 'lj', + 'NJ' => 'NJ', + 'Nj' => 'Nj', + 'nj' => 'nj', + 'Ǎ' => 'A', + 'ǎ' => 'a', + 'Ǐ' => 'I', + 'ǐ' => 'i', + 'Ǒ' => 'O', + 'ǒ' => 'o', + 'Ǔ' => 'U', + 'ǔ' => 'u', + 'Ǖ' => 'UE', + 'ǖ' => 'ue', + 'Ǘ' => 'UE', + 'ǘ' => 'ue', + 'Ǚ' => 'UE', + 'ǚ' => 'ue', + 'Ǜ' => 'UE', + 'ǜ' => 'ue', + 'Ǟ' => 'AE', + 'ǟ' => 'ae', + 'Ǡ' => 'A', + 'ǡ' => 'a', + 'Ǣ' => 'AE', + 'ǣ' => 'ae', + 'Ǥ' => 'G', + 'ǥ' => 'g', + 'Ǧ' => 'G', + 'ǧ' => 'g', + 'Ǩ' => 'K', + 'ǩ' => 'k', + 'Ǫ' => 'O', + 'ǫ' => 'o', + 'Ǭ' => 'O', + 'ǭ' => 'o', + 'ǰ' => 'j', + 'DZ' => 'DZ', + 'Dz' => 'Dz', + 'dz' => 'dz', + 'Ǵ' => 'G', + 'ǵ' => 'g', + 'Ǹ' => 'N', + 'ǹ' => 'n', + 'Ǻ' => 'AA', + 'ǻ' => 'aa', + 'Ǽ' => 'AE', + 'ǽ' => 'ae', + 'Ǿ' => 'OE', + 'ǿ' => 'oe', + 'Ȁ' => 'A', + 'ȁ' => 'a', + 'Ȃ' => 'A', + 'ȃ' => 'a', + 'Ȅ' => 'E', + 'ȅ' => 'e', + 'Ȇ' => 'E', + 'ȇ' => 'e', + 'Ȉ' => 'I', + 'ȉ' => 'i', + 'Ȋ' => 'I', + 'ȋ' => 'i', + 'Ȍ' => 'O', + 'ȍ' => 'o', + 'Ȏ' => 'O', + 'ȏ' => 'o', + 'Ȑ' => 'R', + 'ȑ' => 'r', + 'Ȓ' => 'R', + 'ȓ' => 'r', + 'Ȕ' => 'U', + 'ȕ' => 'u', + 'Ȗ' => 'U', + 'ȗ' => 'u', + 'Ș' => 'S', + 'ș' => 's', + 'Ț' => 'T', + 'ț' => 't', + 'Ȟ' => 'H', + 'ȟ' => 'h', + 'Ƞ' => 'N', + 'ȡ' => 'd', + 'Ȥ' => 'Z', + 'ȥ' => 'z', + 'Ȧ' => 'A', + 'ȧ' => 'a', + 'Ȩ' => 'E', + 'ȩ' => 'e', + 'Ȫ' => 'OE', + 'ȫ' => 'oe', + 'Ȭ' => 'O', + 'ȭ' => 'o', + 'Ȯ' => 'O', + 'ȯ' => 'o', + 'Ȱ' => 'O', + 'ȱ' => 'o', + 'Ȳ' => 'Y', + 'ȳ' => 'y', + 'ȴ' => 'l', + 'ȵ' => 'n', + 'ȶ' => 't', + 'Ⱥ' => 'A', + 'Ȼ' => 'C', + 'ȼ' => 'c', + 'Ƚ' => 'L', + 'Ⱦ' => 'T', + 'ȿ' => 's', + 'ɀ' => 'z', + 'Ƀ' => 'B', + 'Ɇ' => 'E', + 'ɇ' => 'e', + 'Ɉ' => 'J', + 'ɉ' => 'j', + 'ɋ' => 'q', + 'Ɍ' => 'R', + 'ɍ' => 'r', + 'Ɏ' => 'Y', + 'ɏ' => 'y', + 'ɓ' => 'b', + 'ɕ' => 'c', + 'ɖ' => 'd', + 'ɗ' => 'd', + 'ɠ' => 'g', + 'ɦ' => 'h', + 'ɨ' => 'i', + 'ɫ' => 'l', + 'ɬ' => 'l', + 'ɭ' => 'l', + 'ɱ' => 'm', + 'ɲ' => 'n', + 'ɳ' => 'n', + 'ɼ' => 'r', + 'ɽ' => 'r', + 'ɾ' => 'r', + 'ʂ' => 's', + 'ʈ' => 't', + 'ʋ' => 'v', + 'ʐ' => 'z', + 'ʑ' => 'z', + 'ʝ' => 'j', + 'ʠ' => 'q', + 'ʰ' => 'h', + 'ʱ' => 'h', + 'ʲ' => 'j', + 'ʳ' => 'r', + 'ʷ' => 'w', + 'ʸ' => 'y', + 'ˡ' => 'l', + 'ˢ' => 's', + 'ˣ' => 'x', + 'ʹ' => '\'', + ';' => '?', + 'Ά' => 'f', + '·' => '*', + 'Έ' => 'E', + 'Ή' => 'I', + 'Ί' => 'I', + 'Ό' => 'O', + 'Ύ' => 'Y', + 'Ώ' => 'O', + 'ΐ' => 'i', + 'Ϊ' => 'I', + 'Ϋ' => 'Y', + 'ά' => 'a', + 'έ' => 'e', + 'ή' => 'i', + 'ί' => 'i', + 'ΰ' => 'y', + 'ϊ' => 'i', + 'ϋ' => 'y', + 'ό' => 'o', + 'ύ' => 'y', + 'ώ' => 'o', + 'ϐ' => 'b', + 'ϑ' => 'th', + 'ϒ' => 'Y', + 'ϓ' => 'Y', + 'ϔ' => 'Y', + 'ϕ' => 'f', + 'ϖ' => 'p', + 'ϰ' => 'k', + 'ϱ' => 'r', + 'ϲ' => 's', + 'ϴ' => 'TH', + 'ϵ' => 'e', + 'Ϲ' => 'S', + 'Ѐ' => 'E', + 'Ё' => 'JO', + 'Ѓ' => 'G', + 'Ї' => 'I', + 'Ќ' => 'K', + 'Ѝ' => 'I', + 'Ў' => 'U', + 'Й' => 'I', + 'й' => 'i', + 'ѐ' => 'e', + 'ё' => 'jo', + 'ѓ' => 'g', + 'ї' => 'ji', + 'ќ' => 'k', + 'ѝ' => 'i', + 'ў' => 'u', + 'Ӂ' => 'ZH', + 'ӂ' => 'zh', + 'Ӑ' => 'A', + 'ӑ' => 'a', + 'Ӓ' => 'A', + 'ӓ' => 'a', + 'Ӗ' => 'E', + 'ӗ' => 'e', + 'Ӝ' => 'ZH', + 'ӝ' => 'zh', + 'Ӟ' => 'Z', + 'ӟ' => 'z', + 'Ӣ' => 'I', + 'ӣ' => 'i', + 'Ӥ' => 'I', + 'ӥ' => 'i', + 'Ӧ' => 'O', + 'ӧ' => 'o', + 'Ӭ' => 'EH', + 'ӭ' => 'eh', + 'Ӯ' => 'U', + 'ӯ' => 'u', + 'Ӱ' => 'U', + 'ӱ' => 'u', + 'Ӳ' => 'U', + 'ӳ' => 'u', + 'Ӵ' => 'CH', + 'ӵ' => 'ch', + 'Ӹ' => 'Y', + 'ӹ' => 'y', + 'ᴬ' => 'A', + 'ᴭ' => 'AE', + 'ᴮ' => 'B', + 'ᴰ' => 'D', + 'ᴱ' => 'E', + 'ᴳ' => 'G', + 'ᴴ' => 'H', + 'ᴵ' => 'I', + 'ᴶ' => 'J', + 'ᴷ' => 'K', + 'ᴸ' => 'L', + 'ᴹ' => 'M', + 'ᴺ' => 'N', + 'ᴼ' => 'O', + 'ᴾ' => 'P', + 'ᴿ' => 'R', + 'ᵀ' => 'T', + 'ᵁ' => 'U', + 'ᵂ' => 'W', + 'ᵃ' => 'a', + 'ᵇ' => 'b', + 'ᵈ' => 'd', + 'ᵉ' => 'e', + 'ᵍ' => 'g', + 'ᵏ' => 'k', + 'ᵐ' => 'm', + 'ᵒ' => 'o', + 'ᵖ' => 'p', + 'ᵗ' => 't', + 'ᵘ' => 'u', + 'ᵛ' => 'v', + 'ᵝ' => 'b', + 'ᵞ' => 'g', + 'ᵟ' => 'd', + 'ᵠ' => 'f', + 'ᵡ' => 'ch', + 'ᵢ' => 'i', + 'ᵣ' => 'r', + 'ᵤ' => 'u', + 'ᵥ' => 'v', + 'ᵦ' => 'b', + 'ᵧ' => 'g', + 'ᵨ' => 'r', + 'ᵩ' => 'f', + 'ᵪ' => 'ch', + 'ᵬ' => 'b', + 'ᵭ' => 'd', + 'ᵮ' => 'f', + 'ᵯ' => 'm', + 'ᵰ' => 'n', + 'ᵱ' => 'p', + 'ᵲ' => 'r', + 'ᵳ' => 'r', + 'ᵴ' => 's', + 'ᵵ' => 't', + 'ᵶ' => 'z', + 'ᵸ' => 'n', + 'ᵽ' => 'p', + 'ᶀ' => 'b', + 'ᶁ' => 'd', + 'ᶂ' => 'f', + 'ᶃ' => 'g', + 'ᶄ' => 'k', + 'ᶅ' => 'l', + 'ᶆ' => 'm', + 'ᶇ' => 'n', + 'ᶈ' => 'p', + 'ᶉ' => 'r', + 'ᶊ' => 's', + 'ᶌ' => 'v', + 'ᶍ' => 'x', + 'ᶎ' => 'z', + 'ᶏ' => 'a', + 'ᶑ' => 'd', + 'ᶒ' => 'e', + 'ᶖ' => 'i', + 'ᶙ' => 'u', + 'ᶜ' => 'c', + 'ᶝ' => 'c', + 'ᶞ' => 'd', + 'ᶠ' => 'f', + 'ᶤ' => 'i', + 'ᶨ' => 'j', + 'ᶩ' => 'l', + 'ᶪ' => 'l', + 'ᶬ' => 'm', + 'ᶮ' => 'n', + 'ᶯ' => 'n', + 'ᶳ' => 's', + 'ᶵ' => 't', + 'ᶹ' => 'v', + 'ᶻ' => 'z', + 'ᶼ' => 'z', + 'ᶽ' => 'z', + 'ᶿ' => 'th', + 'Ḁ' => 'A', + 'ḁ' => 'a', + 'Ḃ' => 'B', + 'ḃ' => 'b', + 'Ḅ' => 'B', + 'ḅ' => 'b', + 'Ḇ' => 'B', + 'ḇ' => 'b', + 'Ḉ' => 'C', + 'ḉ' => 'c', + 'Ḋ' => 'D', + 'ḋ' => 'd', + 'Ḍ' => 'D', + 'ḍ' => 'd', + 'Ḏ' => 'D', + 'ḏ' => 'd', + 'Ḑ' => 'D', + 'ḑ' => 'd', + 'Ḓ' => 'D', + 'ḓ' => 'd', + 'Ḕ' => 'E', + 'ḕ' => 'e', + 'Ḗ' => 'E', + 'ḗ' => 'e', + 'Ḙ' => 'E', + 'ḙ' => 'e', + 'Ḛ' => 'E', + 'ḛ' => 'e', + 'Ḝ' => 'E', + 'ḝ' => 'e', + 'Ḟ' => 'F', + 'ḟ' => 'f', + 'Ḡ' => 'G', + 'ḡ' => 'g', + 'Ḣ' => 'H', + 'ḣ' => 'h', + 'Ḥ' => 'H', + 'ḥ' => 'h', + 'Ḧ' => 'H', + 'ḧ' => 'h', + 'Ḩ' => 'H', + 'ḩ' => 'h', + 'Ḫ' => 'H', + 'ḫ' => 'h', + 'Ḭ' => 'I', + 'ḭ' => 'i', + 'Ḯ' => 'I', + 'ḯ' => 'i', + 'Ḱ' => 'K', + 'ḱ' => 'k', + 'Ḳ' => 'K', + 'ḳ' => 'k', + 'Ḵ' => 'K', + 'ḵ' => 'k', + 'Ḷ' => 'L', + 'ḷ' => 'l', + 'Ḹ' => 'L', + 'ḹ' => 'l', + 'Ḻ' => 'L', + 'ḻ' => 'l', + 'Ḽ' => 'L', + 'ḽ' => 'l', + 'Ḿ' => 'M', + 'ḿ' => 'm', + 'Ṁ' => 'M', + 'ṁ' => 'm', + 'Ṃ' => 'M', + 'ṃ' => 'm', + 'Ṅ' => 'N', + 'ṅ' => 'n', + 'Ṇ' => 'N', + 'ṇ' => 'n', + 'Ṉ' => 'N', + 'ṉ' => 'n', + 'Ṋ' => 'N', + 'ṋ' => 'n', + 'Ṍ' => 'O', + 'ṍ' => 'o', + 'Ṏ' => 'O', + 'ṏ' => 'o', + 'Ṑ' => 'OO', + 'ṑ' => 'oo', + 'Ṓ' => 'OO', + 'ṓ' => 'oo', + 'Ṕ' => 'P', + 'ṕ' => 'p', + 'Ṗ' => 'P', + 'ṗ' => 'p', + 'Ṙ' => 'R', + 'ṙ' => 'r', + 'Ṛ' => 'R', + 'ṛ' => 'r', + 'Ṝ' => 'R', + 'ṝ' => 'r', + 'Ṟ' => 'R', + 'ṟ' => 'r', + 'Ṡ' => 'S', + 'ṡ' => 's', + 'Ṣ' => 'S', + 'ṣ' => 's', + 'Ṥ' => 'S', + 'ṥ' => 's', + 'Ṧ' => 'S', + 'ṧ' => 's', + 'Ṩ' => 'S', + 'ṩ' => 's', + 'Ṫ' => 'T', + 'ṫ' => 't', + 'Ṭ' => 'T', + 'ṭ' => 't', + 'Ṯ' => 'T', + 'ṯ' => 't', + 'Ṱ' => 'T', + 'ṱ' => 't', + 'Ṳ' => 'U', + 'ṳ' => 'u', + 'Ṵ' => 'U', + 'ṵ' => 'u', + 'Ṷ' => 'U', + 'ṷ' => 'u', + 'Ṹ' => 'U', + 'ṹ' => 'u', + 'Ṻ' => 'U', + 'ṻ' => 'u', + 'Ṽ' => 'V', + 'ṽ' => 'v', + 'Ṿ' => 'V', + 'ṿ' => 'v', + 'Ẁ' => 'W', + 'ẁ' => 'w', + 'Ẃ' => 'W', + 'ẃ' => 'w', + 'Ẅ' => 'W', + 'ẅ' => 'w', + 'Ẇ' => 'W', + 'ẇ' => 'w', + 'Ẉ' => 'W', + 'ẉ' => 'w', + 'Ẋ' => 'X', + 'ẋ' => 'x', + 'Ẍ' => 'X', + 'ẍ' => 'x', + 'Ẏ' => 'Y', + 'ẏ' => 'y', + 'Ẑ' => 'Z', + 'ẑ' => 'z', + 'Ẓ' => 'Z', + 'ẓ' => 'z', + 'Ẕ' => 'Z', + 'ẕ' => 'z', + 'ẖ' => 'h', + 'ẗ' => 't', + 'ẘ' => 'w', + 'ẙ' => 'y', + 'ẛ' => 's', + 'Ạ' => 'A', + 'ạ' => 'a', + 'Ả' => 'A', + 'ả' => 'a', + 'Ấ' => 'A', + 'ấ' => 'a', + 'Ầ' => 'A', + 'ầ' => 'a', + 'Ẩ' => 'A', + 'ẩ' => 'a', + 'Ẫ' => 'A', + 'ẫ' => 'a', + 'Ậ' => 'A', + 'ậ' => 'a', + 'Ắ' => 'A', + 'ắ' => 'a', + 'Ằ' => 'A', + 'ằ' => 'a', + 'Ẳ' => 'A', + 'ẳ' => 'a', + 'Ẵ' => 'A', + 'ẵ' => 'a', + 'Ặ' => 'A', + 'ặ' => 'a', + 'Ẹ' => 'E', + 'ẹ' => 'e', + 'Ẻ' => 'E', + 'ẻ' => 'e', + 'Ẽ' => 'E', + 'ẽ' => 'e', + 'Ế' => 'E', + 'ế' => 'e', + 'Ề' => 'E', + 'ề' => 'e', + 'Ể' => 'E', + 'ể' => 'e', + 'Ễ' => 'E', + 'ễ' => 'e', + 'Ệ' => 'E', + 'ệ' => 'e', + 'Ỉ' => 'I', + 'ỉ' => 'i', + 'Ị' => 'I', + 'ị' => 'i', + 'Ọ' => 'O', + 'ọ' => 'o', + 'Ỏ' => 'O', + 'ỏ' => 'o', + 'Ố' => 'O', + 'ố' => 'o', + 'Ồ' => 'O', + 'ồ' => 'o', + 'Ổ' => 'O', + 'ổ' => 'o', + 'Ỗ' => 'O', + 'ỗ' => 'o', + 'Ộ' => 'O', + 'ộ' => 'o', + 'Ớ' => 'O', + 'ớ' => 'o', + 'Ờ' => 'O', + 'ờ' => 'o', + 'Ở' => 'O', + 'ở' => 'o', + 'Ỡ' => 'O', + 'ỡ' => 'o', + 'Ợ' => 'O', + 'ợ' => 'o', + 'Ụ' => 'U', + 'ụ' => 'u', + 'Ủ' => 'U', + 'ủ' => 'u', + 'Ứ' => 'U', + 'ứ' => 'u', + 'Ừ' => 'U', + 'ừ' => 'u', + 'Ử' => 'U', + 'ử' => 'u', + 'Ữ' => 'U', + 'ữ' => 'u', + 'Ự' => 'U', + 'ự' => 'u', + 'Ỳ' => 'Y', + 'ỳ' => 'y', + 'Ỵ' => 'Y', + 'ỵ' => 'y', + 'Ỷ' => 'Y', + 'ỷ' => 'y', + 'Ỹ' => 'Y', + 'ỹ' => 'y', + 'Ỿ' => 'Y', + 'ỿ' => 'y', + 'ἀ' => 'a', + 'ἁ' => 'a', + 'ἂ' => 'a', + 'ἃ' => 'a', + 'ἄ' => 'a', + 'ἅ' => 'a', + 'ἆ' => 'a', + 'ἇ' => 'a', + 'Ἀ' => 'A', + 'Ἁ' => 'A', + 'Ἂ' => 'A', + 'Ἃ' => 'A', + 'Ἄ' => 'A', + 'Ἅ' => 'A', + 'Ἆ' => 'A', + 'Ἇ' => 'A', + 'ἐ' => 'e', + 'ἑ' => 'e', + 'ἒ' => 'e', + 'ἓ' => 'e', + 'ἔ' => 'e', + 'ἕ' => 'e', + 'Ἐ' => 'E', + 'Ἑ' => 'E', + 'Ἒ' => 'E', + 'Ἓ' => 'E', + 'Ἔ' => 'E', + 'Ἕ' => 'E', + 'ἠ' => 'i', + 'ἡ' => 'i', + 'ἢ' => 'i', + 'ἣ' => 'i', + 'ἤ' => 'i', + 'ἥ' => 'i', + 'ἦ' => 'i', + 'ἧ' => 'i', + 'Ἠ' => 'I', + 'Ἡ' => 'I', + 'Ἢ' => 'I', + 'Ἣ' => 'I', + 'Ἤ' => 'I', + 'Ἥ' => 'I', + 'Ἦ' => 'I', + 'Ἧ' => 'I', + 'ἰ' => 'i', + 'ἱ' => 'i', + 'ἲ' => 'i', + 'ἳ' => 'i', + 'ἴ' => 'i', + 'ἵ' => 'i', + 'ἶ' => 'i', + 'ἷ' => 'i', + 'Ἰ' => 'I', + 'Ἱ' => 'I', + 'Ἲ' => 'I', + 'Ἳ' => 'I', + 'Ἴ' => 'I', + 'Ἵ' => 'I', + 'Ἶ' => 'I', + 'Ἷ' => 'I', + 'ὀ' => 'o', + 'ὁ' => 'o', + 'ὂ' => 'o', + 'ὃ' => 'o', + 'ὄ' => 'o', + 'ὅ' => 'o', + 'Ὀ' => 'O', + 'Ὁ' => 'O', + 'Ὂ' => 'O', + 'Ὃ' => 'O', + 'Ὄ' => 'O', + 'Ὅ' => 'O', + 'ὐ' => 'y', + 'ὑ' => 'y', + 'ὒ' => 'y', + 'ὓ' => 'y', + 'ὔ' => 'y', + 'ὕ' => 'y', + 'ὖ' => 'y', + 'ὗ' => 'y', + 'Ὑ' => 'Y', + 'Ὓ' => 'Y', + 'Ὕ' => 'Y', + 'Ὗ' => 'Y', + 'ὠ' => 'o', + 'ὡ' => 'o', + 'ὢ' => 'o', + 'ὣ' => 'o', + 'ὤ' => 'o', + 'ὥ' => 'o', + 'ὦ' => 'o', + 'ὧ' => 'o', + 'Ὠ' => 'O', + 'Ὡ' => 'O', + 'Ὢ' => 'O', + 'Ὣ' => 'O', + 'Ὤ' => 'O', + 'Ὥ' => 'O', + 'Ὦ' => 'O', + 'Ὧ' => 'O', + 'ὰ' => 'a', + 'ά' => 'a', + 'ὲ' => 'e', + 'έ' => 'e', + 'ὴ' => 'i', + 'ή' => 'i', + 'ὶ' => 'i', + 'ί' => 'i', + 'ὸ' => 'o', + 'ό' => 'o', + 'ὺ' => 'y', + 'ύ' => 'y', + 'ὼ' => 'o', + 'ώ' => 'o', + 'ᾀ' => 'a', + 'ᾁ' => 'a', + 'ᾂ' => 'a', + 'ᾃ' => 'a', + 'ᾄ' => 'a', + 'ᾅ' => 'a', + 'ᾆ' => 'a', + 'ᾇ' => 'a', + 'ᾈ' => 'A', + 'ᾉ' => 'A', + 'ᾊ' => 'A', + 'ᾋ' => 'A', + 'ᾌ' => 'A', + 'ᾍ' => 'A', + 'ᾎ' => 'A', + 'ᾏ' => 'A', + 'ᾐ' => 'i', + 'ᾑ' => 'i', + 'ᾒ' => 'i', + 'ᾓ' => 'i', + 'ᾔ' => 'i', + 'ᾕ' => 'i', + 'ᾖ' => 'i', + 'ᾗ' => 'i', + 'ᾘ' => 'I', + 'ᾙ' => 'I', + 'ᾚ' => 'I', + 'ᾛ' => 'I', + 'ᾜ' => 'I', + 'ᾝ' => 'I', + 'ᾞ' => 'I', + 'ᾟ' => 'I', + 'ᾠ' => 'o', + 'ᾡ' => 'o', + 'ᾢ' => 'o', + 'ᾣ' => 'o', + 'ᾤ' => 'o', + 'ᾥ' => 'o', + 'ᾦ' => 'o', + 'ᾧ' => 'o', + 'ᾨ' => 'O', + 'ᾩ' => 'O', + 'ᾪ' => 'O', + 'ᾫ' => 'O', + 'ᾬ' => 'O', + 'ᾭ' => 'O', + 'ᾮ' => 'O', + 'ᾯ' => 'O', + 'ᾰ' => 'a', + 'ᾱ' => 'a', + 'ᾲ' => 'a', + 'ᾳ' => 'a', + 'ᾴ' => 'a', + 'ᾶ' => 'a', + 'ᾷ' => 'a', + 'Ᾰ' => 'A', + 'Ᾱ' => 'A', + 'Ὰ' => 'A', + 'Ά' => 'f', + 'ᾼ' => 'A', + 'ι' => 'i', + 'ῂ' => 'i', + 'ῃ' => 'i', + 'ῄ' => 'i', + 'ῆ' => 'i', + 'ῇ' => 'i', + 'Ὲ' => 'E', + 'Έ' => 'E', + 'Ὴ' => 'I', + 'Ή' => 'I', + 'ῌ' => 'I', + 'ῐ' => 'i', + 'ῑ' => 'i', + 'ῒ' => 'i', + 'ΐ' => 'i', + 'ῖ' => 'i', + 'ῗ' => 'i', + 'Ῐ' => 'I', + 'Ῑ' => 'I', + 'Ὶ' => 'I', + 'Ί' => 'I', + 'ῠ' => 'y', + 'ῡ' => 'y', + 'ῢ' => 'y', + 'ΰ' => 'y', + 'ῤ' => 'r', + 'ῥ' => 'r', + 'ῦ' => 'y', + 'ῧ' => 'y', + 'Ῠ' => 'Y', + 'Ῡ' => 'Y', + 'Ὺ' => 'Y', + 'Ύ' => 'Y', + 'Ῥ' => 'R', + '`' => '`', + 'ῲ' => 'o', + 'ῳ' => 'o', + 'ῴ' => 'o', + 'ῶ' => 'o', + 'ῷ' => 'o', + 'Ὸ' => 'O', + 'Ό' => 'O', + 'Ὼ' => 'O', + 'Ώ' => 'O', + 'ῼ' => 'O', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + ' ' => ' ', + '‑' => '-', + '․' => '.', + '‥' => '..', + '…' => '...', + ' ' => ' ', + '‼' => '!!', + '⁇' => '??', + '⁈' => '?!', + '⁉' => '!?', + ' ' => ' ', + '⁰' => '0', + 'ⁱ' => 'i', + '⁴' => '4', + '⁵' => '5', + '⁶' => '6', + '⁷' => '7', + '⁸' => '8', + '⁹' => '9', + '⁺' => '+', + '⁼' => '=', + '⁽' => '(', + '⁾' => ')', + 'ⁿ' => 'n', + '₀' => '0', + '₁' => '1', + '₂' => '2', + '₃' => '3', + '₄' => '4', + '₅' => '5', + '₆' => '6', + '₇' => '7', + '₈' => '8', + '₉' => '9', + '₊' => '+', + '₌' => '=', + '₍' => '(', + '₎' => ')', + 'ₐ' => 'a', + 'ₑ' => 'e', + 'ₒ' => 'o', + 'ₓ' => 'x', + 'ₕ' => 'h', + 'ₖ' => 'k', + 'ₗ' => 'l', + 'ₘ' => 'm', + 'ₙ' => 'n', + 'ₚ' => 'p', + 'ₛ' => 's', + 'ₜ' => 't', + '₨' => 'Rs', + '℀' => 'a/c', + '℁' => 'a/s', + 'ℂ' => 'C', + '℅' => 'c/o', + '℆' => 'c/u', + 'ℊ' => 'g', + 'ℋ' => 'H', + 'ℌ' => 'H', + 'ℍ' => 'H', + 'ℎ' => 'h', + 'ℏ' => 'h', + 'ℐ' => 'I', + 'ℑ' => 'I', + 'ℒ' => 'L', + 'ℓ' => 'l', + 'ℕ' => 'N', + '№' => 'No', + 'ℙ' => 'P', + 'ℚ' => 'Q', + 'ℛ' => 'R', + 'ℜ' => 'R', + 'ℝ' => 'R', + '℠' => 'SM', + '℡' => 'TEL', + '™' => '(TM)', + 'ℤ' => 'Z', + 'Ω' => 'O', + 'ℨ' => 'Z', + 'K' => 'K', + 'Å' => 'AA', + 'ℬ' => 'B', + 'ℭ' => 'C', + 'ℯ' => 'e', + 'ℰ' => 'E', + 'ℱ' => 'F', + 'ℳ' => 'M', + 'ℴ' => 'o', + 'ℵ' => 'a', + 'ℶ' => 'b', + 'ℷ' => 'g', + 'ℸ' => 'd', + 'ℹ' => 'i', + '℻' => 'FAX', + 'ℼ' => 'p', + 'ℽ' => 'g', + 'ℾ' => 'G', + 'ℿ' => 'P', + 'ⅅ' => 'D', + 'ⅆ' => 'd', + 'ⅇ' => 'e', + 'ⅈ' => 'i', + 'ⅉ' => 'j', + '⅐' => '1/7', + '⅑' => '1/9', + '⅒' => '1/10', + '⅓' => '1/3', + '⅔' => '2/3', + '⅕' => '1/5', + '⅖' => '2/5', + '⅗' => '3/5', + '⅘' => '4/5', + '⅙' => '1/6', + '⅚' => '5/6', + '⅛' => '1/8', + '⅜' => '3/8', + '⅝' => '5/8', + '⅞' => '7/8', + '⅟' => '1/', + 'Ⅰ' => 'I', + 'Ⅱ' => 'II', + 'Ⅲ' => 'III', + 'Ⅳ' => 'IV', + 'Ⅴ' => 'V', + 'Ⅵ' => 'VI', + 'Ⅶ' => 'VII', + 'Ⅷ' => 'VIII', + 'Ⅸ' => 'IX', + 'Ⅹ' => 'X', + 'Ⅺ' => 'XI', + 'Ⅻ' => 'XII', + 'Ⅼ' => 'L', + 'Ⅽ' => 'C', + 'Ⅾ' => 'D', + 'Ⅿ' => 'M', + 'ⅰ' => 'i', + 'ⅱ' => 'ii', + 'ⅲ' => 'iii', + 'ⅳ' => 'iv', + 'ⅴ' => 'v', + 'ⅵ' => 'vi', + 'ⅶ' => 'vii', + 'ⅷ' => 'viii', + 'ⅸ' => 'ix', + 'ⅹ' => 'x', + 'ⅺ' => 'xi', + 'ⅻ' => 'xii', + 'ⅼ' => 'l', + 'ⅽ' => 'c', + 'ⅾ' => 'd', + 'ⅿ' => 'm', + '↉' => '0/3', + '≠' => '=', + '≮' => '<', + '≯' => '>', + '①' => '(1)', + '②' => '(2)', + '③' => '(3)', + '④' => '(4)', + '⑤' => '(5)', + '⑥' => '(6)', + '⑦' => '(7)', + '⑧' => '(8)', + '⑨' => '(9)', + '⑩' => '(10)', + '⑪' => '(11)', + '⑫' => '(12)', + '⑬' => '(13)', + '⑭' => '(14)', + '⑮' => '(15)', + '⑯' => '(16)', + '⑰' => '(17)', + '⑱' => '(18)', + '⑲' => '(19)', + '⑳' => '(20)', + '⑴' => '(1)', + '⑵' => '(2)', + '⑶' => '(3)', + '⑷' => '(4)', + '⑸' => '(5)', + '⑹' => '(6)', + '⑺' => '(7)', + '⑻' => '(8)', + '⑼' => '(9)', + '⑽' => '(10)', + '⑾' => '(11)', + '⑿' => '(12)', + '⒀' => '(13)', + '⒁' => '(14)', + '⒂' => '(15)', + '⒃' => '(16)', + '⒄' => '(17)', + '⒅' => '(18)', + '⒆' => '(19)', + '⒇' => '(20)', + '⒈' => '1.', + '⒉' => '2.', + '⒊' => '3.', + '⒋' => '4.', + '⒌' => '5.', + '⒍' => '6.', + '⒎' => '7.', + '⒏' => '8.', + '⒐' => '9.', + '⒑' => '10.', + '⒒' => '11.', + '⒓' => '12.', + '⒔' => '13.', + '⒕' => '14.', + '⒖' => '15.', + '⒗' => '16.', + '⒘' => '17.', + '⒙' => '18.', + '⒚' => '19.', + '⒛' => '20.', + '⒜' => '(a)', + '⒝' => '(b)', + '⒞' => '(c)', + '⒟' => '(d)', + '⒠' => '(e)', + '⒡' => '(f)', + '⒢' => '(g)', + '⒣' => '(h)', + '⒤' => '(i)', + '⒥' => '(j)', + '⒦' => '(k)', + '⒧' => '(l)', + '⒨' => '(m)', + '⒩' => '(n)', + '⒪' => '(o)', + '⒫' => '(p)', + '⒬' => '(q)', + '⒭' => '(r)', + '⒮' => '(s)', + '⒯' => '(t)', + '⒰' => '(u)', + '⒱' => '(v)', + '⒲' => '(w)', + '⒳' => '(x)', + '⒴' => '(y)', + '⒵' => '(z)', + 'Ⓐ' => '(A)', + 'Ⓑ' => '(B)', + 'Ⓒ' => '(C)', + 'Ⓓ' => '(D)', + 'Ⓔ' => '(E)', + 'Ⓕ' => '(F)', + 'Ⓖ' => '(G)', + 'Ⓗ' => '(H)', + 'Ⓘ' => '(I)', + 'Ⓙ' => '(J)', + 'Ⓚ' => '(K)', + 'Ⓛ' => '(L)', + 'Ⓜ' => '(M)', + 'Ⓝ' => '(N)', + 'Ⓞ' => '(O)', + 'Ⓟ' => '(P)', + 'Ⓠ' => '(Q)', + 'Ⓡ' => '(R)', + 'Ⓢ' => '(S)', + 'Ⓣ' => '(T)', + 'Ⓤ' => '(U)', + 'Ⓥ' => '(V)', + 'Ⓦ' => '(W)', + 'Ⓧ' => '(X)', + 'Ⓨ' => '(Y)', + 'Ⓩ' => '(Z)', + 'ⓐ' => '(a)', + 'ⓑ' => '(b)', + 'ⓒ' => '(c)', + 'ⓓ' => '(d)', + 'ⓔ' => '(e)', + 'ⓕ' => '(f)', + 'ⓖ' => '(g)', + 'ⓗ' => '(h)', + 'ⓘ' => '(i)', + 'ⓙ' => '(j)', + 'ⓚ' => '(k)', + 'ⓛ' => '(l)', + 'ⓜ' => '(m)', + 'ⓝ' => '(n)', + 'ⓞ' => '(o)', + 'ⓟ' => '(p)', + 'ⓠ' => '(q)', + 'ⓡ' => '(r)', + 'ⓢ' => '(s)', + 'ⓣ' => '(t)', + 'ⓤ' => '(u)', + 'ⓥ' => '(v)', + 'ⓦ' => '(w)', + 'ⓧ' => '(x)', + 'ⓨ' => '(y)', + 'ⓩ' => '(z)', + '⓪' => '(0)', + '⩴' => '::=', + '⩵' => '==', + '⩶' => '===', + 'Ⱡ' => 'L', + 'ⱡ' => 'l', + 'Ɫ' => 'L', + 'Ᵽ' => 'P', + 'Ɽ' => 'R', + 'ⱥ' => 'a', + 'ⱦ' => 't', + 'Ⱨ' => 'H', + 'ⱨ' => 'h', + 'Ⱪ' => 'K', + 'ⱪ' => 'k', + 'Ⱬ' => 'Z', + 'ⱬ' => 'z', + 'Ɱ' => 'M', + 'ⱱ' => 'v', + 'Ⱳ' => 'W', + 'ⱳ' => 'w', + 'ⱴ' => 'v', + 'ⱸ' => 'e', + 'ⱺ' => 'o', + 'ⱼ' => 'j', + 'ⱽ' => 'V', + 'Ȿ' => 'S', + 'Ɀ' => 'Z', + ' ' => ' ', + '㉐' => '[PTE]', + '㉑' => '(21)', + '㉒' => '(22)', + '㉓' => '(23)', + '㉔' => '(24)', + '㉕' => '(25)', + '㉖' => '(26)', + '㉗' => '(27)', + '㉘' => '(28)', + '㉙' => '(29)', + '㉚' => '(30)', + '㉛' => '(31)', + '㉜' => '(32)', + '㉝' => '(33)', + '㉞' => '(34)', + '㉟' => '(35)', + '㊱' => '(36)', + '㊲' => '(37)', + '㊳' => '(38)', + '㊴' => '(39)', + '㊵' => '(40)', + '㊶' => '(41)', + '㊷' => '(42)', + '㊸' => '(43)', + '㊹' => '(44)', + '㊺' => '(45)', + '㊻' => '(46)', + '㊼' => '(47)', + '㊽' => '(48)', + '㊾' => '(49)', + '㊿' => '(50)', + '㋌' => '[Hg]', + '㋍' => '[erg]', + '㋎' => '[eV]', + '㋏' => '[LTD]', + '㍱' => '[hPa]', + '㍲' => '[da]', + '㍳' => '[AU]', + '㍴' => '[bar]', + '㍵' => '[oV]', + '㍶' => '[pc]', + '㍷' => '[dm]', + '㍸' => '[dm2]', + '㍹' => '[dm3]', + '㍺' => '[IU]', + '㎀' => '[pA]', + '㎁' => '[nA]', + '㎂' => '[mA]', + '㎃' => '[mA]', + '㎄' => '[kA]', + '㎅' => '[KB]', + '㎆' => '[MB]', + '㎇' => '[GB]', + '㎈' => '[cal]', + '㎉' => '[kcal]', + '㎊' => '[pF]', + '㎋' => '[nF]', + '㎌' => '[mF]', + '㎍' => '[mg]', + '㎎' => '[mg]', + '㎏' => '[kg]', + '㎐' => '[Hz]', + '㎑' => '[kHz]', + '㎒' => '[MHz]', + '㎓' => '[GHz]', + '㎔' => '[THz]', + '㎕' => '[ml]', + '㎖' => '[ml]', + '㎗' => '[dl]', + '㎘' => '[kl]', + '㎙' => '[fm]', + '㎚' => '[nm]', + '㎛' => '[mm]', + '㎜' => '[mm]', + '㎝' => '[cm]', + '㎞' => '[km]', + '㎟' => '[mm2]', + '㎠' => '[cm2]', + '㎡' => '[m2]', + '㎢' => '[km2]', + '㎣' => '[mm3]', + '㎤' => '[cm3]', + '㎥' => '[m3]', + '㎦' => '[km3]', + '㎩' => '[Pa]', + '㎪' => '[kPa]', + '㎫' => '[MPa]', + '㎬' => '[GPa]', + '㎭' => '[rad]', + '㎰' => '[ps]', + '㎱' => '[ns]', + '㎲' => '[ms]', + '㎳' => '[ms]', + '㎴' => '[pV]', + '㎵' => '[nV]', + '㎶' => '[mV]', + '㎷' => '[mV]', + '㎸' => '[kV]', + '㎹' => '[MV]', + '㎺' => '[pW]', + '㎻' => '[nW]', + '㎼' => '[mW]', + '㎽' => '[mW]', + '㎾' => '[kW]', + '㎿' => '[MW]', + '㏀' => '[kO]', + '㏁' => '[MO]', + '㏂' => '[a.m.]', + '㏃' => '[Bq]', + '㏄' => '[cc]', + '㏅' => '[cd]', + '㏇' => '[Co.]', + '㏈' => '[dB]', + '㏉' => '[Gy]', + '㏊' => '[ha]', + '㏋' => '[HP]', + '㏌' => '[in]', + '㏍' => '[KK]', + '㏎' => '[KM]', + '㏏' => '[kt]', + '㏐' => '[lm]', + '㏑' => '[ln]', + '㏒' => '[log]', + '㏓' => '[lx]', + '㏔' => '[mb]', + '㏕' => '[mil]', + '㏖' => '[mol]', + '㏗' => '[PH]', + '㏘' => '[p.m.]', + '㏙' => '[PPM]', + '㏚' => '[PR]', + '㏛' => '[sr]', + '㏜' => '[Sv]', + '㏝' => '[Wb]', + '㏿' => '[gal]', + 'Ꝁ' => 'K', + 'ꝁ' => 'k', + 'Ꝃ' => 'K', + 'ꝃ' => 'k', + 'Ꝅ' => 'K', + 'ꝅ' => 'k', + 'Ꝉ' => 'L', + 'ꝉ' => 'l', + 'Ꝋ' => 'O', + 'ꝋ' => 'o', + 'Ꝍ' => 'O', + 'ꝍ' => 'o', + 'Ꝑ' => 'P', + 'ꝑ' => 'p', + 'Ꝓ' => 'P', + 'ꝓ' => 'p', + 'Ꝕ' => 'P', + 'ꝕ' => 'p', + 'Ꝗ' => 'Q', + 'ꝗ' => 'q', + 'Ꝙ' => 'Q', + 'ꝙ' => 'q', + 'Ꝟ' => 'V', + 'ꝟ' => 'v', + 'ꞎ' => 'l', + 'Ꞑ' => 'N', + 'ꞑ' => 'n', + 'Ꞓ' => 'C', + 'ꞓ' => 'c', + 'ꞔ' => 'c', + 'ꞕ' => 'h', + 'Ꞗ' => 'B', + 'ꞗ' => 'b', + 'Ꞙ' => 'F', + 'ꞙ' => 'f', + 'Ꞡ' => 'G', + 'ꞡ' => 'g', + 'Ꞣ' => 'K', + 'ꞣ' => 'k', + 'Ꞥ' => 'N', + 'ꞥ' => 'n', + 'Ꞧ' => 'R', + 'ꞧ' => 'r', + 'Ꞩ' => 'S', + 'ꞩ' => 's', + 'Ɦ' => 'H', + 'Ɬ' => 'L', + 'Ʝ' => 'J', + 'ꟸ' => 'H', + 'ꟹ' => 'oe', + 'ꬴ' => 'e', + 'ꬷ' => 'l', + 'ꬸ' => 'l', + 'ꬹ' => 'l', + 'ꬺ' => 'm', + 'ꬻ' => 'n', + 'ꭇ' => 'r', + 'ꭉ' => 'r', + 'ꭎ' => 'u', + 'ꭒ' => 'u', + 'ꭖ' => 'x', + 'ꭗ' => 'x', + 'ꭘ' => 'x', + 'ꭙ' => 'x', + 'ꭚ' => 'y', + 'ꭝ' => 'l', + 'ꭞ' => 'l', + 'ꭟ' => 'u', + 'ff' => 'ff', + 'fi' => 'fi', + 'fl' => 'fl', + 'ffi' => 'ffi', + 'ffl' => 'ffl', + 'ſt' => 'st', + 'st' => 'st', + 'יִ' => 'i', + 'ﬠ' => 'a', + 'ﬡ' => 'a', + 'ﬢ' => 'd', + 'ﬣ' => 'ha', + 'ﬤ' => 'k', + 'ﬥ' => 'l', + 'ﬦ' => 'm', + 'ﬧ' => 'r', + 'ﬨ' => 't', + '﬩' => '+', + 'שׁ' => 'sh', + 'שׂ' => 'sh', + 'שּׁ' => 'sh', + 'שּׂ' => 'sh', + 'אַ' => 'a', + 'אָ' => 'a', + 'אּ' => 'a', + 'בּ' => 'b', + 'גּ' => 'g', + 'דּ' => 'd', + 'הּ' => 'ha', + 'וּ' => 'o', + 'זּ' => 'z', + 'טּ' => 't', + 'יּ' => 'i', + 'ךּ' => 'kh', + 'כּ' => 'k', + 'לּ' => 'l', + 'מּ' => 'm', + 'נּ' => 'n', + 'סּ' => 's', + 'ףּ' => 'f', + 'פּ' => 'f', + 'צּ' => 'tz', + 'קּ' => 'k', + 'רּ' => 'r', + 'שּ' => 'sh', + 'תּ' => 't', + 'וֹ' => 'o', + 'בֿ' => 'b', + 'כֿ' => 'k', + 'פֿ' => 'f', + 'ﭏ' => 'al', + '﹍' => '_', + '﹎' => '_', + '﹏' => '_', + '﹐' => ',', + '﹒' => '.', + '﹔' => ';', + '﹕' => ':', + '﹖' => '?', + '﹗' => '!', + '﹘' => '-', + '﹙' => '(', + '﹚' => ')', + '﹛' => '{', + '﹜' => '}', + '﹟' => '#', + '﹠' => '&', + '﹡' => '*', + '﹢' => '+', + '﹣' => '-', + '﹤' => '<', + '﹥' => '>', + '﹦' => '=', + '﹨' => '\\', + '﹩' => '$', + '﹪' => '%', + '﹫' => '@', + '!' => '!', + '"' => '"', + '#' => '#', + '$' => '$', + '%' => '%', + '&' => '&', + ''' => '\'', + '(' => '(', + ')' => ')', + '*' => '*', + '+' => '+', + ',' => ',', + '-' => '-', + '.' => '.', + '/' => '/', + '0' => '0', + '1' => '1', + '2' => '2', + '3' => '3', + '4' => '4', + '5' => '5', + '6' => '6', + '7' => '7', + '8' => '8', + '9' => '9', + ':' => ':', + ';' => ';', + '<' => '<', + '=' => '=', + '>' => '>', + '?' => '?', + '@' => '@', + 'A' => 'A', + 'B' => 'B', + 'C' => 'C', + 'D' => 'D', + 'E' => 'E', + 'F' => 'F', + 'G' => 'G', + 'H' => 'H', + 'I' => 'I', + 'J' => 'J', + 'K' => 'K', + 'L' => 'L', + 'M' => 'M', + 'N' => 'N', + 'O' => 'O', + 'P' => 'P', + 'Q' => 'Q', + 'R' => 'R', + 'S' => 'S', + 'T' => 'T', + 'U' => 'U', + 'V' => 'V', + 'W' => 'W', + 'X' => 'X', + 'Y' => 'Y', + 'Z' => 'Z', + '[' => '[', + '\' => '\\', + ']' => ']', + '^' => '^', + '_' => '_', + '`' => '`', + 'a' => 'a', + 'b' => 'b', + 'c' => 'c', + 'd' => 'd', + 'e' => 'e', + 'f' => 'f', + 'g' => 'g', + 'h' => 'h', + 'i' => 'i', + 'j' => 'j', + 'k' => 'k', + 'l' => 'l', + 'm' => 'm', + 'n' => 'n', + 'o' => 'o', + 'p' => 'p', + 'q' => 'q', + 'r' => 'r', + 's' => 's', + 't' => 't', + 'u' => 'u', + 'v' => 'v', + 'w' => 'w', + 'x' => 'x', + 'y' => 'y', + 'z' => 'z', + '{' => '{', + '|' => '|', + '}' => '}', + '~' => '~', + '¢' => 'cent', + '£' => 'pound', + '¦' => '|', + '¥' => 'yen', + '¡' => '!', + '¢' => 'cent', + '£' => 'pound', + '¥' => 'yen', + '¦' => '|', + '«' => '<<', + '©' => '(c)', + '®' => '(R)', + '±' => '+/-', + '·' => '*', + '»' => '>>', + '¿' => '?', + 'Æ' => 'AE', + 'Ð' => 'D', + '×' => 'x', + 'Þ' => 'TH', + 'ß' => 'ss', + 'æ' => 'ae', + 'ð' => 'd', + '÷' => '/', + 'þ' => 'th', + 'ı' => 'i', + 'Œ' => 'OE', + 'œ' => 'oe', + 'ʼ' => '\'', + 'ˊ' => '\'', + '‐' => '-', + '‒' => '-', + '–' => '-', + '—' => '-', + '―' => '-', + '‘' => '`', + '’' => '\'', + '“' => '"', + '”' => '"', + '„' => '"', + '•' => '*', + '‹' => '<', + '›' => '>', + '⁄' => '/', + '₠' => 'EUR', + '€' => 'EUR', + 'Ͱ' => 'H', + 'ͱ' => 'h', + '͵' => ',', + '΄' => '\'', + 'Α' => 'A', + 'Β' => 'B', + 'Γ' => 'G', + 'Δ' => 'D', + 'Ε' => 'E', + 'Ζ' => 'Z', + 'Η' => 'I', + 'Θ' => 'TH', + 'Ι' => 'I', + 'Κ' => 'K', + 'Λ' => 'L', + 'Μ' => 'M', + 'Ν' => 'N', + 'Ξ' => 'X', + 'Ο' => 'O', + 'Π' => 'P', + 'Ρ' => 'R', + 'Σ' => 'S', + 'Τ' => 'T', + 'Υ' => 'Y', + 'Φ' => 'F', + 'Χ' => 'CH', + 'Ψ' => 'PS', + 'Ω' => 'O', + 'α' => 'a', + 'β' => 'b', + 'γ' => 'g', + 'δ' => 'd', + 'ε' => 'e', + 'ζ' => 'z', + 'η' => 'i', + 'θ' => 'th', + 'ι' => 'i', + 'κ' => 'k', + 'λ' => 'l', + 'μ' => 'm', + 'ν' => 'n', + 'ξ' => 'x', + 'ο' => 'o', + 'π' => 'p', + 'ρ' => 'r', + 'ς' => 's', + 'σ' => 's', + 'τ' => 't', + 'υ' => 'y', + 'φ' => 'f', + 'χ' => 'ch', + 'ψ' => 'ps', + 'ω' => 'o', + 'Є' => 'JE', + 'І' => 'I', + 'А' => 'A', + 'Б' => 'B', + 'В' => 'V', + 'Г' => 'G', + 'Д' => 'D', + 'Е' => 'E', + 'Ж' => 'ZH', + 'З' => 'Z', + 'И' => 'I', + 'К' => 'K', + 'Л' => 'L', + 'М' => 'M', + 'Н' => 'N', + 'О' => 'O', + 'П' => 'P', + 'Р' => 'R', + 'С' => 'S', + 'Т' => 'T', + 'У' => 'U', + 'Ф' => 'F', + 'Х' => 'KH', + 'Ц' => 'C', + 'Ч' => 'CH', + 'Ш' => 'SH', + 'Щ' => 'SHCH', + 'Ъ' => '', + 'Ы' => 'Y', + 'Ь' => '', + 'Э' => 'EH', + 'Ю' => 'JU', + 'Я' => 'JA', + 'а' => 'a', + 'б' => 'b', + 'в' => 'v', + 'г' => 'g', + 'д' => 'd', + 'е' => 'e', + 'ж' => 'zh', + 'з' => 'z', + 'и' => 'i', + 'к' => 'k', + 'л' => 'l', + 'м' => 'm', + 'н' => 'n', + 'о' => 'o', + 'п' => 'p', + 'р' => 'r', + 'с' => 's', + 'т' => 't', + 'у' => 'u', + 'ф' => 'f', + 'х' => 'kh', + 'ц' => 'c', + 'ч' => 'ch', + 'ш' => 'sh', + 'щ' => 'shch', + 'ъ' => '', + 'ы' => 'y', + 'ь' => '', + 'э' => 'eh', + 'ю' => 'ju', + 'я' => 'ja', + 'є' => 'je', + 'і' => 'i', + 'Ґ' => 'GG', + 'ґ' => 'gv', + 'א' => 'a', + 'ב' => 'b', + 'ג' => 'g', + 'ד' => 'd', + 'ה' => 'ha', + 'ו' => 'o', + 'ז' => 'z', + 'ח' => 'h', + 'ט' => 't', + 'י' => 'i', + 'ך' => 'kh', + 'כ' => 'k', + 'ל' => 'l', + 'ם' => 'm', + 'מ' => 'm', + 'ן' => 'n', + 'נ' => 'n', + 'ס' => 's', + 'ע' => 'a', + 'ף' => 'f', + 'פ' => 'f', + 'ץ' => 'tz', + 'צ' => 'tz', + 'ק' => 'k', + 'ר' => 'r', + 'ש' => 'sh', + 'ת' => 't', + 'װ' => 'v', + '٠' => '0', + '١' => '1', + '٢' => '2', + '٣' => '3', + '٤' => '4', + '٥' => '5', + '٦' => '6', + '٧' => '7', + '٨' => '8', + '٩' => '9', + '۰' => '0', + '۱' => '1', + '۲' => '2', + '۳' => '3', + '۴' => '4', + '۵' => '5', + '۶' => '6', + '۷' => '7', + '۸' => '8', + '۹' => '9', + '߀' => '0', + '߁' => '1', + '߂' => '2', + '߃' => '3', + '߄' => '4', + '߅' => '5', + '߆' => '6', + '߇' => '7', + '߈' => '8', + '߉' => '9', + '०' => '0', + '१' => '1', + '२' => '2', + '३' => '3', + '४' => '4', + '५' => '5', + '६' => '6', + '७' => '7', + '८' => '8', + '९' => '9', + '০' => '0', + '১' => '1', + '২' => '2', + '৩' => '3', + '৪' => '4', + '৫' => '5', + '৬' => '6', + '৭' => '7', + '৮' => '8', + '৯' => '9', + '৴' => '1/16', + '৵' => '1/8', + '৶' => '3/16', + '৷' => '1/4', + '৸' => '3/4', + '৹' => '16', + '੦' => '0', + '੧' => '1', + '੨' => '2', + '੩' => '3', + '੪' => '4', + '੫' => '5', + '੬' => '6', + '੭' => '7', + '੮' => '8', + '੯' => '9', + '૦' => '0', + '૧' => '1', + '૨' => '2', + '૩' => '3', + '૪' => '4', + '૫' => '5', + '૬' => '6', + '૭' => '7', + '૮' => '8', + '૯' => '9', + '୦' => '0', + '୧' => '1', + '୨' => '2', + '୩' => '3', + '୪' => '4', + '୫' => '5', + '୬' => '6', + '୭' => '7', + '୮' => '8', + '୯' => '9', + '୲' => '1/4', + '୳' => '1/2', + '୴' => '3/4', + '୵' => '1/16', + '୶' => '1/8', + '୷' => '3/16', + '௦' => '0', + '௧' => '1', + '௨' => '2', + '௩' => '3', + '௪' => '4', + '௫' => '5', + '௬' => '6', + '௭' => '7', + '௮' => '8', + '௯' => '9', + '௰' => '10', + '௱' => '100', + '௲' => '1000', + '౦' => '0', + '౧' => '1', + '౨' => '2', + '౩' => '3', + '౪' => '4', + '౫' => '5', + '౬' => '6', + '౭' => '7', + '౮' => '8', + '౯' => '9', + '౸' => '0', + '౹' => '1', + '౺' => '2', + '౻' => '3', + '౼' => '1', + '౽' => '2', + '౾' => '3', + '೦' => '0', + '೧' => '1', + '೨' => '2', + '೩' => '3', + '೪' => '4', + '೫' => '5', + '೬' => '6', + '೭' => '7', + '೮' => '8', + '೯' => '9', + '൘' => '1/160', + '൙' => '1/40', + '൚' => '3/80', + '൛' => '1/20', + '൜' => '1/10', + '൝' => '3/20', + '൞' => '1/5', + '൦' => '0', + '൧' => '1', + '൨' => '2', + '൩' => '3', + '൪' => '4', + '൫' => '5', + '൬' => '6', + '൭' => '7', + '൮' => '8', + '൯' => '9', + '൰' => '10', + '൱' => '100', + '൲' => '1000', + '൳' => '1/4', + '൴' => '1/2', + '൵' => '3/4', + '൶' => '1/16', + '൷' => '1/8', + '൸' => '3/16', + '෦' => '0', + '෧' => '1', + '෨' => '2', + '෩' => '3', + '෪' => '4', + '෫' => '5', + '෬' => '6', + '෭' => '7', + '෮' => '8', + '෯' => '9', + '๐' => '0', + '๑' => '1', + '๒' => '2', + '๓' => '3', + '๔' => '4', + '๕' => '5', + '๖' => '6', + '๗' => '7', + '๘' => '8', + '๙' => '9', + '໐' => '0', + '໑' => '1', + '໒' => '2', + '໓' => '3', + '໔' => '4', + '໕' => '5', + '໖' => '6', + '໗' => '7', + '໘' => '8', + '໙' => '9', + '༠' => '0', + '༡' => '1', + '༢' => '2', + '༣' => '3', + '༤' => '4', + '༥' => '5', + '༦' => '6', + '༧' => '7', + '༨' => '8', + '༩' => '9', + '༪' => '1/2', + '༫' => '3/2', + '༬' => '5/2', + '༭' => '7/2', + '༮' => '9/2', + '༯' => '11/2', + '༰' => '13/2', + '༱' => '15/2', + '༲' => '17/2', + '༳' => '-1/2', + '၀' => '0', + '၁' => '1', + '၂' => '2', + '၃' => '3', + '၄' => '4', + '၅' => '5', + '၆' => '6', + '၇' => '7', + '၈' => '8', + '၉' => '9', + '႐' => '0', + '႑' => '1', + '႒' => '2', + '႓' => '3', + '႔' => '4', + '႕' => '5', + '႖' => '6', + '႗' => '7', + '႘' => '8', + '႙' => '9', + '፩' => '1', + '፪' => '2', + '፫' => '3', + '፬' => '4', + '፭' => '5', + '፮' => '6', + '፯' => '7', + '፰' => '8', + '፱' => '9', + '፲' => '10', + '፳' => '20', + '፴' => '30', + '፵' => '40', + '፶' => '50', + '፷' => '60', + '፸' => '70', + '፹' => '80', + '፺' => '90', + '፻' => '100', + '፼' => '10000', + 'ᛮ' => '17', + 'ᛯ' => '18', + 'ᛰ' => '19', + '០' => '0', + '១' => '1', + '២' => '2', + '៣' => '3', + '៤' => '4', + '៥' => '5', + '៦' => '6', + '៧' => '7', + '៨' => '8', + '៩' => '9', + '៰' => '0', + '៱' => '1', + '៲' => '2', + '៳' => '3', + '៴' => '4', + '៵' => '5', + '៶' => '6', + '៷' => '7', + '៸' => '8', + '៹' => '9', + '᠐' => '0', + '᠑' => '1', + '᠒' => '2', + '᠓' => '3', + '᠔' => '4', + '᠕' => '5', + '᠖' => '6', + '᠗' => '7', + '᠘' => '8', + '᠙' => '9', + '᥆' => '0', + '᥇' => '1', + '᥈' => '2', + '᥉' => '3', + '᥊' => '4', + '᥋' => '5', + '᥌' => '6', + '᥍' => '7', + '᥎' => '8', + '᥏' => '9', + '᧐' => '0', + '᧑' => '1', + '᧒' => '2', + '᧓' => '3', + '᧔' => '4', + '᧕' => '5', + '᧖' => '6', + '᧗' => '7', + '᧘' => '8', + '᧙' => '9', + '᧚' => '1', + '᪀' => '0', + '᪁' => '1', + '᪂' => '2', + '᪃' => '3', + '᪄' => '4', + '᪅' => '5', + '᪆' => '6', + '᪇' => '7', + '᪈' => '8', + '᪉' => '9', + '᪐' => '0', + '᪑' => '1', + '᪒' => '2', + '᪓' => '3', + '᪔' => '4', + '᪕' => '5', + '᪖' => '6', + '᪗' => '7', + '᪘' => '8', + '᪙' => '9', + '᭐' => '0', + '᭑' => '1', + '᭒' => '2', + '᭓' => '3', + '᭔' => '4', + '᭕' => '5', + '᭖' => '6', + '᭗' => '7', + '᭘' => '8', + '᭙' => '9', + '᮰' => '0', + '᮱' => '1', + '᮲' => '2', + '᮳' => '3', + '᮴' => '4', + '᮵' => '5', + '᮶' => '6', + '᮷' => '7', + '᮸' => '8', + '᮹' => '9', + '᱀' => '0', + '᱁' => '1', + '᱂' => '2', + '᱃' => '3', + '᱄' => '4', + '᱅' => '5', + '᱆' => '6', + '᱇' => '7', + '᱈' => '8', + '᱉' => '9', + '᱐' => '0', + '᱑' => '1', + '᱒' => '2', + '᱓' => '3', + '᱔' => '4', + '᱕' => '5', + '᱖' => '6', + '᱗' => '7', + '᱘' => '8', + '᱙' => '9', + 'ↀ' => '1000', + 'ↁ' => '5000', + 'ↂ' => '10000', + 'ↅ' => '6', + 'ↆ' => '50', + 'ↇ' => '50000', + 'ↈ' => '100000', + '⓫' => '11', + '⓬' => '12', + '⓭' => '13', + '⓮' => '14', + '⓯' => '15', + '⓰' => '16', + '⓱' => '17', + '⓲' => '18', + '⓳' => '19', + '⓴' => '20', + '⓵' => '1', + '⓶' => '2', + '⓷' => '3', + '⓸' => '4', + '⓹' => '5', + '⓺' => '6', + '⓻' => '7', + '⓼' => '8', + '⓽' => '9', + '⓾' => '10', + '⓿' => '0', + '❶' => '1', + '❷' => '2', + '❸' => '3', + '❹' => '4', + '❺' => '5', + '❻' => '6', + '❼' => '7', + '❽' => '8', + '❾' => '9', + '❿' => '10', + '➀' => '1', + '➁' => '2', + '➂' => '3', + '➃' => '4', + '➄' => '5', + '➅' => '6', + '➆' => '7', + '➇' => '8', + '➈' => '9', + '➉' => '10', + '➊' => '1', + '➋' => '2', + '➌' => '3', + '➍' => '4', + '➎' => '5', + '➏' => '6', + '➐' => '7', + '➑' => '8', + '➒' => '9', + '➓' => '10', + '⳽' => '1/2', + '〇' => '0', + '〡' => '1', + '〢' => '2', + '〣' => '3', + '〤' => '4', + '〥' => '5', + '〦' => '6', + '〧' => '7', + '〨' => '8', + '〩' => '9', + '〸' => '10', + '〹' => '20', + '〺' => '30', + '㆒' => '1', + '㆓' => '2', + '㆔' => '3', + '㆕' => '4', + '㈠' => '1', + '㈡' => '2', + '㈢' => '3', + '㈣' => '4', + '㈤' => '5', + '㈥' => '6', + '㈦' => '7', + '㈧' => '8', + '㈨' => '9', + '㈩' => '10', + '㉈' => '10', + '㉉' => '20', + '㉊' => '30', + '㉋' => '40', + '㉌' => '50', + '㉍' => '60', + '㉎' => '70', + '㉏' => '80', + '㊀' => '1', + '㊁' => '2', + '㊂' => '3', + '㊃' => '4', + '㊄' => '5', + '㊅' => '6', + '㊆' => '7', + '㊇' => '8', + '㊈' => '9', + '㊉' => '10', + '꘠' => '0', + '꘡' => '1', + '꘢' => '2', + '꘣' => '3', + '꘤' => '4', + '꘥' => '5', + '꘦' => '6', + '꘧' => '7', + '꘨' => '8', + '꘩' => '9', + 'ꛦ' => '1', + 'ꛧ' => '2', + 'ꛨ' => '3', + 'ꛩ' => '4', + 'ꛪ' => '5', + 'ꛫ' => '6', + 'ꛬ' => '7', + 'ꛭ' => '8', + 'ꛮ' => '9', + 'ꛯ' => '0', + '꠰' => '1/4', + '꠱' => '1/2', + '꠲' => '3/4', + '꠳' => '1/16', + '꠴' => '1/8', + '꠵' => '3/16', + '꣐' => '0', + '꣑' => '1', + '꣒' => '2', + '꣓' => '3', + '꣔' => '4', + '꣕' => '5', + '꣖' => '6', + '꣗' => '7', + '꣘' => '8', + '꣙' => '9', + '꤀' => '0', + '꤁' => '1', + '꤂' => '2', + '꤃' => '3', + '꤄' => '4', + '꤅' => '5', + '꤆' => '6', + '꤇' => '7', + '꤈' => '8', + '꤉' => '9', + '꧐' => '0', + '꧑' => '1', + '꧒' => '2', + '꧓' => '3', + '꧔' => '4', + '꧕' => '5', + '꧖' => '6', + '꧗' => '7', + '꧘' => '8', + '꧙' => '9', + '꧰' => '0', + '꧱' => '1', + '꧲' => '2', + '꧳' => '3', + '꧴' => '4', + '꧵' => '5', + '꧶' => '6', + '꧷' => '7', + '꧸' => '8', + '꧹' => '9', + '꩐' => '0', + '꩑' => '1', + '꩒' => '2', + '꩓' => '3', + '꩔' => '4', + '꩕' => '5', + '꩖' => '6', + '꩗' => '7', + '꩘' => '8', + '꩙' => '9', + '꯰' => '0', + '꯱' => '1', + '꯲' => '2', + '꯳' => '3', + '꯴' => '4', + '꯵' => '5', + '꯶' => '6', + '꯷' => '7', + '꯸' => '8', + '꯹' => '9', + ], + ]; + + public function hasMultibyteChar(string $charset, string $index): bool + { + return isset(self::RAW_DATA[$charset][$index]); + } + + public function getByMultibyteChar(string $charset, string $index): string + { + return self::RAW_DATA[$charset][$index]; + } +} diff --git a/Classes/Collection/AbstractRecordCollection.php b/Classes/Collection/AbstractRecordCollection.php new file mode 100644 index 0000000..f61eb38 --- /dev/null +++ b/Classes/Collection/AbstractRecordCollection.php @@ -0,0 +1,392 @@ + + */ +abstract class AbstractRecordCollection implements RecordCollectionInterface, PersistableCollectionInterface +{ + /** + * The table name collections are stored to + * + * @var string + */ + protected static $storageItemsField = 'items'; + + /** + * The table name collections are stored to, must be defined in the subclass + * + * @var string + */ + protected static $storageTableName = ''; + + /** + * Uid of the storage + * + * @var int + */ + protected $uid = 0; + + /** + * Collection title + * + * @var string + */ + protected $title; + + /** + * Collection description + * + * @var string + */ + protected $description; + + /** + * Table name of the records stored in this collection + * + * @var string + */ + protected $itemTableName; + + /** + * The local storage + * + * @var \SplDoublyLinkedList + */ + protected $storage; + + /** + * Creates this object. + */ + public function __construct() + { + $this->storage = new \SplDoublyLinkedList(); + } + + /** + * Return the current element + * + * @return T|null + */ + public function current(): mixed + { + return $this->storage->current(); + } + + /** + * Move forward to next element + */ + public function next(): void + { + $this->storage->next(); + } + + /** + * Return the key of the current element + * + * @return int|string 0 on failure. + */ + public function key(): mixed + { + $currentRecord = $this->storage->current(); + return $currentRecord['uid'] ?? 0; + } + + /** + * Checks if current position is valid + * + * @return bool The return value will be cast to boolean and then evaluated. + */ + public function valid(): bool + { + return $this->storage->valid(); + } + + /** + * Rewind the Iterator to the first element + */ + public function rewind(): void + { + $this->storage->rewind(); + } + + /** + * Returns class state to be serialized. + */ + public function __serialize(): array + { + return [ + 'uid' => $this->getIdentifier(), + ]; + } + + /** + * Load records with the given serialized information + */ + public function __unserialize(array $arrayRepresentation): void + { + self::load($arrayRepresentation['uid']); + } + + /** + * Count elements of an object + * + * @return int The custom count as an integer. + */ + public function count(): int + { + return $this->storage->count(); + } + + /** + * Getter for the title + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Getter for the UID + * + * @return int + */ + public function getUid() + { + return $this->uid; + } + + /** + * Getter for the description + * + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * Setter for the title + * + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Setter for the description + * + * @param string $desc + */ + public function setDescription($desc) + { + $this->description = $desc; + } + + /** + * Setter for the name of the data-source table + * + * @return string + */ + public function getItemTableName() + { + return $this->itemTableName; + } + + /** + * Setter for the name of the data-source table + * + * @param string $tableName + */ + public function setItemTableName($tableName) + { + $this->itemTableName = $tableName; + } + + /** + * Returns the uid of the collection + * + * @return int + */ + public function getIdentifier() + { + return $this->uid; + } + + /** + * Sets the identifier of the collection + * + * @param int $id + */ + public function setIdentifier($id) + { + $this->uid = (int)$id; + } + + /** + * Loads the collections with the given id from persistence + * + * For memory reasons, per default only f.e. title, database-table, + * identifier (what ever static data is defined) is loaded. + * Entries can be load on first access. + * + * @param int $id Id of database record to be loaded + * @param bool $fillItems Populates the entries directly on load, might be bad for memory on large collections + * @return CollectionInterface + */ + public static function load($id, $fillItems = false) + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(static::getCollectionDatabaseTable()); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $collectionRecord = $queryBuilder->select('*') + ->from(static::getCollectionDatabaseTable()) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + return self::create($collectionRecord ?: [], $fillItems); + } + + /** + * Creates a new collection objects and reconstitutes the + * given database record to the new object. + * + * @param array $collectionRecord Database record + * @param bool $fillItems Populates the entries directly on load, might be bad for memory on large collections + * @return CollectionInterface + */ + public static function create(array $collectionRecord, $fillItems = false) + { + // [phpstan] Unsafe usage of new static() + // todo: Either mark this class or its constructor final or use new self instead. + $collection = new static(); + $collection->fromArray($collectionRecord); + if ($fillItems) { + $collection->loadContents(); + } + return $collection; + } + + /** + * Persists current collection state to underlying storage + */ + public function persist() + { + $uid = $this->getIdentifier() == 0 ? 'NEW' . random_int(100000, 999999) : $this->getIdentifier(); + $data = [ + trim(static::getCollectionDatabaseTable()) => [ + $uid => $this->getPersistableDataArray(), + ], + ]; + // New records always must have a pid + if ($this->getIdentifier() == 0) { + $data[trim(static::getCollectionDatabaseTable())][$uid]['pid'] = 0; + } + $tce = GeneralUtility::makeInstance(DataHandler::class); + $tce->start($data, []); + $tce->process_datamap(); + } + + /** + * Returns an array of the persistable properties and contents + * which are processable by DataHandler. + * + * For internal usage in persist only. + * + * @return array + */ + abstract protected function getPersistableDataArray(); + + /** + * Generates comma-separated list of entry uids for usage in DataHandler + * + * also allow to add table name, if it might be needed by DataHandler for + * storing the relation + * + * @param bool $includeTableName + * @return string + */ + protected function getItemUidList($includeTableName = true) + { + $list = []; + foreach ($this->storage as $entry) { + $list[] = ($includeTableName ? $this->getItemTableName() . '_' : '') . $entry['uid']; + } + return implode(',', $list); + } + + /** + * Builds an array representation of this collection + * + * @return array + */ + public function toArray() + { + $itemArray = []; + foreach ($this->storage as $item) { + $itemArray[] = $item; + } + return [ + 'uid' => $this->getIdentifier(), + 'title' => $this->getTitle(), + 'description' => $this->getDescription(), + 'table_name' => $this->getItemTableName(), + 'items' => $itemArray, + ]; + } + + /** + * Loads the properties of this collection from an array + */ + public function fromArray(array $array) + { + $this->uid = $array['uid']; + $this->title = $array['title']; + $this->description = $array['description']; + $this->itemTableName = $array['table_name']; + } + + protected static function getCollectionDatabaseTable(): string + { + if (!empty(static::$storageTableName)) { + return static::$storageTableName; + } + throw new \RuntimeException('No storage table name was defined the class "' . static::class . '".', 1592207959); + } +} diff --git a/Classes/Collection/CollectionInterface.php b/Classes/Collection/CollectionInterface.php new file mode 100644 index 0000000..2126b77 --- /dev/null +++ b/Classes/Collection/CollectionInterface.php @@ -0,0 +1,28 @@ +items = $initialization; + } + + public function count(): int + { + $this->initialize(); + return count($this->items); + } + + private function initialize(): void + { + if ($this->items instanceof \Closure) { + $this->items = ($this->items)(); + } + } + + public function getIterator(): \Iterator + { + $this->initialize(); + return new \ArrayIterator($this->items); + } + + public function __toString(): string + { + return (string)$this->fieldValue; + } + + public function offsetExists(mixed $offset): bool + { + $this->initialize(); + return isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + $this->initialize(); + return $this->items[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($value instanceof RecordInterface === false) { + throw new \InvalidArgumentException( + 'Modifying the record collection is only allowed by setting a value of type RecordInterface.', + 1723188315 + ); + } + $this->items[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('Removing items from the record collection is not implemented.', 1723188316); + } +} diff --git a/Classes/Collection/NameableCollectionInterface.php b/Classes/Collection/NameableCollectionInterface.php new file mode 100644 index 0000000..fd44de6 --- /dev/null +++ b/Classes/Collection/NameableCollectionInterface.php @@ -0,0 +1,53 @@ + + */ +interface RecordCollectionInterface extends CollectionInterface, NameableCollectionInterface +{ + /** + * Setter for the name of the data-source table + * + * @param string $tableName + */ + public function setItemTableName($tableName); + + /** + * Setter for the name of the data-source table + * + * @return string + */ + public function getItemTableName(); +} diff --git a/Classes/Command/AssetPublishCommand.php b/Classes/Command/AssetPublishCommand.php new file mode 100644 index 0000000..57b60a2 --- /dev/null +++ b/Classes/Command/AssetPublishCommand.php @@ -0,0 +1,102 @@ +setDescription('Publish public assets.'); + $this->setHelp( + 'Publishes public assets. ' + . 'Needs to be run after composer install.' + ); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $failsafeContainer = $this->bootService->getFailsafeContainer(); + $failsafeResourcePublisher = $failsafeContainer->has(AssetPublishing::class) ? $failsafeContainer->get(SystemResourcePublisherInterface::class) : null; + try { + $container = $this->bootService->loadExtLocalconfDatabase(false, false); + } catch (\Throwable $e) { + if ($output->isVerbose()) { + throw $e; + } + $output->writeln('Can not initialize dependency injection container. Increase verbosity to get the full error message.'); + return self::FAILURE; + } + $resourcePublisher = $container->get(SystemResourcePublisherInterface::class); + + $output->getFormatter()->setStyle('bold', new OutputFormatterStyle(null, null, ['bold'])); + $output->writeln('Publishing assets from extensions…'); + + $exitCode = self::SUCCESS; + foreach ($this->packageManager->getAvailablePackages() as $package) { + $messages = $resourcePublisher->publishResources($package); + if ($package->isPartOfMinimalUsableSystem()) { + // Publish resources for install tool, if it is installed + $failsafeResourcePublisher?->publishResources($package); + } + $exitCode = $this->determineExitCode($exitCode, $messages); + $this->messageRenderer->renderAll($messages, $output); + } + + $output->writeln('done.'); + return $exitCode; + } + + private function determineExitCode(int $currentCode, FlashMessageQueue $queue): int + { + if ($currentCode === self::FAILURE) { + return self::FAILURE; + } + foreach ($queue->getAllMessages() as $message) { + if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) { + return self::FAILURE; + } + } + return $currentCode; + } +} diff --git a/Classes/Command/CacheFlushCommand.php b/Classes/Command/CacheFlushCommand.php new file mode 100644 index 0000000..52d95a9 --- /dev/null +++ b/Classes/Command/CacheFlushCommand.php @@ -0,0 +1,124 @@ +setDescription('Flush TYPO3 caches.'); + $this->setHelp( + 'Clears TYPO3 caches. ' + . 'Useful after code changes during development or after deployments. ' + . 'You can flush a specific cache group (system, pages, di) or all caches.' + ); + $this->setDefinition([ + new InputOption('group', 'g', InputOption::VALUE_OPTIONAL, 'Cache group to flush (system, pages, di, or all).', 'all'), + ]); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $group = $input->getOption('group') ?? 'all'; + + $this->flushDependencyInjectionCaches($group); + if ($group === 'di') { + if ($output->isVerbose()) { + $io->success('Dependency Injection caches flushed.'); + } + return Command::SUCCESS; + } + + $container = $this->bootService->getContainer(true); + + $this->flushCoreCaches($group, $container); + + $this->bootService->loadExtLocalconfDatabase(false, true); + + $eventDispatcher = $container->get(EventDispatcherInterface::class); + + $groups = $group === 'all' ? $container->get(CacheManager::class)->getCacheGroups() : [$group]; + $event = new CacheFlushEvent($groups); + $eventDispatcher->dispatch($event); + + if (count($event->getErrors()) > 0) { + $io->error('Errors occurred while flushing caches.'); + foreach ($event->getErrors() as $error) { + $io->error($error); + } + return Command::FAILURE; + } + if ($output->isVerbose()) { + if ($group === 'all') { + $io->success('All caches flushed.'); + } else { + $io->success(sprintf('Caches for group "%s" flushed.', $group)); + } + } + return Command::SUCCESS; + } + + protected function flushDependencyInjectionCaches(string $group): void + { + if ($group !== 'di' && $group !== 'system' && $group !== 'all') { + return; + } + + if ($this->dependencyInjectionCache->getBackend() instanceof ContainerBackend) { + $diCacheBackend = $this->dependencyInjectionCache->getBackend(); + // We need to remove using the forceFlush method because the DI cache backend disables the flush method + $diCacheBackend->forceFlush(); + } + } + + protected function flushCoreCaches(string $group, ContainerInterface $container): void + { + if ($group !== 'system' && $group !== 'all') { + return; + } + + $container->get('cache.core')->flush(); + } +} diff --git a/Classes/Command/CacheFlushTagsCommand.php b/Classes/Command/CacheFlushTagsCommand.php new file mode 100644 index 0000000..3a67744 --- /dev/null +++ b/Classes/Command/CacheFlushTagsCommand.php @@ -0,0 +1,80 @@ +setDescription('Flush TYPO3 caches with tags.'); + $this->setHelp('This command can be used to clear the caches with specific tags, for example after code updates in local development and after deployments.'); + $this->setDefinition([ + new InputArgument( + 'tags', + InputArgument::REQUIRED, + 'Array of tags (specified as comma separated values) to flush.' + ), + new InputOption( + 'groups', + 'g', + InputOption::VALUE_REQUIRED, + 'Array of groups (specified as comma separated values) for which to flush tags. If no group is specified, caches of all groups are flushed.', + 'all' + ), + ]); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $groups = GeneralUtility::trimExplode(',', $input->getOption('groups') ?? '', true); + $tags = GeneralUtility::trimExplode(',', $input->getArgument('tags') ?? '', true); + + foreach ($groups as $group) { + if ($group === 'all') { + $this->cacheManager->flushCachesByTags($tags); + continue; + } + + $this->cacheManager->flushCachesInGroupByTags($group, $tags); + } + + return Command::SUCCESS; + } +} diff --git a/Classes/Command/CacheWarmupCommand.php b/Classes/Command/CacheWarmupCommand.php new file mode 100644 index 0000000..fece91d --- /dev/null +++ b/Classes/Command/CacheWarmupCommand.php @@ -0,0 +1,108 @@ +setDescription('Warmup TYPO3 caches.'); + $this->setHelp( + <<<'EOF' +This command is useful for deployments to warmup caches during release preparation. + + +Cache warming does not work if the PHP version used to execute the command differs from +the PHP version used in the web context. + +See: https://docs.typo3.org/permalink/changelog:important-107649-1760090777 + +EOF + ); + $this->setDefinition([ + new InputOption('group', 'g', InputOption::VALUE_OPTIONAL, 'The cache group to warmup (system, pages, di or all)', 'all'), + ]); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $group = $input->getOption('group') ?? 'all'; + + if ($group === 'di' || $group === 'system' || $group === 'all') { + $this->containerBuilder->warmupCache($this->packageManager, $this->dependencyInjectionCache); + if ($group === 'di') { + return Command::SUCCESS; + } + } + + $container = $this->bootService->getContainer(); + + $allowExtFileCaches = true; + if ($group === 'system' || $group === 'all') { + $allowExtFileCaches = false; + $container->get(ExtLocalconfFactory::class)->createCacheEntry(); + } + // Perform a full boot to load localconf (requirement for extensions and for TCA loading). + $this->bootService->loadExtLocalconfDatabase(false, $allowExtFileCaches); + if ($group === 'system' || $group === 'all') { + $tcaFactory = $container->get(TcaFactory::class); + $tcaFactory->createBaseTcaCacheFile($GLOBALS['TCA']); + } + + $eventDispatcher = $container->get(EventDispatcherInterface::class); + + $groups = $group === 'all' ? $container->get(CacheManager::class)->getCacheGroups() : [$group]; + $event = new CacheWarmupEvent($groups); + $eventDispatcher->dispatch($event); + + if (count($event->getErrors()) > 0) { + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/Classes/Command/ConsumeMessagesCommand.php b/Classes/Command/ConsumeMessagesCommand.php new file mode 100644 index 0000000..18ea5ed --- /dev/null +++ b/Classes/Command/ConsumeMessagesCommand.php @@ -0,0 +1,358 @@ +receiverNames = array_keys([...$receiverNamesIterator]); + parent::__construct(); + } + + protected function configure(): void + { + $defaultReceiverName = count($this->receiverNames) === 1 ? current($this->receiverNames) : null; + + $this + ->setDefinition( + [ + new InputArgument( + 'receivers', + InputArgument::IS_ARRAY, + 'Names of the receivers/transports to consume in order of priority', + $defaultReceiverName ? [$defaultReceiverName] : [] + ), + new InputOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of received messages'), + new InputOption('failure-limit', 'f', InputOption::VALUE_REQUIRED, 'The number of failed messages the worker can consume'), + new InputOption('memory-limit', 'm', InputOption::VALUE_REQUIRED, 'The memory limit the worker can consume'), + new InputOption('time-limit', 't', InputOption::VALUE_REQUIRED, 'The time limit in seconds the worker can handle new messages'), + new InputOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to sleep before asking for new messages after no messages were found', 1), + new InputOption('bus', 'b', InputOption::VALUE_REQUIRED, 'Name of the bus to which received messages should be dispatched (if not passed, bus is determined automatically)'), + new InputOption('queues', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit receivers to only consume from the specified queues'), + new InputOption('all', null, InputOption::VALUE_NONE, 'Consume messages from all receivers'), + new InputOption('keepalive', null, InputOption::VALUE_OPTIONAL, 'Whether to use the transport\'s keepalive mechanism if implemented', self::DEFAULT_KEEPALIVE_INTERVAL), + ] + ) + ->setHelp( + <<<'EOF' +The %command.name% command consumes messages and dispatches them to the message bus. + + php %command.full_name% + +To receive from multiple transports, pass each name: + + php %command.full_name% receiver1 receiver2 + +Use the --limit option to limit the number of messages received: + + php %command.full_name% --limit=10 + +Use the --failure-limit option to stop the worker when the given number of failed messages is reached: + + php %command.full_name% --failure-limit=2 + +Use the --memory-limit option to stop the worker if it exceeds a given memory usage limit. You can use shorthand byte values [K, M or G]: + + php %command.full_name% --memory-limit=128M + +Use the --time-limit option to stop the worker when the given time limit (in seconds) is reached. +If a message is being handled, the worker will stop after the processing is finished: + + php %command.full_name% --time-limit=3600 + +Use the --bus option to specify the message bus to dispatch received messages +to instead of trying to determine it automatically. This is required if the +messages didn't originate from Messenger: + + php %command.full_name% --bus=event_bus + +Use the --queues option to limit a receiver to only certain queues (only supported by some receivers): + + php %command.full_name% --queues=fasttrack + +Use the --all option to consume from all receivers: + + php %command.full_name% --all +EOF + ) + ; + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ($input->hasParameterOption('--keepalive')) { + $this->getApplication()->setAlarmInterval((int)($input->getOption('keepalive') ?? self::DEFAULT_KEEPALIVE_INTERVAL)); + } + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if ($input->getOption('all')) { + return; + } + + if ($this->receiverNames && !$input->getArgument('receivers')) { + if (count($this->receiverNames) === 1) { + $input->setArgument('receivers', $this->receiverNames); + return; + } + + $io->block('Which transports/receivers do you want to consume?', null, 'fg=white;bg=blue', ' ', true); + + $io->writeln('Choose which receivers you want to consume messages from in order of priority.'); + $io->writeln(sprintf('Hint: to consume from multiple, use a list of their names, e.g. %s', implode(', ', $this->receiverNames))); + + $question = new ChoiceQuestion('Select receivers to consume:', $this->receiverNames, 0); + $question->setMultiselect(true); + + $input->setArgument('receivers', $io->askQuestion($question)); + } + + if (!$input->getArgument('receivers')) { + throw new RuntimeException('Please pass at least one receiver.', 1605305001); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->logger = new ConsoleLogger($output); + + $receivers = []; + $rateLimiters = []; + $receiverNames = $input->getOption('all') ? $this->receiverNames : $input->getArgument('receivers'); + foreach ($receiverNames as $receiverName) { + if (!$this->receiverLocator->has($receiverName)) { + $message = sprintf('The receiver "%s" does not exist.', $receiverName); + if ($this->receiverNames) { + $message .= sprintf(' Valid receivers are: %s.', implode(', ', $this->receiverNames)); + } + throw new RuntimeException($message, 1605305002); + } + + $receiver = $this->receiverLocator->get($receiverName); + if ($receiver instanceof SyncTransport) { + $idx = array_search($receiverName, $receiverNames); + unset($receiverNames[$idx]); + + continue; + } + + $receivers[$receiverName] = $receiver; + if ($this->rateLimiterLocator?->has($receiverName)) { + $rateLimiters[$receiverName] = $this->rateLimiterLocator->get($receiverName); + } + } + + $stopsWhen = []; + if (null !== $limit = $input->getOption('limit')) { + if (!is_numeric($limit) || $limit <= 0) { + throw new InvalidOptionException(sprintf('Option "limit" must be a positive integer, "%s" passed.', $limit), 1605305003); + } + + $stopsWhen[] = "processed {$limit} messages"; + $this->addSubscriber(new StopWorkerOnMessageLimitListener((int)$limit, $this->logger)); + } + + if ($failureLimit = $input->getOption('failure-limit')) { + $stopsWhen[] = "reached {$failureLimit} failed messages"; + $this->addSubscriber(new StopWorkerOnFailureLimitListener((int)$failureLimit, $this->logger)); + } + + if ($memoryLimit = $input->getOption('memory-limit')) { + $stopsWhen[] = "exceeded {$memoryLimit} of memory"; + $this->addSubscriber(new StopWorkerOnMemoryLimitListener($this->convertToBytes($memoryLimit), $this->logger)); + } + + if (null !== $timeLimit = $input->getOption('time-limit')) { + if (!is_numeric($timeLimit) || $timeLimit <= 0) { + throw new InvalidOptionException(sprintf('Option "time-limit" must be a positive integer, "%s" passed.', $timeLimit), 1605305004); + } + + $stopsWhen[] = "been running for {$timeLimit}s"; + $this->addSubscriber(new StopWorkerOnTimeLimitListener((int)$timeLimit, $this->logger)); + } + + $stopsWhen[] = 'received a stop signal via the messenger:stop-workers command'; + + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $io->success(sprintf('Consuming messages from transport%s "%s".', count($receivers) > 1 ? 's' : '', implode(', ', $receiverNames))); + + if ($stopsWhen) { + $last = array_pop($stopsWhen); + $stopsWhen = ($stopsWhen ? implode(', ', $stopsWhen) . ' or ' : '') . $last; + $io->comment("The worker will automatically exit once it has {$stopsWhen}."); + } + + $io->comment('Quit the worker with CONTROL-C.'); + + if ($output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) { + $io->comment('Re-run the command with a -vv option to see logs about consumed messages.'); + } + + $this->worker = new Worker($receivers, $this->messageBus, $this->eventDispatcher, $this->logger, $rateLimiters); + $options = [ + 'sleep' => $input->getOption('sleep') * 1000000, + ]; + if ($queues = $input->getOption('queues')) { + $options['queues'] = $queues; + } + + try { + $this->worker->run($options); + } finally { + $this->worker = null; + } + + return Command::SUCCESS; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('receivers')) { + $suggestions->suggestValues(array_diff($this->receiverNames, array_diff($input->getArgument('receivers'), [$input->getCompletionValue()]))); + + return; + } + + if ($input->mustSuggestOptionValuesFor('bus')) { + $suggestions->suggestValues($this->busIds); + } + } + + public function getSubscribedSignals(): array + { + return $this->signals ?? (extension_loaded('pcntl') ? [SIGTERM, SIGINT, SIGQUIT, SIGALRM] : []); + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + if (!$this->worker) { + return false; + } + + if (defined('SIGALRM') && $signal === SIGALRM) { + $this->logger?->debug('Sending keepalive request.', ['transport_names' => $this->worker->getMetadata()->getTransportNames()]); + + $this->worker->keepalive($this->getApplication()->getAlarmInterval()); + + return false; + } + + $this->logger?->info('Received signal {signal}.', ['signal' => $signal, 'transport_names' => $this->worker->getMetadata()->getTransportNames()]); + + $this->worker->stop(); + + return false; + } + + private function convertToBytes(string $memoryLimit): int + { + $memoryLimit = strtolower($memoryLimit); + $max = ltrim($memoryLimit, '+'); + if (str_starts_with($max, '0x')) { + $max = intval($max, 16); + } elseif (str_starts_with($max, '0')) { + $max = intval($max, 8); + } else { + $max = (float)$max; + } + + switch (substr(rtrim($memoryLimit, 'b'), -1)) { + case 't': $max *= 1024; + // no break + case 'g': $max *= 1024; + // no break + case 'm': $max *= 1024; + // no break + case 'k': $max *= 1024; + } + + return (int)$max; + } + + /** + * @todo: This method show be removed when we can add event subscribers dynamically. + */ + private function addSubscriber(EventSubscriberInterface $subscriber): void + { + $this->container->set($subscriber::class, $subscriber); + foreach ($subscriber->getSubscribedEvents() as $eventName => $params) { + $this->listenerProvider->addListener( + $eventName, + $subscriber::class, + is_string($params) ? $params : $params[0], + ); + } + } +} diff --git a/Classes/Command/Descriptor/TextDescriptor.php b/Classes/Command/Descriptor/TextDescriptor.php new file mode 100644 index 0000000..38caa0f --- /dev/null +++ b/Classes/Command/Descriptor/TextDescriptor.php @@ -0,0 +1,128 @@ +commandRegistry = $commandRegistry; + $this->degraded = $degraded; + } + + /** + * {@inheritdoc} + */ + protected function describeApplication(Application $application, array $options = []): void + { + $describedNamespace = $options['namespace'] ?? null; + $rawOutput = $options['raw_text'] ?? false; + + $commands = $this->commandRegistry->filter($describedNamespace); + + if ($rawOutput) { + $width = $this->getColumnWidth(['' => ['commands' => array_keys($commands)]]); + + foreach ($commands as $command) { + $this->write(sprintf("%-{$width}s %s\n", $command['name'], strip_tags($command['description'] ?? '')), true); + } + return; + } + + if ($this->degraded) { + $this->write("Failed to boot dependency injection, only lowlevel commands are available.\n\n", true); + } + + $namespaces = $this->commandRegistry->getNamespaces(); + $help = $application->getHelp(); + if ($help !== '') { + $this->write($help . "\n\n", true); + } + + $this->write("Usage:\n", true); + $this->write(" command [options] [arguments]\n\n"); + + $this->describeInputDefinition(new InputDefinition($application->getDefinition()->getOptions())); + + $this->write("\n\n"); + + if ($describedNamespace) { + $this->write(sprintf('Available commands for the "%s" namespace:', $describedNamespace), true); + $namespace = $namespaces[$describedNamespace] ?? []; + $width = $this->getColumnWidth(['' => $namespace]); + $this->describeNamespace($namespace, $commands, $width); + } else { + $this->write('Available commands:', true); + // calculate max. width based on available commands per namespace + $width = $this->getColumnWidth($namespaces); + foreach ($namespaces as $namespace) { + if ($namespace['id'] !== ApplicationDescription::GLOBAL_NAMESPACE) { + $this->write("\n"); + $this->write(' ' . $namespace['id'] . '', true); + } + $this->describeNamespace($namespace, $commands, $width); + } + } + + $this->write("\n"); + + if ($this->degraded) { + $this->write("\nFailed to boot dependency injection, only lowlevel commands are available.\n", true); + } + } + + private function describeNamespace(array $namespace, array $commands, int $width): void + { + foreach ($namespace['commands'] as $name) { + $this->write("\n"); + $spacingWidth = $width - Helper::length($name); + $command = $commands[$name]; + + $aliases = count($command['aliases']) ? '[' . implode('|', $command['aliases']) . '] ' : ''; + $this->write(sprintf(' %s%s%s', $name, str_repeat(' ', $spacingWidth), $aliases . $command['description']), true); + } + } + + private function getColumnWidth(array $namespaces): int + { + $widths = []; + foreach ($namespaces as $name => $namespace) { + $widths[] = Helper::length($name); + foreach ($namespace['commands'] as $commandName) { + $widths[] = Helper::length($commandName); + } + } + + return $widths ? max($widths) + 2 : 0; + } +} diff --git a/Classes/Command/DumpAutoloadCommand.php b/Classes/Command/DumpAutoloadCommand.php new file mode 100644 index 0000000..fb2da1a --- /dev/null +++ b/Classes/Command/DumpAutoloadCommand.php @@ -0,0 +1,64 @@ +setName('dumpautoload'); + $this->setDescription('Updates class loading information in non-composer mode.'); + $this->setHelp('This command is only needed during development. The extension manager takes care of creating or updating this info properly during extension (de-)activation.'); + $this->setAliases([ + 'extensionmanager:extension:dumpclassloadinginformation', + 'extension:dumpclassloadinginformation', + ]); + } + + /** + * This command is not needed in composer mode. + */ + public function isEnabled(): bool + { + return !Environment::isComposerMode(); + } + + /** + * Dumps the class loading information + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + ClassLoadingInformation::dumpClassLoadingInformation(); + $io->success('Class loading information has been updated.'); + return Command::SUCCESS; + } +} diff --git a/Classes/Command/Exception/WizardDoesNotNeedToMakeChangesException.php b/Classes/Command/Exception/WizardDoesNotNeedToMakeChangesException.php new file mode 100644 index 0000000..d3df37f --- /dev/null +++ b/Classes/Command/Exception/WizardDoesNotNeedToMakeChangesException.php @@ -0,0 +1,27 @@ +addOption( + 'all', + 'a', + InputOption::VALUE_NONE, + 'Also display currently inactive/uninstalled extensions.' + ) + ->addOption( + 'inactive', + 'i', + InputOption::VALUE_NONE, + 'Only show inactive/uninstalled extensions available for installation.' + ); + } + + /** + * Shows the list of all extensions + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $onlyShowInactiveExtensions = $input->getOption('inactive'); + $showAlsoInactiveExtensions = $input->getOption('all'); + if ($onlyShowInactiveExtensions) { + $packages = $this->packageManager->getAvailablePackages(); + $io->title('All inactive/currently uninstalled extensions'); + } elseif ($showAlsoInactiveExtensions) { + $packages = $this->packageManager->getAvailablePackages(); + $io->title('All installed (= active) and available (= inactive/currently uninstalled) extensions'); + } else { + $packages = $this->packageManager->getActivePackages(); + $io->title('All installed (= active) extensions'); + } + + $table = new Table($output); + $table->setHeaders([ + 'Extension Key', + 'Version', + 'Type', + 'Status', + ]); + $table->setColumnWidths([30, 10, 8, 6]); + + /** @var FormatterHelper $formatter */ + $formatter = $this->getHelper('formatter'); + foreach ($packages as $package) { + $isActivePackage = $this->packageManager->isPackageActive($package->getPackageKey()); + if (!$package->getPackageMetaData()->isExtensionType()) { + continue; + } + // Do not show the package if it is active but we only want to see inactive packages + if ($onlyShowInactiveExtensions && $isActivePackage) { + continue; + } + $type = $package->getPackageMetaData()->isFrameworkType() ? 'System' : 'Local'; + // Ensure that the inactive extensions are shown as well + if ($onlyShowInactiveExtensions || ($showAlsoInactiveExtensions && !$isActivePackage)) { + $status = 'inactive'; + } else { + $status = 'active'; + } + + $table->addRow([$package->getPackageKey(), $package->getPackageMetaData()->getVersion(), $type, $status]); + + // Also show the title of the extension, if verbose option is set + if ($output->isVerbose()) { + $title = (string)$package->getPackageMetaData()->getTitle(); + $table->addRow([new TableCell(' ' . $formatter->truncate($title, 80) . "\n\n", ['colspan' => 4])]); + } + } + $table->render(); + return Command::SUCCESS; + } +} diff --git a/Classes/Command/ListCommand.php b/Classes/Command/ListCommand.php new file mode 100644 index 0000000..a1abf0a --- /dev/null +++ b/Classes/Command/ListCommand.php @@ -0,0 +1,67 @@ +bootService->getContainer(); + } catch (\Throwable $e) { + $container = $this->failsafeContainer; + $degraded = true; + } + + $commandRegistry = $container->get(CommandRegistry::class); + + $helper = new DescriptorHelper(); + $helper->register('txt', new TextDescriptor($commandRegistry, $degraded)); + $helper->describe($output, $this->getApplication(), [ + 'format' => $input->getOption('format'), + 'raw_text' => $input->getOption('raw'), + 'namespace' => $input->getArgument('namespace'), + ]); + + return Command::SUCCESS; + } +} diff --git a/Classes/Command/Output/MessageRenderer.php b/Classes/Command/Output/MessageRenderer.php new file mode 100644 index 0000000..a430184 --- /dev/null +++ b/Classes/Command/Output/MessageRenderer.php @@ -0,0 +1,52 @@ +getAllMessages() as $message) { + $this->renderOne($message, $output); + } + } + + public function renderOne(FlashMessage $message, OutputInterface $output): void + { + [$style, $verbosity] = match ($message->getSeverity()) { + ContextualFeedbackSeverity::INFO, + ContextualFeedbackSeverity::NOTICE, + ContextualFeedbackSeverity::OK => ['info', $output::VERBOSITY_VERBOSE], + ContextualFeedbackSeverity::WARNING => ['comment', $output::VERBOSITY_NORMAL], + ContextualFeedbackSeverity::ERROR => ['error', $output::VERBOSITY_NORMAL], + }; + $formattedMessage = sprintf( + "<%s>%s\n%s\n", + $style, + $message->getTitle(), + $message->getMessage(), + $style, + ); + $output->writeln($formattedMessage, $verbosity); + } +} diff --git a/Classes/Command/SendEmailCommand.php b/Classes/Command/SendEmailCommand.php new file mode 100644 index 0000000..c49f501 --- /dev/null +++ b/Classes/Command/SendEmailCommand.php @@ -0,0 +1,83 @@ +addOption('message-limit', null, InputOption::VALUE_REQUIRED, 'The maximum number of messages to send.') + ->addOption('time-limit', null, InputOption::VALUE_REQUIRED, 'The time limit for sending messages (in seconds).') + ->addOption('recover-timeout', null, InputOption::VALUE_REQUIRED, 'The timeout for recovering messages that have taken too long to send (in seconds).'); + } + + /** + * Executes the mailer command + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $transport = $this->mailer->getTransport(); + if ($transport instanceof DelayedTransportInterface) { + if ($transport instanceof FileSpool) { + $transport->setMessageLimit((int)$input->getOption('message-limit')); + $transport->setTimeLimit((int)$input->getOption('time-limit')); + $recoverTimeout = (int)$input->getOption('recover-timeout'); + if ($recoverTimeout) { + $transport->recover($recoverTimeout); + } else { + $transport->recover(); + } + } + $sent = $transport->flushQueue($this->mailer->getRealTransport()); + $io->comment($sent . ' emails sent'); + return Command::SUCCESS; + } + $io->error('The Mailer Transport is not set to "spool".'); + + return Command::FAILURE; + } +} diff --git a/Classes/Command/SetupExtensionsCommand.php b/Classes/Command/SetupExtensionsCommand.php new file mode 100644 index 0000000..899c0b0 --- /dev/null +++ b/Classes/Command/SetupExtensionsCommand.php @@ -0,0 +1,106 @@ +setDescription('Set up extensions') + ->setHelp( + <<<'EOD' +Setup all extensions or the given extension by extension key. This must +be performed after new extensions are required via Composer. + +The command performs all necessary setup operations, such as database +schema changes, static data import, distribution files import etc. + +The given extension keys must be recognized by TYPO3 or will be ignored. +EOD + ) + ->addOption( + 'extension', + '-e', + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, + 'Only set up extensions with given key' + ); + } + + /** + * Sets up one or all extensions + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $this->eventDispatcher->dispatch(new PackagesMayHaveChangedEvent()); + + $io = new SymfonyStyle($input, $output); + $extensionKeys = $input->getOption('extension'); + $packagesToSetUp = $this->packageManager->getActivePackages(); + if (!empty($extensionKeys)) { + $packagesToSetUp = array_filter( + $packagesToSetUp, + static function ($extKey) use ($extensionKeys) { + return in_array($extKey, $extensionKeys, true); + }, + ARRAY_FILTER_USE_KEY + ); + } + if (empty($packagesToSetUp)) { + $io->error('Given extension(s) "' . implode(', ', $extensionKeys) . '" not found in the system.'); + return Command::FAILURE; + } + $messages = $this->packageSetup->setup($packagesToSetUp); + foreach ($messages as $message) { + $io->warning($message->getMessage()); + } + $io->success('Extension(s) "' . implode(', ', array_keys($packagesToSetUp)) . '" successfully set up.'); + + return Command::SUCCESS; + } +} diff --git a/Classes/Command/SiteListCommand.php b/Classes/Command/SiteListCommand.php new file mode 100644 index 0000000..8163aeb --- /dev/null +++ b/Classes/Command/SiteListCommand.php @@ -0,0 +1,96 @@ +siteFinder->getAllSites(); + + if (empty($sites)) { + $io->title('No sites configured'); + $io->note('Configure new sites in the "Sites" module.'); + return Command::SUCCESS; + } + + $io->title('All configured sites'); + $table = new Table($output); + $table->setHeaders([ + 'Identifier', + 'Root PID', + 'Base URL', + 'Language', + 'Locale', + 'Status', + ]); + foreach ($sites as $site) { + $baseUrls = []; + $languages = []; + $locales = []; + $status = []; + foreach ($site->getAllLanguages() as $language) { + $baseUrls[] = (string)$language->getBase(); + $languages[] = sprintf( + '%s (id:%d)', + $language->getTitle(), + $language->getLanguageId() + ); + $locales[] = (string)$language->getLocale(); + $status[] = $language->isEnabled() + ? 'enabled' + : 'disabled'; + } + $table->addRow( + [ + '' . $site->getIdentifier() . '', + $site->getRootPageId(), + implode("\n", $baseUrls), + implode("\n", $languages), + implode("\n", $locales), + implode("\n", $status), + ] + ); + } + $table->render(); + return Command::SUCCESS; + } +} diff --git a/Classes/Command/SiteSetsListCommand.php b/Classes/Command/SiteSetsListCommand.php new file mode 100644 index 0000000..9f6af1f --- /dev/null +++ b/Classes/Command/SiteSetsListCommand.php @@ -0,0 +1,125 @@ +setDefinition([ + new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show all sets, including hidden ones.'), + ]); + } + + /** + * Shows a table with all configured sites + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $showAll = $input->getOption('all') ?? false; + $sets = $this->setRegistry->getAllSets(); + + if ($sets === []) { + $io->title('No site sets configured'); + $io->note('Configure new sites by placing a Configuration/Sets/MySetName/config.yaml in an extension.'); + return Command::SUCCESS; + } + + $io->title('All configured site sets'); + $table = new Table($output); + $table->setHeaders([ + 'Name', + 'Label', + 'Dependencies', + ]); + foreach ($sets as $set) { + if ($set->hidden && !$showAll) { + continue; + } + $table->addRow( + [ + '' . $set->name . ($set->hidden ? ' (hidden)' : '') . '', + $this->getLanguageService()->sL($set->label), + implode(', ', [ + ...$set->dependencies, + ...array_map(static fn(string $d): string => '(' . $d . ')', $set->optionalDependencies), + ]), + ] + ); + } + $table->render(); + + $invalidSets = $this->setRegistry->getInvalidSets(); + if ($invalidSets !== []) { + $io->newLine(); + $io->newLine(); + $io->title('Invalid site set configurations'); + $table = new Table($output); + $table->setHeaders([ + 'Set', + 'Error', + ]); + foreach ($invalidSets as $invalidSet) { + $table->addRow( + [ + $invalidSet['name'], + sprintf( + $this->getLanguageService()->sL($invalidSet['error']->getLabel()), + $invalidSet['name'], + $invalidSet['context'], + ), + ] + ); + } + $table->render(); + } + + return Command::SUCCESS; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Command/SiteShowCommand.php b/Classes/Command/SiteShowCommand.php new file mode 100644 index 0000000..1df2fc7 --- /dev/null +++ b/Classes/Command/SiteShowCommand.php @@ -0,0 +1,65 @@ +addArgument( + 'identifier', + InputArgument::REQUIRED, + 'The identifier of the site' + ); + } + + /** + * Shows the configuration of a site + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $site = $this->siteFinder->getSiteByIdentifier($input->getArgument('identifier')); + $io->title('Site configuration for ' . $input->getArgument('identifier')); + $io->block(Yaml::dump($site->getConfiguration(), 4)); + return Command::SUCCESS; + } +} diff --git a/Classes/Command/UpdateLanguagePackCommand.php b/Classes/Command/UpdateLanguagePackCommand.php new file mode 100644 index 0000000..2fad9b6 --- /dev/null +++ b/Classes/Command/UpdateLanguagePackCommand.php @@ -0,0 +1,171 @@ +setDescription('Update the language files of all activated extensions') + ->addArgument( + 'locales', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'Provide iso codes separated by space to update only selected language packs. Example `bin/typo3 language:update de ja`.', + [] + ) + ->addOption( + 'no-progress', + null, + InputOption::VALUE_NONE, + 'Disable progress bar.' + ) + ->addOption( + 'fail-on-warnings', + null, + InputOption::VALUE_NONE, + 'Fail command when translation was not found on the server.' + ) + ->addOption( + 'skip-extension', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Skip extension. Useful for e.g. for not public extensions, which don\'t have language packs.', + [] + ); + } + + /** + * Update language packs of all active languages for all active extensions + * + * @throws \InvalidArgumentException + * @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $container = $this->bootService->loadExtLocalconfDatabase(); + $languagePackService = $container->get(LanguagePackService::class); + $noProgress = $input->getOption('no-progress') || $output->isVerbose(); + $isos = (array)$input->getArgument('locales'); + $skipExtensions = (array)$input->getOption('skip-extension'); + $failOnWarnings = (bool)$input->getOption('fail-on-warnings'); + $status = Command::SUCCESS; + + // Condition for the scheduler command, e.g. "de fr pt" + if (count($isos) === 1 && str_contains($isos[0], ' ')) { + $isos = GeneralUtility::trimExplode(' ', $isos[0], true); + } + if (empty($isos)) { + $isos = $languagePackService->getActiveLanguages(); + } + + $output->writeln('Updating language packs'); + + $extensions = $languagePackService->getExtensionLanguagePackDetails(); + + if ($noProgress) { + $progressBarOutput = new NullOutput(); + } else { + $progressBarOutput = $output; + } + + $downloads = []; + $packageCount = 0; + foreach ($extensions as $extensionKey => $extension) { + if (in_array($extensionKey, $skipExtensions, true)) { + continue; + } + $downloads[$extensionKey] = []; + foreach ($extension['packs'] as $iso => $pack) { + if (!in_array($iso, $isos, true)) { + continue; + } + $downloads[$extensionKey][] = $iso; + $packageCount++; + } + + if (empty($downloads[$extensionKey])) { + unset($downloads[$extensionKey]); + } + } + $progressBar = new ProgressBar($progressBarOutput, $packageCount); + foreach ($downloads as $extension => $extensionLanguages) { + foreach ($extensionLanguages as $iso) { + if ($noProgress) { + $output->writeln(sprintf('Fetching pack for language "%s" for extension "%s"', $iso, $extension), $output::VERBOSITY_VERY_VERBOSE); + } + $result = $languagePackService->languagePackDownload($extension, $iso); + if ($noProgress) { + switch ($result) { + case 'failed': + $output->writeln(sprintf('Fetching pack for language "%s" for extension "%s" failed', $iso, $extension)); + break; + case 'update': + $output->writeln(sprintf('Updated pack for language "%s" for extension "%s"', $iso, $extension)); + break; + case 'new': + $output->writeln(sprintf('Fetching new pack for language "%s" for extension "%s"', $iso, $extension)); + break; + case 'skipped': + $output->writeln(sprintf('Skipped pack for language "%s" for extension "%s"', $iso, $extension)); + break; + } + } + + // Fail only if --fail-on-warnings is set and a language pack was not found. + if ($failOnWarnings && $result === 'failed') { + $status = Command::FAILURE; + } + + $progressBar->advance(); + } + } + $languagePackService->setLastUpdatedIsoCode($isos); + $progressBar->finish(); + $output->writeln(''); + // Flush language cache + GeneralUtility::makeInstance(CacheManager::class)->getCache('l10n')->flush(); + + return $status; + } +} diff --git a/Classes/Command/UpgradeWizardListCommand.php b/Classes/Command/UpgradeWizardListCommand.php new file mode 100644 index 0000000..58528c1 --- /dev/null +++ b/Classes/Command/UpgradeWizardListCommand.php @@ -0,0 +1,134 @@ +upgradeWizardsService = $this->bootService + ->loadExtLocalconfDatabase(false, false) + ->get(UpgradeWizardsService::class); + Bootstrap::initializeBackendAuthentication(); + } + + /** + * Configure the command by defining the name, options and arguments + */ + protected function configure(): void + { + $this->setDescription('List available upgrade wizards.') + ->addOption( + 'all', + 'a', + InputOption::VALUE_NONE, + 'Include wizards already done.' + ); + } + + /** + * List available upgrade wizards. If -all is given, already done wizards are listed, too. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->output = new SymfonyStyle($input, $output); + $this->bootstrap(); + + $wizards = []; + $all = $input->getOption('all'); + foreach ($this->upgradeWizardsService->getUpgradeWizardIdentifiers() as $identifier) { + $upgradeWizard = $this->getWizard($identifier, (bool)$all); + if ($upgradeWizard !== null) { + $wizardInfo = [ + 'identifier' => $identifier, + 'title' => $upgradeWizard->getTitle(), + 'description' => wordwrap($upgradeWizard->getDescription()), + ]; + if ($all === true) { + $wizardInfo['status'] = $this->upgradeWizardsService->isWizardDone($identifier) ? 'DONE' : 'AVAILABLE'; + } + $wizards[] = $wizardInfo; + } + } + if (empty($wizards)) { + $this->output->success('No wizards available.'); + } elseif ($all === true) { + $this->output->table(['Identifier', 'Title', 'Description', 'Status'], $wizards); + } else { + $this->output->table(['Identifier', 'Title', 'Description'], $wizards); + } + return Command::SUCCESS; + } + + /** + * Get Wizard instance by identifier + * Returns null if wizard is already done + */ + protected function getWizard(string $identifier, bool $all = false): ?UpgradeWizardInterface + { + // already done + if (!$all && $this->upgradeWizardsService->isWizardDone($identifier)) { + return null; + } + + $wizard = $this->upgradeWizardsService->getUpgradeWizard($identifier); + if ($wizard === null) { + return null; + } + + if ($wizard instanceof ChattyInterface) { + $wizard->setOutput($this->output); + } + + return !$all ? $wizard->updateNecessary() ? $wizard : null : $wizard; + } +} diff --git a/Classes/Command/UpgradeWizardMarkUndoneCommand.php b/Classes/Command/UpgradeWizardMarkUndoneCommand.php new file mode 100644 index 0000000..288f03c --- /dev/null +++ b/Classes/Command/UpgradeWizardMarkUndoneCommand.php @@ -0,0 +1,85 @@ +upgradeWizardsService = $this->bootService + ->loadExtLocalconfDatabase(false, false) + ->get(UpgradeWizardsService::class); + Bootstrap::initializeBackendAuthentication(); + } + + /** + * Configure the command by defining the name, options and arguments + */ + protected function configure(): void + { + $this->setDescription('Mark upgrade wizard as undone.') + ->addArgument( + 'wizardIdentifier', + InputArgument::REQUIRED + ); + } + + /** + * Mark an upgrade wizard as undone + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $this->bootstrap(); + $wizardIdentifier = (string)$input->getArgument('wizardIdentifier'); + $wizardInformation = $this->upgradeWizardsService->getWizardInformationByIdentifier($wizardIdentifier); + $hasBeenMarkedUndone = $this->upgradeWizardsService->markWizardUndone($wizardIdentifier); + if ($hasBeenMarkedUndone) { + $io->success('The wizard "' . $wizardInformation['title'] . '" has been marked as undone.'); + return Command::SUCCESS; + } + $io->error('The wizard "' . $wizardInformation['title'] . '" could not be marked undone, because it was most likely not yet run.'); + return Command::FAILURE; + } +} diff --git a/Classes/Command/UpgradeWizardRunCommand.php b/Classes/Command/UpgradeWizardRunCommand.php new file mode 100644 index 0000000..284fae2 --- /dev/null +++ b/Classes/Command/UpgradeWizardRunCommand.php @@ -0,0 +1,316 @@ +bootService + ->loadExtLocalconfDatabase(false, false); + $this->upgradeWizardsService = $container->get(UpgradeWizardsService::class); + $this->databaseUpgradeWizardsService = $container->get(DatabaseUpgradeWizardsService::class); + Bootstrap::initializeBackendAuthentication(); + $this->databaseUpgradeWizardsService->isDatabaseCharsetUtf8() + ?: $this->databaseUpgradeWizardsService->setDatabaseCharsetUtf8(); + // Ensure SilentConfigurationUpdates are also run on CLI, if necessary. Due to the fact that single silent + // upgrade tasks throwing a `ConfigurationChangedException` on changes and stopping the execution of following + // upgrades, we need to handle this in a loop handling this exception. Other errors leading to a direct stop, + // with an additionally concrete handling for readonly `settings.php`. To be safe against future end-less loops, + // a max-try check is used. + $loopSafety = 0; + do { + try { + $this->configurationUpgradeService->execute(); + $success = true; + } catch (ConfigurationChangedException) { + // Due to the fact that single silent upgrade tasks emits and stops the upgrade chain, we need to + // handle this as not successful and continue processing the upgrade chain. Therefore, the loop. + $success = false; + } catch (SettingsWriteException $e) { + // Readonly or not-writable `settings.php`. Throw a more meaning full exception and stop the upgrade. + throw new SilentConfigurationUpgradeReadonlyException(1688462973, $e); + } + $loopSafety++; + } while ($success === false && $loopSafety < self::MAX_SILENT_UPGRADE_TRIES); + } + + /** + * Configure the command by defining the name, options and arguments + */ + protected function configure(): void + { + $this->setDescription('Run upgrade wizard. Without arguments all available wizards will be run.') + ->addArgument( + 'wizardName', + InputArgument::OPTIONAL + )->setHelp( + 'This command allows running upgrade wizards on CLI. To run a single wizard add the ' + . 'identifier of the wizard as argument. The identifier of the wizard is the name it is ' + . 'registered with in ext_localconf.' + ); + } + + /** + * Update language packs of all active languages for all active extensions + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->output = new SymfonyStyle($input, $output); + $this->input = $input; + $this->bootstrap(); + $wizardToExecute = (string)$input->getArgument('wizardName'); + if ($wizardToExecute === '') { + return $this->runAllWizards(); + } + + try { + $upgradeWizard = $this->getWizard($wizardToExecute); + } catch (WizardMarkedAsDoneException|WizardDoesNotNeedToMakeChangesException $e) { + $this->output->note($e->getMessage()); + return Command::SUCCESS; + } catch (WizardNotFoundException $e) { + $this->output->error($e->getMessage()); + return Command::FAILURE; + } + + $prerequisitesFulfilled = $this->handlePrerequisites([$upgradeWizard]); + if ($prerequisitesFulfilled === true) { + return $this->runSingleWizard($upgradeWizard); + } + return Command::FAILURE; + } + + /** + * Get Wizard instance by class name and identifier + * Returns null if wizard is already done + */ + protected function getWizard(string $identifier): UpgradeWizardInterface + { + // already done + if ($this->upgradeWizardsService->isWizardDone($identifier)) { + throw new WizardMarkedAsDoneException( + sprintf('Wizard %s already marked as done', $identifier), + 1713880347 + ); + } + $wizard = $this->upgradeWizardsService->getUpgradeWizard($identifier); + if ($wizard === null) { + throw new WizardNotFoundException( + sprintf('No such wizard: %s', $identifier), + 1713880629 + ); + } + + if ($wizard instanceof ChattyInterface) { + $wizard->setOutput($this->output); + } + if ($wizard->updateNecessary()) { + return $wizard; + } + + if (!($wizard instanceof RepeatableInterface)) { + $this->upgradeWizardsService->markWizardAsDone($wizard); + throw new WizardMarkedAsDoneException( + sprintf('Wizard %s does not need to make changes. Marking wizard as done.', $identifier), + 1713880485 + ); + } + throw new WizardDoesNotNeedToMakeChangesException( + sprintf('Wizard %s does not need to make changes.', $identifier), + 1713880493 + ); + } + + /** + * Handles prerequisites of update wizards, allows a more flexible definition and declaration of dependencies + * Currently implemented prerequisites include "database needs to be up-to-date" and "referenceIndex needs to be up- + * to-date" + * At the moment the install tool automatically displays the database updates when necessary but can't do more + * prerequisites + * + * @param UpgradeWizardInterface[] $instances + */ + protected function handlePrerequisites(array $instances): bool + { + $prerequisites = GeneralUtility::makeInstance(PrerequisiteCollection::class); + foreach ($instances as $instance) { + foreach ($instance->getPrerequisites() as $prerequisite) { + $prerequisites->add($prerequisite); + } + } + $result = true; + foreach ($prerequisites as $prerequisite) { + if ($prerequisite instanceof ChattyInterface) { + $prerequisite->setOutput($this->output); + } + if (!$prerequisite->isFulfilled()) { + $this->output->writeln('Prerequisite "' . $prerequisite->getTitle() . '" not fulfilled, will ensure.'); + $result = $prerequisite->ensure(); + if ($result === false) { + $this->output->error( + 'Error running ' + . $prerequisite->getTitle() + . '. Please ensure this prerequisite manually and try again.' + ); + break; + } + } else { + $this->output->writeln('Prerequisite "' . $prerequisite->getTitle() . '" fulfilled.'); + } + } + return $result; + } + + protected function runSingleWizard( + UpgradeWizardInterface $instance + ): int { + $this->output->title('Running Wizard "' . $instance->getTitle() . '"'); + if ($instance instanceof ConfirmableInterface) { + $confirmation = $instance->getConfirmation(); + $defaultString = $confirmation->getDefaultValue() ? 'Y/n' : 'y/N'; + $question = new ConfirmationQuestion( + sprintf( + '%s' . LF . '%s' . LF . '%s %s (%s)', + $confirmation->getTitle(), + $confirmation->getMessage(), + $confirmation->getConfirm(), + $confirmation->getDeny(), + $defaultString + ), + $confirmation->getDefaultValue() + ); + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + if (!$helper->ask($this->input, $this->output, $question)) { + if ($confirmation->isRequired()) { + $this->output->error('You have to acknowledge this wizard to continue'); + return Command::FAILURE; + } + if ($instance instanceof RepeatableInterface) { + $this->output->note('No changes applied.'); + } else { + $this->upgradeWizardsService->markWizardAsDone($instance); + $this->output->note('No changes applied, marking wizard as done.'); + } + return Command::SUCCESS; + } + } + if ($instance->executeUpdate()) { + $this->output->success('Successfully ran wizard ' . $instance->getTitle()); + if (!$instance instanceof RepeatableInterface) { + $this->upgradeWizardsService->markWizardAsDone($instance); + } + return Command::SUCCESS; + } + $this->output->error('Something went wrong while running ' . $instance->getTitle() . ''); + return Command::FAILURE; + } + + /** + * Get list of registered upgrade wizards. + * + * @return int 0 if all wizards were successful, 1 on error + */ + public function runAllWizards(): int + { + $returnCode = Command::SUCCESS; + $wizardInstances = []; + foreach ($this->upgradeWizardsService->getUpgradeWizardIdentifiers() as $identifier) { + try { + $wizardInstances[] = $this->getWizard($identifier); + } catch (WizardMarkedAsDoneException|WizardDoesNotNeedToMakeChangesException|WizardNotFoundException) { + // NOOP + } + } + if (count($wizardInstances) > 0) { + $prerequisitesResult = $this->handlePrerequisites($wizardInstances); + if ($prerequisitesResult === false) { + $returnCode = Command::FAILURE; + $this->output->error('Error handling prerequisites, aborting.'); + } else { + $this->output->title('Found ' . count($wizardInstances) . ' wizard(s) to run.'); + foreach ($wizardInstances as $wizardInstance) { + $result = $this->runSingleWizard($wizardInstance); + if ($result > 0) { + $returnCode = Command::FAILURE; + } + } + } + } else { + $this->output->success('No wizards left to run.'); + } + return $returnCode; + } +} diff --git a/Classes/Compatibility/PublicMethodDeprecationTrait.php b/Classes/Compatibility/PublicMethodDeprecationTrait.php new file mode 100644 index 0000000..5dd6b9a --- /dev/null +++ b/Classes/Compatibility/PublicMethodDeprecationTrait.php @@ -0,0 +1,99 @@ + 'Using MyControllerClass::myMethod() is deprecated and will not be possible anymore in TYPO3 v10.0. Use MyControllerClass:myOtherMethod() instead.' + * ]; + * + * /** + * * This is my method. + * * + * * @deprecated (if deprecated) + * * @internal (if switched to private) + * / + * protected function myMethod($arg1, $arg2); + * } + */ + +/** + * This trait has no public methods by default, ensure to add a $deprecatedPublicMethods property + * to your class when using this trait. + */ +trait PublicMethodDeprecationTrait +{ + /** + * Checks if the method of the given name is available, calls it but throws a deprecation. + * If the method does not exist, a fatal error is thrown. + * + * Unavailable protected methods must return in a fatal error as usual. + * Marked methods are called and a deprecation entry is thrown. + * + * __call() is not called for public methods. + * + * @property array $deprecatedPublicMethods List of deprecated public methods + * @param string $methodName + * @param array $arguments + * @return mixed + */ + public function __call(string $methodName, array $arguments) + { + if (method_exists($this, $methodName) && isset($this->deprecatedPublicMethods[$methodName])) { + trigger_error($this->deprecatedPublicMethods[$methodName], E_USER_DEPRECATED); + return $this->$methodName(...$arguments); + } + + // Do the same behaviour as calling $myObject->method(); + if (method_exists($this, $methodName)) { + throw new \Error('Call to protected/private method ' . self::class . '::' . $methodName . '()', 1720252929); + } + + throw new \Error('Call to undefined method ' . self::class . '::' . $methodName . '()', 1720252942); + } +} diff --git a/Classes/Compatibility/PublicPropertyDeprecationTrait.php b/Classes/Compatibility/PublicPropertyDeprecationTrait.php new file mode 100644 index 0000000..114c37f --- /dev/null +++ b/Classes/Compatibility/PublicPropertyDeprecationTrait.php @@ -0,0 +1,140 @@ + 'Using myProperty is deprecated and will not be possible anymore in TYPO3 v10.0. Use getMyProperty() instead.' + * ]; + * + * /** + * * This is my property. + * * + * * @var bool + * * @deprecated (if deprecated) + * * @internal (if switched to private) + * / + * protected $myProperty = true; + * } + */ + +/** + * This trait has no public properties by default, ensure to add a $deprecatedPublicProperties to your class + * when using this trait. + */ +trait PublicPropertyDeprecationTrait +{ + /** + * Checks if the property of the given name is set. + * + * Unmarked protected properties must return false as usual. + * Marked properties are evaluated by isset(). + * + * This method is not called for public properties. + * + * @property array $deprecatedPublicProperties List of deprecated public properties + * @param string $propertyName + * @return bool + */ + public function __isset(string $propertyName) + { + if (isset($this->deprecatedPublicProperties[$propertyName])) { + trigger_error($this->deprecatedPublicProperties[$propertyName], E_USER_DEPRECATED); + return isset($this->$propertyName); + } + return false; + } + + /** + * Gets the value of the property of the given name if tagged. + * + * The evaluation is done in the assumption that this method is never + * reached for a public property. + * + * @property array $deprecatedPublicProperties List of deprecated public properties + * @param string $propertyName + * @return mixed + */ + public function __get(string $propertyName) + { + if (isset($this->deprecatedPublicProperties[$propertyName])) { + trigger_error($this->deprecatedPublicProperties[$propertyName], E_USER_DEPRECATED); + } + return property_exists($this, $propertyName) ? $this->$propertyName : null; + } + + /** + * Sets the property of the given name if tagged. + * + * Additionally it's allowed to set unknown properties. + * + * The evaluation is done in the assumption that this method is never + * reached for a public property. + * + * @property array $deprecatedPublicProperties List of deprecated public properties + * @param string $propertyName + * @param mixed $propertyValue + */ + public function __set(string $propertyName, $propertyValue) + { + // It's allowed to set an unknown property as public, the check is thus necessary + if (property_exists($this, $propertyName) && isset($this->deprecatedPublicProperties[$propertyName])) { + trigger_error($this->deprecatedPublicProperties[$propertyName], E_USER_DEPRECATED); + } + $this->$propertyName = $propertyValue; + } + + /** + * Unsets the property of the given name if tagged. + * + * @property array $deprecatedPublicProperties List of deprecated public properties + */ + public function __unset(string $propertyName) + { + if (isset($this->deprecatedPublicProperties[$propertyName])) { + trigger_error($this->deprecatedPublicProperties[$propertyName], E_USER_DEPRECATED); + } + unset($this->$propertyName); + } +} diff --git a/Classes/Composer/CliEntryPoint.php b/Classes/Composer/CliEntryPoint.php new file mode 100644 index 0000000..0b6269d --- /dev/null +++ b/Classes/Composer/CliEntryPoint.php @@ -0,0 +1,74 @@ +source = $source; + $this->target = $target; + } + + public function run(Event $event): bool + { + $composer = $event->getComposer(); + $filesystemUtility = new FilesystemUtility(); + $filesystem = new Filesystem(); + $pluginConfig = Config::load($composer); + + $entryPointContent = file_get_contents($this->source); + if ($entryPointContent === false) { + return false; + } + $targetFile = $pluginConfig->get('root-dir') . '/' . $this->target; + $autoloadFile = $composer->getConfig()->get('vendor-dir') . '/autoload.php'; + + $entryPointContent = preg_replace( + '/__DIR__ . \'[^\']*\'/', + $filesystemUtility->findShortestPathCode($targetFile, $autoloadFile), + $entryPointContent + ); + + $filesystemUtility->ensureDirectoryExists(dirname($targetFile)); + $filesystem->dumpFile($targetFile, $entryPointContent); + $filesystem->chmod($targetFile, 0755); + + return $filesystem->exists($targetFile); + } +} diff --git a/Classes/Composer/CommandExecutionFailedException.php b/Classes/Composer/CommandExecutionFailedException.php new file mode 100644 index 0000000..5470296 --- /dev/null +++ b/Classes/Composer/CommandExecutionFailedException.php @@ -0,0 +1,31 @@ +typo3Command)); + $message .= chr(10) . $this->errorOutput; + parent::__construct($message, $code); + } +} diff --git a/Classes/Composer/ConsoleCommand.php b/Classes/Composer/ConsoleCommand.php new file mode 100644 index 0000000..566fe86 --- /dev/null +++ b/Classes/Composer/ConsoleCommand.php @@ -0,0 +1,112 @@ +getIO(); + if ($this->message) { + $io->writeError(sprintf('%s', $this->message)); + } + try { + $this->executeProcess($event); + } catch (CommandExecutionFailedException $e) { + $io->writeError(sprintf('%s', $e->getMessage())); + return false; + } + return true; + } + + private function executeProcess(Event $event): void + { + $io = $event->getIO(); + $typo3Command = $this->getTypo3Command($event); + array_unshift($typo3Command, ...$this->getPhpExecCommand()); + + $process = new ProcessExecutor($io); + $exitCode = $process->execute($typo3Command, $commandOutput); + if ($exitCode !== 0) { + $errorOutput = trim($commandOutput); + if ($process->getErrorOutput() !== '') { + $errorOutput .= chr(10) . $process->getErrorOutput(); + } + throw new CommandExecutionFailedException( + $this->command, + $errorOutput, + 1765283208, + ); + } + $io->writeError($commandOutput, false); + } + + private function getTypo3Command(Event $event): array + { + $composer = $event->getComposer(); + $binDir = $composer->getConfig()->get('bin-dir'); + + $finder = new ExecutableFinder(); + $pathToTypo3Binary = $finder->find('typo3', null, [$binDir]); + if ($pathToTypo3Binary === null) { + throw new \RuntimeException('Could not determine path to typo3 binary', 1765273845); + } + if (Platform::isWindows()) { + $pathToTypo3BinaryWithoutExt = Preg::replace('{\.(exe|bat|cmd|com)$}i', '', $pathToTypo3Binary); + // prefer non-extension file if it exists when executing with PHP + if (file_exists($pathToTypo3BinaryWithoutExt)) { + $pathToTypo3Binary = $pathToTypo3BinaryWithoutExt; + } + unset($pathToTypo3BinaryWithoutExt); + } + $typo3Command = $this->command; + array_unshift($typo3Command, $pathToTypo3Binary); + return $typo3Command; + } + + private function getPhpExecCommand(): array + { + $finder = new PhpExecutableFinder(); + $phpPath = $finder->find(false); + if (!$phpPath) { + throw new \RuntimeException('Failed to locate PHP binary to execute ' . $phpPath, 1765274260); + } + $phpArgs = $finder->findArguments(); + array_unshift($phpArgs, $phpPath); + $phpArgs[] = '-d'; + $phpArgs[] = 'allow_url_fopen=' . ini_get('allow_url_fopen'); + $phpArgs[] = '-d'; + $phpArgs[] = 'disable_functions=' . ini_get('disable_functions'); + $phpArgs[] = '-d'; + $phpArgs[] = 'memory_limit=' . ini_get('memory_limit'); + return $phpArgs; + } +} diff --git a/Classes/Composer/FrameworkPackageWriter.php b/Classes/Composer/FrameworkPackageWriter.php new file mode 100644 index 0000000..bf27abe --- /dev/null +++ b/Classes/Composer/FrameworkPackageWriter.php @@ -0,0 +1,65 @@ +getComposer(), $event->getIO()); + $basePath = $config->get('base-dir'); + $frameworkPackageNames = $this->getFrameworkPackageNames($config); + $io = $event->getIO(); + $io->writeError('TYPO3: Dumping framework package names', true, $io::VERBOSE); + file_put_contents( + $basePath . self::CORE_RESOURCE_PATH, + 'get('base-dir') . '/composer.json'); + if ($typo3Json === false) { + throw new \RuntimeException('The main TYPO3 composer.json file was not found.', 1774091461); + } + return array_keys( + array_filter( + json_decode($typo3Json, true, 512, JSON_THROW_ON_ERROR)['replace'] ?? [], + static fn($value) => $value === 'self.version' + ) + ); + } +} diff --git a/Classes/Composer/InstallerScripts.php b/Classes/Composer/InstallerScripts.php new file mode 100644 index 0000000..fc51dcd --- /dev/null +++ b/Classes/Composer/InstallerScripts.php @@ -0,0 +1,64 @@ +addInstallerScript( + new EntryPoint( + dirname(__DIR__, 2) . '/Resources/Private/Php/index.php', + 'index.php' + ) + ); + + if ($event->getComposer()->getPackage()->getName() === 'typo3/cms') { + // We only need to provide the binary in monorepo classic mode (regular Composer mode receives it via typo3/cms-cli) + $source = dirname(__DIR__, 2) . '/Resources/Private/Php/cli.php'; + $target = 'typo3/sysext/core/bin/typo3'; + $scriptDispatcher->addInstallerScript(new CliEntryPoint($source, $target)); + $scriptDispatcher->addInstallerScript(new FrameworkPackageWriter()); + } else { + // Provide package artifact in regular composer mode (not needed for monorepo classic mode) + $scriptDispatcher->addInstallerScript( + new PackageArtifactBuilder() + ); + if (!getenv('TYPO3_SKIP_ASSET_PUBLISH')) { + $command = ['asset:publish']; + if ($event->getIO()->isVerbose()) { + $command[] = '-v'; + } + $scriptDispatcher->addInstallerScript( + new ConsoleCommand( + $command + ) + ); + } + } + } +} diff --git a/Classes/Composer/PackageArtifactBuilder.php b/Classes/Composer/PackageArtifactBuilder.php new file mode 100644 index 0000000..23d0165 --- /dev/null +++ b/Classes/Composer/PackageArtifactBuilder.php @@ -0,0 +1,214 @@ + + * @template IOMessage of array{severity: 'title'|'info'|'warning', verbosity: int, message: string} + * + * @internal This class is an implementation detail and does not represent public API + */ +class PackageArtifactBuilder extends PackageManager implements InstallerScript +{ + /** + * @var Event $event + */ + private $event; + + /** + * @var Config $config + */ + private $config; + + /** + * @var Filesystem $fileSystem + */ + private $fileSystem; + + private array $installedTypo3Extensions = []; + + public function __construct() + { + // Disable path determination with Environment class, which is not initialized here + parent::__construct(new DependencyOrderingService(), '', ''); + } + + public function isComposerDependency(string $packageName): bool + { + return !in_array($packageName, $this->installedTypo3Extensions, true); + } + + /** + * Entry method called in Composer post-dump-autoload hook + * + * @throws InvalidPackageKeyException + * @throws InvalidPackageManifestException + * @throws InvalidPackagePathException + * @throws InvalidPackageStateException + */ + public function run(Event $event): bool + { + $io = $event->getIO(); + $this->event = $event; + $this->config = Config::load($this->event->getComposer(), $io); + $this->fileSystem = new Filesystem(); + $composer = $this->event->getComposer(); + $basePath = $this->config->get('base-dir'); + $this->packagesBasePath = $basePath . '/'; + foreach ($this->extractPackageMapFromComposer() as [$composerPackage, $path, $extensionKey]) { + $packagePath = PathUtility::sanitizeTrailingSeparator($path); + $package = new Package($this, $extensionKey, $packagePath, true); + $package->getPackageMetaData()->setVersion($composerPackage->getPrettyVersion()); + $this->registerPackage($package); + } + $this->sortPackagesAndConfiguration(); + $appPackage = new VirtualAppPackage( + $this, + $this->packagesBasePath, + rtrim($this->config->get('web-dir', $this->config::RELATIVE_PATHS), '/') . '/', + ); + $this->registerPackage($appPackage); + $this->packageStatesConfiguration['packages'][$appPackage->getPackageKey()] = []; + $cacheIdentifier = md5(serialize($composer->getLocker()->getLockData()) . $this->event->isDevMode()); + $this->setPackageCache(new ComposerPackageArtifact($composer->getConfig()->get('vendor-dir') . '/typo3', $this->fileSystem, $cacheIdentifier)); + $this->validateResources(); + $this->saveToPackageCache(); + + return true; + } + + /** + * Make package paths of all packages relative + * so that it does not matter in which environment + * the "composer install" operation is performed + */ + protected function saveToPackageCache(): void + { + $basePath = $this->config->get('base-dir'); + foreach ($this->packages as $package) { + if ($package instanceof Package) { + $package->makePathRelative($this->fileSystem, $basePath); + } + } + parent::saveToPackageCache(); + } + + /** + * Sorts all TYPO3 extension packages by dependency defined in composer.json file + */ + private function sortPackagesAndConfiguration(): void + { + $packagesWithDependencies = $this->resolvePackageDependencies($this->packages); + // Sort the packages by key at first, so we get a stable sorting of "equivalent" packages afterwards + ksort($packagesWithDependencies); + $sortedPackageKeys = $this->sortPackageStatesConfigurationByDependency($packagesWithDependencies); + $this->packageStatesConfiguration = []; + $sortedPackages = []; + foreach ($sortedPackageKeys as $packageKey) { + $sortedPackages[$packageKey] = $this->packages[$packageKey]; + // The artifact does not need path information, so it is kept empty + // The keys must be present, though because the PackageManager implies than a + // package is active by this configuration array + $this->packageStatesConfiguration['packages'][$packageKey] = []; + } + $this->packages = $sortedPackages; + $this->packageStatesConfiguration['version'] = 5; + } + + /** + * Fetch a map of all installed packages and filter them, when they apply + * for TYPO3. + * + * @return packageMap + */ + private function extractPackageMapFromComposer(): array + { + $composer = $this->event->getComposer(); + $rootPackage = $composer->getPackage(); + $autoLoadGenerator = $composer->getAutoloadGenerator(); + $localRepo = $composer->getRepositoryManager()->getLocalRepository(); + + return array_map( + function (array $packageAndPath) use ($rootPackage): array { + [$composerPackage, $packagePath] = $packageAndPath; + $packageName = $composerPackage->getName(); + $packagePath = GeneralUtility::fixWindowsFilePath($packagePath); + try { + $extensionKey = ExtensionKeyResolver::resolve($composerPackage); + } catch (\Throwable $e) { + if (str_starts_with($composerPackage->getType(), 'typo3-cms-')) { + // This means we have a package of type extension, and it does not have the extension key set + // This only happens since version > 4.0 of the installer and must be propagated to become user facing + throw $e; + } + // In case we can not otherwise determine the extension key, we take the composer name + $extensionKey = $packageName; + } + if (isset($this->installedTypo3Extensions[$extensionKey])) { + throw new \UnexpectedValueException( + sprintf( + 'Package with the name "%s" registered extension key "%s", but this key was already set by package with the name "%s"', + $packageName, + $extensionKey, + $this->installedTypo3Extensions[$extensionKey] + ), + 1638880941 + ); + } + $this->installedTypo3Extensions[$extensionKey] = $packageName; + $this->composerNameToPackageKeyMap[$packageName] = $extensionKey; + if ($composerPackage === $rootPackage) { + // The root package's path is the Composer base dir + $packagePath = $this->config->get('base-dir'); + } + // Add extension key to the package map for later reference + return [$composerPackage, $packagePath, $extensionKey]; + }, + array_filter( + $autoLoadGenerator->buildPackageMap($composer->getInstallationManager(), $rootPackage, $localRepo->getCanonicalPackages()), + static function (array $packageAndPath): bool { + /** @var PackageInterface $composerPackage */ + [$composerPackage] = $packageAndPath; + return isset($composerPackage->getExtra()['typo3/cms']); + } + ) + ); + } +} diff --git a/Classes/Configuration/ConfigurationManager.php b/Classes/Configuration/ConfigurationManager.php new file mode 100644 index 0000000..90a9032 --- /dev/null +++ b/Classes/Configuration/ConfigurationManager.php @@ -0,0 +1,453 @@ +getDefaultConfigurationFileLocation(); + } + + /** + * Get the file location of the default configuration file, + * currently the path and filename. + * + * @return string + * @internal + */ + public function getDefaultConfigurationFileLocation() + { + return $this->defaultConfigurationFile; + } + + /** + * Get the file location of the default configuration description file, + * currently the path and filename. + * + * @return string + * @internal + */ + public function getDefaultConfigurationDescriptionFileLocation() + { + return $this->defaultConfigurationDescriptionFile; + } + + /** + * Return configuration array of typo3conf/system/settings.php or config/system/settings.php + * + * @return array Content array of local configuration file + */ + public function getLocalConfiguration(): array + { + return require $this->getSystemConfigurationFileLocation(); + } + + /** + * Get the file location of the TYPO3-project specific settings file, + * currently the path and filename. + * + * Path to local overload TYPO3_CONF_VARS file. + * + * @internal + */ + public function getSystemConfigurationFileLocation(bool $relativeToProjectRoot = false): string + { + // For composer-based installations, the file is in config/system/settings.php + $path = Environment::getConfigPath() . '/system/settings.php'; + if ($relativeToProjectRoot) { + return substr($path, strlen(Environment::getProjectPath()) + 1); + } + return $path; + } + + /** + * Returns local configuration array merged with default configuration + */ + public function getMergedLocalConfiguration(): array + { + $localConfiguration = $this->getDefaultConfiguration(); + ArrayUtility::mergeRecursiveWithOverrule($localConfiguration, $this->getLocalConfiguration()); + return $localConfiguration; + } + + /** + * Get the file location of the additional configuration file, + * currently the path and filename. + * + * @return string + * @internal + */ + public function getAdditionalConfigurationFileLocation() + { + // For composer-based installations, the file is in config/system/additional.php + return Environment::getConfigPath() . '/system/additional.php'; + } + + /** + * Get absolute file location of factory configuration file + * + * @return string + */ + protected function getFactoryConfigurationFileLocation() + { + return $this->factoryConfigurationFile; + } + + /** + * Get absolute file location of factory configuration file + * + * @return string + */ + protected function getAdditionalFactoryConfigurationFileLocation() + { + return Environment::getLegacyConfigPath() . '/' . $this->additionalFactoryConfigurationFile; + } + + /** + * Override local configuration with new values. + * + * @param array $configurationToMerge Override configuration array + */ + public function updateLocalConfiguration(array $configurationToMerge) + { + $newLocalConfiguration = $this->getLocalConfiguration(); + ArrayUtility::mergeRecursiveWithOverrule($newLocalConfiguration, $configurationToMerge); + $this->writeLocalConfiguration($newLocalConfiguration); + } + + /** + * Get a value at given path from default configuration + * + * @param string $path Path to search for + * @return mixed Value at path + */ + public function getDefaultConfigurationValueByPath($path) + { + return ArrayUtility::getValueByPath($this->getDefaultConfiguration(), $path); + } + + /** + * Get a value at given path from local configuration + * + * @param string $path Path to search for + * @return mixed Value at path + */ + public function getLocalConfigurationValueByPath($path) + { + return ArrayUtility::getValueByPath($this->getLocalConfiguration(), $path); + } + + /** + * Get a value from configuration, this is default configuration + * merged with local configuration + * + * @param string $path Path to search for + * @return mixed + */ + public function getConfigurationValueByPath($path) + { + $defaultConfiguration = $this->getDefaultConfiguration(); + ArrayUtility::mergeRecursiveWithOverrule($defaultConfiguration, $this->getLocalConfiguration()); + return ArrayUtility::getValueByPath($defaultConfiguration, $path); + } + + /** + * Update a given path in local configuration to a new value. + * Warning: TO BE USED ONLY to update a single feature. + * NOT TO BE USED within iterations to update multiple features. + * To update multiple features use setLocalConfigurationValuesByPathValuePairs(). + * + * @param string $path Path to update + * @param mixed $value Value to set + * @return bool TRUE on success + */ + public function setLocalConfigurationValueByPath($path, $value) + { + $result = false; + if ($this->isValidLocalConfigurationPath($path)) { + $localConfiguration = $this->getLocalConfiguration(); + $localConfiguration = ArrayUtility::setValueByPath($localConfiguration, $path, $value); + $result = $this->writeLocalConfiguration($localConfiguration); + } + return $result; + } + + /** + * Update / set a list of path and value pairs in local configuration file + * + * @param array $pairs Key is path, value is value to set + * @return bool TRUE on success + */ + public function setLocalConfigurationValuesByPathValuePairs(array $pairs) + { + $localConfiguration = $this->getLocalConfiguration(); + foreach ($pairs as $path => $value) { + if ($this->isValidLocalConfigurationPath($path)) { + $localConfiguration = ArrayUtility::setValueByPath($localConfiguration, $path, $value); + } + } + return $this->writeLocalConfiguration($localConfiguration); + } + + /** + * Remove keys from LocalConfiguration + * + * @param array $keys Array with key paths to remove from LocalConfiguration + * @return bool TRUE if something was removed + */ + public function removeLocalConfigurationKeysByPath(array $keys): bool + { + $result = false; + $localConfiguration = $this->getLocalConfiguration(); + foreach ($keys as $path) { + // Remove key if path is within LocalConfiguration + if (ArrayUtility::isValidPath($localConfiguration, $path)) { + $result = true; + $localConfiguration = ArrayUtility::removeByPath($localConfiguration, $path); + } + } + if ($result) { + $this->writeLocalConfiguration($localConfiguration); + } + return $result; + } + + /** + * Enables a certain feature and writes the option to system/settings.php + * Short-hand method + * Warning: TO BE USED ONLY to enable a single feature. + * NOT TO BE USED within iterations to enable multiple features. + * To update multiple features use setLocalConfigurationValuesByPathValuePairs(). + * + * @param string $featureName something like "InlineSvgImages" + * @return bool true on successful writing the setting + */ + public function enableFeature(string $featureName): bool + { + return $this->setLocalConfigurationValueByPath('SYS/features/' . $featureName, true); + } + + /** + * Disables a feature and writes the option to system/settings.php + * Short-hand method + * Warning: TO BE USED ONLY to disable a single feature. + * NOT TO BE USED within iterations to disable multiple features. + * To update multiple features use setLocalConfigurationValuesByPathValuePairs(). + * + * @param string $featureName something like "InlineSvgImages" + * @return bool true on successful writing the setting + */ + public function disableFeature(string $featureName): bool + { + return $this->setLocalConfigurationValueByPath('SYS/features/' . $featureName, false); + } + + /** + * Checks if the configuration can be written. + * + * @return bool + * @internal + */ + public function canWriteConfiguration() + { + $fileLocation = $this->getSystemConfigurationFileLocation(); + return @is_writable(file_exists($fileLocation) ? $fileLocation : dirname($fileLocation)); + } + + /** + * Reads the configuration array and exports it to the global variable + * + * @internal + * @throws \UnexpectedValueException + */ + public function exportConfiguration(): void + { + if (@is_file($this->getSystemConfigurationFileLocation())) { + $localConfiguration = $this->getLocalConfiguration(); + $defaultConfiguration = $this->getDefaultConfiguration(); + ArrayUtility::mergeRecursiveWithOverrule($defaultConfiguration, $localConfiguration); + $GLOBALS['TYPO3_CONF_VARS'] = $defaultConfiguration; + } else { + // No LocalConfiguration (yet), load DefaultConfiguration + $GLOBALS['TYPO3_CONF_VARS'] = $this->getDefaultConfiguration(); + } + + // Load AdditionalConfiguration + if (@is_file($this->getAdditionalConfigurationFileLocation())) { + require $this->getAdditionalConfigurationFileLocation(); + } + } + + /** + * Write configuration array to %config-dir%/system/settings.php + * + * @param array $configuration The local configuration to be written + * @throws \RuntimeException + * @return bool TRUE on success + * @internal + */ + public function writeLocalConfiguration(array $configuration) + { + $systemSettingsFile = $this->getSystemConfigurationFileLocation(); + if (!$this->canWriteConfiguration()) { + throw new SettingsWriteException( + $this->getSystemConfigurationFileLocation(true) . ' is not writable.', + 1346323822 + ); + } + $configuration = ArrayUtility::sortByKeyRecursive($configuration); + $result = GeneralUtility::writeFile( + $systemSettingsFile, + "clearAllActive($systemSettingsFile); + + return $result; + } + + /** + * Write additional configuration array to config/system/additional.php / typo3conf/system/additional.php + * + * @param array $additionalConfigurationLines The configuration lines to be written + * @throws \RuntimeException + * @return bool TRUE on success + * @internal + */ + public function writeAdditionalConfiguration(array $additionalConfigurationLines) + { + return GeneralUtility::writeFile( + $this->getAdditionalConfigurationFileLocation(), + "getSystemConfigurationFileLocation())) { + throw new \RuntimeException( + basename($this->getSystemConfigurationFileLocation(true)) . ' already exists', + 1364836026 + ); + } + $localConfigurationArray = require $this->getFactoryConfigurationFileLocation(); + $additionalFactoryConfigurationFileLocation = $this->getAdditionalFactoryConfigurationFileLocation(); + if (file_exists($additionalFactoryConfigurationFileLocation)) { + $additionalFactoryConfigurationArray = require $additionalFactoryConfigurationFileLocation; + ArrayUtility::mergeRecursiveWithOverrule( + $localConfigurationArray, + $additionalFactoryConfigurationArray + ); + } + $randomKey = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96); + $localConfigurationArray['SYS']['encryptionKey'] = $randomKey; + + $this->writeLocalConfiguration($localConfigurationArray); + } + + /** + * Check if access / write to given path in local configuration is allowed. + * + * @param string $path Path to search for + * @return bool TRUE if access is allowed + */ + protected function isValidLocalConfigurationPath(string $path): bool + { + // Early return for white listed paths + foreach ($this->allowedSettingsPaths as $allowedSettingsPath) { + if (str_starts_with($path, $allowedSettingsPath)) { + return true; + } + } + return ArrayUtility::isValidPath($this->getDefaultConfiguration(), $path); + } +} diff --git a/Classes/Configuration/Event/AfterFlexFormDataStructureIdentifierInitializedEvent.php b/Classes/Configuration/Event/AfterFlexFormDataStructureIdentifierInitializedEvent.php new file mode 100644 index 0000000..ac11c2b --- /dev/null +++ b/Classes/Configuration/Event/AfterFlexFormDataStructureIdentifierInitializedEvent.php @@ -0,0 +1,96 @@ +fieldTca; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + /** + * Returns the whole database row of the current record. + */ + public function getRow(): array + { + return $this->row; + } + + /** + * Allows to modify or completely replace the initialized data + * structure identifier. + */ + public function setIdentifier(array $identifier): void + { + $this->identifier = $identifier; + } + + /** + * Returns the initialized data structure identifier, which has + * either been defined by an event listener or set to the default + * by the `FlexFormTools` component. + */ + public function getIdentifier(): array + { + return $this->identifier; + } +} diff --git a/Classes/Configuration/Event/AfterFlexFormDataStructureParsedEvent.php b/Classes/Configuration/Event/AfterFlexFormDataStructureParsedEvent.php new file mode 100644 index 0000000..daf65a5 --- /dev/null +++ b/Classes/Configuration/Event/AfterFlexFormDataStructureParsedEvent.php @@ -0,0 +1,61 @@ +identifier; + } + + /** + * Returns the current data structure, which has been processed and + * parsed by the `FlexFormTools` component. Might contain additional + * data from previously called listeners. + */ + public function getDataStructure(): array + { + return $this->dataStructure; + } + + /** + * Allows to modify or completely replace the parsed data + * structure identifier. + */ + public function setDataStructure(array $dataStructure): void + { + $this->dataStructure = $dataStructure; + } +} diff --git a/Classes/Configuration/Event/AfterRichtextConfigurationPreparedEvent.php b/Classes/Configuration/Event/AfterRichtextConfigurationPreparedEvent.php new file mode 100644 index 0000000..a365846 --- /dev/null +++ b/Classes/Configuration/Event/AfterRichtextConfigurationPreparedEvent.php @@ -0,0 +1,33 @@ +configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Configuration/Event/AfterTcaCompilationEvent.php b/Classes/Configuration/Event/AfterTcaCompilationEvent.php new file mode 100644 index 0000000..bd79a67 --- /dev/null +++ b/Classes/Configuration/Event/AfterTcaCompilationEvent.php @@ -0,0 +1,37 @@ +tca; + } + + public function setTca(array $tca): void + { + $this->tca = $tca; + } +} diff --git a/Classes/Configuration/Event/BeforeFlexFormDataStructureIdentifierInitializedEvent.php b/Classes/Configuration/Event/BeforeFlexFormDataStructureIdentifierInitializedEvent.php new file mode 100644 index 0000000..052ad5c --- /dev/null +++ b/Classes/Configuration/Event/BeforeFlexFormDataStructureIdentifierInitializedEvent.php @@ -0,0 +1,112 @@ +setIdentifier() to set the identifier or ignore the + * event to allow other listeners to set it. Do not set an empty string as this + * will immediately stop event propagation! + * + * The identifier SHOULD include the keys specified in the Identifier definition + * on FlexFormTools, and nothing else. Adding other keys may or may not work, + * depending on other code that is enabled, and they are not guaranteed nor + * covered by BC guarantees. + * + * Warning: If adding source record details like the uid or pid here, this may turn out to be fragile. + * Be sure to test scenarios like workspaces and data handler copy/move well, additionally, this may + * break in between different core versions. + * It is probably a good idea to return at least something like [ 'type' => 'myExtension', ... ], see + * the core internal 'tca' and 'record' return values below + * + * See the note on FlexFormTools regarding the schema of $dataStructure. + */ +final class BeforeFlexFormDataStructureIdentifierInitializedEvent implements StoppableEventInterface +{ + private ?array $identifier = null; + + /** + * @param array $fieldTca Full TCA of the field in question that has type=flex set + * @param string $tableName The table name of the TCA field + * @param string $fieldName The field name + * @param array $row The data row + */ + public function __construct( + private readonly array $fieldTca, + private readonly string $tableName, + private readonly string $fieldName, + private readonly array $row, + ) {} + + /** + * Returns the full TCA of the currently handled field, having + * `type=flex` set. + */ + public function getFieldTca(): array + { + return $this->fieldTca; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + /** + * Returns the whole database row of the current record. + */ + public function getRow(): array + { + return $this->row; + } + + /** + * Allows to define the data structure identifier for the TCA field. + * Setting an identifier will immediately stop propagation. Avoid + * setting this parameter to an empty array as this will also stop + * propagation. + */ + public function setIdentifier(array $identifier): void + { + $this->identifier = $identifier; + } + + /** + * Returns the current data structure identifier, which will always be + * `null` for listeners, since the event propagation is + * stopped as soon as a listener defines an identifier. + */ + public function getIdentifier(): ?array + { + return $this->identifier ?? null; + } + + public function isPropagationStopped(): bool + { + return isset($this->identifier); + } +} diff --git a/Classes/Configuration/Event/BeforeFlexFormDataStructureParsedEvent.php b/Classes/Configuration/Event/BeforeFlexFormDataStructureParsedEvent.php new file mode 100644 index 0000000..9087886 --- /dev/null +++ b/Classes/Configuration/Event/BeforeFlexFormDataStructureParsedEvent.php @@ -0,0 +1,72 @@ +setDataStructure() to set the data structure (this + * can either be a resolved data structure string, a "FILE:" reference or a + * fully parsed data structure as array) or ignore the event to allow other + * listeners to set it. Do not set an empty array or string as this will + * immediately stop event propagation! + * + * See the note on FlexFormTools regarding the schema of $dataStructure. + */ +final class BeforeFlexFormDataStructureParsedEvent implements StoppableEventInterface +{ + private array|string|null $dataStructure = null; + + public function __construct( + private readonly array $identifier, + ) {} + + /** + * Returns the current data structure, which will always be `null` + * for listeners, since the event propagation is stopped as soon as + * a listener sets a data structure. + */ + public function getDataStructure(): array|string|null + { + return $this->dataStructure ?? null; + } + + /** + * Allows to either set an already parsed data structure as `array`, + * a file reference or the XML structure as `string`. Setting a data + * structure will immediately stop propagation. Avoid setting this parameter + * to an empty array or string as this will also stop propagation. + */ + public function setDataStructure(array|string $dataStructure): void + { + $this->dataStructure = $dataStructure; + } + + public function getIdentifier(): array + { + return $this->identifier; + } + + public function isPropagationStopped(): bool + { + return isset($this->dataStructure); + } +} diff --git a/Classes/Configuration/Event/BeforeTcaOverridesEvent.php b/Classes/Configuration/Event/BeforeTcaOverridesEvent.php new file mode 100644 index 0000000..0035f9c --- /dev/null +++ b/Classes/Configuration/Event/BeforeTcaOverridesEvent.php @@ -0,0 +1,37 @@ +tca; + } + + public function setTca(array $tca): void + { + $this->tca = $tca; + } +} diff --git a/Classes/Configuration/Event/SiteConfigurationBeforeWriteEvent.php b/Classes/Configuration/Event/SiteConfigurationBeforeWriteEvent.php new file mode 100644 index 0000000..ae633b4 --- /dev/null +++ b/Classes/Configuration/Event/SiteConfigurationBeforeWriteEvent.php @@ -0,0 +1,48 @@ +siteIdentifier; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + /** + * @param array $configuration overwrite the configuration array of the site + */ + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Configuration/Event/SiteConfigurationChangedEvent.php b/Classes/Configuration/Event/SiteConfigurationChangedEvent.php new file mode 100644 index 0000000..264052d --- /dev/null +++ b/Classes/Configuration/Event/SiteConfigurationChangedEvent.php @@ -0,0 +1,28 @@ +siteIdentifier; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + /** + * @param array $configuration overwrite the configuration array of the site + */ + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Configuration/Exception/ExtensionConfigurationExtensionNotConfiguredException.php b/Classes/Configuration/Exception/ExtensionConfigurationExtensionNotConfiguredException.php new file mode 100644 index 0000000..1807789 --- /dev/null +++ b/Classes/Configuration/Exception/ExtensionConfigurationExtensionNotConfiguredException.php @@ -0,0 +1,24 @@ +get() is called for + * an extension that has no configuration. + */ +class ExtensionConfigurationExtensionNotConfiguredException extends Exception {} diff --git a/Classes/Configuration/Exception/ExtensionConfigurationPathDoesNotExistException.php b/Classes/Configuration/Exception/ExtensionConfigurationPathDoesNotExistException.php new file mode 100644 index 0000000..0414e0d --- /dev/null +++ b/Classes/Configuration/Exception/ExtensionConfigurationPathDoesNotExistException.php @@ -0,0 +1,24 @@ +get() is called with + * a path that does not exist within the extension configuration. + */ +class ExtensionConfigurationPathDoesNotExistException extends Exception {} diff --git a/Classes/Configuration/Exception/SettingsWriteException.php b/Classes/Configuration/Exception/SettingsWriteException.php new file mode 100644 index 0000000..9c3c3f4 --- /dev/null +++ b/Classes/Configuration/Exception/SettingsWriteException.php @@ -0,0 +1,25 @@ +getExtLocalconfCacheIdentifier(); + $hasCache = $this->codeCache->require($cacheIdentifier) !== false; + if (!$hasCache) { + $this->loadSingleExtLocalconfFiles(); + $this->createCacheEntry(); + } + } + + public function loadUncached(): void + { + $this->loadSingleExtLocalconfFiles(); + } + + /** + * Create cache entry for concatenated ext_localconf.php files + */ + public function createCacheEntry(): void + { + $phpCodeToCache = []; + // Set same globals as in loadSingleExtLocalconfFiles() + $phpCodeToCache[] = '/**'; + $phpCodeToCache[] = ' * Compiled ext_localconf.php cache file'; + $phpCodeToCache[] = ' */'; + // Iterate through loaded extensions and add ext_localconf content + foreach ($this->packageManager->getActivePackages() as $package) { + $extensionKey = $package->getPackageKey(); + $extLocalconfPath = $package->getPackagePath() . 'ext_localconf.php'; + if (@file_exists($extLocalconfPath)) { + // Include a header per extension to make the cache file more readable + $phpCodeToCache[] = '/**'; + $phpCodeToCache[] = ' * Extension: ' . $extensionKey; + $phpCodeToCache[] = ' * File: ' . $extLocalconfPath; + $phpCodeToCache[] = ' */'; + // Add ext_localconf.php content of extension + $phpCodeToCache[] = 'namespace {'; + $phpCodeToCache[] = trim((string)file_get_contents($extLocalconfPath)); + $phpCodeToCache[] = '}'; + $phpCodeToCache[] = ''; + $phpCodeToCache[] = ''; + } + } + $phpCodeToCache = implode(LF, $phpCodeToCache); + // Remove all start and ending php tags from content, and remove strict_types=1 declaration. + $phpCodeToCache = preg_replace('/<\\?php|\\?>/is', '', $phpCodeToCache); + $phpCodeToCache = preg_replace('/declare\\s?+\\(\\s?+strict_types\\s?+=\\s?+1\\s?+\\);/is', '', (string)$phpCodeToCache); + $this->codeCache->set($this->getExtLocalconfCacheIdentifier(), $phpCodeToCache); + } + + /** + * Require ext_localconf.php files from extensions + */ + private function loadSingleExtLocalconfFiles(): void + { + foreach ($this->packageManager->getActivePackages() as $package) { + $extLocalconfPath = $package->getPackagePath() . 'ext_localconf.php'; + if (file_exists($extLocalconfPath)) { + require $extLocalconfPath; + } + } + } + + /** + * Cache identifier of concatenated ext_localconf file + */ + private function getExtLocalconfCacheIdentifier(): string + { + return (new PackageDependentCacheIdentifier($this->packageManager))->withPrefix('ext_localconf')->toString(); + } +} diff --git a/Classes/Configuration/ExtensionConfiguration.php b/Classes/Configuration/ExtensionConfiguration.php new file mode 100644 index 0000000..4b1da9b --- /dev/null +++ b/Classes/Configuration/ExtensionConfiguration.php @@ -0,0 +1,279 @@ +get() is official API and other public methods are low level + * core internal API that is usually only used by extension manager and install tool. + */ +#[AsAlias('extension-configuration', public: true)] +readonly class ExtensionConfiguration +{ + /** + * Get a single configuration value, a sub array or the whole configuration. + * + * Examples: + * // Simple and typical usage: Get a single config value, or an array if the key is a "TypoScript" + * // a-like sub-path in ext_conf_template.txt "foo.bar = defaultValue" + * ->get('myExtension', 'aConfigKey'); + * + * // Get all current configuration values, always an array + * ->get('myExtension'); + * + * // Get a nested config value if the path is a "TypoScript" a-like sub-path + * // in ext_conf_template.txt "topLevelKey.subLevelKey = defaultValue" + * ->get('myExtension', 'topLevelKey/subLevelKey') + * + * Notes: + * - If a configuration or configuration path of an extension is not found, the + * code tries to synchronize configuration with ext_conf_template.txt first, only + * if still not found, it will throw exceptions. + * - Return values are NOT type safe: A boolean false could be returned as string 0. + * Cast accordingly. + * - This API throws exceptions if the path does not exist or the extension + * configuration is not available. The install tool takes care any new + * ext_conf_template.txt values are available TYPO3_CONF_VARS['EXTENSIONS'], + * a thrown exception indicates a programming error on developer side + * and should not be caught. + * - It is not checked if the extension in question is loaded at all, + * it's just checked the extension configuration path exists. + * - Extensions should typically not get configuration of a different extension. + * + * @param string $extension Extension name + * @param string $path Configuration path - e.g. "featureCategory/coolThingIsEnabled" + * @return mixed The value. Can be a sub array or a single value. + * @throws ExtensionConfigurationExtensionNotConfiguredException If the extension configuration does not exist + * @throws ExtensionConfigurationPathDoesNotExistException If a requested path in the extension configuration does not exist + */ + public function get(string $extension, string $path = ''): mixed + { + $hasBeenSynchronized = false; + if (!$this->hasConfiguration($extension)) { + // This if() should not be hit at "casual" runtime, but only in early setup phases + $this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true); + $hasBeenSynchronized = true; + if (!$this->hasConfiguration($extension)) { + // If there is still no such entry, even after sync -> throw + throw new ExtensionConfigurationExtensionNotConfiguredException( + 'No extension configuration for extension ' . $extension . ' found. Either this extension' + . ' has no extension configuration or the configuration is not up to date. Execute the' + . ' install tool to update configuration.', + 1509654728 + ); + } + } + if (empty($path)) { + return $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]; + } + if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) { + // This if() should not be hit at "casual" runtime, but only in early setup phases + if (!$hasBeenSynchronized) { + $this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true); + } + // If there is still no such entry, even after sync -> throw + if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) { + throw new ExtensionConfigurationPathDoesNotExistException( + 'Path ' . $path . ' does not exist in extension configuration', + 1509977699 + ); + } + } + return ArrayUtility::getValueByPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path); + } + + /** + * Store a new or overwrite an existing configuration value. + * + * This is typically used by core internal low level tasks like the install + * tool but may become handy if an extension needs to update extension configuration + * on the fly for whatever reason. + * + * Examples: + * // Set a full extension configuration ($value could be a nested array, too) + * ->set('myExtension', ['aFeature' => 'true', 'aCustomClass' => 'css-foo']) + * + * // Unset a whole extension configuration + * ->set('myExtension') + * + * Notes: + * - Do NOT call this at arbitrary places during runtime (eg. NOT in ext_localconf.php or + * similar). ->set() is not supposed to be called each request since it writes LocalConfiguration + * each time. This API is however OK to be called from extension manager hooks. + * - Values are not type safe, if the install tool wrote them, + * boolean true could become string 1 on ->get() + * - It is not possible to store 'null' as value, giving $value=null + * or no value at all will unset the path + * - Setting a value and calling ->get() afterwards will still return the new value. + * - Warning on system/additional.php: If this file overwrites settings, it spoils the + * ->set() call and values may not end up as expected. + * + * @param string $extension Extension name + * @param mixed|null $value The value. If null, unset the path + * @internal + */ + public function set(string $extension, mixed $value = null): void + { + if (empty($extension)) { + throw new \RuntimeException('extension name must not be empty', 1509715852); + } + $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class); + if ($value === null) { + // Remove whole extension config + $configurationManager->removeLocalConfigurationKeysByPath(['EXTENSIONS/' . $extension]); + if (isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension])) { + unset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]); + } + } else { + // Set full extension config + $configurationManager->setLocalConfigurationValueByPath('EXTENSIONS/' . $extension, $value); + $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension] = $value; + } + } + + /** + * Set new configuration of all extensions and reload TYPO3_CONF_VARS. + * This is a "do all" variant of set() for all extensions that prevents + * writing and loading system/settings.php many times. + * + * @param array $configuration Configuration of all extensions + * @internal + */ + public function setAll(array $configuration, bool $skipWriteIfLocalConfigurationDoesNotExist = false): void + { + $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class); + if ($skipWriteIfLocalConfigurationDoesNotExist === false || @file_exists($configurationManager->getSystemConfigurationFileLocation())) { + $configurationManager->setLocalConfigurationValueByPath('EXTENSIONS', $configuration); + } + $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] = $configuration; + } + + /** + * If there are new config settings in ext_conf_template of an extension, + * they are found here and synchronized to LocalConfiguration['EXTENSIONS']. + * + * Used when entering the install tool, during installation and if calling ->get() + * with an extension or path that is not yet found in LocalConfiguration + * + * @internal + */ + public function synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(bool $skipWriteIfLocalConfigurationDoesNotExist = false): void + { + $activePackages = GeneralUtility::makeInstance(PackageManager::class)->getActivePackages(); + $fullConfiguration = []; + $currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] ?? []; + foreach ($activePackages as $package) { + if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) { + continue; + } + $extensionKey = $package->getPackageKey(); + $currentExtensionConfig = $currentLocalConfiguration[$extensionKey] ?? []; + $extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey); + ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentExtensionConfig); + if (!empty($extConfTemplateConfiguration)) { + $fullConfiguration[$extensionKey] = $extConfTemplateConfiguration; + } + } + // Write new config if changed. Loose array comparison to not write if only array key order is different + if ($fullConfiguration != $currentLocalConfiguration) { + $this->setAll($fullConfiguration, $skipWriteIfLocalConfigurationDoesNotExist); + } + } + + /** + * Read values from ext_conf_template, verify if they are in LocalConfiguration.php + * already and if not, add them. + * + * Used public by extension manager when updating extension + * + * @internal + */ + public function synchronizeExtConfTemplateWithLocalConfiguration(string $extensionKey): void + { + $package = GeneralUtility::makeInstance(PackageManager::class)->getPackage($extensionKey); + if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) { + return; + } + $currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extensionKey] ?? []; + $extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey); + ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentLocalConfiguration); + // Write new config if changed. Loose array comparison to not write if only array key order is different + if ($extConfTemplateConfiguration != $currentLocalConfiguration) { + $this->set($extensionKey, $extConfTemplateConfiguration); + } + } + + /** + * Helper method of ext_conf_template.txt parsing. + * + * Poor man version of getDefaultConfigurationFromExtConfTemplateAsValuedArray() which ignores + * comments and returns ext_conf_template as array where nested keys have no dots. + */ + protected function getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots(string $extensionKey): array + { + $rawConfigurationString = $this->getDefaultConfigurationRawString($extensionKey); + $typoScriptStringFactory = GeneralUtility::makeInstance(TypoScriptStringFactory::class); + $typoScriptTree = $typoScriptStringFactory->parseFromString($rawConfigurationString, new AstBuilder(new NoopEventDispatcher())); + return GeneralUtility::removeDotsFromTS($typoScriptTree->toArray()); + } + + /** + * Helper method of ext_conf_template.txt parsing. + * + * Return content of an extensions' ext_conf_template.txt file if + * the file exists, empty string if file does not exist. + */ + protected function getDefaultConfigurationRawString(string $extensionKey): string + { + $rawString = ''; + $extConfTemplateFileLocation = GeneralUtility::getFileAbsFileName( + 'EXT:' . $extensionKey . '/ext_conf_template.txt' + ); + if (file_exists($extConfTemplateFileLocation)) { + $rawString = (string)file_get_contents($extConfTemplateFileLocation); + } + return $rawString; + } + + protected function hasConfiguration(string $extension): bool + { + return isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]) && is_array($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]); + } +} diff --git a/Classes/Configuration/Features.php b/Classes/Configuration/Features.php new file mode 100644 index 0000000..09f6ccd --- /dev/null +++ b/Classes/Configuration/Features.php @@ -0,0 +1,97 @@ +isFeatureEnabled('InlineSvg')) { + * ... do stuff here ... + * } + */ +#[AsAlias('features', public: true)] +readonly class Features +{ + /** + * A list of features that are always activated (mainly happens if a previous feature switch is now always + * "turned on" to enforce a behaviour, but still valid for extension authors to ensure the feature switch + * returns "enabled" for future versions. + */ + private const array ALWAYS_ACTIVE_FEATURES = [ + // Enabled since v15.0 at any time. + 'extbase.consistentDateTimeHandling', + // Enabled since v13.0 at any time. + 'security.usePasswordPolicyForFrontendUsers', + 'security.backend.enforceContentSecurityPolicy', + // Enabled since v12.0 at any time. + 'subrequestPageErrors', + 'yamlImportsFollowDeclarationOrder', + 'security.frontend.htmlSanitizeParseFuncDefault', + 'runtimeDbQuotingOfTcaConfiguration', + // Enabled since v11.0 at any time. + 'fluidBasedPageModule', + // Enabled since v10.0 at any time. + 'simplifiedControllerActionDispatching', + 'unifiedPageTranslationHandling', + 'felogin.extbase', + ]; + + /** + * Checks if a feature is active + * + * @param string $featureName the name of the feature + */ + public function isFeatureEnabled(string $featureName): bool + { + if (in_array($featureName, self::ALWAYS_ACTIVE_FEATURES, true)) { + return true; + } + return isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['features'][$featureName]) + && $GLOBALS['TYPO3_CONF_VARS']['SYS']['features'][$featureName] === true; + } +} diff --git a/Classes/Configuration/FlexForm/Exception/AbstractInvalidDataStructureException.php b/Classes/Configuration/FlexForm/Exception/AbstractInvalidDataStructureException.php new file mode 100644 index 0000000..e625921 --- /dev/null +++ b/Classes/Configuration/FlexForm/Exception/AbstractInvalidDataStructureException.php @@ -0,0 +1,25 @@ +getDataStructureIdentifier($fieldTca, $table, $field, $row, $tcaSchema); + * + * // With raw TCA array (only during schema) + * $flexFormTools->getDataStructureIdentifier($fieldTca, $table, $field, $row, $rawTcaArray); + * ``` + * + * The service automatically detects the input type and uses the appropriate resolution strategy. + */ +#[Autoconfigure(public: true)] +readonly class FlexFormTools +{ + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private TcaMigration $tcaMigration, + private TcaPreparation $tcaPreparation, + ) {} + + /** + * The method locates a specific data structure from given TCA and row combination + * and returns an identifier string that can be handed around, and can be resolved + * to a single data structure later without giving $row and $tca data again. + * + * Note: The returned syntax is meant to only specify the target location of the data structure. + * It SHOULD NOT be abused and enriched with data from the record that is dealt with. For + * instance, it is not allowed to add source record specific date like the "uid" or the "pid"! + * If that is done, it is up to the hook consumer to take care of possible side effects, e.g. if + * the DataHandler copies or moves records around and those references change. + * + * This method gets: Source data that influences the target location of a data structure + * This method returns: Target specification of the data structure + * + * This method is "paired" with method parseDataStructureByIdentifier() that + * will resolve the returned syntax again and returns the data structure itself. + * + * Both methods can be extended via events to return and accept additional + * identifier strings if needed, and to transmit further information within the identifier strings. + * + * Important: The TCA for data structure definitions MUST be overridden by 'columnsOverrides' + * as the "ds" config is a string, containing the data structure or a file pointer. + * + * Note: This method and the resolving methods below are well unit tested and document all + * nasty details this way. + * + * @param array $fieldTca Full TCA of the field in question that has type=flex set + * @param string $tableName The table name of the TCA field + * @param string $fieldName The field name + * @param array $row The data row + * @param array|TcaSchema|null $schema Either be the Tca Schema object or raw TCA configuration. Only omit in + * case handling is done via events. Otherwise, this will throw an exception + * on resolving the default identifier {@see InvalidTcaSchemaException}. + * Using the raw TCA configuration is furthermore not recommended and only + * available to support FlexFormTools during schema building. For extensions + * this might be the case on using {@see BeforeTcaOverridesEvent} or + * {@see AfterTcaCompilationEvent}. + * + * @return string Identifier JSON string + * @throws \RuntimeException If TCA is misconfigured + * @throws InvalidTcaException + */ + public function getDataStructureIdentifier(array $fieldTca, string $tableName, string $fieldName, array $row, array|TcaSchema|null $schema = null): string + { + $dataStructureIdentifier = $this->eventDispatcher + ->dispatch(new BeforeFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row)) + ->getIdentifier() ?? $this->getDefaultDataStructureIdentifier($tableName, $fieldName, $row, $schema); + $dataStructureIdentifier = $this->eventDispatcher + ->dispatch(new AfterFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row, $dataStructureIdentifier)) + ->getIdentifier(); + return json_encode($dataStructureIdentifier, JSON_THROW_ON_ERROR); + } + + /** + * Parse a data structure identified by $identifier to the final data structure array. + * This method is called after getDataStructureIdentifier(), finds the data structure + * and returns it. + * + * Events allow to manipulate the find logic and to post process the data structure array. + * + * Important: The TCA for data structure definitions MUST be overridden by 'columnsOverrides' + * as the "ds" config is a string, containing the data structure or a file pointer. + * + * After the data structure definition is found, the method resolves: + * - FILE:EXT: prefix of the data structure itself - the ds is in a file + * - FILE:EXT: prefix for sheets - if single sheets are in files + * - Create a sDEF sheet if the data structure has non, yet. + * - TCA Migration and Preparation is done for the resolved fields + * + * After that method is run, the data structure is fully resolved to an array, + * and same base normalization is done: If the ds did not contain a sheet, + * it will have one afterward as "sDEF". + * + * This method gets: Target specification of the data structure. + * This method returns: The normalized data structure parsed to an array. + * + * @param string $identifier JSON string to find the data structure location + * @param array|TcaSchema|null $schema Either be the Tca Schema object or raw TCA configuration. Only omit in + * case handling is done via events. Otherwise, this will throw an exception + * on resolving the default identifier {@see InvalidTcaSchemaException}. + * Using the raw TCA configuration is furthermore not recommended and only + * available to support FlexFormTools during schema building. For extensions + * this might be the case on using {@see BeforeTcaOverridesEvent} or + * {@see AfterTcaCompilationEvent}. + * + * @return array Parsed and normalized data structure + * @throws InvalidIdentifierException + * @throws InvalidTcaSchemaException + * @throws InvalidDataStructureException + */ + public function parseDataStructureByIdentifier(string $identifier, array|TcaSchema|null $schema = null): array + { + // Throw an exception for an empty string. This might be a valid use case for new + // records in some situations, so this is catchable to give callers a chance to deal with that. + if ($identifier === '') { + throw new InvalidIdentifierException( + 'Empty string given to parseDataStructureByIdentifier(). This exception might ' + . ' be caught to handle some new record situations properly', + 1478100828 + ); + } + $parsedIdentifier = json_decode($identifier, true); + if (!is_array($parsedIdentifier) || $parsedIdentifier === []) { + // If there is some identifier and it can't be decoded, programming error -> not catchable + throw new \RuntimeException( + 'Identifier could not be decoded to an array.', + 1478345642 + ); + } + $dataStructure = $this->eventDispatcher + ->dispatch(new BeforeFlexFormDataStructureParsedEvent($parsedIdentifier)) + ->getDataStructure() ?? $this->getDefaultStructureForIdentifier($parsedIdentifier, $schema); + $dataStructure = $this->convertDataStructureToArray($dataStructure); + $dataStructure = $this->ensureDefaultSheet($dataStructure); + $dataStructure = $this->resolveFileDirectives($dataStructure); + $dataStructure = $this->checkMigratePrepareFlexTca($dataStructure); + return $this->eventDispatcher + ->dispatch(new AfterFlexFormDataStructureParsedEvent($dataStructure, $parsedIdentifier)) + ->getDataStructure(); + } + + /** + * Clean up FlexForm value XML to hold only the values it may according to its Data Structure. + * The order of tags will follow that of the data structure. + * + * @param array|TcaSchema $schema Main schema only, no sub schema! Using the raw TCA configuration is + * furthermore not recommended and only available to support FlexFormTools + * during schema building. For extensions this might be the case on + * using {@see BeforeTcaOverridesEvent} or {@see AfterTcaCompilationEvent}. + * + * @internal Signature may change, for instance to split 'DS finding' and flexArray2Xml(), + * which would allow broader use of the method. It is currently consumed by + * cleanup:flexforms CLI only. + */ + public function cleanFlexFormXML(string $table, string $field, array $row, array|TcaSchema $schema): string + { + if ((is_array($schema) && !isset($schema['columns'][$field]['config'])) || ($schema instanceof TcaSchema && !$schema->hasField($field)) || !isset($row[$field])) { + throw new \RuntimeException('Can not clean up FlexForm XML for a column not declared in TCA or not in record.', 1697554398); + } + try { + $fieldTca = is_array($schema) ? ['config' => $schema['columns'][$field]['config']] : ['config' => $schema->getField($field)->getConfiguration()]; + $dataStructureArray = $this->parseDataStructureByIdentifier($this->getDataStructureIdentifier($fieldTca, $table, $field, $row, $schema), $schema); + } catch (InvalidIdentifierException) { + // Data structure can not be resolved or parsed. Reset value to empty string. + return ''; + } + $valueArray = GeneralUtility::xml2array($row[$field]); + if (!is_array($valueArray)) { + // Current flex form values can not be parsed to an array. The entire thing is invalid. Reset to empty string. + return ''; + } + if (!is_array($dataStructureArray['sheets'] ?? false)) { + // We might return empty string instead of throwing here, unsure. + throw new \RuntimeException('Data structure should always declare at least one sheet', 1697555523); + } + $newValueArray = []; + foreach ($dataStructureArray['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementData) { + // For all elements allowed in Data Structure. + if (($sheetElementData['type'] ?? '') === 'array') { + // This is a section. + if (!is_array($sheetElementData['el'] ?? false) || !is_array($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + // No possible containers defined for this section in DS, or no values set for this section. + continue; + } + foreach ($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + // We have containers for this section in values. + if (!is_array($valueSectionContainers ?? false)) { + // Values don't validate to an array, skip. + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + // For all value containers in this section. + if (!is_array($sheetElementData['el'][$valueContainerType]['el'] ?? false)) { + // There is no DS for this container type, skip. + continue; + } + foreach (array_keys($sheetElementData['el'][$valueContainerType]['el']) as $containerElement) { + // Container type of this value container exists in DS. Iterate DS container to pick allowed single elements. + if (isset($valueContainerElements['el'][$containerElement]['vDEF'])) { + $newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] + = $valueContainerElements['el'][$containerElement]['vDEF']; + } + } + } + if (isset($valueSectionContainers['_TOGGLE'])) { + // This was removed in TYPO3 v13, see #102551 + unset($newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey]['_TOGGLE']); + } + } + } elseif (isset($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Not a section but a simple field. Keep value if set. + $newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = $valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF']; + } + } + } + return $this->flexArray2Xml($newValueArray); + } + + /** + * Convert FlexForm data array to XML + * + * @internal + */ + public function flexArray2Xml(array $array): string + { + // Map the weird keys from the internal array to tags and attributes. + $options = [ + 'parentTagMap' => [ + 'data' => 'sheet', + 'sheet' => 'language', + 'language' => 'field', + 'el' => 'field', + 'field' => 'value', + 'field:el' => 'el', + 'el:_IS_NUM' => 'section', + 'section' => 'itemType', + ], + 'disableTypeAttrib' => 2, + ]; + return '' . LF + . GeneralUtility::array2xml($array, '', 0, 'T3FlexForms', 4, $options); + } + + /** + * Parses the flexForm XML string and converts it to an array. + * The resulting array will be multidimensional, as a value "bla.blubb" + * results in two levels, and a value "bla.blubb.bla" results in three levels. + */ + public function convertFlexFormContentToArray(string $flexFormContent): array + { + $settings = []; + $flexFormArray = GeneralUtility::xml2array($flexFormContent); + $flexFormArray = $flexFormArray['data'] ?? []; + foreach ($flexFormArray as $languages) { + if (!is_array($languages['lDEF'] ?? false)) { + continue; + } + foreach ($languages['lDEF'] as $valueKey => $valueDefinition) { + if (!str_contains($valueKey, '.')) { + $settings[$valueKey] = $this->walkFlexFormNode($valueDefinition); + } else { + $valueKeyParts = explode('.', $valueKey); + $currentNode = &$settings; + foreach ($valueKeyParts as $valueKeyPart) { + $currentNode = &$currentNode[$valueKeyPart]; + } + if (is_array($valueDefinition)) { + if (array_key_exists('vDEF', $valueDefinition)) { + $currentNode = $valueDefinition['vDEF']; + } else { + $currentNode = $this->walkFlexFormNode($valueDefinition); + } + } else { + $currentNode = $valueDefinition; + } + } + } + } + return $settings; + } + + /** + * Parses the flexForm XML string and converts it to an array. + * The resulting array will be multidimensional. Sheets are + * respected to support property paths in multiple sheets. + * + * A value such as "settings.pageId" results in three levels: + * "'sDEF' => ['settings' => ['pageId' => 123]]" and a value such + * as "settings.storages.newsPid" results in four levels: + * "'sDEF' => ['settings' => ['storages' => ['newsPid' => 123]]]" + * + * @param string $flexFormContent flexForm xml string + */ + public function convertFlexFormContentToSheetsArray(string $flexFormContent): array + { + $settings = []; + $flexFormArray = GeneralUtility::xml2array($flexFormContent); + $flexFormArray = $flexFormArray['data'] ?? []; + foreach ($flexFormArray as $sheetName => $sheet) { + foreach ($sheet as $language => $fields) { + if ($language !== 'lDEF') { + continue; + } + foreach ($fields as $valueKey => $valueDefinition) { + if (!str_contains($valueKey, '.')) { + $settings[$sheetName][$valueKey] = $this->walkFlexFormNode($valueDefinition); + } else { + $valueKeyParts = explode('.', $valueKey); + $currentNode = &$settings[$sheetName]; + foreach ($valueKeyParts as $valueKeyPart) { + $currentNode = &$currentNode[$valueKeyPart]; + } + if (is_array($valueDefinition)) { + if (array_key_exists('vDEF', $valueDefinition)) { + $currentNode = $valueDefinition['vDEF']; + } else { + $currentNode = $this->walkFlexFormNode($valueDefinition); + } + } else { + $currentNode = $valueDefinition; + } + } + } + } + } + return $settings; + } + + /** + * Finds data structure in TCA, defined in column config 'ds' + * + * fieldTca = [ + * 'config' => [ + * 'type' => 'flex', + * 'ds' => '...' OR 'FILE:...', + * ] + * ] + * + * This method returns an array of the form: + * [ + * 'type' => 'tca', + * 'tableName' => $tableName, + * 'fieldName' => $fieldName, + * 'dataStructureKey' => $key, + * ]; + * + * Example: + * [ + * 'type' => 'tca', + * 'tableName' => 'tt_content', + * 'fieldName' => 'pi_flexform', + * 'dataStructureKey' => 'default', + * ]; + * + * In case the TCA table supports record types and the given $row uses a record type with a custom + * data structure (via columnsOverrides) the record type is used as "dataStructureKey". + * + * Example: + * [ + * 'type' => 'tca', + * 'tableName' => 'tt_content', + * 'fieldName' => 'pi_flexform', + * 'dataStructureKey' => 'powermail_pi1', + * ]; + * + * @return array Identifier as array, see example above + * @throws InvalidTcaException + * @throws InvalidTcaSchemaException + */ + protected function getDefaultDataStructureIdentifier(string $tableName, string $fieldName, array $row, array|TcaSchema|null $schema = null): array + { + if ($schema === null) { + throw new InvalidTcaSchemaException('Can not resolve default data structure without TCA.', 1753182123); + } + + $defaultIdentifier = [ + 'type' => 'tca', + 'tableName' => $tableName, + 'fieldName' => $fieldName, + 'dataStructureKey' => null, + ]; + + return is_array($schema) + ? $this->getDataStructureIdentifierFromRawTca($schema, $tableName, $fieldName, $row, $defaultIdentifier) + : $this->getDataStructureIdentifierFromTcaSchema($schema, $tableName, $fieldName, $row, $defaultIdentifier); + } + + /** + * Finds and returns the data structure from TCA - defined in column config 'ds' + * + * fieldTca = [ + * 'config' => [ + * 'type' => 'flex', + * 'ds' => '...' OR 'FILE:...', + * ] + * ] + * + * Based on an identifier, e.g.: + * [ + * 'type' => 'tca', + * 'tableName' => 'tt_content', + * 'fieldName' => 'pi_flexform', + * 'dataStructureKey' => 'default', + * ]; + * + * this method returns '...' OR 'FILE:...'. + * + * In case the TCA table supports record types and the "dataStructureKey" points to a record type, + * which is only the case if a record type defines a custom flex config (via columnsOverrides), this + * custom data structure is returned. + * + * @return string resolved data structure + * @throws InvalidTcaSchemaException + */ + protected function getDefaultStructureForIdentifier(array $identifier, array|TcaSchema|null $schema = null): string + { + // For the default only type "tca" is handled. Custom types need to be handled by corresponding events. + if (($identifier['type'] ?? '') !== 'tca') { + throw new InvalidIdentifierException( + 'Identifier ' . json_encode($identifier) . ' could not be resolved', + 1478104554 + ); + } + + $tableName = (string)($identifier['tableName'] ?? ''); + $fieldName = (string)($identifier['fieldName'] ?? ''); + $dataStructureKey = (string)($identifier['dataStructureKey'] ?? ''); + + if ($tableName === '' || $fieldName === '' || $dataStructureKey === '') { + throw new \RuntimeException( + 'Incomplete "tca" based identifier: ' . json_encode($identifier), + 1478113471 + ); + } + + if ($schema === null) { + throw new InvalidTcaSchemaException('Can not resolve default data structure without TCA.', 1753182125); + } + + $dataStructure = is_array($schema) + ? $this->resolveDataStructureFromRawTca($schema, $fieldName, $dataStructureKey) + : $this->resolveDataStructureFromTcaSchema($schema, $tableName, $fieldName, $dataStructureKey); + + if ($dataStructure === '') { + throw new InvalidIdentifierException( + 'Specified identifier ' . json_encode($identifier) . ' does not resolve to a valid data structure', + 1732199538 + ); + } + + return $dataStructure; + } + + protected function convertDataStructureToArray(string|array $dataStructure): array + { + if (is_array($dataStructure)) { + return $dataStructure; + } + // Resolve FILE: prefix pointing to a DS in a file + if (str_starts_with(trim($dataStructure), 'FILE:')) { + $fileName = substr(trim($dataStructure), 5); + $file = GeneralUtility::getFileAbsFileName($fileName); + if (empty($file) || !is_file($file)) { + throw new InvalidIdentifierException( + 'Data structure file "' . $fileName . '" could not be resolved to an existing file', + 1478105826 + ); + } + $dataStructure = (string)file_get_contents($file); + } + // Parse main structure + $dataStructure = GeneralUtility::xml2array($dataStructure); + // Throw if it still is not an array, probably because GeneralUtility::xml2array() failed. + // This also may happen if artificial identifiers were constructed which don't resolve. The + // flex form "exclude" access rights systems does that -> catchable + if (!is_array($dataStructure)) { + throw new InvalidIdentifierException( + 'Parse error: Data structure could not be resolved to a valid structure.', + 1478106090 + ); + } + + return $dataStructure; + } + + /** + * Ensures a data structure has a default sheet, and no duplicate data + */ + protected function ensureDefaultSheet(array $dataStructure): array + { + if (isset($dataStructure['ROOT']) && isset($dataStructure['sheets'])) { + throw new \RuntimeException( + 'Parsed data structure has both ROOT and sheets on top level. That is invalid.', + 1440676540 + ); + } + if (isset($dataStructure['ROOT']) && is_array($dataStructure['ROOT'])) { + $dataStructure['sheets']['sDEF']['ROOT'] = $dataStructure['ROOT']; + unset($dataStructure['ROOT']); + } + return $dataStructure; + } + + /** + * Resolve FILE:EXT and EXT: for single sheets + */ + protected function resolveFileDirectives(array $dataStructure): array + { + if (isset($dataStructure['sheets']) && is_array($dataStructure['sheets'])) { + foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) { + if (!is_array($sheetStructure)) { + if (str_starts_with(trim($sheetStructure), 'FILE:')) { + $file = GeneralUtility::getFileAbsFileName(substr(trim($sheetStructure), 5)); + } else { + $file = GeneralUtility::getFileAbsFileName(trim($sheetStructure)); + } + if ($file && @is_file($file)) { + $sheetStructure = GeneralUtility::xml2array((string)file_get_contents($file)); + } + } + $dataStructure['sheets'][$sheetName] = $sheetStructure; + } + } + return $dataStructure; + } + + /** + * Check for invalid flex form structures, migrate and prepare single fields. + * @throws InvalidDataStructureException + */ + private function checkMigratePrepareFlexTca(array $dataStructure): array + { + if (!is_array($dataStructure['sheets'] ?? null)) { + return $dataStructure; + } + $newStructure = $dataStructure; + foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) { + if (!is_array($sheetStructure['ROOT']['el'])) { + continue; + } + foreach ($sheetStructure['ROOT']['el'] as $sheetElementName => $sheetElementConfig) { + if (!is_array($sheetElementConfig)) { + continue; + } + if (($sheetElementConfig['type'] ?? null) === 'array' xor ($sheetElementConfig['section'] ?? null) === '1') { + // Section element, but type=array without section=1 or vice versa is not ok + throw new InvalidDataStructureException( + 'Broken data structure on field name ' . $sheetElementName . '. section without type or vice versa is not allowed', + 1440685208 + ); + } + if (($sheetElementConfig['type'] ?? null) === 'array' && ($sheetElementConfig['section'] ?? null) === '1') { + // Section element + if (!is_array($sheetElementConfig['el'] ?? null)) { + continue; + } + foreach ($sheetElementConfig['el'] as $containerName => $containerConfig) { + if (!is_array($containerConfig['el'] ?? null)) { + continue; + } + foreach ($containerConfig['el'] as $containerElementName => $containerElementConfig) { + if (!is_array($containerElementConfig)) { + continue; + } + if ( + // inline, file, group and category are always DB relations + in_array($containerElementConfig['config']['type'] ?? [], ['inline', 'file', 'folder', 'group', 'category'], true) + // MM is not allowed (usually type=select, otherwise the upper check should kick in) + || isset($containerElementConfig['config']['MM']) + // foreign_table is not allowed (usually type=select, otherwise the upper check should kick in) + || isset($containerElementConfig['config']['foreign_table']) + ) { + // Nesting types that use DB relations in container sections is not supported. + throw new InvalidDataStructureException( + 'Invalid flex form data structure on field name "' . $containerElementName . '" with element "' . $sheetElementName . '"' + . ' in section container "' . $containerName . '": Nesting elements that have database relations in flex form' + . ' sections is not allowed.', + 1458745468 + ); + } + if (($containerElementConfig['type'] ?? null) === 'array' && ($containerElementConfig['section'] ?? null) === '1') { + // Nesting sections is not supported. Throw an exception if configured. + throw new InvalidDataStructureException( + 'Invalid flex form data structure on field name "' . $containerElementName . '" with element "' . $sheetElementName . '"' + . ' in section container "' . $containerName . '": Nesting sections in container elements' + . ' sections is not allowed.', + 1458745712 + ); + } + $containerElementConfig = $this->migrateFlexField($containerElementName, $containerElementConfig); + $containerElementConfig = $this->prepareFlexField($containerElementName, $containerElementConfig); + $newStructure['sheets'][$sheetName]['ROOT']['el'][$sheetElementName]['el'][$containerName]['el'][$containerElementName] = $containerElementConfig; + } + } + } else { + // Normal element + $sheetElementConfig = $this->migrateFlexField($sheetElementName, $sheetElementConfig); + $sheetElementConfig = $this->prepareFlexField($sheetElementName, $sheetElementConfig); + $newStructure['sheets'][$sheetName]['ROOT']['el'][$sheetElementName] = $sheetElementConfig; + } + } + } + return $newStructure; + } + + private function migrateFlexField(string $fieldName, array $fieldConfig): array + { + // TcaMigration of this field. Call the TcaMigration and log any deprecations. + $dummyTca = [ + 'dummyTable' => [ + 'columns' => [ + $fieldName => $fieldConfig, + ], + ], + ]; + $tcaProcessingResult = $this->tcaMigration->migrate($dummyTca); + // Messages are reset on each `migrate()` execution + $messages = $tcaProcessingResult->getMessages(); + if (!empty($messages)) { + $context = 'FlexFormTools did an on-the-fly migration of a flex form data structure. This is deprecated and will be removed.' + . ' Merge the following changes into the flex form definition "' . $fieldName . '":'; + array_unshift($messages, $context); + trigger_error(implode(LF, $messages), E_USER_DEPRECATED); + } + return $tcaProcessingResult->getTca()['dummyTable']['columns'][$fieldName]; + } + + private function prepareFlexField(string $fieldName, array $fieldConfig): array + { + $dummyTca = [ + 'dummyTable' => [ + 'columns' => [ + $fieldName => $fieldConfig, + ], + ], + ]; + $preparedTca = $this->tcaPreparation->prepare($dummyTca, true); + return $preparedTca['dummyTable']['columns'][$fieldName]; + } + + /** + * Resolve data structure identifier from raw TCA configuration. + */ + private function getDataStructureIdentifierFromRawTca(array $schema, string $tableName, string $fieldName, array $row, array $defaultIdentifier): array + { + // Check for record type specific configuration + if (isset($schema['ctrl']['type'])) { + $recordType = $row[$schema['ctrl']['type']] ?? ''; + if (isset($schema['types'][$recordType]) && ($fieldConfig = $this->getRecordTypeSpecificFieldConfig($schema, $recordType, $fieldName)) !== []) { + if ($fieldConfig['config']['type'] === 'flex' && $fieldConfig['config']['ds'] !== '') { + $defaultIdentifier['dataStructureKey'] = $recordType; + return $defaultIdentifier; + } + + throw new InvalidTcaException( + 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" with record type "' . $recordType . '"' + . ' The field is either not configured as type="flex" or no valid data structure is defined for this record type.', + 1751796941 + ); + } + } + + // Fall back to base field configuration + $baseField = $schema['columns'][$fieldName]['config'] ?? []; + if (($baseField['type'] ?? '') === 'flex' && ($baseField['ds'] ?? '') !== '') { + $defaultIdentifier['dataStructureKey'] = 'default'; + return $defaultIdentifier; + } + + throw new InvalidTcaException( + 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:' + . ' The field is either not configured as type="flex" or no valid data structure is defined.', + 1732198005 + ); + } + + /** + * Resolve data structure identifier from TCA Schema. + */ + private function getDataStructureIdentifierFromTcaSchema(TcaSchema $schema, string $tableName, string $fieldName, array $row, array $defaultIdentifier): array + { + if ($schema->getName() !== $tableName) { + throw new InvalidTcaSchemaException('Given Tca Schema does not match table ' . $tableName . ' from data structure identifier.', 1753182124); + } + + // Check for record type specific configuration + if ($schema->supportsSubSchema()) { + $recordType = (string)($row[$schema->getSubSchemaTypeInformation()->getFieldName()] ?? ''); + if ($recordType !== '' && $schema->hasSubSchema($recordType) && ($subSchema = $schema->getSubSchema($recordType))->hasField($fieldName)) { + $flexField = $subSchema->getField($fieldName); + if ($flexField instanceof FlexFormFieldType && $flexField->getDataStructure() !== '') { + $defaultIdentifier['dataStructureKey'] = $recordType; + return $defaultIdentifier; + } + + throw new InvalidTcaException( + 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" with record type "' . $recordType . '"' + . ' The field is either not configured as type="flex" or no valid data structure is defined for this record type.', + 1751796940 + ); + } + } + + // Fall back to base field + $baseField = $schema->getField($fieldName); + if ($baseField instanceof FlexFormFieldType && $baseField->getDataStructure() !== '') { + $defaultIdentifier['dataStructureKey'] = 'default'; + return $defaultIdentifier; + } + + throw new InvalidTcaException( + 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:' + . ' The field is either not configured as type="flex" or no valid data structure is defined.', + 1732198004 + ); + } + + /** + * Resolve data structure from raw TCA configuration. + */ + private function resolveDataStructureFromRawTca(array $schema, string $fieldName, string $dataStructureKey): string + { + // Try record type specific configuration first + if (isset($schema['ctrl']['type'], $schema['types'][$dataStructureKey]) + && ($fieldConfig = $this->getRecordTypeSpecificFieldConfig($schema, $dataStructureKey, $fieldName)) !== [] + && ($fieldConfig['config']['type'] ?? '') === 'flex' + && is_string($fieldConfig['config']['ds'] ?? false) + ) { + return $fieldConfig['config']['ds']; + } + + // Fall back to default configuration + if ($dataStructureKey === 'default') { + $baseField = $schema['columns'][$fieldName]['config'] ?? []; + if (($baseField['type'] ?? '') === 'flex' && is_string($baseField['ds'] ?? false)) { + return $baseField['ds']; + } + } + + return ''; + } + + /** + * Resolve data structure from TCA Schema. + */ + private function resolveDataStructureFromTcaSchema(TcaSchema $schema, string $table, string $field, string $dataStructureKey): string + { + if ($schema->getName() !== $table) { + throw new InvalidTcaSchemaException('Given Tca Schema does not match table ' . $table . ' from data structure identifier.', 1753182126); + } + + // Try record type specific configuration first + if ($schema->supportsSubSchema() + && $schema->hasSubSchema($dataStructureKey) + && ($subSchema = $schema->getSubSchema($dataStructureKey))->hasField($field) + && ($flexField = $subSchema->getField($field)) instanceof FlexFormFieldType + ) { + return $flexField->getDataStructure(); + } + + // Fall back to default configuration + if ($dataStructureKey === 'default' && ($flexField = $schema->getField($field)) instanceof FlexFormFieldType) { + return $flexField->getDataStructure(); + } + + return ''; + } + + /** + * Returns the record type specific configuration, also already taking columnsOverrides into account. + * In case the field is not defined for the record type, no configuration is returned. + */ + protected function getRecordTypeSpecificFieldConfig(array $tcaForTable, string $recordType, string $fieldName): array + { + $recordTypeConfig = $tcaForTable['types'][$recordType]; + $showItemArray = GeneralUtility::trimExplode(',', $recordTypeConfig['showitem'] ?? '', true); + foreach ($showItemArray as $aShowItemFieldString) { + [$name, , $paletteName] = GeneralUtility::trimExplode(';', $aShowItemFieldString . ';;;'); + if ($name === '--div--') { + continue; + } + if ($name === '--palette--' && !empty($paletteName)) { + if (!isset($tcaForTable['palettes'][$paletteName]['showitem'])) { + continue; + } + $palettesArray = GeneralUtility::trimExplode(',', $tcaForTable['palettes'][$paletteName]['showitem']); + foreach ($palettesArray as $aPalettesString) { + [$name] = GeneralUtility::trimExplode(';', $aPalettesString . ';;'); + if ($name === $fieldName && isset($tcaForTable['columns'][$name])) { + return array_replace_recursive($tcaForTable['columns'][$name], $recordTypeConfig['columnsOverrides'][$name] ?? []); + } + } + } elseif ($name === $fieldName && isset($tcaForTable['columns'][$name])) { + return array_replace_recursive($tcaForTable['columns'][$name], $recordTypeConfig['columnsOverrides'][$name] ?? []); + } + } + return []; + } + + /** + * Parses a flexForm node recursively and takes care of sections etc. + * Helper method of convertFlexFormContentToArray() and convertFlexFormContentToSheetsArray(). + */ + private function walkFlexFormNode(mixed $nodeArray): mixed + { + if (!is_array($nodeArray)) { + return $nodeArray; + } + $result = []; + foreach ($nodeArray as $nodeKey => $nodeValue) { + if ($nodeKey === 'vDEF') { + return $nodeValue; + } + if (in_array($nodeKey, ['el', '_arrayContainer'])) { + return $this->walkFlexFormNode($nodeValue); + } + if (($nodeKey[0] ?? '') === '_') { + continue; + } + if (strpos((string)$nodeKey, '.')) { + $nodeKeyParts = explode('.', $nodeKey); + $currentNode = &$result; + $nodeKeyPartsCount = count($nodeKeyParts); + for ($i = 0; $i < $nodeKeyPartsCount - 1; $i++) { + $currentNode = &$currentNode[$nodeKeyParts[$i]]; + } + $newNode = [next($nodeKeyParts) => $nodeValue]; + $subVal = $this->walkFlexFormNode($newNode); + $currentNode[key($subVal)] = current($subVal); + } elseif (is_array($nodeValue)) { + if (array_key_exists('vDEF', $nodeValue)) { + $result[$nodeKey] = $nodeValue['vDEF']; + } else { + $result[$nodeKey] = $this->walkFlexFormNode($nodeValue); + } + } else { + $result[$nodeKey] = $nodeValue; + } + } + return $result; + } +} diff --git a/Classes/Configuration/Loader/Exception/YamlFileLoadingException.php b/Classes/Configuration/Loader/Exception/YamlFileLoadingException.php new file mode 100644 index 0000000..cc40921 --- /dev/null +++ b/Classes/Configuration/Loader/Exception/YamlFileLoadingException.php @@ -0,0 +1,20 @@ +loadAndParse($fileName, $flags, null); + } + + /** + * Internal method which does all the logic. Built so it can be re-used recursively. + * + * @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:... + * @param string|null $currentFileName when called recursively + * @return array the configuration as array + */ + protected function loadAndParse(string $fileName, int $flags, ?string $currentFileName): array + { + $sanitizedFileName = $this->getStreamlinedFileName($fileName, $currentFileName); + $content = $this->getFileContents($sanitizedFileName); + try { + $content = Yaml::parse($content); + } catch (ParseException $e) { + throw new YamlParseException( + 'YAML file "' . $fileName . '" has syntax errors: ' . $e->getMessage(), + 1740817000, + $e + ); + } + + if ($content === null && $this->hasFlag($flags, self::ALLOW_EMPTY_FILE)) { + $content = []; + } + + if (!is_array($content)) { + throw new YamlParseException( + 'YAML file "' . $fileName . '" does not contain data.', + 1497332874 + ); + } + + if ($this->hasFlag($flags, self::PROCESS_IMPORTS)) { + $content = $this->processImports($content, $flags, $sanitizedFileName); + } + if ($this->hasFlag($flags, self::PROCESS_PLACEHOLDERS)) { + // Check for "%" placeholders + $content = $this->processPlaceholders($content, $content); + } + return $content; + } + + /** + * Put into a separate method to ease the pains with unit tests + * + * @return string the contents or empty string if file_get_contents fails + */ + protected function getFileContents(string $fileName): string + { + return is_readable($fileName) ? (string)file_get_contents($fileName) : ''; + } + + /** + * Fetches the absolute file name, but if a different file name is given, it is built relative to that. + * + * @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:... + * @param string|null $currentFileName when called recursively this contains the absolute file name of the file that included this file + * @return string the contents of the file + * @throws YamlFileLoadingException when the file was not accessible + */ + protected function getStreamlinedFileName(string $fileName, ?string $currentFileName): string + { + if (!empty($currentFileName)) { + if (PathUtility::isExtensionPath($fileName) || PathUtility::isAbsolutePath($fileName)) { + $streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName); + } else { + // Now this path is considered to be relative the current file name + $streamlinedFileName = PathUtility::getAbsolutePathOfRelativeReferencedFileOrPath( + $currentFileName, + $fileName + ); + if (!GeneralUtility::isAllowedAbsPath($streamlinedFileName)) { + throw new YamlFileLoadingException( + 'Referencing a file which is outside of TYPO3s main folder', + 1560319866 + ); + } + } + } else { + $streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName); + } + if (!$streamlinedFileName) { + throw new YamlFileLoadingException('YAML File "' . $fileName . '" could not be loaded', 1485784246); + } + return $streamlinedFileName; + } + + /** + * Checks for the special "imports" key on the main level of a file, + * which calls "load" recursively. + */ + protected function processImports(array $content, int $flags, ?string $fileName): array + { + if (isset($content['imports']) && is_array($content['imports'])) { + // Reverse the order of imports to follow the order of the declarations, see #92100 + $content['imports'] = array_reverse($content['imports']); + foreach ($content['imports'] as $import) { + try { + $import = $this->processPlaceholders($import, $content); + $resource = $import['resource']; + if ($import['glob'] ?? false) { + $resource = $this->getStreamlinedFileName($resource, $fileName); + foreach (array_reverse(glob($resource)) as $file) { + $content = ArrayUtility::replaceAndAppendScalarValuesRecursive($this->loadAndParse($file, $flags, $fileName), $content); + } + } else { + $importedContent = $this->loadAndParse($resource, $flags, $fileName); + // override the imported content with the one from the current file + $content = ArrayUtility::replaceAndAppendScalarValuesRecursive($importedContent, $content); + } + } catch (ParseException|YamlParseException|YamlFileLoadingException $exception) { + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + } + } + unset($content['imports']); + } + return $content; + } + + /** + * Main function that gets called recursively to check for %...% placeholders + * inside the array + * + * @param array $content the current sub-level content array + * @param array $referenceArray the global configuration array + * @return array the modified sub-level content array + */ + protected function processPlaceholders(array $content, array $referenceArray): array + { + foreach ($content as $k => $v) { + if ($this->containsPlaceholder($k)) { + $resolvedKey = $this->processPlaceholderLine($k, $referenceArray); + if (isset($content[$resolvedKey])) { + if ($k === $resolvedKey) { + throw new \UnexpectedValueException( + 'Unresolvable placeholder key "' . $k . '" could not be substituted.', + 1719672440 + ); + } + throw new \UnexpectedValueException( + 'Placeholder key "' . $k . '" can not be substituted with "' . $resolvedKey . '" because key already exists', + 1719316250 + ); + } + unset($content[$k]); + $k = $resolvedKey; + $content[$k] = $v; + } + if (is_array($v)) { + $content[$k] = $this->processPlaceholders($v, $referenceArray); + } elseif ($this->containsPlaceholder($v)) { + $content[$k] = $this->processPlaceholderLine($v, $referenceArray); + } + } + return $content; + } + + protected function processPlaceholderLine(string $line, array $referenceArray): mixed + { + $parts = $this->getParts($line); + foreach ($parts as $partKey => $part) { + $result = $this->processSinglePlaceholder($partKey, $part, $referenceArray); + // Replace whole content if placeholder is the only thing in this line + if ($line === $partKey) { + $line = $result; + } elseif (is_string($result) || is_numeric($result)) { + $line = str_replace($partKey, $result, $line); + } else { + throw new \UnexpectedValueException( + 'Placeholder can not be substituted if result is not string or numeric', + 1581502783 + ); + } + if ($result !== $partKey && $this->containsPlaceholder($line)) { + $line = $this->processPlaceholderLine($line, $referenceArray); + } + } + return $line; + } + + protected function processSinglePlaceholder(string $placeholder, string $value, array $referenceArray): mixed + { + $processorList = GeneralUtility::makeInstance( + PlaceholderProcessorList::class, + $GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors'] + ); + foreach ($processorList->compile() as $processor) { + if ($processor->canProcess($placeholder, $referenceArray)) { + try { + $result = $processor->process($value, $referenceArray); + } catch (\UnexpectedValueException) { + $result = $placeholder; + } + if (is_array($result)) { + $result = $this->processPlaceholders($result, $referenceArray); + } + break; + } + } + return $result ?? $placeholder; + } + + protected function getParts(string $placeholders): array + { + // find occurrences of placeholders like %some()% and %array.access%. + // Only find the innermost ones, so we can nest them. + preg_match_all( + '/' . self::PATTERN_PARTS . '/', + $placeholders, + $parts, + PREG_UNMATCHED_AS_NULL + ); + $matches = array_filter( + array_merge($parts[1], $parts[2]) + ); + return array_combine($parts[0], $matches); + } + + /** + * Finds possible placeholders. + * May find false positives for complexer structures, but they will be sorted later on. + */ + protected function containsPlaceholder(mixed $value): bool + { + return is_string($value) && substr_count($value, '%') >= 2; + } + + protected function hasFlag(int $flags, int $flag): bool + { + return ($flags & $flag) === $flag; + } +} diff --git a/Classes/Configuration/Loader/YamlPlaceholderGuard.php b/Classes/Configuration/Loader/YamlPlaceholderGuard.php new file mode 100644 index 0000000..80c8db0 --- /dev/null +++ b/Classes/Configuration/Loader/YamlPlaceholderGuard.php @@ -0,0 +1,102 @@ +fragmentSplitter = GeneralUtility::makeInstance( + StringFragmentSplitter::class, + $fragmentPattern + ); + } + + /** + * Modifies existing configuration. + */ + public function process(array $modified): array + { + return $this->protectPlaceholders($this->existingConfiguration, $modified); + } + + /** + * Detects placeholders that have been introduced and handles* them. + * (*) currently throws an exception, but could be purged or escaped as well + * + * @param array $current + * @param array $modified + * @param list $steps configuration keys traversed so far + * @return array sanitized configuration (currently not used, exception thrown before) + * @throws YamlPlaceholderException + */ + protected function protectPlaceholders(array $current, array $modified, array $steps = []): array + { + foreach ($modified as $key => $value) { + $currentSteps = array_merge($steps, [$key]); + if (is_array($value)) { + $modified[$key] = $this->protectPlaceholders( + $current[$key] ?? [], + $value, + $currentSteps + ); + } elseif (is_string($value)) { + $splitFlags = StringFragmentSplitter::FLAG_UNMATCHED_AS_NULL; + $newFragments = $this->fragmentSplitter->split($value, $splitFlags); + if (is_string($current[$key] ?? null)) { + $currentFragments = $this->fragmentSplitter->split($current[$key] ?? '', $splitFlags); + } else { + $currentFragments = null; + } + // in case there are new fragments (at least one matching the pattern) + if ($newFragments !== null) { + // compares differences in `expression` fragments only + $differences = $currentFragments === null + ? $newFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION) + : $newFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION) + ->diff($currentFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION)); + if (count($differences) > 0) { + throw new YamlPlaceholderException( + sprintf( + 'Introducing placeholder%s %s for %s is not allowed', + count($differences) !== 1 ? 's' : '', + implode(', ', $differences->getFragments()), + implode('.', $currentSteps) + ), + 1651690534 + ); + } + } + } + } + return $modified; + } +} diff --git a/Classes/Configuration/Processor/Placeholder/EnvPlaceholderProcessor.php b/Classes/Configuration/Processor/Placeholder/EnvPlaceholderProcessor.php new file mode 100644 index 0000000..9779f6b --- /dev/null +++ b/Classes/Configuration/Processor/Placeholder/EnvPlaceholderProcessor.php @@ -0,0 +1,130 @@ +processorList = $processorList->compile(); + } + + public function canProcess(mixed $placeholder): bool + { + // only strings may be candidates for $placeholder substitution + if (!is_string($placeholder)) { + return false; + } + + return str_contains($placeholder, '%env('); + } + + public function process(string $value): string + { + return $this->processPlaceholderLine($value); + } + + /** + * The following methods are taken from YamlFileLoader, but adapted + * for isolated usage and preventing circular dependencies. + */ + protected function processPlaceholderLine(string $line): string + { + $parts = $this->getParts($line); + foreach ($parts as $partKey => $part) { + $result = $this->processSinglePlaceholder($partKey, $part); + // Replace whole content if placeholder is the only thing in this line + if ($line === $partKey) { + $line = $result; + } elseif (is_string($result) || is_numeric($result)) { + $line = str_replace($partKey, $result, $line); + } else { + throw new \UnexpectedValueException( + 'ENV Placeholder can not be substituted if result is not string or numeric', + 1770965068 + ); + } + if ($result !== $partKey && $this->containsPlaceholder($line)) { + $line = $this->processPlaceholderLine($line); + } + } + return $line; + } + + protected function processSinglePlaceholder(string $placeholder, string $value): mixed + { + foreach ($this->processorList as $processor) { + if ($processor->canProcess($placeholder, [])) { + try { + $result = $processor->process($value, []); + } catch (\UnexpectedValueException) { + $result = $placeholder; + } + break; + } + } + return $result ?? $placeholder; + } + + // These two methods are used just as in YamlFileLoader and + // might be moved to utility classes; however for now they + // are replicated to be able to be adapted. + protected function getParts(string $placeholders): array + { + // find occurrences of placeholders like %some()% and %array.access%. + // Only find the innermost ones, so we can nest them. + preg_match_all( + '/' . self::PATTERN_PARTS . '/', + $placeholders, + $parts, + PREG_UNMATCHED_AS_NULL + ); + $matches = array_filter( + array_merge($parts[1], $parts[2]) + ); + return array_combine($parts[0], $matches); + } + + /** + * Finds possible placeholders. + * May find false positives for complexer structures, but they will be sorted later on. + */ + protected function containsPlaceholder(mixed $value): bool + { + return is_string($value) && substr_count($value, '%') >= 2; + } +} diff --git a/Classes/Configuration/Processor/Placeholder/EnvVariableProcessor.php b/Classes/Configuration/Processor/Placeholder/EnvVariableProcessor.php new file mode 100644 index 0000000..b1cc9b5 --- /dev/null +++ b/Classes/Configuration/Processor/Placeholder/EnvVariableProcessor.php @@ -0,0 +1,39 @@ +processors = $processorList; + } + + /** + * @return PlaceholderProcessorInterface[] + */ + public function compile(): array + { + $processors = []; + $orderingService = GeneralUtility::makeInstance(DependencyOrderingService::class); + $orderedProcessors = $orderingService->orderByDependencies($this->processors, 'before', 'after'); + + foreach ($orderedProcessors as $processorClassName => $providerConfig) { + if (isset($providerConfig['disabled']) && $providerConfig['disabled'] === true) { + continue; + } + + $processor = GeneralUtility::makeInstance($processorClassName); + if (!$processor instanceof PlaceholderProcessorInterface) { + throw new \UnexpectedValueException( + 'Placeholder processor ' . $processorClassName . ' must implement PlaceholderProcessorInterface', + 1581343410 + ); + } + $processors[] = $processor; + } + return $processors; + } +} diff --git a/Classes/Configuration/Richtext.php b/Classes/Configuration/Richtext.php new file mode 100644 index 0000000..4ecc23a --- /dev/null +++ b/Classes/Configuration/Richtext.php @@ -0,0 +1,229 @@ +getPageTsConfiguration($table, $field, $pid, $recordType); + + // determine which preset to use + $pageTs['preset'] = $pageTs['fieldSpecificPreset'] ?? $tcaFieldConf['richtextConfiguration'] ?? $pageTs['generalPreset'] ?? 'default'; + unset($pageTs['fieldSpecificPreset']); + unset($pageTs['generalPreset']); + + // load configuration from preset + $configuration = $this->loadConfigurationFromPreset($pageTs['preset']); + + // overlay preset configuration with pageTs + ArrayUtility::mergeRecursiveWithOverrule( + $configuration, + $this->addFlattenedPageTsConfig($pageTs) + ); + + // Handle "mode" / "transformation" config when overridden + if (!isset($configuration['proc.']['mode']) && !isset($configuration['proc.']['overruleMode'])) { + $configuration['proc.']['overruleMode'] = 'default'; + } + + $event = $this->eventDispatcher->dispatch(new AfterRichtextConfigurationPreparedEvent($configuration)); + + return $event->getConfiguration(); + } + + /** + * Load a configuration preset from an external resource (currently only YAML is supported). + * This is the default behaviour and can be overridden by page TSconfig. + * + * @return array the parsed configuration + */ + protected function loadConfigurationFromPreset(string $presetName = ''): array + { + $configuration = []; + if (!empty($presetName) && isset($GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets'][$presetName])) { + $identifier = 'richtext_' . $presetName; + $configuration = $this->runtimeCache->get($identifier); + + if ($configuration === false) { + $configuration = $this->yamlFileLoader->load($GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets'][$presetName]); + // For future versions, you should however rely on the "processing" key and not the "proc" key. + if (is_array($configuration['processing'] ?? null)) { + $configuration['proc.'] = $this->convertPlainArrayToTypoScriptArray($configuration['processing']); + } + $this->runtimeCache->set($identifier, $configuration); + } + } + return $configuration; + } + + /** + * Return RTE section of page TS + * + * @param int $pid Page ts of given pid + * @return array RTE section of pageTs of given pid + */ + protected function getRtePageTsConfigOfPid(int $pid): array + { + return BackendUtility::getPagesTSconfig($pid)['RTE.'] ?? []; + } + + /** + * Returns an array with Typoscript the old way (with dot) + * Since the functionality in YAML is without the dots, but the new configuration is used without the dots + * this functionality adds also an explicit = 1 to the arrays + * + * @param array $plainArray An array + * @return array array with TypoScript as usual (with dot) + */ + protected function convertPlainArrayToTypoScriptArray(array $plainArray) + { + $typoScriptArray = []; + foreach ($plainArray as $key => $value) { + if (is_array($value)) { + if (!isset($typoScriptArray[$key])) { + $typoScriptArray[$key] = 1; + } + $typoScriptArray[$key . '.'] = $this->convertPlainArrayToTypoScriptArray($value); + } else { + $typoScriptArray[$key] = $value ?? ''; + } + } + return $typoScriptArray; + } + + /** + * Add all PageTS.RTE options keys to configuration without dots + * + * We need to keep the dotted keys for backwards compatibility like ext:rtehtmlarea + * + * @param array $typoScriptArray TypoScriptArray + * @return array array with config without dots added + */ + protected function addFlattenedPageTsConfig(array $typoScriptArray): array + { + foreach ($typoScriptArray as $key => $data) { + if (!str_ends_with($key, '.')) { + continue; + } + $typoScriptArray[substr($key, 0, -1)] = $this->typoScriptService->convertTypoScriptArrayToPlainArray($typoScriptArray[$key]); + } + + return $typoScriptArray; + } + + /** + * Load PageTS configuration for the RTE + * + * Return RTE section of page TS, taking into account overloading via table, field and record type + * + * @param string $table The table the field is in + * @param string $field Field name + * @param int $pid Real page id + * @param string $recordType Record type value + */ + protected function getPageTsConfiguration(string $table, string $field, int $pid, string $recordType): array + { + // Load page TSconfig configuration + $fullPageTsConfig = $this->getRtePageTsConfigOfPid($pid); + $defaultPageTsConfigOverrides = $fullPageTsConfig['default.'] ?? null; + + $defaultPageTsConfigOverrides['generalPreset'] = $fullPageTsConfig['default.']['preset'] ?? null; + + $fieldSpecificPageTsConfigOverrides = $fullPageTsConfig['config.'][$table . '.'][$field . '.'] ?? null; + unset($fullPageTsConfig['default.'], $fullPageTsConfig['config.']); + + // First use RTE.* + $rtePageTsConfiguration = $fullPageTsConfig; + + // Then overload with RTE.default.* + if (is_array($defaultPageTsConfigOverrides)) { + ArrayUtility::mergeRecursiveWithOverrule($rtePageTsConfiguration, $defaultPageTsConfigOverrides); + } + + $rtePageTsConfiguration['fieldSpecificPreset'] = $fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.']['preset'] + ?? $fieldSpecificPageTsConfigOverrides['preset'] ?? null; + + // Then overload with RTE.config.tt_content.bodytext + if (is_array($fieldSpecificPageTsConfigOverrides)) { + $fieldSpecificPageTsConfigOverridesWithoutType = $fieldSpecificPageTsConfigOverrides; + unset($fieldSpecificPageTsConfigOverridesWithoutType['types.']); + ArrayUtility::mergeRecursiveWithOverrule($rtePageTsConfiguration, $fieldSpecificPageTsConfigOverridesWithoutType); + + // Then overload with RTE.config.tt_content.bodytext.types.textmedia + if ( + $recordType + && isset($fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.']) + && is_array($fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.']) + ) { + ArrayUtility::mergeRecursiveWithOverrule( + $rtePageTsConfiguration, + $fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.'] + ); + } + } + + unset($rtePageTsConfiguration['preset']); + + return $rtePageTsConfiguration; + } +} diff --git a/Classes/Configuration/SiteConfiguration.php b/Classes/Configuration/SiteConfiguration.php new file mode 100644 index 0000000..0cd53cb --- /dev/null +++ b/Classes/Configuration/SiteConfiguration.php @@ -0,0 +1,336 @@ +runtimeCache->has(self::CACHE_IDENTIFIER)) { + return $this->runtimeCache->get(self::CACHE_IDENTIFIER); + } + return $this->resolveAllExistingSites($useCache); + } + + /** + * Resolve all site objects which have been found in the filesystem. + * + * @return Site[] + */ + public function resolveAllExistingSites(bool $useCache = true): array + { + $sites = []; + $siteConfiguration = $this->getAllSiteConfigurationFromFiles($useCache); + foreach ($siteConfiguration as $identifier => $configuration) { + // cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers + $identifier = (string)$identifier; + $siteSettings = $this->siteSettingsFactory->getSettings($identifier, $configuration); + $siteTypoScript = $this->getSiteTypoScript($identifier); + $siteTSconfig = $this->getSiteTSconfig($identifier); + $configuration['contentSecurityPolicies'] = $this->getContentSecurityPolicies($identifier); + $configuration['routeEnhancers'] = ArrayUtility::replaceAndAppendScalarValuesRecursive( + $this->getRouteEnhancersFromSets($configuration['dependencies'] ?? []), + $configuration['routeEnhancers'] ?? [] + ); + + $rootPageId = (int)($configuration['rootPageId'] ?? 0); + if ($rootPageId > 0) { + $site = new Site($identifier, $rootPageId, $configuration, $siteSettings, $siteTypoScript, $siteTSconfig); + $this->determineInvalidSets($site); + $sites[$identifier] = $site; + + } + } + $this->runtimeCache->set(self::CACHE_IDENTIFIER, $sites); + return $sites; + } + + /** + * Resolve all site objects which have been found in the filesystem containing settings only from the `config.yaml` + * file ignoring values from the `settings.yaml` and `csp.yaml` file. + * + * @return Site[] + * @internal Not part of public API. Used as intermediate solution until settings are handled by a dedicated GUI. + */ + public function resolveAllExistingSitesRaw(): array + { + $sites = []; + $siteConfiguration = $this->getAllSiteConfigurationFromFiles(false); + foreach ($siteConfiguration as $identifier => $configuration) { + // cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers + $identifier = (string)$identifier; + $inlineSettings = $configuration['settings'] ?? []; + $siteSettings = SiteSettings::createFromSettingsTree($inlineSettings); + $siteTypoScript = $this->getSiteTypoScript($identifier); + + $rootPageId = (int)($configuration['rootPageId'] ?? 0); + if ($rootPageId > 0) { + $site = new Site($identifier, $rootPageId, $configuration, $siteSettings, $siteTypoScript); + $this->determineInvalidSets($site); + $sites[$identifier] = $site; + } + } + return $sites; + } + + /** + * Returns an array of paths in which a site configuration is found. + * + * @internal + */ + public function getAllSiteConfigurationPaths(): array + { + $finder = new Finder(); + $paths = []; + try { + $finder->files()->depth(0)->name(self::CONFIG_FILE_NAME)->in($this->configPath . '/*'); + } catch (\InvalidArgumentException $e) { + $finder = []; + } + + foreach ($finder as $fileInfo) { + $path = $fileInfo->getPath(); + $paths[basename($path)] = $path; + } + return $paths; + } + + /** + * Read the site configuration from config files. + * + * @throws InvalidDataException + */ + protected function getAllSiteConfigurationFromFiles(bool $useCache = true): array + { + // Check if the data is already cached + $siteConfiguration = $useCache ? $this->cache->require(self::CACHE_IDENTIFIER) : false; + if ($siteConfiguration !== false) { + return $siteConfiguration; + } + $finder = new Finder(); + try { + $finder->files()->depth(0)->name(self::CONFIG_FILE_NAME)->in($this->configPath . '/*'); + } catch (\InvalidArgumentException $e) { + // Directory $this->configPath does not exist yet + $finder = []; + } + $siteConfiguration = []; + foreach ($finder as $fileInfo) { + $configuration = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath((string)$fileInfo)); + $identifier = basename($fileInfo->getPath()); + $event = $this->eventDispatcher->dispatch(new SiteConfigurationLoadedEvent($identifier, $configuration)); + $siteConfiguration[$identifier] = $event->getConfiguration(); + } + $this->cache->set(self::CACHE_IDENTIFIER, 'return ' . var_export($siteConfiguration, true) . ';'); + + return $siteConfiguration; + } + + /** + * Load plain configuration without additional settings. + * + * This method should only be used in case the original configuration as it exists in the file should be loaded, + * for example for writing / editing configuration. + * + * All read related actions should be performed on the site entity. + * + * @param string $siteIdentifier + */ + public function load(string $siteIdentifier): array + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME; + return $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS); + } + + protected function getSiteTypoScript(string $siteIdentifier): ?SiteTypoScript + { + $data = [ + 'setup' => self::TYPOSCRIPT_SETUP_FILE_NAME, + 'constants' => self::TYPOSCRIPT_CONSTANTS_FILE_NAME, + ]; + $definitions = []; + foreach ($data as $type => $fileName) { + $path = $this->configPath . '/' . $siteIdentifier . '/' . $fileName; + if (file_exists($path)) { + $contents = @file_get_contents(GeneralUtility::fixWindowsFilePath($path)); + if ($contents !== false) { + $definitions[$type] = $contents; + } + } + } + if ($definitions === []) { + return null; + } + return new SiteTypoScript(...$definitions); + } + + protected function getSiteTSconfig(string $siteIdentifier): ?SiteTSconfig + { + $pageTSconfig = null; + $path = $this->configPath . '/' . $siteIdentifier . '/' . self::PAGE_TSCONFIG_FILE_NAME; + if (file_exists($path)) { + $contents = @file_get_contents(GeneralUtility::fixWindowsFilePath($path)); + if ($contents !== false) { + $pageTSconfig = $contents; + } + } + if ($pageTSconfig === null) { + return null; + } + + return new SiteTSconfig( + pageTSconfig: $pageTSconfig + ); + } + + protected function getContentSecurityPolicies(string $siteIdentifier): array + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONTENT_SECURITY_FILE_NAME; + if (file_exists($fileName)) { + return $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName)); + } + return []; + } + + /** + * Get route enhancers from site sets. + */ + protected function getRouteEnhancersFromSets(array $dependencies): array + { + $routeEnhancers = []; + $sets = $this->setRegistry->getSets(...$dependencies); + foreach ($sets as $set) { + $routeEnhancers = ArrayUtility::replaceAndAppendScalarValuesRecursive( + $routeEnhancers, + $set->routeEnhancers + ); + } + return $routeEnhancers; + } + + protected function determineInvalidSets(Site $site): void + { + $site->invalidSets = array_filter( + $this->setRegistry->getInvalidSets(), + static fn($setName) => in_array($setName, $site->getSets(), true), + ARRAY_FILTER_USE_KEY + ); + foreach ($site->getSets() as $set) { + if (!$this->setRegistry->hasSet($set) && !isset($site->invalidSets[$set])) { + $site->invalidSets[$set] = [ + 'name' => $set, + 'error' => SetError::notFound, + 'context' => 'site:' . $site->getIdentifier(), + ]; + } + } + } + + #[AsEventListener(event: SiteConfigurationChangedEvent::class)] + public function siteConfigurationChanged(): void + { + $this->cache->remove(self::CACHE_IDENTIFIER); + $this->runtimeCache->remove(self::CACHE_IDENTIFIER); + } + + #[AsEventListener('typo3-core/site-configuration')] + public function warmupCaches(CacheWarmupEvent $event): void + { + if ($event->hasGroup('system')) { + $this->getAllSiteConfigurationFromFiles(false); + } + } +} diff --git a/Classes/Configuration/SiteWriter.php b/Classes/Configuration/SiteWriter.php new file mode 100644 index 0000000..4902662 --- /dev/null +++ b/Classes/Configuration/SiteWriter.php @@ -0,0 +1,243 @@ + $rootPageId, + 'base' => $base, + 'languages' => [ + 0 => [ + 'title' => 'English', + 'enabled' => true, + 'languageId' => 0, + 'base' => '/', + 'locale' => 'en_US.UTF-8', + 'navigationTitle' => 'English', + 'flag' => 'us', + ], + ], + 'errorHandling' => [], + 'routes' => [], + 'dependencies' => $dependencies, + ]; + + $this->write($identifier, $configuration); + } + + public function writeSettings(string $siteIdentifier, array $settings): void + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::SETTINGS_FILE_NAME; + if ($settings === []) { + if (!is_file($fileName)) { + return; + } + $yamlFileContents = '# No site specific settings defined'; + } else { + $yamlFileContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP); + } + if (!GeneralUtility::writeFile($fileName, $yamlFileContents, true)) { + throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . self::SETTINGS_FILE_NAME, 1590487411); + } + } + + /** + * Add or update a site configuration + * + * @param bool $protectPlaceholders whether to disallow introducing new placeholders + * @todo enforce $protectPlaceholders with TYPO3 v13.0 + * @throws SiteConfigurationWriteException + */ + public function write(string $siteIdentifier, array $configuration, bool $protectPlaceholders = false): void + { + $folder = $this->configPath . '/' . $siteIdentifier; + $fileName = $folder . '/' . self::CONFIG_FILE_NAME; + $newConfiguration = $configuration; + if (!file_exists($folder)) { + GeneralUtility::mkdir_deep($folder); + if ($protectPlaceholders && $newConfiguration !== []) { + $newConfiguration = $this->protectPlaceholders([], $newConfiguration); + } + } elseif (file_exists($fileName)) { + // load without any processing to have the unprocessed base to modify + $newConfiguration = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), 0); + // load the processed configuration to diff changed values, + // but don't process placeholders, because all properties that + // were modified via GUI are unprocessed values as well + $processed = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS); + // find properties that were modified via GUI + $newModified = array_replace_recursive( + self::findRemoved($processed, $configuration), + self::findModified($processed, $configuration) + ); + if ($protectPlaceholders && $newModified !== []) { + $newModified = $this->protectPlaceholders($newConfiguration, $newModified); + } + // change _only_ the modified keys, leave the original non-changed areas alone + ArrayUtility::mergeRecursiveWithOverrule($newConfiguration, $newModified); + } + $event = $this->eventDispatcher->dispatch(new SiteConfigurationBeforeWriteEvent($siteIdentifier, $newConfiguration)); + $newConfiguration = $this->sortConfiguration($event->getConfiguration()); + $yamlFileContents = Yaml::dump($newConfiguration, 99, 2); + if (!GeneralUtility::writeFile($fileName, $yamlFileContents, true)) { + throw new SiteConfigurationWriteException('Unable to write site configuration in sites/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME, 1590487011); + } + $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier)); + } + + /** + * Renames a site identifier (and moves the folder) + * + * @throws SiteConfigurationWriteException + */ + public function rename(string $currentIdentifier, string $newIdentifier): void + { + if (!rename($this->configPath . '/' . $currentIdentifier, $this->configPath . '/' . $newIdentifier)) { + throw new SiteConfigurationWriteException('Unable to rename folder sites/' . $currentIdentifier, 1522491300); + } + $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($newIdentifier)); + } + + /** + * Removes the config.yaml file of a site configuration. + * Also clears the cache. + * + * @throws SiteNotFoundException|SiteConfigurationWriteException + */ + public function delete(string $siteIdentifier): void + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME; + if (!file_exists($fileName)) { + throw new SiteNotFoundException('Site configuration file ' . self::CONFIG_FILE_NAME . ' within the site ' . $siteIdentifier . ' not found.', 1522866184); + } + if (!unlink($fileName)) { + throw new SiteConfigurationWriteException('Unable to delete folder sites/' . $siteIdentifier, 1596462020); + } + $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier)); + } + + /** + * Detects placeholders that have been introduced and handles* them. + * (*) currently throws an exception, but could be purged or escaped as well + * + * @param array $existingConfiguration + * @param array $modifiedConfiguration + * @return array sanitized configuration (currently not used, exception thrown before) + * @throws SiteConfigurationWriteException + */ + protected function protectPlaceholders(array $existingConfiguration, array $modifiedConfiguration): array + { + try { + return GeneralUtility::makeInstance(YamlPlaceholderGuard::class, $existingConfiguration) + ->process($modifiedConfiguration); + } catch (YamlPlaceholderException $exception) { + throw new SiteConfigurationWriteException($exception->getMessage(), 1670361271, $exception); + } + } + + protected function sortConfiguration(array $newConfiguration): array + { + ksort($newConfiguration); + if (isset($newConfiguration['imports'])) { + $imports = $newConfiguration['imports']; + unset($newConfiguration['imports']); + $newConfiguration['imports'] = $imports; + } + return $newConfiguration; + } + + protected static function findModified(array $currentConfiguration, array $newConfiguration): array + { + $differences = []; + foreach ($newConfiguration as $key => $value) { + if (!isset($currentConfiguration[$key]) || $currentConfiguration[$key] !== $value) { + if (!isset($value) && isset($currentConfiguration[$key])) { + $differences[$key] = '__UNSET'; + } elseif (isset($currentConfiguration[$key]) + && is_array($value) + && is_array($currentConfiguration[$key]) + ) { + $differences[$key] = self::findModified($currentConfiguration[$key], $value); + } else { + $differences[$key] = $value; + } + } + } + return $differences; + } + + protected static function findRemoved(array $currentConfiguration, array $newConfiguration): array + { + $removed = []; + foreach ($currentConfiguration as $key => $value) { + if (!isset($newConfiguration[$key])) { + $removed[$key] = '__UNSET'; + } elseif (isset($value) && is_array($value) && is_array($newConfiguration[$key])) { + $removedInRecursion = self::findRemoved($value, $newConfiguration[$key]); + if (!empty($removedInRecursion)) { + $removed[$key] = $removedInRecursion; + } + } + } + + return $removed; + } +} diff --git a/Classes/Configuration/Tca/TcaEnrichment.php b/Classes/Configuration/Tca/TcaEnrichment.php new file mode 100644 index 0000000..bf05026 --- /dev/null +++ b/Classes/Configuration/Tca/TcaEnrichment.php @@ -0,0 +1,293 @@ +enrichDisabledField($tca); + $tca = $this->enrichStarttimeField($tca); + $tca = $this->enrichEndtimeField($tca); + $tca = $this->enrichFeGroupField($tca); + $tca = $this->enrichEditLockField($tca); + $tca = $this->enrichDescriptionField($tca); + $tca = $this->enrichLanguageField($tca); + $tca = $this->setTransOrigPointerFieldInCtrl($tca); + $tca = $this->enrichTransOrigPointerField($tca); + $tca = $this->enrichTransOrigDiffSourceField($tca); + $tca = $this->enrichTranslationSourceField($tca); + return $tca; + } + + private function enrichDisabledField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $disabledFieldName = $tableDefinition['ctrl']['enablecolumns']['disabled'] ?? null; + if ($disabledFieldName && !is_array($tableDefinition['columns'][$disabledFieldName] ?? null)) { + $tca[$table]['columns'][$disabledFieldName] = [ + 'label' => 'core.db.general:enabled', + 'exclude' => true, + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + 'items' => [ + [ + 'label' => '', + 'invertStateDisplay' => true, + ], + ], + ], + ]; + } + } + return $tca; + } + + private function enrichStarttimeField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $starttimeFieldName = $tableDefinition['ctrl']['enablecolumns']['starttime'] ?? null; + if ($starttimeFieldName && !is_array($tableDefinition['columns'][$starttimeFieldName] ?? null)) { + $tca[$table]['columns'][$starttimeFieldName] = [ + 'exclude' => true, + 'label' => 'core.db.general:starttime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + ], + ]; + } + } + return $tca; + } + + private function enrichEndtimeField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $endtimeFieldName = $tableDefinition['ctrl']['enablecolumns']['endtime'] ?? null; + if ($endtimeFieldName && !is_array($tableDefinition['columns'][$endtimeFieldName] ?? null)) { + $tca[$table]['columns'][$endtimeFieldName] = [ + 'exclude' => true, + 'label' => 'core.db.general:endtime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + 'range' => [ + 'upper' => mktime(0, 0, 0, 1, 1, 2106), + ], + ], + ]; + } + } + return $tca; + } + + private function enrichFeGroupField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $feGroupFieldName = $tableDefinition['ctrl']['enablecolumns']['fe_group'] ?? null; + if ($feGroupFieldName && !is_array($tableDefinition['columns'][$feGroupFieldName] ?? null)) { + $tca[$table]['columns'][$feGroupFieldName] = [ + 'exclude' => true, + 'label' => 'core.db.general:fe_group', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'size' => 5, + 'maxitems' => 20, + 'items' => [ + [ + 'label' => 'core.db.general:fe_group.hide_at_login', + 'value' => -1, + ], + [ + 'label' => 'core.db.general:fe_group.any_login', + 'value' => -2, + ], + [ + 'label' => 'core.db.general:fe_group.usergroups', + 'value' => '--div--', + ], + ], + 'exclusiveKeys' => '-1,-2', + 'foreign_table' => 'fe_groups', + ], + ]; + } + } + return $tca; + } + + private function enrichEditLockField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $editLockFieldName = $tableDefinition['ctrl']['editlock'] ?? null; + if ($editLockFieldName && !is_array($tableDefinition['columns'][$editLockFieldName] ?? null)) { + $tca[$table]['columns'][$editLockFieldName] = [ + 'displayCond' => 'HIDE_FOR_NON_ADMINS', + 'label' => 'core.db.general:editlock', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ]; + } + } + return $tca; + } + + private function enrichDescriptionField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $descriptionFieldName = $tableDefinition['ctrl']['descriptionColumn'] ?? null; + if ($descriptionFieldName && !is_array($tableDefinition['columns'][$descriptionFieldName] ?? null)) { + $tca[$table]['columns'][$descriptionFieldName] = [ + 'exclude' => true, + 'label' => 'core.db.general:description', + 'config' => [ + 'type' => 'text', + 'rows' => 5, + 'cols' => 30, + 'max' => 2000, + ], + ]; + } + } + return $tca; + } + + private function enrichLanguageField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $languageFieldName = $tableDefinition['ctrl']['languageField'] ?? null; + if ($languageFieldName && !is_array($tableDefinition['columns'][$languageFieldName] ?? null)) { + $tca[$table]['columns'][$languageFieldName] = [ + 'exclude' => true, + 'label' => 'core.db.general:language', + 'config' => [ + 'type' => 'language', + ], + ]; + } + if ($languageFieldName && !is_array($tableDefinition['columns']['language_tag'] ?? null)) { + $tca[$table]['columns']['language_tag'] = [ + 'exclude' => true, + 'label' => 'Language Tag', + 'config' => [ + 'type' => 'input', + 'size' => 10, + 'max' => 35, + 'eval' => 'trim', + 'default' => '', + ], + ]; + } + } + return $tca; + } + + /** + * When 'languageField' is set, 'transOrigPointerField' must be set as well. + * We silently add 'transOrigPointerField' if that iss not the case. + * + * @todo: This obviously needs a consolidation in ctrl. We should have a single, probably + * boolean ctrl toggle to make a table 'localization' aware, with core then handling + * all internals. This will require streamlining the field names along the way, and + * we can think about this as soon as sys_language_uid=-1 is gone. + */ + private function setTransOrigPointerFieldInCtrl(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + if (isset($tableDefinition['ctrl']['languageField']) && !isset($tableDefinition['ctrl']['transOrigPointerField'])) { + $tca[$table]['ctrl']['transOrigPointerField'] = 'l10n_parent'; + } + } + return $tca; + } + + private function enrichTransOrigPointerField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $transOrigPointerFieldName = $tableDefinition['ctrl']['transOrigPointerField'] ?? null; + if ($transOrigPointerFieldName && !is_array($tableDefinition['columns'][$transOrigPointerFieldName] ?? null)) { + $languageFieldName = $tableDefinition['ctrl']['languageField']; + $tca[$table]['columns'][$transOrigPointerFieldName] = [ + 'displayCond' => 'FIELD:' . $languageFieldName . ':>:0', + 'label' => 'core.db.general:l18n_parent', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => '', + 'value' => 0, + ], + ], + 'foreign_table' => $table, + 'foreign_table_where' => 'AND {#' . $table . '}.{#pid}=###CURRENT_PID### AND {#' . $table . '}.{#' . $languageFieldName . '} IN (-1,0)', + 'default' => 0, + ], + ]; + } + } + return $tca; + } + + private function enrichTransOrigDiffSourceField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $transOrigDiffSourceFieldName = $tableDefinition['ctrl']['transOrigDiffSourceField'] ?? null; + if ($transOrigDiffSourceFieldName && !is_array($tableDefinition['columns'][$transOrigDiffSourceFieldName] ?? null)) { + $tca[$table]['columns'][$transOrigDiffSourceFieldName] = [ + 'config' => [ + 'type' => 'passthrough', + 'default' => '', + ], + ]; + } + } + return $tca; + } + + private function enrichTranslationSourceField(array $tca): array + { + foreach ($tca as $table => $tableDefinition) { + $translationSourceFieldName = $tableDefinition['ctrl']['translationSource'] ?? null; + if ($translationSourceFieldName && !is_array($tableDefinition['columns'][$translationSourceFieldName] ?? null)) { + $tca[$table]['columns'][$translationSourceFieldName] = [ + 'config' => [ + 'type' => 'passthrough', + ], + ]; + } + } + return $tca; + } +} diff --git a/Classes/Configuration/Tca/TcaFactory.php b/Classes/Configuration/Tca/TcaFactory.php new file mode 100644 index 0000000..a0eaf07 --- /dev/null +++ b/Classes/Configuration/Tca/TcaFactory.php @@ -0,0 +1,196 @@ +codeCache->require($this->getTcaCacheIdentifier()); + if ($cacheData) { + $tca = $cacheData['tca']; + } else { + $tca = $this->create(); + $this->createBaseTcaCacheFile($tca); + } + + return $tca; + } + + /** + * This is (indirectly) used by extension manager when loading + * extensions, by install tool bootstrap and cache warmup. + */ + public function create(): array + { + $tca = $this->loadConfigurationTcaFiles(); + $tca = $this->dispatchBeforeTcaOverridesEvent($tca); + $tca = $this->enrichTca($tca); + $tca = $this->loadConfigurationTcaOverridesFiles($tca); + $tca = $this->migrateTca($tca); + $tca = $this->prepareTca($tca); + return $this->dispatchAfterTcaCompilationEvent($tca); + } + + /** + * This is used by install tool LoadTcaService to check certain aspects of TCA + */ + public function createNotMigrated(): array + { + $tca = $this->loadConfigurationTcaFiles(); + $tca = $this->dispatchBeforeTcaOverridesEvent($tca); + $tca = $this->enrichTca($tca); + return $this->loadConfigurationTcaOverridesFiles($tca); + } + + /** + * Public since it's also used by CacheWarmupCommand + */ + public function createBaseTcaCacheFile(array $tca): void + { + $this->codeCache->set( + $this->getTcaCacheIdentifier(), + 'return ' + . var_export(['tca' => $tca], true) + . ';' + ); + } + + private function getTcaCacheIdentifier(): string + { + return (new PackageDependentCacheIdentifier($this->packageManager))->withPrefix('tca_base')->toString(); + } + + private function loadConfigurationTcaFiles(): array + { + // To require TCA in a safe scoped environment avoiding local variable clashes. + // Note: Return type 'mixed' is intended, otherwise broken TCA files with missing "return [];" statement would + // emit a "return value must be of type array, int returned" PHP TypeError. This is mitigated by an array + // check below. + $scopedReturnRequire = static function (string $filename): mixed { + return require $filename; + }; + // First load "full table" files from Configuration/TCA + $tca = []; + $activePackages = $this->packageManager->getActivePackages(); + foreach ($activePackages as $package) { + try { + $finder = Finder::create()->files()->sortByName()->depth(0)->name('*.php')->in($package->getPackagePath() . 'Configuration/TCA'); + } catch (\InvalidArgumentException) { + // No such directory in this package + continue; + } + foreach ($finder as $fileInfo) { + $tcaOfTable = $scopedReturnRequire($fileInfo->getPathname()); + if (is_array($tcaOfTable)) { + $tcaTableName = substr($fileInfo->getBasename(), 0, -4); + $tca[$tcaTableName] = $tcaOfTable; + } + } + } + return $tca; + } + + private function enrichTca(array $tca): array + { + return (new TcaEnrichment())->enrich($tca); + } + + private function loadConfigurationTcaOverridesFiles(array $tca): array + { + // To require TCA Overrides in a safe scoped environment avoiding local variable clashes. + $scopedRequire = static function (string $filename): void { + require $filename; + }; + // Execute override files from Configuration/TCA/Overrides + $GLOBALS['TCA'] = $tca; + $activePackages = $this->packageManager->getActivePackages(); + foreach ($activePackages as $package) { + try { + $finder = Finder::create()->files()->sortByName()->depth(0)->name('*.php')->in($package->getPackagePath() . 'Configuration/TCA/Overrides'); + } catch (\InvalidArgumentException) { + // No such directory in this package + continue; + } + foreach ($finder as $fileInfo) { + $scopedRequire($fileInfo->getPathname()); + } + } + $tca = $GLOBALS['TCA']; + unset($GLOBALS['TCA']); + return $tca; + } + + private function migrateTca(array $tca): array + { + // Call the TcaMigration and log any deprecations. + $tcaMigration = new TcaMigration(); + $tcaProcessingResult = $tcaMigration->migrate($tca); + $messages = $tcaProcessingResult->getMessages(); + if (!empty($messages)) { + $context = 'Automatic TCA migration done during bootstrap. Please adapt TCA accordingly, these migrations' + . ' will be removed. The backend module "Configuration -> TCA" shows the modified values.' + . ' Please adapt these areas:'; + array_unshift($messages, $context); + trigger_error(implode(LF, $messages), E_USER_DEPRECATED); + } + return $tcaProcessingResult->getTca(); + } + + private function prepareTca(array $tca): array + { + return (new TcaPreparation())->prepare($tca); + } + + private function dispatchBeforeTcaOverridesEvent($tca): array + { + return $this->eventDispatcher->dispatch(new BeforeTcaOverridesEvent($tca))->getTca(); + } + + private function dispatchAfterTcaCompilationEvent($tca): array + { + $GLOBALS['TCA'] = $tca; + $tca = $this->eventDispatcher->dispatch(new AfterTcaCompilationEvent($tca))->getTca(); + unset($GLOBALS['TCA']); + return $tca; + } +} diff --git a/Classes/Configuration/Tca/TcaMigration.php b/Classes/Configuration/Tca/TcaMigration.php new file mode 100644 index 0000000..16cebbf --- /dev/null +++ b/Classes/Configuration/Tca/TcaMigration.php @@ -0,0 +1,1861 @@ +validateTcaType($tca); + + $tcaProcessingResult = new TcaProcessingResult($tca); + + $tcaProcessingResult = $this->migrateColumnsConfig($tcaProcessingResult); + $tcaProcessingResult = $this->migratePagesLanguageOverlayRemoval($tcaProcessingResult); + $tcaProcessingResult = $this->removeSelIconFieldPath($tcaProcessingResult); + $tcaProcessingResult = $this->removeSetToDefaultOnCopy($tcaProcessingResult); + $tcaProcessingResult = $this->removeEnableMultiSelectFilterTextfieldConfiguration($tcaProcessingResult); + $tcaProcessingResult = $this->removeExcludeFieldForTransOrigPointerField($tcaProcessingResult); + $tcaProcessingResult = $this->removeShowRecordFieldListField($tcaProcessingResult); + $tcaProcessingResult = $this->removeMaxDBListItems($tcaProcessingResult); + $tcaProcessingResult = $this->removeWorkspacePlaceholderShadowColumnsConfiguration($tcaProcessingResult); + $tcaProcessingResult = $this->migrateLanguageFieldToTcaTypeLanguage($tcaProcessingResult); + $tcaProcessingResult = $this->migrateSpecialLanguagesToTcaTypeLanguage($tcaProcessingResult); + $tcaProcessingResult = $this->removeShowRemovedLocalizationRecords($tcaProcessingResult); + $tcaProcessingResult = $this->migrateFileFolderConfiguration($tcaProcessingResult); + $tcaProcessingResult = $this->migrateLevelLinksPosition($tcaProcessingResult); + $tcaProcessingResult = $this->migrateRootUidToStartingPoints($tcaProcessingResult); + $tcaProcessingResult = $this->migrateInternalTypeFolderToTypeFolder($tcaProcessingResult); + $tcaProcessingResult = $this->migrateRequiredFlag($tcaProcessingResult); + $tcaProcessingResult = $this->migrateNullFlag($tcaProcessingResult); + $tcaProcessingResult = $this->migrateEmailFlagToEmailType($tcaProcessingResult); + $tcaProcessingResult = $this->migrateTypeNoneColsToSize($tcaProcessingResult); + $tcaProcessingResult = $this->migrateRenderTypeInputLinkToTypeLink($tcaProcessingResult); + $tcaProcessingResult = $this->migratePasswordAndSaltedPasswordToPasswordType($tcaProcessingResult); + $tcaProcessingResult = $this->migrateRenderTypeInputDateTimeToTypeDatetime($tcaProcessingResult); + $tcaProcessingResult = $this->removeAuthModeEnforce($tcaProcessingResult); + $tcaProcessingResult = $this->removeSelectAuthModeIndividualItemsKeyword($tcaProcessingResult); + $tcaProcessingResult = $this->migrateAuthMode($tcaProcessingResult); + $tcaProcessingResult = $this->migrateRenderTypeColorpickerToTypeColor($tcaProcessingResult); + $tcaProcessingResult = $this->migrateEvalIntAndDouble2ToTypeNumber($tcaProcessingResult); + $tcaProcessingResult = $this->removeAlwaysDescription($tcaProcessingResult); + $tcaProcessingResult = $this->migrateFalHandlingInInlineToTypeFile($tcaProcessingResult); + $tcaProcessingResult = $this->removeCtrlCruserId($tcaProcessingResult); + $tcaProcessingResult = $this->removeFalRelatedElementBrowserOptions($tcaProcessingResult); + $tcaProcessingResult = $this->removeFalRelatedOptionsFromTypeInline($tcaProcessingResult); + $tcaProcessingResult = $this->removePassContentFromTypeNone($tcaProcessingResult); + $tcaProcessingResult = $this->migrateItemsToAssociativeArray($tcaProcessingResult); + $tcaProcessingResult = $this->migrateItemsOfValuePickerToAssociativeArray($tcaProcessingResult); + $tcaProcessingResult = $this->removeMmInsertFields($tcaProcessingResult); + $tcaProcessingResult = $this->removeMmHasUidField($tcaProcessingResult); + $tcaProcessingResult = $this->migrateT3EditorToCodeEditor($tcaProcessingResult); + $tcaProcessingResult = $this->removeAllowLanguageSynchronizationFromColumnsOverrides($tcaProcessingResult); + $tcaProcessingResult = $this->removeSubTypesConfiguration($tcaProcessingResult); + $tcaProcessingResult = $this->addWorkspaceAwarenessToInlineChildren($tcaProcessingResult); + $tcaProcessingResult = $this->removeEvalYearFlag($tcaProcessingResult); + $tcaProcessingResult = $this->removeIsStaticControlOption($tcaProcessingResult); + $tcaProcessingResult = $this->removeFieldSearchConfigOptions($tcaProcessingResult); + $tcaProcessingResult = $this->removeSearchFieldsControlOption($tcaProcessingResult); + $tcaProcessingResult = $this->migrateSingleDataStructureConfiguration($tcaProcessingResult); + $tcaProcessingResult = $this->removeValuePickerMode($tcaProcessingResult); + $tcaProcessingResult = $this->migrateSysRedirectDefaultType($tcaProcessingResult); + + return $tcaProcessingResult; + } + + /** + * Check for required TCA configuration + */ + protected function validateTcaType(array $tca): void + { + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (isset($fieldConfig['config']) && is_array($fieldConfig['config']) && empty($fieldConfig['config']['type'])) { + throw new \UnexpectedValueException( + 'Missing "type" in TCA of field "[\'' . $table . '\'][\'' . $fieldName . '\'][\'config\']".', + 1482394401 + ); + } + } + } + } + + /** + * Find columns fields that don't have a 'config' section at all, add + * ['config']['type'] = 'none'; for those to enforce config + */ + protected function migrateColumnsConfig(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((!isset($fieldConfig['config']) || !is_array($fieldConfig['config'])) && !isset($fieldConfig['type'])) { + $fieldConfig['config'] = [ + 'type' => 'none', + ]; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('TCA table "' . $table . '" columns field "' . $fieldName . '"' + . ' had no mandatory "config" section. This has been added with default type "none":' + . ' TCA "' . $table . '[\'columns\'][\'' . $fieldName . '\'][\'config\'][\'type\'] = \'none\'"'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA['pages_language_overlay'] if defined. + */ + protected function migratePagesLanguageOverlayRemoval(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + if (isset($tca['pages_language_overlay'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'pages_language_overlay\' is' + . ' not used anymore and has been removed automatically in' + . ' order to avoid negative side-effects.'); + unset($tca['pages_language_overlay']); + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes configuration removeEnableMultiSelectFilterTextfield + */ + protected function removeEnableMultiSelectFilterTextfieldConfiguration(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (!isset($fieldConfig['config']['enableMultiSelectFilterTextfield'])) { + continue; + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA setting \'enableMultiSelectFilterTextfield\' is deprecated ' + . ' and should be removed from TCA for ' . $table . '[\'columns\']' + . '[\'' . $fieldName . '\'][\'config\'][\'enableMultiSelectFilterTextfield\']'); + unset($fieldConfig['config']['enableMultiSelectFilterTextfield']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable][ctrl][selicon_field_path] + */ + protected function removeSelIconFieldPath(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['ctrl']['selicon_field_path'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'' . $table . '\' defines ' + . '[ctrl][selicon_field_path] which should be removed from TCA, ' + . 'as it is not in use anymore.'); + unset($configuration['ctrl']['selicon_field_path']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable][ctrl][setToDefaultOnCopy] + */ + protected function removeSetToDefaultOnCopy(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['ctrl']['setToDefaultOnCopy'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'' . $table . '\' defines ' + . '[ctrl][setToDefaultOnCopy] which should be removed from TCA, ' + . 'as it is not in use anymore.'); + unset($configuration['ctrl']['setToDefaultOnCopy']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable][columns][_transOrigPointerField_][exclude] if defined + */ + protected function removeExcludeFieldForTransOrigPointerField(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['ctrl']['transOrigPointerField'], + $configuration['columns'][$configuration['ctrl']['transOrigPointerField']]['exclude']) + ) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The \'' . $table . '\' TCA tables transOrigPointerField ' + . '\'' . $configuration['ctrl']['transOrigPointerField'] . '\' is defined ' + . ' as excluded field which is no longer needed and should therefore be removed.'); + unset($configuration['columns'][$configuration['ctrl']['transOrigPointerField']]['exclude']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable]['interface']['showRecordFieldList'] and also $TCA[$mytable]['interface'] + * if `showRecordFieldList` was the only key in the array. + */ + protected function removeShowRecordFieldListField(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (!isset($configuration['interface']['showRecordFieldList'])) { + continue; + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The \'' . $table . '\' TCA configuration \'showRecordFieldList\'' + . ' inside the section \'interface\' is not evaluated anymore and should therefore be removed.'); + unset($configuration['interface']['showRecordFieldList']); + if ($configuration['interface'] === []) { + unset($configuration['interface']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable]['interface']['maxDBListItems'], and 'maxSingleDBListItems' and also $TCA[$mytable]['interface'] + * if `interface` is empty later-on. + */ + protected function removeMaxDBListItems(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['interface']['maxDBListItems'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The \'' . $table . '\' TCA configuration \'maxDBListItems\'' + . ' inside the section \'interface\' is not evaluated anymore and should therefore be removed.'); + unset($configuration['interface']['maxDBListItems']); + } + if (isset($configuration['interface']['maxSingleDBListItems'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The \'' . $table . '\' TCA configuration \'maxSingleDBListItems\'' + . ' inside the section \'interface\' is not evaluated anymore and should therefore be removed.'); + unset($configuration['interface']['maxSingleDBListItems']); + } + if (isset($configuration['interface']) && $configuration['interface'] === []) { + unset($configuration['interface']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable][ctrl][shadowColumnsForMovePlaceholders] + * and $TCA[$mytable][ctrl][shadowColumnsForNewPlaceholders] + */ + protected function removeWorkspacePlaceholderShadowColumnsConfiguration(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['ctrl']['shadowColumnsForNewPlaceholders'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'' . $table . '\' defines ' + . '[ctrl][shadowColumnsForNewPlaceholders] which should be removed from TCA, ' + . 'as it is not in use anymore.'); + unset($configuration['ctrl']['shadowColumnsForNewPlaceholders']); + } + if (isset($configuration['ctrl']['shadowColumnsForMovePlaceholders'])) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'' . $table . '\' defines ' + . '[ctrl][shadowColumnsForMovePlaceholders] which should be removed from TCA, ' + . 'as it is not in use anymore.'); + unset($configuration['ctrl']['shadowColumnsForMovePlaceholders']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Replaces $TCA[$mytable][columns][$TCA[$mytable][ctrl][languageField]][config] with + * $TCA[$mytable][columns][$TCA[$mytable][ctrl][languageField]][config][type] = 'language' + */ + protected function migrateLanguageFieldToTcaTypeLanguage(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (isset($configuration['ctrl']['languageField'], $configuration['columns'][$configuration['ctrl']['languageField']]) + && ($configuration['columns'][$configuration['ctrl']['languageField']]['config']['type'] ?? '') !== 'language' + ) { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $configuration['ctrl']['languageField'] . '\' ' + . 'of table \'' . $table . '\' is defined as the \'languageField\' and should ' + . 'therefore use the TCA type \'language\' instead of TCA type \'select\' with ' + . '\'foreign_table=sys_language\' or \'special=languages\'.'); + $configuration['columns'][$configuration['ctrl']['languageField']]['config'] = [ + 'type' => 'language', + ]; + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Replaces $TCA[$mytable][columns][field][config][special] = 'languages' with + * $TCA[$mytable][columns][field][config][type] = 'language' + */ + protected function migrateSpecialLanguagesToTcaTypeLanguage(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'select' + || (string)($fieldConfig['config']['special'] ?? '') !== 'languages' + ) { + continue; + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' is ' + . 'defined as type \'select\' with the \'special=languages\' option. This is not ' + . 'evaluated anymore and should be replaced by the TCA type \'language\'.'); + $fieldConfig['config'] = [ + 'type' => 'language', + ]; + } + } + return $tcaProcessingResult->withTca($tca); + } + + protected function removeShowRemovedLocalizationRecords(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'inline' + || !isset($fieldConfig['config']['appearance']['showRemovedLocalizationRecords']) + ) { + continue; + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' is ' + . 'defined as type \'inline\' with the \'appearance.showRemovedLocalizationRecords\' option set. ' + . 'As this option is not evaluated anymore and no replacement exists, it should be removed from TCA.'); + unset($fieldConfig['config']['appearance']['showRemovedLocalizationRecords']); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Moves the "fileFolder" configuration of TCA columns type=select + * into sub array "fileFolderConfig", while renaming those options. + */ + protected function migrateFileFolderConfiguration(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'select' + || !isset($fieldConfig['config']['fileFolder']) + ) { + continue; + } + $fieldConfig['config']['fileFolderConfig'] = [ + 'folder' => $fieldConfig['config']['fileFolder'], + ]; + unset($fieldConfig['config']['fileFolder']); + if (isset($fieldConfig['config']['fileFolder_extList'])) { + $fieldConfig['config']['fileFolderConfig']['allowedExtensions'] = $fieldConfig['config']['fileFolder_extList']; + unset($fieldConfig['config']['fileFolder_extList']); + } + if (isset($fieldConfig['config']['fileFolder_recursions'])) { + $fieldConfig['config']['fileFolderConfig']['depth'] = $fieldConfig['config']['fileFolder_recursions']; + unset($fieldConfig['config']['fileFolder_recursions']); + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' is ' + . 'defined as type \'select\' with the \'fileFolder\' configuration option set. To streamline ' + . 'the configuration, all \'fileFolder\' related configuration options were moved into a ' + . 'dedicated sub array \'fileFolderConfig\', while \'fileFolder\' is now just \'folder\' and ' + . 'the other options have been renamed to \'allowedExtensions\' and \'depth\'. ' + . 'The TCA configuration should be adjusted accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * The [appearance][levelLinksPosition] option can be used + * to select the position of the level links. This option + * was previously misused to disable all those links by + * setting it to "none". Since all of those links can be + * disabled by a dedicated option, e.g. showNewRecordLink, + * this wizard sets those options to false and unsets the + * invalid levelLinksPosition value. + */ + protected function migrateLevelLinksPosition(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((string)($fieldConfig['config']['type'] ?? '') !== 'inline' + || (string)($fieldConfig['config']['appearance']['levelLinksPosition'] ?? '') !== 'none' + ) { + continue; + } + // Unset levelLinksPosition and disable all level link buttons + unset($fieldConfig['config']['appearance']['levelLinksPosition']); + $fieldConfig['config']['appearance']['showAllLocalizationLink'] = false; + $fieldConfig['config']['appearance']['showSynchronizationLink'] = false; + $fieldConfig['config']['appearance']['showNewRecordLink'] = false; + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' sets ' + . '[appearance][levelLinksPosition] to "none", while only "top", "bottom" and "both" are supported. ' + . 'The TCA configuration should be adjusted accordingly. In case you want to disable all level links, ' + . 'use the corresponding level link specific options, e.g. [appearance][showNewRecordLink], instead.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * If a column has [treeConfig][rootUid] defined, migrate to [treeConfig][startingPoints] on the same level. + */ + protected function migrateRootUidToStartingPoints(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if ((int)($fieldConfig['config']['treeConfig']['rootUid'] ?? 0) === 0 + || !in_array((string)($fieldConfig['config']['type'] ?? ''), ['select', 'category'], true) + ) { + continue; + } + + $fieldConfig['config']['treeConfig']['startingPoints'] = (string)(int)$fieldConfig['config']['treeConfig']['rootUid']; + unset($fieldConfig['config']['treeConfig']['rootUid']); + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' sets ' + . '[treeConfig][rootUid], which is superseded by [treeConfig][startingPoints].' + . 'The TCA configuration should be adjusted accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][internal_type] = 'folder' to [config][type] = 'folder'. + * Also removes [config][internal_type] completely, if present. + */ + protected function migrateInternalTypeFolderToTypeFolder(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'group' || !isset($fieldConfig['config']['internal_type'])) { + continue; + } + unset($tca[$table]['columns'][$fieldName]['config']['internal_type']); + + if ($fieldConfig['config']['internal_type'] === 'folder') { + $tca[$table]['columns'][$fieldName]['config']['type'] = 'folder'; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' has been migrated to ' + . 'the TCA type \'folder\'. Please adjust your TCA accordingly.'); + } else { + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The property \'internal_type\' of the TCA field \'' . $fieldName . '\' of table \'' + . $table . '\' is obsolete and has been removed. You can remove it from your TCA as it is not evaluated anymore.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][eval] = 'required' to [config][required] = true and removes 'required' from [config][eval]. + * If [config][eval] becomes empty, it will be removed completely. + */ + protected function migrateRequiredFlag(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (!GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'required')) { + continue; + } + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + // Remove "required" from $evalList + $evalList = array_filter($evalList, static function (string $eval) { + return $eval !== 'required'; + }); + if ($evalList !== []) { + // Write back filtered 'eval' + $tca[$table]['columns'][$fieldName]['config']['eval'] = implode(',', $evalList); + } else { + // 'eval' is empty, remove whole configuration + unset($tca[$table]['columns'][$fieldName]['config']['eval']); + } + + $tca[$table]['columns'][$fieldName]['config']['required'] = true; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . '"required" in its "eval" list. This is not evaluated anymore and should be replaced ' + . ' by `\'required\' => true`.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][eval] = 'null' to [config][nullable] = true and removes 'null' from [config][eval]. + * If [config][eval] becomes empty, it will be removed completely. + */ + protected function migrateNullFlag(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (!GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'null')) { + continue; + } + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + // Remove "null" from $evalList + $evalList = array_filter($evalList, static function (string $eval) { + return $eval !== 'null'; + }); + if ($evalList !== []) { + // Write back filtered 'eval' + $tca[$table]['columns'][$fieldName]['config']['eval'] = implode(',', $evalList); + } else { + // 'eval' is empty, remove whole configuration + unset($tca[$table]['columns'][$fieldName]['config']['eval']); + } + + $tca[$table]['columns'][$fieldName]['config']['nullable'] = true; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . '"null" in its "eval" list. This is not evaluated anymore and should be replaced ' + . ' by `\'nullable\' => true`.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][eval] = 'email' to [config][type] = 'email' and removes 'email' from [config][eval]. + * If [config][eval] contains 'trim', it will also be removed. If [config][eval] becomes empty, the option + * will be removed completely. + */ + protected function migrateEmailFlagToEmailType(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'input' + || !GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'email') + ) { + // Early return in case column is not of type=input or does not define eval=email + continue; + } + + // Set the TCA type to "email" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'email'; + + // Unset "max" + unset($tca[$table]['columns'][$fieldName]['config']['max']); + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + $evalList = array_filter($evalList, static function (string $eval) { + // Remove anything except "unique" and "uniqueInPid" from eval + return in_array($eval, ['unique', 'uniqueInPid'], true); + }); + + if ($evalList !== []) { + // Write back filtered 'eval' + $tca[$table]['columns'][$fieldName]['config']['eval'] = implode(',', $evalList); + } else { + // 'eval' is empty, remove whole configuration + unset($tca[$table]['columns'][$fieldName]['config']['eval']); + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . '"email" in its "eval" list. The field has therefore been migrated to the TCA type \'email\'. ' + . 'Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates type => "none" [config][cols] to [config][size] and removes "cols". + */ + protected function migrateTypeNoneColsToSize(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'none' || !array_key_exists('cols', $fieldConfig['config'])) { + continue; + } + + $tca[$table]['columns'][$fieldName]['config']['size'] = $fieldConfig['config']['cols']; + unset($tca[$table]['columns'][$fieldName]['config']['cols']); + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . '"cols" in its config. This value has been migrated to the option "size". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][renderType] = 'inputLink' to [config][type] = 'link'. + * Migrates the [config][fieldConfig][linkPopup] to type specific configuration. + * Removes option [config][eval]. + * Removes option [config][max], if set. + * Removes option [config][softref], if set to "typolink". + */ + protected function migrateRenderTypeInputLinkToTypeLink(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'input' + || ($fieldConfig['config']['renderType'] ?? '') !== 'inputLink' + ) { + // Early return in case column is not of type=input with renderType=inputLink + continue; + } + + // Set the TCA type to "link" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'link'; + + // Unset "renderType", "max" and "eval" + unset( + $tca[$table]['columns'][$fieldName]['config']['max'], + $tca[$table]['columns'][$fieldName]['config']['renderType'], + $tca[$table]['columns'][$fieldName]['config']['eval'], + ); + + // Unset "softref" if set to "typolink" + if (($fieldConfig['config']['softref'] ?? '') === 'typolink') { + unset($tca[$table]['columns'][$fieldName]['config']['softref']); + } + + // Migrate the linkPopup configuration + if (is_array($fieldConfig['config']['fieldControl']['linkPopup'] ?? false)) { + $linkPopupConfig = $fieldConfig['config']['fieldControl']['linkPopup']; + if ($linkPopupConfig['options']['blindLinkOptions'] ?? false) { + $availableTypes = $GLOBALS['TYPO3_CONF_VARS']['SYS']['linkHandler'] ?? []; + if ($availableTypes !== []) { + $availableTypes = array_keys($availableTypes); + } else { + // Fallback to a static list, in case linkHandler configuration is not available at this point + $availableTypes = ['page', 'file', 'folder', 'url', 'email', 'record', 'telephone']; + } + $tca[$table]['columns'][$fieldName]['config']['allowedTypes'] = array_values(array_diff( + $availableTypes, + GeneralUtility::trimExplode(',', str_replace('mail', 'email', (string)$linkPopupConfig['options']['blindLinkOptions']), true) + )); + } + if ($linkPopupConfig['disabled'] ?? false) { + $tca[$table]['columns'][$fieldName]['config']['appearance']['enableBrowser'] = false; + } + if ($linkPopupConfig['options']['title'] ?? false) { + $tca[$table]['columns'][$fieldName]['config']['appearance']['browserTitle'] = (string)$linkPopupConfig['options']['title']; + } + if ($linkPopupConfig['options']['blindLinkFields'] ?? false) { + $tca[$table]['columns'][$fieldName]['config']['appearance']['allowedOptions'] = array_values(array_diff( + ['target', 'title', 'class', 'params', 'rel'], + GeneralUtility::trimExplode(',', (string)$linkPopupConfig['options']['blindLinkFields'], true) + )); + } + if ($linkPopupConfig['options']['allowedExtensions'] ?? false) { + $tca[$table]['columns'][$fieldName]['config']['appearance']['allowedFileExtensions'] = GeneralUtility::trimExplode( + ',', + (string)$linkPopupConfig['options']['allowedExtensions'], + true + ); + } + } + + // Unset ['fieldControl']['linkPopup'] - Note: We do this here to ensure + // also an invalid (e.g. not an array) field control configuration is removed. + unset($tca[$table]['columns'][$fieldName]['config']['fieldControl']['linkPopup']); + + // In case "linkPopup" has been the only configured fieldControl, unset ['fieldControl'], too. + if (empty($tca[$table]['columns'][$fieldName]['config']['fieldControl'])) { + unset($tca[$table]['columns'][$fieldName]['config']['fieldControl']); + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'renderType="inputLink". The field has therefore been migrated to the TCA type \'link\'. ' + . 'This includes corresponding configuration of the "linkPopup", as well as obsolete field ' + . 'configurations, such as "max" and "softref". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][eval] = 'password' and [config][eval] = 'saltedPassword' to [config][type] = 'password' + * Sets option "hashed" to FALSE if "saltedPassword" is not set for "password" + * Removes option [config][eval]. + * Removes option [config][max], if set. + * Removes option [config][search], if set. + */ + protected function migratePasswordAndSaltedPasswordToPasswordType(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'input' + || (!GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'password') + && !GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'saltedPassword')) + ) { + // Early return in case column is not of type=input or does not define eval=passowrd + continue; + } + + // Set the TCA type to "password" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'password'; + + // Unset "max", "search" and "eval" + unset( + $tca[$table]['columns'][$fieldName]['config']['max'], + $tca[$table]['columns'][$fieldName]['config']['search'], + $tca[$table]['columns'][$fieldName]['config']['eval'], + ); + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + + // Disable password hashing, if eval=password is used standalone + if (in_array('password', $evalList, true) && !in_array('saltedPassword', $evalList, true)) { + $tca[$table]['columns'][$fieldName]['config']['hashed'] = false; + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . '"password" or "saltedPassword" in its "eval" list. The field has therefore been migrated to ' + . 'the TCA type \'password\'. This also includes the removal of obsolete field configurations,' + . 'such as "max" and "search". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][renderType] = 'inputDateTime' to [config][type] = 'datetime'. + * Migrates "date", "time" and "timesec" from [config][eval] to [config][format]. + * Removes option [config][eval]. + * Removes option [config][max], if set. + * Removes option [config][format], if set. + * Removes option [config][default], if the default is the native "empty" value + */ + protected function migrateRenderTypeInputDateTimeToTypeDatetime(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'input' + || ($fieldConfig['config']['renderType'] ?? '') !== 'inputDateTime' + ) { + // Early return in case column is not of type=input with renderType=inputDateTime + continue; + } + + // Set the TCA type to "datetime" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'datetime'; + + // Unset "renderType", "max" and "eval" + // Note: Also unset "format". This option had been documented but was actually + // never used in the FormEngine element. This migration will set it according + // to the corresponding "eval" value. + unset( + $tca[$table]['columns'][$fieldName]['config']['max'], + $tca[$table]['columns'][$fieldName]['config']['renderType'], + $tca[$table]['columns'][$fieldName]['config']['format'], + $tca[$table]['columns'][$fieldName]['config']['eval'], + ); + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'] ?? '', true); + + // Set the "format" based on "eval". If set to "datetime", + // no migration is done since this is the default format. + if (in_array('date', $evalList, true)) { + $tca[$table]['columns'][$fieldName]['config']['format'] = 'date'; + } elseif (in_array('time', $evalList, true)) { + $tca[$table]['columns'][$fieldName]['config']['format'] = 'time'; + } elseif (in_array('timesec', $evalList, true)) { + $tca[$table]['columns'][$fieldName]['config']['format'] = 'timesec'; + } + + if (isset($fieldConfig['config']['default'])) { + if (in_array($fieldConfig['config']['dbType'] ?? '', QueryHelper::getDateTimeTypes(), true)) { + if ($fieldConfig['config']['default'] === QueryHelper::getDateTimeFormats()[$fieldConfig['config']['dbType']]['empty']) { + // Unset default for native datetime fields if the default is the native "empty" value + unset($tca[$table]['columns'][$fieldName]['config']['default']); + } + } elseif (!is_int($fieldConfig['config']['default'])) { + if ($fieldConfig['config']['default'] === '') { + // Always use int as default (string values are no longer supported for "datetime") + $tca[$table]['columns'][$fieldName]['config']['default'] = 0; + } elseif (MathUtility::canBeInterpretedAsInteger($fieldConfig['config']['default'])) { + // Cast default to int, in case it can be interpreted as integer + $tca[$table]['columns'][$fieldName]['config']['default'] = (int)$fieldConfig['config']['default']; + } else { + // Unset default in case it's a no longer supported string + unset($tca[$table]['columns'][$fieldName]['config']['default']); + } + } + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'renderType="inputDateTime". The field has therefore been migrated to the TCA type \'datetime\'. ' + . 'This includes corresponding migration of the "eval" list, as well as obsolete field ' + . 'configurations, such as "max". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][renderType] = 'colorpicker' to [config][type] = 'color'. + * Removes [config][eval]. + * Removes option [config][max], if set. + */ + protected function migrateRenderTypeColorpickerToTypeColor(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'input' + || ($fieldConfig['config']['renderType'] ?? '') !== 'colorpicker' + ) { + // Early return in case column is not of type=input with renderType=colorpicker + continue; + } + + // Set the TCA type to "color" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'color'; + + // Unset "renderType", "max" and "eval" + unset( + $tca[$table]['columns'][$fieldName]['config']['max'], + $tca[$table]['columns'][$fieldName]['config']['renderType'], + $tca[$table]['columns'][$fieldName]['config']['eval'], + ); + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'renderType="colorpicker". The field has therefore been migrated to the TCA type \'color\'. ' + . 'This includes corresponding migration of the "eval" list, as well as obsolete field ' + . 'configurations, such as "max". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Remove ['columns'][aField]['config']['authMode_enforce'] + */ + protected function removeAuthModeEnforce(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (array_key_exists('authMode_enforce', $fieldConfig['config'] ?? [])) { + unset($tca[$table]['columns'][$fieldName]['config']['authMode_enforce']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . '\'authMode_enforce\'. This config key is obsolete and has been removed.' + . ' Please adjust your TCA accordingly.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * If a column has authMode=individual and items with the corresponding key on position 5 + * defined, or if EXPL_ALLOW or EXPL_DENY is set for position 6, migrate or remove them. + */ + protected function removeSelectAuthModeIndividualItemsKeyword(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'select' || ($fieldConfig['config']['authMode'] ?? '') !== 'individual') { + continue; + } + foreach ($fieldConfig['config']['items'] ?? [] as $index => $item) { + if (in_array($item[4] ?? '', ['EXPL_ALLOW', 'EXPL_DENY'], true)) { + $tca[$table]['columns'][$fieldName]['config']['items'][$index][4] = ''; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' sets ' . $item[4] + . ' at position 5 of the items array. This was used in combination with \'authMode=individual\' and' + . ' is obsolete since \'individual\' is no longer supported.'); + } + if (isset($item[5])) { + unset($tca[$table]['columns'][$fieldName]['config']['items'][$index][5]); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' sets ' . $item[5] + . ' at position 6 of the items array. This was used in combination with \'authMode=individual\' and' + . ' is obsolete since \'individual\' is no longer supported.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * See if ['columns'][aField]['config']['authMode'] is not set to 'explicitAllow' and + * set it to this value if needed. + */ + protected function migrateAuthMode(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (array_key_exists('authMode', $fieldConfig['config'] ?? []) + && $fieldConfig['config']['authMode'] !== 'explicitAllow' + ) { + $tca[$table]['columns'][$fieldName]['config']['authMode'] = 'explicitAllow'; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' sets ' + . '\'authMode\' to \'' . $fieldConfig['config']['authMode'] . '\'. The only allowed value is \'explicitAllow\',' + . ' and that value has been set now. Please adjust your TCA accordingly. Note this has impact on' + . ' backend group access rights, these should be reviewed and new access right for this field should' + . ' be set. An upgrade wizard partially migrates this and reports be_groups rows that need manual attention.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates [config][eval] = 'int' and [config][eval] = 'double2' to [config][type] = 'number'. + * The migration only applies to fields without a renderType defined. + * Adds [config][format] = "decimal" if [config][eval] = double2 + * Removes [config][eval]. + * Removes option [config][max], if set. + */ + protected function migrateEvalIntAndDouble2ToTypeNumber(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + // Return early, if not TCA type "input" or a renderType is set + // or neither eval=int nor eval=double2 are set. + if ( + ($fieldConfig['config']['type'] ?? '') !== 'input' + || ($fieldConfig['config']['renderType'] ?? '') !== '' + || ( + !GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'int') + && !GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'double2') + ) + ) { + continue; + } + + // Set the TCA type to "number" + $tca[$table]['columns'][$fieldName]['config']['type'] = 'number'; + + // Unset "max" and "eval" + unset( + $tca[$table]['columns'][$fieldName]['config']['max'], + $tca[$table]['columns'][$fieldName]['config']['eval'], + ); + + $numberType = ''; + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + + // Convert eval "double2" to format = "decimal" and store the "number type" for the deprecation log + if (in_array('double2', $evalList, true)) { + $numberType = 'double2'; + $tca[$table]['columns'][$fieldName]['config']['format'] = 'decimal'; + } elseif (in_array('int', $evalList, true)) { + $numberType = 'int'; + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' in table \'' . $table . '\'" defines ' + . 'eval="' . $numberType . '". The field has therefore been migrated to the TCA type \'number\'. ' + . 'This includes corresponding migration of the "eval" list, as well as obsolete field ' + . 'configurations, such as "max". Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes ['interface']['always_description'] and also ['interface'] + * if `always_description` was the only key in the array. + */ + protected function removeAlwaysDescription(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['interface']['always_description'])) { + continue; + } + unset($tableDefinition['interface']['always_description']); + if ($tableDefinition['interface'] === []) { + unset($tableDefinition['interface']); + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA property [\'interface\'][\'always_description\'] of table \'' . $table + . '\' is not evaluated anymore and has therefore been removed. Please adjust your TCA accordingly.'); + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Remove ['ctrl']['cruser_id']. + */ + protected function removeCtrlCruserId(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['ctrl']['cruser_id'])) { + continue; + } + unset($tableDefinition['ctrl']['cruser_id']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA property [\'ctrl\'][\'cruser_id\'] of table \'' . $table + . '\' is not evaluated anymore and has therefore been removed. Please adjust your TCA accordingly.'); + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates type='inline' with foreign_table='sys_file_reference' to type='file'. + * Removes table relation related options. + * Removes no longer available appearance options. + * Detects usage of "customControls" hook. + * Migrates renamed appearance options. + * Migrates allowed file extensions. + */ + protected function migrateFalHandlingInInlineToTypeFile(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'inline' + || ($fieldConfig['config']['foreign_table'] ?? '') !== 'sys_file_reference' + ) { + // Early return in case column is not of type=inline with foreign_table=sys_file_reference + continue; + } + + // Place to add additional information, which will later be appended to the deprecation message + $additionalInformation = ''; + + // Set the TCA type to "file" + $fieldConfig['config']['type'] = 'file'; + + // Remove table relation related options, since they are + // either not needed anymore or set by TcaPreperation automatically. + unset( + $fieldConfig['config']['foreign_table'], + $fieldConfig['config']['foreign_field'], + $fieldConfig['config']['foreign_sortby'], + $fieldConfig['config']['foreign_table_field'], + $fieldConfig['config']['foreign_label'], + $fieldConfig['config']['foreign_selector'], + $fieldConfig['config']['foreign_unique'], + ); + + // "new" control is not supported for this type so remove it altogether for cleaner TCA + unset($fieldConfig['config']['appearance']['enabledControls']['new']); + + // [appearance][headerThumbnail][field] is not needed anymore + unset($fieldConfig['config']['appearance']['headerThumbnail']['field']); + + // A couple of further appearance options are not supported by type "file", unset them as well + unset( + $fieldConfig['config']['appearance']['showNewRecordLink'], + $fieldConfig['config']['appearance']['newRecordLinkAddTitle'], + $fieldConfig['config']['appearance']['newRecordLinkTitle'], + $fieldConfig['config']['appearance']['levelLinksPosition'], + $fieldConfig['config']['appearance']['useCombination'], + $fieldConfig['config']['appearance']['suppressCombinationWarning'] + ); + + // Migrate [appearance][showPossibleRecordsSelector] to [appearance][showFileSelectors] + if (isset($fieldConfig['config']['appearance']['showPossibleRecordsSelector'])) { + $fieldConfig['config']['appearance']['showFileSelectors'] = $fieldConfig['config']['appearance']['showPossibleRecordsSelector']; + unset($fieldConfig['config']['appearance']['showPossibleRecordsSelector']); + } + + // "customControls" hook has been replaced by the CustomFileControlsEvent + if (isset($fieldConfig['config']['customControls'])) { + $additionalInformation .= ' The \'customControls\' option is not evaluated anymore and has ' + . 'to be replaced with the PSR-14 \'CustomFileControlsEvent\'.'; + unset($fieldConfig['config']['customControls']); + } + + // Migrate element browser related settings + if (!empty($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance'])) { + if (!empty($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance']['elementBrowserAllowed'])) { + // Migrate "allowed" file extensions from appearance + $fieldConfig['config']['allowed'] = $fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance']['elementBrowserAllowed']; + } + unset( + $fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance']['elementBrowserType'], + $fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance']['elementBrowserAllowed'] + ); + if (empty($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance'])) { + unset($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']['appearance']); + if (empty($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config'])) { + unset($fieldConfig['config']['overrideChildTca']['columns']['uid_local']['config']); + if (empty($fieldConfig['config']['overrideChildTca']['columns']['uid_local'])) { + unset($fieldConfig['config']['overrideChildTca']['columns']['uid_local']); + if (empty($fieldConfig['config']['overrideChildTca']['columns'])) { + unset($fieldConfig['config']['overrideChildTca']['columns']); + if (empty($fieldConfig['config']['overrideChildTca'])) { + unset($fieldConfig['config']['overrideChildTca']); + } + } + } + } + } + } + + // Migrate file extension filter + if (!empty($fieldConfig['config']['filter'])) { + foreach ($fieldConfig['config']['filter'] as $key => $filter) { + if (($filter['userFunc'] ?? '') === (FileExtensionFilter::class . '->filterInlineChildren')) { + $allowedFileExtensions = (string)($filter['parameters']['allowedFileExtensions'] ?? ''); + // Note: Allowed file extensions in the filter take precedence over possible + // extensions defined for the element browser. This is due to filters are evaluated + // by the DataHandler while element browser is only applied in FormEngine UI. + if ($allowedFileExtensions !== '') { + $fieldConfig['config']['allowed'] = $allowedFileExtensions; + } + $disallowedFileExtensions = (string)($filter['parameters']['disallowedFileExtensions'] ?? ''); + if ($disallowedFileExtensions !== '') { + $fieldConfig['config']['disallowed'] = $disallowedFileExtensions; + } + unset($fieldConfig['config']['filter'][$key]); + } + } + // Remove filter if it got empty + if (empty($fieldConfig['config']['filter'])) { + unset($fieldConfig['config']['filter']); + } + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'type="inline" with foreign_table=sys_file_reference. The field has therefore been ' + . 'migrated to the dedicated TCA type \'file\'' . $additionalInformation . ' ' + . 'Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes the [appearance][elementBrowserType] and [appearance][elementBrowserAllowed] + * options from TCA type "group" fields. + */ + protected function removeFalRelatedElementBrowserOptions(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'group' + || ( + !isset($fieldConfig['config']['appearance']['elementBrowserType']) + && !isset($fieldConfig['config']['appearance']['elementBrowserAllowed']) + ) + ) { + // Early return in case column is not of type=group or does not define the options in question + continue; + } + + unset( + $fieldConfig['config']['appearance']['elementBrowserType'], + $fieldConfig['config']['appearance']['elementBrowserAllowed'] + ); + + // Also unset "appearance" if empty + if (empty($fieldConfig['config']['appearance'])) { + unset($fieldConfig['config']['appearance']); + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'fal related element browser options, which are no longer needed and therefore removed. ' + . 'Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes the following options from TCA type "inline" fields: + * - [appearance][headerThumbnail] + * - [appearance][fileUploadAllowed] + * - [appearance][fileByUrlAllowed] + */ + protected function removeFalRelatedOptionsFromTypeInline(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'inline' + || ( + !isset($fieldConfig['config']['appearance']['headerThumbnail']) + && !isset($fieldConfig['config']['appearance']['fileUploadAllowed']) + && !isset($fieldConfig['config']['appearance']['fileByUrlAllowed']) + ) + ) { + // Early return in case column is not of type=inline or does not define the options in question + continue; + } + + unset( + $fieldConfig['config']['appearance']['headerThumbnail'], + $fieldConfig['config']['appearance']['fileUploadAllowed'], + $fieldConfig['config']['appearance']['fileByUrlAllowed'] + ); + + // Also unset "appearance" if empty + if (empty($fieldConfig['config']['appearance'])) { + unset($fieldConfig['config']['appearance']); + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'fal related appearance options, which are no longer evaluated and therefore removed. ' + . 'Please adjust your TCA accordingly.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes ['config']['pass_content'] from TCA type "none" fields + */ + protected function removePassContentFromTypeNone(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') === 'none' + && array_key_exists('pass_content', $fieldConfig['config'] ?? []) + ) { + unset($tca[$table]['columns'][$fieldName]['config']['pass_content']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . '\'pass_content\'. This config key is obsolete and has been removed. ' + . 'Please adjust your TCA accordingly.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Converts the item list of type "select", "radio" and "check" to an associated array. + * + * // From: + * [ + * 0 => 'A label', + * 1 => 'value', + * 2 => 'icon-identifier', + * 3 => 'group1', + * 4 => 'a custom description' + * ] + * + * // To: + * [ + * 'label' => 'A label', + * 'value' => 'value', + * 'icon' => 'icon-identifier', + * 'group' => 'group1', + * 'description' => 'a custom description' + * ] + */ + protected function migrateItemsToAssociativeArray(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if ( + array_key_exists('items', $fieldConfig['config'] ?? []) + && in_array(($fieldConfig['config']['type'] ?? ''), ['select', 'radio', 'check'], true) + ) { + $hasLegacyItemConfiguration = false; + $items = $fieldConfig['config']['items']; + if (is_string($items)) { + continue; + } + foreach ($items as $key => $item) { + if (!is_array($item)) { + continue; + } + if (array_key_exists(0, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['label'] = $item[0]; + unset($items[$key][0]); + } + if (($fieldConfig['config']['type'] !== 'check') && array_key_exists(1, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['value'] = $item[1]; + unset($items[$key][1]); + } + if ($fieldConfig['config']['type'] === 'select') { + if (array_key_exists(2, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['icon'] = $item[2]; + unset($items[$key][2]); + } + if (array_key_exists(3, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['group'] = $item[3]; + unset($items[$key][3]); + } + if (array_key_exists(4, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['description'] = $item[4]; + unset($items[$key][4]); + } + } + } + if ($hasLegacyItemConfiguration) { + $tca[$table]['columns'][$fieldName]['config']['items'] = $items; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . 'the legacy way of defining \'items\'. Please switch to associated array keys: ' + . 'label, value, icon, group, description.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Converts the item list of valuePicker to an associated array. + * + * // From: + * [ + * 0 => 'A label', + * 1 => 'value', + * ] + * + * // To: + * [ + * 'label' => 'A label', + * 'value' => 'value', + * ] + */ + protected function migrateItemsOfValuePickerToAssociativeArray(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (is_array($fieldConfig['config']['valuePicker']['items'] ?? false)) { + $hasLegacyItemConfiguration = false; + $items = $fieldConfig['config']['valuePicker']['items']; + foreach ($items as $key => $item) { + if (!is_array($item)) { + continue; + } + if (array_key_exists(0, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['label'] = $item[0]; + unset($items[$key][0]); + } + if (array_key_exists(1, $item)) { + $hasLegacyItemConfiguration = true; + $items[$key]['value'] = $item[1]; + unset($items[$key][1]); + } + } + if ($hasLegacyItemConfiguration) { + $tca[$table]['columns'][$fieldName]['config']['valuePicker']['items'] = $items; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . 'the legacy way of defining \'items\' for the \'valuePicker\'. Please switch to associated array keys: ' + . 'label, value.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + protected function removeMmInsertFields(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (isset($fieldConfig['config']['MM_insert_fields'])) { + unset($tca[$table]['columns'][$fieldName]['config']['MM_insert_fields']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . '\'MM_insert_fields\'. This config key is obsolete and should be removed. ' + . 'Please adjust your TCA accordingly.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + protected function removeMmHasUidField(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (isset($fieldConfig['config']['MM_hasUidField'])) { + unset($tca[$table]['columns'][$fieldName]['config']['MM_hasUidField']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . '\'MM_hasUidField\'. This config key is obsolete and should be removed. ' + . 'Please adjust your TCA accordingly.'); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + protected function migrateT3EditorToCodeEditor(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['renderType'] ?? '') === 't3editor') { + $tca[$table]['columns'][$fieldName]['config']['renderType'] = 'codeEditor'; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' uses ' + . '\'renderType\' with the value \'t3editor\', which has been migrated to \'codeEditor\'. ' + . 'Please adjust your TCA accordingly.'); + } + } + + foreach ($tableDefinition['types'] ?? [] as $typeName => $typeConfig) { + foreach ($typeConfig['columnsOverrides'] ?? [] as $columnOverride => $columnOverrideConfig) { + if (($columnOverrideConfig['config']['renderType'] ?? '') === 't3editor') { + $tca[$table]['types'][$typeName]['columnsOverrides'][$columnOverride]['config']['renderType'] = 'codeEditor'; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA column override \'' . $columnOverride . '\' of table \'' . $table . '\' uses ' + . '\'renderType\' with the value \'t3editor\', which has been migrated to \'codeEditor\'. ' + . 'Please adjust your TCA accordingly.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Setting "allowLanguageSynchronization" for columns via columnsOverride is currently not supported + * see Localization\State and therefore leads to an exception in the LocalizationStateSelector wizard. + * Therefore, the setting is removed for now and the integrator is informed accordingly. + */ + protected function removeAllowLanguageSynchronizationFromColumnsOverrides(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!is_array($tableDefinition['types'] ?? false)) { + continue; + } + foreach ($tableDefinition['types'] ?? [] as $typeName => $typeConfig) { + foreach ($typeConfig['columnsOverrides'] ?? [] as $columnOverride => $columnOverrideConfig) { + if (isset($columnOverrideConfig['config']['behaviour']['allowLanguageSynchronization'])) { + unset($tca[$table]['types'][$typeName]['columnsOverrides'][$columnOverride]['config']['behaviour']['allowLanguageSynchronization']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA columns override of column \'' . $columnOverride . '\' for type \'' . $typeName . '\' ' + . 'of table \'' . $table . '\' sets \'[behaviour][allowLanguageSynchronization]\'. Setting ' + . 'this option in \'columnsOverrides\' is currently not supported. Please adjust your TCA accordingly.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes the following sub types configuration options: + * + * - subtype_value_field + * - subtypes_addlist + * - subtypes_excludelist + */ + protected function removeSubTypesConfiguration(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!is_array($tableDefinition['types'] ?? false)) { + continue; + } + foreach ($tableDefinition['types'] ?? [] as $typeName => $typeConfig) { + if (!isset($typeConfig['subtype_value_field']) + && !isset($typeConfig['subtypes_addlist']) + && !isset($typeConfig['subtypes_excludelist']) + ) { + continue; + } + unset( + $tca[$table]['types'][$typeName]['subtype_value_field'], + $tca[$table]['types'][$typeName]['subtypes_addlist'], + $tca[$table]['types'][$typeName]['subtypes_excludelist'], + ); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA record type \'' . $typeName . '\' of table \'' . $table . '\' makes ' + . 'use of the removed "sub types" functionality. The options \'subtype_value_field\', ' + . '\'subtypes_addlist\' and \'subtypes_excludelist\' are not evaluated anymore. Please adjust your ' + . 'TCA accordingly by migrating those sub types to dedicated record types.'); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Inline foreign_table relations with a parent being workspace aware and + * a child not being workspace aware are not supported. The method detects + * this scenario in parent columns (not in flex forms) and enforces workspace + * awareness of child tables. + */ + protected function addWorkspaceAwarenessToInlineChildren(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $parentTable => $parentTableDefinition) { + if (!($parentTableDefinition['ctrl']['versioningWS'] ?? false) + || !is_array($parentTableDefinition['columns'] ?? null) + ) { + continue; + } + foreach ($parentTableDefinition['columns'] as $parentFieldName => $parentFieldConfig) { + if (($parentFieldConfig['config']['type'] ?? '') === 'inline') { + if (empty($parentFieldConfig['config']['foreign_table'] ?? '')) { + continue; + } + $foreignTable = $parentFieldConfig['config']['foreign_table']; + if ((bool)($tca[$foreignTable]['ctrl']['versioningWS'] ?? false) === false) { + $tca[$foreignTable]['ctrl']['versioningWS'] = true; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA table \'' . $foreignTable . '\' has been declared workspace aware because it is' + . ' used as an inline child in TCA table field \'' . $parentTable . '\':\'' . $parentFieldName . '\',' + . ' and that table is workspace aware. Please adjust your TCA accordingly by adding' + . ' "\'versioningWS\' => true;" to the \'ctrl\' section of \'' . $foreignTable . '\'.'); + } + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes [config][eval] = 'year'. + * If [config][eval] becomes empty, it will be removed completely. + */ + protected function removeEvalYearFlag(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (!GeneralUtility::inList($fieldConfig['config']['eval'] ?? '', 'year')) { + continue; + } + + $evalList = GeneralUtility::trimExplode(',', $fieldConfig['config']['eval'], true); + // Remove "year" from $evalList + $evalList = array_filter($evalList, static fn(string $eval): bool => $eval !== 'year'); + if ($evalList !== []) { + // Write back filtered 'eval' + $tca[$table]['columns'][$fieldName]['config']['eval'] = implode(',', $evalList); + } else { + // 'eval' is empty, remove whole configuration + unset($tca[$table]['columns'][$fieldName]['config']['eval']); + } + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines' + . ' "year" in its "eval" list. This is not evaluated anymore and is therefore removed.' + . ' Please adjust your TCA accordingly.'); + } + } + + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable]['ctrl']['is_static'] + */ + protected function removeIsStaticControlOption(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (!isset($configuration['ctrl']['is_static'])) { + continue; + } + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The \'' . $table . '\' TCA configuration \'is_static\'' + . ' inside the \'ctrl\' section is not evaluated anymore and is therefore removed.' + . ' Please adjust your TCA accordingly.'); + unset($configuration['ctrl']['is_static']); + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $[config][search] + */ + protected function removeFieldSearchConfigOptions(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (!isset($fieldConfig['config']['search'])) { + continue; + } + unset($fieldConfig['config']['search']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages( + 'The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines' + . ' "search" config options. Those are not evaluated anymore and are therefore removed.' + . ' Please adjust your TCA accordingly.' + ); + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes $TCA[$mytable]['ctrl']['searchFields'] + */ + protected function removeSearchFieldsControlOption(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => &$configuration) { + if (!isset($configuration['ctrl']['searchFields'])) { + continue; + } + $searchFields = GeneralUtility::trimExplode(',', (string)$configuration['ctrl']['searchFields'], true); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages( + 'The \'' . $table . '\' TCA configuration \'searchFields\'' + . ' inside the \'ctrl\' section is not evaluated anymore and is therefore removed.' + . ' Suitable field types, e.g. \'input\' are now automatically considered, while using the' + . ' \'searchable\' field config for specific exclusion can be used. Please adjust your TCA accordingly.' + ); + unset($configuration['ctrl']['searchFields']); + + if (!isset($configuration['columns']) || !is_array($configuration['columns'])) { + continue; + } + foreach ($configuration['columns'] as $fieldName => &$fieldConfig) { + $type = (string)($fieldConfig['config']['type'] ?? ''); + if ($type !== '' + && !isset($fieldConfig['config']['searchable']) + && !in_array($fieldName, $searchFields, true) + && ( + in_array($type, ['color', 'email', 'flex', 'input', 'json', 'link', 'slug', 'text', 'uuid'], true) + || ($type === 'datetime' && !in_array($fieldConfig['config']['dbType'] ?? null, QueryHelper::getDateTimeTypes(), true)) + ) + ) { + $fieldConfig['config']['searchable'] = false; + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages( + 'Because the field \'' . $fieldName . '\' of \'' . $table . '\' is considered' + . ' searchable based on it\'s TCA type \'' . $type . '\' but is not included in still existing' + . ' but no longer evaluated \'searchFields\' TCA \'ctrl\' option, it was automatically set to' + . ' searchable => false, to be excluded in searches. Please consider this when adjusting your' + . ' TCA towards proper usage of searchable fields.' + ); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + protected function migrateSingleDataStructureConfiguration(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? false)) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'flex' + || !isset($fieldConfig['config']['ds']) + || is_string($fieldConfig['config']['ds']) + ) { + continue; + } + + $dataStructureConfiguration = $fieldConfig['config']['ds']; + if (is_array($dataStructureConfiguration) && count($dataStructureConfiguration) === 1) { + $dataStructureKey = key($dataStructureConfiguration); + $tca[$table]['columns'][$fieldName]['config']['ds'] = reset($dataStructureConfiguration); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages( + 'The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines ' + . 'a data structure below key \'' . $dataStructureKey . '\' in \'ds\'. Since \'ds\' does ' + . 'now contain the data strcuture directly, the corresponding configuration has been migrated. ' + . 'Please adjust your TCA accordingly.' + ); + } + } + } + return $tcaProcessingResult->withTca($tca); + } + + /** + * Removes [config][valuePicker][mode] + */ + protected function removeValuePickerMode(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + foreach ($tca as $table => $tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + + foreach ($tableDefinition['columns'] as $fieldName => $fieldConfig) { + if (!isset($fieldConfig['config']['valuePicker']['mode'])) { + continue; + } + + unset($tca[$table]['columns'][$fieldName]['config']['valuePicker']['mode']); + + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages('The TCA field \'' . $fieldName . '\' of table \'' . $table . '\' defines' + . ' a "mode" in its "valuePicker" configuration. This is not evaluated anymore and is therefore removed.' + . ' Please adjust your TCA accordingly.'); + } + } + + return $tcaProcessingResult->withTca($tca); + } + + /** + * Migrates $TCA['sys_redirect']['types']['1'] to $TCA['sys_redirect']['types']['default'] + */ + protected function migrateSysRedirectDefaultType(TcaProcessingResult $tcaProcessingResult): TcaProcessingResult + { + $tca = $tcaProcessingResult->getTca(); + if (isset($tca['sys_redirect']['types']['1'])) { + // Override each key from '1' into 'default' + foreach ($tca['sys_redirect']['types']['1'] as $key => $value) { + $tca['sys_redirect']['types']['default'][$key] = $value; + } + unset($tca['sys_redirect']['types']['1']); + $tcaProcessingResult = $tcaProcessingResult->withAdditionalMessages( + 'The TCA table \'sys_redirect\' used to define the default type as \'1\', which has been migrated to \'default\'. ' + . 'Please adjust your TCA accordingly by using $GLOBALS[\'TCA\'][\'sys_redirect\'][\'types\'][\'default\'] ' + . 'instead of $GLOBALS[\'TCA\'][\'sys_redirect\'][\'types\'][\'1\'].' + ); + } + + return $tcaProcessingResult->withTca($tca); + } +} diff --git a/Classes/Configuration/Tca/TcaPreparation.php b/Classes/Configuration/Tca/TcaPreparation.php new file mode 100644 index 0000000..9efa476 --- /dev/null +++ b/Classes/Configuration/Tca/TcaPreparation.php @@ -0,0 +1,624 @@ +configureCategoryRelations($tca, $isFlexForm); + $tca = $this->configureFileReferences($tca, $isFlexForm); + $tca = $this->configureEmailSoftReferences($tca); + $tca = $this->configureLinkSoftReferences($tca); + $tca = $this->configureSelectSingle($tca); + $tca = $this->configureRelationshipToOne($tca); + $tca = $this->addSystemFieldsToShowitemTypes($tca); + $tca = $this->addIgnoredPageTypeRestrictionRecords($tca); + return $tca; + } + + /** + * Prepares TCA configuration of type='category' fields. + * + * It adds some TCA config settings so category fields end up with similar + * config as type='select' field, but in a more restricted way. + * Some settings could also be set in TCA directly, but some fields + * can not be overridden, e.g. foreign_table. + * + * This also sets necessary MM properties, in case relationship is + * set to "manyToMany", which is the default. Note it is "oneToMany" + * with flex forms, since flex forms do NOT support "manyToMany". + * + * Finally, all category fields with a "manyToMany" relationship are + * added to the MM_oppositeUsage of sys_category "items". + * + * Important: Since this method defines a "foreign_table_where", this + * must always be executed before prepareQuotingOfTableNamesAndColumnNames(). + */ + protected function configureCategoryRelations(array $tca, bool $isFlexForm): array + { + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') !== 'category') { + continue; + } + if (!isset($fieldConfig['label'])) { + $fieldConfig['label'] = 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.categories'; + } + // Force foreign_table for type category + $fieldConfig['config']['foreign_table'] = 'sys_category'; + // Initialize default column configuration and merge it with already defined + $fieldConfig['config']['size'] ??= 20; + $defaultTag = \Local\Multilanguage\Service\DefaultLanguageTagService::getTag(); + $fieldConfig['config']['foreign_table_where'] ??= " AND {#sys_category}.{#language_tag} IN ('" . $defaultTag . "', '')"; + if (empty($fieldConfig['config']['relationship'])) { + // In case no relationship is given, set "manyToMany" for non flex form, but "oneToMany" with flex form. + $fieldConfig['config']['relationship'] = $isFlexForm ? 'oneToMany' : 'manyToMany'; + } + + // Sanitize 'relationship' + if ($isFlexForm && !in_array($fieldConfig['config']['relationship'], ['oneToOne', 'oneToMany'], true)) { + throw new \UnexpectedValueException( + '"relationship" must be one of "oneToOne" or "oneToMany", "manyToMany" is not supported as "relationship"' + . ' for field ' . $fieldName . ' of type "category" in flexform.', + 1627640208 + ); + } + if (!in_array($fieldConfig['config']['relationship'], ['oneToOne', 'oneToMany', 'manyToMany'], true)) { + throw new \RuntimeException( + $fieldName . ' of table ' . $table . ' is defined as type category with relationship "' + . $fieldConfig['config']['relationship'] . '", but only "oneToOne", "oneToMany" and "manyToMany"' + . ' are allowed.', + 1627898896 + ); + } + + // Set the maxitems value (necessary for DataHandling and FormEngine) + if ($fieldConfig['config']['relationship'] === 'oneToOne') { + // In case relationship is set to "oneToOne", the database column for this + // field will be an integer column. This means, only one uid can be stored. + // Therefore, maxitems must be 1. Sanitize for flex form fields as well. + if ((int)($fieldConfig['config']['maxitems'] ?? 0) > 1) { + throw new \RuntimeException( + $fieldName . ' of table ' . $table . ' is defined as type category with an oneToOne relationship. ' + . 'Therefore maxitems must be 1. Otherwise, use oneToMany or manyToMany as relationship instead.', + 1627335016 + ); + } + $fieldConfig['config']['maxitems'] = 1; + } elseif (!($fieldConfig['config']['maxitems'] ?? false)) { + // In case maxitems is not set or set to 0, set the default value "99999" + $fieldConfig['config']['maxitems'] = 99999; + } elseif ($fieldConfig['config']['relationship'] === 'oneToMany' + && (int)($fieldConfig['config']['maxitems'] ?? 0) === 1 + ) { + throw new \RuntimeException( + $fieldName . ' of table ' . $table . ' is defined as type category with a ' . $fieldConfig['config']['relationship'] + . ' relationship. Therefore, maxitems can not be set to 1. Use oneToOne as relationship instead.', + 1627335017 + ); + } + + // Add the default value if not set + if (!isset($fieldConfig['config']['default']) + && $fieldConfig['config']['relationship'] !== 'oneToMany' + ) { + // @todo: This is db wise not accurate: A oneToOne relation without a relation being assigned, + // @todo: should have NULL as value, not 0, since 0 looks like a uid, but it isn't. + // @todo: The field should be nullable and DH should handle that. + $fieldConfig['config']['default'] = 0; + } + + // Add MM related properties in case relationship is set to "manyToMany". + // This will not be done for the sys_category table itself. Not relevant with flex forms. + if ($fieldConfig['config']['relationship'] === 'manyToMany' && $table !== 'sys_category') { + // Note these settings are hard coded here and can't be overridden. + $fieldConfig['config'] = array_replace_recursive($fieldConfig['config'], [ + 'MM' => 'sys_category_record_mm', + 'MM_opposite_field' => 'items', + 'MM_match_fields' => [ + 'tablenames' => $table, + 'fieldname' => $fieldName, + ], + ]); + // Register opposite references for the foreign side of a category relation + if (empty($tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table])) { + $tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table] = []; + } + if (!in_array($fieldName, $tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table], true)) { + $tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table][] = $fieldName; + } + // Take specific value of exclude flag into account + if (!isset($fieldConfig['exclude'])) { + $fieldConfig['exclude'] = true; + } + } + } + } + return $tca; + } + + protected function configureFileReferences(array $tca, bool $isFlexForm): array + { + foreach ($tca as $table => &$tableDefinition) { + if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) { + continue; + } + foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? '') === 'file') { + // Set static values for this type. Most of them are not needed due to the + // dedicated TCA type. However a lot of underlying code in DataHandler and + // friends relies on those keys, especially "foreign_table" and "foreign_selector". + // @todo Check which of those values can be removed since only used by FormEngine + $fieldConfig['config'] = array_replace_recursive($fieldConfig['config'], [ + 'foreign_table' => 'sys_file_reference', + 'foreign_field' => 'uid_foreign', + 'foreign_sortby' => 'sorting_foreign', + 'foreign_table_field' => 'tablenames', + 'foreign_label' => 'uid_local', + 'foreign_selector' => 'uid_local', + ]); + if (!isset($fieldConfig['config']['foreign_match_fields']['fieldname'])) { + $fieldConfig['config']['foreign_match_fields']['fieldname'] = $fieldName; + } + if (!$isFlexForm) { + $fieldConfig['config']['foreign_match_fields']['tablenames'] = $table; + } + $fieldConfig['config'] = $this->configureAllowedDisallowedFileExtensions($fieldConfig['config']); + } + if (is_array($fieldConfig['config']['overrideChildTca'] ?? null)) { + $fieldConfig['config']['overrideChildTca'] = $this->configureAllowedDisallowedInOverrideChildTca($fieldConfig['config']['overrideChildTca']); + } + } + unset($fieldConfig); + if (is_array($tableDefinition['types'] ?? null)) { + foreach ($tableDefinition['types'] as &$typeConfig) { + if (!isset($typeConfig['columnsOverrides']) || !is_array($typeConfig['columnsOverrides'])) { + continue; + } + foreach ($typeConfig['columnsOverrides'] as &$columnsOverridesConfig) { + if (!isset($columnsOverridesConfig['config']) || !is_array($columnsOverridesConfig['config'])) { + continue; + } + $columnsOverridesConfig['config'] = $this->configureAllowedDisallowedFileExtensions($columnsOverridesConfig['config']); + if (is_array($columnsOverridesConfig['config']['overrideChildTca'] ?? null)) { + $columnsOverridesConfig['config']['overrideChildTca'] = $this->configureAllowedDisallowedInOverrideChildTca($columnsOverridesConfig['config']['overrideChildTca']); + } + } + } + } + } + return $tca; + } + + /** + * configureFileReferences() helper + */ + protected function configureAllowedDisallowedInOverrideChildTca(array $overrideChildTcaConfig): array + { + if (is_array($overrideChildTcaConfig['columns'] ?? null)) { + foreach ($overrideChildTcaConfig['columns'] as &$overrideChildTcaColumnConfig) { + if (!isset($overrideChildTcaColumnConfig['config'])) { + continue; + } + $overrideChildTcaColumnConfig['config'] = $this->configureAllowedDisallowedFileExtensions($overrideChildTcaColumnConfig['config']); + } + unset($overrideChildTcaColumnConfig); + } + if (is_array($overrideChildTcaConfig['types'] ?? null)) { + foreach ($overrideChildTcaConfig['types'] as &$overrideChildTcaTypeConfig) { + if (!isset($overrideChildTcaTypeConfig['config'])) { + continue; + } + $overrideChildTcaTypeConfig['config'] = $this->configureAllowedDisallowedFileExtensions($overrideChildTcaTypeConfig['config']); + } + } + return $overrideChildTcaConfig; + } + + /** + * configureFileReferences() helper + */ + protected function configureAllowedDisallowedFileExtensions(array $config): array + { + if (!empty($allowed = ($config['allowed'] ?? null))) { + $config['allowed'] = $this->prepareFileExtensions($allowed); + } + if (!empty($disallowed = ($config['disallowed'] ?? null))) { + $config['disallowed'] = $this->prepareFileExtensions($disallowed); + } + return $config; + } + + /** + * configureFileReferences() helper: Ensures format, replaces placeholders and remove duplicates + */ + protected function prepareFileExtensions(mixed $fileExtensions): string + { + if (is_array($fileExtensions)) { + $fileExtensions = implode(',', $fileExtensions); + } else { + $fileExtensions = (string)$fileExtensions; + } + // Replace placeholders with the corresponding $GLOBALS value for now + if (preg_match_all('/common-(image|text|media)-types/', $fileExtensions, $matches)) { + foreach ($matches[1] as $key => $type) { + $fileExtensions = str_replace( + $matches[0][$key], + $GLOBALS['TYPO3_CONF_VARS'][$type === 'image' ? 'GFX' : 'SYS'][$type . 'file_ext'] ?? '', + $fileExtensions + ); + } + } + return StringUtility::uniqueList($fileExtensions); + } + + /** + * Add "'softref' = 'email[subst]'" to all 'type' = 'email' column fields. + */ + protected function configureEmailSoftReferences(array $tca): array + { + foreach ($tca as &$tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? null)) { + continue; + } + foreach ($tableDefinition['columns'] as &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? null) === 'email') { + // Hard set/override: 'softref' is not listed as property for type=email at all, + // there is little need to have this configurable. + $fieldConfig['config']['softref'] = 'email[subst]'; + } + } + } + return $tca; + } + + /** + * Add "'softref' = 'typolink'" to all 'type' = 'link' column fields. + */ + protected function configureLinkSoftReferences(array $tca): array + { + foreach ($tca as &$tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? null)) { + continue; + } + foreach ($tableDefinition['columns'] as &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? null) === 'link') { + // Hard set/override: 'softref' is not listed as property for type=link at all, + // there is little need to have this configurable. + $fieldConfig['config']['softref'] = 'typolink'; + } + } + } + return $tca; + } + + /** + * Add "'relationship' for TCA type "select" fields, having "selectSingle" set as renderType and are + * pointing to a "foreign_table". Depending on further configuration, this will set the "relationship" + * to either "manyToMany" (in case "MM" is set) or to "manyToOne". + * Already defined "relationship" is not overwritten! + * + * This is mainly done to prevent checks on the renderType, which should be avoided. + */ + protected function configureSelectSingle(array $tca): array + { + foreach ($tca as &$tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? null)) { + continue; + } + foreach ($tableDefinition['columns'] as &$fieldConfig) { + if (($fieldConfig['config']['type'] ?? null) !== 'select' + || ($fieldConfig['config']['renderType'] ?? null) !== 'selectSingle' + || !isset($fieldConfig['config']['foreign_table']) + || isset($fieldConfig['config']['relationship']) + ) { + continue; + } + + if (isset($fieldConfig['config']['MM'])) { + $fieldConfig['config']['relationship'] = 'manyToMany'; + } else { + $fieldConfig['config']['relationship'] = 'manyToOne'; + } + } + } + return $tca; + } + + /** + * Add "'maxitems' => 1" to all relation type column fields with 'relationship' set to 'oneToOne' or 'manyToOne'. + */ + protected function configureRelationshipToOne(array $tca): array + { + foreach ($tca as &$tableDefinition) { + if (!is_array($tableDefinition['columns'] ?? null)) { + continue; + } + foreach ($tableDefinition['columns'] as &$fieldConfig) { + $type = $fieldConfig['config']['type'] ?? null; + if (in_array($type, ['select', 'inline', 'group', 'folder', 'file'], true) + && in_array($fieldConfig['config']['relationship'] ?? null, ['oneToOne', 'manyToOne'], true) + ) { + // Hard set/override: 'maxitems' to 1, since relationship [x]ToOne - as the name suggests - + // only allows a single item to be selected. + $fieldConfig['config']['maxitems'] = 1; + } + } + } + return $tca; + } + + /** + * Ensure that all system fields (CType, colPos, hidden etc.) are automatically added + * to the showitem list of all CTypes in tt_content. As custom CTypes might have added + * the fields, the respective fields also need to be removed first. + */ + protected function addSystemFieldsToShowitemTypes(array $tca): array + { + // @todo Only deal with this for tt_content in v13, as other parts might be too intrusive + // might change in v14 + if (!isset($tca['tt_content'])) { + return $tca; + } + // Only proceed in case the record type field is defined + $typeField = (string)($tca['tt_content']['ctrl']['type'] ?? ''); + if ($typeField === '') { + return $tca; + } + // Build list of values (fields and palettes) which should be removed + // from custom palettes, because they will be added automatically. + $listOfValuesToRemove = [ + '--div--;core.form.tabs:general', + '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general', + '--div--;core.form.tabs:language', + '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language', + '--div--;core.form.tabs:access', + '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access', + '--div--;core.form.tabs:notes', + '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:notes', + '--palette--;;general', + '--palette--;;language', + '--palette--;;access', + '--palette--;;hidden', + 'colPos', + ]; + $listOfValuesToRemove[] = $typeField; + if (($languageField = (string)($tca['tt_content']['ctrl']['languageField'] ?? '')) !== '') { + $listOfValuesToRemove[] = $languageField; + } + if (($transOrigPointerField = (string)($tca['tt_content']['ctrl']['transOrigPointerField'] ?? '')) !== '') { + $listOfValuesToRemove[] = $transOrigPointerField; + } + $enablecolumns = $tca['tt_content']['ctrl']['enablecolumns'] ?? []; + foreach ($enablecolumns as $fieldName) { + $listOfValuesToRemove[] = $fieldName; + } + if (($editlock = (string)($tca['tt_content']['ctrl']['editlock'] ?? '')) !== '') { + $listOfValuesToRemove[] = $editlock; + } + if (($descriptionColumn = (string)($tca['tt_content']['ctrl']['descriptionColumn'] ?? '')) !== '') { + $listOfValuesToRemove[] = $descriptionColumn; + } + + // Remove any system field from custom palettes + foreach ($tca['tt_content']['palettes'] as $paletteName => &$paletteConfig) { + if (in_array($paletteName, ['general', 'language', 'access', 'hidden'], true)) { + continue; + } + $showItemSplitted = GeneralUtility::trimExplode(',', $paletteConfig['showitem'], true); + $paletteConfig['showitem'] = implode(',', array_diff($this->removeCustomFieldLabels($showItemSplitted, $listOfValuesToRemove), $listOfValuesToRemove)); + } + unset($paletteConfig); + + // Process the content types + foreach ($tca['tt_content']['types'] as $type => $typeInformation) { + // Remove any of the special fields from the content type's current showitem + $showItemSplitted = GeneralUtility::trimExplode(',', $typeInformation['showitem'] ?? '', true); + $showItemFiltered = array_diff($this->removeCustomFieldLabels($showItemSplitted, $listOfValuesToRemove), $listOfValuesToRemove); + + // Extract all fields of the extended tab to add it at the end + [$showItemList, $extendedParts] = $this->extractExtendedParts($showItemFiltered); + + // Add record type field (usually "CType") and colPos either using the "general" palette + // or manually, in case the palette does not exist or does not contain the fields. + $generalPaletteItems = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['general']['showitem'] ?? '', true), $listOfValuesToRemove); + if (in_array($typeField, $generalPaletteItems, true) && in_array('colPos', $generalPaletteItems, true)) { + $showItemParts = ['--palette--;;general']; + } else { + $showItemParts = [ + $typeField, + 'colPos', + ]; + } + + // Because FormEngine will add the general tab automatically, we will not do this here + // However, if the first item in the $showItemList is actually a tab (--div--), we need to + // add if before the "first fields" + if (str_starts_with($showItemList[0] ?? '', '--div--')) { + array_unshift($showItemParts, $showItemList[0]); + unset($showItemList[0]); + } + $showItemParts = array_merge($showItemParts, $showItemList); + + // Add language field either using the "language" palette or manually, + // in case the palette does not exist or does not contain the field. + if ($languageField !== '') { + $showItemParts[] = '--div--;core.form.tabs:language'; + $languagePaletteItems = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['language']['showitem'] ?? '', true), $listOfValuesToRemove); + if (in_array($languageField, $languagePaletteItems, true) + && ($transOrigPointerField === '' || in_array($transOrigPointerField, $languagePaletteItems, true)) + ) { + $showItemParts[] = '--palette--;;language'; + } else { + $showItemParts[] = $languageField; + if ($transOrigPointerField) { + $showItemParts[] = $transOrigPointerField; + } + } + } + + // Add enable fields either using the "hidden" amd "access" palettes or + // manually, in case the palettes do not exist or do not contain the fields. + if ($enablecolumns !== [] || $editlock !== '') { + $showItemParts[] = '--div--;core.form.tabs:access'; + if (isset($enablecolumns['disabled'])) { + $hiddenPaletteParts = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['hidden']['showitem'] ?? '', true), $listOfValuesToRemove); + if (in_array($enablecolumns['disabled'], $hiddenPaletteParts, true)) { + $showItemParts[] = '--palette--;;hidden'; + } else { + $showItemParts[] = $enablecolumns['disabled']; + } + } + if ((isset($enablecolumns['starttime']) || isset($enablecolumns['endtime']) || isset($enablecolumns['fe_group']) || $editlock)) { + $accessPaletteParts = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['access']['showitem'] ?? '', true), $listOfValuesToRemove); + if ((!isset($enablecolumns['starttime']) || in_array($enablecolumns['starttime'], $accessPaletteParts, true)) + && (!isset($enablecolumns['endtime']) || in_array($enablecolumns['endtime'], $accessPaletteParts, true)) + && (!isset($enablecolumns['fe_group']) || in_array($enablecolumns['fe_group'], $accessPaletteParts, true)) + && (!$editlock || in_array($editlock, $accessPaletteParts, true)) + ) { + $showItemParts[] = '--palette--;;access'; + } else { + if (isset($enablecolumns['starttime'])) { + $showItemParts[] = $enablecolumns['starttime']; + } + if (isset($enablecolumns['endtime'])) { + $showItemParts[] = $enablecolumns['endtime']; + } + if (isset($enablecolumns['fe_group'])) { + $showItemParts[] = $enablecolumns['fe_group']; + } + if ($editlock) { + $showItemParts[] = $editlock; + } + } + } + } + + // Add description column if defined + if ($descriptionColumn !== '') { + $showItemParts[] = '--div--;core.form.tabs:notes,' . $descriptionColumn; + } + + // Add extended tab at the end - if it exists + $showItemParts = array_merge($showItemParts, $extendedParts); + + // Merge parts together + $tca['tt_content']['types'][$type]['showitem'] = trim(implode(',', $showItemParts), ','); + } + return $tca; + } + + private function extractExtendedParts(array $showItemFiltered): array + { + $extendedParts = []; + $addFields = false; + foreach ($showItemFiltered as $key => $part) { + if ($part === '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:extended' || $part === '--div--;core.form.tabs:extended') { + $extendedParts[] = $part; + $addFields = true; + unset($showItemFiltered[$key]); + } elseif ($addFields) { + if (str_starts_with($part, '--div--')) { + break; + } + $extendedParts[] = $part; + unset($showItemFiltered[$key]); + } + } + return [$showItemFiltered, $extendedParts]; + } + + private function removeCustomFieldLabels(array $showitemParts, array $fieldList): array + { + if ($fieldList === []) { + return $showitemParts; + } + foreach ($showitemParts as &$showItem) { + // Tabs keep their labels + if (str_starts_with($showItem, '--div--')) { + continue; + } + // Remove label of palette + if (str_starts_with($showItem, '--palette--')) { + $parts = GeneralUtility::trimExplode(';', $showItem, true, 3); + // Palette without label, continue. + if (count($parts) !== 3) { + continue; + } + $paletteName = '--palette--;;' . $parts[2]; + if (in_array($paletteName, $fieldList, true)) { + $showItem = $paletteName; + } + continue; + } + // This must be a field. + $parts = GeneralUtility::trimExplode(';', $showItem, true, 2); + $fieldName = $parts[0]; + // Just keep the first part => the fieldname in case field is defined in the $fieldList + if (in_array($fieldName, $fieldList, true)) { + $showItem = $fieldName; + } + } + return $showitemParts; + } + + protected function addIgnoredPageTypeRestrictionRecords(array $tca): array + { + $allowedRecordTypes = []; + foreach ($tca as $table => $configuration) { + if ($configuration['ctrl']['security']['ignorePageTypeRestriction'] ?? false) { + $allowedRecordTypes[] = $table; + } + } + if ($allowedRecordTypes === []) { + return $tca; + } + $mergedAllowedRecords = array_merge( + $tca['pages']['ctrl']['defaultAllowedRecordTypes'] ?? [], + $allowedRecordTypes + ); + $tca['pages']['ctrl']['defaultAllowedRecordTypes'] = array_unique($mergedAllowedRecords); + return $tca; + } +} diff --git a/Classes/Configuration/Tca/TcaProcessingResult.php b/Classes/Configuration/Tca/TcaProcessingResult.php new file mode 100644 index 0000000..0c6915e --- /dev/null +++ b/Classes/Configuration/Tca/TcaProcessingResult.php @@ -0,0 +1,53 @@ +tca; + } + + /** + * @return string[] + */ + public function getMessages(): array + { + return $this->messages; + } + + public function withTca(array $tca): TcaProcessingResult + { + return new self($tca, $this->messages); + } + + public function withAdditionalMessages(string ...$messages): TcaProcessingResult + { + return new self($this->tca, array_merge($this->messages, $messages)); + } +} diff --git a/Classes/Console/Application.php b/Classes/Console/Application.php new file mode 100644 index 0000000..108c173 --- /dev/null +++ b/Classes/Console/Application.php @@ -0,0 +1,40 @@ +%2$s (Application Context: %3$s) - PHP %4$s', + $this->getName(), + $this->getVersion(), + Environment::getContext(), + PHP_VERSION + ); + } +} diff --git a/Classes/Console/CommandApplication.php b/Classes/Console/CommandApplication.php new file mode 100644 index 0000000..2925e4f --- /dev/null +++ b/Classes/Console/CommandApplication.php @@ -0,0 +1,189 @@ +context = $context; + $this->commandRegistry = $commandRegistry; + $this->configurationManager = $configurationMananger; + $this->bootService = $bootService; + $this->languageServiceFactory = $languageServiceFactory; + + $this->checkEnvironmentOrDie(); + $this->application = new Application('TYPO3 CMS', (new Typo3Version())->getVersion()); + $this->application->setAutoExit(false); + $this->application->setDispatcher($eventDispatcher); + $this->application->setCommandLoader($commandRegistry); + // Replace default list command with TYPO3 override + $this->application->addCommands([$commandRegistry->get('list')]); + } + + /** + * Run the Symfony Console application in this TYPO3 application + */ + public function run() + { + $input = new ArgvInput(); + $output = new ConsoleOutput(); + + $commandName = $this->getCommandName($input); + if ($this->wantsFullBoot($commandName)) { + // Do a full container boot if command is not a 1:1 matching low-level command + $container = $this->bootService->getContainer(); + $eventDispatcher = $container->get(SymfonyEventDispatcher::class); + $commandRegistry = $container->get(CommandRegistry::class); + $this->application->setDispatcher($eventDispatcher); + $this->application->setCommandLoader($commandRegistry); + $this->context = $container->get(Context::class); + + $realName = $this->resolveShortcut($commandName, $commandRegistry); + $isLowLevelCommandShortcut = $realName !== null && !$this->wantsFullBoot($realName); + // Load ext_localconf, except if a low level command shortcut was found + // or if essential configuration is missing + if (!$isLowLevelCommandShortcut && Bootstrap::checkIfEssentialConfigurationExists($this->configurationManager)) { + $this->bootService->loadExtLocalconfDatabase(); + } + } + + // Make sure output is not buffered, so command-line output and interaction can take place. + // Bootstrap does not open a buffer anymore, but third-party extension code may have done + // so while ext_localconf.php files were loaded. + while (ob_get_level()) { + ob_end_clean(); + } + + $this->initializeContext(); + // create the BE_USER object (not logged in yet) + Bootstrap::initializeBackendUser(CommandLineUserAuthentication::class); + $GLOBALS['LANG'] = $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER']); + + $exitCode = $this->application->run($input, $output); + // exit codes > 255 are not handled in UNIX + if ($exitCode > 255) { + $exitCode = 255; + } + + exit($exitCode); + } + + private function resolveShortcut(string $commandName, CommandRegistry $commandRegistry): ?string + { + if ($commandRegistry->has($commandName)) { + return $commandName; + } + + $allCommands = $commandRegistry->getNames(); + $expr = implode('[^:]*:', array_map(preg_quote(...), explode(':', $commandName))) . '[^:]*'; + $commands = preg_grep('{^' . $expr . '}', $allCommands); + + if ($commands === false || count($commands) === 0) { + $commands = preg_grep('{^' . $expr . '}i', $allCommands); + } + + if ($commands === false || count($commands) !== 1) { + return null; + } + + return reset($commands); + } + + protected function wantsFullBoot(string $commandName): bool + { + if ($commandName === 'help') { + return true; + } + return !$this->commandRegistry->has($commandName); + } + + protected function getCommandName(ArgvInput $input): string + { + try { + $input->bind($this->application->getDefinition()); + } catch (ExceptionInterface $e) { + // Errors must be ignored, full binding/validation happens later when the console application runs. + } + + return $input->getFirstArgument() ?? 'list'; + } + + /** + * Check the script is called from a cli environment. + */ + protected function checkEnvironmentOrDie(): void + { + if (PHP_SAPI !== 'cli') { + die('Not called from a command line interface (e.g. a shell or scheduler).' . LF); + } + } + + /** + * Initializes the Context used for accessing data and finding out the current state of the application + */ + protected function initializeContext(): void + { + $this->context->setAspect('date', new DateTimeAspect(DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME']))); + $this->context->setAspect('visibility', new VisibilityAspect(true, true, false, true)); + $this->context->setAspect('workspace', new WorkspaceAspect(0)); + $this->context->setAspect('backend.user', new UserAspect(null)); + } +} diff --git a/Classes/Console/CommandRegistry.php b/Classes/Console/CommandRegistry.php new file mode 100644 index 0000000..d012f50 --- /dev/null +++ b/Classes/Console/CommandRegistry.php @@ -0,0 +1,239 @@ +commandConfigurations); + } + + /** + * {@inheritdoc} + */ + public function get(string $name): Command + { + try { + return $this->getCommandByIdentifier($name); + } catch (UnknownCommandException $e) { + throw new CommandNotFoundException($e->getMessage(), [], 1567969355, $e); + } + } + + /** + * {@inheritdoc} + */ + public function getNames(): array + { + return array_keys($this->commandConfigurations); + } + + /** + * Get the configuration form all commands which are schedulable. + * By using the #[AsNonSchedulableCommand] attribute, all commands + * are filtered out which utilize this attribute, as the Symfony + * #[AsCommand] attribute does not allow to set additional meta-data. + */ + public function getSchedulableCommandsConfiguration(): array + { + return array_filter( + $this->commandConfigurations, + static fn(array $configuration): bool => ($configuration['schedulable'] ?? true) + ); + } + + /** + * Get all commands which are allowed for scheduling recurring commands. + * @todo Not used by core. Consider deprecating! + */ + public function getSchedulableCommands(): \Generator + { + foreach ($this->commandConfigurations as $commandName => $configuration) { + if ($configuration['schedulable'] ?? true) { + yield $commandName => $this->getInstance($configuration['serviceName']); + } + } + } + + /** + * @throws UnknownCommandException + */ + public function getCommandByIdentifier(string $identifier): Command + { + if (!isset($this->commandConfigurations[$identifier])) { + throw new UnknownCommandException( + sprintf('Command "%s" has not been registered.', $identifier), + 1510906768 + ); + } + + return $this->getInstance($this->commandConfigurations[$identifier]['serviceName']); + } + + protected function getInstance(string $service): Command + { + return $this->container->get($service); + } + + /** + * @internal + */ + public function getNamespaces(): array + { + $namespaces = []; + foreach ($this->commandConfigurations as $commandName => $configuration) { + if ($configuration['hidden']) { + continue; + } + if ($configuration['aliasFor'] !== null) { + continue; + } + $namespace = $configuration['namespace']; + $namespaces[$namespace]['id'] = $namespace; + $namespaces[$namespace]['commands'][] = $commandName; + } + + ksort($namespaces); + foreach ($namespaces as &$commands) { + ksort($commands); + } + + return $namespaces; + } + + /** + * Gets the commands (registered in the given namespace if provided). + * + * The array keys are the full names and the values the command instances. + * + * @return array An array of Command descriptors + * @internal + */ + public function filter(?string $namespace = null): array + { + $commands = []; + foreach ($this->commandConfigurations as $commandName => $configuration) { + if ($configuration['hidden']) { + continue; + } + if ($namespace !== null && $namespace !== $this->extractNamespace($commandName, substr_count($namespace, ':') + 1)) { + continue; + } + if ($configuration['aliasFor'] !== null) { + continue; + } + + $commands[$commandName] = $configuration; + $commands[$commandName]['aliases'] = $this->aliases[$commandName] ?? []; + } + + return $commands; + } + + /** + * @internal + */ + public function addLazyCommand( + string $commandName, + string $serviceName, + ?string $description = null, + bool $hidden = false, + bool $schedulable = false, + ?string $aliasFor = null + ): void { + + if ($schedulable) { + // Workaround: Symfony #[AsCommand] can not utilize an extra 'schedulable' key. Thus, we + // evaluate the additional #[AsUnschedulableCommand] as well. + try { + $reflection = new \ReflectionClass($serviceName); + $attributes = $reflection->getAttributes(AsNonSchedulableCommand::class); + if ($attributes !== []) { + // The presence of the attribute alone is sufficient, no further reflection + // or construction is required at this time. Might be needed if the attribute + // ever gets properties. + $schedulable = false; + } + } catch (\ReflectionException $e) { + } + } + $this->commandConfigurations[$commandName] = [ + 'name' => $aliasFor ?? $commandName, + 'serviceName' => $serviceName, + 'description' => $description, + 'hidden' => $hidden, + 'schedulable' => $schedulable, + 'aliasFor' => $aliasFor, + 'namespace' => $this->extractNamespace($commandName, 1), + ]; + + if ($aliasFor !== null) { + $this->aliases[$aliasFor][] = $commandName; + } + } + + /** + * Returns the namespace part of the command name. + * + * This method is not part of public API and should not be used directly. + * + * @return string The namespace of the command + */ + private function extractNamespace(string $name, ?int $limit = null): string + { + $parts = explode(':', $name, -1); + if (count($parts) === 0) { + return ApplicationDescription::GLOBAL_NAMESPACE; + } + + return implode(':', $limit === null ? $parts : array_slice($parts, 0, $limit)); + } +} diff --git a/Classes/Console/UnknownCommandException.php b/Classes/Console/UnknownCommandException.php new file mode 100644 index 0000000..9ca3aba --- /dev/null +++ b/Classes/Console/UnknownCommandException.php @@ -0,0 +1,25 @@ +setAspect(GeneralUtility::makeInstance(VisibilityAspect::class, true, true, false)) + * ``` + * + * ... which in turn can be injected in the various places where TYPO3 uses contexts. + * + * + * Classic aspect names to be used are: + * - date (DateTimeAspect) + * - workspace + * - visibility + * - frontend.user + * - backend.user + * - language + * - frontend.preview [if EXT:frontend is loaded] + */ +class Context implements SingletonInterface +{ + /** + * @var AspectInterface[] + */ + protected array $aspects = []; + + /** + * Checks if an aspect exists in the context + */ + public function hasAspect(string $name): bool + { + return match ($name) { + 'date', 'visibility', 'backend.user', 'frontend.user', 'workspace', 'language' => true, + default => isset($this->aspects[$name]), + }; + } + + /** + * Returns an aspect, if it is set + * + * @throws AspectNotFoundException + * @return ($name is 'date' ? DateTimeAspect + * : ($name is 'visibility' ? VisibilityAspect + * : ($name is 'backend.user' ? UserAspect + * : ($name is 'frontend.user' ? UserAspect + * : ($name is 'workspace' ? WorkspaceAspect + * : ($name is 'language' ? LanguageAspect : AspectInterface)))))) + */ + public function getAspect(string $name): AspectInterface + { + if (!isset($this->aspects[$name])) { + // Ensure the default aspects are available, this is mostly necessary for tests to not set up everything + switch ($name) { + case 'date': + $this->setAspect('date', new DateTimeAspect(DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME']))); + break; + case 'visibility': + $this->setAspect('visibility', new VisibilityAspect()); + break; + case 'backend.user': + $this->setAspect('backend.user', new UserAspect()); + break; + case 'frontend.user': + $this->setAspect('frontend.user', new UserAspect()); + break; + case 'workspace': + $this->setAspect('workspace', new WorkspaceAspect()); + break; + case 'language': + $this->setAspect('language', new LanguageAspect()); + break; + default: + throw new AspectNotFoundException('No aspect named "' . $name . '" found.', 1527777641); + } + } + return $this->aspects[$name]; + } + + /** + * Returns a property from the aspect, but only if the property is found. + * + * @throws AspectNotFoundException + */ + public function getPropertyFromAspect(string $name, string $property, mixed $default = null): mixed + { + if (!$this->hasAspect($name)) { + throw new AspectNotFoundException('No aspect named "' . $name . '" found.', 1527777868); + } + try { + return $this->getAspect($name)->get($property); + } catch (AspectPropertyNotFoundException) { + return $default; + } + } + + /** + * Sets an aspect, or overrides an existing aspect if an aspect is already set + */ + public function setAspect(string $name, AspectInterface $aspect): void + { + $this->aspects[$name] = $aspect; + } + + /** + * @internal Using this method is a sign of a technical debt. It is used by RedirectService, + * but may vanish any time when this is fixed, and thus internal. + * In general, Context aspects should never have to be unset. + * When a middleware has to use this method, it is either located + * at the wrong position in the chain, or has some other dependency issue. + */ + public function unsetAspect(string $name): void + { + unset($this->aspects[$name]); + } +} diff --git a/Classes/Context/DateTimeAspect.php b/Classes/Context/DateTimeAspect.php new file mode 100644 index 0000000..24e22e2 --- /dev/null +++ b/Classes/Context/DateTimeAspect.php @@ -0,0 +1,70 @@ +dateTimeObject->getTimestamp(); + case 'iso': + return $this->dateTimeObject->format('c'); + case 'timezone': + return $this->dateTimeObject->format('e'); + case 'full': + return $this->dateTimeObject; + case 'accessTime': + return $this->dateTimeObject->getTimestamp() - ($this->dateTimeObject->getTimestamp() % 60); + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1527778767); + } + + /** + * Return the full date time object + */ + public function getDateTime(): \DateTimeImmutable + { + return $this->dateTimeObject; + } +} diff --git a/Classes/Context/Exception/AspectNotFoundException.php b/Classes/Context/Exception/AspectNotFoundException.php new file mode 100644 index 0000000..2083ad5 --- /dev/null +++ b/Classes/Context/Exception/AspectNotFoundException.php @@ -0,0 +1,25 @@ +deferProcessing; + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1599164743); + } + + public function isProcessingDeferred(): bool + { + return $this->deferProcessing; + } +} diff --git a/Classes/Context/LanguageAspect.php b/Classes/Context/LanguageAspect.php new file mode 100644 index 0000000..1265f50 --- /dev/null +++ b/Classes/Context/LanguageAspect.php @@ -0,0 +1,159 @@ +overlayType; + } + + /** + * Returns the language ID the current page was requested, + * this is relevant when building menus or links to other pages. + */ + public function getId(): int + { + return $this->id; + } + + /** + * Contains the language UID of the content records that should be overlaid to would be fetched. + * This is especially useful when a page requested with language=4 should fall back to showing + * content of language=2 (see fallbackChain) + */ + public function getContentId(): int + { + return $this->contentId ?? $this->id; + } + + public function getFallbackChain(): array + { + return $this->fallbackChain; + } + + /** + * Whether overlays should be done + */ + public function doOverlays(): bool + { + return $this->getContentId() > 0 && $this->overlayType !== self::OVERLAYS_OFF; + } + + /** + * Here for compatibility reasons + */ + public function getLegacyLanguageMode(): string + { + if ($this->fallbackChain === ['off']) { + return ''; + } + if (empty($this->fallbackChain)) { + return 'strict'; + } + return 'content_fallback'; + } + + /** + * Here for compatibility reasons + */ + public function getLegacyOverlayType(): string + { + return match ($this->overlayType) { + self::OVERLAYS_ON_WITH_FLOATING, self::OVERLAYS_ON => 'hideNonTranslated', + self::OVERLAYS_MIXED => '1', + default => '0', + }; + } + + /** + * Fetch a property. + * + * @throws AspectPropertyNotFoundException + */ + public function get(string $name): int|string|array + { + switch ($name) { + case 'id': + return $this->id; + case 'contentId': + return $this->getContentId(); + case 'fallbackChain': + return $this->fallbackChain; + case 'overlayType': + return $this->overlayType; + case 'legacyLanguageMode': + return $this->getLegacyLanguageMode(); + case 'legacyOverlayType': + return $this->getLegacyOverlayType(); + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1530448504); + } +} diff --git a/Classes/Context/LanguageAspectFactory.php b/Classes/Context/LanguageAspectFactory.php new file mode 100644 index 0000000..04c2adc --- /dev/null +++ b/Classes/Context/LanguageAspectFactory.php @@ -0,0 +1,61 @@ +getLanguageId(); + $fallbackType = $language->getFallbackType(); + $fallbackOrder = $language->getFallbackLanguageIds(); + $fallbackOrder[] = 'pageNotFound'; + switch ($fallbackType) { + // Fall back to other language, if the page does not exist in the requested language + // But always fetch only records of this specific (available) language + case 'free': + $overlayType = LanguageAspect::OVERLAYS_OFF; + break; + + // Fall back to other language, if the page does not exist in the requested language + // Do overlays, and keep the ones that are not translated + case 'fallback': + $overlayType = LanguageAspect::OVERLAYS_MIXED; + break; + + // Same as "fallback" but remove the records that are not translated + case 'strict': + $overlayType = LanguageAspect::OVERLAYS_ON_WITH_FLOATING; + break; + + // Ignore, fallback to default language + default: + $fallbackOrder = [0]; + $overlayType = LanguageAspect::OVERLAYS_OFF; + } + return new LanguageAspect($languageId, $languageId, $overlayType, $fallbackOrder); + } +} diff --git a/Classes/Context/SecurityAspect.php b/Classes/Context/SecurityAspect.php new file mode 100644 index 0000000..4daff3d --- /dev/null +++ b/Classes/Context/SecurityAspect.php @@ -0,0 +1,107 @@ +hasAspect('security')) { + $securityAspect = $context->getAspect('security'); + } + if (!isset($securityAspect) || !$securityAspect instanceof SecurityAspect) { + $securityAspect = GeneralUtility::makeInstance(SecurityAspect::class); + $context->setAspect('security', $securityAspect); + } + return $securityAspect; + } + + public function __construct() + { + $this->noncePool = GeneralUtility::makeInstance(NoncePool::class); + $this->signingSecretResolver = GeneralUtility::makeInstance( + SigningSecretResolver::class, + [ + 'nonce' => $this->noncePool, + // @todo enrich in separate step with `*FormProtection` + ] + ); + } + + public function get(string $name): bool|Nonce|RequestToken|null + { + return match ($name) { + 'receivedRequestToken' => $this->receivedRequestToken, + 'signingSecretResolver' => $this->signingSecretResolver, + 'noncePool' => $this->noncePool, + default => null, + }; + } + + public function getReceivedRequestToken(): RequestToken|false|null + { + return $this->receivedRequestToken; + } + + public function setReceivedRequestToken(RequestToken|false|null $receivedRequestToken): void + { + $this->receivedRequestToken = $receivedRequestToken; + } + + /** + * Resolves corresponding signing secret providers (such as `NoncePool`). + * Example: `...->getSigningSecretResolver->findByType('nonce')` resolves `NoncePool` + */ + public function getSigningSecretResolver(): SigningSecretResolver + { + return $this->signingSecretResolver; + } + + public function getNoncePool(): NoncePool + { + return $this->noncePool; + } + + /** + * Shortcut function to `NoncePool`, providing a `SigningSecret` + * @todo this is a "comfort function", might be dropped + */ + public function provideNonce(): Nonce + { + return $this->noncePool->provideSigningSecret(); + } +} diff --git a/Classes/Context/UserAspect.php b/Classes/Context/UserAspect.php new file mode 100644 index 0000000..5129e82 --- /dev/null +++ b/Classes/Context/UserAspect.php @@ -0,0 +1,154 @@ +user?->user[$this->user->userid_column] ?? 0); + case 'username': + return (string)($this->user?->user[$this->user->username_column] ?? ''); + case 'isLoggedIn': + return $this->isLoggedIn(); + case 'isAdmin': + return $this->isAdmin(); + case 'groupIds': + return $this->getGroupIds(); + case 'groupNames': + return $this->getGroupNames(); + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1529996567); + } + + /** + * A user is logged in if the user has a UID, but does not care about groups. + * + * For frontend purposes, it is possible to e.g. simulate groups, but this would still be defined as "not logged in". + * + * For backend, only the check on the user ID is used. + */ + public function isLoggedIn(): bool + { + return ($this->user?->user[$this->user->userid_column] ?? 0) > 0; + } + + /** + * Check if admin is set + */ + public function isAdmin(): bool + { + if ($this->user instanceof BackendUserAuthentication) { + // Only backend users have the admin flag at all. + return $this->user->isAdmin(); + } + return false; + } + + /** + * Return the groups the user is a member of + * + * For Frontend Users there are two special groups: + * "-1" = hide at login + * "-2" = show at any login + */ + public function getGroupIds(): array + { + // Alternative groups are set + if (is_array($this->alternativeGroups)) { + return $this->alternativeGroups; + } + if ($this->user instanceof BackendUserAuthentication) { + return $this->user->userGroupsUID; + } + $groups = []; + if ($this->user instanceof FrontendUserAuthentication) { + if ($this->isLoggedIn()) { + // If a user is logged in, always add "-2" + $groups = [0, -2]; + if (!empty($this->user->userGroups)) { + $groups = array_merge($groups, array_keys($this->user->userGroups)); + } + } else { + $groups = [0, -1]; + } + } + return $groups; + } + + /** + * Get the name of all groups, used in Fluid's IfHasRole ViewHelper + */ + public function getGroupNames(): array + { + $groupNames = []; + if ($this->user instanceof AbstractUserAuthentication) { + foreach ($this->user->userGroups as $userGroup) { + $groupNames[] = $userGroup['title']; + } + } + return $groupNames; + } + + /** + * Checking if a user is logged in or a group constellation different from "0,-1" + * + * @return bool TRUE if either a login user is found OR if the group list is set to something else than '0,-1' (could be done even without a user being logged in!) + */ + public function isUserOrGroupSet(): bool + { + if ($this->user instanceof FrontendUserAuthentication) { + $groups = $this->getGroupIds(); + return $this->isLoggedIn() || implode(',', $groups) !== '0,-1'; + } + return $this->isLoggedIn(); + } +} diff --git a/Classes/Context/VisibilityAspect.php b/Classes/Context/VisibilityAspect.php new file mode 100644 index 0000000..2ccd903 --- /dev/null +++ b/Classes/Context/VisibilityAspect.php @@ -0,0 +1,90 @@ +includeHiddenPages; + case 'includeHiddenContent': + return $this->includeHiddenContent; + case 'includeDeletedRecords': + return $this->includeDeletedRecords; + case 'includeScheduledRecords': + return $this->includeScheduledRecords; + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1527780439); + } + + public function includeHidden(): bool + { + return $this->includeHiddenContent || $this->includeHiddenPages; + } + + public function includeHiddenPages(): bool + { + return $this->includeHiddenPages; + } + + public function includeHiddenContent(): bool + { + return $this->includeHiddenContent; + } + + public function includeScheduledRecords(): bool + { + return $this->includeScheduledRecords; + } + + public function includeDeletedRecords(): bool + { + return $this->includeDeletedRecords; + } +} diff --git a/Classes/Context/WorkspaceAspect.php b/Classes/Context/WorkspaceAspect.php new file mode 100644 index 0000000..1dd0e39 --- /dev/null +++ b/Classes/Context/WorkspaceAspect.php @@ -0,0 +1,69 @@ +workspaceId; + case 'isLive': + return $this->isLive(); + case 'isOffline': + return !$this->isLive(); + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1527779447); + } + + /** + * Return the workspace ID + */ + public function getId(): int + { + return $this->workspaceId; + } + + /** + * Return whether this is live workspace or in a custom offline workspace + */ + public function isLive(): bool + { + return $this->workspaceId === 0; + } +} diff --git a/Classes/Controller/ErrorPageController.php b/Classes/Controller/ErrorPageController.php new file mode 100644 index 0000000..8669c2a --- /dev/null +++ b/Classes/Controller/ErrorPageController.php @@ -0,0 +1,72 @@ +viewFactory->create($viewFactoryData); + $view->assignMultiple([ + 'message' => $message, + 'title' => $title, + 'httpStatusCode' => $httpStatusCode, + 'errorCodeUrlPrefix' => Typo3Information::URL_EXCEPTION, + 'donationUrl' => Typo3Information::URL_DONATE, + 'errorCode' => $errorCode, + 'requestId' => GeneralUtility::makeInstance(RequestId::class), + 'copyrightYear' => $this->typo3Information->getCopyrightYear(), + ]); + $this->policyRegistry->appendMutationCollection( + new ContentSecurityPolicy\MutationCollection( + new ContentSecurityPolicy\Mutation( + ContentSecurityPolicy\MutationMode::Extend, + ContentSecurityPolicy\Directive::StyleSrcElem, + ContentSecurityPolicy\SourceKeyword::nonceProxy + ) + ) + ); + return $view->render('ErrorPage/Error'); + } +} diff --git a/Classes/Controller/FileDumpController.php b/Classes/Controller/FileDumpController.php new file mode 100644 index 0000000..01cda0b --- /dev/null +++ b/Classes/Controller/FileDumpController.php @@ -0,0 +1,245 @@ +buildParametersFromRequest($request); + + if (!$this->isTokenValid($parameters, $request)) { + return $this->responseFactory->createResponse(403); + } + $file = $this->createFileObjectByParameters($parameters); + if ($file === null) { + return $this->responseFactory->createResponse(404); + } + + // Allow some other process to do some security/access checks. + // Event Listeners should return a 403 response if access is rejected + $event = new ModifyFileDumpEvent($file, $request); + $event = $this->eventDispatcher->dispatch($event); + if ($event->isPropagationStopped()) { + return $this->applyContentSecurityPolicy($event->getFile(), $event->getResponse()); + } + $file = $event->getFile(); + + $processingInstructions = []; + + // Apply cropping, if possible + if (!empty($parameters['cv'])) { + $cropVariant = $parameters['cv']; + $cropString = $file instanceof FileReference ? $file->getProperty('crop') : ''; + $cropArea = CropVariantCollection::create((string)$cropString)->getCropArea($cropVariant); + $processingInstructions = array_merge( + $processingInstructions, + [ + 'crop' => $cropArea->isEmpty() ? null : $cropArea->makeAbsoluteBasedOnFile($file), + ] + ); + } + + // Apply width/height, if given + if (!empty($parameters['s'])) { + $size = GeneralUtility::trimExplode(':', $parameters['s']); + $processingInstructions = array_merge( + $processingInstructions, + [ + 'width' => $size[0] ?? null, + 'height' => $size[1] ?? null, + 'minWidth' => $size[2] ? (int)$size[2] : null, + 'minHeight' => $size[3] ? (int)$size[3] : null, + 'maxWidth' => $size[4] ? (int)$size[4] : null, + 'maxHeight' => $size[5] ? (int)$size[5] : null, + ] + ); + } + + if (!empty($processingInstructions) && !($file instanceof ProcessedFile)) { + if (is_callable([$file, 'getOriginalFile'])) { + // Get the original file from the file reference + $file = $file->getOriginalFile(); + } + $file = $file->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $processingInstructions); + } + + return $this->applyContentSecurityPolicy( + $file, + $file->getStorage()->streamFile( + $file, + (bool)($parameters['dl'] ?? false), + $parameters['fn'] ?? null + ) + ); + } + + protected function buildParametersFromRequest(ServerRequestInterface $request): array + { + $parameters = ['eID' => 'dumpFile']; + $queryParams = $request->getQueryParams(); + // Identifier of what to process. f, r or p + // Only needed while hash_equals + $t = (string)($queryParams['t'] ?? ''); + if ($t) { + $parameters['t'] = $t; + } + // sys_file + $f = (string)($queryParams['f'] ?? ''); + if ($f) { + $parameters['f'] = (int)$f; + } + // sys_file_reference + $r = (string)($queryParams['r'] ?? ''); + if ($r) { + $parameters['r'] = (int)$r; + } + // Processed file + $p = (string)($queryParams['p'] ?? ''); + if ($p) { + $parameters['p'] = (int)$p; + } + // File's width and height in this order: w:h:minW:minH:maxW:maxH + $s = (string)($queryParams['s'] ?? ''); + if ($s) { + $parameters['s'] = $s; + } + // File's crop variant + $cv = (string)($queryParams['cv'] ?? ''); + if ($cv) { + $parameters['cv'] = $cv; + } + // As download + $dl = (string)($queryParams['dl'] ?? ''); + if ($dl) { + $parameters['dl'] = (int)$dl; + } + // Alternative file name + $fn = (string)($queryParams['fn'] ?? ''); + if ($fn) { + $parameters['fn'] = $fn; + } + + return $parameters; + } + + protected function isTokenValid(array $parameters, ServerRequestInterface $request): bool + { + return hash_equals( + $this->hashService->hmac(implode('|', $parameters), 'resourceStorageDumpFile', HashAlgo::SHA3_256), + $request->getQueryParams()['token'] ?? '' + ); + } + + /** + * @return File|FileReference|ProcessedFile|null + */ + protected function createFileObjectByParameters(array $parameters) + { + $file = null; + if (isset($parameters['f'])) { + try { + $file = $this->resourceFactory->getFileObject($parameters['f']); + if ($file->isDeleted() || $file->isMissing() || !$this->isFileValid($file)) { + $file = null; + } + } catch (\Exception $e) { + $file = null; + } + } elseif (isset($parameters['r'])) { + try { + $file = $this->resourceFactory->getFileReferenceObject($parameters['r']); + if ($file->isMissing() || !$this->isFileValid($file->getOriginalFile())) { + $file = null; + } + } catch (\Exception $e) { + $file = null; + } + } elseif (isset($parameters['p'])) { + try { + $file = $this->processedFileRepository->findByUid((int)$parameters['p']); + if ($file->isDeleted() || !$this->isFileValid($file->getOriginalFile())) { + $file = null; + } + } catch (\Exception) { + $file = null; + } + } + return $file; + } + + protected function isFileValid(FileInterface $file): bool + { + return $file->getStorage()->getDriverType() !== 'Local' + || $this->fileNameValidator->isValid(basename($file->getIdentifier())); + } + + /** + * Applies hard-coded content-security-policy (CSP) for file to be dumped. + */ + protected function applyContentSecurityPolicy(ResourceInterface $file, ResponseInterface $response): ResponseInterface + { + $extension = PathUtility::pathinfo($file->getName(), PATHINFO_EXTENSION); + // same as in `typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/resources-root-htaccess` + if ($extension === 'pdf' || $response->getHeaderLine('content-type') === 'application/pdf') { + $policy = "default-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'self'; plugin-types application/pdf;"; + } elseif ($extension === 'svg' || $response->getHeaderLine('content-type') === 'image/svg+xml') { + $policy = "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; object-src 'none';"; + } else { + $policy = "default-src 'self'; script-src 'none'; style-src 'none'; object-src 'none';"; + } + return $response->withAddedHeader('content-security-policy', $policy); + } +} diff --git a/Classes/Controller/IconController.php b/Classes/Controller/IconController.php new file mode 100644 index 0000000..7afbc90 --- /dev/null +++ b/Classes/Controller/IconController.php @@ -0,0 +1,57 @@ +getParsedBody(); + $queryParams = $request->getQueryParams(); + $requestedIcon = json_decode($parsedBody['icon'] ?? $queryParams['icon'], true); + + [$identifier, $size, $overlayIdentifier, $iconState, $alternativeMarkupIdentifier] = $requestedIcon; + + if (empty($overlayIdentifier)) { + $overlayIdentifier = null; + } + + $iconState = IconState::tryFrom($iconState); + $icon = $this->iconFactory->getIcon($identifier, IconSize::from($size), $overlayIdentifier, $iconState); + + return new HtmlResponse($icon->render($alternativeMarkupIdentifier)); + } +} diff --git a/Classes/Controller/PasswordGeneratorController.php b/Classes/Controller/PasswordGeneratorController.php new file mode 100644 index 0000000..c5aa682 --- /dev/null +++ b/Classes/Controller/PasswordGeneratorController.php @@ -0,0 +1,96 @@ +getParsedBody()['passwordPolicy'] ?? null; + + try { + if (is_string($passwordPolicy) && $passwordPolicy !== 'null') { + $generator = $GLOBALS['TYPO3_CONF_VARS']['SYS']['passwordPolicies'][$passwordPolicy]['generator'] ?? null; + if (empty($generator['className']) + || !is_string($generator['className']) + || !class_exists($generator['className']) + || !isset($generator['options']) + || !is_array($generator['options']) + ) { + throw new \LogicException( + 'The TYPO3_CONF_VARS.SYS.passwordPolicies.' . $passwordPolicy . '.generator configuration is misconfigured.' + . ' Please ensure that the sub key \'className\' is set, and the sub key \'options\' is an array of required option values.', + 1770142937 + ); + } + + $passwordGeneratorClassName = $generator['className']; + $passwordGeneratorOptions = $generator['options']; + + $passwordGenerator = GeneralUtility::makeInstance($passwordGeneratorClassName); + if (!$passwordGenerator instanceof PasswordGeneratorInterface) { + throw new \LogicException('Class ' . $passwordGeneratorClassName . ' does not implement PasswordGeneratorInterface', 1770142966); + } + + $password = $passwordGenerator->generate($passwordGeneratorOptions); + return $this->createResponse([ + 'success' => true, + 'password' => $password, + ]); + } + } catch (\LogicException $exception) { + $this->logger->error('Password generation failed', ['exception' => $exception]); + } + + return $this->createResponse([ + 'success' => false, + ]); + } + + protected function createResponse(array $data): ResponseInterface + { + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withBody($this->streamFactory->createStream((string)json_encode($data))); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Core/ApplicationContext.php b/Classes/Core/ApplicationContext.php new file mode 100644 index 0000000..f694fef --- /dev/null +++ b/Classes/Core/ApplicationContext.php @@ -0,0 +1,135 @@ +isProduction(), $context->isTesting() and + * $context->isDevelopment() inside your custom code. + * + * ATTENTION: The Testing context is only used internally when executing TYPO3 Core tests. It must not be used otherwise. + * + * This class is derived from the TYPO3 Flow framework. + * Credits go to the respective authors. + */ +class ApplicationContext +{ + /** + * The (internal) context string; could be something like "Development" or "Development/MyLocalMacBook" + * + * @var string + */ + protected $contextString; + + /** + * The root context; must be one of "Development", "Testing" or "Production" + * + * @var string + */ + protected $rootContextString; + + /** + * The parent context, or NULL if there is no parent context + * + * @var \TYPO3\CMS\Core\Core\ApplicationContext|null + */ + protected $parentContext; + + /** + * Initialize the context object. + * + * @param string $contextString + * @throws Exception if the parent context is none of "Development", "Production" or "Testing" + */ + public function __construct($contextString) + { + if (!str_contains($contextString, '/')) { + $this->rootContextString = $contextString; + $this->parentContext = null; + } else { + $contextStringParts = explode('/', $contextString); + $this->rootContextString = $contextStringParts[0]; + array_pop($contextStringParts); + $this->parentContext = new self(implode('/', $contextStringParts)); + } + + if (!in_array($this->rootContextString, ['Development', 'Production', 'Testing'], true)) { + throw new Exception('The given context "' . $contextString . '" was not valid. Only allowed are Development, Production and Testing, including their sub-contexts', 1335436551); + } + + $this->contextString = $contextString; + } + + /** + * Returns the full context string, for example "Development", or "Production/LiveSystem" + * + * @return string + */ + public function __toString() + { + return $this->contextString; + } + + /** + * Returns TRUE if this context is the Development context or a sub-context of it + * + * @return bool + */ + public function isDevelopment() + { + return $this->rootContextString === 'Development'; + } + + /** + * Returns TRUE if this context is the Production context or a sub-context of it + * + * @return bool + */ + public function isProduction() + { + return $this->rootContextString === 'Production'; + } + + /** + * Returns TRUE if this context is the Testing context or a sub-context of it + * + * @return bool + */ + public function isTesting() + { + return $this->rootContextString === 'Testing'; + } + + /** + * Returns the parent context object, if any + * + * @return \TYPO3\CMS\Core\Core\ApplicationContext|null the parent context or NULL, if there is none + */ + public function getParent() + { + return $this->parentContext; + } +} diff --git a/Classes/Core/ApplicationInterface.php b/Classes/Core/ApplicationInterface.php new file mode 100644 index 0000000..293626d --- /dev/null +++ b/Classes/Core/ApplicationInterface.php @@ -0,0 +1,32 @@ +containerBuilder = $containerBuilder; + $this->failsafeContainer = $failsafeContainer; + } + + public function getContainer(bool $allowCaching = true): ContainerInterface + { + return $this->container ?? $this->prepareContainer($allowCaching); + } + + private function prepareContainer(bool $allowCaching = true): ContainerInterface + { + $packageManager = $this->failsafeContainer->get(PackageManager::class); + $dependencyInjectionContainerCache = $this->failsafeContainer->get('cache.di'); + + $failsafe = false; + + // Build a non-failsafe container which is required for loading ext_localconf + $this->container = $this->containerBuilder->createDependencyInjectionContainer($packageManager, $dependencyInjectionContainerCache, $failsafe); + $this->container->set('_early.boot-service', $this); + if ($allowCaching) { + $this->container->get('boot.state')->cacheDisabled = false; + $coreCache = Bootstrap::createCache('core'); + // Core cache is initialized with a NullBackend in failsafe mode. + // Replace it with a new cache that uses the real backend. + $this->container->set('_early.cache.core', $coreCache); + if (!Environment::isComposerMode()) { + $this->container->get(PackageManager::class)->setPackageCache(Bootstrap::createPackageCache($coreCache)); + } + } + + return $this->container; + } + + /** + * Switch global context to a new context, or revert + * to the original booting container if no container + * is specified + */ + public function makeCurrent(?ContainerInterface $container = null, array $backup = []): array + { + $container = $container ?? $backup['container'] ?? $this->failsafeContainer; + + $newBackup = [ + 'singletonInstances' => GeneralUtility::getSingletonInstances(), + 'container' => GeneralUtility::getContainer(), + ]; + + GeneralUtility::purgeInstances(); + + // Set global state to the non-failsafe container and it's instances + GeneralUtility::setContainer($container); + ExtensionManagementUtility::setPackageManager($container->get(PackageManager::class)); + + $backupSingletonInstances = $backup['singletonInstances'] ?? []; + foreach ($backupSingletonInstances as $className => $instance) { + GeneralUtility::setSingletonInstance($className, $instance); + } + + return $newBackup; + } + + /** + * Bootstrap a non-failsafe container and load ext_localconf.phps of all extensions. + * + * Use by actions like the database analyzer and the upgrade wizards which + * need additional bootstrap actions performed. + * + * Those actions can potentially fatal if some old extension is loaded that triggers + * a fatal in ext_localconf code! Use only if really needed. + */ + public function loadExtLocalconfDatabase(bool $resetContainer = false, bool $allowCaching = true): ContainerInterface + { + $container = $this->getContainer($allowCaching); + + $backup = $this->makeCurrent($container); + $beUserBackup = $GLOBALS['BE_USER'] ?? null; + + $container->get('boot.state')->complete = false; + $eventDispatcher = $container->get(EventDispatcherInterface::class); + $tcaFactory = $container->get(TcaFactory::class); + if ($allowCaching) { + $container->get(ExtLocalconfFactory::class)->load(); + } else { + $container->get(ExtLocalconfFactory::class)->loadUncached(); + } + $GLOBALS['BE_USER'] = $beUserBackup; + if ($allowCaching) { + $GLOBALS['TCA'] = $tcaFactory->get(); + } else { + $GLOBALS['TCA'] = $tcaFactory->create(); + } + $container->get('boot.state')->complete = true; + if ($allowCaching) { + $container->get(TcaSchemaFactory::class)->load($GLOBALS['TCA']); + } else { + $container->get(TcaSchemaFactory::class)->rebuild($GLOBALS['TCA']); + } + $eventDispatcher->dispatch(new BootCompletedEvent($allowCaching)); + if ($resetContainer) { + $this->makeCurrent(null, $backup); + } + + return $container; + } + + public function resetGlobalContainer(): void + { + $this->makeCurrent(null, []); + } + + public function getFailsafeContainer(): ContainerInterface + { + return $this->failsafeContainer; + } + + public function unsetInternalContainerInstance(): void + { + $this->container = null; + } +} diff --git a/Classes/Core/Bootstrap.php b/Classes/Core/Bootstrap.php new file mode 100644 index 0000000..23e4ba9 --- /dev/null +++ b/Classes/Core/Bootstrap.php @@ -0,0 +1,407 @@ +exportConfiguration(); + + $logManager = new LogManager($requestId); + // LogManager is used by the core ErrorHandler (using GeneralUtility::makeInstance), + // therefore we have to push the LogManager to GeneralUtility, in case there + // happen errors before we call GeneralUtility::setContainer(). + GeneralUtility::setSingletonInstance(LogManager::class, $logManager); + + static::initializeErrorHandling(); + + $disableCaching = $failsafe ? true : false; + /** @var PhpFrontend $coreCache */ + $coreCache = static::createCache('core', $disableCaching); + $packageCache = static::createPackageCache($coreCache); + $packageManager = static::createPackageManager( + $failsafe ? FailsafePackageManager::class : PackageManager::class, + $packageCache + ); + + static::setDefaultTimezone(); + static::setMemoryLimit(); + + $dependencyInjectionContainerCache = static::createCache('di'); + + $bootState = new \stdClass(); + $bootState->complete = false; + $bootState->cacheDisabled = $disableCaching; + + $builder = new ContainerBuilder([ + ClassLoader::class => $classLoader, + ApplicationContext::class => Environment::getContext(), + ConfigurationManager::class => $configurationManager, + LogManager::class => $logManager, + RequestId::class => $requestId, + 'cache.di' => $dependencyInjectionContainerCache, + 'cache.core' => $coreCache, + PackageManager::class => $packageManager, + + // @internal + 'boot.state' => $bootState, + ]); + + $container = $builder->createDependencyInjectionContainer($packageManager, $dependencyInjectionContainerCache, $failsafe); + + // Push the container to GeneralUtility as we want to make sure its + // makeInstance() method creates classes using the container from now on. + GeneralUtility::setContainer($container); + + // Reset LogManager singleton instance in order for GeneralUtility::makeInstance() + // to proxy LogManager retrieval to ContainerInterface->get() from now on. + GeneralUtility::removeSingletonInstance(LogManager::class, $logManager); + + // Push PackageManager instance to ExtensionManagementUtility + ExtensionManagementUtility::setPackageManager($packageManager); + + if ($failsafe) { + $bootState->complete = true; + return $container; + } + + // The encryption key is part of the system configuration and must be set up + // before any extension code is executed. + static::checkEncryptionKey(); + + $eventDispatcher = $container->get(EventDispatcherInterface::class); + $container->get(ExtLocalconfFactory::class)->load(); + $tca = $container->get(TcaFactory::class)->get(); + $bootState->complete = true; + // $GLOBALS['TCA'] is only published once the schema is built, so consumers + // triggered by the schema factory can not work with a half-initialized state. + $container->get(TcaSchemaFactory::class)->load($tca); + $GLOBALS['TCA'] = $tca; + $eventDispatcher->dispatch(new BootCompletedEvent(true)); + + return $container; + } + + /** + * Sets the class loader to the bootstrap + * + * @param ClassLoader $classLoader an instance of the class loader + * @internal This is not a public API method, do not use in own extensions + */ + public static function initializeClassLoader(ClassLoader $classLoader): void + { + ClassLoadingInformation::setClassLoader($classLoader); + } + + /** + * checks if config/system/settings.php or PackageStates.php is missing, + * used to see if a redirect to the installer is needed + * + * All file_exists checks are delayed as far as possible to avoid I/O impact + * + * @return bool TRUE when the essential configuration is available, otherwise FALSE + * @internal This is not a public API method, do not use in own extensions + */ + public static function checkIfEssentialConfigurationExists(ConfigurationManager $configurationManager): bool + { + if (!Environment::isComposerMode() + && !file_exists(Environment::getPackageStatesFile()) + ) { + // Early return in case system is not properly set up + return false; + } + + // The system configuration file (settings.php) is mandatory, the additional configuration + // file (additional.php) is optional. + return file_exists($configurationManager->getSystemConfigurationFileLocation()); + } + + /** + * Initializes the package system and loads the package configuration and settings + * provided by the packages. + * + * @param string $packageManagerClassName Define an alternative package manager implementation (usually for the installer) + * @internal This is not a public API method, do not use in own extensions + */ + public static function createPackageManager($packageManagerClassName, PackageCacheInterface $packageCache): PackageManager + { + $dependencyOrderingService = GeneralUtility::makeInstance(DependencyOrderingService::class); + /** @var PackageManager $packageManager */ + $packageManager = new $packageManagerClassName($dependencyOrderingService); + $packageManager->setPackageCache($packageCache); + $packageManager->initialize(); + + return $packageManager; + } + + /** + * @internal + */ + public static function createPackageCache(FrontendInterface $coreCache): PackageCacheInterface + { + if (!Environment::isComposerMode()) { + return new PackageStatesPackageCache(Environment::getPackageStatesFile(), $coreCache); + } + + $composerInstallersPath = InstalledVersions::getInstallPath('typo3/cms-composer-installers'); + if ($composerInstallersPath === null) { + throw new \RuntimeException('Package "typo3/cms-composer-installers" not found. Replacing the package is not allowed. Fork the package instead and pull in the fork with the same name.', 1636145677); + } + + return new ComposerPackageArtifact(dirname($composerInstallersPath)); + } + + /** + * Instantiates an early cache instance + * + * Creates a cache instances independently of the CacheManager. + * The is used to create the core cache during early bootstrap when the CacheManager + * is not yet available (i.e. configuration is not yet loaded). + * + * @param class-string|null $enforcedCacheBackend + * @internal + */ + public static function createCache( + string $identifier, + bool $disableCaching = false, + ?string $enforcedCacheBackend = null + ): FrontendInterface { + $cacheConfigurations = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] ?? []; + $cacheConfigurations['di']['frontend'] = PhpFrontend::class; + $cacheConfigurations['di']['backend'] = ContainerBackend::class; + $cacheConfigurations['di']['options'] = []; + $configuration = $cacheConfigurations[$identifier] ?? []; + + $frontend = $configuration['frontend'] ?? VariableFrontend::class; + $backend = $enforcedCacheBackend ?? $configuration['backend'] ?? Typo3DatabaseBackend::class; + $options = $configuration['options'] ?? []; + + if ($disableCaching) { + $backend = NullBackend::class; + $options = []; + } + + $backendInstance = new $backend($options); + if (!$backendInstance instanceof BackendInterface) { + throw new InvalidBackendException('"' . $backend . '" is not a valid cache backend object.', 1545260108); + } + if (is_callable([$backendInstance, 'initializeObject'])) { + $backendInstance->initializeObject(); + } + + $frontendInstance = new $frontend($identifier, $backendInstance); + if (!$frontendInstance instanceof FrontendInterface) { + throw new InvalidCacheException('"' . $frontend . '" is not a valid cache frontend object.', 1545260109); + } + if (is_callable([$frontendInstance, 'initializeObject'])) { + $frontendInstance->initializeObject(); + } + + return $frontendInstance; + } + + /** + * Set default timezone + */ + protected static function setDefaultTimezone(): void + { + $timeZone = $GLOBALS['TYPO3_CONF_VARS']['SYS']['phpTimeZone']; + if (empty($timeZone)) { + // Time zone from the server environment (TZ env or OS query) + $defaultTimeZone = @date_default_timezone_get(); + if ($defaultTimeZone !== '') { + $timeZone = $defaultTimeZone; + } else { + $timeZone = 'UTC'; + } + } + // Set default to avoid E_WARNINGs with PHP > 5.3 + date_default_timezone_set($timeZone); + } + + /** + * Configure and set up exception and error handling + */ + protected static function initializeErrorHandling(): void + { + $productionExceptionHandlerClassName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['productionExceptionHandler']; + $debugExceptionHandlerClassName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['debugExceptionHandler']; + + $errorHandlerClassName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandler']; + $errorHandlerErrors = $GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] | E_USER_DEPRECATED; + $exceptionalErrors = $GLOBALS['TYPO3_CONF_VARS']['SYS']['exceptionalErrors']; + + $displayErrorsSetting = (int)$GLOBALS['TYPO3_CONF_VARS']['SYS']['displayErrors']; + switch ($displayErrorsSetting) { + case -1: + $ipMatchesDevelopmentSystem = GeneralUtility::cmpIP(NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress(), $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask']); + $exceptionHandlerClassName = $ipMatchesDevelopmentSystem ? $debugExceptionHandlerClassName : $productionExceptionHandlerClassName; + $displayErrors = $ipMatchesDevelopmentSystem ? 1 : 0; + $exceptionalErrors = $ipMatchesDevelopmentSystem ? $exceptionalErrors : 0; + break; + case 0: + $exceptionHandlerClassName = $productionExceptionHandlerClassName; + $displayErrors = 0; + break; + case 1: + $exceptionHandlerClassName = $debugExceptionHandlerClassName; + $displayErrors = 1; + break; + default: + // Throw exception if an invalid option is set. A default for displayErrors is set + // in very early install tool, coming from DefaultConfiguration.php. It is safe here + // to just throw if there is no value for whatever reason. + throw new \RuntimeException( + 'The option $TYPO3_CONF_VARS[SYS][displayErrors] is not set to "-1", "0" or "1".', + 1476046290 + ); + } + @ini_set('display_errors', (string)$displayErrors); + + if (!empty($errorHandlerClassName)) { + // Register an error handler for the given errorHandlerError + $errorHandler = GeneralUtility::makeInstance($errorHandlerClassName, $errorHandlerErrors); + $errorHandler->setExceptionalErrors($exceptionalErrors); + if (is_callable([$errorHandler, 'setDebugMode'])) { + $errorHandler->setDebugMode($displayErrors === 1); + } + if (is_callable([$errorHandler, 'registerErrorHandler'])) { + $errorHandler->registerErrorHandler(); + } + } + if (!empty($exceptionHandlerClassName)) { + // Registering the exception handler is done in the constructor + GeneralUtility::makeInstance($exceptionHandlerClassName); + } + } + + /** + * Set PHP memory limit depending on value of + * $GLOBALS['TYPO3_CONF_VARS']['SYS']['setMemoryLimit'] + */ + protected static function setMemoryLimit(): void + { + if ((int)$GLOBALS['TYPO3_CONF_VARS']['SYS']['setMemoryLimit'] > 16) { + @ini_set('memory_limit', (string)((int)$GLOBALS['TYPO3_CONF_VARS']['SYS']['setMemoryLimit'] . 'm')); + } + } + + /** + * Check if a configuration key has been configured + */ + protected static function checkEncryptionKey(): void + { + if (empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'])) { + throw new \RuntimeException( + 'TYPO3 Encryption is empty. $GLOBALS[\'TYPO3_CONF_VARS\'][\'SYS\'][\'encryptionKey\'] needs to be set for TYPO3 to work securely', + 1502987245 + ); + } + } + + /** + * Initialize backend user object in globals + * + * @param string $className usually \TYPO3\CMS\Core\Authentication\BackendUserAuthentication::class but can be used for CLI + */ + public static function initializeBackendUser($className = BackendUserAuthentication::class, ?ServerRequestInterface $request = null): BackendUserAuthentication + { + /** @var BackendUserAuthentication $backendUser */ + $backendUser = GeneralUtility::makeInstance($className); + // The global must be available very early, because methods below + // might trigger code which relies on it. See: #45625 + $GLOBALS['BE_USER'] = $backendUser; + $backendUser->start($request); + return $backendUser; + } + + /** + * Initializes and ensures authenticated access + */ + public static function initializeBackendAuthentication(): void + { + $GLOBALS['BE_USER']->backendCheckLogin(); + } +} diff --git a/Classes/Core/ClassLoadingInformation.php b/Classes/Core/ClassLoadingInformation.php new file mode 100644 index 0000000..3516e14 --- /dev/null +++ b/Classes/Core/ClassLoadingInformation.php @@ -0,0 +1,294 @@ +buildAutoloadInformationFiles(self::isTestingContext(), Environment::getProjectPath() . '/', $activeExtensionPackages); + GeneralUtility::writeFile(self::getClassLoadingInformationDirectory() . self::AUTOLOAD_CLASSMAP_FILENAME, $classInfoFiles['classMapFile'], true); + GeneralUtility::writeFile(self::getClassLoadingInformationDirectory() . self::AUTOLOAD_PSR4_FILENAME, $classInfoFiles['psr-4File'], true); + GeneralUtility::writeFile(self::getClassLoadingInformationDirectory() . self::AUTOLOAD_INCLUDE_FILENAME, $classInfoFiles['includesFile'], true); + + $classAliasMapFile = $generator->buildClassAliasMapFile($activeExtensionPackages); + GeneralUtility::writeFile(self::getClassLoadingInformationDirectory() . self::AUTOLOAD_CLASSALIASMAP_FILENAME, $classAliasMapFile, true); + } + + /** + * Registers the class aliases, the class maps and the PSR4 prefixes previously identified by + * the ClassLoadingInformationGenerator during runtime. + */ + public static function registerClassLoadingInformation() + { + $composerClassLoader = static::getClassLoader(); + + $dynamicClassAliasMapFile = self::getClassLoadingInformationDirectory() . self::AUTOLOAD_CLASSALIASMAP_FILENAME; + if (file_exists($dynamicClassAliasMapFile)) { + $classAliasMap = require $dynamicClassAliasMapFile; + if (is_array($classAliasMap) && !empty($classAliasMap['aliasToClassNameMapping']) && !empty($classAliasMap['classNameToAliasMapping'])) { + ClassAliasMap::addAliasMap($classAliasMap); + } + } + + $dynamicClassMapFile = self::getClassLoadingInformationDirectory() . self::AUTOLOAD_CLASSMAP_FILENAME; + if (file_exists($dynamicClassMapFile)) { + $classMap = require $dynamicClassMapFile; + if (!empty($classMap) && is_array($classMap)) { + $composerClassLoader->addClassMap($classMap); + } + } + + $dynamicPsr4File = self::getClassLoadingInformationDirectory() . self::AUTOLOAD_PSR4_FILENAME; + if (file_exists($dynamicPsr4File)) { + $psr4 = require $dynamicPsr4File; + if (is_array($psr4)) { + self::registerPsr4Prefixes($composerClassLoader, $psr4); + } + } + + $dynamicIncludesFile = self::getClassLoadingInformationDirectory() . self::AUTOLOAD_INCLUDE_FILENAME; + if (file_exists($dynamicIncludesFile)) { + $includes = require $dynamicIncludesFile; + if (is_array($includes)) { + foreach ($includes as $fileIdentifier => $file) { + self::requireFile($fileIdentifier, $file); + } + } + } + } + + /** + * Sets class loading information for a package for the current web request + * + * @throws \TYPO3\CMS\Core\Error\Exception + */ + public static function registerTransientClassLoadingInformationForPackage(PackageInterface $package) + { + $composerClassLoader = static::getClassLoader(); + $generator = new ClassLoadingInformationGenerator(); + $classInformation = $generator->buildClassLoadingInformationForPackage($package, false, self::isTestingContext(), Environment::getPublicPath() . '/'); + $composerClassLoader->addClassMap($classInformation['classMap']); + self::registerPsr4Prefixes($composerClassLoader, $classInformation['psr-4']); + foreach ($classInformation['files'] as $fileIdentifier => $file) { + self::requireFile($fileIdentifier, $file); + } + $classAliasMap = $generator->buildClassAliasMapForPackage($package); + if (!empty($classAliasMap['aliasToClassNameMapping']) && !empty($classAliasMap['classNameToAliasMapping'])) { + ClassAliasMap::addAliasMap($classAliasMap); + } + } + + /** + * Registers PSR-4 prefixes on the Composer class loader, keeping the directories + * that are already registered for the very same prefix. + * + * ClassLoader::setPsr4() replaces all directories of a prefix. A prefix can be used + * by more than one package, for instance when an extension ships a library of the + * same namespace as a dedicated Composer package. Extension directories take + * precedence, all other directories are kept as fallback. + * + * De-duplication keeps repeated registration idempotent: functional tests bootstrap + * TYPO3 more than once per process and would otherwise grow the directory list of a + * prefix with every single bootstrap. + * + * @param array> $psr4 + */ + private static function registerPsr4Prefixes(ClassLoader $composerClassLoader, array $psr4): void + { + $registeredPrefixes = $composerClassLoader->getPrefixesPsr4(); + foreach ($psr4 as $prefix => $paths) { + $composerClassLoader->setPsr4( + $prefix, + array_values(array_unique(array_merge((array)$paths, $registeredPrefixes[$prefix] ?? []))) + ); + } + } + + private static function requireFile(string $fileIdentifier, string $file): void + { + $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { + if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { + $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; + + require $file; + } + }, null, null); + try { + if (!file_exists($file)) { + return; + } + $requireFile($fileIdentifier, $file); + } catch (\Throwable) { + // Make sure to not break everything in case the does something weird + // and especially allow to dump new class loading information to eventually recover + } + } + + /** + * @return string + */ + protected static function getClassLoadingInformationDirectory() + { + if (self::isTestingContext()) { + return dirname(Environment::getExtensionsPath()) . '/' . self::AUTOLOAD_INFO_DIR_TESTS; + } + return dirname(Environment::getExtensionsPath()) . '/' . self::AUTOLOAD_INFO_DIR; + } + + /** + * Get class name for alias + * + * @param string $alias + * @return class-string + */ + public static function getClassNameForAlias($alias) + { + return ClassAliasMap::getClassNameForAlias($alias); + } + + /** + * Ensures the defined path for class information files exists + * And clears it in case we're in testing context + */ + protected static function ensureAutoloadInfoDirExists() + { + $autoloadInfoDir = self::getClassLoadingInformationDirectory(); + if (!file_exists($autoloadInfoDir)) { + GeneralUtility::mkdir_deep($autoloadInfoDir); + } + } + + /** + * Internal method calling the bootstrap to fetch the composer class loader + * + * @return ClassLoader + * @internal Currently used in TYPO3 testing. Public visibility is experimental and may vanish without further notice. + */ + public static function getClassLoader() + { + return static::$classLoader; + } + + /** + * Internal method calling the bootstrap to get application context information + * + * @return bool + * @throws \TYPO3\CMS\Core\Exception + */ + protected static function isTestingContext() + { + return Environment::getContext()->isTesting(); + } + + /** + * Get all packages except the protected ones, as they are covered already + * + * @return PackageInterface[] + */ + protected static function getActiveExtensionPackages() + { + $activeExtensionPackages = []; + $packageManager = GeneralUtility::makeInstance(PackageManager::class); + foreach ($packageManager->getActivePackages() as $package) { + if ($package->getPackageMetaData()->isFrameworkType()) { + // Skip all core packages as the class loading info is prepared for them already + continue; + } + $activeExtensionPackages[] = $package; + } + return $activeExtensionPackages; + } +} diff --git a/Classes/Core/ClassLoadingInformationGenerator.php b/Classes/Core/ClassLoadingInformationGenerator.php new file mode 100644 index 0000000..c56157f --- /dev/null +++ b/Classes/Core/ClassLoadingInformationGenerator.php @@ -0,0 +1,293 @@ +getPackagePath(); + $manifest = $package->getValueFromComposerManifest(); + if (empty($manifest->autoload)) { + // Legacy mode: Scan the complete extension directory for class files + // @todo: Drop this as breaking change in v14?! Extensions must deliver a proper + // composer.json nowadays, and PSR-4 can and should be strong requirement as well?! + $classMap = $this->createClassMap($packagePath, $useRelativePaths, $installationRoot, !$isDevMode); + } else { + $autoloadPsr4 = $this->getAutoloadSectionFromManifest($manifest, 'psr-4', $isDevMode); + if (!empty($autoloadPsr4)) { + foreach ($autoloadPsr4 as $namespacePrefix => $paths) { + foreach ((array)$paths as $path) { + $namespacePath = $packagePath . $path; + $namespaceRealPath = (string)realpath($namespacePath); + if ($useRelativePaths) { + $psr4[$namespacePrefix][] = $this->makePathRelative($namespacePath, $namespaceRealPath, $installationRoot); + } else { + $psr4[$namespacePrefix][] = $namespacePath; + } + if (!empty($namespaceRealPath) && is_dir($namespaceRealPath)) { + // Add all prs-4 classes to the class map for improved class loading performance + $classMap = array_merge($classMap, $this->createClassMap($namespacePath, $useRelativePaths, $installationRoot, false, $namespacePrefix)); + } + } + } + } + $autoloadClassmap = $this->getAutoloadSectionFromManifest($manifest, 'classmap', $isDevMode); + if (!empty($autoloadClassmap)) { + foreach ($autoloadClassmap as $path) { + $classMap = array_merge($classMap, $this->createClassMap($packagePath . $path, $useRelativePaths, $installationRoot)); + } + } + if ($package instanceof Package) { + $packageProvides = $package->getProvidesPackages(); + foreach ($packageProvides as $relativePath) { + if ($relativePath === '') { + continue; + } + $libraryPath = $packagePath . rtrim($relativePath, '/') . '/autoload.php'; + $libraryRealPath = (string)realpath($libraryPath); + if (!file_exists($libraryRealPath)) { + continue; + } + $contentToCheck = file_get_contents($libraryRealPath); + if (!str_contains($contentToCheck, 'return ComposerAutoloaderInit')) { + continue; + } + $relativeLibraryPath = $this->makePathRelative($libraryPath, $libraryRealPath, $installationRoot); + $fileHash = md5($package->getPackageKey() . ':' . $relativeLibraryPath); + $includeFiles[$fileHash] = $useRelativePaths ? $relativeLibraryPath : $libraryPath; + } + } + } + return [ + 'classMap' => $classMap, + 'psr-4' => $psr4, + 'files' => $includeFiles, + ]; + } + + /** + * Returns class alias map for given package + * + * @throws Exception + */ + public function buildClassAliasMapForPackage(PackageInterface $package): array + { + $aliasToClassNameMapping = []; + $classNameToAliasMapping = []; + $possibleClassAliasFiles = []; + $manifest = $package->getValueFromComposerManifest(); + if (!empty($manifest->extra->{'typo3/class-alias-loader'}->{'class-alias-maps'})) { + $possibleClassAliasFiles = $manifest->extra->{'typo3/class-alias-loader'}->{'class-alias-maps'}; + if (!is_array($possibleClassAliasFiles)) { + throw new Exception('"typo3/class-alias-loader"/"class-alias-maps" must return an array!', 1444142481); + } + } else { + $possibleClassAliasFiles[] = 'Migrations/Code/ClassAliasMap.php'; + } + $packagePath = $package->getPackagePath(); + foreach ($possibleClassAliasFiles as $possibleClassAliasFile) { + $possiblePathToClassAliasFile = $packagePath . $possibleClassAliasFile; + if (file_exists($possiblePathToClassAliasFile)) { + $packageAliasMap = require $possiblePathToClassAliasFile; + if (!is_array($packageAliasMap)) { + throw new Exception('"class alias maps" must return an array', 1422625075); + } + foreach ($packageAliasMap as $aliasClassName => $className) { + $lowerCasedAliasClassName = strtolower($aliasClassName); + $aliasToClassNameMapping[$lowerCasedAliasClassName] = $className; + $classNameToAliasMapping[$className][$lowerCasedAliasClassName] = $lowerCasedAliasClassName; + } + } + } + return [ + 'aliasToClassNameMapping' => $aliasToClassNameMapping, + 'classNameToAliasMapping' => $classNameToAliasMapping, + ]; + } + + /** + * Generate the class map file + * + * @return string[] + */ + public function buildAutoloadInformationFiles( + bool $isDevMode, + string $installationRoot, + array $activeExtensionPackages, + ): array { + $psr4File = $classMapFile = $includesFile = <<buildClassLoadingInformationForPackage($package, true, $isDevMode, $installationRoot); + $classMap = array_merge($classMap, $classLoadingInformation['classMap']); + // Accumulate per prefix: more than one package can use the same PSR-4 prefix, + // array_merge() would drop all directories but the ones of the last package. + foreach ($classLoadingInformation['psr-4'] as $namespacePrefix => $namespacePaths) { + $psr4[$namespacePrefix] = array_merge($psr4[$namespacePrefix] ?? [], $namespacePaths); + } + $includeFiles = array_merge($includeFiles, $classLoadingInformation['files']); + } + ksort($classMap); + ksort($psr4); + ksort($includeFiles); + foreach ($classMap as $class => $relativePath) { + $classMapFile .= sprintf(' %s => %s,', var_export($class, true), $this->getPathCode($relativePath)) . "\n"; + } + $classMapFile .= ");\n"; + foreach ($psr4 as $prefix => $relativePaths) { + $psr4File .= sprintf(' %s => array(%s),', var_export($prefix, true), implode(',', array_map($this->getPathCode(...), $relativePaths))) . "\n"; + } + $psr4File .= ");\n"; + foreach ($includeFiles as $hash => $relativePath) { + $includesFile .= sprintf(' %s => %s,', var_export($hash, true), $this->getPathCode($relativePath)) . "\n"; + } + $includesFile .= ");\n"; + return ['classMapFile' => $classMapFile, 'psr-4File' => $psr4File, 'includesFile' => $includesFile]; + } + + /** + * Build class alias mapping file + */ + public function buildClassAliasMapFile(array $activeExtensionPackages): string + { + $aliasToClassNameMapping = []; + $classNameToAliasMapping = []; + foreach ($activeExtensionPackages as $package) { + $aliasMappingForPackage = $this->buildClassAliasMapForPackage($package); + $aliasToClassNameMapping = array_merge($aliasToClassNameMapping, $aliasMappingForPackage['aliasToClassNameMapping']); + $classNameToAliasMapping = array_merge($classNameToAliasMapping, $aliasMappingForPackage['classNameToAliasMapping']); + } + $exportArray = [ + 'aliasToClassNameMapping' => $aliasToClassNameMapping, + 'classNameToAliasMapping' => $classNameToAliasMapping, + ]; + $fileContent = "autoload), true); + if (!empty($autoloadDefinition[$section]) && is_array($autoloadDefinition[$section])) { + $finalAutoloadSection = $autoloadDefinition[$section]; + } + if ($isDevMode) { + if (isset($manifest->{'autoload-dev'})) { + $autoloadDefinitionDev = json_decode((string)json_encode($manifest->{'autoload-dev'}), true); + if (!empty($autoloadDefinitionDev[$section]) && is_array($autoloadDefinitionDev[$section])) { + $finalAutoloadSection = array_merge($finalAutoloadSection, $autoloadDefinitionDev[$section]); + } + } + } + return $finalAutoloadSection; + } + + /** + * Creates a class map for a given absolute path + */ + protected function createClassMap( + string $classesPath, + bool $useRelativePaths, + string $installationRoot, + bool $ignorePotentialTestClasses = false, + ?string $namespace = null + ): array { + $classMap = []; + $blacklistExpression = null; + if ($ignorePotentialTestClasses) { + $blacklistPathPrefix = (string)realpath($classesPath); + $blacklistPathPrefix = str_replace('\\', '/', $blacklistPathPrefix); + $blacklistExpression = "{($blacklistPathPrefix/tests/|$blacklistPathPrefix/Tests/|$blacklistPathPrefix/Resources/|$blacklistPathPrefix/res/)}"; + } + $generator = new ClassMapGenerator(); + $generator->scanPaths($classesPath, $blacklistExpression, 'classmap', $namespace); + $map = $generator->getClassMap()->getMap(); + foreach ($map as $class => $path) { + if ($useRelativePaths) { + $classMap[$class] = $this->makePathRelative($classesPath, realpath($path), $installationRoot); + } else { + $classMap[$class] = $path; + } + } + return $classMap; + } + + /** + * Generate a relative path string from an absolute path within a give package path + */ + protected function makePathRelative(string $packagePath, string $realPathOfClassFile, string $installationRoot): string + { + $realPathOfClassFile = GeneralUtility::fixWindowsFilePath($realPathOfClassFile); + $packageRealPath = GeneralUtility::fixWindowsFilePath((string)realpath($packagePath)); + $relativePackagePath = rtrim(substr($packagePath, strlen($installationRoot)), '/'); + if ($realPathOfClassFile === $packageRealPath) { + return $relativePackagePath; + } + return $relativePackagePath . '/' . ltrim(substr($realPathOfClassFile, strlen($packageRealPath)), '/'); + } + + /** + * Generate a relative path string from a relative path + */ + protected function getPathCode(string $relativePathToClassFile): string + { + return '$typo3InstallDir . ' . var_export($relativePathToClassFile, true); + } +} diff --git a/Classes/Core/ClassLoadingInformationUpdater.php b/Classes/Core/ClassLoadingInformationUpdater.php new file mode 100644 index 0000000..b314729 --- /dev/null +++ b/Classes/Core/ClassLoadingInformationUpdater.php @@ -0,0 +1,38 @@ + (string)self::getContext(), + 'cli' => self::isCli(), + 'projectPath' => self::getProjectPath(), + 'publicPath' => self::getPublicPath(), + 'varPath' => self::getVarPath(), + 'configPath' => self::getConfigPath(), + 'currentScript' => self::getCurrentScript(), + 'os' => self::isWindows() ? 'WINDOWS' : 'UNIX', + ]; + } +} diff --git a/Classes/Core/Event/BootCompletedEvent.php b/Classes/Core/Event/BootCompletedEvent.php new file mode 100644 index 0000000..09e19d6 --- /dev/null +++ b/Classes/Core/Event/BootCompletedEvent.php @@ -0,0 +1,31 @@ +cachingEnabled; + } +} diff --git a/Classes/Core/RequestId.php b/Classes/Core/RequestId.php new file mode 100644 index 0000000..2d7e7a8 --- /dev/null +++ b/Classes/Core/RequestId.php @@ -0,0 +1,44 @@ +long = bin2hex(random_bytes(20)); + $this->short = substr($this->long, 0, 13); + $this->microtime = (int)(microtime(true) * 1000000); + $this->nonce = new ConsumableNonce(); + } + + public function __toString(): string + { + return $this->short; + } +} diff --git a/Classes/Core/SystemEnvironmentBuilder.php b/Classes/Core/SystemEnvironmentBuilder.php new file mode 100644 index 0000000..e9dad42 --- /dev/null +++ b/Classes/Core/SystemEnvironmentBuilder.php @@ -0,0 +1,352 @@ + used with Apache suexec support + * REDIRECT_TYPO3_CONTEXT -> used under some circumstances when value is set in the webserver and proxying the values to FPM + * @throws \TYPO3\CMS\Core\Exception + */ + protected static function createApplicationContext(): ApplicationContext + { + $applicationContext = getenv('TYPO3_CONTEXT') ?: (getenv('REDIRECT_TYPO3_CONTEXT') ?: (getenv('HTTP_TYPO3_CONTEXT') ?: 'Production')); + return new ApplicationContext($applicationContext); + } + + /** + * Define all simple constants that have no dependency to local configuration + */ + protected static function defineBaseConstants() + { + // A linefeed, a carriage return, a CR-LF combination + defined('LF') ?: define('LF', chr(10)); + defined('CR') ?: define('CR', chr(13)); + defined('CRLF') ?: define('CRLF', CR . LF); + + // A generic constant to state we are in TYPO3 scope. This is especially used in script files + // like ext_localconf.php that run in global scope without class encapsulation: "defined('TYPO3') or die();" + // This is a security measure to prevent script output if those files are located within document root and + // called directly without bootstrap and error handling setup. + defined('TYPO3') ?: define('TYPO3', true); + } + + /** + * Calculate script path. This is the absolute path to the entry script. + * Can be something like '.../public/index.php' for web calls, or + * '.../bin/typo3' or similar for cli calls. + * + * @param int $entryPointLevel Number of subdirectories where the entry script is located under the document root + * @return string Absolute path to entry script + */ + protected static function calculateScriptPath(int $entryPointLevel, int $requestType): string + { + $isCli = static::isCliRequestType($requestType); + // Absolute path of the entry script that was called + $scriptPath = GeneralUtility::fixWindowsFilePath((string)static::getPathThisScript($isCli)); + $rootPath = static::getRootPathFromScriptPath($scriptPath, $entryPointLevel); + // Check if the root path has been set in the environment (e.g. by the composer installer) + $rootPathFromEnvironment = static::getDefinedPathRoot(); + if ($rootPathFromEnvironment) { + if ($isCli && static::usesComposerClassLoading()) { + // $scriptPath is used for various path calculations based on the document root + // Therefore we assume it is always a subdirectory of the document root, which is not the case + // in composer mode on cli, as the binary is in the composer bin directory. + // Because of that, we enforce the document root path of this binary to be set + $scriptName = 'typo3/sysext/core/bin/typo3'; + } else { + // Base the script path on the path taken from the environment + // to make relative path calculations work in case only one of both is symlinked + // or has the real path + $scriptName = ltrim(substr($scriptPath, strlen($rootPath)), '/'); + } + $rootPath = rtrim(GeneralUtility::fixWindowsFilePath($rootPathFromEnvironment), '/'); + $scriptPath = $rootPath . '/' . $scriptName; + } + return $scriptPath; + } + + /** + * Absolute path to the "classic" site root of the TYPO3 application. + * This semantically refers to the directory where executable server-side code, configuration + * and runtime files are located (e.g. typo3conf/ext, typo3/sysext, typo3temp/var). + * In practice this is always identical to the public web document root path which contains + * files that are served by the webserver directly (fileadmin/ and public resources). + * + * This is not to be confused with the app-path that is used in composer-mode installations (by default). + * Resources in app-path are located outside the document root. + * + * @param int $entryPointLevel Number of subdirectories where the entry script is located under the document root + * @param int $requestType + * @return string Absolute path without trailing slash + */ + protected static function calculateRootPath(int $entryPointLevel, int $requestType): string + { + // Check if the root path has been set in the environment (e.g. by the composer installer) + $pathRoot = static::getDefinedPathRoot(); + if ($pathRoot) { + return rtrim(GeneralUtility::fixWindowsFilePath($pathRoot), '/'); + } + $isCli = static::isCliRequestType($requestType); + // Absolute path of the entry script that was called + $scriptPath = GeneralUtility::fixWindowsFilePath((string)static::getPathThisScript($isCli)); + return static::getRootPathFromScriptPath($scriptPath, $entryPointLevel); + } + + /** + * Set up / initialize several globals variables + */ + protected static function initializeGlobalVariables() + { + // Unset variable(s) in global scope (security issue #13959) + $GLOBALS['T3_SERVICES'] = []; + } + + /** + * Initialize global time tracking variables. + * These are helpers to for example output script parsetime at the end of a script. + */ + protected static function initializeGlobalTimeTrackingVariables() + { + // EXEC_TIME is set so that the rest of the script has a common value for the script execution time + $GLOBALS['EXEC_TIME'] = time(); + // $ACCESS_TIME is a common time in minutes for access control + $GLOBALS['ACCESS_TIME'] = $GLOBALS['EXEC_TIME'] - $GLOBALS['EXEC_TIME'] % 60; + // $SIM_EXEC_TIME is set to $EXEC_TIME but can be altered later in the script if we want to + // simulate another execution-time when selecting from eg. a database + $GLOBALS['SIM_EXEC_TIME'] = $GLOBALS['EXEC_TIME']; + // If $SIM_EXEC_TIME is changed this value must be set accordingly + $GLOBALS['SIM_ACCESS_TIME'] = $GLOBALS['ACCESS_TIME']; + } + + protected static function getDefinedPathRoot(): string + { + return getenv('TYPO3_PATH_ROOT') ?: getenv('REDIRECT_TYPO3_PATH_ROOT') ?: ''; + } + + /** + * Initialize the Environment class + */ + protected static function initializeEnvironment(int $requestType, string $scriptPath, string $sitePath) + { + $pathRoot = static::getDefinedPathRoot(); + if ($pathRoot) { + $rootPathFromEnvironment = rtrim(GeneralUtility::fixWindowsFilePath($pathRoot), '/'); + if ($sitePath !== $rootPathFromEnvironment) { + // This means, that we re-initialized the environment during a single request + // This currently only happens in custom code or during functional testing + // Once the constants are removed, we might be able to remove this code here as well and directly pass an environment to the application + $scriptPath = $rootPathFromEnvironment . substr($scriptPath, strlen($sitePath)); + $sitePath = $rootPathFromEnvironment; + } + } + + $projectRootPath = (string)(getenv('TYPO3_PATH_APP') ?: getenv('REDIRECT_TYPO3_PATH_APP') ?: ''); + $projectRootPath = GeneralUtility::fixWindowsFilePath($projectRootPath); + $isDifferentRootPath = ($projectRootPath && $projectRootPath !== $sitePath); + Environment::initialize( + static::createApplicationContext(), + static::isCliRequestType($requestType), + static::usesComposerClassLoading(), + $isDifferentRootPath ? $projectRootPath : $sitePath, + $sitePath, + $isDifferentRootPath ? $projectRootPath . '/var' : $sitePath . '/typo3temp/var', + $isDifferentRootPath ? $projectRootPath . '/config' : $sitePath . '/typo3conf', + $scriptPath, + static::isRunningOnWindows() ? 'WINDOWS' : 'UNIX' + ); + } + + /** + * Determine if the operating system TYPO3 is running on is windows. + */ + protected static function isRunningOnWindows(): bool + { + return stripos(PHP_OS, 'darwin') === false + && stripos(PHP_OS, 'cygwin') === false + && stripos(PHP_OS, 'win') !== false; + } + + /** + * Calculate script path. + * + * First step in path calculation: Goal is to find the absolute path of the entry script + * that was called without resolving any links. This is important since the TYPO3 entry + * points are often linked to a central core location, so we can not use the php magic + * __FILE__ here, but resolve the called script path from given server environments. + * + * This path is important to calculate the document root. The strategy is to + * find out the script name that was called in the first place and to subtract the local + * part from it to find the document root. + * + * @param bool $isCli + * @return string Absolute path to entry script + */ + protected static function getPathThisScript(bool $isCli) + { + if ($isCli) { + return static::getPathThisScriptCli(); + } + return static::getPathThisScriptNonCli(); + } + + /** + * Return path to entry script if not in cli mode. + * + * @return string Absolute path to entry script + */ + protected static function getPathThisScriptNonCli() + { + if (Environment::isRunningOnCgiServer() && !Environment::usesCgiFixPathInfo()) { + throw new \Exception('TYPO3 does only support being used with cgi.fix_pathinfo=1 on CGI server APIs.', 1675108421); + } + + return $_SERVER['SCRIPT_FILENAME']; + } + + /** + * Calculate path to entry script if in cli mode. + * + * First argument of a cli script is the path to the script that was called. If the script does not start + * with / (or A:\ for Windows), the path is not absolute yet, and the current working directory is added. + * + * @return string Absolute path to entry script + */ + protected static function getPathThisScriptCli() + { + // Possible relative path of the called script + $scriptPath = $_SERVER['argv'][0] ?? $_ENV['_'] ?? $_SERVER['_']; + // Find out if path is relative or not + $isRelativePath = false; + if (self::isRunningOnWindows()) { + if (!preg_match('/^([a-zA-Z]:)?\\\\/', $scriptPath)) { + $isRelativePath = true; + } + } elseif ($scriptPath[0] !== '/') { + $isRelativePath = true; + } + // Concatenate path to current working directory with relative path and remove "/./" constructs + if ($isRelativePath) { + $workingDirectory = $_SERVER['PWD'] ?? getcwd(); + $scriptPath = $workingDirectory . '/' . preg_replace('/\\.\\//', '', $scriptPath); + } + return $scriptPath; + } + + /** + * Calculate the document root part to the instance from $scriptPath. + * This is based on the amount of subdirectories "under" root path where $scriptPath is located. + * + * The following main scenarios for entry points exist by default in the TYPO3 core: + * - Directly called documentRoot/index.php (-> FE, BE or install tool call): + * index.php is located in the same directory as the main project. + * The document root is identical to the directory the script is located at. + * - The CLI script 'typo3/sysext/core/bin/typo3' which is located inside typo3/ directly. + * + * @param string $scriptPath Calculated path to the entry script + * @param int $entryPointLevel Number of subdirectories where the entry script is located under the document root + * @return string Absolute path to document root of installation without trailing slash + */ + protected static function getRootPathFromScriptPath($scriptPath, $entryPointLevel) + { + $entryScriptDirectory = PathUtility::dirnameDuringBootstrap($scriptPath); + if ($entryPointLevel > 0) { + [$rootPath] = GeneralUtility::revExplode('/', $entryScriptDirectory, $entryPointLevel + 1); + } else { + $rootPath = $entryScriptDirectory; + } + return $rootPath; + } + + protected static function usesComposerClassLoading(): bool + { + return defined('TYPO3_COMPOSER_MODE') && TYPO3_COMPOSER_MODE; + } + + /** + * Checks if request type is cli. + * Falls back to check PHP_SAPI in case request type is not provided + */ + protected static function isCliRequestType(?int $requestType): bool + { + if ($requestType === null) { + return PHP_SAPI === 'cli'; + } + + return ($requestType & self::REQUESTTYPE_CLI) === self::REQUESTTYPE_CLI; + } +} diff --git a/Classes/Country/Country.php b/Classes/Country/Country.php new file mode 100644 index 0000000..f2307c7 --- /dev/null +++ b/Classes/Country/Country.php @@ -0,0 +1,82 @@ +name; + } + public function getLocalizedNameLabel(): string + { + return 'LLL:' . self::LABEL_FILE . ':' . $this->alpha2 . '.name'; + } + + public function getOfficialName(): ?string + { + return $this->officialName; + } + + public function getLocalizedOfficialNameLabel(): string + { + return 'LLL:' . self::LABEL_FILE . ':' . $this->alpha2 . '.official_name'; + } + + public function getAlpha2IsoCode(): string + { + return $this->alpha2; + } + + public function getAlpha3IsoCode(): string + { + return $this->alpha3; + } + + public function getNumericRepresentation(): string + { + return $this->numeric; + } + + public function getFlag(): string + { + return $this->flag; + } + + public function __toString(): string + { + // This helper method allows to easily use the Object in Fluid or Extbase + // context and pass along to `` / `LocalizationUtility::translate()` + return $this->getLocalizedNameLabel(); + } +} diff --git a/Classes/Country/CountryFilter.php b/Classes/Country/CountryFilter.php new file mode 100644 index 0000000..9f69874 --- /dev/null +++ b/Classes/Country/CountryFilter.php @@ -0,0 +1,66 @@ +excludeCountries); + } + + /** + * @param string[] $excludeCountries + * @return $this + */ + public function setExcludeCountries(array $excludeCountries): CountryFilter + { + $this->excludeCountries = $excludeCountries; + return $this; + } + + /** + * @return string[] + */ + public function getOnlyCountries(): array + { + return array_map(strtoupper(...), $this->onlyCountries); + } + + /** + * @param string[] $onlyCountries + * @return $this + */ + public function setOnlyCountries(array $onlyCountries): CountryFilter + { + $this->onlyCountries = $onlyCountries; + return $this; + } +} diff --git a/Classes/Country/CountryProvider.php b/Classes/Country/CountryProvider.php new file mode 100644 index 0000000..9def9fc --- /dev/null +++ b/Classes/Country/CountryProvider.php @@ -0,0 +1,1827 @@ + [ + 'alpha_3' => 'AND', + 'flag' => '🇦🇩', + 'name' => 'Andorra', + 'numeric' => '020', + 'official_name' => 'Principality of Andorra', + ], + 'AE' => [ + 'alpha_3' => 'ARE', + 'flag' => '🇦🇪', + 'name' => 'United Arab Emirates', + 'numeric' => '784', + ], + 'AF' => [ + 'alpha_3' => 'AFG', + 'flag' => '🇦🇫', + 'name' => 'Afghanistan', + 'numeric' => '004', + 'official_name' => 'Islamic Republic of Afghanistan', + ], + 'AG' => [ + 'alpha_3' => 'ATG', + 'flag' => '🇦🇬', + 'name' => 'Antigua and Barbuda', + 'numeric' => '028', + ], + 'AI' => [ + 'alpha_3' => 'AIA', + 'flag' => '🇦🇮', + 'name' => 'Anguilla', + 'numeric' => '660', + ], + 'AL' => [ + 'alpha_3' => 'ALB', + 'flag' => '🇦🇱', + 'name' => 'Albania', + 'numeric' => '008', + 'official_name' => 'Republic of Albania', + ], + 'AM' => [ + 'alpha_3' => 'ARM', + 'flag' => '🇦🇲', + 'name' => 'Armenia', + 'numeric' => '051', + 'official_name' => 'Republic of Armenia', + ], + 'AO' => [ + 'alpha_3' => 'AGO', + 'flag' => '🇦🇴', + 'name' => 'Angola', + 'numeric' => '024', + 'official_name' => 'Republic of Angola', + ], + 'AQ' => [ + 'alpha_3' => 'ATA', + 'flag' => '🇦🇶', + 'name' => 'Antarctica', + 'numeric' => '010', + ], + 'AR' => [ + 'alpha_3' => 'ARG', + 'flag' => '🇦🇷', + 'name' => 'Argentina', + 'numeric' => '032', + 'official_name' => 'Argentine Republic', + ], + 'AS' => [ + 'alpha_3' => 'ASM', + 'flag' => '🇦🇸', + 'name' => 'American Samoa', + 'numeric' => '016', + ], + 'AT' => [ + 'alpha_3' => 'AUT', + 'flag' => '🇦🇹', + 'name' => 'Austria', + 'numeric' => '040', + 'official_name' => 'Republic of Austria', + ], + 'AU' => [ + 'alpha_3' => 'AUS', + 'flag' => '🇦🇺', + 'name' => 'Australia', + 'numeric' => '036', + ], + 'AW' => [ + 'alpha_3' => 'ABW', + 'flag' => '🇦🇼', + 'name' => 'Aruba', + 'numeric' => '533', + ], + 'AX' => [ + 'alpha_3' => 'ALA', + 'flag' => '🇦🇽', + 'name' => 'Åland Islands', + 'numeric' => '248', + ], + 'AZ' => [ + 'alpha_3' => 'AZE', + 'flag' => '🇦🇿', + 'name' => 'Azerbaijan', + 'numeric' => '031', + 'official_name' => 'Republic of Azerbaijan', + ], + 'BA' => [ + 'alpha_3' => 'BIH', + 'flag' => '🇧🇦', + 'name' => 'Bosnia and Herzegovina', + 'numeric' => '070', + 'official_name' => 'Republic of Bosnia and Herzegovina', + ], + 'BB' => [ + 'alpha_3' => 'BRB', + 'flag' => '🇧🇧', + 'name' => 'Barbados', + 'numeric' => '052', + ], + 'BD' => [ + 'alpha_3' => 'BGD', + 'flag' => '🇧🇩', + 'name' => 'Bangladesh', + 'numeric' => '050', + 'official_name' => 'People\'s Republic of Bangladesh', + ], + 'BE' => [ + 'alpha_3' => 'BEL', + 'flag' => '🇧🇪', + 'name' => 'Belgium', + 'numeric' => '056', + 'official_name' => 'Kingdom of Belgium', + ], + 'BF' => [ + 'alpha_3' => 'BFA', + 'flag' => '🇧🇫', + 'name' => 'Burkina Faso', + 'numeric' => '854', + ], + 'BG' => [ + 'alpha_3' => 'BGR', + 'flag' => '🇧🇬', + 'name' => 'Bulgaria', + 'numeric' => '100', + 'official_name' => 'Republic of Bulgaria', + ], + 'BH' => [ + 'alpha_3' => 'BHR', + 'flag' => '🇧🇭', + 'name' => 'Bahrain', + 'numeric' => '048', + 'official_name' => 'Kingdom of Bahrain', + ], + 'BI' => [ + 'alpha_3' => 'BDI', + 'flag' => '🇧🇮', + 'name' => 'Burundi', + 'numeric' => '108', + 'official_name' => 'Republic of Burundi', + ], + 'BJ' => [ + 'alpha_3' => 'BEN', + 'flag' => '🇧🇯', + 'name' => 'Benin', + 'numeric' => '204', + 'official_name' => 'Republic of Benin', + ], + 'BL' => [ + 'alpha_3' => 'BLM', + 'flag' => '🇧🇱', + 'name' => 'Saint Barthélemy', + 'numeric' => '652', + ], + 'BM' => [ + 'alpha_3' => 'BMU', + 'flag' => '🇧🇲', + 'name' => 'Bermuda', + 'numeric' => '060', + ], + 'BN' => [ + 'alpha_3' => 'BRN', + 'flag' => '🇧🇳', + 'name' => 'Brunei Darussalam', + 'numeric' => '096', + ], + 'BO' => [ + 'alpha_3' => 'BOL', + 'common_name' => 'Bolivia', + 'flag' => '🇧🇴', + 'name' => 'Bolivia, Plurinational State of', + 'numeric' => '068', + 'official_name' => 'Plurinational State of Bolivia', + ], + 'BQ' => [ + 'alpha_3' => 'BES', + 'flag' => '🇧🇶', + 'name' => 'Bonaire, Sint Eustatius and Saba', + 'numeric' => '535', + 'official_name' => 'Bonaire, Sint Eustatius and Saba', + ], + 'BR' => [ + 'alpha_3' => 'BRA', + 'flag' => '🇧🇷', + 'name' => 'Brazil', + 'numeric' => '076', + 'official_name' => 'Federative Republic of Brazil', + ], + 'BS' => [ + 'alpha_3' => 'BHS', + 'flag' => '🇧🇸', + 'name' => 'Bahamas', + 'numeric' => '044', + 'official_name' => 'Commonwealth of the Bahamas', + ], + 'BT' => [ + 'alpha_3' => 'BTN', + 'flag' => '🇧🇹', + 'name' => 'Bhutan', + 'numeric' => '064', + 'official_name' => 'Kingdom of Bhutan', + ], + 'BV' => [ + 'alpha_3' => 'BVT', + 'flag' => '🇧🇻', + 'name' => 'Bouvet Island', + 'numeric' => '074', + ], + 'BW' => [ + 'alpha_3' => 'BWA', + 'flag' => '🇧🇼', + 'name' => 'Botswana', + 'numeric' => '072', + 'official_name' => 'Republic of Botswana', + ], + 'BY' => [ + 'alpha_3' => 'BLR', + 'flag' => '🇧🇾', + 'name' => 'Belarus', + 'numeric' => '112', + 'official_name' => 'Republic of Belarus', + ], + 'BZ' => [ + 'alpha_3' => 'BLZ', + 'flag' => '🇧🇿', + 'name' => 'Belize', + 'numeric' => '084', + ], + 'CA' => [ + 'alpha_3' => 'CAN', + 'flag' => '🇨🇦', + 'name' => 'Canada', + 'numeric' => '124', + ], + 'CC' => [ + 'alpha_3' => 'CCK', + 'flag' => '🇨🇨', + 'name' => 'Cocos (Keeling) Islands', + 'numeric' => '166', + ], + 'CD' => [ + 'alpha_3' => 'COD', + 'flag' => '🇨🇩', + 'name' => 'Congo, The Democratic Republic of the', + 'numeric' => '180', + ], + 'CF' => [ + 'alpha_3' => 'CAF', + 'flag' => '🇨🇫', + 'name' => 'Central African Republic', + 'numeric' => '140', + ], + 'CG' => [ + 'alpha_3' => 'COG', + 'flag' => '🇨🇬', + 'name' => 'Congo', + 'numeric' => '178', + 'official_name' => 'Republic of the Congo', + ], + 'CH' => [ + 'alpha_3' => 'CHE', + 'flag' => '🇨🇭', + 'name' => 'Switzerland', + 'numeric' => '756', + 'official_name' => 'Swiss Confederation', + ], + 'CI' => [ + 'alpha_3' => 'CIV', + 'flag' => '🇨🇮', + 'name' => 'Côte d\'Ivoire', + 'numeric' => '384', + 'official_name' => 'Republic of Côte d\'Ivoire', + ], + 'CK' => [ + 'alpha_3' => 'COK', + 'flag' => '🇨🇰', + 'name' => 'Cook Islands', + 'numeric' => '184', + ], + 'CL' => [ + 'alpha_3' => 'CHL', + 'flag' => '🇨🇱', + 'name' => 'Chile', + 'numeric' => '152', + 'official_name' => 'Republic of Chile', + ], + 'CM' => [ + 'alpha_3' => 'CMR', + 'flag' => '🇨🇲', + 'name' => 'Cameroon', + 'numeric' => '120', + 'official_name' => 'Republic of Cameroon', + ], + 'CN' => [ + 'alpha_3' => 'CHN', + 'flag' => '🇨🇳', + 'name' => 'China', + 'numeric' => '156', + 'official_name' => 'People\'s Republic of China', + ], + 'CO' => [ + 'alpha_3' => 'COL', + 'flag' => '🇨🇴', + 'name' => 'Colombia', + 'numeric' => '170', + 'official_name' => 'Republic of Colombia', + ], + 'CR' => [ + 'alpha_3' => 'CRI', + 'flag' => '🇨🇷', + 'name' => 'Costa Rica', + 'numeric' => '188', + 'official_name' => 'Republic of Costa Rica', + ], + 'CU' => [ + 'alpha_3' => 'CUB', + 'flag' => '🇨🇺', + 'name' => 'Cuba', + 'numeric' => '192', + 'official_name' => 'Republic of Cuba', + ], + 'CV' => [ + 'alpha_3' => 'CPV', + 'flag' => '🇨🇻', + 'name' => 'Cabo Verde', + 'numeric' => '132', + 'official_name' => 'Republic of Cabo Verde', + ], + 'CW' => [ + 'alpha_3' => 'CUW', + 'flag' => '🇨🇼', + 'name' => 'Curaçao', + 'numeric' => '531', + 'official_name' => 'Curaçao', + ], + 'CX' => [ + 'alpha_3' => 'CXR', + 'flag' => '🇨🇽', + 'name' => 'Christmas Island', + 'numeric' => '162', + ], + 'CY' => [ + 'alpha_3' => 'CYP', + 'flag' => '🇨🇾', + 'name' => 'Cyprus', + 'numeric' => '196', + 'official_name' => 'Republic of Cyprus', + ], + 'CZ' => [ + 'alpha_3' => 'CZE', + 'flag' => '🇨🇿', + 'name' => 'Czechia', + 'numeric' => '203', + 'official_name' => 'Czech Republic', + ], + 'DE' => [ + 'alpha_3' => 'DEU', + 'flag' => '🇩🇪', + 'name' => 'Germany', + 'numeric' => '276', + 'official_name' => 'Federal Republic of Germany', + ], + 'DJ' => [ + 'alpha_3' => 'DJI', + 'flag' => '🇩🇯', + 'name' => 'Djibouti', + 'numeric' => '262', + 'official_name' => 'Republic of Djibouti', + ], + 'DK' => [ + 'alpha_3' => 'DNK', + 'flag' => '🇩🇰', + 'name' => 'Denmark', + 'numeric' => '208', + 'official_name' => 'Kingdom of Denmark', + ], + 'DM' => [ + 'alpha_3' => 'DMA', + 'flag' => '🇩🇲', + 'name' => 'Dominica', + 'numeric' => '212', + 'official_name' => 'Commonwealth of Dominica', + ], + 'DO' => [ + 'alpha_3' => 'DOM', + 'flag' => '🇩🇴', + 'name' => 'Dominican Republic', + 'numeric' => '214', + ], + 'DZ' => [ + 'alpha_3' => 'DZA', + 'flag' => '🇩🇿', + 'name' => 'Algeria', + 'numeric' => '012', + 'official_name' => 'People\'s Democratic Republic of Algeria', + ], + 'EC' => [ + 'alpha_3' => 'ECU', + 'flag' => '🇪🇨', + 'name' => 'Ecuador', + 'numeric' => '218', + 'official_name' => 'Republic of Ecuador', + ], + 'EE' => [ + 'alpha_3' => 'EST', + 'flag' => '🇪🇪', + 'name' => 'Estonia', + 'numeric' => '233', + 'official_name' => 'Republic of Estonia', + ], + 'EG' => [ + 'alpha_3' => 'EGY', + 'flag' => '🇪🇬', + 'name' => 'Egypt', + 'numeric' => '818', + 'official_name' => 'Arab Republic of Egypt', + ], + 'EH' => [ + 'alpha_3' => 'ESH', + 'flag' => '🇪🇭', + 'name' => 'Western Sahara', + 'numeric' => '732', + ], + 'ER' => [ + 'alpha_3' => 'ERI', + 'flag' => '🇪🇷', + 'name' => 'Eritrea', + 'numeric' => '232', + 'official_name' => 'the State of Eritrea', + ], + 'ES' => [ + 'alpha_3' => 'ESP', + 'flag' => '🇪🇸', + 'name' => 'Spain', + 'numeric' => '724', + 'official_name' => 'Kingdom of Spain', + ], + 'ET' => [ + 'alpha_3' => 'ETH', + 'flag' => '🇪🇹', + 'name' => 'Ethiopia', + 'numeric' => '231', + 'official_name' => 'Federal Democratic Republic of Ethiopia', + ], + 'FI' => [ + 'alpha_3' => 'FIN', + 'flag' => '🇫🇮', + 'name' => 'Finland', + 'numeric' => '246', + 'official_name' => 'Republic of Finland', + ], + 'FJ' => [ + 'alpha_3' => 'FJI', + 'flag' => '🇫🇯', + 'name' => 'Fiji', + 'numeric' => '242', + 'official_name' => 'Republic of Fiji', + ], + 'FK' => [ + 'alpha_3' => 'FLK', + 'flag' => '🇫🇰', + 'name' => 'Falkland Islands (Malvinas)', + 'numeric' => '238', + ], + 'FM' => [ + 'alpha_3' => 'FSM', + 'flag' => '🇫🇲', + 'name' => 'Micronesia, Federated States of', + 'numeric' => '583', + 'official_name' => 'Federated States of Micronesia', + ], + 'FO' => [ + 'alpha_3' => 'FRO', + 'flag' => '🇫🇴', + 'name' => 'Faroe Islands', + 'numeric' => '234', + ], + 'FR' => [ + 'alpha_3' => 'FRA', + 'flag' => '🇫🇷', + 'name' => 'France', + 'numeric' => '250', + 'official_name' => 'French Republic', + ], + 'GA' => [ + 'alpha_3' => 'GAB', + 'flag' => '🇬🇦', + 'name' => 'Gabon', + 'numeric' => '266', + 'official_name' => 'Gabonese Republic', + ], + 'GB' => [ + 'alpha_3' => 'GBR', + 'flag' => '🇬🇧', + 'name' => 'United Kingdom', + 'numeric' => '826', + 'official_name' => 'United Kingdom of Great Britain and Northern Ireland', + ], + 'GD' => [ + 'alpha_3' => 'GRD', + 'flag' => '🇬🇩', + 'name' => 'Grenada', + 'numeric' => '308', + ], + 'GE' => [ + 'alpha_3' => 'GEO', + 'flag' => '🇬🇪', + 'name' => 'Georgia', + 'numeric' => '268', + ], + 'GF' => [ + 'alpha_3' => 'GUF', + 'flag' => '🇬🇫', + 'name' => 'French Guiana', + 'numeric' => '254', + ], + 'GG' => [ + 'alpha_3' => 'GGY', + 'flag' => '🇬🇬', + 'name' => 'Guernsey', + 'numeric' => '831', + ], + 'GH' => [ + 'alpha_3' => 'GHA', + 'flag' => '🇬🇭', + 'name' => 'Ghana', + 'numeric' => '288', + 'official_name' => 'Republic of Ghana', + ], + 'GI' => [ + 'alpha_3' => 'GIB', + 'flag' => '🇬🇮', + 'name' => 'Gibraltar', + 'numeric' => '292', + ], + 'GL' => [ + 'alpha_3' => 'GRL', + 'flag' => '🇬🇱', + 'name' => 'Greenland', + 'numeric' => '304', + ], + 'GM' => [ + 'alpha_3' => 'GMB', + 'flag' => '🇬🇲', + 'name' => 'Gambia', + 'numeric' => '270', + 'official_name' => 'Republic of the Gambia', + ], + 'GN' => [ + 'alpha_3' => 'GIN', + 'flag' => '🇬🇳', + 'name' => 'Guinea', + 'numeric' => '324', + 'official_name' => 'Republic of Guinea', + ], + 'GP' => [ + 'alpha_3' => 'GLP', + 'flag' => '🇬🇵', + 'name' => 'Guadeloupe', + 'numeric' => '312', + ], + 'GQ' => [ + 'alpha_3' => 'GNQ', + 'flag' => '🇬🇶', + 'name' => 'Equatorial Guinea', + 'numeric' => '226', + 'official_name' => 'Republic of Equatorial Guinea', + ], + 'GR' => [ + 'alpha_3' => 'GRC', + 'flag' => '🇬🇷', + 'name' => 'Greece', + 'numeric' => '300', + 'official_name' => 'Hellenic Republic', + ], + 'GS' => [ + 'alpha_3' => 'SGS', + 'flag' => '🇬🇸', + 'name' => 'South Georgia and the South Sandwich Islands', + 'numeric' => '239', + ], + 'GT' => [ + 'alpha_3' => 'GTM', + 'flag' => '🇬🇹', + 'name' => 'Guatemala', + 'numeric' => '320', + 'official_name' => 'Republic of Guatemala', + ], + 'GU' => [ + 'alpha_3' => 'GUM', + 'flag' => '🇬🇺', + 'name' => 'Guam', + 'numeric' => '316', + ], + 'GW' => [ + 'alpha_3' => 'GNB', + 'flag' => '🇬🇼', + 'name' => 'Guinea-Bissau', + 'numeric' => '624', + 'official_name' => 'Republic of Guinea-Bissau', + ], + 'GY' => [ + 'alpha_3' => 'GUY', + 'flag' => '🇬🇾', + 'name' => 'Guyana', + 'numeric' => '328', + 'official_name' => 'Republic of Guyana', + ], + 'HK' => [ + 'alpha_3' => 'HKG', + 'flag' => '🇭🇰', + 'name' => 'Hong Kong', + 'numeric' => '344', + 'official_name' => 'Hong Kong Special Administrative Region of China', + ], + 'HM' => [ + 'alpha_3' => 'HMD', + 'flag' => '🇭🇲', + 'name' => 'Heard Island and McDonald Islands', + 'numeric' => '334', + ], + 'HN' => [ + 'alpha_3' => 'HND', + 'flag' => '🇭🇳', + 'name' => 'Honduras', + 'numeric' => '340', + 'official_name' => 'Republic of Honduras', + ], + 'HR' => [ + 'alpha_3' => 'HRV', + 'flag' => '🇭🇷', + 'name' => 'Croatia', + 'numeric' => '191', + 'official_name' => 'Republic of Croatia', + ], + 'HT' => [ + 'alpha_3' => 'HTI', + 'flag' => '🇭🇹', + 'name' => 'Haiti', + 'numeric' => '332', + 'official_name' => 'Republic of Haiti', + ], + 'HU' => [ + 'alpha_3' => 'HUN', + 'flag' => '🇭🇺', + 'name' => 'Hungary', + 'numeric' => '348', + 'official_name' => 'Hungary', + ], + 'ID' => [ + 'alpha_3' => 'IDN', + 'flag' => '🇮🇩', + 'name' => 'Indonesia', + 'numeric' => '360', + 'official_name' => 'Republic of Indonesia', + ], + 'IE' => [ + 'alpha_3' => 'IRL', + 'flag' => '🇮🇪', + 'name' => 'Ireland', + 'numeric' => '372', + ], + 'IL' => [ + 'alpha_3' => 'ISR', + 'flag' => '🇮🇱', + 'name' => 'Israel', + 'numeric' => '376', + 'official_name' => 'State of Israel', + ], + 'IM' => [ + 'alpha_3' => 'IMN', + 'flag' => '🇮🇲', + 'name' => 'Isle of Man', + 'numeric' => '833', + ], + 'IN' => [ + 'alpha_3' => 'IND', + 'flag' => '🇮🇳', + 'name' => 'India', + 'numeric' => '356', + 'official_name' => 'Republic of India', + ], + 'IO' => [ + 'alpha_3' => 'IOT', + 'flag' => '🇮🇴', + 'name' => 'British Indian Ocean Territory', + 'numeric' => '086', + ], + 'IQ' => [ + 'alpha_3' => 'IRQ', + 'flag' => '🇮🇶', + 'name' => 'Iraq', + 'numeric' => '368', + 'official_name' => 'Republic of Iraq', + ], + 'IR' => [ + 'alpha_3' => 'IRN', + 'common_name' => 'Iran', + 'flag' => '🇮🇷', + 'name' => 'Iran, Islamic Republic of', + 'numeric' => '364', + 'official_name' => 'Islamic Republic of Iran', + ], + 'IS' => [ + 'alpha_3' => 'ISL', + 'flag' => '🇮🇸', + 'name' => 'Iceland', + 'numeric' => '352', + 'official_name' => 'Republic of Iceland', + ], + 'IT' => [ + 'alpha_3' => 'ITA', + 'flag' => '🇮🇹', + 'name' => 'Italy', + 'numeric' => '380', + 'official_name' => 'Italian Republic', + ], + 'JE' => [ + 'alpha_3' => 'JEY', + 'flag' => '🇯🇪', + 'name' => 'Jersey', + 'numeric' => '832', + ], + 'JM' => [ + 'alpha_3' => 'JAM', + 'flag' => '🇯🇲', + 'name' => 'Jamaica', + 'numeric' => '388', + ], + 'JO' => [ + 'alpha_3' => 'JOR', + 'flag' => '🇯🇴', + 'name' => 'Jordan', + 'numeric' => '400', + 'official_name' => 'Hashemite Kingdom of Jordan', + ], + 'JP' => [ + 'alpha_3' => 'JPN', + 'flag' => '🇯🇵', + 'name' => 'Japan', + 'numeric' => '392', + ], + 'KE' => [ + 'alpha_3' => 'KEN', + 'flag' => '🇰🇪', + 'name' => 'Kenya', + 'numeric' => '404', + 'official_name' => 'Republic of Kenya', + ], + 'KG' => [ + 'alpha_3' => 'KGZ', + 'flag' => '🇰🇬', + 'name' => 'Kyrgyzstan', + 'numeric' => '417', + 'official_name' => 'Kyrgyz Republic', + ], + 'KH' => [ + 'alpha_3' => 'KHM', + 'flag' => '🇰🇭', + 'name' => 'Cambodia', + 'numeric' => '116', + 'official_name' => 'Kingdom of Cambodia', + ], + 'KI' => [ + 'alpha_3' => 'KIR', + 'flag' => '🇰🇮', + 'name' => 'Kiribati', + 'numeric' => '296', + 'official_name' => 'Republic of Kiribati', + ], + 'KM' => [ + 'alpha_3' => 'COM', + 'flag' => '🇰🇲', + 'name' => 'Comoros', + 'numeric' => '174', + 'official_name' => 'Union of the Comoros', + ], + 'KN' => [ + 'alpha_3' => 'KNA', + 'flag' => '🇰🇳', + 'name' => 'Saint Kitts and Nevis', + 'numeric' => '659', + ], + 'KP' => [ + 'alpha_3' => 'PRK', + 'common_name' => 'North Korea', + 'flag' => '🇰🇵', + 'name' => 'Korea, Democratic People\'s Republic of', + 'numeric' => '408', + 'official_name' => 'Democratic People\'s Republic of Korea', + ], + 'KR' => [ + 'alpha_3' => 'KOR', + 'common_name' => 'South Korea', + 'flag' => '🇰🇷', + 'name' => 'Korea, Republic of', + 'numeric' => '410', + ], + 'KW' => [ + 'alpha_3' => 'KWT', + 'flag' => '🇰🇼', + 'name' => 'Kuwait', + 'numeric' => '414', + 'official_name' => 'State of Kuwait', + ], + 'KY' => [ + 'alpha_3' => 'CYM', + 'flag' => '🇰🇾', + 'name' => 'Cayman Islands', + 'numeric' => '136', + ], + 'KZ' => [ + 'alpha_3' => 'KAZ', + 'flag' => '🇰🇿', + 'name' => 'Kazakhstan', + 'numeric' => '398', + 'official_name' => 'Republic of Kazakhstan', + ], + 'LA' => [ + 'alpha_3' => 'LAO', + 'common_name' => 'Laos', + 'flag' => '🇱🇦', + 'name' => 'Lao People\'s Democratic Republic', + 'numeric' => '418', + ], + 'LB' => [ + 'alpha_3' => 'LBN', + 'flag' => '🇱🇧', + 'name' => 'Lebanon', + 'numeric' => '422', + 'official_name' => 'Lebanese Republic', + ], + 'LC' => [ + 'alpha_3' => 'LCA', + 'flag' => '🇱🇨', + 'name' => 'Saint Lucia', + 'numeric' => '662', + ], + 'LI' => [ + 'alpha_3' => 'LIE', + 'flag' => '🇱🇮', + 'name' => 'Liechtenstein', + 'numeric' => '438', + 'official_name' => 'Principality of Liechtenstein', + ], + 'LK' => [ + 'alpha_3' => 'LKA', + 'flag' => '🇱🇰', + 'name' => 'Sri Lanka', + 'numeric' => '144', + 'official_name' => 'Democratic Socialist Republic of Sri Lanka', + ], + 'LR' => [ + 'alpha_3' => 'LBR', + 'flag' => '🇱🇷', + 'name' => 'Liberia', + 'numeric' => '430', + 'official_name' => 'Republic of Liberia', + ], + 'LS' => [ + 'alpha_3' => 'LSO', + 'flag' => '🇱🇸', + 'name' => 'Lesotho', + 'numeric' => '426', + 'official_name' => 'Kingdom of Lesotho', + ], + 'LT' => [ + 'alpha_3' => 'LTU', + 'flag' => '🇱🇹', + 'name' => 'Lithuania', + 'numeric' => '440', + 'official_name' => 'Republic of Lithuania', + ], + 'LU' => [ + 'alpha_3' => 'LUX', + 'flag' => '🇱🇺', + 'name' => 'Luxembourg', + 'numeric' => '442', + 'official_name' => 'Grand Duchy of Luxembourg', + ], + 'LV' => [ + 'alpha_3' => 'LVA', + 'flag' => '🇱🇻', + 'name' => 'Latvia', + 'numeric' => '428', + 'official_name' => 'Republic of Latvia', + ], + 'LY' => [ + 'alpha_3' => 'LBY', + 'flag' => '🇱🇾', + 'name' => 'Libya', + 'numeric' => '434', + 'official_name' => 'Libya', + ], + 'MA' => [ + 'alpha_3' => 'MAR', + 'flag' => '🇲🇦', + 'name' => 'Morocco', + 'numeric' => '504', + 'official_name' => 'Kingdom of Morocco', + ], + 'MC' => [ + 'alpha_3' => 'MCO', + 'flag' => '🇲🇨', + 'name' => 'Monaco', + 'numeric' => '492', + 'official_name' => 'Principality of Monaco', + ], + 'MD' => [ + 'alpha_3' => 'MDA', + 'common_name' => 'Moldova', + 'flag' => '🇲🇩', + 'name' => 'Moldova, Republic of', + 'numeric' => '498', + 'official_name' => 'Republic of Moldova', + ], + 'ME' => [ + 'alpha_3' => 'MNE', + 'flag' => '🇲🇪', + 'name' => 'Montenegro', + 'numeric' => '499', + 'official_name' => 'Montenegro', + ], + 'MF' => [ + 'alpha_3' => 'MAF', + 'flag' => '🇲🇫', + 'name' => 'Saint Martin (French part)', + 'numeric' => '663', + ], + 'MG' => [ + 'alpha_3' => 'MDG', + 'flag' => '🇲🇬', + 'name' => 'Madagascar', + 'numeric' => '450', + 'official_name' => 'Republic of Madagascar', + ], + 'MH' => [ + 'alpha_3' => 'MHL', + 'flag' => '🇲🇭', + 'name' => 'Marshall Islands', + 'numeric' => '584', + 'official_name' => 'Republic of the Marshall Islands', + ], + 'MK' => [ + 'alpha_3' => 'MKD', + 'flag' => '🇲🇰', + 'name' => 'North Macedonia', + 'numeric' => '807', + 'official_name' => 'Republic of North Macedonia', + ], + 'ML' => [ + 'alpha_3' => 'MLI', + 'flag' => '🇲🇱', + 'name' => 'Mali', + 'numeric' => '466', + 'official_name' => 'Republic of Mali', + ], + 'MM' => [ + 'alpha_3' => 'MMR', + 'flag' => '🇲🇲', + 'name' => 'Myanmar', + 'numeric' => '104', + 'official_name' => 'Republic of Myanmar', + ], + 'MN' => [ + 'alpha_3' => 'MNG', + 'flag' => '🇲🇳', + 'name' => 'Mongolia', + 'numeric' => '496', + ], + 'MO' => [ + 'alpha_3' => 'MAC', + 'flag' => '🇲🇴', + 'name' => 'Macao', + 'numeric' => '446', + 'official_name' => 'Macao Special Administrative Region of China', + ], + 'MP' => [ + 'alpha_3' => 'MNP', + 'flag' => '🇲🇵', + 'name' => 'Northern Mariana Islands', + 'numeric' => '580', + 'official_name' => 'Commonwealth of the Northern Mariana Islands', + ], + 'MQ' => [ + 'alpha_3' => 'MTQ', + 'flag' => '🇲🇶', + 'name' => 'Martinique', + 'numeric' => '474', + ], + 'MR' => [ + 'alpha_3' => 'MRT', + 'flag' => '🇲🇷', + 'name' => 'Mauritania', + 'numeric' => '478', + 'official_name' => 'Islamic Republic of Mauritania', + ], + 'MS' => [ + 'alpha_3' => 'MSR', + 'flag' => '🇲🇸', + 'name' => 'Montserrat', + 'numeric' => '500', + ], + 'MT' => [ + 'alpha_3' => 'MLT', + 'flag' => '🇲🇹', + 'name' => 'Malta', + 'numeric' => '470', + 'official_name' => 'Republic of Malta', + ], + 'MU' => [ + 'alpha_3' => 'MUS', + 'flag' => '🇲🇺', + 'name' => 'Mauritius', + 'numeric' => '480', + 'official_name' => 'Republic of Mauritius', + ], + 'MV' => [ + 'alpha_3' => 'MDV', + 'flag' => '🇲🇻', + 'name' => 'Maldives', + 'numeric' => '462', + 'official_name' => 'Republic of Maldives', + ], + 'MW' => [ + 'alpha_3' => 'MWI', + 'flag' => '🇲🇼', + 'name' => 'Malawi', + 'numeric' => '454', + 'official_name' => 'Republic of Malawi', + ], + 'MX' => [ + 'alpha_3' => 'MEX', + 'flag' => '🇲🇽', + 'name' => 'Mexico', + 'numeric' => '484', + 'official_name' => 'United Mexican States', + ], + 'MY' => [ + 'alpha_3' => 'MYS', + 'flag' => '🇲🇾', + 'name' => 'Malaysia', + 'numeric' => '458', + ], + 'MZ' => [ + 'alpha_3' => 'MOZ', + 'flag' => '🇲🇿', + 'name' => 'Mozambique', + 'numeric' => '508', + 'official_name' => 'Republic of Mozambique', + ], + 'NA' => [ + 'alpha_3' => 'NAM', + 'flag' => '🇳🇦', + 'name' => 'Namibia', + 'numeric' => '516', + 'official_name' => 'Republic of Namibia', + ], + 'NC' => [ + 'alpha_3' => 'NCL', + 'flag' => '🇳🇨', + 'name' => 'New Caledonia', + 'numeric' => '540', + ], + 'NE' => [ + 'alpha_3' => 'NER', + 'flag' => '🇳🇪', + 'name' => 'Niger', + 'numeric' => '562', + 'official_name' => 'Republic of the Niger', + ], + 'NF' => [ + 'alpha_3' => 'NFK', + 'flag' => '🇳🇫', + 'name' => 'Norfolk Island', + 'numeric' => '574', + ], + 'NG' => [ + 'alpha_3' => 'NGA', + 'flag' => '🇳🇬', + 'name' => 'Nigeria', + 'numeric' => '566', + 'official_name' => 'Federal Republic of Nigeria', + ], + 'NI' => [ + 'alpha_3' => 'NIC', + 'flag' => '🇳🇮', + 'name' => 'Nicaragua', + 'numeric' => '558', + 'official_name' => 'Republic of Nicaragua', + ], + 'NL' => [ + 'alpha_3' => 'NLD', + 'flag' => '🇳🇱', + 'name' => 'Netherlands', + 'numeric' => '528', + 'official_name' => 'Kingdom of the Netherlands', + ], + 'NO' => [ + 'alpha_3' => 'NOR', + 'flag' => '🇳🇴', + 'name' => 'Norway', + 'numeric' => '578', + 'official_name' => 'Kingdom of Norway', + ], + 'NP' => [ + 'alpha_3' => 'NPL', + 'flag' => '🇳🇵', + 'name' => 'Nepal', + 'numeric' => '524', + 'official_name' => 'Federal Democratic Republic of Nepal', + ], + 'NR' => [ + 'alpha_3' => 'NRU', + 'flag' => '🇳🇷', + 'name' => 'Nauru', + 'numeric' => '520', + 'official_name' => 'Republic of Nauru', + ], + 'NU' => [ + 'alpha_3' => 'NIU', + 'flag' => '🇳🇺', + 'name' => 'Niue', + 'numeric' => '570', + 'official_name' => 'Niue', + ], + 'NZ' => [ + 'alpha_3' => 'NZL', + 'flag' => '🇳🇿', + 'name' => 'New Zealand', + 'numeric' => '554', + ], + 'OM' => [ + 'alpha_3' => 'OMN', + 'flag' => '🇴🇲', + 'name' => 'Oman', + 'numeric' => '512', + 'official_name' => 'Sultanate of Oman', + ], + 'PA' => [ + 'alpha_3' => 'PAN', + 'flag' => '🇵🇦', + 'name' => 'Panama', + 'numeric' => '591', + 'official_name' => 'Republic of Panama', + ], + 'PE' => [ + 'alpha_3' => 'PER', + 'flag' => '🇵🇪', + 'name' => 'Peru', + 'numeric' => '604', + 'official_name' => 'Republic of Peru', + ], + 'PF' => [ + 'alpha_3' => 'PYF', + 'flag' => '🇵🇫', + 'name' => 'French Polynesia', + 'numeric' => '258', + ], + 'PG' => [ + 'alpha_3' => 'PNG', + 'flag' => '🇵🇬', + 'name' => 'Papua New Guinea', + 'numeric' => '598', + 'official_name' => 'Independent State of Papua New Guinea', + ], + 'PH' => [ + 'alpha_3' => 'PHL', + 'flag' => '🇵🇭', + 'name' => 'Philippines', + 'numeric' => '608', + 'official_name' => 'Republic of the Philippines', + ], + 'PK' => [ + 'alpha_3' => 'PAK', + 'flag' => '🇵🇰', + 'name' => 'Pakistan', + 'numeric' => '586', + 'official_name' => 'Islamic Republic of Pakistan', + ], + 'PL' => [ + 'alpha_3' => 'POL', + 'flag' => '🇵🇱', + 'name' => 'Poland', + 'numeric' => '616', + 'official_name' => 'Republic of Poland', + ], + 'PM' => [ + 'alpha_3' => 'SPM', + 'flag' => '🇵🇲', + 'name' => 'Saint Pierre and Miquelon', + 'numeric' => '666', + ], + 'PN' => [ + 'alpha_3' => 'PCN', + 'flag' => '🇵🇳', + 'name' => 'Pitcairn', + 'numeric' => '612', + ], + 'PR' => [ + 'alpha_3' => 'PRI', + 'flag' => '🇵🇷', + 'name' => 'Puerto Rico', + 'numeric' => '630', + ], + 'PS' => [ + 'alpha_3' => 'PSE', + 'flag' => '🇵🇸', + 'name' => 'Palestine, State of', + 'numeric' => '275', + 'official_name' => 'the State of Palestine', + ], + 'PT' => [ + 'alpha_3' => 'PRT', + 'flag' => '🇵🇹', + 'name' => 'Portugal', + 'numeric' => '620', + 'official_name' => 'Portuguese Republic', + ], + 'PW' => [ + 'alpha_3' => 'PLW', + 'flag' => '🇵🇼', + 'name' => 'Palau', + 'numeric' => '585', + 'official_name' => 'Republic of Palau', + ], + 'PY' => [ + 'alpha_3' => 'PRY', + 'flag' => '🇵🇾', + 'name' => 'Paraguay', + 'numeric' => '600', + 'official_name' => 'Republic of Paraguay', + ], + 'QA' => [ + 'alpha_3' => 'QAT', + 'flag' => '🇶🇦', + 'name' => 'Qatar', + 'numeric' => '634', + 'official_name' => 'State of Qatar', + ], + 'RE' => [ + 'alpha_3' => 'REU', + 'flag' => '🇷🇪', + 'name' => 'Réunion', + 'numeric' => '638', + ], + 'RO' => [ + 'alpha_3' => 'ROU', + 'flag' => '🇷🇴', + 'name' => 'Romania', + 'numeric' => '642', + ], + 'RS' => [ + 'alpha_3' => 'SRB', + 'flag' => '🇷🇸', + 'name' => 'Serbia', + 'numeric' => '688', + 'official_name' => 'Republic of Serbia', + ], + 'RU' => [ + 'alpha_3' => 'RUS', + 'flag' => '🇷🇺', + 'name' => 'Russian Federation', + 'numeric' => '643', + ], + 'RW' => [ + 'alpha_3' => 'RWA', + 'flag' => '🇷🇼', + 'name' => 'Rwanda', + 'numeric' => '646', + 'official_name' => 'Rwandese Republic', + ], + 'SA' => [ + 'alpha_3' => 'SAU', + 'flag' => '🇸🇦', + 'name' => 'Saudi Arabia', + 'numeric' => '682', + 'official_name' => 'Kingdom of Saudi Arabia', + ], + 'SB' => [ + 'alpha_3' => 'SLB', + 'flag' => '🇸🇧', + 'name' => 'Solomon Islands', + 'numeric' => '090', + ], + 'SC' => [ + 'alpha_3' => 'SYC', + 'flag' => '🇸🇨', + 'name' => 'Seychelles', + 'numeric' => '690', + 'official_name' => 'Republic of Seychelles', + ], + 'SD' => [ + 'alpha_3' => 'SDN', + 'flag' => '🇸🇩', + 'name' => 'Sudan', + 'numeric' => '729', + 'official_name' => 'Republic of the Sudan', + ], + 'SE' => [ + 'alpha_3' => 'SWE', + 'flag' => '🇸🇪', + 'name' => 'Sweden', + 'numeric' => '752', + 'official_name' => 'Kingdom of Sweden', + ], + 'SG' => [ + 'alpha_3' => 'SGP', + 'flag' => '🇸🇬', + 'name' => 'Singapore', + 'numeric' => '702', + 'official_name' => 'Republic of Singapore', + ], + 'SH' => [ + 'alpha_3' => 'SHN', + 'flag' => '🇸🇭', + 'name' => 'Saint Helena, Ascension and Tristan da Cunha', + 'numeric' => '654', + ], + 'SI' => [ + 'alpha_3' => 'SVN', + 'flag' => '🇸🇮', + 'name' => 'Slovenia', + 'numeric' => '705', + 'official_name' => 'Republic of Slovenia', + ], + 'SJ' => [ + 'alpha_3' => 'SJM', + 'flag' => '🇸🇯', + 'name' => 'Svalbard and Jan Mayen', + 'numeric' => '744', + ], + 'SK' => [ + 'alpha_3' => 'SVK', + 'flag' => '🇸🇰', + 'name' => 'Slovakia', + 'numeric' => '703', + 'official_name' => 'Slovak Republic', + ], + 'SL' => [ + 'alpha_3' => 'SLE', + 'flag' => '🇸🇱', + 'name' => 'Sierra Leone', + 'numeric' => '694', + 'official_name' => 'Republic of Sierra Leone', + ], + 'SM' => [ + 'alpha_3' => 'SMR', + 'flag' => '🇸🇲', + 'name' => 'San Marino', + 'numeric' => '674', + 'official_name' => 'Republic of San Marino', + ], + 'SN' => [ + 'alpha_3' => 'SEN', + 'flag' => '🇸🇳', + 'name' => 'Senegal', + 'numeric' => '686', + 'official_name' => 'Republic of Senegal', + ], + 'SO' => [ + 'alpha_3' => 'SOM', + 'flag' => '🇸🇴', + 'name' => 'Somalia', + 'numeric' => '706', + 'official_name' => 'Federal Republic of Somalia', + ], + 'SR' => [ + 'alpha_3' => 'SUR', + 'flag' => '🇸🇷', + 'name' => 'Suriname', + 'numeric' => '740', + 'official_name' => 'Republic of Suriname', + ], + 'SS' => [ + 'alpha_3' => 'SSD', + 'flag' => '🇸🇸', + 'name' => 'South Sudan', + 'numeric' => '728', + 'official_name' => 'Republic of South Sudan', + ], + 'ST' => [ + 'alpha_3' => 'STP', + 'flag' => '🇸🇹', + 'name' => 'Sao Tome and Principe', + 'numeric' => '678', + 'official_name' => 'Democratic Republic of Sao Tome and Principe', + ], + 'SV' => [ + 'alpha_3' => 'SLV', + 'flag' => '🇸🇻', + 'name' => 'El Salvador', + 'numeric' => '222', + 'official_name' => 'Republic of El Salvador', + ], + 'SX' => [ + 'alpha_3' => 'SXM', + 'flag' => '🇸🇽', + 'name' => 'Sint Maarten (Dutch part)', + 'numeric' => '534', + 'official_name' => 'Sint Maarten (Dutch part)', + ], + 'SY' => [ + 'alpha_3' => 'SYR', + 'common_name' => 'Syria', + 'flag' => '🇸🇾', + 'name' => 'Syrian Arab Republic', + 'numeric' => '760', + ], + 'SZ' => [ + 'alpha_3' => 'SWZ', + 'flag' => '🇸🇿', + 'name' => 'Eswatini', + 'numeric' => '748', + 'official_name' => 'Kingdom of Eswatini', + ], + 'TC' => [ + 'alpha_3' => 'TCA', + 'flag' => '🇹🇨', + 'name' => 'Turks and Caicos Islands', + 'numeric' => '796', + ], + 'TD' => [ + 'alpha_3' => 'TCD', + 'flag' => '🇹🇩', + 'name' => 'Chad', + 'numeric' => '148', + 'official_name' => 'Republic of Chad', + ], + 'TF' => [ + 'alpha_3' => 'ATF', + 'flag' => '🇹🇫', + 'name' => 'French Southern Territories', + 'numeric' => '260', + ], + 'TG' => [ + 'alpha_3' => 'TGO', + 'flag' => '🇹🇬', + 'name' => 'Togo', + 'numeric' => '768', + 'official_name' => 'Togolese Republic', + ], + 'TH' => [ + 'alpha_3' => 'THA', + 'flag' => '🇹🇭', + 'name' => 'Thailand', + 'numeric' => '764', + 'official_name' => 'Kingdom of Thailand', + ], + 'TJ' => [ + 'alpha_3' => 'TJK', + 'flag' => '🇹🇯', + 'name' => 'Tajikistan', + 'numeric' => '762', + 'official_name' => 'Republic of Tajikistan', + ], + 'TK' => [ + 'alpha_3' => 'TKL', + 'flag' => '🇹🇰', + 'name' => 'Tokelau', + 'numeric' => '772', + ], + 'TL' => [ + 'alpha_3' => 'TLS', + 'flag' => '🇹🇱', + 'name' => 'Timor-Leste', + 'numeric' => '626', + 'official_name' => 'Democratic Republic of Timor-Leste', + ], + 'TM' => [ + 'alpha_3' => 'TKM', + 'flag' => '🇹🇲', + 'name' => 'Turkmenistan', + 'numeric' => '795', + ], + 'TN' => [ + 'alpha_3' => 'TUN', + 'flag' => '🇹🇳', + 'name' => 'Tunisia', + 'numeric' => '788', + 'official_name' => 'Republic of Tunisia', + ], + 'TO' => [ + 'alpha_3' => 'TON', + 'flag' => '🇹🇴', + 'name' => 'Tonga', + 'numeric' => '776', + 'official_name' => 'Kingdom of Tonga', + ], + 'TR' => [ + 'alpha_3' => 'TUR', + 'flag' => '🇹🇷', + 'name' => 'Türkiye', + 'numeric' => '792', + 'official_name' => 'Republic of Türkiye', + ], + 'TT' => [ + 'alpha_3' => 'TTO', + 'flag' => '🇹🇹', + 'name' => 'Trinidad and Tobago', + 'numeric' => '780', + 'official_name' => 'Republic of Trinidad and Tobago', + ], + 'TV' => [ + 'alpha_3' => 'TUV', + 'flag' => '🇹🇻', + 'name' => 'Tuvalu', + 'numeric' => '798', + ], + 'TW' => [ + 'alpha_3' => 'TWN', + 'common_name' => 'Taiwan', + 'flag' => '🇹🇼', + 'name' => 'Taiwan, Province of China', + 'numeric' => '158', + 'official_name' => 'Taiwan, Province of China', + ], + 'TZ' => [ + 'alpha_3' => 'TZA', + 'common_name' => 'Tanzania', + 'flag' => '🇹🇿', + 'name' => 'Tanzania, United Republic of', + 'numeric' => '834', + 'official_name' => 'United Republic of Tanzania', + ], + 'UA' => [ + 'alpha_3' => 'UKR', + 'flag' => '🇺🇦', + 'name' => 'Ukraine', + 'numeric' => '804', + ], + 'UG' => [ + 'alpha_3' => 'UGA', + 'flag' => '🇺🇬', + 'name' => 'Uganda', + 'numeric' => '800', + 'official_name' => 'Republic of Uganda', + ], + 'UM' => [ + 'alpha_3' => 'UMI', + 'flag' => '🇺🇲', + 'name' => 'United States Minor Outlying Islands', + 'numeric' => '581', + ], + 'US' => [ + 'alpha_3' => 'USA', + 'flag' => '🇺🇸', + 'name' => 'United States', + 'numeric' => '840', + 'official_name' => 'United States of America', + ], + 'UY' => [ + 'alpha_3' => 'URY', + 'flag' => '🇺🇾', + 'name' => 'Uruguay', + 'numeric' => '858', + 'official_name' => 'Eastern Republic of Uruguay', + ], + 'UZ' => [ + 'alpha_3' => 'UZB', + 'flag' => '🇺🇿', + 'name' => 'Uzbekistan', + 'numeric' => '860', + 'official_name' => 'Republic of Uzbekistan', + ], + 'VA' => [ + 'alpha_3' => 'VAT', + 'flag' => '🇻🇦', + 'name' => 'Holy See (Vatican City State)', + 'numeric' => '336', + ], + 'VC' => [ + 'alpha_3' => 'VCT', + 'flag' => '🇻🇨', + 'name' => 'Saint Vincent and the Grenadines', + 'numeric' => '670', + ], + 'VE' => [ + 'alpha_3' => 'VEN', + 'common_name' => 'Venezuela', + 'flag' => '🇻🇪', + 'name' => 'Venezuela, Bolivarian Republic of', + 'numeric' => '862', + 'official_name' => 'Bolivarian Republic of Venezuela', + ], + 'VG' => [ + 'alpha_3' => 'VGB', + 'flag' => '🇻🇬', + 'name' => 'Virgin Islands, British', + 'numeric' => '092', + 'official_name' => 'British Virgin Islands', + ], + 'VI' => [ + 'alpha_3' => 'VIR', + 'flag' => '🇻🇮', + 'name' => 'Virgin Islands, U.S.', + 'numeric' => '850', + 'official_name' => 'Virgin Islands of the United States', + ], + 'VN' => [ + 'alpha_3' => 'VNM', + 'common_name' => 'Vietnam', + 'flag' => '🇻🇳', + 'name' => 'Viet Nam', + 'numeric' => '704', + 'official_name' => 'Socialist Republic of Viet Nam', + ], + 'VU' => [ + 'alpha_3' => 'VUT', + 'flag' => '🇻🇺', + 'name' => 'Vanuatu', + 'numeric' => '548', + 'official_name' => 'Republic of Vanuatu', + ], + 'WF' => [ + 'alpha_3' => 'WLF', + 'flag' => '🇼🇫', + 'name' => 'Wallis and Futuna', + 'numeric' => '876', + ], + 'WS' => [ + 'alpha_3' => 'WSM', + 'flag' => '🇼🇸', + 'name' => 'Samoa', + 'numeric' => '882', + 'official_name' => 'Independent State of Samoa', + ], + 'YE' => [ + 'alpha_3' => 'YEM', + 'flag' => '🇾🇪', + 'name' => 'Yemen', + 'numeric' => '887', + 'official_name' => 'Republic of Yemen', + ], + 'YT' => [ + 'alpha_3' => 'MYT', + 'flag' => '🇾🇹', + 'name' => 'Mayotte', + 'numeric' => '175', + ], + 'ZA' => [ + 'alpha_3' => 'ZAF', + 'flag' => '🇿🇦', + 'name' => 'South Africa', + 'numeric' => '710', + 'official_name' => 'Republic of South Africa', + ], + 'ZM' => [ + 'alpha_3' => 'ZMB', + 'flag' => '🇿🇲', + 'name' => 'Zambia', + 'numeric' => '894', + 'official_name' => 'Republic of Zambia', + ], + 'ZW' => [ + 'alpha_3' => 'ZWE', + 'flag' => '🇿🇼', + 'name' => 'Zimbabwe', + 'numeric' => '716', + 'official_name' => 'Republic of Zimbabwe', + ], + ]; + + /** + * @var Country[] + */ + private array $countries = []; + + public function __construct(EventDispatcherInterface $eventDispatcher) + { + foreach ($this->rawData as $alpha2Code => $countryData) { + $this->countries[$alpha2Code] = new Country( + $alpha2Code, + $countryData['alpha_3'], + $countryData['name'], + $countryData['numeric'], + $countryData['flag'], + $countryData['official_name'] ?? null, + ); + } + $event = new BeforeCountriesEvaluatedEvent($this->countries); + $eventDispatcher->dispatch($event); + $this->countries = $event->getCountries(); + } + + /** + * @return Country[] + */ + public function getAll(): array + { + return $this->countries; + } + + /** + * Searches for a matching country with fallback; uses Alpha2 ISO-Code by + * default, and if that does not match, compare the input $isoCode + * as an Alpha3 ISO-Code. + */ + public function getByIsoCode(string $isoCode): ?Country + { + $isoCode = strtoupper($isoCode); + if (isset($this->countries[$isoCode])) { + return $this->countries[$isoCode]; + } + foreach ($this->countries as $country) { + if ($country->getAlpha3IsoCode() === $isoCode) { + return $country; + } + } + return null; + } + + public function getByAlpha2IsoCode(string $isoCode): ?Country + { + $isoCode = strtoupper($isoCode); + return $this->countries[$isoCode] ?? null; + } + + public function getByAlpha3IsoCode(string $isoCode): ?Country + { + $isoCode = strtoupper($isoCode); + foreach ($this->countries as $country) { + if ($country->getAlpha3IsoCode() === $isoCode) { + return $country; + } + } + return null; + } + + public function getByEnglishName(string $name): ?Country + { + foreach ($this->countries as $country) { + if ($country->getName() === $name) { + return $country; + } + } + return null; + } + + /** + * @return array + */ + public function getFiltered(CountryFilter $filter): array + { + if (empty($filter->getOnlyCountries()) && empty($filter->getExcludeCountries())) { + return $this->countries; + } + + if (!empty($filter->getExcludeCountries())) { + $possibleCountries = []; + foreach ($this->countries as $country) { + if (!in_array($country->getAlpha2IsoCode(), $filter->getExcludeCountries(), true) + && !in_array($country->getAlpha3IsoCode(), $filter->getExcludeCountries(), true)) { + $possibleCountries[$country->getAlpha2IsoCode()] = $country; + } + } + } else { + $possibleCountries = $this->countries; + } + + if (empty($filter->getOnlyCountries())) { + return $possibleCountries; + } + + $countries = []; + foreach ($filter->getOnlyCountries() as $countryCode) { + $country = $this->getByIsoCode($countryCode); + if ($country !== null && isset($possibleCountries[$country->getAlpha2IsoCode()])) { + $countries[$country->getAlpha2IsoCode()] = $country; + } + } + + return $countries; + } +} diff --git a/Classes/Country/Event/BeforeCountriesEvaluatedEvent.php b/Classes/Country/Event/BeforeCountriesEvaluatedEvent.php new file mode 100644 index 0000000..1e24855 --- /dev/null +++ b/Classes/Country/Event/BeforeCountriesEvaluatedEvent.php @@ -0,0 +1,44 @@ +countries; + } + + /** + * @param Country[] $countries + */ + public function setCountries(array $countries): void + { + $this->countries = $countries; + } +} diff --git a/Classes/Crypto/Cipher/CipherDecryptionFailedException.php b/Classes/Crypto/Cipher/CipherDecryptionFailedException.php new file mode 100644 index 0000000..9c3f0a8 --- /dev/null +++ b/Classes/Crypto/Cipher/CipherDecryptionFailedException.php @@ -0,0 +1,20 @@ +value + ); + return new CipherValue($nonce, $cipher); + } + + /** + * Decrypts the provided cipher value using a shared key and optional additional authenticated data. + * + * @param CipherValue $cipherValue The cipher value containing encrypted data and nonce. + * @param SharedKey $key The shared key used for decryption. + * @param string $additionalData Optional additional authenticated data that was included during encryption. + * @throws CipherDecryptionFailedException If decryption fails or the integrity check is invalid. + */ + public function decrypt(CipherValue $cipherValue, SharedKey $key, string $additionalData = ''): string + { + $result = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $cipherValue->cipher, + $additionalData, + $cipherValue->nonce, + $key->value + ); + if ($result === false) { + throw new CipherDecryptionFailedException('Cipher could not be decrypted', 1762465681); + } + return $result; + } +} diff --git a/Classes/Crypto/Cipher/CipherValue.php b/Classes/Crypto/Cipher/CipherValue.php new file mode 100644 index 0000000..4fa627a --- /dev/null +++ b/Classes/Crypto/Cipher/CipherValue.php @@ -0,0 +1,59 @@ +encode(); + } + + public function encode(): string + { + $data = [ + 'nonce' => StringUtility::base64urlEncode($this->nonce), + 'cipher' => StringUtility::base64urlEncode($this->cipher), + ]; + try { + return StringUtility::base64urlEncode(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + } catch (\JsonException) { + throw new CipherException('Failed to encode cipher value', 1763068727); + } + } +} diff --git a/Classes/Crypto/Cipher/KeyFactory.php b/Classes/Crypto/Cipher/KeyFactory.php new file mode 100644 index 0000000..d2b729e --- /dev/null +++ b/Classes/Crypto/Cipher/KeyFactory.php @@ -0,0 +1,87 @@ +adjustKeyLength($this->resolveEncryptionKey()); + // context must be exactly 8 bytes + $context = hash('xxh64', $seed, true); + return new SharedKey( + sodium_crypto_kdf_derive_from_key( + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, + $subKeyId, + $context, + $key + ) + ); + } + + /** + * Creates a SharedKey instance from a given key. + * + * @throws CipherException + */ + public function createSharedKeyFromString(#[\SensitiveParameter] string $key): SharedKey + { + return new SharedKey($this->adjustKeyLength($key)); + } + + /** + * Generates a SharedKey instance from a random key. + * + * @throws CipherException + * @throws \Random\RandomException + */ + public function generateSharedKey(): SharedKey + { + return new SharedKey(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)); + } + + /** + * Ensures to use a 32-byte key for XChaCha20-Poly1305 encryption + * (having a length of `SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES`). + */ + private function adjustKeyLength(#[\SensitiveParameter] $key): string + { + if (strlen($key) === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { + return $key; + } + return hash('sha3-256', $key, true); + } + + private function resolveEncryptionKey(): string + { + $key = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? null; + if (!is_string($key) || $key === '') { + throw new CipherException('No encryption key configured', 1762897148); + } + return $key; + } +} diff --git a/Classes/Crypto/Cipher/SharedKey.php b/Classes/Crypto/Cipher/SharedKey.php new file mode 100644 index 0000000..7de36ab --- /dev/null +++ b/Classes/Crypto/Cipher/SharedKey.php @@ -0,0 +1,38 @@ +value) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { + throw new CipherException( + sprintf( + 'Length of key value must be %d bytes', + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES + ), + 1762508248 + ); + } + } +} diff --git a/Classes/Crypto/HashAlgo.php b/Classes/Crypto/HashAlgo.php new file mode 100644 index 0000000..e48654b --- /dev/null +++ b/Classes/Crypto/HashAlgo.php @@ -0,0 +1,77 @@ +value => 20, + self::SHA256->value => 32, + self::SHA384->value => 48, + self::SHA512->value => 64, + self::SHA3_256->value => 32, + self::SHA3_384->value => 48, + self::SHA3_512->value => 64, + ]; + + public function isAllowedForHmac(): bool + { + return in_array($this, self::ALLOWED_HMAC_ALGOS, true); + } + + /** + * @param bool $binary whether to return binary or hex length + */ + public function length(bool $binary = false): int + { + return self::BINARY_LENGTHS[$this->value] * ($binary ? 1 : 2); + } + + public function equals(string $other): bool + { + return strtolower($this->value) === strtolower($other); + } + + public function hash(string $data, bool $binary = false): string + { + return hash($this->value, $data, $binary); + } +} diff --git a/Classes/Crypto/HashService.php b/Classes/Crypto/HashService.php new file mode 100644 index 0000000..e6a4548 --- /dev/null +++ b/Classes/Crypto/HashService.php @@ -0,0 +1,96 @@ +isAllowedForHmac()) { + throw new \LogicException('The ' . __METHOD__ . ' function does not allow "' . $algo->value . '".', 1763812644); + } + $secret = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . $additionalSecret; + return hash_hmac($algo->value, $input, $secret); + } + + /** + * Appends a hash (HMAC) to a given string and additional secret and returns the result + * + * @param non-empty-string $additionalSecret + * + * @return non-empty-string + */ + public function appendHmac(string $string, string $additionalSecret, HashAlgo $algo = HashAlgo::SHA1): string + { + return $string . $this->hmac($string, $additionalSecret, $algo); + } + + /** + * Returns, if a string $string and $additionalSecret matches the HMAC given by $hash. + * + * @param non-empty-string $additionalSecret + */ + public function validateHmac(string $string, string $additionalSecret, string $hmac, HashAlgo $algo = HashAlgo::SHA1): bool + { + return hash_equals($this->hmac($string, $additionalSecret, $algo), $hmac); + } + + /** + * Tests if the last 40 characters of a given string $string and $additionalSecret matches the HMAC of + * the rest of the string and, if true, returns the string without the HMAC. In case of an invalid HMAC string + * an exception is thrown. + * + * @param non-empty-string $string + * @param non-empty-string $additionalSecret + */ + public function validateAndStripHmac(string $string, string $additionalSecret, HashAlgo $algo = HashAlgo::SHA1): string + { + $hashLength = $algo->length(); + if (strlen($string) < $hashLength) { + throw new InvalidHashStringException( + sprintf( + 'A hashed string must contain at least %d characters, the given string was only %d characters long.', + $hashLength, + strlen($string) + ), + 1704454152 + ); + } + $stringWithoutHmac = substr($string, 0, -$hashLength); + if ($this->validateHmac($stringWithoutHmac, $additionalSecret, substr($string, -$hashLength), $algo) !== true) { + throw new InvalidHashStringException('The given string was not appended with a valid HMAC.', 1704454157); + } + return $stringWithoutHmac; + } +} diff --git a/Classes/Crypto/PasswordHashing/AbstractArgon2PasswordHash.php b/Classes/Crypto/PasswordHashing/AbstractArgon2PasswordHash.php new file mode 100644 index 0000000..28a7bcf --- /dev/null +++ b/Classes/Crypto/PasswordHashing/AbstractArgon2PasswordHash.php @@ -0,0 +1,157 @@ + 65536, 'time_cost' => 4, 'threads' => 1) + * We raise that significantly by default. At the time of this writing, with the options + * below, password_verify() needs about 130ms on an I7 6820 on 2 CPU's (argon2i). + * + * We are not raising the amount of threads used, as that might lead to problems on various + * systems - see #90612 + * + * @var array + */ + protected $options = [ + 'memory_cost' => 65536, + 'time_cost' => 16, + ]; + + /** + * Constructor sets options if given + * + * @throws \InvalidArgumentException + */ + public function __construct(array $options = []) + { + $newOptions = $this->options; + if (isset($options['memory_cost'])) { + if ((int)$options['memory_cost'] < PASSWORD_ARGON2_DEFAULT_MEMORY_COST) { + throw new \InvalidArgumentException( + 'memory_cost must not be lower than ' . PASSWORD_ARGON2_DEFAULT_MEMORY_COST, + 1533899612 + ); + } + $newOptions['memory_cost'] = (int)$options['memory_cost']; + } + if (isset($options['time_cost'])) { + if ((int)$options['time_cost'] < PASSWORD_ARGON2_DEFAULT_TIME_COST) { + throw new \InvalidArgumentException( + 'time_cost must not be lower than ' . PASSWORD_ARGON2_DEFAULT_TIME_COST, + 1533899613 + ); + } + $newOptions['time_cost'] = (int)$options['time_cost']; + } + if (isset($options['threads'])) { + if (extension_loaded('sodium')) { + // Libsodium does not support threads, so ignore the + // options and force single-thread. + $newOptions['threads'] = 1; + } elseif ((int)$options['threads'] < PASSWORD_ARGON2_DEFAULT_THREADS) { + throw new \InvalidArgumentException( + 'threads must not be lower than ' . PASSWORD_ARGON2_DEFAULT_THREADS, + 1533899614 + ); + } else { + $newOptions['threads'] = (int)$options['threads']; + } + } + $this->options = $newOptions; + } + + /** + * Returns password algorithm constant from name + * + * Since PHP 7.4 Password hashing algorithm identifiers + * are nullable strings rather than integers. + * + * @return int|string|null + */ + protected function getPasswordAlgorithm() + { + return constant($this->getPasswordAlgorithmName()); + } + + /** + * Checks if a given plaintext password is correct by comparing it with + * a given salted hashed password. + * + * @param string $plainPW plain text password to compare with salted hash + * @param string $saltedHashPW Salted hash to compare plain-text password with + * @return bool TRUE, if plaintext password is correct, otherwise FALSE + */ + public function checkPassword(string $plainPW, string $saltedHashPW): bool + { + return password_verify($plainPW, $saltedHashPW); + } + + /** + * Returns true if PHP is compiled '--with-password-argon2' so + * the hash algorithm is available. + */ + public function isAvailable(): bool + { + return defined($this->getPasswordAlgorithmName()) && $this->getPasswordAlgorithm(); + } + + public function getHashedPassword(string $password): ?string + { + $hashedPassword = null; + if ($password !== '') { + $hashedPassword = password_hash($password, $this->getPasswordAlgorithm(), $this->options); + if (empty($hashedPassword)) { + throw new InvalidPasswordHashException('Cannot generate password, probably invalid options', 1526052118); + } + } + return $hashedPassword; + } + + /** + * Checks whether a user's hashed password needs to be replaced with a new hash, + * for instance if options changed. + * + * @param string $passString Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $passString): bool + { + return password_needs_rehash($passString, $this->getPasswordAlgorithm(), $this->options); + } + + /** + * Determines if a given string is a valid password hash. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $passwordInfo = password_get_info($saltedPW); + + return + isset($passwordInfo['algo']) + && $passwordInfo['algo'] === $this->getPasswordAlgorithm() + && strncmp($saltedPW, $this->getPasswordHashPrefix(), strlen($this->getPasswordHashPrefix())) === 0; + } +} diff --git a/Classes/Crypto/PasswordHashing/Argon2PasswordHashInterface.php b/Classes/Crypto/PasswordHashing/Argon2PasswordHashInterface.php new file mode 100644 index 0000000..ab57fb1 --- /dev/null +++ b/Classes/Crypto/PasswordHashing/Argon2PasswordHashInterface.php @@ -0,0 +1,24 @@ + 12, + ]; + + /** + * Constructor sets options if given + */ + public function __construct(array $options = []) + { + $newOptions = $this->options; + // Check options for validity + if (isset($options['cost'])) { + if (!$this->isValidBcryptCost((int)$options['cost'])) { + throw new \InvalidArgumentException( + 'cost must not be lower than 10 or higher than 31', + 1533902002 + ); + } + $newOptions['cost'] = (int)$options['cost']; + } + $this->options = $newOptions; + } + + /** + * bcrypt is always available in PHP core hash functions. + */ + public function isAvailable(): bool + { + return true; + } + + /** + * Checks if a given plaintext password is correct by comparing it with + * a given salted hashed password. + * + * @param string $plainPW plain text password to compare with salted hash + * @param string $saltedHashPW Salted hash to compare plain-text password with + */ + public function checkPassword(string $plainPW, string $saltedHashPW): bool + { + return password_verify($this->processPlainPassword($plainPW), $saltedHashPW); + } + + public function getHashedPassword(string $password): ?string + { + $hashedPassword = null; + if ($password !== '') { + $password = $this->processPlainPassword($password); + $hashedPassword = password_hash($password, PASSWORD_BCRYPT, $this->options); + if (empty($hashedPassword)) { + throw new InvalidPasswordHashException('Cannot generate password, probably invalid options', 1517174114); + } + } + return $hashedPassword; + } + + /** + * Determines if a given string is a valid salted hashed password. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $result = false; + $passwordInfo = password_get_info($saltedPW); + // Validate the cost value, password_get_info() does not check it + $cost = (int)substr($saltedPW, 4, 2); + if (isset($passwordInfo['algo']) + && $passwordInfo['algo'] === PASSWORD_BCRYPT + && strncmp($saltedPW, static::PREFIX, strlen(static::PREFIX)) === 0 + && $this->isValidBcryptCost($cost) + ) { + $result = true; + } + return $result; + } + /** + * Checks whether a user's hashed password needs to be replaced with a new hash. + * + * @param string $passString Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $passString): bool + { + return password_needs_rehash($passString, PASSWORD_BCRYPT, $this->options); + } + + /** + * The plain password is processed through sha384 and then base64 + * encoded. This will produce a 64 characters input to use with + * password_* functions, which has some advantages: + * 1. It is close to the (bcrypt-) maximum of 72 character keyspace + * 2. base64 will never produce NUL bytes (bcrypt truncates on NUL bytes) + * 3. sha384 is resistant to length extension attacks + */ + protected function processPlainPassword(string $password): string + { + return base64_encode(hash('sha384', $password, true)); + } + + /** + * @see https://github.com/php/php-src/blob/php-7.2.0/ext/standard/password.c#L441-L444 + */ + protected function isValidBcryptCost(int $cost): bool + { + return $cost >= 10 && $cost <= 31; + } +} diff --git a/Classes/Crypto/PasswordHashing/BlowfishPasswordHash.php b/Classes/Crypto/PasswordHashing/BlowfishPasswordHash.php new file mode 100644 index 0000000..3060df2 --- /dev/null +++ b/Classes/Crypto/PasswordHashing/BlowfishPasswordHash.php @@ -0,0 +1,272 @@ + 7, + ]; + + /** + * Constructor sets options if given + * + * @throws \InvalidArgumentException + */ + public function __construct(array $options = []) + { + $newOptions = $this->options; + if (isset($options['hash_count'])) { + if ((int)$options['hash_count'] < 4 || (int)$options['hash_count'] > 17) { + throw new \InvalidArgumentException( + 'hash_count must not be lower than 4 or bigger than 17', + 1533903545 + ); + } + $newOptions['hash_count'] = (int)$options['hash_count']; + } + $this->options = $newOptions; + } + + /** + * Method checks if a given plaintext password is correct by comparing it with + * a given salted hashed password. + * + * @param string $plainPW plain-text password to compare with salted hash + * @param string $saltedHashPW salted hash to compare plain-text password with + * @return bool TRUE, if plain-text password matches the salted hash, otherwise FALSE + */ + public function checkPassword(string $plainPW, string $saltedHashPW): bool + { + $isCorrect = false; + if ($this->isValidSalt($saltedHashPW)) { + $isCorrect = password_verify($plainPW, $saltedHashPW); + } + return $isCorrect; + } + + /** + * Returns whether all prerequisites for the hashing methods are matched + * + * @return bool Method available + */ + public function isAvailable(): bool + { + return (bool)CRYPT_BLOWFISH; + } + + public function getHashedPassword(string $password): ?string + { + $saltedPW = null; + if (!empty($password)) { + $salt = $this->getGeneratedSalt(); + $saltedPW = crypt($password, $this->applySettingsToSalt($salt)); + } + return $saltedPW; + } + + /** + * Checks whether a user's hashed password needs to be replaced with a new hash. + * + * This is typically called during the login process when the plain text + * password is available. A new hash is needed when the desired iteration + * count has changed through a change in the variable $hashCount or + * HASH_COUNT. + * + * @param string $saltedPW Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $saltedPW): bool + { + // Check whether the iteration count used differs from the standard number. + $countLog2 = $this->getCountLog2($saltedPW); + return $countLog2 !== null && $countLog2 < $this->options['hash_count']; + } + + /** + * Method determines if a given string is a valid salted hashed password. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $isValid = !strncmp(self::PREFIX, $saltedPW, strlen(self::PREFIX)); + if ($isValid) { + $isValid = $this->isValidSalt($saltedPW); + } + return $isValid; + } + + /** + * Generates a random base 64-encoded salt prefixed and suffixed with settings for the hash. + * + * Proper use of salts may defeat a number of attacks, including: + * - The ability to try candidate passwords against multiple hashes at once. + * - The ability to use pre-hashed lists of candidate passwords. + * - The ability to determine whether two users have the same (or different) + * password without actually having to guess one of the passwords. + * + * @return string A character string containing settings and a random salt + */ + protected function getGeneratedSalt(): string + { + $randomBytes = GeneralUtility::makeInstance(Random::class)->generateRandomBytes(16); + return $this->base64Encode($randomBytes, 16); + } + + /** + * Method applies settings (prefix, hash count) to a salt. + * + * @param string $salt A salt to apply setting to + * @return string Salt with setting + */ + protected function applySettingsToSalt(string $salt): string + { + $saltWithSettings = $salt; + $reqLenBase64 = $this->getLengthBase64FromBytes(16); + // salt without setting + if (strlen($salt) == $reqLenBase64) { + $saltWithSettings = self::PREFIX . sprintf('%02u', $this->options['hash_count']) . '$' . $salt; + } + return $saltWithSettings; + } + + /** + * Parses the log2 iteration count from a stored hash or setting string. + * + * @param string $setting Complete hash or a hash's setting string or to get log2 iteration count from + * @return int|null Used hashcount for given hash string + */ + protected function getCountLog2(string $setting): ?int + { + $countLog2 = null; + $setting = substr($setting, strlen(self::PREFIX)); + $firstSplitPos = strpos($setting, '$'); + // Hashcount existing + if ($firstSplitPos !== false && $firstSplitPos <= 2 && is_numeric(substr($setting, 0, $firstSplitPos))) { + $countLog2 = (int)substr($setting, 0, $firstSplitPos); + } + return $countLog2; + } + + /** + * Returns a string for mapping an int to the corresponding base 64 character. + * + * @return string String for mapping an int to the corresponding base 64 character + */ + protected function getItoa64(): string + { + return './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + } + + /** + * Method determines if a given string is a valid salt. + * + * @param string $salt String to check + * @return bool TRUE if it's valid salt, otherwise FALSE + */ + protected function isValidSalt(string $salt): bool + { + $isValid = ($skip = false); + $reqLenBase64 = $this->getLengthBase64FromBytes(16); + if (strlen($salt) >= $reqLenBase64) { + // Salt with prefixed setting + if (!strncmp('$', $salt, 1)) { + if (!strncmp(self::PREFIX, $salt, strlen(self::PREFIX))) { + $isValid = true; + $salt = substr($salt, (int)strrpos($salt, '$') + 1); + } else { + $skip = true; + } + } + // Checking base64 characters + if (!$skip && strlen($salt) >= $reqLenBase64) { + if (preg_match('/^[' . preg_quote($this->getItoa64(), '/') . ']{' . $reqLenBase64 . ',' . $reqLenBase64 . '}$/', substr($salt, 0, $reqLenBase64))) { + $isValid = true; + } + } + } + return $isValid; + } + + /** + * Encodes bytes into printable base 64 using the *nix standard from crypt(). + * + * @param string $input The string containing bytes to encode. + * @param int $count The number of characters (bytes) to encode. + * @return string Encoded string + */ + protected function base64Encode(string $input, int $count): string + { + $output = ''; + $i = 0; + $itoa64 = $this->getItoa64(); + do { + $value = ord($input[$i++]); + $output .= $itoa64[$value & 63]; + if ($i < $count) { + $value |= ord($input[$i]) << 8; + } + $output .= $itoa64[$value >> 6 & 63]; + if ($i++ >= $count) { + break; + } + if ($i < $count) { + $value |= ord($input[$i]) << 16; + } + $output .= $itoa64[$value >> 12 & 63]; + if ($i++ >= $count) { + break; + } + $output .= $itoa64[$value >> 18 & 63]; + } while ($i < $count); + return $output; + } + + /** + * Method determines required length of base64 characters for a given + * length of a byte string. + * + * @param int $byteLength Length of bytes to calculate in base64 chars + * @return int Required length of base64 characters + */ + protected function getLengthBase64FromBytes(int $byteLength): int + { + // Calculates bytes in bits in base64 + return (int)ceil($byteLength * 8 / 6); + } +} diff --git a/Classes/Crypto/PasswordHashing/InvalidPasswordHashException.php b/Classes/Crypto/PasswordHashing/InvalidPasswordHashException.php new file mode 100644 index 0000000..cabeb0a --- /dev/null +++ b/Classes/Crypto/PasswordHashing/InvalidPasswordHashException.php @@ -0,0 +1,25 @@ +isValidSalt($saltedHashPW)) { + $isCorrect = password_verify($plainPW, $saltedHashPW); + } + return $isCorrect; + } + + /** + * Returns whether all prerequisites for the hashing methods are matched + * + * @return bool Method available + */ + public function isAvailable(): bool + { + return (bool)CRYPT_MD5; + } + + public function getHashedPassword(string $password): ?string + { + $saltedPW = null; + if (!empty($password)) { + $salt = $this->getGeneratedSalt(); + $saltedPW = crypt($password, $this->applySettingsToSalt($salt)); + } + return $saltedPW; + } + + /** + * Checks whether a user's hashed password needs to be replaced with a new hash. + * + * This is typically called during the login process when the plain text + * password is available. A new hash is needed when the desired iteration + * count has changed through a change in the variable $hashCount or HASH_COUNT. + * + * @param string $passString Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $passString): bool + { + return false; + } + + /** + * Method determines if a given string is a valid salted hashed password. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $isValid = !strncmp(self::PREFIX, $saltedPW, strlen(self::PREFIX)); + if ($isValid) { + $isValid = $this->isValidSalt($saltedPW); + } + return $isValid; + } + + /** + * Generates a random base 64-encoded salt prefixed and suffixed with settings for the hash. + * + * Proper use of salts may defeat a number of attacks, including: + * - The ability to try candidate passwords against multiple hashes at once. + * - The ability to use pre-hashed lists of candidate passwords. + * - The ability to determine whether two users have the same (or different) + * password without actually having to guess one of the passwords. + * + * @return string A character string containing settings and a random salt + */ + protected function getGeneratedSalt(): string + { + $randomBytes = GeneralUtility::makeInstance(Random::class)->generateRandomBytes(6); + return $this->base64Encode($randomBytes, 6); + } + + /** + * Method applies settings (prefix, suffix) to a salt. + * + * @param string $salt A salt to apply setting to + * @return string Salt with setting + */ + protected function applySettingsToSalt(string $salt): string + { + $saltWithSettings = $salt; + $reqLenBase64 = $this->getLengthBase64FromBytes(6); + // Salt without setting + if (strlen($salt) == $reqLenBase64) { + $saltWithSettings = self::PREFIX . $salt . '$'; + } + return $saltWithSettings; + } + + /** + * Returns a string for mapping an int to the corresponding base 64 character. + * + * @return string String for mapping an int to the corresponding base 64 character + */ + protected function getItoa64(): string + { + return './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + } + + /** + * Method determines if a given string is a valid salt + * + * @param string $salt String to check + * @return bool TRUE if it's valid salt, otherwise FALSE + */ + protected function isValidSalt(string $salt): bool + { + $isValid = ($skip = false); + $reqLenBase64 = $this->getLengthBase64FromBytes(6); + if (strlen($salt) >= $reqLenBase64) { + // Salt with prefixed setting + if (!strncmp('$', $salt, 1)) { + if (!strncmp(self::PREFIX, $salt, strlen(self::PREFIX))) { + $isValid = true; + $salt = substr($salt, strlen(self::PREFIX)); + } else { + $skip = true; + } + } + // Checking base64 characters + if (!$skip && strlen($salt) >= $reqLenBase64) { + if (preg_match('/^[' . preg_quote($this->getItoa64(), '/') . ']{' . $reqLenBase64 . ',' . $reqLenBase64 . '}$/', substr($salt, 0, $reqLenBase64))) { + $isValid = true; + } + } + } + return $isValid; + } + + /** + * Encodes bytes into printable base 64 using the *nix standard from crypt(). + * + * @param string $input The string containing bytes to encode. + * @param int $count The number of characters (bytes) to encode. + * @return string Encoded string + */ + protected function base64Encode(string $input, int $count): string + { + $output = ''; + $i = 0; + $itoa64 = $this->getItoa64(); + do { + $value = ord($input[$i++]); + $output .= $itoa64[$value & 63]; + if ($i < $count) { + $value |= ord($input[$i]) << 8; + } + $output .= $itoa64[$value >> 6 & 63]; + if ($i++ >= $count) { + break; + } + if ($i < $count) { + $value |= ord($input[$i]) << 16; + } + $output .= $itoa64[$value >> 12 & 63]; + if ($i++ >= $count) { + break; + } + $output .= $itoa64[$value >> 18 & 63]; + } while ($i < $count); + return $output; + } + + /** + * Method determines required length of base64 characters for a given + * length of a byte string. + * + * @param int $byteLength Length of bytes to calculate in base64 chars + * @return int Required length of base64 characters + */ + protected function getLengthBase64FromBytes(int $byteLength): int + { + // Calculates bytes in bits in base64 + return (int)ceil($byteLength * 8 / 6); + } +} diff --git a/Classes/Crypto/PasswordHashing/PasswordHashFactory.php b/Classes/Crypto/PasswordHashing/PasswordHashFactory.php new file mode 100644 index 0000000..b8375ac --- /dev/null +++ b/Classes/Crypto/PasswordHashing/PasswordHashFactory.php @@ -0,0 +1,146 @@ +isAvailable() && $hashInstance->isValidSaltedPW($hash)) { + return $hashInstance; + } + } + // Do not add the hash to the exception to prevent information disclosure + throw new InvalidPasswordHashException( + 'No implementation found to handle given hash. This happens if the stored hash uses a' + . ' mechanism not supported by current server. Follow the documentation link to fix this issue.', + 1533818591 + ); + } + + /** + * Determine configured default hash method and return an instance of the class representing it. + * + * @param string $mode 'FE' for frontend users, 'BE' for backend users + * @return PasswordHashInterface Class instance that is configured as default hash method + * @throws \InvalidArgumentException + * @throws \LogicException + * @throws InvalidPasswordHashException If configuration is broken + */ + public function getDefaultHashInstance(string $mode): PasswordHashInterface + { + if ($mode !== 'FE' && $mode !== 'BE') { + throw new \InvalidArgumentException('Mode must be either \'FE\' or \'BE\', ' . $mode . ' given.', 1533820041); + } + + if (empty($GLOBALS['TYPO3_CONF_VARS'][$mode]['passwordHashing']['className']) + || !isset($GLOBALS['TYPO3_CONF_VARS'][$mode]['passwordHashing']['options']) + || !is_array($GLOBALS['TYPO3_CONF_VARS'][$mode]['passwordHashing']['options']) + ) { + throw new \LogicException( + 'passwordHashing configuration of ' . $mode . ' broken', + 1533950622 + ); + } + + $defaultHashClassName = $GLOBALS['TYPO3_CONF_VARS'][$mode]['passwordHashing']['className']; + $defaultHashOptions = $GLOBALS['TYPO3_CONF_VARS'][$mode]['passwordHashing']['options']; + $availableHashClasses = static::getRegisteredSaltedHashingMethods(); + + if (!in_array($defaultHashClassName, $availableHashClasses, true)) { + throw new InvalidPasswordHashException( + 'Configured default hash method ' . $defaultHashClassName . ' is not registered', + 1533820194 + ); + } + $hashInstance = GeneralUtility::makeInstance($defaultHashClassName, $defaultHashOptions); + if (!$hashInstance instanceof PasswordHashInterface) { + throw new \LogicException( + 'Configured default hash method ' . $defaultHashClassName . ' is not an instance of PasswordHashInterface', + 1533820281 + ); + } + if (!$hashInstance->isAvailable()) { + throw new InvalidPasswordHashException( + 'Configured default hash method ' . $defaultHashClassName . ' is not available. If' + . ' the instance has just been upgraded, please log in to the standalone install tool' + . ' at ?__typo3_install to fix this. Follow the documentation link for more details.', + 1533822084 + ); + } + return $hashInstance; + } + + /** + * Returns list of all registered hashing methods. Used eg. in + * extension configuration to select the default hashing method. + * + * @throws \RuntimeException + */ + public static function getRegisteredSaltedHashingMethods(): array + { + $saltMethods = $GLOBALS['TYPO3_CONF_VARS']['SYS']['availablePasswordHashAlgorithms']; + if (!is_array($saltMethods) || empty($saltMethods)) { + throw new \RuntimeException('No password hash methods configured', 1533948733); + } + return $saltMethods; + } +} diff --git a/Classes/Crypto/PasswordHashing/PasswordHashInterface.php b/Classes/Crypto/PasswordHashing/PasswordHashInterface.php new file mode 100644 index 0000000..4e94ec6 --- /dev/null +++ b/Classes/Crypto/PasswordHashing/PasswordHashInterface.php @@ -0,0 +1,70 @@ + 25000, + ]; + + /** + * Constructor sets options if given + */ + public function __construct(array $options = []) + { + $newOptions = $this->options; + if (isset($options['hash_count'])) { + if ((int)$options['hash_count'] < 1000 || (int)$options['hash_count'] > 10000000) { + throw new \InvalidArgumentException( + 'hash_count must not be lower than 1000 or bigger than 10000000', + 1533903544 + ); + } + $newOptions['hash_count'] = (int)$options['hash_count']; + } + $this->options = $newOptions; + } + + /** + * Method checks if a given plaintext password is correct by comparing it with + * a given salted hashed password. + * + * @param string $plainPW plain-text password to compare with salted hash + * @param string $saltedHashPW salted hash to compare plain-text password with + * @return bool TRUE, if plain-text password matches the salted hash, otherwise FALSE + */ + public function checkPassword(string $plainPW, string $saltedHashPW): bool + { + return $this->isValidSalt($saltedHashPW) && hash_equals((string)$this->getHashedPasswordInternal($plainPW, $saltedHashPW), $saltedHashPW); + } + + /** + * Returns whether all prerequisites for the hashing methods are matched + * + * @return bool Method available + */ + public function isAvailable(): bool + { + return true; + } + + public function getHashedPassword(string $password): ?string + { + return $this->getHashedPasswordInternal($password); + } + + /** + * Method determines if a given string is a valid salted hashed password. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $isValid = !strncmp(self::PREFIX, $saltedPW, strlen(self::PREFIX)); + if ($isValid) { + $isValid = $this->isValidSalt($saltedPW); + } + return $isValid; + } + + /** + * Checks whether a user's hashed password needs to be replaced with a new hash. + * + * This is typically called during the login process when the plain text + * password is available. A new hash is needed when the desired iteration + * count has changed through a change in the variable $this->options['hashCount']. + * + * @param string $saltedPW Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $saltedPW): bool + { + // Check whether this was an updated password. + if (strncmp($saltedPW, self::PREFIX, strlen(self::PREFIX)) || !$this->isValidSalt($saltedPW)) { + return true; + } + // Check whether the iteration count used differs from the standard number. + $iterationCount = $this->getIterationCount($saltedPW); + return $iterationCount !== null && $iterationCount < $this->options['hash_count']; + } + + /** + * Parses the log2 iteration count from a stored hash or setting string. + * + * @param string $setting Complete hash or a hash's setting string or to get log2 iteration count from + * @return int|null Used hashcount for given hash string + */ + protected function getIterationCount(string $setting) + { + $iterationCount = null; + $setting = substr($setting, strlen(self::PREFIX)); + $firstSplitPos = strpos($setting, '$'); + // Hashcount existing + if ($firstSplitPos !== false + && $firstSplitPos <= strlen((string)10000000) + && is_numeric(substr($setting, 0, $firstSplitPos)) + ) { + $iterationCount = (int)substr($setting, 0, $firstSplitPos); + } + return $iterationCount; + } + + /** + * Method creates a salted hash for a given plaintext password + * + * @param string $password plaintext password to create a salted hash from + * @param string $salt Optional custom salt with setting to use + * @return string|null Salted hashed password + */ + protected function getHashedPasswordInternal(string $password, ?string $salt = null) + { + $saltedPW = null; + if ($password !== '') { + $hashCount = $this->options['hash_count']; + if (empty($salt) || !$this->isValidSalt($salt)) { + $salt = $this->getGeneratedSalt(); + } else { + $hashCount = $this->getIterationCount($salt); + $salt = $this->getStoredSalt($salt); + } + $hash = hash_pbkdf2('sha256', $password, $salt, $hashCount, 0, true); + $saltWithSettings = $salt; + // salt without setting + if (strlen($salt) === 16) { + $saltWithSettings = self::PREFIX . sprintf('%02u', $hashCount) . '$' . $this->base64Encode($salt, 16); + } + $saltedPW = $saltWithSettings . '$' . $this->base64Encode($hash, strlen($hash)); + } + return $saltedPW; + } + + /** + * Generates a random base 64-encoded salt prefixed and suffixed with settings for the hash. + * + * Proper use of salts may defeat a number of attacks, including: + * - The ability to try candidate passwords against multiple hashes at once. + * - The ability to use pre-hashed lists of candidate passwords. + * - The ability to determine whether two users have the same (or different) + * password without actually having to guess one of the passwords. + * + * @return string A character string containing settings and a random salt + */ + protected function getGeneratedSalt(): string + { + return GeneralUtility::makeInstance(Random::class)->generateRandomBytes(16); + } + + /** + * Parses the salt out of a salt string including settings. If the salt does not include settings + * it is returned unmodified. + */ + protected function getStoredSalt(string $salt): string + { + if (!strncmp('$', $salt, 1)) { + if (!strncmp(self::PREFIX, $salt, strlen(self::PREFIX))) { + $saltParts = GeneralUtility::trimExplode('$', $salt, true); + $salt = $saltParts[2]; + } + } + return $this->base64Decode($salt); + } + + /** + * Returns a string for mapping an int to the corresponding base 64 character. + * + * @return string String for mapping an int to the corresponding base 64 character + */ + protected function getItoa64(): string + { + return './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + } + + /** + * Method determines if a given string is a valid salt. + * + * @param string $salt String to check + * @return bool TRUE if it's valid salt, otherwise FALSE + */ + protected function isValidSalt(string $salt): bool + { + $isValid = ($skip = false); + $reqLenBase64 = $this->getLengthBase64FromBytes(16); + if (strlen($salt) >= $reqLenBase64) { + // Salt with prefixed setting + if (!strncmp('$', $salt, 1)) { + if (!strncmp(self::PREFIX, $salt, strlen(self::PREFIX))) { + $isValid = true; + $salt = substr($salt, (int)strrpos($salt, '$') + 1); + } else { + $skip = true; + } + } + // Checking base64 characters + if (!$skip && strlen($salt) >= $reqLenBase64) { + if (preg_match('/^[' . preg_quote($this->getItoa64(), '/') . ']{' . $reqLenBase64 . ',' . $reqLenBase64 . '}$/', substr($salt, 0, $reqLenBase64))) { + $isValid = true; + } + } + } + return $isValid; + } + + /** + * Method determines required length of base64 characters for a given + * length of a byte string. + * + * @param int $byteLength Length of bytes to calculate in base64 chars + * @return int Required length of base64 characters + */ + protected function getLengthBase64FromBytes(int $byteLength): int + { + // Calculates bytes in bits in base64 + return (int)ceil($byteLength * 8 / 6); + } + + /** + * Adapted version of base64_encoding for compatibility with python passlib. The output of this function is + * is identical to base64_encode, except that it uses . instead of +, and omits trailing padding = and whitespace. + * + * @param string $input The string containing bytes to encode. + * @param int $count The number of characters (bytes) to encode. + * @return string Encoded string + */ + protected function base64Encode(string $input, int $count): string + { + $input = substr($input, 0, $count); + return rtrim(str_replace('+', '.', base64_encode($input)), " =\r\n\t\0\x0B"); + } + + /** + * Adapted version of base64_encoding for compatibility with python passlib. The output of this function is + * is identical to base64_encode, except that it uses . instead of +, and omits trailing padding = and whitespace. + */ + protected function base64Decode(string $value): string + { + return base64_decode(str_replace('.', '+', $value)); + } +} diff --git a/Classes/Crypto/PasswordHashing/PhpassPasswordHash.php b/Classes/Crypto/PasswordHashing/PhpassPasswordHash.php new file mode 100644 index 0000000..dbc0220 --- /dev/null +++ b/Classes/Crypto/PasswordHashing/PhpassPasswordHash.php @@ -0,0 +1,308 @@ + 14, + ]; + + /** + * Constructor sets options if given + */ + public function __construct(array $options = []) + { + $newOptions = $this->options; + if (isset($options['hash_count'])) { + if ((int)$options['hash_count'] < 7 || (int)$options['hash_count'] > 24) { + throw new \InvalidArgumentException( + 'hash_count must not be lower than 7 or bigger than 24', + 1533940454 + ); + } + $newOptions['hash_count'] = (int)$options['hash_count']; + } + $this->options = $newOptions; + } + + /** + * Method checks if a given plaintext password is correct by comparing it with + * a given salted hashed password. + * + * @param string $plainPW Plain-text password to compare with salted hash + * @param string $saltedHashPW Salted hash to compare plain-text password with + * @return bool TRUE, if plain-text password matches the salted hash, otherwise FALSE + */ + public function checkPassword(string $plainPW, string $saltedHashPW): bool + { + $hash = $this->cryptPassword($plainPW, $saltedHashPW); + return $hash && hash_equals($hash, $saltedHashPW); + } + + /** + * Returns whether all prerequisites for the hashing methods are matched + * + * @return bool Method available + */ + public function isAvailable(): bool + { + return true; + } + + public function getHashedPassword(string $password): ?string + { + $saltedPW = null; + if (!empty($password)) { + $salt = $this->getGeneratedSalt(); + $saltedPW = $this->cryptPassword($password, $this->applySettingsToSalt($salt)); + } + return $saltedPW; + } + + /** + * Checks whether a user's hashed password needs to be replaced with a new hash. + * + * This is typically called during the login process when the plain text + * password is available. A new hash is needed when the desired iteration + * count has changed through a change in the variable $hashCount or HASH_COUNT. + * + * @param string $passString Salted hash to check if it needs an update + * @return bool TRUE if salted hash needs an update, otherwise FALSE + */ + public function isHashUpdateNeeded(string $passString): bool + { + // Check whether this was an updated password. + if (strncmp($passString, '$P$', 3) || strlen($passString) != 34) { + return true; + } + // Check whether the iteration count used differs from the standard number. + return $this->getCountLog2($passString) < $this->options['hash_count']; + } + + /** + * Method determines if a given string is a valid salted hashed password. + * + * @param string $saltedPW String to check + * @return bool TRUE if it's valid salted hashed password, otherwise FALSE + */ + public function isValidSaltedPW(string $saltedPW): bool + { + $isValid = !strncmp(self::PREFIX, $saltedPW, strlen(self::PREFIX)); + if ($isValid) { + $isValid = $this->isValidSalt($saltedPW); + } + return $isValid; + } + + /** + * Method applies settings (prefix, hash count) to a salt. + * + * @param string $salt A salt to apply setting to + * @return string Salt with setting + */ + protected function applySettingsToSalt(string $salt): string + { + $saltWithSettings = $salt; + $reqLenBase64 = $this->getLengthBase64FromBytes(6); + // Salt without setting + if (strlen($salt) == $reqLenBase64) { + // We encode the final log2 iteration count in base 64. + $itoa64 = $this->getItoa64(); + $saltWithSettings = self::PREFIX . $itoa64[$this->options['hash_count']]; + $saltWithSettings .= $salt; + } + return $saltWithSettings; + } + + /** + * Hashes a password using a secure stretched hash. + * + * By using a salt and repeated hashing the password is "stretched". Its + * security is increased because it becomes much more computationally costly + * for an attacker to try to break the hash by brute-force computation of the + * hashes of a large number of plain-text words or strings to find a match. + * + * @param string $password Plain-text password to hash + * @param string $setting An existing hash or the output of getGeneratedSalt() + * @return mixed A string containing the hashed password (and salt) + */ + protected function cryptPassword(string $password, string $setting) + { + $saltedPW = null; + $reqLenBase64 = $this->getLengthBase64FromBytes(6); + // Retrieving settings with salt + $setting = substr($setting, 0, strlen(self::PREFIX) + 1 + $reqLenBase64); + $count_log2 = $this->getCountLog2($setting); + // Hashes may be imported from elsewhere, so we allow != HASH_COUNT + if ($count_log2 >= 7 && $count_log2 <= 24) { + $salt = substr($setting, strlen(self::PREFIX) + 1, $reqLenBase64); + // We must use md5() or sha1() here since they are the only cryptographic + // primitives always available in PHP 5. To implement our own low-level + // cryptographic function in PHP would result in much worse performance and + // consequently in lower iteration counts and hashes that are quicker to crack + // (by non-PHP code). + $count = 1 << $count_log2; + $hash = md5($salt . $password, true); + do { + $hash = md5($hash . $password, true); + } while (--$count); + $saltedPW = $setting . $this->base64Encode($hash, 16); + // base64Encode() of a 16 byte MD5 will always be 22 characters. + return strlen($saltedPW) == 34 ? $saltedPW : false; + } + return $saltedPW; + } + + /** + * Parses the log2 iteration count from a stored hash or setting string. + * + * @param string $setting Complete hash or a hash's setting string or to get log2 iteration count from + * @return int Used hashcount for given hash string + */ + protected function getCountLog2(string $setting): int + { + return strpos($this->getItoa64(), $setting[strlen(self::PREFIX)]); + } + + /** + * Generates a random base 64-encoded salt prefixed and suffixed with settings for the hash. + * + * Proper use of salts may defeat a number of attacks, including: + * - The ability to try candidate passwords against multiple hashes at once. + * - The ability to use pre-hashed lists of candidate passwords. + * - The ability to determine whether two users have the same (or different) + * password without actually having to guess one of the passwords. + * + * @return string A character string containing settings and a random salt + */ + protected function getGeneratedSalt(): string + { + $randomBytes = GeneralUtility::makeInstance(Random::class)->generateRandomBytes(6); + return $this->base64Encode($randomBytes, 6); + } + + /** + * Returns a string for mapping an int to the corresponding base 64 character. + * + * @return string String for mapping an int to the corresponding base 64 character + */ + protected function getItoa64(): string + { + return './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + } + + /** + * Method determines if a given string is a valid salt. + * + * @param string $salt String to check + * @return bool TRUE if it's valid salt, otherwise FALSE + */ + protected function isValidSalt(string $salt): bool + { + $isValid = ($skip = false); + $reqLenBase64 = $this->getLengthBase64FromBytes(6); + if (strlen($salt) >= $reqLenBase64) { + // Salt with prefixed setting + if (!strncmp('$', $salt, 1)) { + if (!strncmp(self::PREFIX, $salt, strlen(self::PREFIX))) { + $isValid = true; + $salt = substr($salt, (int)strrpos($salt, '$') + 2); + } else { + $skip = true; + } + } + // Checking base64 characters + if (!$skip && strlen($salt) >= $reqLenBase64) { + if (preg_match('/^[' . preg_quote($this->getItoa64(), '/') . ']{' . $reqLenBase64 . ',' . $reqLenBase64 . '}$/', substr($salt, 0, $reqLenBase64))) { + $isValid = true; + } + } + } + return $isValid; + } + + /** + * Encodes bytes into printable base 64 using the *nix standard from crypt(). + * + * @param string $input The string containing bytes to encode. + * @param int $count The number of characters (bytes) to encode. + * @return string Encoded string + */ + protected function base64Encode(string $input, int $count): string + { + $output = ''; + $i = 0; + $itoa64 = $this->getItoa64(); + do { + $value = ord($input[$i++]); + $output .= $itoa64[$value & 63]; + if ($i < $count) { + $value |= ord($input[$i]) << 8; + } + $output .= $itoa64[$value >> 6 & 63]; + if ($i++ >= $count) { + break; + } + if ($i < $count) { + $value |= ord($input[$i]) << 16; + } + $output .= $itoa64[$value >> 12 & 63]; + if ($i++ >= $count) { + break; + } + $output .= $itoa64[$value >> 18 & 63]; + } while ($i < $count); + return $output; + } + + /** + * Method determines required length of base64 characters for a given + * length of a byte string. + * + * @param int $byteLength Length of bytes to calculate in base64 chars + * @return int Required length of base64 characters + */ + protected function getLengthBase64FromBytes(int $byteLength): int + { + // Calculates bytes in bits in base64 + return (int)ceil($byteLength * 8 / 6); + } +} diff --git a/Classes/Crypto/Random.php b/Classes/Crypto/Random.php new file mode 100644 index 0000000..6077673 --- /dev/null +++ b/Classes/Crypto/Random.php @@ -0,0 +1,132 @@ +?@[\]^_`{|}~'; + private const string DIGIT_CHARACTERS = '1234567890'; + + /** + * Generates cryptographic secure pseudo-random bytes + */ + public function generateRandomBytes(int $length): string + { + return random_bytes($length); + } + + /** + * Generates cryptographic secure pseudo-random integers + */ + public function generateRandomInteger(int $min, int $max): int + { + return random_int($min, $max); + } + + /** + * Generates cryptographic secure pseudo-random hex string + */ + public function generateRandomHexString(int $length): string + { + return substr(bin2hex($this->generateRandomBytes((int)(($length + 1) / 2))), 0, $length); + } + + /** + * Generates cryptographic secure pseudo-random base64 string + */ + public function generateRandomBase64String(int $length): string + { + return substr(StringUtility::base64urlEncode($this->generateRandomBytes((int)ceil(($length / 4) * 3))), 0, $length); + } + + /** + * Generates cryptographic secure pseudo-random password based on given password rules + * + * @internal Only to be used within TYPO3. Might change in the future. + */ + public function generateRandomPassword(array $passwordRules): string + { + $passwordLength = (int)($passwordRules['length'] ?? self::DEFAULT_PASSWORD_LENGTH); + if ($passwordLength < 8) { + throw new InvalidPasswordRulesException( + 'Password rules are invalid. Length must be at least 8.', + 1667557900 + ); + } + + $password = ''; + + if ($passwordRules['random'] ?? false) { + $password = match ((string)$passwordRules['random']) { + 'hex' => $this->generateRandomHexString($passwordLength), + 'base64' => $this->generateRandomBase64String($passwordLength), + default => throw new InvalidPasswordRulesException('Invalid value for special password rule \'random\'. Valid options are: \'hex\' and \'base64\'', 1667557901), + }; + } else { + $characters = []; + $characterSets = []; + if (filter_var($passwordRules['lowerCaseCharacters'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + $characters = array_merge($characters, str_split(self::LOWERCASE_CHARACTERS)); + $characterSets[] = self::LOWERCASE_CHARACTERS; + } + if (filter_var($passwordRules['upperCaseCharacters'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + $characters = array_merge($characters, str_split(self::UPPERCASE_CHARACTERS)); + $characterSets[] = self::UPPERCASE_CHARACTERS; + } + if (filter_var($passwordRules['digitCharacters'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + $characters = array_merge($characters, str_split(self::DIGIT_CHARACTERS)); + $characterSets[] = self::DIGIT_CHARACTERS; + } + if (filter_var($passwordRules['specialCharacters'] ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + $characters = array_merge($characters, str_split(self::SPECIAL_CHARACTERS)); + $characterSets[] = self::SPECIAL_CHARACTERS; + } + + if ($characterSets === []) { + throw new InvalidPasswordRulesException( + 'Password rules are invalid. At least one character set must be allowed.', + 1667557902 + ); + } + + // enforces that at least one character matches the requirements + foreach ($characterSets as $characterSet) { + $password .= $characterSet[random_int(0, strlen($characterSet) - 1)]; + } + + $charactersCount = count($characters); + for ($i = 0; $i < $passwordLength - count($characterSets); $i++) { + $password .= $characters[random_int(0, $charactersCount - 1)]; + } + + $password = (new Randomizer())->shuffleBytes($password); + } + + return $password; + } +} diff --git a/Classes/DataHandling/DataHandler.php b/Classes/DataHandling/DataHandler.php new file mode 100644 index 0000000..bd3f392 --- /dev/null +++ b/Classes/DataHandling/DataHandler.php @@ -0,0 +1,9751 @@ +setDefaultsFromUserTS is called UserTSconfig default values will overrule existing values in this array + * (thus UserTSconfig overrules externally set defaults which overrules TCA defaults) + * + * @internal should only be used from within DataHandler as permission checks do not apply to default values + */ + public array $defaultValues = []; + + /** + * Use this array to validate suggested uids for tables by setting [table]:[uid]. This is a dangerous option + * since it will force the inserted record to have a certain UID. The value just have to be TRUE, but if you set + * it to "DELETE" it will make sure any record with that UID will be deleted first (raw delete). + * The option is used for import of T3D files when synchronizing between two mirrored servers. + * As a security measure this feature is available only for Admin Users (for now) + */ + public array $suggestedInsertUids = []; + + /** + * A string which can be used as correlationId for RecordHistory entries. + * The string can later be used to rollback multiple changes at once. + */ + protected ?CorrelationId $correlationId = null; + + // ********************* + // Internal variables (mapping arrays) which can be used (read-only) from outside + // ********************* + /** + * Contains mapping of auto-versioned records. + * + * @var array> + * @internal should only be used from within TYPO3 Core + */ + public array $autoVersionIdMap = []; + + /** + * When new elements are created, this array contains a map between their "NEW..." string IDs + * and the final uid they got when stored in database. This public array is rather important + * since it is used by many DH consumers to further work with records after creation. + */ + public array $substNEWwithIDs = []; + + /** + * Like $substNEWwithIDs, but where each old "NEW..." id is mapped to the table it was from. + * + * @internal should only be used from within TYPO3 Core + */ + public array $substNEWwithIDs_table = []; + + /** + * Holds the tables and there the ids of newly created child records from IRRE + * + * @internal should only be used from within TYPO3 Core + */ + public array $newRelatedIDs = []; + + /** + * This array is the sum of all copying operations in this class. + * + * @internal should only be used from within TYPO3 Core + */ + public array $copyMappingArray_merged = []; + + /** + * Errors are collected in this variable. + * + * @internal should only be used from within TYPO3 Core + * + * @var list + */ + public array $errorLog = []; + + /** + * Fields from the pages-table for which changes will trigger a pagetree refresh + */ + public array $pagetreeRefreshFieldsFromPages = ['pid', 'sorting', 'deleted', 'hidden', 'title', 'doktype', 'is_siteroot', 'fe_group', 'nav_hide', 'nav_title', 'module', 'starttime', 'endtime', 'content_from_pid', 'extendToSubpages']; + + /** + * Indicates whether the pagetree needs a refresh because of important changes + * + * @internal should only be used from within TYPO3 Core + */ + public bool $pagetreeNeedsRefresh = false; + + // ********************* + // Internal Variables, do not touch. + // ********************* + + // Variables set in init() function: + + /** + * The user-object the script uses. If not set from outside, this is set to the current global $BE_USER. + */ + public BackendUserAuthentication $BE_USER; + + /** + * Data submitted from the form view, used to control behaviours, + * e.g. this is used to activate/deactivate fields and thus store NULL values + */ + protected array $control = []; + + /** + * Set with incoming data array. The array shape is checked in start() before setting this property. + * + * @todo: This is public to allow manipulation by hooks (e.g. workspaces). Consider + * introduction of a public setter setCommandMap() that checks the array shape + * as done in start() already. Then have a getter as well and protect this property. + * @var array> + */ + public array $datamap = []; + + /** + * Incoming command array. The array shape is checked in start() before setting this property. + * + * @todo: This is public to allow manipulation by hooks (e.g. workspaces). Consider + * introduction of a public setter setCommandMap() that checks the array shape + * as done in start() already. Then have a getter as well and protect this property. + * @var array> + */ + public array $cmdmap = []; + + /** + * List of changed old record ids to new records ids + */ + protected array $mmHistoryRecords = []; + + /** + * List of changed old record ids to new records ids + */ + protected array $historyRecords = []; + + /** + * The interval between sorting numbers used with tables with a 'sorting' field defined. + * + * Min 1, should be power of 2 + * + * @internal should only be used from within TYPO3 Core + */ + public int $sortIntervals = 256; + + /** + * For accumulation of MM relations that must be written after new records are created. + * + * @internal + */ + public array $dbAnalysisStore = []; + + /** + * Used for tracking references that might need correction after operations + * + * @var array> + * @internal + */ + public array $registerDBList = []; + + /** + * Used for tracking references that might need correction in pid field after operations (e.g. IRRE) + * + * @internal + */ + public array $registerDBPids = []; + + /** + * Used by the copy action to track the ids of new pages so subpages are correctly inserted! + * THIS is internally cleared for each executed copy operation! DO NOT USE THIS FROM OUTSIDE! + * Read from copyMappingArray_merged instead which is accumulating this information. + * + * NOTE: This is used by some outside scripts (e.g. hooks), as the results in $copyMappingArray_merged + * are only available after an action has been completed. + * + * @var array + * @internal + */ + public array $copyMappingArray = []; + + /** + * Array used for remapping uids and values at the end of process_datamap + * + * @internal + */ + public array $remapStack = []; + + /** + * Array used for remapping uids and values at the end of process_datamap + * (e.g. $remapStackRecords[][] = ) + * + * @internal + */ + public array $remapStackRecords = []; + + /** + * Array used for executing addition actions after remapping happened (set processRemapStack()) + */ + protected array $remapStackActions = []; + + /** + * A list of fields which should not be processed. They are still written - just passed through no-questions-asked! + */ + protected array $nonFields = [ + 'uid', + 'perms_userid', + 'perms_groupid', + 'perms_user', + 'perms_group', + 'perms_everybody', + 't3ver_oid', + 't3ver_wsid', + 't3ver_state', + 't3ver_stage', + ]; + + /** + * Registry object to gather reference index update requests and perform updates after + * main processing has been done. It is created upon first start() call and hand over + * when dealing with internal sub instances. The final update() call is done at the end of + * process_cmdmap() or process_datamap() in the outermost instance. + */ + protected ReferenceIndexUpdater $referenceIndexUpdater; + + // Various + + /** + * Set to "currentRecord" during checking of values. + * + * @var array + * @internal + */ + public $checkValue_currentRecord = []; + + /** + * The outermost instance of \TYPO3\CMS\Core\DataHandling\DataHandler: + * This object instantiates itself on versioning and localization ... + */ + protected ?self $outerMostInstance = null; + + /** + * Internal cache for collecting records that should trigger cache clearing + */ + protected static array $recordsToClearCacheFor = []; + + /** + * Internal cache for pids of records which were deleted. It's not possible + * to retrieve the parent folder/page at a later stage + */ + protected static array $recordPidsForDeletedRecords = []; + + /** + * Remove fields that should not be processed during record copying. + * + * @param array $row Record row to filter + * @return array Filtered row without nonFields (uid, perms_*, t3ver_*) + */ + protected function removeNonCopyableFields(string $table, array $row, string $callingOperation): array + { + $beforeRemoveNonCopyableFieldsEvent = $this->eventDispatcher->dispatch( + new BeforeRemoveNonCopyableFieldsEvent($table, $row, $callingOperation, $this->nonFields), + ); + return array_diff_key($row, array_flip($beforeRemoveNonCopyableFieldsEvent->getNonCopyableFields())); + } + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + private readonly CacheManager $cacheManager, + #[Autowire(service: 'cache.runtime')] + private readonly FrontendInterface $runtimeCache, + private readonly ConnectionPool $connectionPool, + private readonly LoggerInterface $logger, + private readonly PagePermissionAssembler $pagePermissionAssembler, + private readonly TcaSchemaFactory $tcaSchemaFactory, + private readonly PageDoktypeRegistry $pageDoktypeRegistry, + private readonly FlexFormTools $flexFormTools, + private readonly Richtext $richtext, + private readonly PasswordHashFactory $passwordHashFactory, + private readonly Random $randomGenerator, + private readonly TypoLinkCodecService $typoLinkCodecService, + private readonly OpcodeCacheService $opcodeCacheService, + private readonly FlashMessageService $flashMessageService, + private readonly LogEntryRepository $logEntryRepository, + private readonly LocalizationRepository $localizationRepository, + private readonly SiteFinder $siteFinder, + private readonly DataMapProcessor $dataMapProcessor, + private readonly LinkService $linkService, + ) {} + + /** + * @internal + */ + public function setControl(array $control): void + { + $this->control = $control; + } + + /** + * Initializing. + * For details, see 'TYPO3 Core API' document. + * This method does not start the processing of data, but merely initializes the object. + * + * @param array $dataMap Data to be modified or inserted in the database + * @param array $commandMap Commands to copy, move, delete, localize, versionize records. + * @param BackendUserAuthentication|null $backendUser An alternative user, default is $GLOBALS['BE_USER'] + * @param CorrelationId|null $correlationId Correlation id of the outer instance when this is a sub instance of a DataHandler chain run, default is a newly generated one + */ + public function start( + array $dataMap, + array $commandMap, + ?BackendUserAuthentication $backendUser = null, + ?ReferenceIndexUpdater $referenceIndexUpdater = null, + ?CorrelationId $correlationId = null + ): void { + // Initializing BE_USER + $this->BE_USER = $backendUser ?: $GLOBALS['BE_USER']; + // Sub instances should receive ReferenceIndexUpdater via start() and not from __construct() DI since + // it is a stateful object for *this* DH chain run. If this is the outermost instance, a new one is created. + $this->referenceIndexUpdater = $referenceIndexUpdater ?? GeneralUtility::makeInstance(ReferenceIndexUpdater::class); + + // Sub instances receive the correlation id of the outer instance so all record history entries of one + // logical operation share the same scope. A new one is set for each new outermost set of data or commands. + $this->correlationId = $correlationId ?? CorrelationId::forScope( + $this->randomGenerator->generateRandomBase64String(32) + ); + + // Get default values from user TSconfig + $tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null; + $this->setDefaultsFromUserTS($tcaDefaultOverride); + + foreach ($dataMap as $tableName => $tableRecordArray) { + // @todo: Move this to a public setter and call it here. Then protect the property. + if (!is_string($tableName) || !is_array($tableRecordArray)) { + throw new \UnexpectedValueException('Data array must be shaped ["tableName" => [uid/"NEW.." => ["fieldName" => value]]]', 1709035799); + } + } + $this->datamap = $dataMap; + + foreach ($commandMap as $idCommandArray) { + // @todo: Move this to a public setter and call it here. Then protect the property. + if (!is_array($idCommandArray)) { + throw new \UnexpectedValueException('Command array must be shaped ["table" => [uid => ["command" => value]]]', 1708586415); + } + foreach ($idCommandArray as $id => $commandValueArray) { + if (!MathUtility::canBeInterpretedAsInteger($id) || !is_array($commandValueArray)) { + throw new \UnexpectedValueException('Single record commands must be shaped [uid => ["command" => value]]', 1708586979); + } + } + } + $this->cmdmap = $commandMap; + } + + /** + * Function that can mirror input values in datamap-array to other uid numbers. + * Example: $mirror[table][11] = '22,33' will look for content in $this->datamap[table][11] and copy it to $this->datamap[table][22] and $this->datamap[table][33] + * + * @param array|mixed $mirror This array has the syntax $mirror[table_name][uid] = [list of uids to copy data-value TO!] + * @internal + */ + public function setMirror($mirror): void + { + if (!is_array($mirror)) { + return; + } + foreach ($mirror as $table => $uid_array) { + if (!isset($this->datamap[$table])) { + continue; + } + foreach ($uid_array as $id => $uidList) { + if (!isset($this->datamap[$table][$id])) { + continue; + } + $theIdsInArray = GeneralUtility::trimExplode(',', $uidList, true); + foreach ($theIdsInArray as $copyToUid) { + $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id]; + } + } + } + } + + /** + * Initializes default values coming from user TSconfig + * Supports both field-level defaults and type-specific defaults + * TCAdefaults.tt_content.header_layout = 1 (field-level) + * TCAdefaults.tt_content.header_layout.types.textmedia = 3 (type-specific) + * + * @param array|null $userTS User TSconfig array + * @internal should only be used from within DataHandler + */ + public function setDefaultsFromUserTS($userTS): void + { + if (!is_array($userTS)) { + return; + } + foreach ($userTS as $k => $v) { + $k = mb_substr($k, 0, -1); + if (!$k || !is_array($v) || !$this->tcaSchemaFactory->has($k)) { + continue; + } + // Process type-specific TCA defaults, if schema supports subschema + if ($this->tcaSchemaFactory->get($k)->supportsSubSchema()) { + $v = $this->processTypeSpecificTcaDefaults($v); + } + if (is_array($this->defaultValues[$k] ?? false)) { + $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v); + } else { + $this->defaultValues[$k] = $v; + } + } + } + + /** + * Process TCA defaults configuration, preserving type-specific + * structure for later processing when record type is known. + * + * @param array $tcaDefaults Raw TCA defaults configuration + * @return array Processed defaults ready for storage + */ + protected function processTypeSpecificTcaDefaults(array $tcaDefaults): array + { + $processedDefaults = []; + + foreach ($tcaDefaults as $fieldKey => $fieldConfiguration) { + if (str_ends_with($fieldKey, '.')) { + // Field with potential sub-configuration (types) + $fieldName = rtrim($fieldKey, '.'); + if (!is_array($fieldConfiguration)) { + continue; + } + + // Store the full configuration including type-specific data + // This will be processed later in newFieldArray when we have record context + $processedDefaults['__typeSpecific'][$fieldName] = $fieldConfiguration; + + // Also set field-level default if available + foreach ($fieldConfiguration as $key => $value) { + if (!str_ends_with($key, '.') && !is_array($value)) { + $processedDefaults[$fieldName] = $value; + break; + } + } + } else { + // Simple field-level default + $processedDefaults[$fieldKey] = $fieldConfiguration; + } + } + + return $processedDefaults; + } + + /** + * When a new record is created, all values that haven't been set but are set via PageTSconfig / UserTSconfig + * get applied here. + * + * This is only executed for new records. The most important part is that the pageTS of the actual resolved $pid + * is taken, and a new field array with empty defaults is set again. + */ + protected function applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray, array $incomingFieldArray = []): array + { + // First set TCAdefaults respecting the given PageID + $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null; + // Re-apply $this->defaultValues settings + $this->setDefaultsFromUserTS($tcaDefaults); + // Merge incoming field array to have access to record type for type-specific defaults + $recordContext = array_merge($prepopulatedFieldArray, $incomingFieldArray); + $cleanFieldArray = $this->newFieldArray($table, $recordContext); + if (isset($prepopulatedFieldArray['pid'])) { + $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid']; + } + if (!$this->tcaSchemaFactory->has($table)) { + return $cleanFieldArray; + } + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $sortByField = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + if (isset($prepopulatedFieldArray[$sortByField])) { + $cleanFieldArray[$sortByField] = $prepopulatedFieldArray[$sortByField]; + } + } + return $cleanFieldArray; + } + + /** + * Hook: processDatamap_afterDatabaseOperations + * (calls $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);) + * + * Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id, + * but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array. + * + * @param array $hookObjectsArr (reference) Array with hook objects + * @param string $status (reference) Status of the current operation, 'new' or 'update + * @param string $table (reference) The table currently processing data for + * @param string $id (reference) The record uid currently processing data for, [integer] or [string] (like 'NEW...') + * @param array $fieldArray (reference) The field array of a record + * @internal should only be used from within DataHandler + */ + public function hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, &$table, &$id, &$fieldArray): void + { + if (!isset($this->remapStackRecords[$table][$id])) { + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) { + $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this); + } + } + } else { + $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [ + 'status' => $status, + 'fieldArray' => $fieldArray, + 'hookObjectsArr' => $hookObjectsArr, + ]; + } + } + + /** + * Processing the data-array + * Call this function to process the data-array set by start() + */ + public function process_datamap(): void + { + $this->controlActiveElements(); + $this->registerElementsToBeDeleted(); + $this->datamap = $this->unsetElementsToBeDeleted($this->datamap); + + $hookObjectsArr = []; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) { + // Instantiate hooks and call first hook method + $hookObject = GeneralUtility::makeInstance($className); + if (method_exists($hookObject, 'processDatamap_beforeStart')) { + $hookObject->processDatamap_beforeStart($this); + } + $hookObjectsArr[] = $hookObject; + } + + $this->datamap = $this->dataMapProcessor->process($this->datamap, $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $registerDBList = []; + $orderOfTables = []; + if (isset($this->datamap['pages'])) { + // Handling pages table is always the first task to make sure they are done if other records are added to them. + $orderOfTables[] = 'pages'; + } + $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap))); + foreach ($orderOfTables as $table) { + if (!$this->checkModifyAccessList($table)) { + // User is not allowed to modify + $this->log($table, 0, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', null, ['table' => $table]); + continue; + } + if (!$this->tcaSchemaFactory->has($table)) { + // Table not set in TCA + continue; + } + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) { + // Table is readonly + continue; + } + + if ($this->reverseOrder) { + $this->datamap[$table] = array_reverse($this->datamap[$table], true); + } + + foreach ($this->datamap[$table] as $id => $incomingFieldArray) { + if (!is_array($incomingFieldArray)) { + continue; + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) { + $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this); + // If a hook invalidated $incomingFieldArray, skip the record completely + if (!is_array($incomingFieldArray)) { // @phpstan-ignore function.alreadyNarrowedType (hook may modify variable type by reference) + continue 2; + } + } + } + + $theRealPid = null; + $createNewVersion = false; + $old_pid_value = ''; + if (!MathUtility::canBeInterpretedAsInteger($id)) { + // $id is not an integer. We're creating a new record. + // Get a fieldArray with tca default values + $fieldArray = $this->newFieldArray($table, $incomingFieldArray); + if (isset($incomingFieldArray['pid'])) { + // A pid must be set for new records. + $pid_value = $incomingFieldArray['pid']; + // Checking and finding numerical pid, it may be a string-reference to another value + $canProceed = true; + // If a NEW... id + if (str_contains($pid_value, 'NEW')) { + if ($pid_value[0] === '-') { + $negFlag = -1; + $pid_value = substr($pid_value, 1); + } else { + $negFlag = 1; + } + // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record. + if (isset($this->substNEWwithIDs[$pid_value])) { + if ($negFlag === 1) { + $old_pid_value = $this->substNEWwithIDs[$pid_value]; + } + $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]); + } else { + $canProceed = false; + } + } + $pid_value = (int)$pid_value; + if ($canProceed) { + $fieldArray = $this->resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray); + } + } + $theRealPid = $fieldArray['pid']; + // Checks if records can be inserted on this $pid. + // If this is a page translation, the check needs to be done for the l10n_parent record + $languageField = null; + $transOrigPointerField = null; + if ($schema->isLanguageAware()) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + } + if ($table === 'pages' + && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0 + && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0 + ) { + $pageRecord = BackendUtility::getRecord('pages', $incomingFieldArray[$transOrigPointerField]) ?? []; + if (!$this->hasPermissionToInsert($table, $incomingFieldArray[$transOrigPointerField], $pageRecord, (int)$incomingFieldArray[$languageField])) { + $this->log($table, $incomingFieldArray[$transOrigPointerField], SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record on pages:{pid} where table "{table}" is not allowed', null, ['pid' => $incomingFieldArray[$transOrigPointerField], 'table' => $table], $incomingFieldArray[$transOrigPointerField]); + continue; + } + } else { + $pageRecord = BackendUtility::getRecord('pages', $theRealPid) ?? []; + if (!$this->hasPermissionToInsert($table, $theRealPid, $pageRecord)) { + $this->log($table, $theRealPid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record on pages:{pid} where table "{table}" is not allowed', null, ['pid' => $theRealPid, 'table' => $table], $theRealPid); + continue; + } + } + $incomingFieldArray = $this->addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray, $theRealPid); + $accessResult = $this->BE_USER->checkRecordEditAccess($table, $incomingFieldArray, true); + if (!$accessResult->isAllowed) { + $this->log($table, 0, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'checkRecordEditAccess() check failed [{reason}]', null, ['reason' => $accessResult->errorMessage]); + continue; + } + if (!$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) { + // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record + // So, if no live records were allowed in the current workspace, we have to create a new version of this record + if ($schema->isWorkspaceAware()) { + $createNewVersion = true; + } else { + $this->log($table, 0, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert version record "{table}:{uid}" to this workspace failed. "Live" edit permissions of records from tables without versioning required', null, ['table' => $table, 'uid' => $id]); + continue; + } + } + // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array. + $tscPID = (int)BackendUtility::getRealPageId($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0)); + // Apply TCA defaults from pageTS + $fieldArray = $this->applyDefaultsForFieldArray($table, $tscPID, $fieldArray, $incomingFieldArray); + // Apply page permissions as well + if ($table === 'pages') { + $fieldArray = $this->pagePermissionAssembler->applyDefaults( + $fieldArray, + $tscPID, + (int)$this->BE_USER->getUserId(), + (int)$this->BE_USER->firstMainGroup + ); + } + // Ensure that the default values, that are stored in the $fieldArray (built from internal default values) + // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and + // all default values are also checked for validity + // This allows to set TCA defaults (for example) without having to use FormEngine to have the fields available first. + $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray); + // Processing of all fields in incomingFieldArray and setting them in $fieldArray + $fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, 'new', $tscPID); + // Setting system fields + if ($schema->hasCapability(TcaSchemaCapability::CreatedAt)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + // Set stage to "Editing" to make sure we restart the workflow + if ($schema->isWorkspaceAware()) { + $fieldArray['t3ver_stage'] = 0; + } + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt) && !empty($fieldArray)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) { + $hookObj->processDatamap_postProcessFieldArray('new', $table, $id, $fieldArray, $this); + } + } + // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything + // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already + if (is_array($fieldArray)) { + if ($table === 'pages') { + // for new pages always a refresh is needed + $this->pagetreeNeedsRefresh = true; + } + // This creates a version of the record, instead of adding it to the live workspace + if ($createNewVersion) { + // new record created in a workspace - so always refresh page tree to indicate there is a change in the workspace + $this->pagetreeNeedsRefresh = true; + $fieldArray['pid'] = $theRealPid; + $fieldArray['t3ver_oid'] = 0; + // Setting state for version (so it can know it is currently a new version...) + $fieldArray['t3ver_state'] = VersionState::NEW_PLACEHOLDER->value; + $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace; + $this->insertDB($table, $id, $fieldArray, (int)($incomingFieldArray['uid'] ?? 0)); + // Hold auto-versioned ids of placeholders + $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id]; + } else { + $this->insertDB($table, $id, $fieldArray, (int)($incomingFieldArray['uid'] ?? 0)); + } + } + // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id, + // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array. + $this->hook_processDatamap_afterDatabaseOperations($hookObjectsArr, 'new', $table, $id, $fieldArray); + } else { + // $id is an integer. We're updating an existing record or creating a workspace version. + $id = (int)$id; + $fieldArray = []; + $recordAccess = null; + $currentRecord = BackendUtility::getRecord($table, $id, '*', '', false); + if (empty($currentRecord) || ($currentRecord['pid'] ?? null) === null) { + // Skip if there is no record. Skip if record has no pid column indicating incomplete DB. + continue; + } + if ($table === 'pages') { + $pageRecord = $currentRecord; + } elseif ((int)$currentRecord['pid'] > 0) { + $pageRecord = BackendUtility::getRecord('pages', $currentRecord['pid']) ?? []; + } else { + $pageRecord = VirtualRecord::RootPage; + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'checkRecordUpdateAccess')) { + $recordAccess = $hookObj->checkRecordUpdateAccess($table, $id, $incomingFieldArray, $recordAccess, $this); + } + } + if ($recordAccess !== null) { + if (!$recordAccess) { + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record {table}:{uid} denied by checkRecordUpdateAccess hook', null, ['table' => $table, 'uid' => $id], (int)$currentRecord['pid']); + continue; + } + } elseif (!$this->hasPermissionToUpdate($table, $pageRecord)) { + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record {table}:{uid} without permission or non-existing page', null, ['table' => $table, 'uid' => $id], (int)$currentRecord['pid']); + continue; + } + $accessResult = $this->BE_USER->checkRecordEditAccess($table, $currentRecord); + if (!$accessResult->isAllowed) { + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record {table}:{uid} failed with: {reason}', null, ['table' => $table, 'uid' => $id, 'reason' => $accessResult->errorMessage]); + continue; + } + // Use the new id of the versioned record we're trying to write to. + // This record is a child record of a parent and has already been versioned. + if (!empty($this->autoVersionIdMap[$table][$id])) { + // For the reason that creating a new version of this record, automatically + // created related child records (e.g. "IRRE"), update the accordant field: + $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList); + // Use the new id of the copied/versioned record: + $id = $this->autoVersionIdMap[$table][$id]; + } elseif (($errorCode = $this->workspaceCannotEditRecord($table, $currentRecord))) { + // Versioning is required and it must be offline version! + // Check if there already is a workspace version + $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid'); + if ($workspaceVersion) { + $id = $workspaceVersion['uid']; + } elseif ($this->workspaceAllowAutoCreation($table, $id, (int)$currentRecord['pid'])) { + // new version of a record created in a workspace - so always refresh page tree to indicate there is a change in the workspace + $this->pagetreeNeedsRefresh = true; + /** @var DataHandler $tce */ + $tce = GeneralUtility::makeInstance(self::class); + $tce->enableLogging = $this->enableLogging; + // Setting up command for creating a new version of the record: + $cmd = []; + $cmd[$table][$id]['version'] = [ + 'action' => 'new', + // Default is to create a version of the individual records + 'label' => 'Auto-created for WS #' . $this->BE_USER->workspace, + ]; + $tce->start([], $cmd, $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $tce->process_cmdmap(); + $this->errorLog = array_merge($this->errorLog, $tce->errorLog); + // If copying was successful, share the new uids (also of related children): + if (empty($tce->copyMappingArray[$table][$id])) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to version record "{table}:{uid}" failed [{reason}]', null, ['reason' => $errorCode, 'table' => $table, 'uid' => $id]); + continue; + } + foreach ($tce->copyMappingArray as $origTable => $origIdArray) { + foreach ($origIdArray as $origId => $newId) { + $this->autoVersionIdMap[$origTable][$origId] = $newId; + } + } + // Update registerDBList, that holds the copied relations to child records: + $registerDBList = array_merge($registerDBList, $tce->registerDBList); + // For the reason that creating a new version of this record, automatically + // created related child records (e.g. "IRRE"), update the accordant field: + $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList); + // Use the new id of the copied/versioned record: + $id = $this->autoVersionIdMap[$table][$id]; + } else { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to version record "{table}:{uid}" failed [{reason}]. "Live" edit permissions of records from tables without versioning required', null, ['reason' => $errorCode, 'table' => $table, 'uid' => $id]); + continue; + } + } + // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array. + $tscPID = (int)BackendUtility::getRealPageId($table, $id, 0); + // Processing of all fields in incomingFieldArray and setting them in $fieldArray + $fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, (int)$currentRecord['pid'], 'update', $tscPID); + // Set stage to "Editing" to make sure we restart the workflow + if ($schema->isWorkspaceAware()) { + $fieldArray['t3ver_stage'] = 0; + } + // Removing fields which are equal to the current value: + $fieldArray = $this->compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray); + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt) && !empty($fieldArray)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) { + $hookObj->processDatamap_postProcessFieldArray('update', $table, $id, $fieldArray, $this); + } + } + // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything + // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already + if (!empty($fieldArray)) { + if ($table === 'pages') { + // Only a certain number of fields needs to be checked for updates, + // fields with unchanged values are already removed here. + $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray)); + if (!empty($fieldsToCheck)) { + $this->pagetreeNeedsRefresh = true; + } + } + $this->updateDB($table, $id, $fieldArray, (int)$currentRecord['pid']); + } + // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id, + // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array. + $this->hook_processDatamap_afterDatabaseOperations($hookObjectsArr, 'update', $table, $id, $fieldArray); + } + } + } + + // Process the stack of relations to remap/correct + $this->processRemapStack(); + $this->dbAnalysisStoreExec(); + + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processDatamap_afterAllOperations')) { + // When this hook gets called, all operations on the submitted data have been finished. + $hookObj->processDatamap_afterAllOperations($this); + } + } + + if ($this->isOuterMostInstance()) { + $this->referenceIndexUpdater->update(); + $this->processClearCacheQueue(); + $this->resetElementsToBeDeleted(); + } + } + + /** + * Sets the "sorting" DB field and the "pid" field of an incoming record that should be added (NEW1234) + * depending on the record that should be added or where it should be added. + * + * This method is called from process_datamap() + * + * @param string $table the table name of the record to insert + * @param int $pid the real PID (numeric) where the record should be + * @param array $fieldArray field+value pairs to add + * @return array the modified field array + */ + protected function resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array + { + $schema = $this->tcaSchemaFactory->get($table); + // Points to a page on which to insert the element, possibly in the top of the page + if ($pid >= 0) { + // Ensure that the "pid" is not a translated page ID, but the default page ID + $pid = $this->getDefaultLanguagePageId($pid); + // The numerical pid is inserted in the data array + $fieldArray['pid'] = $pid; + // If this table is sorted we better find the top sorting number + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName()] = $this->getSortNumber($table, 0, $pid); + } + } elseif ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + // Points to another record before itself + // If this table is sorted we better find the top sorting number + // Because $pid is < 0, getSortNumber() returns an array + $sortingInfo = $this->getSortNumber($table, 0, $pid); + $fieldArray['pid'] = $sortingInfo['pid']; + $fieldArray[$schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName()] = $sortingInfo['sortNumber']; + } else { + // Here we fetch the PID of the record that we point to + $record = BackendUtility::getRecord($table, abs($pid), '*', '', false); + // Ensure that the "pid" is not a translated page ID, but the default page ID + $fieldArray['pid'] = $this->getDefaultLanguagePageId($record['pid']); + } + return $fieldArray; + } + + /** + * Filling in the field array + * + * @param int|string $id Record ID + * @param array $fieldArray Default values, Preset $fieldArray with 'pid' maybe (pid and uid will be not be overridden anyway) + * @param array $incomingFieldArray Is which fields/values you want to set. There are processed and put into $fieldArray if OK + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $status Is 'new' or 'update' + * @param int $tscPID TSconfig PID + */ + protected function fillInFieldArray(string $table, $id, array $fieldArray, array $incomingFieldArray, $realPid, $status, $tscPID): array + { + $schema = $this->tcaSchemaFactory->get($table); + $originalLanguageRecord = null; + $originalLanguage_diffStorage = null; + $diffStorageFlag = false; + $isNewRecord = str_contains((string)$id, 'NEW'); + // Setting 'currentRecord' and 'checkValueRecord': + if ($isNewRecord) { + // Overlay default values with incoming values. + $checkValueRecord = $fieldArray; + ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray); + $currentRecord = $checkValueRecord; + } else { + $id = (int)$id; + // We must use the current values as basis for this! + $currentRecord = ($checkValueRecord = BackendUtility::getRecord($table, $id, '*', '', false)); + // However, we need to check if the record type is a different one, we need to adapt this one value + // in order to have columnsOverrides working + $testRecord = array_replace($checkValueRecord, $incomingFieldArray); + if ($schema->supportsSubSchema() + && !$schema->getSubSchemaTypeInformation()->isPointerToForeignFieldInForeignSchema() + && ($newTypeValue = BackendUtility::getTCAtypeValue($table, $testRecord)) !== BackendUtility::getTCAtypeValue($table, $checkValueRecord) + ) { + $checkValueRecord[$schema->getSubSchemaTypeInformation()->getFieldName()] = $newTypeValue; + } + } + + // Get original language record if available: + /** @var LanguageAwareSchemaCapability|null $languageCapability */ + $languageCapability = null; + if ($schema->isLanguageAware() && is_array($currentRecord)) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + if ($languageCapability->hasDiffSourceField()) { + // Get original language record if available + if ((int)($currentRecord[$languageCapability->getLanguageField()->getName()] ?? 0) > 0 + && (int)($currentRecord[$languageCapability->getTranslationOriginPointerField()->getName()] ?? 0) > 0 + ) { + $originalLanguageRecord = BackendUtility::getRecord($table, $currentRecord[$languageCapability->getTranslationOriginPointerField()->getName()], '*', '', false); + BackendUtility::workspaceOL($table, $originalLanguageRecord, $this->BE_USER->workspace); + $originalLanguage_diffStorage = json_decode( + (string)($currentRecord[$languageCapability->getDiffSourceField()->getName()] ?? ''), + true + ); + } + } + } + + $this->checkValue_currentRecord = $checkValueRecord; + // In the following all incoming value-fields are tested: + // - Are the user allowed to change the field? + // - Is the field uid/pid (which are already set) + // - perms-fields for pages-table, then do special things... + // - If the field is nothing of the above and the field is configured in TCA, the fieldvalues are evaluated by ->checkValue + // If everything is OK, the field is entered into $fieldArray[] + foreach ($incomingFieldArray as $field => $fieldValue) { + if ($this->BE_USER->isAdmin() === false && $schema->hasField($field) && $schema->getField($field)->getDisplayConditions() === 'HIDE_FOR_NON_ADMINS') { + continue; + } + if ($schema->hasField($field) && $schema->getField($field)->supportsAccessControl() && !$this->BE_USER->check('non_exclude_fields', $table . ':' . $field)) { + continue; + } + + // The field must be editable. + // Checking if a value for language can be changed: + if ($languageCapability + && $languageCapability->getLanguageField()->getName() === (string)$field + && !$this->BE_USER->checkLanguageAccess($fieldValue) + ) { + continue; + } + + switch ($field) { + case 'uid': + case 'pid': + // Nothing happens, already set + break; + case 'perms_userid': + case 'perms_groupid': + case 'perms_user': + case 'perms_group': + case 'perms_everybody': + // Permissions can be edited by the owner or the administrator + if ($table === 'pages' && ($this->BE_USER->isAdmin() || $status === 'new' || (int)$currentRecord['perms_userid'] === (int)$this->BE_USER->getUserId())) { + $value = (int)$fieldValue; + switch ($field) { + case 'perms_userid': + case 'perms_groupid': + $fieldArray[$field] = $value; + break; + default: + if ($value >= 0 && $value < (2 ** 5)) { + $fieldArray[$field] = $value; + } + } + } + break; + case 't3ver_oid': + case 't3ver_wsid': + case 't3ver_state': + case 't3ver_stage': + break; + case 'l10n_state': + $fieldArray[$field] = $fieldValue; + break; + default: + if ($schema->hasField($field)) { + // Evaluating the value + $res = $this->checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray); + if (array_key_exists('value', $res)) { + $fieldArray[$field] = $res['value']; + } + // Add the value of the original record to the diff-storage content: + if ($languageCapability && $languageCapability->hasDiffSourceField() + // not "sys_language_uid", this is 0 in default language record by definition + && $languageCapability->getLanguageField()->getName() !== (string)$field + // not the "diffsource" field itself + && $languageCapability->getDiffSourceField()->getName() !== (string)$field + // not "l10n_parent", this is 0 in default language record by definition + && $languageCapability->getTranslationOriginPointerField()->getName() !== (string)$field + // not "l10n_source", this is 0 in default language record by definition + && !($languageCapability->hasTranslationSourceField() && $languageCapability->getTranslationOriginPointerField()->getName() === (string)$field) + ) { + if (!is_array($originalLanguage_diffStorage)) { + $originalLanguage_diffStorage = []; + } + $originalLanguage_diffStorage[$field] = (string)($originalLanguageRecord[$field] ?? ''); + $diffStorageFlag = true; + } + } elseif ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField) && $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName() === $field) { + // Allow value for original UID to pass by... + $fieldArray[$field] = $fieldValue; + } + } + } + + // Dealing with a page translation, setting "sorting", "pid", "perms_*" to the same values as the original record + if ($table === 'pages' && is_array($originalLanguageRecord)) { + $fieldArray['sorting'] = $originalLanguageRecord['sorting']; + $fieldArray['perms_userid'] = $originalLanguageRecord['perms_userid']; + $fieldArray['perms_groupid'] = $originalLanguageRecord['perms_groupid']; + $fieldArray['perms_user'] = $originalLanguageRecord['perms_user']; + $fieldArray['perms_group'] = $originalLanguageRecord['perms_group']; + $fieldArray['perms_everybody'] = $originalLanguageRecord['perms_everybody']; + } + + // Add diff-storage information + if ($diffStorageFlag + && ( + !array_key_exists($languageCapability->getDiffSourceField()->getName(), $fieldArray) + || ($isNewRecord && $originalLanguageRecord !== null) + ) + ) { + // If the field is set it would probably be because of an undo-operation - in which case we should not + // update the field of course. On the other hand, e.g. for record localization, we need to update the field. + ksort($originalLanguage_diffStorage); + $fieldArray[$languageCapability->getDiffSourceField()->getName()] = json_encode($originalLanguage_diffStorage); + } + return $fieldArray; + } + + /** + * Evaluates a value according to $table/$field settings. + * This function is for real database fields - NOT FlexForm "pseudo" fields. + * NOTICE: Calling this function expects this: 1) That the data is saved! + * + * @param string $table Table name + * @param string $field Field name + * @param string $value Value to be evaluated. Notice, this is the INPUT value from the form. The original value (from any existing record) must be manually looked up inside the function if needed - or taken from $currentRecord array. + * @param int|string $id The record-uid, mainly - but not exclusively - used for logging + * @param string $status 'update' or 'new' flag + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param int $tscPID TSconfig PID + * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) + * @return array Returns the evaluated $value as key "value" in this array. Can be checked with isset($res['value']) ... + * @internal should only be used from within DataHandler + */ + public function checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = []): array + { + $currentRecord = null; + $currentRecordValue = null; + if ((int)$id !== 0) { + // Int cast of $id can be 0 with NEW... records + // @todo: Hand over current record if there is some to avoid guesswork and DB call here + $currentRecord = BackendUtility::getRecord($table, (int)$id, '*', '', false); + // isset() won't work here, since values can be NULL + if ($currentRecord !== null && array_key_exists($field, $currentRecord)) { + $currentRecordValue = $currentRecord[$field]; + } + } + + if ($table === 'pages' && $field === 'doktype') { + // Processing special case of field pages.doktype + if (!($this->BE_USER->isAdmin() || GeneralUtility::inList($this->BE_USER->groupData['pagetypes_select'], $value))) { + // User is not allowed to use this specific doktype + $this->log($table, (int)$id, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::USER_ERROR, 'User lacks permissions to set pages:{uid} doktype to "{value}"', null, ['uid' => $id, 'value' => $value], $currentRecord['pid'] ?? 0); + return []; + } + if ($status === 'update') { + // Switching from one doktype to a different one. This is denied if the new doktype restricts the + // list of tables that can exist on them and if there are such records on the current page. + // Use the page uid of the default language + $recordId = $this->getDefaultLanguagePageId((int)$id); + $existingDisallowedTables = $this->doesPageHaveUnallowedTables($recordId, (int)$value); + if ($existingDisallowedTables !== []) { + $this->log($table, (int)$id, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::USER_ERROR, 'Can not set pages:{uid} doktype to "{value}". The page contains records from tables "{disallowedTables}" that are not allowed with new doktype.', null, ['uid' => (int)$id, 'value' => $value, 'disallowedTables' => implode(', ', $existingDisallowedTables)], $recordId); + return []; + } + } + } + + // Getting config for the field + $tcaFieldConf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field, $this->checkValue_currentRecord); + + // Create $recFID only for those types that need it + if ($tcaFieldConf['type'] === 'flex') { + $recFID = $table . ':' . $id . ':' . $field; + } else { + $recFID = ''; + } + + return $this->checkValue_SW([], $value, $tcaFieldConf, $table, $id, $currentRecordValue, $status, $realPid, $recFID, $field, $tscPID, ['incomingFieldArray' => $incomingFieldArray]); + } + + /** + * Get field configuration respecting columnsOverrides for a specific record. + * + * This method resolves the TCA field configuration while considering type-specific + * columnsOverrides. It determines the record type and returns the merged configuration + * from the appropriate sub-schema if available. + * + * Fetch the TCA ["config"] part for a specific field, including the columnsOverrides value. + * Used for checkValue purposes currently (as it takes the checkValue_currentRecord value) but also for + * all other places as well now. + * + * @param array $record The record array (must contain the type field if the table has types) + * @return array The field configuration array, or empty array if field doesn't exist + */ + protected function resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field, array $record): array + { + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->hasField($field)) { + return []; + } + $recordType = BackendUtility::getTCAtypeValue($table, $record, true); + if ($recordType !== null && $schema->hasSubSchema($recordType) && $schema->getSubSchema($recordType)->hasField($field)) { + return $schema->getSubSchema($recordType)->getField($field)->getConfiguration(); + } + return $schema->getField($field)->getConfiguration(); + } + + /** + * Branches out evaluation of a field value based on its type as configured in $GLOBALS['TCA'] + * Can be called for FlexForm pseudo fields as well, BUT must not have $field set if so. + * And hey, there's a good thing about the method arguments: 13 is prime :-P + * + * @param array $res The result array. The processed value (if any!) is set in the "value" key. + * @param string|null $value The value to set. + * @param array $tcaFieldConf Field configuration from $GLOBALS['TCA'] + * @param string $table Table name + * @param int $id UID of record + * @param mixed $curValue Current value of the field + * @param string $status 'update' or 'new' flag + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $recFID Field identifier [table:uid:field] for flexforms + * @param string $field Field name. Must NOT be set if the call is for a flexform field (since flexforms are not allowed within flexforms). + * @param int $tscPID TSconfig PID + * @param array|null $additionalData Additional data to be forwarded to sub-processors + * @return array Returns the evaluated $value as key "value" in this array. + * @internal should only be used from within DataHandler + */ + public function checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $tscPID, ?array $additionalData = null): array + { + // Convert to NULL value if defined in TCA + if ($value === null && ($tcaFieldConf['nullable'] ?? false)) { + return ['value' => null]; + } + + // This is either a normal field or a FlexForm field. + // Used to enrich the (potential) error log with contextual information. + $checkField = $recFID !== '' ? explode(':', $recFID)[2] : $field; + + $res = (array)match ((string)$tcaFieldConf['type']) { + 'category' => $this->checkValueForCategory($res, (string)$value, $tcaFieldConf, (string)$table, $id, (string)$status, (string)$field), + 'check' => $this->checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field), + 'color' => $this->checkValueForColor((string)$value, $tcaFieldConf), + 'country' => $this->checkValueForCountry((string)$value, $tcaFieldConf), + 'datetime' => $this->checkValueForDatetime($value, $tcaFieldConf), + 'email' => $this->checkValueForEmail((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $checkField), + 'flex' => $this->checkValueForFlex($res, $value, $tcaFieldConf, (string)$table, $id, (string)$curValue, (string)$status, (int)$realPid, (string)$recFID, (int)$tscPID, (string)$field), + 'inline' => $this->checkValueForInline($res, (string)$value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [], + 'file' => $this->checkValueForFile($res, (string)$value, $tcaFieldConf, $table, $id, $field, $additionalData), + 'input' => $this->checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field), + 'language' => $this->checkValueForLanguage((int)$value, $table, $field), + 'link' => $this->checkValueForLink((string)$value, $tcaFieldConf, $table, $id, $checkField), + 'number' => $this->checkValueForNumber($value, $tcaFieldConf), + 'password' => $this->checkValueForPassword((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $additionalData['incomingFieldArray'] ?? []), + 'radio' => $this->checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field), + 'slug' => $this->checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []), + 'text' => $this->checkValueForText($value, $tcaFieldConf, $table, $realPid, $field), + 'group', 'folder', 'select' => $this->checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field), + 'json' => $this->checkValueForJson($value, $tcaFieldConf), + 'uuid' => $this->checkValueForUuid((string)$value, $tcaFieldConf), + 'passthrough', 'imageManipulation', 'user' => ['value' => $value], + default => [], + }; + + return $this->checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field); + } + + /** + * Checks values that are used for internal references. If the provided $value + * is a NEW-identifier, the direct processing is stopped. Instead, the value is + * forwarded to the remap-stack to be post-processed and resolved into a proper + * UID after all data has been resolved. + * + * This method considers TCA types that cannot handle and resolve these internal + * values directly, like 'passthrough', 'none' or 'user'. Values are only modified + * here if the $field is used as 'transOrigPointerField' or 'translationSource'. + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id UID of record + * @param string $field The field name + * @return array The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field): array + { + $relevantFieldNames = []; + if ($this->tcaSchemaFactory->has($table)) { + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->isLanguageAware()) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $relevantFieldNames[] = $languageCapability->getTranslationOriginPointerField()->getName(); + if ($languageCapability->hasTranslationSourceField()) { + $relevantFieldNames[] = $languageCapability->getTranslationSourceField()->getName(); + } + } + } + + if ( + // in case field is empty + empty($field) + // in case the field is not relevant + || !in_array($field, $relevantFieldNames) + // in case the 'value' index has been unset already + || !array_key_exists('value', $res) + // in case it's not a NEW-identifier + || !str_contains($value, 'NEW') + ) { + return $res; + } + + $valueArray = [$value]; + $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)]; + $this->remapStack[] = [ + 'args' => [$valueArray, $tcaFieldConf, $id, $table, $field], + 'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3], + 'field' => $field, + ]; + unset($res['value']); + + return $res; + } + + /** + * Evaluate "text" type values. + * + * @param string|null $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $field Field name + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForText($value, $tcaFieldConf, $table, $realPid, $field) + { + $richtextEnabled = (bool)($tcaFieldConf['enableRichtext'] ?? false); + + // Reset value to empty string, if less than "min" characters. + $min = $tcaFieldConf['min'] ?? 0; + if (!$richtextEnabled && $min > 0 && mb_strlen((string)$value) < $min) { + $value = ''; + } + + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + $valueArray = []; + } elseif (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') { + $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true); + $valueArray = $this->checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? ''); + } else { + $valueArray = ['value' => $value]; + } + + // Handle richtext transformations + if ($this->dontProcessTransformations) { + return $valueArray; + } + // Keep null as value + if ($value === null) { + return $valueArray; + } + if ($richtextEnabled) { + $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord); + $richtextConfiguration = $this->richtext->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf); + $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class); + $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []); + } + + return $valueArray; + } + + /** + * Evaluate "input" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int $id UID of record + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $field Field name + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field): array + { + // Secures the string-length to be less than max. + if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) { + $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8'); + } + + // Reset value to empty string, if less than "min" characters. + $min = $tcaFieldConf['min'] ?? 0; + if ($min > 0 && mb_strlen((string)$value) < $min) { + $value = ''; + } + + if (!$this->validateValueForRequired($tcaFieldConf, (string)$value)) { + $res = []; + } elseif (empty($tcaFieldConf['eval'])) { + $res = ['value' => $value]; + } else { + // Process evaluation settings: + $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true); + $res = $this->checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table, $id); + // Process UNIQUE settings: + // Field is NOT set for flexForms - which also means that uniqueInPid and unique is NOT available for flexForm fields! Also getUnique should not be done for versioning + if ($field && !empty($res['value'])) { + if (in_array('uniqueInPid', $evalCodesArray, true)) { + $res['value'] = $this->getUnique($table, $field, $res['value'], $id, $realPid); + } + if ($res['value'] && in_array('unique', $evalCodesArray, true)) { + $res['value'] = $this->getUnique($table, $field, $res['value'], $id); + } + } + } + + return $res; + } + + /** + * Evaluate 'number' type values + * + * @param mixed $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + */ + protected function checkValueForNumber(mixed $value, array $tcaFieldConf): array + { + $format = $tcaFieldConf['format'] ?? 'integer'; + if ($format !== 'integer' && $format !== 'decimal') { + // Early return if format is not valid + return []; + } + + if (!$this->validateValueForRequired($tcaFieldConf, (string)$value)) { + return []; + } + + if ($format === 'decimal') { + // @todo Make precision configurable + $precision = 2; + $value = preg_replace('/[^0-9,\\.-]/', '', $value); + $negative = substr($value, 0, 1) === '-'; + $value = strtr($value, [',' => '.', '-' => '']); + if (!str_contains($value, '.')) { + $value .= '.0'; + } + $valueArray = explode('.', $value); + $dec = array_pop($valueArray); + $value = (float)(implode('', $valueArray) . '.' . $dec); + if ($negative) { + $value = $value * -1; + } + $result['value'] = number_format($value, $precision, '.', ''); + } else { + $result['value'] = (int)$value; + } + + // Checking range of value: + if (is_array($tcaFieldConf['range'] ?? false)) { + if ($format === 'decimal') { + if (isset($tcaFieldConf['range']['upper']) && ceil((float)$result['value']) > (float)$tcaFieldConf['range']['upper']) { + $result['value'] = (float)$tcaFieldConf['range']['upper']; + } + if (isset($tcaFieldConf['range']['lower']) && floor((float)$result['value']) < (float)$tcaFieldConf['range']['lower']) { + $result['value'] = (float)$tcaFieldConf['range']['lower']; + } + } else { + if (isset($tcaFieldConf['range']['upper']) && ceil($result['value']) > (int)$tcaFieldConf['range']['upper']) { + $result['value'] = (int)$tcaFieldConf['range']['upper']; + } + if (isset($tcaFieldConf['range']['lower']) && floor($result['value']) < (int)$tcaFieldConf['range']['lower']) { + $result['value'] = (int)$tcaFieldConf['range']['lower']; + } + } + } + + return $result; + } + + /** + * Evaluate "color" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForColor(string $value, array $tcaFieldConf): array + { + // Always trim the value + $value = trim($value); + // Secures the string-length to be <= 7 or <= 9 if opacity enabled. + $opacity = (bool)($tcaFieldConf['opacity'] ?? false); + $value = mb_substr($value, 0, $opacity ? 9 : 7, 'utf-8'); + // Early return if required validation fails + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + return []; + } + return [ + 'value' => $value, + ]; + } + + /** + * Evaluate "email" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id UID of record - might be a NEW.. string for new records + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $field Field name + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForEmail( + string $value, + array $tcaFieldConf, + string $table, + int|string $id, + int $realPid, + string $field + ): array { + // Always trim the value + $value = trim($value); + + // Early return if required validation fails + // Note: The "required" check is evaluated but does not yet lead to an error, see + // the comment in the DataHandler::validateValueForRequired() for more information. + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + return []; + } + + if ($value !== '' && !GeneralUtility::validEmail($value)) { + // A non-empty value is given, which however is no valid email. Log this and unset the value afterwards. + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, '"{email}" is not a valid e-mail address for the field "{field}" of the table "{table}"', null, ['email' => $value, 'field' => $field, 'table' => $table]); + $value = ''; + } + + $res = [ + 'value' => $value, + ]; + + // Early return if no evaluation is configured + if (!isset($tcaFieldConf['eval'])) { + return $res; + } + $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true); + + // Process UNIQUE settings: + // Field is NOT set for flexForms - which also means that uniqueInPid and unique is NOT available for flexForm fields! Also getUnique should not be done for versioning + if ($field && !empty($res['value'])) { + if (in_array('uniqueInPid', $evalCodesArray, true)) { + $res['value'] = $this->getUnique($table, $field, $res['value'], $id, $realPid); + } + if ($res['value'] && in_array('unique', $evalCodesArray, true)) { + $res['value'] = $this->getUnique($table, $field, $res['value'], $id); + } + } + + return $res; + } + + /** + * Evaluate "password" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id UID of record - might be a NEW.. string for new records + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) for the record + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForPassword( + string $value, + array $tcaFieldConf, + string $table, + int|string $id, + int $realPid, + array $incomingFieldArray = [] + ): array { + // Always trim the value + $value = trim($value); + + // Early return if required validation fails + // Note: The "required" check is evaluated but does not yet lead to an error, see + // the comment in the DataHandler::validateValueForRequired() for more information. + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + return []; + } + + // Early return, if password hashing is disabled and the table is not fe_users or be_users + if (!($tcaFieldConf['hashed'] ?? true) && !in_array($table, ['fe_users', 'be_users'], true)) { + return [ + 'value' => $value, + ]; + } + + // An incoming value is either the salted password if the user did not change existing password + // when submitting the form, or a plaintext new password that needs to be turned into a salted password now. + // The strategy is to see if a salt instance can be created from the incoming value. If so, + // no new password was submitted and we keep the value. If no salting instance can be created, + // incoming value must be a new plain text value that needs to be hashed. + $mode = $table === 'fe_users' ? 'FE' : 'BE'; + $isNewUser = str_contains((string)$id, 'NEW'); + $newHashInstance = $this->passwordHashFactory->getDefaultHashInstance($mode); + + try { + $this->passwordHashFactory->get($value, $mode); + } catch (InvalidPasswordHashException $e) { + // We got no salted password instance, incoming value must be a new plaintext password + // Validate new password against password policy for field + $passwordPolicy = $tcaFieldConf['passwordPolicy'] ?? ''; + $passwordPolicyValidator = GeneralUtility::makeInstance( + PasswordPolicyValidator::class, + PasswordPolicyAction::NEW_USER_PASSWORD, + is_string($passwordPolicy) ? $passwordPolicy : '' + ); + + $contextData = new ContextData( + loginMode: $mode, + newUsername: $incomingFieldArray['username'] ?? '', + newUserFirstName: $incomingFieldArray['first_name'] ?? '', + newUserLastName: $incomingFieldArray['last_name'] ?? '', + newUserFullName: $incomingFieldArray['realName'] ?? '', + ); + $event = $this->eventDispatcher->dispatch( + new EnrichPasswordValidationContextDataEvent( + $contextData, + $incomingFieldArray, + self::class + ) + ); + $contextData = $event->getContextData(); + + $isValidPassword = $passwordPolicyValidator->isValidPassword($value, $contextData); + if (!$isValidPassword) { + $message = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:dataHandler.passwordNotSaved'); + $this->log( + $table, + (int)$id, + SystemLogDatabaseAction::UPDATE, + null, + SystemLogErrorClassification::WARNING, + $message . implode('. ', $passwordPolicyValidator->getValidationErrors()), + null, + [ + 'table' => $table, + 'uid' => (string)$id, + ], + $realPid + ); + + // Password not valid for existing user. Stopping here, password won't be changed + if (!$isNewUser) { + return []; + } + // Password not valid for new user. To prevent empty passwords in the database, we set a random password. + $value = $this->randomGenerator->generateRandomHexString(96); + } + + // Get an instance of the current configured salted password strategy and hash the value + $value = $newHashInstance->getHashedPassword($value); + } + + if (!($tcaFieldConf['nullable'] ?? false) && $value === null) { + // type=password columns are by default NOT NULL-able. + // In this case, an invalid password needs to be an empty string to not throw an SQL error + // on a potential optional password. + $value = ''; + } + + return [ + 'value' => $value, + ]; + } + + /** + * Evaluate "slug" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int $id UID of record + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $field Field name + * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) for the record + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + * @see SlugHelper + */ + protected function checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array + { + $workspaceId = $this->BE_USER->workspace; + $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId); + $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray); + // Generate a value if there is none, otherwise ensure that all characters are cleaned up + if ($value === '') { + $value = $helper->generate($fullRecord, $realPid); + } else { + $value = $helper->sanitize($value); + } + + // Return directly in case no evaluations are defined + if (empty($tcaFieldConf['eval'])) { + return ['value' => $value]; + } + + $state = RecordStateFactory::forName($table) + ->fromArray($fullRecord, $realPid, $id); + $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true); + if (in_array('unique', $evalCodesArray, true)) { + $value = $helper->buildSlugForUniqueInTable($value, $state); + } + if (in_array('uniqueInSite', $evalCodesArray, true)) { + $value = $helper->buildSlugForUniqueInSite($value, $state); + } + if (in_array('uniqueInPid', $evalCodesArray, true)) { + $value = $helper->buildSlugForUniqueInPid($value, $state); + } + + return ['value' => $value]; + } + + /** + * Evaluate "language" type value. + * + * Checks whether the user is allowed to add such a value as language + * + * @param int $value The value to set. + * @param string $table Table name + * @param string $field Field name + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForLanguage(int $value, string $table, string $field): array + { + // If given table is localizable and the given field is the defined + // languageField, check if the selected language is allowed for the user. + // Note: Usually this method should never be reached, in case the language value is + // not valid, since "checkRecordEditAccess" checks for proper permission beforehand. + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->isLanguageAware() + && $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName() === $field + && !$this->BE_USER->checkLanguageAccess($value) + ) { + return []; + } + // @todo Should we also check if the language is allowed for the current site - if record has site context? + return ['value' => $value]; + } + + /** + * Evaluate "link" type values. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id UID of record - might be a NEW.. string for new records + * @param string $field The name of the current field + * @return array The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForLink(string $value, array $tcaFieldConf, string $table, int|string $id, string $field): array + { + // Always trim the value + $value = trim($value); + + // Early return if required validation fails + // Note: The "required" check is evaluated but does not yet lead to an error, see + // the comment in the DataHandler::validateValueForRequired() for more information. + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + return []; + } + + // Early return if an empty allow list is defined for the link types + if (is_array($tcaFieldConf['allowedTypes'] ?? false) && $tcaFieldConf['allowedTypes'] === []) { + return []; + } + + if ($value !== '') { + // Extract the actual link from the link definition for further evaluation + $linkParameter = $this->typoLinkCodecService->decode($value)['url']; + if ($linkParameter === '') { + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link definition for the field "{field}" of the table "{table}"', null, ['link' => $value, 'field' => $field, 'table' => $table]); + $value = ''; + } else { + // Try to resolve the actual link type and compare with the allow list + try { + $linkData = $this->linkService->resolve($linkParameter); + $linkType = $linkData['type'] ?? ''; + $linkIdentifier = $linkData['identifier'] ?? ''; + if (is_array($tcaFieldConf['allowedTypes'] ?? false) + && ($tcaFieldConf['allowedTypes'][0] ?? '') !== '*' + && !in_array($linkType, $tcaFieldConf['allowedTypes'], true) + && ($linkType !== 'record' || !in_array($linkIdentifier, $tcaFieldConf['allowedTypes'], true)) + ) { + $message = $linkIdentifier !== '' + ? 'Link type "record" with identifier "{type}" is not allowed for the field "{field}" of the table "{table}"' + : 'Link type "{type}" is not allowed for the field "{field}" of the table "{table}"'; + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, $message, null, ['type' => $linkIdentifier ?: $linkType, 'field' => $field, 'table' => $table]); + $value = ''; + } + } catch (UnknownLinkHandlerException $e) { + $this->log($table, $id, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, '"{link}" is not a valid link for the field "{field}" of the table "{table}"', null, ['link' => $value, 'field' => $field, 'table' => $table]); + $value = ''; + } + } + } + + return ['value' => $value]; + } + + /** + * Evaluate 'category' type values + * + * @param array $result The result array. The processed value (if any!) is set in the 'value' key. + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id uid of record + * @param string $status The status - 'update' or 'new' flag + * @param string $field Field name + */ + protected function checkValueForCategory( + array $result, + string $value, + array $tcaFieldConf, + string $table, + $id, + string $status, + string $field + ): array { + // Exploded comma-separated values and remove duplicates + $valueArray = array_unique(GeneralUtility::trimExplode(',', $value, true)); + // If an exclusive key is found, discard all others: + if ($tcaFieldConf['exclusiveKeys'] ?? false) { + $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']); + foreach ($valueArray as $index => $key) { + if (in_array($key, $exclusiveKeys, true)) { + $valueArray = [$index => $key]; + break; + } + } + } + $unsetResult = false; + if (str_contains($value, 'NEW')) { + $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)]; + $this->remapStack[] = [ + 'func' => 'checkValue_category_processDBdata', + 'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field], + 'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4], + 'field' => $field, + ]; + $unsetResult = true; + } else { + $valueArray = $this->checkValue_category_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field); + } + if ($unsetResult) { + unset($result['value']); + } else { + $newVal = implode(',', $this->checkValue_checkMax($tcaFieldConf, $valueArray)); + $result['value'] = $newVal !== '' ? $newVal : 0; + } + return $result; + } + + /** + * Evaluate 'datetime' type values + * + * @param int|string|\DateTimeInterface $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + */ + protected function checkValueForDatetime(int|string|\DateTimeInterface|null $value, array $tcaFieldConf): array + { + $format = $tcaFieldConf['format'] ?? 'datetime'; + if (!in_array($format, ['datetime', 'date', 'time', 'timesec', 'datetimesec'], true)) { + // Early return if format is not valid + return []; + } + + // Handle native date/time fields + $isNullable = $tcaFieldConf['nullable'] ?? false; + $nativeDateTimeType = $tcaFieldConf['dbType'] ?? null; + if (in_array($nativeDateTimeType, QueryHelper::getDateTimeTypes(), true)) { + $isNullable = $tcaFieldConf['nullable'] ?? true; + $dateTimeFormats = QueryHelper::getDateTimeFormats(); + $nativeDateTimeFieldEmptyValue = $dateTimeFormats[$nativeDateTimeType]['empty']; + if ($value === $nativeDateTimeFieldEmptyValue && $nativeDateTimeType !== 'time') { + $value = null; + } + } else { + $nativeDateTimeType = null; + } + + if (!$this->validateValueForRequired($tcaFieldConf, $value instanceof \DateTimeInterface ? $value->format(\DateTimeInterface::ATOM) : (string)$value)) { + return []; + } + + if ($value === '') { + $value = null; + } + if (MathUtility::canBeInterpretedAsInteger($value)) { + $value = (int)$value; + } + + try { + $datetime = match (true) { + $value === null => null, + $value instanceof \DateTimeImmutable => $value, + $value instanceof \DateTimeInterface => \DateTimeImmutable::createFromInterface($value), + // Reprocessing of an existing database value (e.g. Unix timestamp for date/datetime or seconds for time fields) + is_int($value) => DateTimeFactory::createFromDatabaseValueAndTCAConfig($value, $tcaFieldConf), + // The value we receive from the backend form is an unqualified ISO 8601 date, + // for instance "1999-11-11T11:11:11". + // We can also accept an ISO8601 date with offsets, + // for instance "1999-11-11T12:11:11+01:00" + // And we accept database formatted strings, + // for instance "1999-11-11 12:11:11" + default => new \DateTimeImmutable($value), + }; + } catch (\Exception) { + $datetime = null; + } + + if ($datetime !== null) { + $upper = isset($tcaFieldConf['range']['upper']) ? DateTimeFactory::createFromTimestamp((int)$tcaFieldConf['range']['upper']) : null; + if ($upper !== null && $datetime > $upper) { + $datetime = $upper; + } + + $lower = isset($tcaFieldConf['range']['lower']) ? DateTimeFactory::createFromTimestamp((int)$tcaFieldConf['range']['lower']) : null; + if ($lower !== null && $datetime < $lower) { + $datetime = $lower; + } + } + + return ['value' => QueryHelper::transformDateTimeToDatabaseValue($datetime, $isNullable, $format, $nativeDateTimeType)]; + } + + /** + * Evaluates 'check' type values. + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int $id UID of record + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $field Field name + * @return array Modified $res array + */ + protected function checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field) + { + $items = $tcaFieldConf['items'] ?? null; + if ( + ($tcaFieldConf['itemsProcFunc'] ?? '') !== '' + || ($tcaFieldConf['itemsProcessors'] ?? []) !== [] + ) { + $processingService = GeneralUtility::makeInstance(ItemProcessingService::class); + $itemsCollection = SelectItemCollection::createFromArray($tcaFieldConf['items'], $tcaFieldConf['type']); + $context = new ItemsProcessorContext( + table: $table, + field: $field, + row: $this->checkValue_currentRecord, + fieldConfiguration: $tcaFieldConf, + processorParameters: [], + realPid: $realPid, + site: $processingService->resolveSite($realPid) + ); + $items = $processingService->processItems($itemsCollection, $context)->toArray(); + } + + $itemC = 0; + if ($items !== null) { + $itemC = count($items); + } + if (!$itemC) { + $itemC = 1; + } + $maxV = (2 ** $itemC) - 1; + if ($value < 0) { + // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error. + $value = 0; + } + if ($value > $maxV) { + // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic, + // changing list of items, then it may happen that a value is transformed and vanished checkboxes + // are permanently removed from the value. + // Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a + // error message to the user - dynamic item sets via itemsProcFunc on check would be a bad idea anyway. + $value = (int)$value & $maxV; + } + if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) { + $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true); + $otherRecordsWithSameValue = []; + $maxCheckedRecords = 0; + // @todo These checks do not consider the language of the current record (if available). + if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) { + $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value, $realPid); + $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid']; + } + if (in_array('maximumRecordsChecked', $evalCodesArray, true)) { + $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value); + $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked']; + } + + // there are more than enough records with value "1" in the DB + // if so, set this value to "0" again + if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) { + $value = 0; + $this->log( + $table, + $id, + SystemLogDatabaseAction::CHECK, + null, + SystemLogErrorClassification::USER_ERROR, + 'Could not activate checkbox for field "{field}". A total of {max} record(s) can have this checkbox activated. Uncheck other records first in order to activate the checkbox of this record', + null, + ['field' => $field, 'max' => $maxCheckedRecords] + ); + } + } + $res['value'] = $value; + return $res; + } + + /** + * Evaluates 'radio' type values. + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table The table of the record + * @param int $id The id of the record + * @param int $pid The pid of the record + * @param string $field The field to check + * @return array Modified $res array + */ + protected function checkValueForRadio(array $res, $value, $tcaFieldConf, $table, $id, $pid, $field): array + { + if (!is_array($tcaFieldConf['items'] ?? null)) { + $tcaFieldConf['items'] = []; + } + foreach ($tcaFieldConf['items'] as $set) { + if ((string)$set['value'] === (string)$value) { + $res['value'] = $value; + break; + } + } + + // if no value was found and an itemsProcFunc is defined, check that for the value + if ( + empty($res['value']) + && (($tcaFieldConf['itemsProcFunc'] ?? '') !== '' + || ($tcaFieldConf['itemsProcessors'] ?? []) !== []) + ) { + $processingService = GeneralUtility::makeInstance(ItemProcessingService::class); + $itemsCollection = SelectItemCollection::createFromArray($tcaFieldConf['items'], $tcaFieldConf['type']); + $context = new ItemsProcessorContext( + table: $table, + field: $field, + row: $this->checkValue_currentRecord, + fieldConfiguration: $tcaFieldConf, + processorParameters: [], + realPid: $pid, + site: $processingService->resolveSite($pid) + ); + foreach ($processingService->processItems($itemsCollection, $context) as $set) { + if ((string)$set['value'] === (string)$value) { + $res['value'] = $value; + break; + } + } + } + + return $res; + } + + /** + * Evaluate "json" type values. + * + * @param array|string|null $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @return array The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForJson(array|string|null $value, array $tcaFieldConf): array + { + if ($value === null) { + $value = []; + } elseif (is_string($value)) { + if ($value === '') { + $value = []; + } else { + try { + $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + if ($value === null) { + // Unset value as it could not be decoded + return []; + } + } catch (\JsonException) { + // Unset value as it is invalid + return []; + } + } + } + + if (!$this->validateValueForRequired($tcaFieldConf, $value)) { + // Unset value as it is required + return []; + } + + return [ + 'value' => $value, + ]; + } + + /** + * Evaluates 'group', 'folder' or 'select' type values. + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param string|array $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int $id UID of record + * @param string $status 'update' or 'new' flag + * @param string $field Field name + * @return array Modified $res array + */ + protected function checkValueForGroupFolderSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field) + { + // Detecting if value sent is an array and if so, implode it around a comma: + if (is_array($value)) { + $value = implode(',', $value); + } else { + $value = (string)$value; + } + + // When values are sent as group or select they come as comma-separated values which are exploded by this function: + $valueArray = $this->checkValue_group_select_explodeSelectGroupValue($value); + // If multiple is not set, remove duplicates: + if (!($tcaFieldConf['multiple'] ?? false)) { + $valueArray = array_unique($valueArray); + } + // If an exclusive key is found, discard all others: + if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) { + $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']); + foreach ($valueArray as $index => $key) { + if (in_array($key, $exclusiveKeys, true)) { + $valueArray = [$index => $key]; + break; + } + } + } + // This could be a good spot for parsing the array through a validation-function which checks if the values are correct (except that database references are not in their final form - but that is the point, isn't it?) + // NOTE!!! Must check max-items of files before the later check because that check would just leave out file names if there are too many!! + $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray); + // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed! + if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) { + $preCount = count($valueArray); + foreach ($valueArray as $index => $key) { + if (!$this->BE_USER->checkAuthMode($table, $field, $key)) { + unset($valueArray[$index]); + } + } + // During the check it turns out that the value / all values were removed - we respond by simply returning an empty array so nothing is written to DB for this field. + if ($preCount && empty($valueArray)) { + return []; + } + } + // For select types which has a foreign table attached: + $unsetResult = false; + if ($tcaFieldConf['type'] === 'group' || ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['foreign_table'] ?? false))) { + // check, if there is a NEW... id in the value, that should be substituted later + if (str_contains($value, 'NEW')) { + $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)]; + $this->remapStack[] = [ + 'func' => 'checkValue_group_select_processDBdata', + 'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field], + 'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5], + 'field' => $field, + ]; + $unsetResult = true; + } else { + $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field); + } + } + if (!$unsetResult) { + $newVal = $this->checkValue_checkMax($tcaFieldConf, $valueArray); + $res['value'] = $this->castReferenceValue(implode(',', $newVal), $tcaFieldConf, str_contains($value, 'NEW')); + } else { + unset($res['value']); + } + return $res; + } + + protected function checkValueForCountry(string $value, array $tcaFieldConf): array + { + // @todo For now, countries are only stored as a SINGLE value. MM handling may need implementation here. + $valueArray = $this->applyFiltersToValues($tcaFieldConf, [$value]); + return [ + 'value' => $valueArray[0] ?? '', + ]; + } + + /** + * Evaluate "uuid" type values. Will create a new uuid in case + * an invalid uuid is provided and the field is marked as required. + * + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * + * @return array $res The result array. The processed value (if any!) is set in the "value" key. + */ + protected function checkValueForUuid(string $value, array $tcaFieldConf): array + { + if (Uuid::isValid($value)) { + return ['value' => $value]; + } + if ($tcaFieldConf['required'] ?? true) { + return ['value' => (string)match ((int)($tcaFieldConf['version'] ?? 0)) { + 6 => Uuid::v6(), + 7 => Uuid::v7(), + default => Uuid::v4() + }]; + } + // Unset invalid uuid - in case a field value is not required + return []; + } + + /** + * Applies the filter methods from a column's TCA configuration to a value array. + * + * @return array|mixed + * @throws \RuntimeException + */ + protected function applyFiltersToValues(array $tcaFieldConfiguration, array $values) + { + if (!is_array($tcaFieldConfiguration['filter'] ?? null)) { + return $values; + } + foreach ($tcaFieldConfiguration['filter'] as $filter) { + if (empty($filter['userFunc'])) { + continue; + } + $parameters = $filter['parameters'] ?? []; + if (!is_array($parameters)) { + $parameters = []; + } + $parameters['values'] = $values; + $parameters['tcaFieldConfig'] = $tcaFieldConfiguration; + $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this); + if (!is_array($values)) { + throw new \RuntimeException('Expected userFunc filter "' . $filter['userFunc'] . '" to return an array. Got ' . gettype($values) . '.', 1336051942); + } + } + return $values; + } + + /** + * Evaluates 'flex' type values. + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param mixed $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string|int $id UID of record + * @param string $curValue Current value of the field + * @param string $status 'update' or 'new' flag + * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted. + * @param string $recFID Field identifier [table:uid:field] for flexforms + */ + protected function checkValueForFlex(array $res, mixed $value, array $tcaFieldConf, string $table, string|int $id, string $curValue, string $status, int $realPid, string $recFID, int $tscPID, string $field): array + { + // Called from within a FlexForm traversal (e.g. checkFlexFormData): a flex field + // cannot nest another flex field, so skip processing entirely. + if ($field === '') { + return []; + } + if (!is_array($value)) { + $res['value'] = $value; + return $res; + } + + // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied. + // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord + // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new + // records we do know the expected PID, so we send that with this special parameter. Only active when larger than zero. + $row = $this->checkValue_currentRecord; + if ($status === 'new') { + $row['pid'] = $realPid; + } + + // Get data structure. The methods may throw various exceptions, with some of them being + // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here + // and substitute with a dummy DS. + try { + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $tcaFieldConf], + $table, + $field, + $row, + $schema + ); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + } catch (InvalidIdentifierException) { + $dataStructureArray = ['sheets' => ['sDEF' => []]]; + } + + // Get current value array: + $currentValueArray = $curValue !== '' ? GeneralUtility::xml2array($curValue) : []; + if (!is_array($currentValueArray)) { + $currentValueArray = []; + } + // Remove all old meta for languages... + // Evaluation of input values: + $value['data'] = $this->checkFlexFormData($value['data'] ?? [], $currentValueArray['data'] ?? [], $dataStructureArray, $table, $id, $status, $realPid, $recFID, $tscPID); + // Create XML from input value: + $xmlValue = $this->flexFormTools->flexArray2Xml($value); + + // Here we convert the currently submitted values BACK to an array, then merge the two and then BACK to XML again. This is needed to ensure the charsets are the same + // (provided that the current value was already stored IN the charset that the new value is converted to). + $xmlAsArray = GeneralUtility::xml2array($xmlValue); + + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) { + $hookObject = GeneralUtility::makeInstance($className); + if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) { + $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $xmlAsArray); + } + } + + ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $xmlAsArray); + $xmlValue = $this->flexFormTools->flexArray2Xml($currentValueArray); + + $xmlAsArray = GeneralUtility::xml2array($xmlValue); + $xmlAsArray = $this->sortAndDeleteFlexSectionContainerElements($xmlAsArray, $dataStructureArray); + $xmlValue = $this->flexFormTools->flexArray2Xml($xmlAsArray); + + $res['value'] = $xmlValue; + return $res; + } + + /** + * Delete and resort section container elements. + * + * @todo: It would be better if the magic _ACTION key would be a 'command array', not part of 'data array' + */ + private function sortAndDeleteFlexSectionContainerElements(array $valueArray, array $dataStructure): array + { + foreach (($dataStructure['sheets'] ?? []) as $dataStructureSheetName => $dataStructureSheetDefinition) { + if (!isset($dataStructureSheetDefinition['ROOT']['el']) || !is_array($dataStructureSheetDefinition['ROOT']['el'])) { + continue; + } + $dataStructureFields = $dataStructureSheetDefinition['ROOT']['el']; + foreach ($dataStructureFields as $dataStructureFieldName => $dataStructureFieldDefinition) { + if (isset($dataStructureFieldDefinition['type']) && $dataStructureFieldDefinition['type'] === 'array' + && isset($dataStructureFieldDefinition['section']) && (string)$dataStructureFieldDefinition['section'] === '1' + ) { + // Found a possible section within flex form data structure definition + if (!is_array($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] ?? false)) { + // No containers in data + continue; + } + $newElements = []; + $containerCounter = 0; + foreach ($valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] as $sectionKey => $sectionValues) { + // Remove to-delete containers + $action = $sectionValues['_ACTION'] ?? ''; + if ($action === 'DELETE') { + continue; + } + if (($sectionValues['_ACTION'] ?? '') === '') { + $sectionValues['_ACTION'] = $containerCounter; + } + $newElements[$sectionKey] = $sectionValues; + $containerCounter++; + } + // Resort by action key + uasort($newElements, function ($a, $b) { + return (int)$a['_ACTION'] - (int)$b['_ACTION']; + }); + foreach ($newElements as &$element) { + // Do not store action key + unset($element['_ACTION']); + } + $valueArray['data'][$dataStructureSheetName]['lDEF'][$dataStructureFieldName]['el'] = $newElements; + } + } + } + return $valueArray; + } + + /** + * Evaluates 'inline' type values. + * (partly copied from the select_group function on this issue) + * + * @param array $res The result array. The processed value (if any!) is set in the 'value' key. + * @param string $value The value to set. + * @param array $tcaFieldConf Field configuration from TCA + * @param string $table Table name + * @param int|string $id UID of record + * @param string $status 'update' or 'new' flag + * @param string $field Field name + * @param array|null $additionalData Additional data to be forwarded to sub-processors + * @return array|false Modified $res array + * @internal should only be used from within DataHandler + */ + public function checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, ?array $additionalData = null) + { + if (!$tcaFieldConf['foreign_table']) { + // Fatal error, inline fields should always have a foreign_table defined + return false; + } + // When values are sent they come as comma-separated values which are exploded by this function: + $valueArray = GeneralUtility::trimExplode(',', $value); + // Remove duplicates: (should not be needed) + $valueArray = array_unique($valueArray); + // Example for received data: + // $value = 45,NEW4555fdf59d154,12,123 + // We need to decide whether we use the stack or can save the relation directly. + if (!empty($value) && (str_contains($value, 'NEW') || !MathUtility::canBeInterpretedAsInteger($id))) { + $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)]; + $this->remapStack[] = [ + 'func' => 'checkValue_inline_processDBdata', + 'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData], + 'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4], + 'additionalData' => $additionalData, + 'field' => $field, + ]; + unset($res['value']); + } elseif ($value || MathUtility::canBeInterpretedAsInteger($id)) { + $res['value'] = $this->checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field); + } + return $res; + } + + /** + * Evaluates 'file' type values. + */ + public function checkValueForFile( + array $res, + string $value, + array $tcaFieldConf, + string $table, + int|string $id, + string $field, + ?array $additionalData = null + ): array { + $valueArray = array_unique(GeneralUtility::trimExplode(',', $value)); + if ($value !== '' && (str_contains($value, 'NEW') || !MathUtility::canBeInterpretedAsInteger($id))) { + $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)]; + $this->remapStack[] = [ + 'func' => 'checkValue_file_processDBdata', + 'args' => [$valueArray, $tcaFieldConf, $id, $table], + 'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3], + 'additionalData' => $additionalData, + 'field' => $field, + ]; + unset($res['value']); + } elseif ($value !== '' || MathUtility::canBeInterpretedAsInteger($id)) { + $res['value'] = $this->checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table); + } + return $res; + } + + /** + * Checks if a fields has more items than defined via TCA in maxitems. + * If there are more items than allowed, the item list is truncated to the defined number. + * + * @param array $tcaFieldConf Field configuration from TCA + * @param array $valueArray Current value array of items + * @return array The truncated value array of items + * @internal should only be used from within DataHandler + */ + public function checkValue_checkMax($tcaFieldConf, $valueArray): array + { + // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function + // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check + // before? Probably because we could not evaluate the validity of record uids etc... Hmm... + // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first, + // if the field is actually used regarding the CType. + $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999; + return array_slice($valueArray, 0, $maxitems); + } + + /** + * Gets a unique value for $table/$id/$field based on $value + * + * @param string $table Table name + * @param string $field Field name for which $value must be unique + * @param string $value Value string. + * @param int $id UID to filter out in the lookup (the record itself...) + * @param int $newPid If set, the value will be unique for this PID + * @return string Modified value (if not-unique). Will be the value appended with a number (until 100, then the function just breaks). + * @todo: consider workspaces, especially when publishing a unique value which has a unique value already in live + * @internal should only be used from within DataHandler + */ + public function getUnique($table, $field, $value, $id, $newPid = 0) + { + if (!$this->tcaSchemaFactory->has($table) || !$this->tcaSchemaFactory->get($table)->hasField($field)) { + // Field is not configured in TCA + return $value; + } + + $schema = $this->tcaSchemaFactory->get($table); + $tcaField = $schema->getField($field); + if ($tcaField->getTranslationBehaviour() === FieldTranslationBehaviour::Excluded && $schema->isLanguageAware()) { + $transOrigPointerField = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + $l10nParent = (int)($this->checkValue_currentRecord[$transOrigPointerField] ?? 0); + if ($l10nParent > 0) { + // Current record is a translation and l10n_mode "exclude" just copies the value from source language + return $value; + } + } + + $newValue = $originalValue = $value; + $queryBuilder = $this->getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid); + // For as long as records with the test-value existing, try again (with incremented numbers appended) + $statement = $queryBuilder->prepare(); + $result = $statement->executeQuery(); + if ($result->fetchOne()) { + for ($counter = 0; $counter <= 100; $counter++) { + $result->free(); + $newValue = $value . $counter; + $statement->bindValue(1, $newValue, Connection::PARAM_STR); + $result = $statement->executeQuery(); + if (!$result->fetchOne()) { + break; + } + } + $result->free(); + } + + if ($originalValue !== $newValue) { + $this->log($table, $id, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::WARNING, 'The value of the field "{field}" has been changed from "{originalValue}" to "{newValue}" as it is required to be unique', null, ['field' => $field, 'originalValue' => $originalValue, 'newValue' => $newValue], $newPid); + } + + return $newValue; + } + + /** + * Gets the count of records for a unique field + * + * @param string $value The string value which should be unique + * @param string $table Table name + * @param string $field Field name for which $value must be unique + * @param int $uid UID to filter out in the lookup (the record itself...) + * @param int $pid If set, the value will be unique for this PID + * @return QueryBuilder Return the prepared statement to check uniqueness + */ + protected function getUniqueCountStatement( + string $value, + string $table, + string $field, + int $uid, + int $pid + ): QueryBuilder { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->count('uid') + ->from($table) + ->where( + $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)), + $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter($uid, Connection::PARAM_INT)) + ); + // ignore translations of current record if field is configured with l10n_mode = "exclude" + $schema = $this->tcaSchemaFactory->get($table); + $tcaField = $schema->getField($field); + if ($schema->isLanguageAware() && $tcaField->getTranslationBehaviour() === FieldTranslationBehaviour::Excluded) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $queryBuilder + ->andWhere( + $queryBuilder->expr()->or( + // records without l10n_parent must be taken into account (in any language) + $queryBuilder->expr()->eq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createPositionalParameter(0, Connection::PARAM_INT) + ), + // translations of other records must be taken into account + $queryBuilder->expr()->neq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createPositionalParameter($uid, Connection::PARAM_INT) + ) + ) + ); + } + if ($pid !== 0) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, Connection::PARAM_INT)) + ); + } else { + // pid>=0 for versioning + $queryBuilder->andWhere( + $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, Connection::PARAM_INT)) + ); + } + return $queryBuilder; + } + + /** + * gets all records that have the same value in a field + * excluding the given uid + * + * @param string $tableName Table name + * @param int $uid UID to filter out in the lookup (the record itself...) + * @param string $fieldName Field name for which $value must be unique + * @param string|int $value Value string. + * @param int $pageId If set, the value will be unique for this PID + * @internal should only be used from within DataHandler + */ + public function getRecordsWithSameValue($tableName, $uid, $fieldName, $value, $pageId = 0): array + { + $result = []; + if (!$this->tcaSchemaFactory->has($tableName) || !$this->tcaSchemaFactory->get($tableName)->hasField($fieldName)) { + return $result; + } + + $uid = (int)$uid; + $pageId = (int)$pageId; + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace)); + + $queryBuilder->select('*') + ->from($tableName) + ->where( + $queryBuilder->expr()->eq( + $fieldName, + $queryBuilder->createNamedParameter($value) + ), + $queryBuilder->expr()->neq( + 'uid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ); + + if ($pageId) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)) + ); + } + + return $queryBuilder->executeQuery()->fetchAllAssociative(); + } + + /** + * @param string $value The field value to be evaluated + * @param array $evalArray Array of evaluations to traverse. + * @param string $is_in The "is_in" value of the field configuration from TCA + * @return array + * @internal should only be used from within DataHandler + */ + public function checkValue_text_Eval($value, $evalArray, $is_in) + { + $res = []; + /** @var true|false this is required as PHPstan doesn't know about evaluateFieldValue() $set */ + $set = true; + foreach ($evalArray as $func) { + switch ($func) { + case 'trim': + $value = trim((string)$value); + break; + default: + if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) { + if (class_exists($func)) { + $evalObj = GeneralUtility::makeInstance($func); + if (method_exists($evalObj, 'evaluateFieldValue')) { + $value = $evalObj->evaluateFieldValue($value, $is_in, $set); + } + } + } + } + } + if ($set) { + $res['value'] = $value; + } + return $res; + } + + /** + * Evaluation of 'input'-type values based on 'eval' list + * + * @param string $value Value to evaluate + * @param array $evalArray Array of evaluations to traverse. + * @param string $is_in Is-in string for 'is_in' evaluation + * @param string $table Table name the eval is evaluated on + * @param string|int $id Record ID the eval is evaluated on + * @return array Modified $value in key 'value' or empty array + * @internal should only be used from within DataHandler + */ + public function checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array + { + $res = []; + $set = true; + foreach ($evalArray as $func) { + switch ($func) { + case 'md5': + if (strlen($value) !== 32) { + $set = false; + } + break; + case 'trim': + $value = trim($value); + break; + case 'upper': + $value = mb_strtoupper($value, 'utf-8'); + break; + case 'lower': + $value = mb_strtolower($value, 'utf-8'); + break; + case 'is_in': + $c = mb_strlen($value); + if ($c) { + $newVal = ''; + for ($a = 0; $a < $c; $a++) { + $char = mb_substr($value, $a, 1); + if (str_contains($is_in, $char)) { + $newVal .= $char; + } + } + $value = $newVal; + } + break; + case 'nospace': + $value = str_replace(' ', '', $value); + break; + case 'alpha': + $value = preg_replace('/[^a-zA-Z]/', '', $value); + break; + case 'num': + $value = preg_replace('/[^0-9]/', '', $value); + break; + case 'alphanum': + $value = preg_replace('/[^a-zA-Z0-9]/', '', $value); + break; + case 'alphanum_x': + $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value); + break; + case 'domainname': + if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) { + $value = (string)idn_to_ascii($value); + } + break; + default: + if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) { + if (class_exists($func)) { + $evalObj = GeneralUtility::makeInstance($func); + if (method_exists($evalObj, 'evaluateFieldValue')) { + $value = $evalObj->evaluateFieldValue($value, $is_in, $set); + } + } + } + } + } + if ($set) { + $res['value'] = $value; + } + return $res; + } + + /** + * Checks if required=true is set: + * if set: checks if the value is not empty (or not "0"). + * if not set: does not matter, always returns true: + * + * @todo: If this requirement is not fulfilled, DataHandler should not execute any write statements, which could be + * properly covered by tests then + * + * @return bool true if the required flag is set and the value is properly set, or if the required flag is not needed (and thus always valid). + */ + protected function validateValueForRequired(array $tcaFieldConfig, mixed $value): bool + { + if (!isset($tcaFieldConfig['required']) || !$tcaFieldConfig['required']) { + return true; + } + return !empty($value) || $value === '0'; + } + + /** + * Returns processed data for category fields + * + * @param array $valueArray Current value array + * @param array $tcaFieldConf TCA field config + * @param string|int $id Record id, used for look-up of MM relations (local_uid) + * @param string $status Status string ('update' or 'new') + * @param string $table Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler + * @param string $field field name, needs to be set for writing to sys_history + * @return array Modified value array + * @internal should only be used from within DataHandler + */ + public function checkValue_category_processDBdata( + array $valueArray, + array $tcaFieldConf, + $id, + string $status, + string $table, + string $field + ): array { + $newRelations = implode(',', $valueArray); + $relationHandler = $this->createRelationHandlerInstance(); + $relationHandler->start($newRelations, $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf); + if ($tcaFieldConf['MM'] ?? false) { + $relationHandler->convertItemArray(); + if ($status === 'update') { + $relationHandleForOldRelations = $this->createRelationHandlerInstance(); + $relationHandleForOldRelations->start('', $tcaFieldConf['foreign_table'], $tcaFieldConf['MM'], $id, $table, $tcaFieldConf); + $oldRelations = implode(',', $relationHandleForOldRelations->getValueArray()); + $relationHandler->writeMM($tcaFieldConf['MM'], $id); + if ($oldRelations !== $newRelations) { + $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = $oldRelations; + $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = $newRelations; + } else { + $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = ''; + $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = ''; + } + } else { + $this->dbAnalysisStore[] = [$relationHandler, $tcaFieldConf['MM'], $id, '', $table]; + } + $valueArray = $relationHandler->countItems(); + } else { + $valueArray = $relationHandler->getValueArray(); + } + return $valueArray; + } + + /** + * Returns data for group/db and select fields + * + * @param array $valueArray Current value array + * @param array $tcaFieldConf TCA field config + * @param int $id Record id, used for look-up of MM relations (local_uid) + * @param string $status Status string ('update' or 'new') + * @param string $type The type, either 'select', 'group' or 'inline' + * @param string $currentTable Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler + * @param string $currentField field name, needs to be set for writing to sys_history + * @return array Modified value array + * @internal should only be used from within DataHandler + */ + public function checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField) + { + $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table']; + $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : ''; + $newRelations = implode(',', $valueArray); + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']); + $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf); + if ($tcaFieldConf['MM'] ?? false) { + // convert submitted items to use version ids instead of live ids + // (only required for MM relations in a workspace context) + $dbAnalysis->convertItemArray(); + if ($status === 'update') { + $oldRelations_dbAnalysis = $this->createRelationHandlerInstance(); + $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']); + // Db analysis with $id will initialize with the existing relations + $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf); + $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray()); + $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, $prep); + if ($oldRelations != $newRelations) { + $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations; + $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations; + } else { + $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = ''; + $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = ''; + } + } else { + $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable]; + } + $valueArray = $dbAnalysis->countItems(); + } else { + $valueArray = $dbAnalysis->getValueArray($prep); + } + // Here we should see if 1) the records exist anymore, 2) which are new and check if the BE_USER has read-access to the new ones. + return $valueArray; + } + + /** + * Explodes the $value, which is a list of files/uids (group select) + * + * @param string $value Input string, comma separated values. For each part it will also be detected if a '|' is found and the first part will then be used if that is the case. Further the value will be rawurldecoded. + * @return array The value array. + * @internal should only be used from within DataHandler + */ + public function checkValue_group_select_explodeSelectGroupValue($value): array + { + $valueArray = GeneralUtility::trimExplode(',', $value, true); + foreach ($valueArray as &$newVal) { + $temp = explode('|', $newVal, 2); + $newVal = str_replace(['|', ','], '', rawurldecode($temp[0])); + } + unset($newVal); + return $valueArray; + } + + /** + * Validate and coerce all leaf field values in a FlexForm data array against their + * Data Structure definitions by calling checkValue_SW() on each vDEF value. + * Structure-driven: only DS-declared sheets and elements are processed. + */ + private function checkFlexFormData(array $data, array $currentData, array $dataStructure, string $table, string|int $id, string $status, int $realPid, string $recFID, int $tscPID): array + { + foreach ($dataStructure['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + if (($sheetElementTca['type'] ?? '') === 'array') { + // Section element. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + continue; + } + foreach ($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + if (!is_array($valueSectionContainers ?? false)) { + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + continue; + } + foreach ($sheetElementTca['el'][$valueContainerType]['el'] as $containerElement => $containerElementTca) { + $fieldConfig = $containerElementTca['config'] ?? null; + if (!is_array($fieldConfig)) { + continue; + } + if (($fieldConfig['type'] ?? '') === 'passthrough') { + $currentVDef = $currentData[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] ?? null; + if (!empty($currentVDef)) { + // If there is existing value, keep it. + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] = $currentVDef; + } elseif (!empty($fieldConfig['default']) && !MathUtility::canBeInterpretedAsInteger($id)) { + // If is new record and a default is specified for field, use it. + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] = $fieldConfig['default']; + } + } + if (!is_array($valueContainerElements['el'][$containerElement] ?? false)) { + continue; + } + $flexFormPath = $sheetKey . '/lDEF/' . $sheetElementKey . '/el/' . $valueSectionContainerKey . '/' . $valueContainerType . '/el/' . $containerElement . '/vDEF'; + $res = $this->checkValue_SW( + [], + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] ?? null, + $fieldConfig, + $table, + $id, + $currentData[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] ?? null, + $status, + $realPid, + $recFID, + '', + $tscPID, + ['flexFormId' => $recFID, 'flexFormPath' => $flexFormPath] + ); + if (isset($res['value'])) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] = $res['value']; + } + } + } + } + } else { + // Simple field element. + $fieldConfig = $sheetElementTca['config'] ?? null; + if (!is_array($fieldConfig)) { + continue; + } + if (($fieldConfig['type'] ?? '') === 'passthrough') { + $currentVDef = $currentData[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] ?? null; + if (!empty($currentVDef)) { + // If there is existing value, keep it. + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = $currentVDef; + } elseif (!empty($fieldConfig['default']) && !MathUtility::canBeInterpretedAsInteger($id)) { + // If is new record and a default is specified for field, use it. + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = $fieldConfig['default']; + } + } + if (!isset($data[$sheetKey]['lDEF'][$sheetElementKey]) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey])) { + continue; + } + $flexFormPath = $sheetKey . '/lDEF/' . $sheetElementKey . '/vDEF'; + $res = $this->checkValue_SW( + [], + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] ?? null, + $fieldConfig, + $table, + $id, + $currentData[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] ?? null, + $status, + $realPid, + $recFID, + '', + $tscPID, + ['flexFormId' => $recFID, 'flexFormPath' => $flexFormPath] + ); + if (isset($res['value'])) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = $res['value']; + } + } + } + } + return $data; + } + + /** + * Returns data for inline fields. + * + * @param array $valueArray Current value array + * @param array $tcaFieldConf TCA field config + * @param int $id Record id + * @param string $status Status string ('update' or 'new') + * @param string $table Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler + * @param string $field The current field the values are modified for + * @return string Modified values + */ + protected function checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field) + { + $foreignTable = $tcaFieldConf['foreign_table']; + $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray); + // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf); + // IRRE with a pointer field (database normalization): + if ($tcaFieldConf['foreign_field'] ?? false) { + // update record in intermediate table (sorting & pointer uid to parent record) + $dbAnalysis->writeForeignField($tcaFieldConf, $id); + $newValue = $dbAnalysis->countItems(false); + } elseif ($this->getRelationFieldType($tcaFieldConf) === 'mm') { + // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here + $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field); + $newValue = $valueArray[0]; + } else { + $valueArray = $dbAnalysis->getValueArray(); + // Checking that the number of items is correct: + $valueArray = $this->checkValue_checkMax($tcaFieldConf, $valueArray); + $newValue = $this->castReferenceValue(implode(',', $valueArray), $tcaFieldConf, ($status === 'new')); + } + return $newValue; + } + + /** + * Returns data for file fields. + */ + protected function checkValue_file_processDBdata($valueArray, $tcaFieldConf, $id, $table): mixed + { + $filteredValueArray = GeneralUtility::makeInstance(FileExtensionFilter::class)->filter( + $valueArray, + (string)($tcaFieldConf['allowed'] ?? ''), + (string)($tcaFieldConf['disallowed'] ?? ''), + ); + $disallowedReferences = array_diff($valueArray, $filteredValueArray); + foreach ($disallowedReferences as $reference) { + // Remove not allowed sys_file_reference rows for this record + $parts = GeneralUtility::revExplode('_', (string)$reference, 2); + $fileReferenceUid = (int)$parts[count($parts) - 1]; + if ($fileReferenceUid <= 0) { + continue; + } + $this->deleteAction('sys_file_reference', $fileReferenceUid); + } + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start(implode(',', $filteredValueArray), $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf); + $dbAnalysis->writeForeignField($tcaFieldConf, $id); + return $dbAnalysis->countItems(false); + } + + /** + * Processing the cmd-array + * See "TYPO3 Core API" for a description of the options. + * + * @return void|bool + */ + public function process_cmdmap() + { + // Hook initialization: + $hookObjectsArr = []; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) { + $hookObj = GeneralUtility::makeInstance($className); + if (method_exists($hookObj, 'processCmdmap_beforeStart')) { + $hookObj->processCmdmap_beforeStart($this); + } + $hookObjectsArr[] = $hookObj; + } + $pasteDatamap = []; + // Traverse command map: + foreach ($this->cmdmap as $table => $idCommandArray) { + $table = (string)$table; + if (!$this->checkModifyAccessList($table)) { + // Check if the table may be modified! + $this->log($table, 0, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table "{table}" without permission', null, ['table' => $table]); + continue; + } + // Check basic permissions and circumstances: + if (!$this->tcaSchemaFactory->has($table) || $this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::AccessReadOnly)) { + continue; + } + + // Traverse the command map: + foreach ($idCommandArray as $id => $incomingCmdArray) { + $id = (int)$id; + if (!is_array($incomingCmdArray)) { + continue; + } + + if ($table === 'pages') { + // for commands on pages do a pagetree-refresh + $this->pagetreeNeedsRefresh = true; + } + + foreach ($incomingCmdArray as $command => $value) { + $command = (string)$command; + $pasteUpdate = false; + $schema = $this->tcaSchemaFactory->get($table); + $languageField = $schema->isLanguageAware() ? $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName() : null; + if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') { + // Extended paste command: $command is set to "move" or "copy" + // $value['update'] holds field/value pairs which should be updated after copy/move operation + // $value['target'] holds original $value (target of move/copy) + if ($languageField) { + $row = BackendUtility::getRecord($table, $id); + $languageId = $value['update'][$languageField] ?? null; + // Update language field after copy/move only if language was changed + if ($languageId !== null && (int)$languageId === $row[$languageField]) { + unset($value['update'][$languageField]); + } + // Reset language for a -1 element from original record if copied or moved into language 0 + if ($row[$languageField] === -1 && (int)$languageId === 0) { + $value['update'][$languageField] = $row[$languageField]; + } + } + $pasteUpdate = $value['update']; + $value = $value['target']; + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processCmdmap_preProcess')) { + $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate); + } + } + // Init copyMapping array: + // Must clear this array before call from here to those functions: + // Contains mapping information between new and old id numbers. + $this->copyMappingArray = []; + // process the command + $commandIsProcessed = false; + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processCmdmap')) { + /** @var bool $commandIsProcessed */ + $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate); + } + } + // Only execute default commands if a hook hasn't been processed the command already + $pasteDatamap = []; + if (!$commandIsProcessed) { + $procId = $id; + // Branch, based on command + switch ($command) { + case 'move': + $this->moveRecord($table, $id, (int)$value); + if (is_array($pasteUpdate) && $procId > 0) { + // Update after copy/move operation + $pasteDatamap[$table][$procId] = $pasteUpdate; + } + break; + case 'copy': + $target = $value['target'] ?? $value; + $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false); + if ($table === 'pages') { + $this->copyPages($id, $target); + } else { + $this->copyRecord($table, $id, $target, true, [], $ignoreLocalization); + } + $procId = $this->copyMappingArray[$table][$id] ?? null; + if (is_array($pasteUpdate) && $procId > 0) { + // Update after copy/move operation + $pasteDatamap[$table][$procId] = $pasteUpdate; + // Update language field of relations after copy operation (record was copied to a different language) + // When 'copy' is called to copy/paste some record that has inline children to *some other language*, + // the copied children must have the same "sys_language_uid" (TCA ctrl languageField) value as their + // copied parent. The loop goes through the copied elements to set their "sys_language_uid" + // @todo: localize() in 'copyToLanguage' does this already, mainly by calling copyRecord() with a negative uid/pid, + // and by not calling copyRecord() with 0 for $language (7th argument), but with the target + // language id. Trying to implement this here however leads to different "sorting" values, but + // in general, this loop should not be needed and streamlined with details from 'copyToLanguage'. + // @todo: Make this work for move operation as well? May need additional test coverage. + foreach ($this->copyMappingArray as $procTable => $copyProcIds) { + foreach ($copyProcIds as $copyProcId) { + if ($copyProcId !== $procId) { + if ($languageField !== null && isset($pasteUpdate[$languageField])) { + $pasteDatamap[$procTable][$copyProcId][$languageField] = $pasteUpdate[$languageField]; + } + } + } + } + } + break; + case 'localize': + // @todo: Hand this state change around as method argument to localize() instead. + $backupUseTransOrigPointerField = $this->useTransOrigPointerField; + $this->useTransOrigPointerField = true; + $this->localize($table, $id, $value); + $this->useTransOrigPointerField = $backupUseTransOrigPointerField; + break; + case 'copyToLanguage': + $backupUseTransOrigPointerField = $this->useTransOrigPointerField; + $this->useTransOrigPointerField = false; + $this->localize($table, $id, $value); + $this->useTransOrigPointerField = $backupUseTransOrigPointerField; + break; + case 'inlineLocalizeSynchronize': + $this->inlineLocalizeSynchronize($table, $id, $value); + break; + case 'delete': + $this->deleteAction($table, $id); + break; + case 'undelete': + $this->undeleteRecord($table, $id); + break; + case 'discard': + $this->discard((string)$table, (int)$id); + break; + case 'version': + $action = $value['action'] ?? null; + if ($action === 'new') { + $this->versionizeRecord($table, $id, $value['label'] ?? null); + } elseif ($value['action'] === 'clearWSID' || $value['action'] === 'flush') { + // @todo: This can be removed once testing framework is not using 'clearWSID' and 'flush' anymore + $this->discard($table, $id); + } + break; + } + } + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processCmdmap_postProcess')) { + $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap); + } + } + // Merging the copy-array info together for remapping purposes. + ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray); + } + } + } + $copyTCE = $this->getLocalTCE(); + $copyTCE->start($pasteDatamap, [], $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $copyTCE->process_datamap(); + $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog); + unset($copyTCE); + + // Finally, before exit, check if there are ID references to remap. + // This might be the case if versioning or copying has taken place! + $this->remapListedDBRecords(); + $this->processRemapStack(); + foreach ($hookObjectsArr as $hookObj) { + if (method_exists($hookObj, 'processCmdmap_afterFinish')) { + $hookObj->processCmdmap_afterFinish($this); + } + } + if ($this->isOuterMostInstance()) { + $this->referenceIndexUpdater->update(); + $this->processClearCacheQueue(); + $this->resetNestedElementCalls(); + } + } + + /** + * Copying a single record + * + * @param string $table Element table + * @param int $uid Element UID + * @param int $destPid >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if + * @param bool $first Is a flag set, if the record copied is NOT a 'slave' to another record copied. That is, if this record was asked to be copied in the cmd-array + * @param array $overrideValues Associative array with field/value pairs to override directly. Notice; Fields must exist in the table record and NOT be among excluded fields! + * @param bool $ignoreLocalization If TRUE, any localization routine is skipped + * @return int|null ID of new record, if any + * @internal should only be used from within DataHandler + */ + public function copyRecord(string $table, int $uid, int $destPid, bool $first = false, array $overrideValues = [], bool $ignoreLocalization = false): ?int + { + $uid = ($origUid = $uid); + // Only copy if the table has a Schema, a uid is given and the record wasn't copied before: + if (!$this->tcaSchemaFactory->has($table) || $uid === 0) { + return null; + } + $schema = $this->tcaSchemaFactory->get($table); + if ($this->isRecordCopied($table, $uid)) { + return null; + } + + $row = BackendUtility::getRecord($table, $uid); + if (!is_array($row)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" which does not exist', null, ['table' => $table, 'uid' => (int)$uid]); + return null; + } + BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace); + if ($table === 'pages') { + $pageContext = $row; + } elseif ((int)$row['pid'] > 0) { + $pageContext = BackendUtility::getRecord('pages', $row['pid']); + if (!is_array($pageContext)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" which is not assigned to a valid page', null, ['table' => $table, 'uid' => (int)$uid]); + return null; + } + } else { + $pageContext = VirtualRecord::RootPage; + } + if (!$this->hasPageContextPermission($table, Permission::PAGE_SHOW, $pageContext)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" without read permissions', null, ['table' => $table, 'uid' => (int)$uid]); + return null; + } + + $pageRecord = is_array($pageContext) ? $pageContext : []; + $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, $uid, $destPid); + + // Check if table is allowed on destination page + if (!$this->isTableAllowedForThisPage($tscPID, $table, $pageRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record "{table}:{uid}" on a page ({pid}) that can\'t store record type', null, ['table' => $table, 'uid' => $uid, 'pid' => $tscPID]); + return null; + } + + $fullLanguageCheckNeeded = $table !== 'pages'; + // Used to check language and general editing rights + $accessResult = $this->BE_USER->checkRecordEditAccess($table, $row, false, $fullLanguageCheckNeeded); + if (!$ignoreLocalization && !$accessResult->isAllowed) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "{table}:{uid}" without having permissions to do so [{reason}]', null, ['table' => $table, 'uid' => $uid, 'reason' => $accessResult->errorMessage]); + return null; + } + + BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace); + if ($schema->hasCapability(TcaSchemaCapability::Workspace) + && $this->BE_USER->workspace > 0 + && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER + ) { + // The to-copy record turns out to be a delete placeholder. Those do not make sense to be copied and are skipped. + return null; + } + $row = BackendUtility::purgeComputedPropertiesFromRecord($row); + + // Initializing: + $schema = $this->tcaSchemaFactory->get($table); + $theNewID = StringUtility::getUniqueId('NEW'); + $disabledField = $schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) ? $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getField() : null; + $labelFieldName = $schema->getCapability(TcaSchemaCapability::Label)->getPrimaryFieldName() ?? ''; + // Getting "copy-after" fields if applicable: + $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields((string)$table, (int)abs($destPid)) : []; + // Page TSconfig related: + $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? []; + $tE = $this->getTableEntries($table, $TSConfig); + // Determine the record's own language for field processing + $recordLanguage = 0; + if ($schema->isLanguageAware()) { + $recordLanguage = (int)($row[$schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName()] ?? 0); + } + + $data = []; + $row = $this->removeNonCopyableFields($table, $row, 'copyRecord'); + + // Traverse ALL fields of the selected record: + foreach ($row as $field => $value) { + // Preparation/Processing of the value: + // "pid" is hardcoded of course: + // isset() won't work here, since values can be NULL in each of the arrays + // except setDefaultOnCopyArray, since we exploded that from a string + if ($field === 'pid') { + $value = $destPid; + } elseif (array_key_exists($field, $overrideValues)) { + // Override value... + $value = $overrideValues[$field]; + } elseif (array_key_exists($field, $copyAfterFields)) { + // Copy-after value if available: + $value = $copyAfterFields[$field]; + } else { + // Hide at copy may override: + if ($first && $field === $disabledField?->getName() + && $schema->hasCapability(TcaSchemaCapability::HideRecordsAtCopy) + && !($this->BE_USER->uc['neverHideAtCopy'] ?? false) + && !($tE['disableHideAtCopy'] ?? false) + ) { + $value = 1; + } + // Prepend label on copy: + if ($first && $field === $labelFieldName + && $schema->hasCapability(TcaSchemaCapability::PrependLabelTextAtCopy) + && !($tE['disablePrependAtCopy'] ?? false) + ) { + $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0); + } + // Get TCA configuration for the field (respecting columnsOverrides): + $conf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field, $row); + // Processing based on the TCA config field type (files, references, flexforms...) + $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $recordLanguage); + } + // Add value to array. + $data[$table][$theNewID][$field] = $value; + } + // Overriding values: + if ($schema->hasCapability(TcaSchemaCapability::EditLock)) { + $data[$table][$theNewID][$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] = 0; + } + // Setting original UID: + if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) { + $data[$table][$theNewID][$schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName()] = $uid; + } + // Do the copy by simply submitting the array through DataHandler: + $copyTCE = $this->getLocalTCE(); + $copyTCE->start($data, [], $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $copyTCE->process_datamap(); + // Getting the new UID: + $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null; + if ($theNewSQLID) { + $this->copyMappingArray[$table][$origUid] = $theNewSQLID; + // Keep automatically versionized record information: + if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) { + $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID]; + } + } + $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog); + if (!$ignoreLocalization && $recordLanguage === 0 && $schema->isLanguageAware()) { + // repointing the new translation records to the parent record we just created + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + // repointing the new translation records to the parent record we just created + $overrideValues[$languageCapability->getTranslationOriginPointerField()->getName()] = $theNewSQLID; + if ($languageCapability->hasTranslationSourceField()) { + $overrideValues[$languageCapability->getTranslationSourceField()->getName()] = 0; + } + $this->copyL10nOverlayRecords($table, $uid, $destPid, $first, $overrideValues); + } + + return $theNewSQLID; + } + + /** + * Copying pages + * Main function for copying pages. + * + * @param int $uid Page UID to copy + * @param int $destPid Destination PID: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if + * @internal should only be used from within DataHandler + */ + public function copyPages($uid, $destPid): void + { + // Initialize: + $uid = (int)$uid; + $destPid = (int)$destPid; + + $copyTablesAlongWithPage = $this->BE_USER->isAdmin() + ? $this->tcaSchemaFactory->all()->getNames() + : explode(',', $this->BE_USER->groupData['tables_modify']); + $copyTablesAlongWithPage = array_unique($copyTablesAlongWithPage); + + // Begin to copy pages if we're allowed to: + if ($this->BE_USER->isAdmin() || in_array('pages', $copyTablesAlongWithPage, true)) { + // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured) + // This method also copies the localizations of a page + $theNewRootID = $this->copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true); + // If we're going to copy recursively + if ($theNewRootID && MathUtility::forceIntegerInRange($this->BE_USER->uc['copyLevels'] ?? 0, 0, 100) > 0) { + // Get ALL subpages to copy (read-permissions are respected!): + $CPtable = $this->int_pageTreeInfo([], $uid, MathUtility::forceIntegerInRange($this->BE_USER->uc['copyLevels'] ?? 0, 0, 100), $theNewRootID); + // Now copying the subpages: + foreach ($CPtable as $thePageUid => $thePagePid) { + $newPid = $this->copyMappingArray['pages'][$thePagePid] ?? null; + if (isset($newPid)) { + $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage); + } else { + $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch'); + break; + } + } + } + } else { + $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page {uid} without permission to this table', null, ['uid' => $uid]); + } + } + + /** + * Copying a single page ($uid) to $destPid and all tables in the array copyTablesArray. + * + * @param int $uid Page uid + * @param int $destPid Destination PID: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if + * @param array $copyTablesArray Table on pages to copy along with the page. + * @param bool $first Is a flag set, if the record copied is NOT a 'slave' to another record copied. That is, if this record was asked to be copied in the cmd-array + * @return int|null The id of the new page, if applicable. + * @internal should only be used from within DataHandler + */ + public function copySpecificPage($uid, $destPid, $copyTablesArray, $first = false) + { + // Copy the page itself: + $theNewRootID = $this->copyRecord('pages', $uid, $destPid, $first); + if ($theNewRootID === null) { + return null; + } + $currentWorkspaceId = (int)$this->BE_USER->workspace; + foreach ($copyTablesArray as $table) { + // All records under the page is copied. + if ($table && $this->tcaSchemaFactory->has($table) && $table !== 'pages') { + $schema = $this->tcaSchemaFactory->get($table); + $fields = ['uid']; + $languageField = null; + $transOrigPointerField = null; + $translationSourceField = null; + if ($schema->isLanguageAware()) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + $fields[] = $languageField; + $fields[] = $transOrigPointerField; + if ($languageCapability->hasTranslationSourceField()) { + $translationSourceField = $languageCapability->getTranslationSourceField()->getName(); + $fields[] = $translationSourceField; + } + } + $isTableWorkspaceEnabled = $schema->isWorkspaceAware(); + if ($isTableWorkspaceEnabled) { + $fields[] = 't3ver_oid'; + $fields[] = 't3ver_state'; + $fields[] = 't3ver_wsid'; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId)); + $queryBuilder + ->select(...$fields) + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ); + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $queryBuilder->orderBy($schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(), 'DESC'); + } + $queryBuilder->addOrderBy('uid'); + try { + $result = $queryBuilder->executeQuery(); + $rows = []; + $movedLiveIds = []; + $movedLiveRecords = []; + while ($row = $result->fetchAssociative()) { + if ($isTableWorkspaceEnabled && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) { + $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid']; + } + $rows[(int)$row['uid']] = $row; + } + // Resolve placeholders of workspace versions + if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) { + // If a record was moved within the page, the PlainDataResolver needs the moved record + // but not the original live version, otherwise the moved record is not considered at all. + // For this reason, we find the live ids, where there was also a moved record in the SQL + // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver. + // see changeContentSortingAndCopyDraftPage test + foreach ($movedLiveIds as $liveId => $movePlaceHolderId) { + if (isset($rows[$liveId])) { + $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId]; + unset($rows[$liveId]); + } + } + $rows = array_reverse( + $this->resolveVersionedRecords( + $table, + implode(',', $fields), + $schema->hasCapability(TcaSchemaCapability::SortByField) ? $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName() : '', + array_keys($rows) + ), + true + ); + foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) { + $rows[$movePlaceHolderId] = $liveRecord; + } + } + if ($rows !== []) { + $languageSourceMap = []; + $overrideValues = $translationSourceField ? [$translationSourceField => 0] : []; + $doRemap = false; + foreach ($rows as $row) { + // Skip localized records that will be processed in + // copyL10nOverlayRecords() on copying the default language record + $transOrigPointer = $row[$transOrigPointerField ?? ''] ?? 0; + if (!empty($languageField) + && $row[$languageField] > 0 + && $transOrigPointer > 0 + && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer])) + ) { + continue; + } + // Copying each of the underlying records... + $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues); + if ($translationSourceField) { + $languageSourceMap[$row['uid']] = $newUid; + if ($row[$languageField] > 0) { + $doRemap = true; + } + } + } + if ($doRemap) { + //remap is needed for records in non-default language records in the "free mode" + $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap); + } + } + } catch (DBALException $e) { + $this->log($table, $uid, SystemLogDatabaseAction::CHECK, null, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: {reason}', null, ['reason' => $e->getMessage()]); + } + } + } + $this->processRemapStack(); + return $theNewRootID; + } + + /** + * Copying records, but makes a "raw" copy of a record. + * Basically the only thing observed is field processing like the copying of files and correction of ids. All other fields are 1-1 copied. + * Technically the copy is made with THIS instance of the DataHandler class contrary to copyRecord() which creates a new instance and uses the processData() function. + * The copy is created by insertNewCopyVersion() which bypasses most of the regular input checking associated with processData() - maybe copyRecord() should even do this as well!? + * This function is used to create new versions of a record. + * NOTICE: DOES NOT CHECK PERMISSIONS to create! And since page permissions are just passed through and not changed to the user who executes the copy we cannot enforce permissions without getting an incomplete copy - unless we change permissions of course. + * + * @param string $table Element table + * @param int $uid Element UID + * @param int $pid Element PID (real PID, not checked) + * @param array $overrideArray Override array - must NOT contain any fields not in the table! + * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently + * @return int|null Returns the new ID of the record (if applicable) + * @internal should only be used from within DataHandler + */ + public function copyRecord_raw(string $table, $uid, $pid, array $overrideArray = [], array $workspaceOptions = []): ?int + { + $uid = (int)$uid; + // Stop any actions if the record is marked to be deleted: + // (this can occur if IRRE elements are versionized and child elements are removed) + if ($this->isElementToBeDeleted($table, $uid)) { + return null; + } + // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before: + if (!$this->tcaSchemaFactory->has($table) || !$uid || $this->isRecordCopied($table, $uid)) { + return null; + } + $row = BackendUtility::getRecord($table, $uid); + if (!is_array($row)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to create workspace version of a not existing record'); + return null; + } + + $schema = $this->tcaSchemaFactory->get($table); + + // Merge in override array (t3ver_* overrides from versionizeRecord, etc.) + $row = array_merge($row, $overrideArray); + + // Preserve system field values (perms_*, t3ver_*) — they must reach insertNewCopyVersion + $preservedSystemFields = array_intersect_key($row, array_flip($this->nonFields)); + unset($preservedSystemFields['uid']); + + // Remove non-copyable fields for the processing loop + $row = $this->removeNonCopyableFields($table, $row, 'copyRecord_raw'); + + // Traverse ALL fields of the selected record: + foreach ($row as $field => $value) { + if ($field === 'pid') { + $value = $pid; + } else { + // Get TCA configuration for the field (respecting columnsOverrides): + $conf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field, $row); + if ($conf !== []) { + // Processing based on the TCA config field type (files, references, flexforms...) + $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions); + } + } + // Add value to array. + $row[$field] = $value; + } + + // Re-add preserved system fields for insertNewCopyVersion + $row = array_merge($row, $preservedSystemFields); + // Setting original UID: + if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) { + $row[$schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName()] = $uid; + } + // Do the copy by internal function + $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid); + + // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records + // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference + // that refers e.g. to a tt_content record is marked as deleted. The tt_content record then needs a reference index update. + // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now. + if ($workspaceOptions !== []) { + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $this->BE_USER->workspace); + } + + if ($theNewSQLID) { + $this->dbAnalysisStoreExec(); + $this->dbAnalysisStore = []; + $this->copyMappingArray[$table][$uid] = $theNewSQLID; + return $theNewSQLID; + } + return null; + } + + /** + * Inserts a record in the database, passing TCA configuration values through checkValue() but otherwise does NOTHING and checks nothing regarding permissions. + * Passes the "version" parameter to insertDB() so the copy will look like a new version in the log - should probably be changed or modified a bit for more broad usage... + * + * @param string $table Table name + * @param array $fieldArray Field array to insert as a record + * @param int $realPid The value of PID field. + * @return int|null Returns the new ID of the record (if applicable) + * @internal should only be used from within DataHandler + */ + public function insertNewCopyVersion($table, $fieldArray, $realPid): ?int + { + $schema = $this->tcaSchemaFactory->get($table); + $id = StringUtility::getUniqueId('NEW'); + // $fieldArray is set as current record. + // The point is that when new records are created as copies with flex type fields there might be a field containing information about which DataStructure to use and without that information the flexforms cannot be correctly processed.... This should be OK since the $checkValueRecord is used by the flexform evaluation only anyways... + $this->checkValue_currentRecord = $fieldArray; + // Makes sure that transformations aren't processed on the copy. + $backupDontProcessTransformations = $this->dontProcessTransformations; + $this->dontProcessTransformations = true; + // Traverse record and input-process each value: + foreach ($fieldArray as $field => $fieldValue) { + if ($schema->hasField($field)) { + // Evaluating the value. + $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray); + if (isset($res['value'])) { + $fieldArray[$field] = $res['value']; + } + } + } + // System fields being set: + if ($schema->hasCapability(TcaSchemaCapability::CreatedAt)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + // Finally, insert record: + $this->insertDB($table, $id, $fieldArray); + // Resets dontProcessTransformations to the previous state. + $this->dontProcessTransformations = $backupDontProcessTransformations; + // Return new id: + return $this->substNEWwithIDs[$id] ?? null; + } + + /** + * Processing/Preparing content for copyRecord() function + * + * @param string $table Table name + * @param int $uid Record uid + * @param string $field Field name being processed + * @param string|null $value Input value to be processed. + * @param array $row Record array + * @param array $conf TCA field configuration + * @param int $realDestPid Real page id (pid) the record is copied to + * @param int $language Language ID used in the duplicated record + * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently + * @return array|string|null + * @internal + * @see copyRecord() + */ + public function copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = []) + { + $relationFieldType = $this->getRelationFieldType($conf); + // Get the localization mode for the current (parent) record (keep|select): + // Register if there are references to take care of or MM is used on an inline field (no change to value): + if ($this->isReferenceField($conf) || $relationFieldType === 'mm') { + $value = $this->copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language); + } elseif ($relationFieldType !== false) { + $value = $this->copyRecord_processRelation($table, $uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions); + } + // For "flex" fieldtypes we need to traverse the structure for two reasons: If there are file references they have to be prepended with absolute paths and if there are database reference they MIGHT need to be remapped (still done in remapListedDBRecords()) + if (isset($conf['type']) && $conf['type'] === 'flex') { + // Get current value array: + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $conf], + $table, + $field, + $row, + $schema + ); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + $currentValue = is_string($value) ? GeneralUtility::xml2array($value) : null; + // Traversing the XML structure, processing relations in FlexForm such as inline records: + if (is_array($currentValue)) { + $currentValue['data'] = $this->copyFlexFormData($currentValue['data'] ?? [], $dataStructureArray, $table, $uid, $field, $realDestPid, $language, $workspaceOptions); + // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created. + $value = $currentValue; + } + } + if (($conf['type'] ?? '') === 'uuid' && $value !== '') { + // Uuid is unique by definition. On copying a record, this value must be regenerated + // if set. In case uuid is not set, it might be optional - or the base record is + // invalid. In this case the uuid will then be generated by checkValueForUuid(). + $value = (string)match ((int)($conf['version'] ?? 0)) { + 6 => Uuid::v6(), + 7 => Uuid::v7(), + default => Uuid::v4() + }; + } + return $value; + } + + /** + * Processes the children of an MM relation field (select, group, inline) when the parent record is copied. + * + * @param string $table + * @param int $uid + * @param string $field + * @param string $value + * @param array $conf + * @param int $language + * @return string + */ + protected function copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language) + { + $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table']; + $allowedTablesArray = GeneralUtility::trimExplode(',', $allowedTables, true); + $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : ''; + $mmTable = !empty($conf['MM']) ? $conf['MM'] : ''; + + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $allowedTables, $mmTable, $uid, $table, $conf); + $purgeItems = false; + + // Check if referenced records of select or group fields should also be localized in general. + // A further check is done in the loop below for each table name. + if ($language > 0 && $mmTable === '' && !empty($conf['localizeReferencesAtParentLocalization'])) { + // Check whether allowed tables can be localized. + $localizeTables = []; + foreach ($allowedTablesArray as $allowedTable) { + $localizeTables[$allowedTable] = (bool)$this->tcaSchemaFactory->get($allowedTable)->isLanguageAware(); + } + + foreach ($dbAnalysis->itemArray as $index => $item) { + // No action required, if referenced tables cannot be localized (current value will be used). + if (empty($localizeTables[$item['table']])) { + continue; + } + + // Since select or group fields can reference many records, check whether there's already a localization. + $recordLocalization = $this->localizationRepository->getRecordTranslation($item['table'], (int)$item['id'], $language, $this->BE_USER->workspace); + if ($recordLocalization) { + $dbAnalysis->itemArray[$index]['id'] = $recordLocalization->getUid(); + } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . $language) === false) { + $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], (int)$item['id'], (int)$language); + } + } + $purgeItems = true; + } + + if ($purgeItems || $mmTable !== '') { + $dbAnalysis->purgeItemArray(); + $value = implode(',', $dbAnalysis->getValueArray($prependName)); + } + // Setting the value in this array will notify the remapListedDBRecords() function that this field MAY need references to be corrected. + if ($value) { + $this->registerDBList[$table][$uid][$field] = $value; + } + + return $value; + } + + /** + * Processes relations in an inline (IRRE) or file element when the parent record is copied. + * + * @param string $table + * @param int $uid + * @param string $field + * @param string $value + * @param array $row + * @param array $conf + * @param int $realDestPid + * @param int $language + * @return string + */ + protected function copyRecord_processRelation( + $table, + $uid, + $field, + $value, + $row, + $conf, + $realDestPid, + $language, + array $workspaceOptions + ) { + $schema = $this->tcaSchemaFactory->get($table); + // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf); + // Walk through the items, copy them and remember the new id: + foreach ($dbAnalysis->itemArray as $k => $v) { + $newId = null; + $childTableIsWorkspaceAware = $this->tcaSchemaFactory->has($v['table']) && $this->tcaSchemaFactory->get($v['table'])->isWorkspaceAware(); + $childTableIsLanguageAware = $this->tcaSchemaFactory->has($v['table']) && $this->tcaSchemaFactory->get($v['table'])->isLanguageAware(); + // If language is set and differs from original record, this isn't a copy action but a localization of our parent/ancestor: + if ($language > 0 && $schema->isLanguageAware() && $language != ($row[$schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName()] ?? 0)) { + // Skip localization of children whose table is not language-aware, as they cannot be localized (e.g. monoglot IRRE children). + // Remove from itemArray to prevent writeForeignField from reassigning the original child to the localized parent. + // @see DataScenarios\IrreForeignField\AbstractActionTestCase->localizeParentContentWithMonoglotHotelChild() + if (!$childTableIsLanguageAware) { + unset($dbAnalysis->itemArray[$k]); + continue; + } + // Children should be localized when the parent gets localized the first time, just do it: + $newId = $this->localize($v['table'], (int)$v['id'], (int)$language); + } else { + if (!MathUtility::canBeInterpretedAsInteger($realDestPid)) { + $newId = $this->copyRecord($v['table'], $v['id'], -(int)($v['id'])); + // If the destination page id is a NEW string, keep it on the same page + } elseif ($this->BE_USER->workspace > 0 && $childTableIsWorkspaceAware) { + // A filled $workspaceOptions indicated that this call + // has it's origin in previous versionizeRecord() processing + if (!empty($workspaceOptions)) { + // Versions use live default id, thus the "new" + // id is the original live default child record + $newId = $v['id']; + $this->versionizeRecord( + $v['table'], + $v['id'], + $workspaceOptions['label'] ?? 'Auto-created for WS #' . $this->BE_USER->workspace, + $workspaceOptions['delete'] ?? false + ); + // Otherwise just use plain copyRecord() to create placeholders etc. + } else { + // If a record has been copied already during this request, + // prevent superfluous duplication and use the existing copy + if (isset($this->copyMappingArray[$v['table']][$v['id']])) { + $newId = $this->copyMappingArray[$v['table']][$v['id']]; + } else { + $newId = $this->copyRecord($v['table'], $v['id'], $realDestPid); + } + } + } elseif ($this->BE_USER->workspace > 0 && !$childTableIsWorkspaceAware) { + // We are in workspace context creating a new parent version and have a child table + // that is not workspace aware. We don't do anything with this child. + continue; + } else { + // If a record has been copied already during this request, + // prevent superfluous duplication and use the existing copy + if (isset($this->copyMappingArray[$v['table']][$v['id']])) { + $newId = $this->copyMappingArray[$v['table']][$v['id']]; + } else { + $newId = $this->copyRecord_raw($v['table'], $v['id'], $realDestPid, [], $workspaceOptions); + } + } + } + // If the current field is set on a page record, update the pid of related child records: + if ($table === 'pages') { + $this->registerDBPids[$v['table']][$v['id']] = $uid; + } elseif (isset($this->registerDBPids[$table][$uid])) { + $this->registerDBPids[$v['table']][$v['id']] = $this->registerDBPids[$table][$uid]; + } + $dbAnalysis->itemArray[$k]['id'] = $newId; + } + // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record): + $value = implode(',', $dbAnalysis->getValueArray()); + $this->registerDBList[$table][$uid][$field] = $value; + + return $value; + } + + /** + * Process FlexForm relation and inline fields during a record copy, registering them + * for post-copy UID remapping via remapListedDBRecords(). + */ + private function copyFlexFormData(array $data, array $dataStructure, string $table, int $uid, string $field, int $realDestPid, int $language, array $workspaceOptions): array + { + foreach ($dataStructure['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + if (($sheetElementTca['type'] ?? '') === 'array') { + // Section element. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + continue; + } + foreach ($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + if (!is_array($valueSectionContainers ?? false)) { + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + continue; + } + foreach ($sheetElementTca['el'][$valueContainerType]['el'] as $containerElement => $containerElementTca) { + if (!isset($data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'])) { + continue; + } + $fieldConfig = $containerElementTca['config'] ?? []; + $fieldValue = $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF']; + if (($this->isReferenceField($fieldConfig) || $this->getRelationFieldType($fieldConfig) !== false) && (string)$fieldValue !== '') { + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] + = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $fieldValue, [], $fieldConfig, $realDestPid, $language, $workspaceOptions); + $this->registerDBList[$table][$uid][$field] = 'FlexForm_reference'; + } + } + } + } + } elseif (isset($data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Simple field element. + $fieldConfig = $sheetElementTca['config'] ?? []; + $fieldValue = $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF']; + if (($this->isReferenceField($fieldConfig) || $this->getRelationFieldType($fieldConfig) !== false) && (string)$fieldValue !== '') { + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] + = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $fieldValue, [], $fieldConfig, $realDestPid, $language, $workspaceOptions); + $this->registerDBList[$table][$uid][$field] = 'FlexForm_reference'; + } + } + } + } + return $data; + } + + /** + * Find l10n-overlay records and perform the requested copy action for these records. + * + * @param int $uid uid default language record + * @param int $destPid Position to copy to + */ + protected function copyL10nOverlayRecords(string $table, int $uid, int $destPid, bool $first = false, array $overrideValues = []): void + { + if (!$this->tcaSchemaFactory->has($table)) { + return; + } + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware()) { + return; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace)); + $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + $transOrigPointerField, + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT, ':pointer') + ) + ); + + // Never copy the actual placeholders around, as the newly copied records are + // always created as new record / new placeholder pairs + if ($schema->isWorkspaceAware()) { + $queryBuilder->andWhere( + $queryBuilder->expr()->neq( + 't3ver_state', + VersionState::DELETE_PLACEHOLDER->value + ) + ); + } + + // If $destPid is < 0, get the pid of the record with uid equal to abs($destPid) + // @todo: getTSconfig_pidValue() may return -1 or -2, which is an ugly interface and not handled below properly. + $tscPID = BackendUtility::getTSconfig_pidValue($table, $uid, $destPid) ?? 0; + // Get the localized records to be copied + $l10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative(); + if (empty($l10nRecords)) { + return; + } + + // Check whether the target site has configured the language to avoid orphaned records + $targetPageId = $destPid < 0 ? $tscPID : $destPid; + try { + $targetSite = $this->siteFinder->getSiteByPageId($targetPageId); + // Get all language IDs configured in the target site (including disabled ones, as they may be re-enabled) + $siteLanguageIds = array_keys($targetSite->getAllLanguages()); + foreach ($l10nRecords as $key => $record) { + // Remove page translation when the language is not configured in the target site + if (!in_array($record[$languageField], $siteLanguageIds, true)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::WARNING, 'Attempt to copy translation of record {table}:{uid} into a site, but the site does not support the language {language}', null, ['table' => $table, 'uid' => $uid, 'language' => $record[$languageField]]); + unset($l10nRecords[$key]); + } + } + if (empty($l10nRecords)) { + return; + } + } catch (SiteNotFoundException) { + // Translations are not possible in a non-site context + return; + } + + $localizedDestPids = []; + // If $destPid < 0, then it is the uid of the original language record we are inserting after + if ($destPid < 0) { + // Get the localized records of the record we are inserting after + $queryBuilder->setParameter('pointer', abs($destPid), Connection::PARAM_INT); + $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative(); + // Index the localized record uids by language + foreach ($destL10nRecords as $record) { + $localizedDestPids[$record[$languageField]] = -$record['uid']; + } + } + $languageSourceMap = [ + $uid => $overrideValues[$transOrigPointerField], + ]; + + // For non-page records: Check if target page has translations in the respective language + if ($table !== 'pages') { + $pageTranslations = $this->localizationRepository->getPageTranslations($destPid < 0 ? $tscPID : $destPid, [], $this->BE_USER->workspace); + // Build array with language ids for comparison + $availableLanguages = array_keys($pageTranslations); + // Filter records + foreach ($l10nRecords as $key => $record) { + // Remove record when target page is not available in the corresponding language + if (!in_array($record[$languageField], $availableLanguages, true)) { + $this->log($table, $uid, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::WARNING, 'Attempt to copy translation of record {table}:{uid} into a page, but the page does not support the language {language}', null, ['table' => $table, 'uid' => $uid, 'language' => $record[$languageField]]); + unset($l10nRecords[$key]); + } + } + if (empty($l10nRecords)) { + return; + } + } + + // Copy the localized records after the corresponding localizations of the destination record + foreach ($l10nRecords as $record) { + $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0); + if ($localizedDestPid < 0) { + $newUid = $this->copyRecord($table, $record['uid'], $localizedDestPid, $first, $overrideValues); + } else { + $newUid = $this->copyRecord($table, $record['uid'], $destPid < 0 ? $tscPID : $destPid, $first, $overrideValues); + } + $languageSourceMap[$record['uid']] = $newUid; + } + $this->copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap); + } + + /** + * Remap languageSource field to uids of newly created records + * + * @param array $l10nRecords array of localized records from the page we're copying from (source records) + * @param array $languageSourceMap array mapping source records uids to newly copied uids + */ + protected function copy_remapTranslationSourceField(string $table, array $l10nRecords, array $languageSourceMap): void + { + if (!$this->tcaSchemaFactory->has($table)) { + return; + } + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware()) { + return; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + if (!$languageCapability->hasTranslationSourceField()) { + return; + } + $translationSourceFieldName = $languageCapability->getTranslationSourceField()->getName(); + $translationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + + // We can avoid running these update queries by sorting the $l10nRecords by languageSource dependency (in copyL10nOverlayRecords) + // and first copy records depending on default record (and map the field). + foreach ($l10nRecords as $record) { + $oldSourceUid = $record[$translationSourceFieldName]; + if ($oldSourceUid <= 0 && $record[$translationParentFieldName] > 0) { + //BC fix - in connected mode 'translationSource' field should not be 0 + $oldSourceUid = $record[$translationParentFieldName]; + } + if ($oldSourceUid > 0) { + if (empty($languageSourceMap[$oldSourceUid])) { + // we don't have mapping information available e.g when copyRecord returned null + continue; + } + $newFieldValue = $languageSourceMap[$oldSourceUid]; + $updateFields = [ + $translationSourceFieldName => $newFieldValue, + ]; + if (isset($languageSourceMap[$record['uid']])) { + $this->connectionPool->getConnectionForTable($table) + ->update($table, $updateFields, ['uid' => (int)$languageSourceMap[$record['uid']]]); + if ($this->BE_USER->workspace > 0) { + $this->connectionPool->getConnectionForTable($table) + ->update($table, $updateFields, ['t3ver_oid' => (int)$languageSourceMap[$record['uid']], 't3ver_wsid' => $this->BE_USER->workspace]); + } + } + } + } + } + + /** + * Move a single record. + * + * @param int $destination Position to move to. If >=0, then it points to a page uid on which to insert the + * record as the first element. If <0, then it points to an uid from its own table + * after which the record should be moved to. + * @internal should only be used from within DataHandler + */ + public function moveRecord(string $table, int $uid, int $destination): void + { + if (!$this->tcaSchemaFactory->has($table) || $uid <= 0) { + return; + } + $schema = $this->tcaSchemaFactory->get($table); + + $sortByFieldName = null; + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $sortByFieldName = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } elseif ($destination < 0) { + // Trying to move a record *after* another one of the same table. Stop early if the table is not sorting-aware. + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} after another record, but the table does not support sorting', null, ['table' => $table, 'uid' => $uid]); + return; + } + $pagesLocalizationParentFieldName = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + $tableLanguageFieldName = null; + if ($schema->hasCapability(TcaSchemaCapability::Language)) { + $tableLanguageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + } + + // Gather record facts + $plainRecord = BackendUtility::getRecord($table, $uid, '*', '', false); + if ($plainRecord === null) { + return; + } + $isTableWorkspaceAware = $schema->isWorkspaceAware(); + $isMovingInWorkspaces = false; + if ($this->BE_USER->workspace > 0) { + $isMovingInWorkspaces = true; + } + if (!$isTableWorkspaceAware && (int)($plainRecord['t3ver_wsid'] ?? 0) > 0) { + // Moving record in a not workspace aware table, and the record is a workspace record. Broken record. Skip. + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Attempt to move workspace record {table}:{uid} in not workspace aware table', null, ['table' => $table, 'uid' => $uid], $plainRecord['pid']); + return; + } + if (!$isMovingInWorkspaces && (int)($plainRecord['t3ver_wsid'] ?? 0) > 0) { + // Trying to move a workspace record while user is in live. For now, we'll log and skip. + // @todo: This seems to be fully unhandled right now: If a live workspace record is moved, we should + // consider existing workspace overlays somehow. For instance, it would *probably* be good to move + // an existing workspace overlay to a different page, when the live record is moved. There are various + // edge cases we need to think about when doing this: What should happen if a live record is just resorted + // on the same page? What if a live record is moved that has a workspace-moved overlay already? And what + // if that move-overlay is a combination of "has first been changed, then turned into a move placeholder"? + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Attempt to move workspace record {table}:{uid} and user is not in this workspace', null, ['table' => $table, 'uid' => $uid], $plainRecord['pid']); + return; + } + if ((int)($plainRecord['t3ver_wsid'] ?? 0) === 0 && (int)($plainRecord['t3ver_oid'] ?? 0) > 0) { + // Inconsistent combination: We have a live record with t3ver_oid not 0. Should not happen. This may be no + // hard problem and may be a result of some incomplete publish. We ignore it for now but log a warning. + // @todo: We may want to set such records to t3ver_oid=0 (and potentially t3ver_state=0) since they *are* live. + // This is more a task of a general "repair" strategy (ext:dbdoctor), but details like this *may* be + // involved at a central DH place when performing change operations to existing records? + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::WARNING, 'Moving live record {table}:{uid} while it has non zero t3ver_oid="{t3verOid}"', null, ['table' => $table, 'uid' => $uid, 't3verOid' => $plainRecord['t3ver_oid']], $plainRecord['pid']); + } + $liveRecord = null; + $workspaceRecord = null; + if ($isMovingInWorkspaces && !$isTableWorkspaceAware) { + // Moving a live record when user is in a workspace. Odd, but may happen. Permission checked below. + $liveRecord = $plainRecord; + } elseif ($isMovingInWorkspaces && (int)($plainRecord['t3ver_wsid'] === 0)) { + // We are in a workspace but got a move request for a live record uid. There may be an existing workspace overlay + // that should be moved instead. This is checked using BackendUtility::getWorkspaceVersionOfRecord(), which respects + // current user workspace. But it also returns a workspace row if the record is a "new placeholder" that has no live + // version. In this case, $liveRecord is kept null, but $workspaceRecord is set. Otherwise, both are set. + $potentialWorkspaceOverlay = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $uid); + if ($potentialWorkspaceOverlay) { + $workspaceRecord = $potentialWorkspaceOverlay; + if (VersionState::tryFrom($potentialWorkspaceOverlay['t3ver_state']) !== VersionState::NEW_PLACEHOLDER) { + // A live record is requested to be moved, and we have an overlay record as well. + $liveRecord = $plainRecord; + } + } else { + // We are in workspace requesting to move a live record that has no overlay yet. + // This will create "move placeholder" records down below. + $liveRecord = $plainRecord; + } + // Do not work on $potentialWorkspaceOverlay anymore + unset($potentialWorkspaceOverlay); + } elseif ((int)($plainRecord['t3ver_wsid'] ?? 0) > 0) { + // Moving workspace overlay record + $workspaceRecord = $plainRecord; + if ((int)$workspaceRecord['t3ver_oid'] > 0) { + // The record is an overlay of a live record, the live record needs to exist. New workspace + // placeholder records have no live record and t3ver_oid=0, leaving $liveRecord unset. + $liveRecord = BackendUtility::getRecord($table, $workspaceRecord['t3ver_oid'], '*', '', false); + if (empty($liveRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Attempt to move workspace overlay record {table}:{uid}, but live record {table}:{liveUid} not found', null, ['table' => $table, 'uid' => $uid, 'liveUid' => $workspaceRecord['t3ver_oid']], $workspaceRecord['pid']); + return; + } + } + } else { + // Moving a live record in live workspaces. There is no overlay. + $liveRecord = $plainRecord; + } + // Do not work on these variables anymore + unset($plainRecord); + if ($liveRecord === null && $workspaceRecord === null) { + // If both are still null, some sanity and determination checks above are broken + throw new \RuntimeException('Could not determine live nor workspace record when moving', 1739389473); + } + + // At this point we have: + // * $liveRecord set and $workspaceRecord is null: We're moving a live record. + // * $liveRecord null and $workspaceRecord is set: We're moving a "new placeholder", turning it into + // a "move placeholder" if all goes well. The table is workspace aware. + // * $liveRecord set and $workspaceRecord set: We're moving an existing workspace overlay. The table + // is workspace aware. + + // Moving a workspace aware record in workspaces + if ($isMovingInWorkspaces && $workspaceRecord === null && $isTableWorkspaceAware) { + // Create version of record first, if it does not exist + // @todo: This strategy is odd. A "dummy" record is created in correct workspace, but it is a + // DEFAULT_STATE one, not a MOVE_POINTER. This is then later updated to a move placeholder. + // This is complex and risky, we can for instance end up with a dangling record when a + // permission check fails down below. Next issue is that versionizeRecord() does + // a lot of stuff that we checked above already, but we can't change versionizeRecord() + // much since it is also used as entry method in process_cmdmap(). + // In the end, we should refactor this to fully prepare the workspace record and insert it once. + $sourceUid = $this->versionizeRecord($table, $liveRecord['uid'], 'MovePointer'); + if (!$sourceUid) { + // If versionizeRecord() could not create, it usually logs. + return; + } + $workspaceRecord = BackendUtility::getRecord($table, $sourceUid); + if ($workspaceRecord === null) { + throw new \RuntimeException('Table ' . $table . ' is workspace aware, but could not create workspace overlay', 1740217183); + } + } + + $sourceUid = (int)($workspaceRecord['uid'] ?? $liveRecord['uid']); + $sourcePid = (int)($workspaceRecord['pid'] ?? $liveRecord['pid']); + $sourcePageRecord = $table === 'pages' + ? (BackendUtility::getRecord('pages', $sourceUid) ?? []) + : (BackendUtility::getRecord('pages', $sourcePid) ?? []); + + $updateFields = []; + $oldData = [ + 'pid' => $workspaceRecord['pid'] ?? $liveRecord['pid'], + ]; + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $updatedAtFieldName = $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName(); + $updateFields[$updatedAtFieldName] = $GLOBALS['EXEC_TIME']; + $oldData[$updatedAtFieldName] = $workspaceRecord[$updatedAtFieldName] ?? $liveRecord[$updatedAtFieldName]; + } + if ($sortByFieldName) { + $oldData[$sortByFieldName] = $workspaceRecord[$sortByFieldName] ?? $liveRecord[$sortByFieldName]; + } + if ($table === 'pages' && (int)($workspaceRecord[$pagesLocalizationParentFieldName] ?? $liveRecord[$pagesLocalizationParentFieldName]) > 0) { + // If this is a translation of a page sorting and pid is kept in sync with default language record. + // Also in workspaces, the default language page may have been moved to a different pid than the + // default language page record of live workspace. In this case, localized pages need to be + // moved to the pid of the workspace move record, which is why we use getRecord() and workspaceOL() here. + $defaultLanguagePageRecord = BackendUtility::getRecord('pages', (int)($workspaceRecord[$pagesLocalizationParentFieldName] ?? $liveRecord[$pagesLocalizationParentFieldName])); + BackendUtility::workspaceOL('pages', $defaultLanguagePageRecord, $this->BE_USER->workspace); + + if (is_array($defaultLanguagePageRecord)) { + $updateFields[$sortByFieldName] = $defaultLanguagePageRecord[$sortByFieldName]; + $updateFields['pid'] = $defaultLanguagePageRecord['pid']; + } + } elseif ($sortByFieldName) { + // Calculate new "sort number" depending on weather the record is inserted as first record, or below another one. + $sortNumber = $this->getSortNumber($table, $sourceUid, $destination); + if ($sortNumber === false) { + // Unable to calculate sort number, logged in getSortNumber() already. + return; + } + if ($destination >= 0) { + $updateFields[$sortByFieldName] = $sortNumber; + $updateFields['pid'] = $destination; + } else { + $updateFields[$sortByFieldName] = $sortNumber['sortNumber']; + $updateFields['pid'] = $sortNumber['pid']; + } + } else { + $updateFields['pid'] = $destination; + } + + $isMovingToDifferentPid = false; + $targetPageRecord = $sourcePageRecord; + if ($updateFields['pid'] !== $sourcePid) { + $isMovingToDifferentPid = true; + $targetPageRecord = BackendUtility::getRecord('pages', $updateFields['pid']) ?? []; + } + + if ($table === 'pages') { + if ($isMovingToDifferentPid) { + if (!$this->destNotInsideSelf($updateFields['pid'], $sourceUid)) { + // When page is moved to a different pid, it must not be a child of itself + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move pages:{uid} to inside of its own rootline', null, ['uid' => $sourceUid]); + return; + } + if (!$this->hasPageContextPermission($table, Permission::PAGE_DELETE, $sourcePageRecord)) { + // When page is moved to a different parent page, delete permissions are needed for the source page + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page {table}:{uid} without having permissions to do so', null, ['table' => $table, 'uid' => $sourceUid], $sourcePid); + return; + } + if (!$this->hasPermissionToInsert($table, $updateFields['pid'], $targetPageRecord)) { + // When page moved to different target, insert permissions are needed + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} to pid "{targetPid}" without having permissions to insert', null, ['table' => $table, 'uid' => $sourceUid, 'targetPid' => $updateFields['pid']], $updateFields['pid']); + return; + } + } else { + if (!$this->hasPermissionToUpdate($table, $sourcePageRecord)) { + // When page is moved within same parent page, page edit records are needed + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} without having permissions to update', null, ['table' => $table, 'uid' => $sourceUid], $sourcePid); + return; + } + } + } else { + if ($isMovingToDifferentPid) { + if (!$this->hasPermissionToUpdate($table, $sourcePageRecord)) { + // When record is moved to different target, update permissions on source page are needed + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} to pid "{targetPid}" without having permissions to update the source page (uid={sourcePid})', null, ['table' => $table, 'uid' => $sourceUid, 'targetPid' => $updateFields['pid'], 'sourcePid' => $sourcePid], $sourcePid); + return; + } + if (!$this->hasPermissionToInsert($table, $updateFields['pid'], $targetPageRecord)) { + // When record is moved to different page, insert permissions on target are needed + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} to pid "{targetPid}" without having permissions to insert', null, ['table' => $table, 'uid' => $sourceUid, 'targetPid' => $updateFields['pid']], $updateFields['pid']); + return; + } + } else { + if (!$this->hasPermissionToUpdate($table, $sourcePageRecord)) { + // When record is moved within same page, edit records are needed + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} without having permissions to update', null, ['table' => $table, 'uid' => $sourceUid], $sourcePid); + return; + } + } + } + $accessResult = $this->BE_USER->checkRecordEditAccess($table, $liveRecord ?? $workspaceRecord, false, $table !== 'pages'); + if (!$accessResult->isAllowed) { + // Check if anything else disallows the move operation + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} without having permissions to do so [{reason}]', null, ['table' => $table, 'uid' => $sourceUid, 'reason' => $accessResult->errorMessage], $sourcePid); + return; + } + + if ($isMovingInWorkspaces && !$isTableWorkspaceAware && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) { + // Moving a not workspace aware record while in workspaces, but user, table TCA or sys_workspace record do not allow live editing this table. + // Can happen when moving a workspace aware parent record with children not being workspace aware. + // @todo: See DataScenarios/IrreForeignFieldNonWs/WorkspacesModify/ActionTest.php for a rough overview on what is broken in this case. + // @todo: This means the parent record *is* moved (gets a move placeholder), while children are not. + // This is fine when only resorting parent on the same page. Its problematic when parent is moved + // to a different page since the child stays on the old page. It is also unclear what happens when + // the parent is later published (should children *then* be moved to recreate integrity?). At + // the moment, the workspace BE module crashes when doing this. + // @todo: If allowed, moving the live record ist still probably not a good idea since this may break live when + // inline children are moved to a different page while the live parent record is on the "old" page. + // It is not an issue with inline children when parent is only resorted on the same page. + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move {table}:{uid} in workspace but the table is not workspace aware and live editing is denied', null, ['table' => $table, 'uid' => (int)$liveRecord['uid']]); + return; + } + + $recordWasMoved = false; + $moveRec = [ + 'header' => 'DATAHANDLER DUMMY', + 'pid' => (int)($liveRecord['pid'] ?? $workspaceRecord['pid']), + 'event_pid' => $table === 'pages' + ? (int)(($liveRecord['t3ver_oid'] ?? null) ?: ($liveRecord['uid'] ?? $workspaceRecord['uid'])) + : (int)($liveRecord['pid'] ?? $workspaceRecord['pid']), + 't3ver_state' => ($liveRecord['t3ver_state'] ?? $workspaceRecord['t3ver_state'] ?? 0), + ]; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [])) { + // Extensive hook argument handling for b/w compatibility + $propArr = [ + 'header' => 'DATAHANDLER DUMMY', + 'pid' => $sourcePid, + 'event_pid' => $table === 'pages' + ? ((int)($workspaceRecord['t3ver_oid'] ?? null) ?: $liveRecord['uid'] ?? $workspaceRecord['uid']) + : $sourcePid, + 't3ver_state' => $workspaceRecord['t3ver_state'] ?? $liveRecord['t3ver_state'] ?? 0, + ]; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) { + $hook = GeneralUtility::makeInstance($className); + if (method_exists($hook, 'moveRecord')) { + $hook->moveRecord($table, $liveRecord['uid'] ?? $workspaceRecord['uid'], $destination, $propArr, $moveRec, $updateFields['pid'], $recordWasMoved, $this); + /** @var bool $recordWasMoved */ + } + } + } + if ($recordWasMoved) {// @phpstan-ignore if.alwaysFalse (hook result is not taken into account) + // Return if a hook handled move + return; + } + + if ($table !== 'pages' && $isMovingToDifferentPid) { + // When moving a record to a different pid, attached children have to be moved as well. + $recordForFieldConfig = $workspaceRecord ?? $liveRecord; + foreach ($recordForFieldConfig as $field => $value) { + $fieldConfig = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field, $recordForFieldConfig); + if (!($fieldConfig['behaviour']['disableMovingChildrenWithParent'] ?? false) + && in_array($this->getRelationFieldType($fieldConfig), ['list', 'field'], true) + ) { + // Move non-MM inline children + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', $sourceUid, $table, $fieldConfig); + // Moving records to a positive destination will insert each record at the beginning, thus the order is reversed here + foreach (array_reverse($dbAnalysis->itemArray) as $item) { + $this->moveRecord($item['table'], (int)$item['id'], $table === 'pages' ? $sourceUid : $updateFields['pid']); + } + continue; + } + if (($fieldConfig['type'] ?? '') === 'flex' && !empty($value)) { + // Children attached to flex inline fields have to be moved to new pid along with their parent record + try { + $fieldConfig['config'] = $fieldConfig; + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier($fieldConfig, $table, $field, $workspaceRecord ?? $liveRecord, $schema); + $dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + $flexFormValueParsed = GeneralUtility::xml2array($value); + } catch (AbstractInvalidDataStructureException) { + // Nothing to do if data structure could not be determined + continue; + } + foreach ($dataStructure['sheets'] as $sheetName => $sheet) { + foreach (($sheet['ROOT']['el'] ?? []) as $sheetFieldName => $sheetFieldConfig) { + if (!($sheetFieldConfig['config']['behaviour']['disableMovingChildrenWithParent'] ?? false) + && in_array($this->getRelationFieldType($sheetFieldConfig['config']), ['list', 'field'], true) + ) { + // Move existing non-MM inline flex children + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($flexFormValueParsed['data'][$sheetName]['lDEF'][$sheetFieldName]['vDEF'] ?? '', $sheetFieldConfig['config']['foreign_table'], '', $sourceUid, $table, $sheetFieldConfig['config']); + foreach (array_reverse($dbAnalysis->itemArray) as $item) { + $this->moveRecord($item['table'], (int)$item['id'], $table === 'pages' ? $sourceUid : $updateFields['pid']); + } + } + } + } + } + } + } + + $this->registerRecordIdForPageCacheClearing($table, $sourceUid, $table === 'pages' ? $workspaceRecord['uid'] ?? $liveRecord['uid'] : $workspaceRecord['pid'] ?? $liveRecord['pid']); + $this->connectionPool->getConnectionForTable($table)->update($table, $updateFields, ['uid' => $sourceUid]); + if ($tableLanguageFieldName && (int)($workspaceRecord[$tableLanguageFieldName] ?? $liveRecord[$tableLanguageFieldName]) === 0) { + $this->moveL10nOverlayRecords($table, $sourceUid, $updateFields['pid'], $destination); + } + if ($destination >= 0) { + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) { + $hook = GeneralUtility::makeInstance($className); + if (method_exists($hook, 'moveRecord_firstElementPostProcess')) { + $hook->moveRecord_firstElementPostProcess($table, $sourceUid, $destination, $moveRec, $updateFields, $this); + } + } + } else { + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) { + $hook = GeneralUtility::makeInstance($className); + if (method_exists($hook, 'moveRecord_afterAnotherElementPostProcess')) { + $hook->moveRecord_afterAnotherElementPostProcess($table, $sourceUid, $updateFields['pid'], $destination, $moveRec, $updateFields, $this); + } + } + } + $this->getRecordHistoryStore()->moveRecord($table, $sourceUid, ['oldData' => $oldData, 'newData' => $updateFields], $this->correlationId); + if ($isMovingToDifferentPid) { + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::MESSAGE, 'Moved record {table}:{uid} to page {pid}', null, ['table' => $table, 'uid' => $sourceUid, 'pid' => $updateFields['pid']], $workspaceRecord['pid'] ?? $liveRecord['pid']); + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::MESSAGE, 'Moved record {table}:{uid} from page {pid}', null, ['table' => $table, 'uid' => $sourceUid, 'pid' => $workspaceRecord['pid'] ?? $liveRecord['pid']], $updateFields['pid']); + } else { + $this->log($table, $sourceUid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::MESSAGE, 'Moved record {table}:{uid} on page {pid}', null, ['table' => $table, 'uid' => $sourceUid, 'pid' => $updateFields['pid']], $updateFields['pid']); + } + $this->fixUniqueInPid($table, $workspaceRecord ?? $liveRecord); + $this->fixUniqueInSite($table, $workspaceRecord ?? $liveRecord); + if ($table === 'pages') { + $this->fixUniqueInSiteForSubpages($sourceUid); + } + + if ($isMovingInWorkspaces && $isTableWorkspaceAware && VersionState::tryFrom((int)($workspaceRecord['t3ver_state'])) !== VersionState::NEW_PLACEHOLDER) { + // Late changes after moveRecord_raw() moved stuff in workspaces. + // This is a "changed", "deleted" or "already moved" record. + if (VersionState::tryFrom($workspaceRecord['t3ver_state']) !== VersionState::DELETE_PLACEHOLDER) { + // Update the state of this record to a move placeholder. This is allowed if the + // record is a 'changed' (t3ver_state=0) record: Changing a record and moving it + // around later, should switch it from 'changed' to 'moved'. Deleted placeholders + // however are an 'end-state', they should not be switched to a move placeholder. + // Scenario: For a live page that has a localization, the localization is first + // marked as to-delete in workspace, creating a delete placeholder for that + // localization. Later, the page is moved around, moving the localization along + // with the default language record. The localization should then NOT be switched + // from 'to-delete' to 'moved', this would lose the 'to-delete' information. + $this->connectionPool->getConnectionForTable($table)->update( + $table, + ['t3ver_state' => VersionState::MOVE_POINTER->value], + ['uid' => $sourceUid] + ); + } + // Check for the localizations of that element and move them as well + // @todo: Why is this not done with "new"? Fishy. Called at different place or no test coverage? + $this->moveL10nOverlayRecords($table, $liveRecord['uid'] ?? $workspaceRecord['uid'], $destination, $destination); + } + } + + /** + * Find l10n-overlay records and perform the requested move action for these records. + * + * @param string $table Record Table + * @param int $uid Record UID + * @param int $destPid Position to move to + * @param int $originalRecordDestinationPid Position to move the original record to + */ + protected function moveL10nOverlayRecords(string $table, $uid, int $destPid, $originalRecordDestinationPid): void + { + $schema = $this->tcaSchemaFactory->get($table); + // There's no need to perform this for non-localizable tables + if (!$schema->isLanguageAware()) { + return; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace)); + + $l10nRecords = $queryBuilder->select('uid', $languageField) + ->from($table) + ->where( + $queryBuilder->expr()->eq($transOrigPointerFieldName, $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT, ':pointer')) + ) + ->executeQuery() + ->fetchAllAssociative(); + + $localizedDestPids = []; + if ($originalRecordDestinationPid < 0) { + // If $originalRecordDestinationPid < 0, then it is the uid of the original language record we are inserting after + // Get the localized records of the record we are inserting after + $queryBuilder->setParameter('pointer', abs($originalRecordDestinationPid), Connection::PARAM_INT); + $destL10nRecords = $queryBuilder->executeQuery()->fetchAllAssociative(); + // Index the localized record uids by language + foreach ($destL10nRecords as $record) { + $localizedDestPids[$record[$languageField]] = -$record['uid']; + } + } + + // Move the localized records after the corresponding localizations of the destination record + foreach ($l10nRecords as $record) { + $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0); + if ($localizedDestPid < 0) { + $this->moveRecord($table, (int)$record['uid'], $localizedDestPid); + } else { + $this->moveRecord($table, (int)$record['uid'], $destPid); + } + } + } + + /** + * Localizes a record to another system language + * + * @param string $table Table name + * @param int $uid Record uid (to be localized) + * @param int $language Language ID + * @return int|false The uid (int) of the new translated record or FALSE (bool) if something went wrong + * @internal should only be used from within DataHandler + */ + public function localize(string $table, int $uid, int $language): int|false + { + if (!$this->tcaSchemaFactory->has($table) || !$uid || $this->isNestedElementCallRegistered($table, $uid, 'localize-' . $language) !== false) { + return false; + } + + $schema = $this->tcaSchemaFactory->get($table); + $this->registerNestedElementCall($table, $uid, 'localize-' . $language); + if (!$schema->isLanguageAware()) { + $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Localization failed; "languageField" and "transOrigPointerField" must be defined for the table {table}', null, ['table' => $table]); + return false; + } + + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageFieldName = $languageCapability->getLanguageField()->getName(); + $translationOriginPointerFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + + // Getting workspace overlay if possible - this will localize versions in workspace if any + $row = BackendUtility::getRecord($table, $uid); + BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace); + if (!is_array($row)) { + $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} that did not exist', null, ['table' => $table, 'uid' => (int)$uid]); + return false; + } + if ($table === 'pages') { + $pageRecord = $row; + } elseif ((int)$row['pid'] > 0) { + $pageRecord = BackendUtility::getRecord('pages', $row['pid']); + if (!is_array($pageRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record "{table}:{uid}" which is not assigned to a valid page', null, ['table' => $table, 'uid' => (int)$uid]); + return false; + } + } else { + $pageRecord = VirtualRecord::RootPage; + } + if (!$this->hasPageContextPermission($table, Permission::PAGE_SHOW, $pageRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record {table}:{uid} without permission', null, ['table' => $table, 'uid' => (int)$uid]); + return false; + } + + $pageId = (int)BackendUtility::getRealPageId($table, $uid); + // Try to fetch the site language from the pages' associated site + $siteLanguage = $this->getSiteLanguageForPage($pageId, $language); + if ($siteLanguage === null) { + $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Language ID "{languageId}" not found for page {pageId}', null, ['languageId' => (int)$language, 'pageId' => $pageId]); + return false; + } + + // Make sure that records which are translated from another language than the default language have a correct + // localization source set themselves, before translating them to another language. + if ((int)$row[$translationOriginPointerFieldName] !== 0 && $row[$languageFieldName] > 0) { + $localizationParentRecord = BackendUtility::getRecord($table, $row[$translationOriginPointerFieldName]); + if ((int)$localizationParentRecord[$languageFieldName] !== 0) { + $this->log($table, $localizationParentRecord['uid'], SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Localization failed: Source record {table}:{originalRecordId} contained a reference to an original record that is not a default record (which is strange)', null, ['table' => $table, 'originalRecordId' => $localizationParentRecord['uid']]); + return false; + } + } + + // Default language records must never have a localization parent as they are the origin of any translation. + if ((int)$row[$translationOriginPointerFieldName] !== 0 && (int)$row[$languageFieldName] === 0) { + $this->log($table, $row['uid'], SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::USER_ERROR, 'Localization failed: Source record {table}:{uid} contained a reference to an original default record but is a default record itself (which is strange)', null, ['table' => $table, 'uid' => (int)$row['uid']]); + return false; + } + + $recordLocalization = $this->localizationRepository->getRecordTranslation($table, $row, $language, $this->BE_USER->workspace); + if ($recordLocalization) { + $this->log( + $table, + $uid, + SystemLogDatabaseAction::LOCALIZE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Localization failed: The record {uid} of type "{table}" has already been localized in language {language} (Localized UID: {localizedUid})', + null, + [ + 'localizedUid' => $recordLocalization->getUid(), + 'language' => $language, + 'table' => $table, + 'uid' => $uid, + ] + ); + return false; + } + + // Initialize: + $overrideValues = [ + $languageFieldName => $language, + ]; + // Set override values: + // If the translated record is a default language record, set it's uid as localization parent of the new record. + // If translating from any other language, no override is needed; we just can copy the localization parent of + // the original record (which is pointing to the correspondent default language record) to the new record. + // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case. + // For pages, there is no "copy/free mode". + if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[$languageFieldName] === 0) { + $overrideValues[$translationOriginPointerFieldName] = $uid; + } elseif (!$this->useTransOrigPointerField) { + $overrideValues[$translationOriginPointerFieldName] = 0; + } + if ($languageCapability->hasTranslationSourceField()) { + $overrideValues[$languageCapability->getTranslationSourceField()->getName()] = $uid; + } + // Copy the value of the type from the original record so that translation has same type as original record + if ($schema->supportsSubSchema()) { + // @todo: We always copy the local field name, even on foreign table types, such as "uid_local:type" + $subSchemaDivisorFieldName = $schema->getSubSchemaTypeInformation()->getFieldName(); + $overrideValues[$subSchemaDivisorFieldName] = $row[$subSchemaDivisorFieldName] ?? null; + } + + // Set exclude Fields: + foreach ($this->getTypeSpecificFields($table, $row) as $field) { + $translateToMsg = ''; + // Check if we are just prefixing: + if ($field->getTranslationBehaviour() === FieldTranslationBehaviour::PrefixLanguageTitle + && $field->isType(TableColumnType::TEXT, TableColumnType::INPUT, TableColumnType::EMAIL, TableColumnType::LINK) + && (string)$row[$field->getName()] !== '' + ) { + $TSConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? []; + $tableEntries = $this->getTableEntries($table, $TSConfig); + if (!empty($TSConfig['translateToMessage']) && !($tableEntries['disablePrependAtCopy'] ?? false)) { + $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']); + $translateToMsg = @sprintf($translateToMsg, $siteLanguage->getTitle()); + } + + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) { + $hookObj = GeneralUtility::makeInstance($className); + if (method_exists($hookObj, 'processTranslateTo_copyAction')) { + // @todo Deprecate passing an array and pass the full SiteLanguage object instead + $hookObj->processTranslateTo_copyAction( + $row[$field->getName()], + ['uid' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle()], + $this, + $field->getName() + ); + } + } + if (!empty($translateToMsg)) { + $translateToMessage = '[' . $translateToMsg . '] '; + $fieldContent = $row[$field->getName()]; + if ($field->isType(TableColumnType::TEXT) && str_starts_with($fieldContent, '<')) { + // If the field is a text field, we need to prepend the translation message to the content + // that means, it should be after the first opening HTML tag, if one exists. + // @todo: Ideally we can use TcaSchema and Subschema in the future, to resolve this issue properly + $overrideValues[$field->getName()] = preg_replace('/(<[^>]+>)/', '$1' . $translateToMessage, $fieldContent, 1); + } else { + $overrideValues[$field->getName()] = $translateToMessage . $fieldContent; + } + } else { + $overrideValues[$field->getName()] = $row[$field->getName()]; + } + } + if (($field->getConfiguration()['MM'] ?? false) + // To determine the local side for fields with 'MM' config, we check if the field either + // has "MM_oppositeUsage" (foreign table names / field names) defined — e.g., sys_category with + // opposite usage to the table "pages" with the field "categories" — or if it does not have + // "MM_opposite_field" set. This field is set by the foreign side (e.g., an inline child) + // to allow editing the connection from both sides (bidirectional). + && (!empty($field->getConfiguration()['MM_oppositeUsage']) || !isset($field->getConfiguration()['MM_opposite_field'])) + ) { + // We are localizing the 'local' side of an MM relation. (eg. localizing a category). + // In this case, MM relations connected to the default lang record should not be copied, + // so we set an override here to not trigger mm handling of 'items' field for this. + $overrideValues[$field->getName()] = 0; + } + } + + if ($table === 'pages') { + $newId = $this->localizePage($uid, $row, $language, $overrideValues); + } else { + $newId = $this->localizeRecord($table, $uid, $row, $language, $overrideValues); + } + + return $newId ?? false; + } + + /** + * Creates a localized version of a record for the given target language. + * + * This method is called from localize() for non-page records. Unlike copyRecord(), + * it does not apply "copy-after" fields, "prepend label at copy", or copy l10n overlays. + * The $row is pre-fetched and workspace-overlaid by localize(). + * + * @param string $table Table name + * @param int $uid Record uid (source record to localize) + * @param array $row Pre-fetched and workspace-overlaid record + * @param int $language Target language ID (always > 0) + * @param array $overrideValues Override values prepared by localize() (language field, translation pointers, etc.) + * @return int|null UID of the new localized record, or null on failure + */ + protected function localizeRecord(string $table, int $uid, array $row, int $language, array $overrideValues): ?int + { + $schema = $this->tcaSchemaFactory->get($table); + + $row = BackendUtility::purgeComputedPropertiesFromRecord($row); + if ($schema->hasCapability(TcaSchemaCapability::Workspace) + && $this->BE_USER->workspace > 0 + && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER + ) { + return null; + } + + // Compute sorting: place after the previous localized record + $previousUid = $this->getPreviousLocalizedRecordUid($table, $uid, $row['pid'], $language); + $destPid = -$previousUid; + + $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, $uid, $destPid); + $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? []; + $tE = $this->getTableEntries($table, $TSConfig); + + $theNewID = StringUtility::getUniqueId('NEW'); + $disabledField = $schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getField() + : null; + + $row = $this->removeNonCopyableFields($table, $row, 'localizeRecord'); + $data = []; + + foreach ($row as $field => $value) { + if ($field === 'pid') { + $value = $destPid; + } elseif (array_key_exists($field, $overrideValues)) { + $value = $overrideValues[$field]; + } else { + // Hide at copy may override — only apply if source record was visible + if ($field === $disabledField?->getName() + && !$value + && $schema->hasCapability(TcaSchemaCapability::HideRecordsAtCopy) + && !($this->BE_USER->uc['neverHideAtCopy'] ?? false) + && !($tE['disableHideAtCopy'] ?? false) + ) { + $value = 1; + } + $conf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field, $row); + $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $language); + } + $data[$table][$theNewID][$field] = $value; + } + + if ($schema->hasCapability(TcaSchemaCapability::EditLock)) { + $data[$table][$theNewID][$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] = 0; + } + if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) { + $data[$table][$theNewID][$schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName()] = $uid; + } + + $copyTCE = $this->getLocalTCE(); + $copyTCE->start($data, [], $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $copyTCE->process_datamap(); + $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null; + if ($theNewSQLID) { + $this->copyMappingArray[$table][$uid] = $theNewSQLID; + if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) { + $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID]; + } + } + $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog); + + return $theNewSQLID; + } + + /** + * Creates a localized version of a record for the given target language. + * * + * @param int $uid Record uid (source record to localize) + * @param array $row Pre-fetched and workspace-overlaid record + * @param int $language Target language ID (always > 0) + * @param array $overrideValues Override values prepared by localize() (language field, translation pointers, etc.) + */ + protected function localizePage(int $uid, array $row, int $language, array $overrideValues): ?int + { + $table = 'pages'; + $schema = $this->tcaSchemaFactory->get('pages'); + // Create new page which needs to contain the same pid as the original page + $overrideValues['pid'] = $row['pid']; + // Take over the hidden state of the original language state, this is done due to legacy reasons where-as + // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1" + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $hiddenField = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getField(); + $hiddenFieldName = $hiddenField->getName(); + $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? $hiddenField->getDefaultValue(); + // Override by TCA "hideAtCopy" or pageTS "disableHideAtCopy" + // Only for visible pages to get the same behavior as for copy + if (!$overrideValues[$hiddenFieldName]) { + $TSConfig = BackendUtility::getPagesTSconfig($uid)['TCEMAIN.'] ?? []; + $tableEntries = $this->getTableEntries('pages', $TSConfig); + if ( + $schema->hasCapability(TcaSchemaCapability::HideRecordsAtCopy) + && !($this->BE_USER->uc['neverHideAtCopy'] ?? false) + && !($tableEntries['disableHideAtCopy'] ?? false) + ) { + $overrideValues[$hiddenFieldName] = 1; + } + } + } + $temporaryId = StringUtility::getUniqueId('NEW'); + $copyTCE = $this->getLocalTCE(); + $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $copyTCE->process_datamap(); + // Getting the new UID as if it had been copied: + $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId] ?? null; + if ($theNewSQLID) { + $this->copyMappingArray[$table][$uid] = $theNewSQLID; + if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) { + $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID]; + } + } + $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog); + return $theNewSQLID; + } + + /** + * Performs localization or synchronization of child records. + * The $command argument expects an array, but supports a string for backward-compatibility. + * + * $command = array( + * 'field' => 'tx_myfieldname', + * 'language' => 2, + * // either the key 'action' or 'ids' must be set + * 'action' => 'synchronize', // or 'localize' + * 'ids' => array(1, 2, 3, 4) // child element ids + * ); + * + * @param string $table The table of the localized parent record + * @param int $id The uid of the localized parent record + * @param array $command Defines the command to be performed (see example above) + */ + protected function inlineLocalizeSynchronize($table, $id, array $command): void + { + $schema = $this->tcaSchemaFactory->get($table); + $field = $command['field'] ?? ''; + $language = (int)($command['language'] ?? 0); + $action = $command['action'] ?? ''; + $ids = $command['ids'] ?? []; + if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !$schema->hasField($field)) { + return; + } + if ($language <= 0) { + return; + } + + $parentRecord = BackendUtility::getRecord($table, $id); + BackendUtility::workspaceOL($table, $parentRecord, $this->BE_USER->workspace); + + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + + // In case the parent record is the default language record, fetch the localization + if (empty($parentRecord[$languageCapability->getLanguageField()->getName()])) { + // We need the live UID inside $id but we still process overlay for the current selected workspace + $parentRecordLocalization = $this->localizationRepository->getRecordTranslation($table, $parentRecord, $language, $this->BE_USER->workspace); + if (!$parentRecordLocalization) { + $this->log($table, $id, SystemLogDatabaseAction::LOCALIZE, null, SystemLogErrorClassification::MESSAGE, 'Localization for parent record {table}:{uid} cannot be fetched', null, ['table' => $table, 'uid' => (int)$id], $table === 'pages' ? $id : $parentRecord['pid']); + return; + } + $parentRecord = $parentRecordLocalization->toArray(true); + $id = $parentRecord['uid']; + } + + if (!$parentRecord) { + return; + } + + $fieldDefinition = $schema->getField($field); + $config = $fieldDefinition->getConfiguration(); + $foreignTable = $config['foreign_table']; + + $foreignTableSchema = $this->tcaSchemaFactory->get($foreignTable); + $transOrigPointer = (int)$parentRecord[$languageCapability->getTranslationOriginPointerField()->getName()]; + $childTransOrigPointerField = $foreignTableSchema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + + if (!$transOrigPointer) { + return; + } + + $relationFieldType = $this->getRelationFieldType($config); + if ($relationFieldType === false) { + return; + } + + $transOrigRecord = BackendUtility::getRecord($table, $transOrigPointer); + BackendUtility::workspaceOL($table, $transOrigRecord, $this->BE_USER->workspace); + + $removeArray = []; + $mmTable = $relationFieldType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : ''; + // Fetch children from original language parent: + $dbAnalysisOriginal = $this->createRelationHandlerInstance(); + $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config); + $elementsOriginal = []; + foreach ($dbAnalysisOriginal->itemArray as $item) { + $elementsOriginal[$item['id']] = $item; + } + unset($dbAnalysisOriginal); + // Fetch children from current localized parent: + $dbAnalysisCurrent = $this->createRelationHandlerInstance(); + $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config); + // Perform synchronization: Possibly removal of already localized records: + if ($action === 'synchronize') { + foreach ($dbAnalysisCurrent->itemArray as $index => $item) { + $childRecord = BackendUtility::getRecord($item['table'], $item['id']); + BackendUtility::workspaceOL($item['table'], $childRecord, $this->BE_USER->workspace); + if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) { + $childTransOrigPointer = $childRecord[$childTransOrigPointerField]; + // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it: + if (!isset($elementsOriginal[$childTransOrigPointer])) { + unset($dbAnalysisCurrent->itemArray[$index]); + $removeArray[$item['table']][$item['id']]['delete'] = 1; + } + } + } + } + // Perform synchronization/localization: Possibly add unlocalized records for original language: + if ($action === 'localize' || $action === 'synchronize') { + foreach ($elementsOriginal as $item) { + if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], $language)) { + continue; + } + $item['id'] = $this->localize($item['table'], (int)$item['id'], $language); + + if (is_int($item['id'])) { + $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']); + } + $dbAnalysisCurrent->itemArray[] = $item; + } + } elseif (!empty($ids)) { + foreach ($ids as $childId) { + if (!MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) { + continue; + } + $item = $elementsOriginal[$childId]; + if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], $language)) { + continue; + } + $item['id'] = $this->localize($item['table'], (int)$item['id'], $language); + if (is_int($item['id'])) { + $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']); + } + $dbAnalysisCurrent->itemArray[] = $item; + } + } + // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record): + $value = implode(',', $dbAnalysisCurrent->getValueArray()); + $this->registerDBList[$table][$id][$field] = $value; + // Remove child records (if synchronization requested it): + if ($removeArray !== []) { + /** @var DataHandler $tce */ + $tce = GeneralUtility::makeInstance(self::class); + $tce->enableLogging = $this->enableLogging; + $tce->start([], $removeArray, $this->BE_USER, $this->referenceIndexUpdater, $this->correlationId); + $tce->process_cmdmap(); + unset($tce); + } + $updateFields = []; + // Handle, reorder and store relations: + if ($relationFieldType === 'list') { + $updateFields = [$field => $value]; + } elseif ($relationFieldType === 'field') { + $dbAnalysisCurrent->writeForeignField($config, $id); + $updateFields = [$field => $dbAnalysisCurrent->countItems(false)]; + } elseif ($relationFieldType === 'mm') { + $dbAnalysisCurrent->writeMM($config['MM'], $id); + $updateFields = [$field => $dbAnalysisCurrent->countItems(false)]; + } + // Update field referencing to child records of localized parent record: + if (!empty($updateFields)) { + $this->updateDB($table, $id, $updateFields, (int)$parentRecord['pid']); + } + if (isset($parentRecord['_ORIG_uid']) && (int)$parentRecord['_ORIG_uid'] !== (int)$id) { + // If there is a workspace overlay of the record, then the relation has been attached to *this* + // record, even though the uids point to live. We still need to update refindex of the overlay + // to reflect this relation. + $this->updateRefIndex($table, (int)$parentRecord['_ORIG_uid']); + } + } + + /** + * Returns true if a localization of a record exists. + */ + protected function isRecordLocalized(string $table, int $uid, int $language): bool + { + $row = BackendUtility::getRecord($table, $uid); + BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace); + if (!$row) { + return false; + } + return $this->localizationRepository->getRecordTranslation($table, $row, $language, $this->BE_USER->workspace) !== null; + } + + /** + * Delete a single record. + * + * @internal should only be used from within DataHandler + */ + public function deleteAction(string $table, int|array $uidOrRow, bool $noRecordCheck = false, bool $forceHardDelete = false): void + { + if (is_int($uidOrRow) && $uidOrRow <= 0) { + $this->log($table, $uidOrRow, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Can not delete "{table}:{uid}": uid must be a positive int larger than zero', null, ['table' => $table, 'uid' => $uidOrRow], 0); + return; + } + if (!is_array($uidOrRow)) { + $recordToDelete = BackendUtility::getRecord($table, $uidOrRow, '*', '', false); + if ($recordToDelete === null) { + // No record no cry. + return; + } + $uid = $uidOrRow; + } else { + $recordToDelete = $uidOrRow; + $uid = (int)$recordToDelete['uid']; + } + unset($uidOrRow); + + // Exit if the current user does not have permission to modify the table and $noRecordCheck is set to false + if (!$noRecordCheck && !$this->checkModifyAccessList($table)) { + $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot delete "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]); + return; + } + + if ((int)($recordToDelete['t3ver_wsid'] ?? null) !== 0) { + // When uid to a workspace record is given, then discard always. This is coming from workspace BE + // module "waste bin" icon, which sends the workspace uid of the record with the intention to + // discard the overlay. + // @todo: It might be better to have an own cmdmap action for 'discard' directly to avoid + // live / workspace uid confusion with 'delete' and this dispatching. + $this->discard($table, null, $recordToDelete); + return; + } + + $recordWasDeleted = false; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) { + $hookObj = GeneralUtility::makeInstance($className); + if (method_exists($hookObj, 'processCmdmap_deleteAction')) { + $hookObj->processCmdmap_deleteAction($table, $uid, $recordToDelete, $recordWasDeleted, $this); + /** @var bool $recordWasDeleted */ + } + } + if ($recordWasDeleted) { // @phpstan-ignore if.alwaysFalse (hook result is not taken into account) + return; + } + + $beUserCurrentWorkspace = $this->BE_USER->workspace; + if ($workspaceRecordOverlay = BackendUtility::getWorkspaceVersionOfRecord($beUserCurrentWorkspace, $table, $uid)) { + $workspaceRecordOverlayVersionState = VersionState::tryFrom($workspaceRecordOverlay['t3ver_state']); + if ($workspaceRecordOverlayVersionState === VersionState::DEFAULT_STATE) { + if (!$noRecordCheck && !$this->BE_USER->checkRecordEditAccess($table, $workspaceRecordOverlay)->isAllowed) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record "{table:uid}" without delete-permissions', null, ['table' => $table, 'uid' => $workspaceRecordOverlay['uid']]); + return; + } + // If incoming uid references a live record (t3ver_wsid=0), and user is in workspace, and there is a "workspace modified" + // version of the record, then we'll turn the workspace record into a delete overlay if it isn't one already. + $this->connectionPool->getConnectionForTable($table)->update( + $table, + ['t3ver_state' => VersionState::DELETE_PLACEHOLDER->value], + ['uid' => (int)$workspaceRecordOverlay['uid']], + [Connection::PARAM_INT] + ); + $this->updateRefIndex($table, (int)$workspaceRecordOverlay['uid']); + // @todo: Optimize to hand over record and skip early for sys_language_uid !== 0 ?? + $this->deleteL10nOverlayRecords($table, $uid); + } else { + // * Discard "delete" placeholder, kinda obvious. + // * Discard "new" placeholder, which is a DB inconsistency at this point since "existing live" record + // plus "new" workspace placeholder shouldn't happen. + // * Discard "move" placeholder: When using "waste bin" in page module of a moved record, the move operation + // (and potentially additionally changed) record is discarded. The live version "re-appers" at the + // old position. + $this->discard($table, null, $workspaceRecordOverlay); + } + return; + } + if ($beUserCurrentWorkspace === 0) { + $this->deleteEl($table, $recordToDelete, $noRecordCheck, $forceHardDelete); + return; + } + if (!$this->tcaSchemaFactory->has($table) || !$this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Workspace)) { + if (!$noRecordCheck && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record "{table}:{uid}" from not workspace aware table without live editing permissions', null, ['table' => $table, 'uid' => $uid]); + return; + } + $this->deleteEl($table, $recordToDelete, $noRecordCheck, $forceHardDelete); + return; + } + // Create delete placeholder records in workspace + // @todo: Stop using versionizeRecord(), check permissions directly instead and use a lower level method. + $copyMappingArray = $this->copyMappingArray; + $this->versionizeRecord($table, $uid, 'DELETED!', true); + // Determine newly created versions to delete localization overlays: + // Remove placeholders are copied and modified, thus they appear in the copyMappingArray + $versionedElements = ArrayUtility::arrayDiffKeyRecursive($this->copyMappingArray, $copyMappingArray); + foreach ($versionedElements as $versionedTableName => $versionedOriginalIds) { + // Delete localization overlays + foreach ($versionedOriginalIds as $versionedOriginalId => $_) { + $this->deleteL10nOverlayRecords($versionedTableName, (int)$versionedOriginalId); + } + } + } + + /** + * Delete element from any table + * + * @param array $recordToDelete Full record row + * @param bool $noRecordCheck If true, records are not checked for permissions + * @param bool $forceHardDelete If true, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY! + */ + protected function deleteEl(string $table, array $recordToDelete, bool $noRecordCheck = false, bool $forceHardDelete = false): void + { + $uid = (int)$recordToDelete['uid']; + if ($table === 'pages') { + $languageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language); + $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + $localizationParent = (int)($recordToDelete[$localizationParentFieldName] ?? 0); + $subPages = []; + if ($localizationParent === 0) { + // When deleting a default language page, subpages have to be deleted as well. + $subPages = $this->getSubPagesOfPage($uid, !$forceHardDelete); + } + $defaultLanguagePageRecord = $recordToDelete; + if ($localizationParent !== 0) { + $defaultLanguagePageRecord = BackendUtility::getRecord('pages', $localizationParent, '*', '', false); + if ($defaultLanguagePageRecord === null) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Can not delete localized "pages:{uid}", default language page record "pages:{localizationParent}" not found', null, ['uid' => $uid, 'localizationParent' => $localizationParent], 0); + return; + } + } + if (!$noRecordCheck && is_string($pagesDeletePermissionError = $this->canDeletePage($recordToDelete, $defaultLanguagePageRecord, $subPages, !$forceHardDelete))) { + $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, $pagesDeletePermissionError); + return; + } + foreach ($subPages as $subPage) { + // Delete subpages. $subPages is a list of default language pages. + $this->deleteSpecificPage($subPage, $forceHardDelete); + } + $this->deleteSpecificPage($recordToDelete, $forceHardDelete); + } else { + $this->discardLocalizedWorkspaceVersionsOfRecord($table, $uid); + $this->discardWorkspaceVersionsOfRecord($table, $uid); + $this->deleteRecord($table, $recordToDelete, $noRecordCheck, $forceHardDelete); + } + } + + /** + * When deleting a live element with sys_language_uid = 0, there may be translated records that + * have been created in workspaces only (t3ver_state=1). Those have to be discarded explicitly + * since the other 'delete' related code does not consider this case, otherwise the 'new' workspace + * translation would be dangling when the live record is gone. + */ + protected function discardLocalizedWorkspaceVersionsOfRecord(string $table, int $uid): void + { + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware() || !$schema->isWorkspaceAware()) { + return; + } + $liveRecord = BackendUtility::getRecord($table, $uid); + if ($liveRecord === null || !$this->BE_USER->checkRecordEditAccess($table, $liveRecord)->isAllowed) { + return; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + if ((int)($liveRecord[$languageField] ?? 0) !== 0 || (int)($liveRecord['t3ver_wsid'] ?? 0) !== 0) { + // Don't do anything if we're not deleting a live record in default language + return; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder = $queryBuilder->select('*')->from($table) + ->where( + // workspace elements + $queryBuilder->expr()->gt('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + // with sys_language_uid > 0 + $queryBuilder->expr()->gt($languageField, $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + // in state 'new' + $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT)), + // with "l10n_parent" set to uid of live record + $queryBuilder->expr()->eq($localizationParentFieldName, $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + ); + $result = $queryBuilder->executeQuery(); + while ($row = $result->fetchAssociative()) { + // BE user must be put into this workspace temporarily so stuff like refindex updating + // is properly registered for this workspace when discarding records in there. + $currentUserWorkspace = $this->BE_USER->workspace; + $this->BE_USER->workspace = (int)$row['t3ver_wsid']; + $this->discard($table, null, $row); + // Switch user back to original workspace + $this->BE_USER->workspace = $currentUserWorkspace; + } + } + + /** + * Discard workspace overlays of a live record: When a live row + * is deleted, all existing workspace overlays are discarded. + * + * @param string $table Table name + * @param int $uid Record UID + */ + protected function discardWorkspaceVersionsOfRecord(string $table, int $uid): void + { + $versions = BackendUtility::selectVersionsOfRecord($table, $uid, '*', null); + if ($versions === null) { + // Null is returned by selectVersionsOfRecord() when table is not workspace aware. + return; + } + foreach ($versions as $record) { + if ($record['_CURRENT_VERSION'] ?? false) { + // The live record is included in the result from selectVersionsOfRecord() + // and marked as '_CURRENT_VERSION'. Skip this one. + continue; + } + // BE user must be put into this workspace temporarily so stuff like refindex updating + // is properly registered for this workspace when discarding records in there. + $currentUserWorkspace = $this->BE_USER->workspace; + $this->BE_USER->workspace = (int)$record['t3ver_wsid']; + $this->discard($table, null, $record); + // Switch user back to original workspace + $this->BE_USER->workspace = $currentUserWorkspace; + } + } + + /** + * Deleting a record + * This function may not be used to delete pages-records unless the underlying records are already deleted + * Deletes a record regardless of versioning state (live or offline, doesn't matter, the uid decides) + * If both $noRecordCheck and $forceHardDelete are set it could even delete a "deleted"-flagged record! + * + * @param string $table Table name + * @param array $recordToDelete Full record row + * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record + * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY! + */ + protected function deleteRecord(string $table, array $recordToDelete, bool $noRecordCheck = false, bool $forceHardDelete = false): void + { + $currentUserWorkspace = $this->BE_USER->workspace; + $uid = (int)$recordToDelete['uid']; + + if (!$this->tcaSchemaFactory->has($table) || $uid <= 0) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions [Invalid table or UID]'); + return; + } + $schema = $this->tcaSchemaFactory->get($table); + $accessResult = $this->BE_USER->checkRecordEditAccess($table, $recordToDelete, false, true); + if (!$accessResult->isAllowed) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions [{reason}]', null, ['reason' => $accessResult->errorMessage]); + return; + } + if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) { + // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page) + $perms = Permission::PAGE_EDIT; + } else { + $perms = Permission::CONTENT_EDIT; + } + + $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0); + if ($recordWorkspaceId > 0) { + // @todo: Verify this can not happen anymore and this case is dispatched to discard() more early. + return; + } + + if (!$noRecordCheck) { + if ((int)$recordToDelete['pid'] > 0) { + $pageRecord = BackendUtility::getRecord('pages', $recordToDelete['pid'], '*', '', false); + if (!is_array($pageRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record "{table}:{uid}" which is not assigned to a valid page', null, ['table' => $table, 'uid' => $uid]); + return; + } + } else { + $pageRecord = VirtualRecord::RootPage; + } + if (!$this->hasPageContextPermission($table, $perms, $pageRecord)) { + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]); + return; + } + } + + // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache + $parentUid = (int)BackendUtility::getRealPageId($table, $uid); + $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid); + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete) && !$forceHardDelete) { + $updateFields = [ + $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() => 1, + ]; + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $updateFields[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + // before deleting this record, check for child records or references + $this->deleteRecord_procFields($table, $recordToDelete); + // Delete all l10n records as well + $this->deleteL10nOverlayRecords($table, $uid); + $this->connectionPool->getConnectionForTable($table)->update($table, $updateFields, ['uid' => $uid]); + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was deleted from pages:{pid}', null, ['table' => $table, 'uid' => $uid, 'pid' => (int)($recordToDelete['pid'] ?? 0)], (int)($recordToDelete['pid'] ?? 0)); + } else { + // Delete child records. If the parent table is NOT soft-delete aware, it will get + // hard deleted. Attached children have to be hard-deleted aware as well, they'd be + // "orphaned" otherwise. We thus $forceHardDelete=true to deleteRecord_procFields(). + $this->deleteRecord_procFields($table, $recordToDelete, true); + $this->hardDeleteSingleRecord($table, $uid); + $this->deleteL10nOverlayRecords($table, $uid); + $this->log($table, $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was deleted unrecoverable from pages:{pid}', null, ['table' => $table, 'uid' => $uid, 'pid' => (int)($recordToDelete['pid'] ?? 0)], (int)($recordToDelete['pid'] ?? 0)); + } + + // Add history entry + $this->getRecordHistoryStore()->deleteRecord($table, $uid, $this->correlationId); + // Update reference index with table/uid on left side (recuid) + $this->updateRefIndex($table, $uid); + // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted. + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $currentUserWorkspace); + } + + /** + * Delete a page (or set deleted field to 1) and all records on it. + * + * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY! + */ + protected function deleteSpecificPage(array $recordToDelete, bool $forceHardDelete): void + { + $pagesSchema = $this->tcaSchemaFactory->get('pages'); + $languageCapability = $pagesSchema->getCapability(TcaSchemaCapability::Language); + + $currentUserWorkspace = $this->BE_USER->workspace; + $uid = (int)$recordToDelete['uid']; + + // Delete either a default language page or a translated page + $pageIdInDefaultLanguage = $uid; + $isPageTranslation = false; + $localizationParent = (int)($recordToDelete[$languageCapability->getTranslationOriginPointerField()->getName()] ?? 0); + $pageLanguageId = 0; + if ($localizationParent > 0) { + // For translated pages, translated records in other tables (eg. tt_content) for the + // to-delete translated page have their pid field set to the uid of the default language record, + // NOT the uid of the translated page record. + // If a translated page is deleted, only translations of records in other tables of this language + // should be deleted. The code checks if the to-delete page is a translated page and + // adapts the query for other tables to use the uid of the default language page as pid together + // with the language id of the translated page. + // If a default language page is deleted, the access checks below behave subtly different. + $pageIdInDefaultLanguage = $localizationParent; + $isPageTranslation = true; + $pagesLanguageFieldName = $languageCapability->getLanguageField()->getName(); + $pageLanguageId = $recordToDelete[$pagesLanguageFieldName] ?? 0; + } + $otherLocalizationExists = false; + if ($isPageTranslation && $forceHardDelete) { + // Restriction when hard deleting localized pages: Usually, localized records are hard deleted as well + // when hard deleting a localized page (via recycler). However, when a soft-deleted localized page is + // hard-deleted while another NOT soft-deleted localization of the page exists in the same + // sys_language_uid (created after the other translation has been soft-deleted), then records do not + // "know" if they belong to the soft-deleted, or the active localized page. In this case, we only + // hard delete the soft-deleted page, but no records, to keep existing records that belong to the + // active localization. + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $otherLocalizationExists = (bool)$queryBuilder->count('uid')->from('pages') + ->where( + $queryBuilder->expr()->eq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $languageCapability->getLanguageField()->getName(), + $queryBuilder->createNamedParameter($pageLanguageId, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchOne(); + } + + if (!$otherLocalizationExists) { + foreach ($this->tcaSchemaFactory->all() as $subSchema) { + $table = $subSchema->getName(); + if ($table === 'pages' || ($isPageTranslation && !$subSchema->isLanguageAware())) { + // Skip pages table. And skip table if not translatable, but a translated page is deleted + continue; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + if (!$forceHardDelete) { + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + } + $queryBuilder + ->select('*') + ->from($table) + // order by uid is needed here to process possible live records first - overlays always + // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order, + // leading to hard to debug issues. This is especially relevant for the + // discardWorkspaceVersionsOfRecord() call below. + ->addOrderBy('uid'); + if ($isPageTranslation) { + // Only delete records in the specified language + $queryBuilder->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $subSchema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(), + $queryBuilder->createNamedParameter($pageLanguageId, Connection::PARAM_INT) + ) + ); + } else { + // Delete all records on this page + $queryBuilder->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ); + } + if ($currentUserWorkspace !== 0 && $subSchema->isWorkspaceAware()) { + // If we are in a workspace, make sure only records of this workspace are deleted. + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter($currentUserWorkspace, Connection::PARAM_INT) + ) + ); + } + $statement = $queryBuilder->executeQuery(); + while ($row = $statement->fetchAssociative()) { + // Delete any further workspace overlays of the record in question, then delete the record. + $this->discardWorkspaceVersionsOfRecord($table, (int)$row['uid']); + $this->deleteRecord($table, $row, true, $forceHardDelete); + } + } + } + + // Delete any further workspace overlays of the record in question, then delete the record. + $this->discardWorkspaceVersionsOfRecord('pages', $uid); + + if (!$this->BE_USER->checkRecordEditAccess('pages', $recordToDelete, false, !$isPageTranslation)->isAllowed) { + $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record "pages:{uid}" without delete-permissions', null, ['uid' => $uid]); + return; + } + + $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0); + + // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache + $parentUid = (int)BackendUtility::getRealPageId('pages', $uid); + $this->registerRecordIdForPageCacheClearing('pages', $uid, $parentUid); + if ($recordWorkspaceId > 0) { + // @todo: This should be relocated elsewhere, dispatching to discard() here should happen at a different position: + // This is called by "delete page" code, especially canDeletePage() and doesBranchExist() which fetch *all* + // sub page uid's including their workspace overlays. It would be better if the "delete sub pages" code would + // not fetch overlays, but if "delete single live page" code would take care of discarding overlays directly. + // If this is a workspace record, use discard + $this->BE_USER->workspace = $recordWorkspaceId; + $this->discard('pages', null, $recordToDelete); + // Switch user back to original workspace + $this->BE_USER->workspace = $currentUserWorkspace; + } elseif ($pagesSchema->hasCapability(TcaSchemaCapability::SoftDelete) && !$forceHardDelete) { + $updateFields = [ + $pagesSchema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() => 1, + ]; + if ($pagesSchema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $updateFields[$pagesSchema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + // before deleting this record, check for child records or references + $this->deleteRecord_procFields('pages', $recordToDelete); + // Delete all l10n records as well + $this->deleteL10nOverlayRecords('pages', $uid); + $this->connectionPool->getConnectionForTable('pages')->update('pages', $updateFields, ['uid' => $uid]); + $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::MESSAGE, 'Record pages:{uid} was deleted', null, ['uid' => $uid], (int)($recordToDelete['pid'] ?? 0)); + } else { + // Delete the hard way...: + $this->deleteL10nOverlayRecords('pages', $uid, $forceHardDelete); + $this->hardDeleteSingleRecord('pages', $uid); + $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::MESSAGE, 'Record pages:{uid} was deleted unrecoverable', null, ['uid' => $uid], (int)($recordToDelete['pid'] ?? 0)); + } + + // Add history entry + $this->getRecordHistoryStore()->deleteRecord('pages', $uid, $this->correlationId); + // Update reference index with table/uid on left side (recuid) + $this->updateRefIndex('pages', $uid); + // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted. + $this->referenceIndexUpdater->registerUpdateForReferencesToItem('pages', $uid, $currentUserWorkspace); + } + + /** + * Evaluate if a page can be deleted. Checks access to page and sub pages, and access to records on them. + * + * @param array $pageRecord The page record to delete + * @param array $defaultLanguagePageRecord The default language page record if $pageRecord is a localization. Identical to + * $pageRecord if $pageRecord *is* a default language record + * @param array $subPages List of subpages. Only set if a default language page record should be deleted. + * @param bool $useDeleteClause Use the delete clause to check if the page is deleted + */ + protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRecord, array $subPages, bool $useDeleteClause = true): ?string + { + $languageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language); + $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + $localizationParent = (int)($pageRecord[$localizationParentFieldName] ?? 0); + $pageRecordToCheck = $pageRecord; + if ($localizationParent > 0) { + $pageRecordToCheck = $defaultLanguagePageRecord; + } + if (!$this->hasPageContextPermission('pages', Permission::PAGE_DELETE, $pageRecordToCheck, $useDeleteClause)) { + return 'Attempt to delete page without permissions'; + } + if (!$this->BE_USER->checkRecordEditAccess('pages', $pageRecord, false, $localizationParent === $pageRecord['uid'])->isAllowed) { + return 'Attempt to delete page which has prohibited localizations'; + } + foreach ($subPages as $subPage) { + if (!$this->BE_USER->isAdmin() && !$this->BE_USER->doesUserHaveAccess($subPage, Permission::PAGE_DELETE, $useDeleteClause)) { + return 'Attempt to delete pages in branch without permissions'; + } + if (!$this->BE_USER->checkRecordEditAccess('pages', $subPage, false, true)->isAllowed) { + return 'Attempt to delete page which has prohibited localizations'; + } + } + if (!$this->BE_USER->isAdmin()) { + // Check if there are records from tables on the pages to be deleted which the current user is not allowed to touch. + foreach ($this->tcaSchemaFactory->all() as $schema) { + // @todo: Shouldn't we skip 'pages' here? + $table = $schema->getName(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $count = $queryBuilder->count('uid') + ->from($table) + ->where( + $queryBuilder->expr()->in( + 'pid', + $queryBuilder->createNamedParameter(array_merge([$pageRecord['uid']], array_column($subPages, 'uid')), Connection::PARAM_INT_ARRAY) + ) + ) + ->executeQuery() + ->fetchOne(); + if ($count && ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly) || !$this->checkModifyAccessList($table))) { + return 'Attempt to delete records from disallowed table "' . $table . '"'; + } + } + } + return null; + } + + /** + * Before a record is deleted, check if it has references such as inline type or MM references. + * If so, set these child records also to be deleted. + */ + protected function deleteRecord_procFields(string $table, array $recordToDelete, bool $forceHardDelete = false): void + { + $uid = (int)$recordToDelete['uid']; + foreach ($recordToDelete as $fieldName => $value) { + // Get TCA configuration for the field (respecting columnsOverrides) + $configuration = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $fieldName, $recordToDelete); + if ($configuration === [] || !isset($configuration['type'])) { + continue; + } + if ($configuration['type'] === 'inline' || $configuration['type'] === 'file') { + if (in_array($this->getRelationFieldType($configuration), ['list', 'field'], true)) { + $dbAnalysis = $this->createRelationHandlerInstance(); + if ($forceHardDelete) { + // @todo: This should be called "disableDeleteClause" or similar. + $dbAnalysis->undeleteRecord = true; + } + $dbAnalysis->start($value, $configuration['foreign_table'], '', $uid, $table, $configuration); + // Non type save comparison is intended! + if (!isset($configuration['behaviour']['enableCascadingDelete']) || $configuration['behaviour']['enableCascadingDelete'] != false) { + // Walk through the items and remove them + foreach ($dbAnalysis->itemArray as $v) { + // @todo: It would be so much better when RelationHandler could return full rows ... + $this->deleteAction($v['table'], (int)$v['id'], false, $forceHardDelete); + } + } + } + } elseif ($this->isReferenceField($configuration)) { + $allowedTables = $configuration['type'] === 'group' ? $configuration['allowed'] : $configuration['foreign_table']; + if ($forceHardDelete && ($configuration['MM'] ?? false)) { + // When hard deleting an MM record, MM rows must be removed. Note they are kept when + // soft-deleting a local or foreign side record to allow undeleting the record and getting + // connected MM relations back. + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start('', $allowedTables, $configuration['MM'], $uid, $table, $configuration); + $previousItemArray = $dbAnalysis->itemArray; + $dbAnalysis->itemArray = []; + $dbAnalysis->writeMM($configuration['MM'], $uid); + } else { + $allowedTables = $configuration['type'] === 'group' ? $configuration['allowed'] : $configuration['foreign_table']; + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $allowedTables, $configuration['MM'] ?? '', $uid, $table, $configuration); + $previousItemArray = $dbAnalysis->itemArray; + } + foreach ($previousItemArray as $v) { + $this->updateRefIndex($v['table'], $v['id']); + } + } elseif ($configuration['type'] === 'flex' && (string)$value !== '') { + try { + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $configuration], + $table, + $fieldName, + $recordToDelete, + $schema + ); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + } catch (AbstractInvalidDataStructureException) { + // Nothing to do if data structure could not be determined + continue; + } + if ($dataStructureArray !== []) { + $flexForm = GeneralUtility::xml2array($value); + foreach (($dataStructureArray['sheets'] ?? []) as $sheetName => $sheet) { + foreach ($sheet['ROOT']['el'] as $sheetFieldName => $flexField) { + $flexFormValue = $flexForm['data'][$sheetName]['lDEF'][$sheetFieldName]['vDEF'] ?? null; + $flexFieldConfig = $flexField['config'] ?? []; + if (!isset($flexFieldConfig['type'])) { + continue; + } + if ($flexFieldConfig['type'] === 'inline' || $flexFieldConfig['type'] === 'file') { + if (in_array($this->getRelationFieldType($flexFieldConfig), ['list', 'field'], true)) { + $dbAnalysis = $this->createRelationHandlerInstance(); + if ($forceHardDelete) { + // @todo: This should be called "disableDeleteClause" or similar. + $dbAnalysis->undeleteRecord = true; + } + $dbAnalysis->start($flexFormValue, $flexFieldConfig['foreign_table'], '', $uid, $table, $flexFieldConfig); + // Non type save comparison is intended! + if (!isset($flexFieldConfig['behaviour']['enableCascadingDelete']) || $flexFieldConfig['behaviour']['enableCascadingDelete'] != false) { + // Walk through the items and remove them + foreach ($dbAnalysis->itemArray as $v) { + $this->deleteAction($v['table'], (int)$v['id'], false, $forceHardDelete); + } + } + } + } elseif ($this->isReferenceField($flexFieldConfig)) { + $allowedTables = $flexFieldConfig['type'] === 'group' ? $flexFieldConfig['allowed'] : $flexFieldConfig['foreign_table']; + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $allowedTables, $flexFieldConfig['MM'] ?? '', $uid, $table, $flexFieldConfig); + foreach ($dbAnalysis->itemArray as $v) { + $this->updateRefIndex($v['table'], $v['id']); + } + } + } + } + } + } + } + } + + /** + * Find l10n-overlay records and perform the requested delete action for these records. + * + * @internal should only be used from within DataHandler + */ + public function deleteL10nOverlayRecords(string $table, int $uid, bool $forceHardDelete = false): void + { + $schema = $this->tcaSchemaFactory->get($table); + // Check whether table can be localized + if (!$schema->isLanguageAware()) { + return; + } + + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $restrictions = $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace)); + if (!$forceHardDelete) { + $restrictions->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + } + $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ); + + $result = $queryBuilder->executeQuery(); + while ($record = $result->fetchAssociative()) { + // Ignore workspace delete placeholders. Those records have been marked for + // deletion before - deleting them again in a workspace would revert that state. + if ($this->BE_USER->workspace > 0 && $schema->isWorkspaceAware()) { + BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace); + if (VersionState::tryFrom($record['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) { + continue; + } + } + $this->deleteAction($table, (int)($record['t3ver_oid'] ?? 0) > 0 ? (int)$record['t3ver_oid'] : (int)$record['uid'], false, $forceHardDelete); + } + } + + /** + * Restore live records by setting soft-delete flag to 0. + * + * Usually only used by ext:recycler. + * Connected relations (eg. inline) are restored, too. + * Additional existing localizations are not restored. + * + * @param string $table Record table name + * @param int $uid Record uid + */ + protected function undeleteRecord(string $table, int $uid): void + { + $schema = $this->tcaSchemaFactory->get($table); + $record = BackendUtility::getRecord($table, $uid, '*', '', false); + $deleteField = $schema->hasCapability(TcaSchemaCapability::SoftDelete) ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() : ''; + $timestampField = $schema->hasCapability(TcaSchemaCapability::UpdatedAt) ? $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName() : ''; + + // Exit if the current user does not have permission to modify the table + if (!$this->checkModifyAccessList($table)) { + $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot restore "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]); + return; + } + + if ($record === null + || $deleteField === '' + || !isset($record[$deleteField]) + || (bool)$record[$deleteField] === false + || ($timestampField !== '' && !isset($record[$timestampField])) + || (int)$this->BE_USER->workspace > 0 + || ($schema->isWorkspaceAware() && (int)($record['t3ver_wsid'] ?? 0) > 0) + ) { + // Return early and silently, if: + // * Record not found + // * Table is not soft-delete aware + // * Record does not have deleted field - db analyzer not up-to-date? + // * Record is not deleted - may eventually happen via recursion with self referencing records? + // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date? + // * User is in a workspace - does not make sense + // * Record is in a workspace - workspace records are not soft-delete aware + return; + } + + $recordPid = (int)($record['pid'] ?? 0); + if ($recordPid > 0) { + // Record is not on root level. Parent page record must exist and must not be deleted itself. + $page = BackendUtility::getRecord('pages', $recordPid, '*', '', false); + if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) { + $this->log( + $table, + $uid, + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Record "{table}:{uid}" can\'t be restored: The page "{pid}" containing it does not exist or is soft-deleted', + null, + [ + 'table' => $table, + 'uid' => $uid, + 'pid' => $recordPid, + ], + $recordPid + ); + return; + } + + if (!$this->hasPermissionToInsert($table, $recordPid, $page)) { + $this->log( + 'pages', + $recordPid, + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions to target page {pid}', + null, + [ + 'table' => $table, + 'uid' => $uid, + 'pid' => $recordPid, + ], + $recordPid + ); + return; + } + } + + // @todo: When restoring a not-default language record, it should be verified the default language + // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted? + + if (!$this->BE_USER->checkRecordEditAccess($table, $record)->isAllowed) { + // User misses access permissions to record + $this->log( + $table, + $uid, + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions', + null, + [ + 'table' => $table, + 'uid' => $uid, + ], + $recordPid + ); + return; + } + + // Restore referenced child records + $this->undeleteRecordRelations($table, $uid, $record); + + // Restore record + $updateFields[$deleteField] = 0; + if ($timestampField !== '') { + $updateFields[$timestampField] = $GLOBALS['EXEC_TIME']; + } + $this->connectionPool->getConnectionForTable($table) + ->update( + $table, + $updateFields, + ['uid' => $uid] + ); + $this->log( + $table, + $uid, + SystemLogDatabaseAction::INSERT, + null, + SystemLogErrorClassification::MESSAGE, + 'Record "{table}:{uid}" was restored on page {pid}', + null, + [ + 'table' => $table, + 'uid' => $uid, + 'pid' => $recordPid, + ], + $recordPid + ); + + // Register cache clearing of page, or parent page if a page is restored. + $this->registerRecordIdForPageCacheClearing($table, $uid, $recordPid); + // Add history entry + $this->getRecordHistoryStore()->undeleteRecord($table, $uid, $this->correlationId); + // Update reference index with table/uid on left side (recuid) + $this->updateRefIndex($table, $uid); + // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored. + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, 0); + } + + /** + * Check if a to-restore record has inline references and restore them. + * + * @param string $table Record table name + * @param int $uid Record uid + * @param array $record Record row + */ + protected function undeleteRecordRelations(string $table, int $uid, array $record): void + { + $schema = $this->tcaSchemaFactory->get($table); + foreach ($record as $fieldName => $value) { + if (!$schema->hasField($fieldName)) { + continue; + } + $fieldInformation = $schema->getField($fieldName); + $fieldConfig = $fieldInformation->getConfiguration(); + $fieldType = $fieldInformation->getType(); + $foreignTable = (string)($fieldInformation->getConfiguration()['foreign_table'] ?? ''); + if ($fieldType === 'inline' || $fieldType === 'file') { + // @todo: Inline MM not handled here, and what about group / select? + if (!in_array($this->getRelationFieldType($fieldConfig), ['list', 'field'], true)) { + continue; + } + $relationHandler = $this->createRelationHandlerInstance(); + // Must be set before start(): readForeignField() evaluates this flag + // to decide whether a DeletedRestriction is applied to the SELECT. + $relationHandler->undeleteRecord = true; + $relationHandler->start($value, $foreignTable, '', $uid, $table, $fieldConfig); + foreach ($relationHandler->itemArray as $reference) { + $this->undeleteRecord($reference['table'], (int)$reference['id']); + } + } elseif ($this->isReferenceField($fieldConfig)) { + $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable; + $relationHandler = $this->createRelationHandlerInstance(); + $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', $uid, $table, $fieldConfig); + foreach ($relationHandler->itemArray as $reference) { + // @todo: Unsure if this is ok / enough. Needs coverage. + $this->updateRefIndex($reference['table'], $reference['id']); + } + } elseif ($fieldType === 'flex') { + // @todo: Implement undelete of relations within a FlexForm + } + } + } + + /** + * Discard a versioned record from this workspace. This deletes records from the database - no soft delete. + * This main entry method is called recursive for sub pages, localizations, relations and records on a page. + * The method checks user access and gathers facts about this record to hand the deletion over to detail methods. + * + * The incoming $uid or $row can be anything: The workspace of current user is respected and only records + * of current user workspace are discarded. If giving a live record uid, the versioned overly will be fetched. + * + * @param string $table Database table name + * @param int|null $uid Uid of live or versioned record to be discarded, or null if $record is given + * @param array|null $record Record row that should be discarded. Used instead of $uid within recursion. + */ + protected function discard(string $table, ?int $uid, ?array $record = null): void + { + if ($uid === null && $record === null) { + throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491); + } + + // Fetch record we are dealing with if not given + if ($record === null) { + $record = BackendUtility::getRecord($table, (int)$uid); + } + if (!is_array($record)) { + return; + } + $uid = (int)$record['uid']; + + // Call hook and return if hook took care of the element + $recordWasDiscarded = false; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) { + $hookObj = GeneralUtility::makeInstance($className); + if (method_exists($hookObj, 'processCmdmap_discardAction')) { + /** @var bool $recordWasDiscarded */ + $hookObj->processCmdmap_discardAction($table, $uid, $record, $recordWasDiscarded); + } + } + + $userWorkspace = $this->BE_USER->workspace; + if ($recordWasDiscarded // @phpstan-ignore booleanOr.leftAlwaysFalse (hook result is not taken into account) + || $userWorkspace === 0 + || !$this->tcaSchemaFactory->has($table) + || !$this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Workspace) + ) { + return; + } + + // Gather versioned record + if ((int)$record['t3ver_wsid'] === 0) { + $record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, $uid); + } + if (!is_array($record)) { + return; + } + $versionRecord = $record; + + if ($table === 'pages') { + $pageRecord = $versionRecord; + } elseif ((int)$versionRecord['pid'] > 0) { + $pageRecord = BackendUtility::getRecord('pages', $versionRecord['pid']) ?? []; + } else { + $pageRecord = VirtualRecord::RootPage; + } + + // User access checks + if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) { + $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: Different workspace', null, ['table' => $table, 'uid' => (int)$versionRecord['uid']]); + return; + } + if ($errorCode = $this->workspaceCannotEditOfflineVersion($table, $versionRecord)) { + $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: {reason}', null, ['table' => $table, 'uid' => (int)$versionRecord['uid'], 'reason' => $errorCode]); + return; + } + if (!$this->hasPermissionToUpdate($table, $pageRecord)) { + $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: User has no edit access', null, ['table' => $table, 'uid' => (int)$versionRecord['uid']]); + return; + } + $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[$this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()] !== 0); + if (!$this->BE_USER->checkRecordEditAccess($table, $versionRecord, false, $fullLanguageAccessCheck)->isAllowed) { + $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record {table}:{uid} failed: User has no delete access', null, ['table' => $table, 'uid' => (int)$versionRecord['uid']]); + return; + } + + // Perform discard operations + $versionState = VersionState::tryFrom($versionRecord['t3ver_state'] ?? 0); + if ($table === 'pages' && $versionState === VersionState::NEW_PLACEHOLDER) { + // When discarding a new page, there can be new sub pages and new records. + // Those need to be discarded, otherwise they'd end up as records without parent page. + $this->discardSubPagesAndRecordsOnPage($versionRecord); + } + + $this->discardLocalizationOverlayRecords($table, $versionRecord); + $this->discardRecordRelations($table, $versionRecord); + $this->discardCsvReferencesToRecord($table, $versionRecord); + $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']); + $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace); + $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId); + $this->log( + $table, + (int)$versionRecord['uid'], + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::MESSAGE, + 'Record {table}:{uid} was deleted unrecoverable from page {pid}', + null, + ['table' => $table, 'uid' => $versionRecord['uid'], 'pid' => $versionRecord['pid']], + (int)$versionRecord['pid'] + ); + } + + /** + * Also discard any sub pages and records of a new parent page if this page is discarded. + * Discarding only in specific localization, if needed. + * + * @param array $page Page record row + */ + protected function discardSubPagesAndRecordsOnPage(array $page): void + { + $isLocalizedPage = false; + $pageSchema = $this->tcaSchemaFactory->get('pages'); + $languageFieldName = $pageSchema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + $sysLanguageId = (int)$page[$languageFieldName]; + $versionState = VersionState::tryFrom($page['t3ver_state'] ?? 0); + if ($sysLanguageId > 0) { + // New or moved localized page. + // Discard records on this page localization, but no sub pages. + // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent. + // @todo: Discard other page translations that inherit from this?! (l10n_source field) + $isLocalizedPage = true; + $pid = (int)$page[$pageSchema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()]; + } elseif ($versionState === VersionState::NEW_PLACEHOLDER) { + // New default language page. + // Discard any sub pages and all other records of this page, including any page localizations. + // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid + // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here. + $pid = (int)$page['uid']; + } else { + // Moved default language page. + // Discard any sub pages and all other records of this page, including any page localizations. + $pid = (int)$page['t3ver_oid']; + } + foreach ($this->tcaSchemaFactory->all() as $schema) { + $table = $schema->getName(); + if (($isLocalizedPage && $table === 'pages') + || ($isLocalizedPage && !$schema->isLanguageAware()) + || !$schema->isWorkspaceAware() + ) { + continue; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, Connection::PARAM_INT) + ) + ); + if ($isLocalizedPage && $schema->isLanguageAware()) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + // Add sys_language_uid = x restriction if discarding a localized page + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + $languageCapability->getLanguageField()->getName(), + $queryBuilder->createNamedParameter($sysLanguageId, Connection::PARAM_INT) + ) + ); + } + $statement = $queryBuilder->executeQuery(); + while ($row = $statement->fetchAssociative()) { + $this->discard($table, null, $row); + } + } + } + + /** + * Discard record relations like inline and MM of a record. + * + * @param string $table Table name of this record + * @param array $record The record row to handle + */ + protected function discardRecordRelations(string $table, array $record): void + { + $schema = $this->tcaSchemaFactory->get($table); + foreach ($record as $fieldName => $value) { + if (!$schema->hasField($fieldName)) { + continue; + } + /** @var InlineFieldType|FileFieldType $fieldType */ + $fieldType = $schema->getField($fieldName); + $fieldConfig = $fieldType->getConfiguration(); + + if ($fieldType->isType(TableColumnType::INLINE, TableColumnType::FILE)) { + $foreignTable = (string)($fieldConfig['foreign_table'] ?? ''); + if ($foreignTable === '' + || (isset($fieldConfig['behaviour']['enableCascadingDelete']) + && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false) + ) { + continue; + } + if ($fieldType->getRelationshipType()->isSingularRelationship()) { + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)$record['uid'], $table, $fieldConfig); + $dbAnalysis->undeleteRecord = true; + foreach ($dbAnalysis->itemArray as $relationRecord) { + $this->discard($relationRecord['table'], (int)$relationRecord['id']); + } + } + } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) { + $this->discardMmRelations($table, $fieldConfig, $record); + } elseif ($fieldType->isType(TableColumnType::FLEX) && (string)$value !== '') { + try { + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier(['config' => $fieldConfig], $table, $fieldName, $record, $schema); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + } catch (AbstractInvalidDataStructureException) { + // Nothing to do if data structure could not be determined + continue; + } + if ($dataStructureArray !== []) { + $flexForm = GeneralUtility::xml2array($value); + foreach (($dataStructureArray['sheets'] ?? []) as $sheetName => $sheet) { + foreach ($sheet['ROOT']['el'] as $sheetFieldName => $flexField) { + $flexFormValue = $flexForm['data'][$sheetName]['lDEF'][$sheetFieldName]['vDEF'] ?? null; + $flexFieldConfig = $flexField['config'] ?? []; + if (!isset($flexFieldConfig['type'])) { + continue; + } + if ($flexFieldConfig['type'] === 'inline' || $flexFieldConfig['type'] === 'file') { + if (in_array($this->getRelationFieldType($flexFieldConfig), ['list', 'field'], true)) { + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($flexFormValue, $flexFieldConfig['foreign_table'], '', (int)$record['uid'], $table, $flexFieldConfig); + foreach ($dbAnalysis->itemArray as $relationRecord) { + $this->discard($relationRecord['table'], (int)$relationRecord['id']); + } + } + } elseif ($this->isReferenceField($flexFieldConfig) && !empty($flexFieldConfig['MM'])) { + $this->discardMmRelations($table, $flexFieldConfig, $record); + } + } + } + } + } + } + } + + /** + * When the to-discard record is the target of a CSV group field of another table record, + * these records need to be updated to no longer point to the discarded record. + * + * Those referencing records are not very easy to find with only the to-discard record being available. + * The solution used here looks up records referencing the to-discard record by fetching a list of + * references from sys_refindex, where the to-discard record is on the right side (ref_* fields) + * and in the workspace the to-discard record lives in. The referencing record fields are then updated + * to drop the to-discard record from the CSV list. + * + * Using sys_refindex for this task is a bit risky: This would fail if a DataHandler call + * adds a reference to the record and requests discarding the record in one call - the refindex + * is always only updated at the very end of a DataHandler call, the logic below wouldn't catch + * this since it would be based on an outdated sys_refindex. The scenario however is of little use and + * not used in core, so it should be fine. + * + * @param string $table Table name of this record + * @param array $record The record row to handle + */ + protected function discardCsvReferencesToRecord(string $table, array $record): void + { + // @see test workspaces Group Discard createContentAndCreateElementRelationAndDiscardElement + // Records referencing the to-discard record. + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $statement = $queryBuilder->select('tablename', 'recuid', 'field') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($record['t3ver_wsid'], Connection::PARAM_INT)), + $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)), + $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($record['uid'], Connection::PARAM_INT)) + ) + ->executeQuery(); + while ($row = $statement->fetchAssociative()) { + // For each record referencing the to-discard record, see if it is a CSV group field definition. + // If so, update that record to drop both the possible "uid" and "table_name_uid" variants from the list. + if (!$this->tcaSchemaFactory->has($row['tablename']) || !$this->tcaSchemaFactory->get($row['tablename'])->hasField($row['field'])) { + continue; + } + $fieldType = $this->tcaSchemaFactory->get($row['tablename'])->getField($row['field']); + $fieldTca = $fieldType->getConfiguration(); + $groupAllowed = GeneralUtility::trimExplode(',', $fieldTca['allowed'] ?? '', true); + // @todo: "select" may be affected too, but it has no coverage to show this, yet? + if ($fieldType->isType(TableColumnType::GROUP) + && empty($fieldTca['MM']) + && (in_array('*', $groupAllowed, true) || in_array($table, $groupAllowed, true)) + ) { + // Note it would be possible to a) update multiple records with only one DB call, and b) combine the + // select and update to a single update query by doing the CSV manipulation as string function in sql. + // That's harder to get right though and probably not *that* beneficial performance-wise since we're + // most likely dealing with a very small number of records here anyways. Still, an optimization should + // be considered after we drop TCA 'prepend_tname' handling and always rely only on "table_name_uid" + // variant for CSV storage. + + // Get that record + $recordReferencingDiscardedRecord = BackendUtility::getRecord($row['tablename'], $row['recuid'], $row['field']); + if (!$recordReferencingDiscardedRecord) { + continue; + } + // Drop "uid" and "table_name_uid" from list + $listOfRelatedRecords = GeneralUtility::trimExplode(',', $recordReferencingDiscardedRecord[$row['field']], true); + $listOfRelatedRecordsWithoutDiscardedRecord = array_diff($listOfRelatedRecords, [$record['uid'], $table . '_' . $record['uid']]); + if ($listOfRelatedRecords !== $listOfRelatedRecordsWithoutDiscardedRecord) { + // Update record if list changed + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($row['tablename']); + $queryBuilder->update($row['tablename']) + ->set($row['field'], implode(',', $listOfRelatedRecordsWithoutDiscardedRecord)) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($row['recuid'], Connection::PARAM_INT))) + ->executeStatement(); + } + } + } + } + + /** + * When a workspace record row is discarded that has mm relations, existing mm table rows need + * to be deleted. The method performs the delete operation depending on TCA field configuration. + * + * @param string $table Table name of this record + * @param array $fieldConfig TCA configuration of this field + * @param array $record The full record of a left- or ride-side relation + */ + protected function discardMmRelations(string $table, array $fieldConfig, array $record): void + { + $recordUid = (int)$record['uid']; + $mmTableName = $fieldConfig['MM']; + // left - non foreign - uid_local vs. right - foreign - uid_foreign decision + $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local'; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName); + $queryBuilder->delete($mmTableName)->where( + // uid_local = given uid OR uid_foreign = given uid + $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, Connection::PARAM_INT)) + ); + if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) { + $queryBuilder->andWhere( + QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $fieldConfig['MM_table_where']))) + ); + } + $mmMatchFields = $fieldConfig['MM_match_fields'] ?? []; + foreach ($mmMatchFields as $fieldName => $fieldValue) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue)) + ); + } + $queryBuilder->executeStatement(); + + // refindex treatment for mm relation handling: If the to discard record is foreign side of an mm relation, + // there may be other refindex rows that become obsolete when that record is discarded. See Modify + // addCategoryRelation sys_category-29->tt_content-298. We thus register an update for references + // to this item (right side - ref_table, ref_uid) in reference index updater to catch these. + if ($relationUidFieldName === 'uid_foreign') { + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $recordUid, (int)$record['t3ver_wsid']); + } + } + + /** + * Find localization overlays of a record and discard them. + * + * @param string $table Table of this record + * @param array $record Record row + */ + protected function discardLocalizationOverlayRecords(string $table, array $record): void + { + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware()) { + return; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $uid = (int)$record['uid']; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + $statement = $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + $languageCapability->getTranslationOriginPointerField()->getName(), + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + while ($record = $statement->fetchAssociative()) { + $this->discard($table, null, $record); + } + } + + /** + * Creates a new version of a record if the table is workspace aware. + * + * @param int $id Live record uid to create a versioned record from + * @param string $label Version label + * @param bool $delete If TRUE, the version is created to delete the record. + * @return int|null Returns the id of the new version (if any) + * @internal should only be used from within DataHandler + */ + protected function versionizeRecord($table, $id, $label, $delete = false): ?int + { + $schema = $this->tcaSchemaFactory->get($table); + $id = (int)$id; + if ($this->isElementToBeDeleted($table, $id)) { + // Stop if the record is marked to be deleted. Can happen when IRRE elements are versioned and children are removed + return null; + } + if (!$schema->isWorkspaceAware() || $id <= 0) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Versioning is not supported for this table {table}:{uid}', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + + $row = BackendUtility::getRecord($table, $id); + if (!is_array($row)) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to create workspace version of "{table}:{uid}" which does not exist', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + if (((int)($row['t3ver_oid'] ?? 0)) > 0) { + // Reject "version of a version" before workspaceOL(): a current-workspace overlay + // legitimately has t3ver_oid > 0 and is handled by the early return further down. + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Record "{table}:{uid}" you wanted to versionize was already a version in archive (record has an online ID)', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace); + if ($table === 'pages') { + $pageRecord = $row; + } elseif ((int)$row['pid'] > 0) { + $pageRecord = BackendUtility::getRecord('pages', $row['pid']); + if (!is_array($pageRecord)) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to create workspace version of "{table}:{uid}" which is not assigned to a valid page', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + } else { + $pageRecord = VirtualRecord::RootPage; + } + if (!$this->hasPageContextPermission($table, Permission::PAGE_SHOW, $pageRecord)) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to create workspace version of "{table}:{uid}" without read permissions', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + if ($delete) { + if ($table === 'pages') { + $pagesLanguageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language); + $pagesLocalizationParentFieldName = $pagesLanguageCapability->getTranslationOriginPointerField()->getName(); + $pagesLocalizationParent = (int)($pageRecord[$pagesLocalizationParentFieldName] ?? 0); + $defaultLanguagePage = $pageRecord; + $subPages = []; + if ($pagesLocalizationParent === 0) { + // When deleting a default language page, user has to have delete permission on subpages as well. + $subPages = $this->getSubPagesOfPage($id); + } else { + $defaultLanguagePage = BackendUtility::getRecord('pages', $pagesLocalizationParent, '*', '', false); + if ($defaultLanguagePage === null) { + $this->log($table, $id, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'Can not delete localized "pages:{uid}" in workspaces, default language page record "pages:{localizationParent}" not found', null, ['uid' => $id, 'localizationParent' => $pagesLocalizationParent], 0); + return null; + } + } + if (is_string($pageDeletePermissionError = $this->canDeletePage($pageRecord, $defaultLanguagePage, $subPages))) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Record {table}:{uid} cannot be deleted: {reason}', null, ['table' => $table, 'uid' => (int)$id, 'reason' => $pageDeletePermissionError]); + return null; + } + } else { + $perms = Permission::CONTENT_EDIT; + if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) { + // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page) + $perms = Permission::PAGE_EDIT; + } + if (!$this->hasPageContextPermission($table, $perms, $pageRecord)) { + $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::USER_ERROR, 'Record {table}:{uid} cannot be deleted due to missing edit permissions', null, ['table' => $table, 'uid' => (int)$id]); + return null; + } + } + } + if ($this->BE_USER->workspace <= 0) { + // User must be in workspace at this point, we may otherwise end up with workspace related records in live. + // @todo: Maybe raise this to an exception? + return null; + } + + // Check if the record already has a version in the current workspace of the backend user + $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid'); + if ($versionRecord) { + return (int)$versionRecord['uid']; + } + + // Create new version of the record and return the new uid + // The information of the label to be used for the workspace record + // as well as the information whether the record shall be removed + // must be forwarded (creating delete placeholders on a workspace are + // done by copying the record and override several fields). + $overrideArray = [ + 't3ver_oid' => $id, + 't3ver_wsid' => $this->BE_USER->workspace, + 't3ver_state' => $delete ? VersionState::DELETE_PLACEHOLDER->value : VersionState::DEFAULT_STATE->value, + 't3ver_stage' => 0, + ]; + if ($schema->hasCapability(TcaSchemaCapability::EditLock)) { + $overrideArray[$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] = 0; + } + $workspaceOptions = [ + 'delete' => $delete, + 'label' => $label, + ]; + return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions); + } + + /** + * Handle MM relations attached to a record when publishing a workspace record. + * + * Strategy: + * * Find all MM tables the record can be attached to by scanning TCA. Handle + * flex form "first level" fields too, but skip scanning for MM relations in + * container sections, since core does not support that since v7 - FormEngine + * throws an exception in this case. + * * For each found MM table: Delete current MM rows of the live record, and + * update MM rows of the workspace record to now point to the live record. + * + * @internal should only be used from within DataHandler + */ + public function versionPublishManyToManyRelations(string $table, array $liveRecord, array $workspaceRecord, int $fromWorkspace): void + { + if (!$this->tcaSchemaFactory->has($table)) { + return; + } + $schema = $this->tcaSchemaFactory->get($table); + $toDeleteRegistry = []; + $toUpdateRegistry = []; + foreach ($schema->getFields() as $fieldType) { + $dbFieldConfig = $fieldType->getConfiguration(); + if (!empty($dbFieldConfig['MM']) && $this->isReferenceField($dbFieldConfig)) { + $toDeleteRegistry[] = $dbFieldConfig; + $toUpdateRegistry[] = $dbFieldConfig; + } + if ($fieldType->isType(TableColumnType::FLEX)) { + // Find possible mm tables attached to live record flex from data structures, mark as to delete + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier(['config' => $dbFieldConfig], $table, $fieldType->getName(), $liveRecord, $schema); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) { + foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) { + if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) { + $toDeleteRegistry[] = $flexFieldDefinition['config']; + } + } + } + // Find possible mm tables attached to workspace record flex from data structures, mark as to update uid + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier(['config' => $dbFieldConfig], $table, $fieldType->getName(), $workspaceRecord, $schema); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) { + foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) { + if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) { + $toUpdateRegistry[] = $flexFieldDefinition['config']; + } + } + } + } + } + + // Delete mm table relations of live record + foreach ($toDeleteRegistry as $config) { + $uidFieldName = $this->mmRelationIsLocalSide($config) ? 'uid_local' : 'uid_foreign'; + $mmTableName = $config['MM']; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName); + $queryBuilder->delete($mmTableName); + $queryBuilder->where($queryBuilder->expr()->eq( + $uidFieldName, + $queryBuilder->createNamedParameter((int)$liveRecord['uid'], Connection::PARAM_INT) + )); + if ($this->mmQueryShouldUseTablenamesColumn($config)) { + $queryBuilder->andWhere($queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($table) + )); + } + $queryBuilder->executeStatement(); + } + + // Update mm table relations of workspace record to uid of live record + foreach ($toUpdateRegistry as $config) { + $mmRelationIsLocalSide = $this->mmRelationIsLocalSide($config); + $uidFieldName = $mmRelationIsLocalSide ? 'uid_local' : 'uid_foreign'; + $mmTableName = $config['MM']; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($mmTableName); + $queryBuilder->update($mmTableName); + $queryBuilder->set($uidFieldName, (int)$liveRecord['uid'], true, Connection::PARAM_INT); + $queryBuilder->where($queryBuilder->expr()->eq( + $uidFieldName, + $queryBuilder->createNamedParameter((int)$workspaceRecord['uid'], Connection::PARAM_INT) + )); + if ($this->mmQueryShouldUseTablenamesColumn($config)) { + $queryBuilder->andWhere($queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($table) + )); + } + $queryBuilder->executeStatement(); + + if (!$mmRelationIsLocalSide) { + // refindex treatment for mm relation handling: If the to publish record is foreign side of an mm relation, we need + // to instruct refindex updater to update all local side references for the live record the current workspace record + // has on foreign side. See ManyToMany Publish addCategoryRelation, this will create the sys_category-31->tt_content-297 entry. + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$workspaceRecord['uid'], $fromWorkspace, 0); + // Similar, when in mm foreign side and relations are deleted in live during publish, other relations pointing to the + // same local side record may need updates due to different sorting, and the former refindex entry of the live record + // needs updates. See ManyToMany Publish deleteCategoryRelation scenario. + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$liveRecord['uid'], 0); + } + } + } + + /** + * Find out if a given flex field definition is a relation with an MM relation. + * Helper of versionPublishManyToManyRelations(). + */ + private function flexFieldDefinitionIsMmRelation(array $flexFieldDefinition): bool + { + return ($flexFieldDefinition['type'] ?? '') !== 'array' // is a field, not a section + && is_array($flexFieldDefinition['config'] ?? false) // config array exists + && $this->isReferenceField($flexFieldDefinition['config']) // select, group, category + && !empty($flexFieldDefinition['config']['MM']); // MM exists + } + + /** + * Find out if a query to an MM table should have a "tablenames=myTable" where. This + * is the case if we're looking at it from the foreign side and if the table must have + * "tablenames" column due to various TCA combinations. + * Helper of versionPublishManyToManyRelations(). + */ + private function mmQueryShouldUseTablenamesColumn(array $config): bool + { + if ($this->mmRelationIsLocalSide($config)) { + return false; + } + + if ($config['type'] === 'group' && !empty($config['prepend_tname'])) { + // prepend_tname in MM on foreign side forces 'tablenames' column + // @todo: See if we can get rid of prepend_tname in MM altogether? + return true; + } + if ($config['type'] === 'group' && is_string($config['allowed'] ?? false) + && (str_contains($config['allowed'], ',') || $config['allowed'] === '*') + ) { + // 'allowed' with *, or more than one table + // @todo: Neither '*' nor 'multiple tables' make sense for MM on foreign side. + // There is a hint in the docs about this, too. Sanitize in TCA bootstrap?! + return true; + } + $localSideTableName = $config['type'] === 'group' ? $config['allowed'] ?? '' : $config['foreign_table'] ?? ''; + $localSideFieldName = $config['MM_opposite_field'] ?? ''; + if (!$this->tcaSchemaFactory->has($localSideTableName) || !$this->tcaSchemaFactory->get($localSideTableName)->hasField($localSideFieldName)) { + return false; + } + $localSideField = $this->tcaSchemaFactory->get($localSideTableName)->getField($localSideFieldName); + $localSideAllowed = $localSideField->getConfiguration()['allowed'] ?? ''; + // Local side with 'allowed' = '*' or multiple tables forces 'tablenames' column + return $localSideAllowed === '*' || str_contains($localSideAllowed, ','); + } + + /** + * Find out if we're looking at an MM relation from local or foreign side. + * Helper of versionPublishManyToManyRelations(). + */ + private function mmRelationIsLocalSide(array $config): bool + { + return empty($config['MM_opposite_field']); + } + + /** + * Returns an instance of DataHandler for handling local datamaps/cmdmaps + */ + protected function getLocalTCE(): DataHandler + { + $copyTCE = GeneralUtility::makeInstance(DataHandler::class); + $copyTCE->enableLogging = $this->enableLogging; + // Transformations should NOT be carried out during copy + $copyTCE->dontProcessTransformations = true; + // make sure the isImporting flag is transferred, so all hooks know if + // the current process is an import process + $copyTCE->isImporting = $this->isImporting; + $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords; + return $copyTCE; + } + + /** + * Processes the fields with references as registered during the copy process. This includes all FlexForm fields which had references. + * @internal should only be used from within DataHandler + */ + public function remapListedDBRecords(): void + { + if (!empty($this->registerDBList)) { + foreach ($this->registerDBList as $table => $records) { + foreach ($records as $uid => $fields) { + $newData = []; + $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null; + $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate); + foreach ($fields as $fieldName => $value) { + $fieldType = $this->tcaSchemaFactory->get($table)->getField($fieldName); + switch ($fieldType->getType()) { + case 'group': + case 'select': + case 'category': + $vArray = $this->remapListedDBRecords_procDBRefs($fieldType->getConfiguration(), $value, $theUidToUpdate, $table); + if (is_array($vArray)) { + $newData[$fieldName] = implode(',', $vArray); + } + break; + case 'flex': + if ($value === 'FlexForm_reference') { + // This will fetch the new row for the element + $origRecordRow = BackendUtility::getRecord($table, $theUidToUpdate, '*', '', false); + if (is_array($origRecordRow)) { + BackendUtility::workspaceOL($table, $origRecordRow, $this->BE_USER->workspace); + // Get current data structure and value array: + $schema = $this->tcaSchemaFactory->get($table); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $fieldType->getConfiguration()], + $table, + $fieldName, + $origRecordRow, + $schema + ); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + $currentValueArray = GeneralUtility::xml2array($origRecordRow[$fieldName]); + // Do recursive processing of the XML data: + $currentValueArray['data'] = $this->remapFlexFormDBRelations($currentValueArray['data'], $dataStructureArray, $table, $theUidToUpdate); + // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later): + if (is_array($currentValueArray['data'])) { + $newData[$fieldName] = $this->flexFormTools->flexArray2Xml($currentValueArray); + } + } + } + break; + case 'inline': + $this->remapListedDBRecords_procInline($fieldType->getConfiguration(), $value, $uid, $table); + break; + case 'file': + $this->remapListedDBRecords_procFile($fieldType->getConfiguration(), $value, $uid, $table); + break; + default: + $this->logger->debug('Field type should not appear here: {type}', ['type' => $fieldType->getType()]); + } + } + // If any fields were changed, those fields are updated! + if (!empty($newData)) { + // @todo: It would be better to have the current record at hand here already, at least its pid. + // This will require changing structure of $this->registerDBList. + $currentRecord = BackendUtility::getRecord($table, $theUidToUpdate_saveTo, 'pid', '', false); + if (!empty($currentRecord)) { + // @todo: For some reason, $this->registerDBList may contain records that do not exist in database + // anymore, so BU::getRecord() above may return null. Find out why/how this happens. + $this->updateDB($table, $theUidToUpdate_saveTo, $newData, (int)$currentRecord['pid']); + } + } + } + } + } + } + + /** + * Remap old copied UIDs to new UIDs for DB reference fields inside a FlexForm data array. + */ + private function remapFlexFormDBRelations(array $data, array $dataStructure, string $table, mixed $uid): array + { + foreach ($dataStructure['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + if (($sheetElementTca['type'] ?? '') === 'array') { + // Section element. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + continue; + } + foreach ($data[$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + if (!is_array($valueSectionContainers ?? false)) { + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + continue; + } + foreach ($sheetElementTca['el'][$valueContainerType]['el'] as $containerElement => $containerElementTca) { + if (!isset($data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'])) { + continue; + } + $fieldConfig = $containerElementTca['config'] ?? []; + $fieldValue = $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF']; + if ($this->isReferenceField($fieldConfig) && (string)$fieldValue !== '') { + $vArray = $this->remapListedDBRecords_procDBRefs($fieldConfig, $fieldValue, $uid, $table); + if (is_array($vArray)) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF'] + = implode(',', $vArray); + } + } + } + } + } + } elseif (isset($data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Simple field element. + $fieldConfig = $sheetElementTca['config'] ?? []; + $fieldValue = $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF']; + if ($this->isReferenceField($fieldConfig) && (string)$fieldValue !== '') { + $vArray = $this->remapListedDBRecords_procDBRefs($fieldConfig, $fieldValue, $uid, $table); + if (is_array($vArray)) { + $data[$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = implode(',', $vArray); + } + } + } + } + } + return $data; + } + + /** + * Performs remapping of old UID values to NEW uid values for a DB reference field. + * + * @param array $conf TCA field config + * @param string $value Field value + * @param int $MM_localUid UID of local record (for MM relations - might need to change if support for FlexForms should be done!) + * @param string $table Table name + * @return array|null Returns array of items ready to implode for field content. + * @see remapListedDBRecords() + * @internal should only be used from within DataHandler + */ + public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table) + { + // Initialize variables + // Will be set TRUE if an upgrade should be done... + $set = false; + // Allowed tables for references. + $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table']; + // Table name to prepend the UID + $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : ''; + // Which tables that should possibly not be remapped + $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true); + // Convert value to list of references: + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false); + $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf); + // Traverse those references and map IDs: + foreach ($dbAnalysis->itemArray as $k => $v) { + $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0; + if ($mapID && !in_array($v['table'], $dontRemapTables, true)) { + $dbAnalysis->itemArray[$k]['id'] = $mapID; + $set = true; + } + } + if (!empty($conf['MM'])) { + // Purge invalid items (live/version) + $dbAnalysis->purgeItemArray(); + if ($dbAnalysis->isPurged()) { + $set = true; + } + // If record has been versioned/copied in this process, handle invalid relations of the live record + $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid); + $originalId = 0; + if (!empty($this->copyMappingArray_merged[$table])) { + $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]); + } + if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) { + $liveRelations = $this->createRelationHandlerInstance(); + $liveRelations->setWorkspaceId(0); + $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf); + // Purge invalid relations in the live workspace ("0") + $liveRelations->purgeItemArray(0); + if ($liveRelations->isPurged()) { + $liveRelations->writeMM($conf['MM'], $liveId, $prependName); + } + } + } + // If a change has been done, set the new value(s) + if ($set) { + if ($conf['MM'] ?? false) { + $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName); + } else { + return $dbAnalysis->getValueArray($prependName); + } + } + return null; + } + + /** + * Performs remapping of old UID values to NEW uid values for an inline field. + * + * @param array $conf TCA field config + * @param string $value Field value + * @param int $uid The uid of the ORIGINAL record + * @param string $table Table name + * @internal should only be used from within DataHandler + */ + public function remapListedDBRecords_procInline($conf, $value, $uid, $table): void + { + $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null; + if ($conf['foreign_table']) { + $relationFieldType = $this->getRelationFieldType($conf); + if ($relationFieldType === 'mm') { + $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table); + } elseif ($relationFieldType !== false) { + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf); + + $updatePidForRecords = []; + // Update values for specific versioned records + foreach ($dbAnalysis->itemArray as &$item) { + $updatePidForRecords[$item['table']][] = $item['id']; + $versionedId = $this->getAutoVersionId($item['table'], $item['id']); + if ($versionedId !== null) { + $updatePidForRecords[$item['table']][] = $versionedId; + $item['id'] = $versionedId; + } + } + + // Update child records if using pointer fields ('foreign_field'): + if ($relationFieldType === 'field') { + $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate); + } + $thePidToUpdate = null; + // If the current field is set on a page record, update the pid of related child records: + if ($table === 'pages') { + $thePidToUpdate = $theUidToUpdate; + } elseif (isset($this->registerDBPids[$table][$uid])) { + $thePidToUpdate = $this->registerDBPids[$table][$uid]; + $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate] ?? null; + } + + // Update child records if change to pid is required + if ($thePidToUpdate && !empty($updatePidForRecords)) { + // Ensure that only the default language page is used as PID + $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate); + // @todo: this can probably go away + // ensure, only live page ids are used as 'pid' values + $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate); + if ($liveId !== null) { + $thePidToUpdate = $liveId; + } + $updateValues = ['pid' => $thePidToUpdate]; + foreach ($updatePidForRecords as $tableName => $uids) { + if (empty($tableName)) { + continue; + } + $conn = $this->connectionPool->getConnectionForTable($tableName); + foreach ($uids as $updateUid) { + $conn->update($tableName, $updateValues, ['uid' => $updateUid]); + } + } + } + } + } + } + + /** + * Performs remapping of old UID values to NEW uid values for an file field. + * + * @internal should only be used from within DataHandler + */ + public function remapListedDBRecords_procFile($conf, $value, $uid, $table): void + { + $thePidToUpdate = null; + $updatePidForRecords = []; + $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null; + + $dbAnalysis = $this->createRelationHandlerInstance(); + $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf); + + foreach ($dbAnalysis->itemArray as &$item) { + $updatePidForRecords[$item['table']][] = $item['id']; + $versionedId = $this->getAutoVersionId($item['table'], $item['id']); + if ($versionedId !== null) { + $updatePidForRecords[$item['table']][] = $versionedId; + $item['id'] = $versionedId; + } + } + unset($item); + + $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate); + + if ($table === 'pages') { + $thePidToUpdate = $theUidToUpdate; + } elseif (isset($this->registerDBPids[$table][$uid])) { + $thePidToUpdate = $this->registerDBPids[$table][$uid]; + $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate] ?? null; + } + + if ($thePidToUpdate && $updatePidForRecords !== []) { + $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate); + $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate); + if ($liveId !== null) { + $thePidToUpdate = $liveId; + } + $updateValues = ['pid' => $thePidToUpdate]; + foreach ($updatePidForRecords as $tableName => $uids) { + if (empty($tableName)) { + continue; + } + $conn = $this->connectionPool->getConnectionForTable($tableName); + foreach ($uids as $updateUid) { + $conn->update($tableName, $updateValues, ['uid' => $updateUid]); + } + } + } + } + + /** + * Processes the $this->remapStack at the end of copying, inserting, etc. actions. + * The remapStack takes care about the correct mapping of new and old uids in case of relational data. + * @internal should only be used from within DataHandler + */ + public function processRemapStack(): void + { + // Processes the remap stack: + $remapFlexForms = []; + $hookPayload = []; + + $newValue = null; + foreach ($this->remapStack as $remapAction) { + // If no position index for the arguments was set, skip this remap action: + if (!is_array($remapAction['pos'])) { + continue; + } + // Load values from the argument array in remapAction: + $isNew = false; + $field = $remapAction['field']; + $id = $remapAction['args'][$remapAction['pos']['id']]; + $rawId = $id; + $table = $remapAction['args'][$remapAction['pos']['table']]; + $valueArray = $remapAction['args'][$remapAction['pos']['valueArray']]; + $tcaFieldConf = $remapAction['args'][$remapAction['pos']['tcaFieldConf']]; + $additionalData = $remapAction['additionalData'] ?? []; + // The record is new and has one or more new ids (in case of versioning/workspaces): + if (str_contains($id, 'NEW')) { + $isNew = true; + // Replace NEW...-ID with real uid: + $id = $this->substNEWwithIDs[$id] ?? ''; + // If the new parent record is on a non-live workspace or versionized, it has another new id: + if (isset($this->autoVersionIdMap[$table][$id])) { + $id = $this->autoVersionIdMap[$table][$id]; + } + $remapAction['args'][$remapAction['pos']['id']] = $id; + } + // Replace relations to NEW...-IDs in field value (uids of child records): + if (is_array($valueArray)) { + foreach ($valueArray as $key => $value) { + if (str_contains($value, 'NEW')) { + if (!str_contains($value, '_')) { + $affectedTable = $tcaFieldConf['foreign_table'] ?? ''; + $prependTable = false; + } else { + $parts = explode('_', $value); + $value = array_pop($parts); + $affectedTable = implode('_', $parts); + $prependTable = true; + } + $value = $this->substNEWwithIDs[$value] ?? ''; + // The record is new, but was also auto-versionized and has another new id: + if (isset($this->autoVersionIdMap[$affectedTable][$value])) { + $value = $this->autoVersionIdMap[$affectedTable][$value]; + } + if ($prependTable) { + $value = $affectedTable . '_' . $value; + } + // Set a hint that this was a new child record: + $this->newRelatedIDs[$affectedTable][] = $value; + $valueArray[$key] = $value; + } + } + $remapAction['args'][$remapAction['pos']['valueArray']] = $valueArray; + } + // Process the arguments with the defined function: + if (!empty($remapAction['func'])) { + $callable = [$this, $remapAction['func']]; + if (is_callable($callable)) { + $newValue = $callable(...$remapAction['args']); + } + } + // If array is returned, check for maxitems condition, if string is returned this was already done: + if (is_array($newValue)) { + $newValue = implode(',', $this->checkValue_checkMax($tcaFieldConf, $newValue)); + // The reference casting is only required if + // checkValue_group_select_processDBdata() returns an array + $newValue = $this->castReferenceValue($newValue, $tcaFieldConf, $isNew); + } + // Update in database (list of children (csv) or number of relations (foreign_field)): + if (!empty($field)) { + $fieldArray = [$field => $newValue]; + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $fieldArray[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + // @todo: It would be better if we'd receive at least the pid somehow here without fetching + // the record again. OTOH, we could also reduce this overhead by getting rid of the + // "parent count" fields altogether, but that's a much more intrusive change. + // For now, we BU::getRecord() the current record explicitly to determine the current + // pid, which is needed at least for proper log records, plus it could make cache + // clearing in updateDB() more effective if used systematically. + $currentRecord = BackendUtility::getRecord($table, $id, '*', '', false); + if (!empty($currentRecord)) { + $this->updateDB($table, $id, $fieldArray, (int)$currentRecord['pid']); + } + } elseif (!empty($additionalData['flexFormId']) && !empty($additionalData['flexFormPath'])) { + // Collect data to update FlexForms + $flexFormId = $additionalData['flexFormId']; + $flexFormPath = $additionalData['flexFormPath']; + + if (!isset($remapFlexForms[$flexFormId])) { + $remapFlexForms[$flexFormId] = []; + } + + $remapFlexForms[$flexFormId][$flexFormPath] = $newValue; + } + + // Collect elements that shall trigger processDatamap_afterDatabaseOperations + if (isset($this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'])) { + $hookArgs = $this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations']; + if (!isset($hookPayload[$table][$rawId])) { + $hookPayload[$table][$rawId] = [ + 'status' => $hookArgs['status'], + 'fieldArray' => $hookArgs['fieldArray'], + 'hookObjects' => $hookArgs['hookObjectsArr'], + ]; + } + $hookPayload[$table][$rawId]['fieldArray'][$field] = $newValue; + } + } + + if ($remapFlexForms) { + foreach ($remapFlexForms as $flexFormId => $modifications) { + $this->updateFlexFormData((string)$flexFormId, $modifications); + } + } + + foreach ($hookPayload as $tableName => $rawIdPayload) { + foreach ($rawIdPayload as $rawId => $payload) { + foreach ($payload['hookObjects'] as $hookObject) { + if (!method_exists($hookObject, 'processDatamap_afterDatabaseOperations')) { + continue; + } + $hookObject->processDatamap_afterDatabaseOperations( + $payload['status'], + $tableName, + $rawId, + $payload['fieldArray'], + $this + ); + } + } + } + // Processes the remap stack actions: + foreach ($this->remapStackActions as $action) { + if (isset($action['callback'], $action['arguments'])) { + $action['callback'](...$action['arguments']); + } + } + // Reset: + $this->remapStack = []; + $this->remapStackRecords = []; + $this->remapStackActions = []; + } + + /** + * Updates FlexForm data. + * + * @param string $flexFormId e.g.
:: + * @param array $modifications Modifications with paths and values (e.g. 'sDEF/lDEV/field/vDEF' => 'TYPO3') + */ + protected function updateFlexFormData($flexFormId, array $modifications): void + { + [$table, $uid, $field] = explode(':', $flexFormId, 3); + if (!MathUtility::canBeInterpretedAsInteger($uid) && !empty($this->substNEWwithIDs[$uid])) { + $uid = $this->substNEWwithIDs[$uid]; + } + $record = BackendUtility::getRecord($table, $uid, '*', '', false); + if (!$table || !$uid || !$field || !is_array($record)) { + return; + } + BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace); + // Get current data structure and value array: + $valueStructure = GeneralUtility::xml2array($record[$field]); + // Do recursive processing of the XML data: + foreach ($modifications as $path => $value) { + $valueStructure['data'] = ArrayUtility::setValueByPath( + $valueStructure['data'], + $path, + $value + ); + } + if (is_array($valueStructure['data'])) { + // The return value should be compiled back into XML + $values = [ + $field => $this->flexFormTools->flexArray2Xml($valueStructure), + ]; + $this->updateDB($table, $uid, $values, (int)$record['pid']); + } + } + + /** + * Adds an instruction to the remap action stack (used with IRRE). + * + * @param string $table The affected table + * @param int|string $id The affected ID + * @param callable $callback The callback information (object and method) + * @param array $arguments The arguments to be used with the callback + * @internal should only be used from within DataHandler + */ + public function addRemapAction($table, $id, callable $callback, array $arguments): void + { + $this->remapStackActions[] = [ + 'affects' => [ + 'table' => $table, + 'id' => $id, + ], + 'callback' => $callback, + 'arguments' => $arguments, + ]; + } + + /** + * If a parent record was versionized on a workspace in $this->process_datamap, + * it might be possible, that child records (e.g. on using IRRE) were affected. + * This function finds these relations and updates their uids in the $incomingFieldArray. + * The $incomingFieldArray is updated by reference! + * + * @param string $table Table name of the parent record + * @param int $id Uid of the parent record + * @param array $incomingFieldArray Reference to the incomingFieldArray of process_datamap + * @param array $registerDBList Reference to the $registerDBList array that was created/updated by versionizing calls to DataHandler in process_datamap. + * @internal should only be used from within DataHandler + */ + public function getVersionizedIncomingFieldArray($table, $id, &$incomingFieldArray, &$registerDBList): void + { + if (!isset($registerDBList[$table][$id]) || !is_array($registerDBList[$table][$id])) { + return; + } + $schema = $this->tcaSchemaFactory->get($table); + foreach ($incomingFieldArray as $field => $value) { + $foreignTable = $schema->hasField($field) ? $schema->getField($field)->getConfiguration()['foreign_table'] ?? '' : ''; + if (($registerDBList[$table][$id][$field] ?? false) + && !empty($foreignTable) + ) { + $newValueArray = []; + $origValueArray = is_array($value) ? $value : explode(',', $value); + // Update the uids of the copied records, but also take care about new records: + foreach ($origValueArray as $childId) { + $newValueArray[] = $this->autoVersionIdMap[$foreignTable][$childId] ?? $childId; + } + // Set the changed value to the $incomingFieldArray + $incomingFieldArray[$field] = implode(',', $newValueArray); + } + } + // Clean up the $registerDBList array: + unset($registerDBList[$table][$id]); + if (empty($registerDBList[$table])) { + unset($registerDBList[$table]); + } + } + + /** + * Straight db based record deletion: + * Either set deleted = 1 for soft-delete enabled tables, or remove row from table. + */ + protected function softOrHardDeleteSingleRecord(string $table, int $uid): void + { + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $this->connectionPool->getConnectionForTable($table)->update( + $table, + [$schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() => 1], + ['uid' => $uid], + [Connection::PARAM_INT] + ); + } else { + $this->hardDeleteSingleRecord($table, $uid); + } + } + + /** + * Simple helper method to hard delete one row from table ignoring delete TCA field + * + * @param string $table A row from this table should be deleted + * @param int $uid Uid of row to be deleted + */ + protected function hardDeleteSingleRecord(string $table, int $uid): void + { + $this->connectionPool->getConnectionForTable($table) + ->delete($table, ['uid' => $uid], [Connection::PARAM_INT]); + } + + /** + * Check user/group for modify table permission of a given table. + */ + protected function checkModifyAccessList(string $table): bool + { + $isTableAdminOnly = $this->tcaSchemaFactory->has($table) && $this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::AccessAdminOnly); + $isAccessAllowed = $this->BE_USER->isAdmin() || (!$isTableAdminOnly && isset($this->BE_USER->groupData['tables_modify']) && GeneralUtility::inList($this->BE_USER->groupData['tables_modify'], $table)); + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) { + $hookObject = GeneralUtility::makeInstance($className); + if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) { + throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472); + } + $hookObject->checkModifyAccessList($isAccessAllowed, $table, $this); + } + return $isAccessAllowed; + } + + /** + * Checks if user may update a record $table on given page record + * + * @internal Strictly internal. May change or vanish any time. + */ + public function hasPermissionToUpdate(string $table, array|VirtualRecord $pageRecord): bool + { + if (!$this->tcaSchemaFactory->has($table)) { + return false; + } + if ($table === 'pages' || ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap))) { + // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page) + $perms = Permission::PAGE_EDIT; + } else { + $perms = Permission::CONTENT_EDIT; + } + if (!$this->hasPageContextPermission($table, $perms, $pageRecord)) { + return false; + } + return true; + } + + /** + * Checks if user may insert a record from $table on $pid + * + * @param int $pid Integer PID + */ + protected function hasPermissionToInsert($table, $pid, array $pageRecord, int $language = 0): bool + { + $pid = (int)$pid; + if ($table === 'pages' && $language > 0) { + // Localizing a page is treated as "PAGE_EDIT": This is not about creating a new sub-page which + // needs "PAGE_NEW". We want to be able to allow editors to create page localization of existing + // pages, while at the same time disallow creating new sub-pages. + $perms = Permission::PAGE_EDIT; + } elseif ($table === 'pages') { + $perms = Permission::PAGE_NEW; + } elseif (($table === 'sys_file_reference') && array_key_exists('pages', $this->datamap)) { + // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page) + $perms = Permission::PAGE_EDIT; + } else { + $perms = Permission::CONTENT_EDIT; + } + if (!$this->hasPageContextPermission($table, $perms, $pid !== 0 ? $pageRecord : VirtualRecord::RootPage)) { + // If page does not exist, it can still be an attempt to add to pid 0. Check this case + // and deny record insert by looking at admin flag and TCA root level restriction as well. + return false; + } + if (!$this->isTableAllowedForThisPage($pid, $table, $pageRecord)) { + return false; + } + return true; + } + + /** + * Checks if a table is allowed on a certain page id according to allowed tables set for the page "doktype" and its [ctrl][rootLevel]-settings if any. + * + * @param int $pageUid Page id for which to check, including 0 (zero) if checking for page tree root. + * @param string $table Table name to check + * @return bool TRUE if OK + */ + protected function isTableAllowedForThisPage(int $pageUid, string $table, array $pageRecord): bool + { + $schema = $this->tcaSchemaFactory->get($table); + /** @var RootLevelCapability $rootLevelCapability */ + $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); + // Check if rootLevel flag is set, and we're trying to insert on rootLevel - and reversed - and + // that the table is not "pages" which are allowed anywhere. + if ($table !== 'pages' + && $rootLevelCapability->getRootLevelType() !== RootLevelCapability::TYPE_BOTH + && ($rootLevelCapability->getRootLevelType() xor !$pageUid) + ) { + return false; + } + // Check root-level + if ($pageUid === 0) { + return $this->BE_USER->isAdmin() || $rootLevelCapability->shallIgnoreRootLevelRestriction(); + } + // Check non-root-level + return $this->pageDoktypeRegistry->isRecordTypeAllowedForDoktype($table, (int)$pageRecord['doktype']); + } + + /** + * Checks if a whole branch of pages exists. + * + * Tests the branch under $pid. It doesn't test the page with $pid as uid. + * + * @param int $defaultLanguagePid uid of a default language page record + * @return array> List of subpage records in branch, empty array if there are none + */ + protected function getSubPagesOfPage(int $defaultLanguagePid, bool $useDeletedRestriction = true): array + { + $schema = $this->tcaSchemaFactory->get('pages'); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll(); + if ($useDeletedRestriction) { + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + } + $result = $queryBuilder + ->select('*') + ->from('pages') + ->where( + // Sub pages of given pid + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($defaultLanguagePid, Connection::PARAM_INT)), + // Only default language pages + $queryBuilder->expr()->eq($schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(), 0), + // We do want to handle "workspace new" and "workspace moved" subpages + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('t3ver_wsid', 0), + // @todo: Verify / add test to see if a workspace-new page is properly discarded if parent page is deleted + // @todo: Add test that moves a page in workspace below some other page, then delete that other page in + // live and workspaces to see what happens ... + $queryBuilder->expr()->and( + $queryBuilder->expr()->gt('t3ver_wsid', 0), + $queryBuilder->expr()->eq('t3ver_state', VersionState::NEW_PLACEHOLDER->value) + ) + ) + ) + ->orderBy('sorting') + ->executeQuery(); + $pages = []; + while ($row = $result->fetchAssociative()) { + // Follow subpages recursive, add before self so deeper pages are handled first + $pages = array_merge($pages, $this->getSubPagesOfPage((int)$row['uid'], $useDeletedRestriction), [$row]); + } + return $pages; + } + + /** + * Checks if page $id is a uid in the rootline of page id $destinationId + * Used when moving a page + * + * @param int $destinationId Destination Page ID to test + * @param int $id Page ID to test for presence inside Destination + * @return bool Returns FALSE if ID is inside destination (including equal to) + */ + protected function destNotInsideSelf($destinationId, $id): bool + { + $loopCheck = 100; + $destinationId = (int)$destinationId; + $id = (int)$id; + if ($destinationId === $id) { + return false; + } + while ($destinationId !== 0 && $loopCheck > 0) { + $loopCheck--; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $result = $queryBuilder + ->select('pid', 'uid', 't3ver_oid', 't3ver_wsid') + ->from('pages') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($destinationId, Connection::PARAM_INT))) + ->executeQuery(); + if ($row = $result->fetchAssociative()) { + // Ensure that the moved location is used as the PID value + BackendUtility::workspaceOL('pages', $row, $this->BE_USER->workspace); + if ($row['pid'] == $id) { + return false; + } + $destinationId = (int)$row['pid']; + } else { + return false; + } + } + return true; + } + + /** + * Checks if there are records on a page from tables that are not allowed + * + * @param int $page_uid Page ID + * @param int $doktype Page doktype + * @return array Returns a list of the tables that are 'present' on the page but not allowed with the page_uid/doktype + * @internal should only be used from within DataHandler + */ + protected function doesPageHaveUnallowedTables(int $page_uid, int $doktype): array + { + if ($page_uid === 0) { + return []; + } + $allowedTables = $this->pageDoktypeRegistry->getAllowedTypesForDoktype($doktype); + // If all tables are allowed, return early + if (in_array('*', $allowedTables, true)) { + return []; + } + $tableList = []; + foreach ($this->tcaSchemaFactory->all() as $schema) { + $table = $schema->getName(); + // If the table is not in the allowed list, check if there are records... + if (in_array($table, $allowedTables, true)) { + continue; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + $count = $queryBuilder + ->count('uid') + ->from($table) + ->where($queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($page_uid, Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchOne(); + if ($count) { + $tableList[] = $table; + } + } + return $tableList; + } + + /** + * @deprecated will be removed in TYPO3 v15.0 (it was always internal -> no deprecation logging) + * @internal Strictly internal. May change or vanish any time. + */ + public function hasPagePermission(int $perms, array $page, bool $useDeleteClause = true): bool + { + return $this->hasPageContextPermission('pages', $perms, $page, $useDeleteClause); + } + + /** + * Checks whether the current backend user holds the requested permissions + * for a record of $table in the context of the given page. + * + * When $page is a VirtualRecord the standard page-permission bitmask check + * is bypassed. For VirtualRecord::RootPage the table's root-level restriction + * capability (security.ignoreRootLevelRestriction) is consulted instead; if + * the table opts out of the restriction, access is granted unconditionally. + * + * @param string $table Table name of the record on the particular page to be checked + * @param int $perms Permission restrictions to observe. An integer bitmask of Permission constants + * @param array $page Full page record + * @param bool $useDeleteClause Use the delete clause to check if the page is deleted + * @internal Strictly internal. May change or vanish any time. + */ + public function hasPageContextPermission(string $table, int $perms, array|VirtualRecord $page, bool $useDeleteClause = true): bool + { + if (!$this->tcaSchemaFactory->has($table)) { + return false; + } + if (!$perms) { + throw new \RuntimeException('Invalid $perms bitset: "' . $perms . '"', 1270853920); + } + $beUserUid = $this->BE_USER->getUserId(); + if (!$beUserUid) { + return false; + } + if ($this->bypassAccessCheckForRecords || $this->BE_USER->isAdmin()) { + return true; + } + if ($page === VirtualRecord::RootPage) { + $tableSchema = $this->tcaSchemaFactory->get($table); + return $tableSchema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction(); + } + $pagesSchema = $this->tcaSchemaFactory->get('pages'); + if (!$pagesSchema->hasCapability(TcaSchemaCapability::RestrictionWebMount) && !$this->BE_USER->isInWebMount($page, '', $useDeleteClause)) { + return false; + } + $permission = new Permission($perms); + + $editLockFieldName = null; + $editLockCheck = false; + if ($pagesSchema->hasCapability(TcaSchemaCapability::EditLock)) { + $editLockFieldName = $pagesSchema->getCapability(TcaSchemaCapability::EditLock)->getFieldName(); + if ($permission->editPagePermissionIsGranted() || $permission->deletePagePermissionIsGranted() || $permission->editContentPermissionIsGranted()) { + $editLockCheck = true; + } + } + $groupUids = []; + $groupCheck = false; + if (!empty($this->BE_USER->userGroupsUID)) { + $groupUids = $this->BE_USER->userGroupsUID; + $groupCheck = true; + } + if ((!$editLockCheck || (int)$page[$editLockFieldName] === 0) + && ( + ((int)$page['perms_everybody'] & $perms) === $perms + || ((int)$page['perms_userid'] === $beUserUid && ((int)$page['perms_user'] & $perms) === $perms) + || ($groupCheck && in_array((int)$page['perms_groupid'], $groupUids, true) && ((int)$page['perms_group'] & $perms) === $perms) + ) + ) { + // A PHP implementation of BackendUserAuthentication->getPagePermsClause() plus the "editlock" check. + return true; + } + return false; + } + + /** + * Update database record + * Does not check permissions but expects them to be verified on beforehand + * + * @param string $table Record table name + * @param int $uid Record uid + * @param array $fieldArray Array of field=>value pairs to insert. FIELDS MUST MATCH the database FIELDS. No check is done. + */ + protected function updateDB($table, $uid, $fieldArray, int $recordPid): void + { + if (!$this->tcaSchemaFactory->has($table) || !(int)$uid) { + return; + } + // Never update the uid field + unset($fieldArray['uid']); + if (empty($fieldArray)) { + return; + } + $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray); + $connection = $this->connectionPool->getConnectionForTable($table); + try { + $connection->update($table, $fieldArray, ['uid' => (int)$uid]); + } catch (DBALException $e) { + $this->log($table, $uid, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: "{reason}" ({table}:{uid})', null, ['reason' => $e->getMessage(), 'table' => $table, 'uid' => $uid]); + return; + } + $this->updateRefIndex($table, $uid); + // Set History data + $historyEntryId = 0; + if (isset($this->historyRecords[$table . ':' . $uid])) { + $historyEntryId = $this->getRecordHistoryStore()->modifyRecord($table, $uid, $this->historyRecords[$table . ':' . $uid], $this->correlationId); + } + $this->log($table, $uid, SystemLogDatabaseAction::UPDATE, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was updated', null, ['table' => $table, 'uid' => $uid, 'history' => $historyEntryId], $recordPid); + // Clear cache for relevant pages: + $this->registerRecordIdForPageCacheClearing($table, $uid); + } + + /** + * Insert into database + * Does not check permissions but expects them to be verified on beforehand + * + * @param string $table Record table name + * @param string $id "NEW...." uid string + * @param array $fieldArray Array of field=>value pairs to insert. FIELDS MUST MATCH the database FIELDS. No check is done. "pid" must point to the destination of the record! + * @param int $suggestedUid Suggested UID value for the inserted record. See the array $this->suggestedInsertUids; Admin-only feature + * @return int|null Returns ID on success. + */ + protected function insertDB($table, $id, $fieldArray, $suggestedUid = 0): ?int + { + if (!$this->tcaSchemaFactory->has($table) || !isset($fieldArray['pid'])) { + return null; + } + // Do NOT insert the UID field, ever! + unset($fieldArray['uid']); + // Check for "suggestedUid". + // This feature is used by the import functionality to force a new record to have a certain UID value. + // This is only recommended for use when the destination server is a passive mirror of another server. + // As a security measure this feature is available only for Admin Users (for now) + // The value of $this->suggestedInsertUids["table":"uid"] is either string 'DELETE' (ext:impexp) to trigger + // a blind delete of any possibly existing row before insert with forced uid, or boolean true (testing-framework) + // to only force the uid insert and skipping deletion of an existing row. + $suggestedUid = (int)$suggestedUid; + if ($this->BE_USER->isAdmin() && $suggestedUid && ($this->suggestedInsertUids[$table . ':' . $suggestedUid] ?? false)) { + // When the value of ->suggestedInsertUids[...] is "DELETE" it will try to remove the previous record + if ($this->suggestedInsertUids[$table . ':' . $suggestedUid] === 'DELETE') { + $this->hardDeleteSingleRecord($table, (int)$suggestedUid); + } + $fieldArray['uid'] = $suggestedUid; + } + $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray); + $connection = $this->connectionPool->getConnectionForTable($table); + try { + // Execute the INSERT query: + $connection->insert($table, $fieldArray); + } catch (DBALException $e) { + $this->log($table, 0, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: "{reason}" ({table}:{uid})', null, ['reason' => $e->getMessage(), 'table' => $table, 'uid' => $id]); + return null; + } + // Set mapping for NEW... -> real uid: + // the NEW_id now holds the 'NEW....' -id + $NEW_id = $id; + $id = $this->postProcessDatabaseInsert($connection, $table, $suggestedUid); + $this->substNEWwithIDs[$NEW_id] = $id; + $this->substNEWwithIDs_table[$NEW_id] = $table; + $newRow = $fieldArray; + $newRow['uid'] = $id; + // Update reference index: + $this->updateRefIndex($table, $id); + // Store in history + $this->getRecordHistoryStore()->addRecord($table, $id, $newRow, $this->correlationId); + if ($this->tcaSchemaFactory->get($table)->isWorkspaceAware() && (int)($newRow['t3ver_wsid'] ?? 0) > 0) { + $this->log($table, $id, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::MESSAGE, 'New version created "{table}:{uid}". UID of new version is "{offlineUid}"', null, ['table' => $table, 'uid' => $newRow['uid'], 'offlineUid' => $id], $table === 'pages' ? $newRow['uid'] : $newRow['pid']); + } else { + $this->log($table, $id, SystemLogDatabaseAction::INSERT, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was inserted on page {pid}', null, ['table' => $table, 'uid' => $id, 'pid' => $newRow['pid']], $newRow['pid']); + // Clear cache of relevant pages + $this->registerRecordIdForPageCacheClearing($table, $id); + } + return $id; + } + + /** + * Setting sys_history record, based on content previously set in $this->historyRecords[$table . ':' . $id] (by compareFieldArrayWithCurrentAndUnset()) + * + * This functionality is now moved into the RecordHistoryStore and can be used instead. + * + * @param string $table Table name + * @param int $id Record ID + * @internal should only be used from within DataHandler + */ + public function setHistory($table, $id): void + { + if (isset($this->historyRecords[$table . ':' . $id])) { + $this->getRecordHistoryStore()->modifyRecord( + $table, + $id, + $this->historyRecords[$table . ':' . $id], + $this->correlationId + ); + } + } + + protected function getRecordHistoryStore(): RecordHistoryStore + { + return GeneralUtility::makeInstance( + RecordHistoryStore::class, + RecordHistoryStore::USER_BACKEND, + (int)$this->BE_USER->user['uid'], + (int)$this->BE_USER->getOriginalUserIdWhenInSwitchUserMode(), + $GLOBALS['EXEC_TIME'], + $this->BE_USER->workspace + ); + } + + /** + * Register a table/uid combination in current user workspace for reference updating. + * Should be called on almost any update to a record which could affect references inside the record. + * + * @param string $table Table name + * @param int $uid Record UID + * @param int|null $workspace Workspace the record lives in + * @internal should only be used from within DataHandler + */ + public function updateRefIndex($table, $uid, ?int $workspace = null): void + { + if ($workspace === null) { + $workspace = (int)$this->BE_USER->workspace; + } + $this->referenceIndexUpdater->registerForUpdate((string)$table, (int)$uid, $workspace); + } + + /** + * Delete rows from sys_refindex a table / uid combination is involved in: + * Either on left side (tablename + recuid) OR right side (ref_table + ref_uid). + * Useful in scenarios like workspace-discard where parents or children are hard deleted: The + * expensive updateRefIndex() does not need to be called since we can just drop straight ahead. + * + * @param string $table Table name, used as tablename and ref_table + * @param int $uid Record uid, used as recuid and ref_uid + * @param int $workspace Workspace the record lives in + * @internal should only be used from within DataHandler + */ + public function registerReferenceIndexRowsForDrop(string $table, int $uid, int $workspace): void + { + $this->referenceIndexUpdater->registerForDrop($table, $uid, $workspace); + } + + /** + * Helper method to access referenceIndexUpdater->registerUpdateForReferencesToItem() + * from within workspace DataHandlerHook. + * + * @internal Exists only for workspace DataHandlerHook. May vanish any time. + */ + public function registerReferenceIndexUpdateForReferencesToItem(string $table, int $uid, int $workspace, ?int $targetWorkspace = null): void + { + $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $workspace, $targetWorkspace); + } + + /** + * Returning sorting number for tables with a "sortby" column + * Using when new records are created and existing records are moved around. + * + * The strategy is: + * - if no record exists: set interval as sorting number + * - if inserted before an element: put in the middle of the existing elements + * - if inserted behind the last element: add interval to last sorting number + * - if collision: move all subsequent records by 2 * interval, insert new record with collision + interval + * + * How to calculate the maximum possible inserts for the worst case of adding all records to the top, + * such that the sorting number stays within INT_MAX + * + * i = interval (currently 256) + * c = number of inserts until collision + * s = max sorting number to reach (INT_MAX - 32bit) + * n = number of records (~83 million) + * + * c = 2 * g + * g = log2(i) / 2 + 1 + * n = g * s / i - g + 1 + * + * The algorithm can be tuned by adjusting the interval value. + * Higher value means less collisions, but also less inserts are possible to stay within INT_MAX. + * + * @param int $uid Uid of record to find sorting number for. May be zero in case of new. + * @param int $pid Positioning PID, either >=0 (pointing to page in which case we find sorting number for first record in page) or <0 (pointing to record in which case to find next sorting number after this record) + * @return int|array|false Returns integer if PID is >=0, otherwise an array with PID and sorting number. Possibly FALSE in case of error. + */ + protected function getSortNumber(string $table, $uid, $pid): int|array|false + { + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->hasCapability(TcaSchemaCapability::SortByField)) { + return false; + } + $sortColumn = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + + $considerWorkspaces = $schema->isWorkspaceAware(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->select($sortColumn, 'pid', 'uid') + ->from($table); + if ($considerWorkspaces) { + $queryBuilder->addSelect('t3ver_state'); + } + + // find and return the sorting value for the first record on that pid + if ($pid >= 0) { + // Fetches the first record (lowest sorting) under this pid + $queryBuilder + ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT))); + + if ($considerWorkspaces) { + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('t3ver_oid', 0), + $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER->value) + ) + ); + } + $row = $queryBuilder + ->orderBy($sortColumn, 'ASC') + ->addOrderBy('uid', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if (!empty($row)) { + // The top record was the record itself, so we return its current sorting value + if ($row['uid'] == $uid) { + return $row[$sortColumn]; + } + // If the record sorting value < 1 we must resort all the records under this pid + if ($row[$sortColumn] < 1) { + $this->increaseSortingOfFollowingRecords($table, (int)$pid); + // Lowest sorting value after full resorting is $sortIntervals + return $this->sortIntervals; + } + // Sorting number between current top element and zero + return (int)floor($row[$sortColumn] / 2); + } + // No records, so we choose the default value as sorting-number + return $this->sortIntervals; + } + + // Find and return first possible sorting value AFTER record with given uid ($pid) + // Fetches the record which is supposed to be the prev record + $row = $queryBuilder + ->where($queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter(abs($pid), Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchAssociative(); + + // There is a previous record + if (!empty($row)) { + // Look if the record UID happens to be a versioned record. If so, find its live version. + // If this is already a moved record in workspace, this is not needed + if (VersionState::tryFrom((int)($row['t3ver_state'] ?? 0)) !== VersionState::MOVE_POINTER && $lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, $row['uid'], [$sortColumn, 'pid', 'uid'])) { + $row = $lookForLiveVersion; + } elseif ($considerWorkspaces && $this->BE_USER->workspace > 0) { + // In case the previous record is moved in the workspace, we need to fetch the information from this specific record + $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $row['uid'], [$sortColumn, 'pid', 'uid', 't3ver_state']); + if (is_array($versionedRecord) && VersionState::tryFrom($versionedRecord['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) { + $row = $versionedRecord; + } + } + // If the record should be inserted after itself, keep the current sorting information: + if ((int)$row['uid'] === (int)$uid) { + $sortNumber = $row[$sortColumn]; + } else { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->select($sortColumn, 'pid', 'uid') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($row['pid'], Connection::PARAM_INT) + ), + $queryBuilder->expr()->gte( + $sortColumn, + $queryBuilder->createNamedParameter($row[$sortColumn], Connection::PARAM_INT) + ) + ) + ->orderBy($sortColumn, 'ASC') + ->addOrderBy('uid', 'DESC') + ->setMaxResults(2); + + if ($considerWorkspaces) { + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('t3ver_oid', 0), + $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER->value) + ) + ); + } + + $subResults = $queryBuilder->executeQuery()->fetchAllAssociative(); + // Fetches the next record in order to calculate the in-between sortNumber + if (count($subResults) === 2) { + // There was a record afterward, fetch that + $subrow = array_pop($subResults); + // The sortNumber is found in between these values + $sortNumber = $row[$sortColumn] + floor(($subrow[$sortColumn] - $row[$sortColumn]) / 2); + // The sortNumber happened NOT to be between the two surrounding numbers, so we'll have to resort the list + if ($sortNumber <= $row[$sortColumn] || $sortNumber >= $subrow[$sortColumn]) { + $this->increaseSortingOfFollowingRecords($table, (int)$row['pid'], (int)$row[$sortColumn]); + $sortNumber = $row[$sortColumn] + $this->sortIntervals; + } + } else { + // If after the last record in the list, we just add the sortInterval to the last sortvalue + $sortNumber = $row[$sortColumn] + $this->sortIntervals; + } + } + return ['pid' => $row['pid'], 'sortNumber' => $sortNumber]; + } + // There must be a previous record or else this cannot work + $this->log($table, $uid, SystemLogDatabaseAction::MOVE, null, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record {table}:{uid} after a non-existing record ({target})', null, ['table' => $table, 'uid' => $uid, 'target' => abs($pid)], $pid); + return false; + } + + /** + * Increases sorting field value of all records with sorting higher than $sortingValue + * + * Used internally by getSortNumber() to "make space" in sorting values when inserting new record + * + * @param string $table Table name + * @param int $pid Page Uid in which to resort records + * @param int|null $sortingValue All sorting numbers larger than this number will be shifted + */ + protected function increaseSortingOfFollowingRecords(string $table, int $pid, ?int $sortingValue = null): void + { + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $sortBy = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder + ->update($table) + ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT))) + ->set($sortBy, $queryBuilder->quoteIdentifier($sortBy) . ' + ' . $this->sortIntervals . ' + ' . $this->sortIntervals, false); + if ($sortingValue !== null) { + $queryBuilder->andWhere($queryBuilder->expr()->gt($sortBy, $sortingValue)); + } + if ($schema->isWorkspaceAware()) { + $queryBuilder + ->andWhere( + $queryBuilder->expr()->eq('t3ver_oid', 0) + ); + } + + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $queryBuilder->andWhere($queryBuilder->expr()->eq($schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(), 0)); + } + + $queryBuilder->executeStatement(); + } + } + + /** + * Returning uid of "previous" localized record, if any, for tables with a "sortby" column. + * Used when records are localized, so that localized records are sorted in the + * same order as the source language records. + * + * The uid of the returned record is later used to create the localized record "after" + * (higher sorting value) than the one the uid is returned of. + * + * There are basically two scenarios: + * * The localized record is to be placed as the first record of the target pid/language + * combination. In this case, there is no "before" record in this language. The method + * returns input $uid, saying "insert the localized record with a higher sorting value + * than the record the localization is created from". + * * There is a localized record "before" (lower sorting value) in the target pid/language + * combination. For instance because source language element 2 is being translated and + * source language element 1 has already been translated. In this case, the uid of the + * 'element 1' is returned, saying "insert the localized record with a higher sorting + * value than the "before" record in this language. + * + * The algorithm first fetches the record of given input uid. It then looks if there is a + * record with a lower sorting value for this pid/language combination. If no, input uid + * is returned ("place with higher sorting than source language record"). If yes, it looks + * if there is a localization of that source record in the target language and return the + * uid of that target language record ("place with higher sorting that this traget language + * record"). When dealing with table tt_content, colpos is also taken into account. + * + * @param string $table Table name + * @param int $uid Uid of source language record + * @param int $pid Pid of source language record + * @param int $targetLanguage Target language id + * @return int uid of record after which the localized record should be inserted + */ + protected function getPreviousLocalizedRecordUid($table, $uid, $pid, $targetLanguage) + { + $previousLocalizedRecordUid = $uid; + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->hasCapability(TcaSchemaCapability::SortByField)) { + return $previousLocalizedRecordUid; + } + $sortColumn = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + + // Typically l10n_parent + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + // Typically sys_language_uid + $languageField = $languageCapability->getLanguageField()->getName(); + + $select = [$sortColumn, $languageField, $transOrigPointerField, 'pid', 'uid']; + // For content elements, we also need the colPos + if ($table === 'tt_content') { + $select[] = 'colPos'; + } + + // Get the sort value and some other details of the source language record + $row = BackendUtility::getRecord($table, $uid, $select); + if (!is_array($row)) { + // This if may be obsolete ... didn't the callee already check if the source record exists? + return $previousLocalizedRecordUid; + } + + // Try to find a "before" record in source language + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->select(...$select) + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $languageField, + $queryBuilder->createNamedParameter($row[$languageField], Connection::PARAM_INT) + ), + $queryBuilder->expr()->lt( + $sortColumn, + $queryBuilder->createNamedParameter($row[$sortColumn], Connection::PARAM_INT) + ) + ) + ->orderBy($sortColumn, 'DESC') + ->addOrderBy('uid', 'DESC') + ->setMaxResults(1); + if ($table === 'tt_content') { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + 'colPos', + $queryBuilder->createNamedParameter($row['colPos'], Connection::PARAM_INT) + ) + ); + } + // If there is a "before" record in source language, see if it is localized to target language. + // If so, return uid of target language record. + if ($previousRow = $queryBuilder->executeQuery()->fetchAssociative()) { + $previousLocalizedRecord = $this->localizationRepository->getRecordTranslation($table, ['uid' => $previousRow['uid'], 'pid' => $pid], $targetLanguage, $this->BE_USER->workspace); + if ($previousLocalizedRecord) { + $previousLocalizedRecordUid = $previousLocalizedRecord->getUid(); + } + } + + return $previousLocalizedRecordUid; + } + + /** + * Returns a fieldArray with default values. Values will be picked up from the TCA array + * looking at the config key "default" for each column. If values are set in ->defaultValues + * they will overrule though. Used for new records and during copy operations for defaults. + * + * @param string $table Table name for which to set default values. + * @param array $recordContext Record context to determine type + * @return array Array with default values. + * @internal should only be used from within DataHandler + */ + public function newFieldArray($table, array $recordContext = []): array + { + $fieldArray = []; + foreach ($this->getTypeSpecificFields($table, $recordContext) as $field) { + $fieldName = $field->getName(); + // PageTSconfig type-specific defaults take highest precedence + if (($typeSpecificValue = $this->getTypeSpecificDefault($table, $fieldName, $recordContext)) !== null) { + $fieldArray[$fieldName] = $typeSpecificValue; + } elseif (isset($this->defaultValues[$table][$fieldName])) { + $fieldArray[$fieldName] = $this->defaultValues[$table][$fieldName]; + } elseif ($field->hasDefaultValue() && ($field->getDefaultValue() !== null || $field->isNullable())) { + // This now uses sub-schema field if available, respecting columnsOverrides defaults + $fieldArray[$fieldName] = $field->getDefaultValue(); + } + } + return $fieldArray; + } + + /** + * Use type-specific sub-schema fields if available, otherwise main schema fields + */ + protected function getTypeSpecificFields(string $table, array $row): FieldCollection + { + if (!$this->tcaSchemaFactory->has($table)) { + return new FieldCollection([]); + } + $schema = $this->tcaSchemaFactory->get($table); + $recordType = BackendUtility::getTCAtypeValue($schema->getName(), $row, true); + return ($recordType !== null && $schema->hasSubSchema($recordType)) + ? $schema->getSubSchema($recordType)->getFields() + : $schema->getFields(); + } + + /** + * Get type-specific default value for a field if available + + * @param array $recordContext Record context to determine type + * @return mixed|null Type-specific default value or null if not found + */ + protected function getTypeSpecificDefault(string $table, string $fieldName, array $recordContext): mixed + { + $schema = $this->tcaSchemaFactory->get($table); + + // Check if we have type-specific configuration for this table and field + if (!isset($this->defaultValues[$table]['__typeSpecific'][$fieldName])) { + return null; + } + + // Determine record type from record context using Schema API + $recordType = ''; + if ($schema->supportsSubSchema()) { + $typeFieldName = $schema->getSubSchemaTypeInformation()->getFieldName(); + if (isset($recordContext[$typeFieldName])) { + $recordType = (string)$recordContext[$typeFieldName]; + } + } + + if ($recordType === '') { + return null; + } + + // Get type-specific configuration for this field + $fieldTypeConfig = $this->defaultValues[$table]['__typeSpecific'][$fieldName]; + + // Check if there's a type-specific override + return $fieldTypeConfig['types.'][$recordType] ?? null; + } + + /** + * If a "languageField" is specified for $table this function will add a + * possible value to the incoming array if none is found in there already. + */ + protected function addDefaultPermittedLanguageIfNotSet(string $table, array $incomingFieldArray, int $pageId): array + { + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware()) { + return $incomingFieldArray; + } + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageFieldName = $languageCapability->getLanguageField()->getName(); + if (isset($incomingFieldArray[$languageFieldName])) { + return $incomingFieldArray; + } + try { + $site = $this->siteFinder->getSiteByPageId($pageId); + foreach ($site->getAvailableLanguages($this->BE_USER, false, $pageId) as $languageId => $language) { + $incomingFieldArray[$languageFieldName] = $languageId; + break; + } + } catch (SiteNotFoundException) { + // No site found, do not set a default language if nothing was set explicitly + } + return $incomingFieldArray; + } + + /** + * Find a site language by the given language ID for a specific page, and check for all available sites + * if the page ID is "0". + * + * Note: Currently, the first language matching the given id is used, while + * there might be more languages with the same id in additional sites. + * + * @param int $pageId + * @param int $languageId + */ + protected function getSiteLanguageForPage(int $pageId, int $languageId): ?SiteLanguage + { + try { + // Try to fetch the site language from the pages' associated site + $site = $this->siteFinder->getSiteByPageId($pageId); + return $site->getLanguageById($languageId); + } catch (SiteNotFoundException|\InvalidArgumentException $e) { + // In case no site language could be found, we might deal with the root node, + // we therefore try to fetch the site language from all available sites. + // NOTE: This has side effects, in case the SAME ID is used for different languages in different sites! + $sites = $this->siteFinder->getAllSites(); + foreach ($sites as $site) { + try { + return $site->getLanguageById($languageId); + } catch (\InvalidArgumentException $e) { + // language not found in site, continue + continue; + } + } + } + + return null; + } + + /** + * Compares the incoming field array with the current record and unsets all fields which are the same. + * Used for existing records being updated + * + * @param string $table Record table name + * @param int $id Record uid + * @param array $fieldArray Array of field=>value pairs intended to be inserted into the database. All keys with values matching exactly the current value will be unset! + * @return array Returns $fieldArray. If the returned array is empty, then the record should not be updated! + * @internal should only be used from within DataHandler + */ + public function compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray): array + { + $connection = $this->connectionPool->getConnectionForTable($table); + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $currentRecord = $queryBuilder->select('*') + ->from($table) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + if (!is_array($currentRecord)) { + return []; + } + // If the current record exists (which it should...), begin comparison: + $currentRecord = BackendUtility::convertDatabaseRowValuesToPhp($table, $currentRecord); + $tableInfo = $connection->getSchemaInformation()->getTableInfo($table); + $columnRecordTypes = []; + foreach ($currentRecord as $columnName => $_) { + $columnRecordTypes[$columnName] = ''; + $type = $tableInfo->getColumnInfo($columnName)?->getType(); + if ($type instanceof IntegerType) { + $columnRecordTypes[$columnName] = 'int'; + } elseif ($type instanceof JsonType) { + $columnRecordTypes[$columnName] = 'json'; + } + } + // Unset the fields which are similar: + foreach ($fieldArray as $col => $val) { + $fieldConfiguration = []; + $isNullField = false; + + if ($this->tcaSchemaFactory->get($table)->hasField($col)) { + $fieldType = $this->tcaSchemaFactory->get($table)->getField($col); + $fieldConfiguration = $fieldType->getConfiguration(); + $isNullField = $fieldType->isNullable(); + } + + // Unset fields if stored and submitted values are equal - except the current field holds MM relations. + // In general this avoids to store superfluous data which also will be visualized in the editing history. + if (empty($fieldConfiguration['MM']) && $this->isSubmittedValueEqualToStoredValue($val, $currentRecord[$col], $columnRecordTypes[$col], $isNullField)) { + unset($fieldArray[$col]); + } else { + if (!isset($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col])) { + $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $currentRecord[$col]; + } elseif ($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col]) { + $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col]; + } + if (!isset($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col])) { + $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $fieldArray[$col]; + } elseif ($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col]) { + $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col]; + } + } + } + return $fieldArray; + } + + /** + * Determines whether submitted values and stored values are equal. + * This prevents from adding superfluous field changes which would be shown in the record history as well. + * For NULL fields (see accordant TCA definition 'nullable'), a special handling is required since + * (!strcmp(NULL, '')) would be a false-positive. + * + * @param mixed $submittedValue Value that has submitted (e.g. from a backend form) + * @param mixed $storedValue Value that is currently stored in the database + * @param string $storedType SQL type of the stored value column (see mysql_field_type(), e.g 'int', 'string', ...) + * @param bool $allowNull Whether NULL values are allowed by accordant TCA definition ('nullable') + * @return bool Whether both values are considered to be equal + */ + protected function isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, $allowNull = false) + { + // No NULL values are allowed, this is the regular behaviour. + // Thus, check whether strings are the same or whether integer values are empty ("0" or ""). + if (!$allowNull) { + switch ($storedType) { + case 'json': + $result = $submittedValue === $storedValue; + break; + case 'int': + $result = (int)$storedValue === (int)$submittedValue; + break; + default: + $result = (string)$submittedValue === (string)$storedValue; + } + // Null values are allowed, but currently there's a real (not NULL) value. + // Thus, ensure no NULL value was submitted and fallback to the regular behaviour. + } elseif ($storedValue !== null) { + $result = ( + $submittedValue !== null + && $this->isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, false) + ); + // Null values are allowed, and currently there's a NULL value. + // Thus, check whether a NULL value was submitted. + } else { + $result = ($submittedValue === null); + } + + return $result; + } + + /** + * Gets UID of parent record. If record is deleted it will be looked up in + * an array built before the record was deleted + * + * @param string $table Table where record lives/lived + * @param int $uid Record UID + * @return int[] Parent UIDs + */ + protected function getOriginalParentOfRecord($table, $uid) + { + if (isset(self::$recordPidsForDeletedRecords[$table][$uid])) { + return self::$recordPidsForDeletedRecords[$table][$uid]; + } + $parentUid = (int)BackendUtility::getRealPageId($table, $uid); + return [$parentUid]; + } + + /** + * Extract entries from TSconfig for a specific table. This will merge specific and default configuration together. + * + * @param string $table Table name + * @param array $TSconfig TSconfig for page + * @return array TSconfig merged + * @internal should only be used from within DataHandler + */ + public function getTableEntries($table, $TSconfig): array + { + $tA = is_array($TSconfig['table.'][$table . '.'] ?? false) ? $TSconfig['table.'][$table . '.'] : []; + $dA = is_array($TSconfig['default.'] ?? false) ? $TSconfig['default.'] : []; + ArrayUtility::mergeRecursiveWithOverrule($dA, $tA); + return $dA; + } + + /** + * Executing dbAnalysisStore + * This will save MM relations for new records but is executed after records are created because we need to know the ID of them + * @internal should only be used from within DataHandler + */ + public function dbAnalysisStoreExec(): void + { + foreach ($this->dbAnalysisStore as $action) { + $idIsInteger = MathUtility::canBeInterpretedAsInteger($action[2]); + // If NEW id is not found in substitution array (due to errors), continue. + if (!$idIsInteger && !isset($this->substNEWwithIDs[$action[2]])) { + continue; + } + $id = BackendUtility::wsMapId($action[4], $idIsInteger ? $action[2] : $this->substNEWwithIDs[$action[2]]); + if ($id) { + $action[0]->writeMM($action[1], $id, $action[3]); + } + } + } + + /** + * Returns array, $CPtable, of pages under the $pid going down to $counter levels. + * Selecting ONLY pages which the user has read-access to! + * + * @param array $CPtable Accumulation of page uid=>pid pairs in branch of $pid + * @param int $pid Page ID for which to find subpages + * @param int $counter Number of levels to go down. + * @param int $rootID ID of root point for new copied branch: The idea seems to be that a copy is not made of the already new page! + * @return array Return array. + * @internal should only be used from within DataHandler + */ + public function int_pageTreeInfo($CPtable, $pid, $counter, $rootID) + { + if ($counter) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->select('uid') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)), + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())), + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')), + ), + ) + ->orderBy('sorting', 'DESC'); + if (!$this->BE_USER->isAdmin()) { + $queryBuilder->andWhere($this->BE_USER->getPagePermsClause(Permission::PAGE_SHOW)); + } + if ($this->BE_USER->workspace === 0) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ); + } else { + $queryBuilder->andWhere($queryBuilder->expr()->in( + 't3ver_wsid', + $queryBuilder->createNamedParameter([0, $this->BE_USER->workspace], Connection::PARAM_INT_ARRAY) + )); + } + $result = $queryBuilder->executeQuery(); + + $pages = []; + while ($row = $result->fetchAssociative()) { + $pages[$row['uid']] = $row; + } + + // Resolve placeholders of workspace versions + if (!empty($pages) && $this->BE_USER->workspace !== 0) { + $pages = array_reverse( + $this->resolveVersionedRecords( + 'pages', + 'uid', + 'sorting', + array_keys($pages) + ), + true + ); + } + + foreach ($pages as $page) { + if ($page['uid'] != $rootID) { + $CPtable[$page['uid']] = $pid; + // If the uid is NOT the rootID of the copyaction and if we are supposed to walk further down + if ($counter - 1) { + $CPtable = $this->int_pageTreeInfo($CPtable, $page['uid'], $counter - 1, $rootID); + } + } + } + } + return $CPtable; + } + + /** + * Checks if any uniqueInPid eval input fields are in the record and if so, they are re-written to be correct. + */ + protected function fixUniqueInPid(string $table, array $row): void + { + $newData = []; + // Use type-specific sub-schema fields if available, otherwise main schema fields + foreach ($this->getTypeSpecificFields($table, $row) as $field) { + if ($field->isType(TableColumnType::INPUT, TableColumnType::EMAIL) && (string)($row[$field->getName()] ?? '') !== '') { + $evalCodesArray = GeneralUtility::trimExplode(',', $field->getConfiguration()['eval'] ?? '', true); + if (in_array('uniqueInPid', $evalCodesArray, true)) { + $newValue = $this->getUnique($table, $field->getName(), $row[$field->getName()], (int)$row['uid'], (int)$row['pid']); + if ((string)$newValue !== (string)$row[$field->getName()]) { + $newData[$field->getName()] = $newValue; + } + } + } + } + if (!empty($newData)) { + $this->updateDB($table, (int)$row['uid'], $newData, (int)$row['pid']); + } + } + + /** + * Checks if any uniqueInSite eval fields are in the record and if so, they are re-written to be correct. + * + * @return bool whether the record had to be fixed or not + */ + protected function fixUniqueInSite(string $table, int|array $uidOrRow): bool + { + // Load record first to determine record type for columnsOverrides support + if (is_array($uidOrRow)) { + $row = $uidOrRow; + } else { + $row = BackendUtility::getRecord($table, $uidOrRow, '*', '', false); + } + if (!is_array($row)) { + return false; + } + $newData = []; + foreach ($this->getTypeSpecificFields($table, $row) as $field) { + if (!$field->isType(TableColumnType::SLUG)) { + continue; + } + $conf = $field->getConfiguration(); + $evalCodesArray = GeneralUtility::trimExplode(',', $conf['eval'] ?? '', true); + if (!in_array('uniqueInSite', $evalCodesArray, true)) { + continue; + } + if ((string)($row[$field->getName()] ?? '') === '') { + continue; + } + $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field->getName(), $conf, $this->BE_USER->workspace); + $state = RecordStateFactory::forName($table)->fromArray($row); + $newValue = $helper->buildSlugForUniqueInSite($row[$field->getName()], $state); + if ((string)$newValue !== (string)$row[$field->getName()]) { + $newData[$field->getName()] = $newValue; + } + } + if (!empty($newData)) { + $this->updateDB($table, (int)$row['uid'], $newData, (int)$row['pid']); + return true; + } + return false; + } + + /** + * Check if there are subpages that need an adoption as well + */ + protected function fixUniqueInSiteForSubpages(int $pageId): void + { + // Get ALL subpages to update - read-permissions are respected + $subPages = $this->int_pageTreeInfo([], $pageId, 99, $pageId); + // Now fix uniqueInSite for subpages + foreach ($subPages as $thePageUid => $thePagePid) { + $recordWasModified = $this->fixUniqueInSite('pages', $thePageUid); + if ($recordWasModified) { + // @todo: Add logging and history - but how? we don't know the data that was in the system before + } + } + } + + /** + * When a record is copied you can specify fields from the previous record which should be copied into the new one + * + * @param string $table Table name + * @param int $prevUid UID of previous record + * + * @return array Output array (For when the copying operation needs to get the information instead of updating the info) + */ + protected function fixCopyAfterDuplFields(string $table, int $prevUid): array + { + $schema = $this->tcaSchemaFactory->get($table); + if (!isset($schema->getRawConfiguration()['copyAfterDuplFields'])) { + return []; + } + if (($prevData = BackendUtility::getRecord($table, $prevUid, '*', '', false)) === null) { + return []; + } + + $fieldNames = GeneralUtility::trimExplode(',', $schema->getRawConfiguration()['copyAfterDuplFields'], true); + $newData = []; + foreach ($fieldNames as $fieldName) { + if ($schema->hasField($fieldName)) { + $fieldType = $schema->getField($fieldName); + $fieldName = $fieldType->getName(); + if (!isset($newData[$fieldName])) { + $newData[$fieldName] = $prevData[$fieldName]; + } + } + } + return $newData; + } + + /** + * Casts a reference value. In case MM relations or foreign_field + * references are used. All other configurations, as well as + * foreign_table(!) could be stored as comma-separated-values + * as well. Since the system is not able to determine the default + * value automatically then, the TCA default value is used if + * it has been defined. + * + * @param int|string $value The value to be casted (e.g. '', '0', '1,2,3') + * @param array $configuration The TCA configuration of the accordant field + * @param bool $isNew is the record new or not + * @return int|string + */ + protected function castReferenceValue($value, array $configuration, bool $isNew) + { + if ((string)$value !== '') { + return $value; + } + + if (!empty($configuration['MM']) || !empty($configuration['foreign_field'])) { + return 0; + } + + if (!$isNew && isset($configuration['renderType']) && $configuration['renderType'] === 'selectCheckBox') { + return ''; + } + + foreach (($configuration['items'] ?? []) as $item) { + if ($item['value'] === $value) { + return $value; + } + } + + if (array_key_exists('default', $configuration)) { + return $configuration['default']; + } + + return $value; + } + + /** + * Returns TRUE if the TCA/columns field type is a DB reference field + * + * @param array $conf Config array for TCA/columns field + * @return bool TRUE if DB reference field (group/db or select with foreign-table) + * @internal should only be used from within DataHandler + */ + public function isReferenceField($conf): bool + { + if (!isset($conf['type'])) { + return false; + } + return ($conf['type'] === 'group') || (($conf['type'] === 'select' || $conf['type'] === 'category') && !empty($conf['foreign_table'])); + } + + /** + * Returns the subtype as a string of a relation (inline / file) field. + * If it's not a relation field at all, it returns FALSE. + * + * @param array $conf Config array for TCA/columns field + * @return string|bool string Inline subtype (field|mm|list), boolean: FALSE + */ + protected function getRelationFieldType($conf): bool|string + { + if ( + empty($conf['foreign_table']) + || !in_array($conf['type'] ?? '', ['inline', 'file'], true) + || ($conf['type'] === 'file' && !($conf['foreign_field'] ?? false)) + ) { + return false; + } + if ($conf['foreign_field'] ?? false) { + // The reference to the parent is stored in a pointer field in the child record + return 'field'; + } + if ($conf['MM'] ?? false) { + // Regular MM intermediate table is used to store data + return 'mm'; + } + // An item list (separated by comma) is stored (like select type is doing) + return 'list'; + } + + /** + * Get modified header for a copied record + * + * @param string $table Table name + * @param int $pid PID value in which other records to test might be + * @param string $field Field name to get header value for. + * @param string $value Current field value + * @param int $count Counter (number of recursions) + * @param string $prevTitle Previous title we checked for (in previous recursion) + * @return string The field value, possibly appended with a "copy label + * @internal should only be used from within DataHandler + */ + public function getCopyHeader($table, $pid, $field, $value, $count, $prevTitle = '') + { + // Set title value to check for: + $checkTitle = $value; + $labelToAppend = $this->prependLabel($table); + if ($count > 0) { + $checkTitle = $value . rtrim(' ' . sprintf($labelToAppend, $count)); + } + // Do check: + if ($prevTitle != $checkTitle || $count < 100) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $rowCount = $queryBuilder + ->count('uid') + ->from($table) + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($checkTitle)) + ) + ->executeQuery() + ->fetchOne(); + // Only call getCopyHeader() again, if $labelToAppend is actually filled, otherwise we execute + // superfluous calls to the DB + if ($rowCount && $labelToAppend !== '') { + return $this->getCopyHeader($table, $pid, $field, $value, $count + 1, $checkTitle); + } + } + // Default is to just return the current input title if no other was returned before: + return $checkTitle; + } + + /** + * Return "copy" label for a table. Although the name is "prepend" it actually APPENDs the label (after ...) + * + * @param string $table Table name + * @return string Label to append, containing "%s" for the number + */ + protected function prependLabel($table): string + { + if ($this->tcaSchemaFactory->has($table)) { + return $this->getLanguageService()->sL($this->tcaSchemaFactory->get($table)->getCapability(TcaSchemaCapability::PrependLabelTextAtCopy)->getValue()); + } + return ''; + } + + /** + * Get the final pid based on $table and $pid ($destPid type... pos/neg) + * + * @param string $table Table name + * @param int $pid "Destination pid": If the value is >= 0 it's just returned directly (through (int)though) but if the + * value is <0 then the method looks up the record with the uid equal to abs($pid) (positive number) and + * returns the PID of that record! The idea is that negative numbers point to the record AFTER WHICH the + * position is supposed to be! + */ + protected function resolvePid($table, $pid): int + { + if ($pid >= 0) { + return (int)$pid; + } + return (int)(BackendUtility::getRecord($table, abs((int)$pid), 'pid', '', false)['pid']); + } + + /** + * Removes the prependAtCopy prefix on values + * + * @param string $table Table name + * @param string $value The value to fix + * @return string Clean name + * @internal should only be used from within DataHandler + */ + public function clearPrefixFromValue($table, $value) + { + $regex = '/\s' . sprintf(preg_quote($this->prependLabel($table)), '[0-9]*') . '$/'; + return @preg_replace($regex, '', $value); + } + + /** + * Determine if a record was copied or if a record is the result of a copy action. + * + * @param string $table The tablename of the record + * @param int $uid The uid of the record + * @return bool Returns TRUE if the record is copied or is the result of a copy action + * @internal should only be used from within DataHandler + */ + public function isRecordCopied($table, $uid): bool + { + // If the record was copied: + if (isset($this->copyMappingArray[$table][$uid])) { + return true; + } + if (isset($this->copyMappingArray[$table]) && in_array($uid, array_values($this->copyMappingArray[$table]))) { + return true; + } + return false; + } + + /** + * Clearing the cache based on a page being updated + * If the $table is 'pages' then cache is cleared for all pages on the same level (and subsequent?) + * Else just clear the cache for the parent page of the record. + * + * @param string $table Table name of record that was just updated. + * @param int $uid UID of updated / inserted record + * @param int $pid REAL PID of page of a deleted/moved record to get TSconfig in ClearCache. + * @internal This method is not meant to be called directly but only from the core itself or from hooks + */ + public function registerRecordIdForPageCacheClearing($table, $uid, $pid = null): void + { + if (!is_array(static::$recordsToClearCacheFor[$table] ?? false)) { + static::$recordsToClearCacheFor[$table] = []; + } + static::$recordsToClearCacheFor[$table][] = (int)$uid; + if ($pid !== null) { + if (!isset(static::$recordPidsForDeletedRecords[$table]) || !is_array(static::$recordPidsForDeletedRecords[$table])) { + static::$recordPidsForDeletedRecords[$table] = []; + } + static::$recordPidsForDeletedRecords[$table][$uid][] = (int)$pid; + } + } + + /** + * Do the actual clear cache + */ + protected function processClearCacheQueue(): void + { + $tagsToClear = []; + $clearCacheCommands = []; + + foreach (static::$recordsToClearCacheFor as $table => $uids) { + foreach (array_unique($uids) as $uid) { + if ($uid <= 0 || !$this->tcaSchemaFactory->has($table)) { + return; + } + // For move commands we may get more then 1 parent. + $pageUids = $this->getOriginalParentOfRecord($table, $uid); + foreach ($pageUids as $originalParent) { + [$tagsToClearFromPrepare, $clearCacheCommandsFromPrepare] + = $this->prepareCacheFlush($table, $uid, $originalParent); + $tagsToClear = array_merge($tagsToClear, $tagsToClearFromPrepare); + $clearCacheCommands = array_merge($clearCacheCommands, $clearCacheCommandsFromPrepare); + } + } + } + + $this->cacheManager->flushCachesInGroupByTags('pages', array_keys($tagsToClear)); + + // Filter duplicate cache commands from cacheQueue + $clearCacheCommands = array_unique($clearCacheCommands); + // Execute collected clear cache commands from page TSconfig + foreach ($clearCacheCommands as $command) { + $this->clear_cacheCmd($command); + } + + // Reset the cache clearing array + static::$recordsToClearCacheFor = []; + + // Reset the original pid array + static::$recordPidsForDeletedRecords = []; + } + + /** + * Prepare the cache clearing + * + * @param string $table Table name of record that needs to be cleared + * @param int $uid UID of record for which the cache needs to be cleared + * @param int $pid Original pid of the page of the record which the cache needs to be cleared + * @return array Array with tagsToClear and clearCacheCommands + */ + protected function prepareCacheFlush(string $table, int $uid, $pid): array + { + $tagsToClear = []; + $clearCacheCommands = []; + $pageUid = 0; + $clearCacheEnabled = true; + // Get Page TSconfig relevant: + $TSConfig = BackendUtility::getPagesTSconfig($pid)['TCEMAIN.'] ?? []; + + if (!empty($TSConfig['clearCache_disable'])) { + $clearCacheEnabled = false; + } + + if ($clearCacheEnabled && $this->BE_USER->workspace !== 0 && $this->tcaSchemaFactory->has($table) && $this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Workspace)) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $count = $queryBuilder + ->count('uid') + ->from($table) + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('t3ver_oid', 0) + ) + ->executeQuery() + ->fetchOne(); + if ($count === 0) { + $clearCacheEnabled = false; + } + } + + if ($clearCacheEnabled) { + $pageIdsThatNeedCacheFlush = []; + if ($table === 'pages') { + // If table is "pages", Find out if the record is a localized one and get the default page + $pageUid = $this->getDefaultLanguagePageId($uid); + + // Builds list of pages on the SAME level as this page (siblings) + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $siblings = $queryBuilder + ->select('A.pid AS pid', 'B.uid AS uid') + ->from('pages', 'A') + ->from('pages', 'B') + ->where( + $queryBuilder->expr()->eq('A.uid', $queryBuilder->createNamedParameter($pageUid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('B.pid', $queryBuilder->quoteIdentifier('A.pid')), + $queryBuilder->expr()->gte('A.pid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->executeQuery(); + + $parentPageId = 0; + while ($row_tmp = $siblings->fetchAssociative()) { + $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['uid']; + $parentPageId = (int)$row_tmp['pid']; + // Add children as well: + if ($TSConfig['clearCache_pageSiblingChildren'] ?? false) { + $siblingChildrenQuery = $this->connectionPool->getQueryBuilderForTable('pages'); + $siblingChildrenQuery->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $siblingChildren = $siblingChildrenQuery + ->select('uid') + ->from('pages') + ->where($siblingChildrenQuery->expr()->eq( + 'pid', + $siblingChildrenQuery->createNamedParameter($row_tmp['uid'], Connection::PARAM_INT) + )) + ->executeQuery(); + while ($row_tmp2 = $siblingChildren->fetchAssociative()) { + $pageIdsThatNeedCacheFlush[] = (int)$row_tmp2['uid']; + } + } + } + // Finally, add the parent page as well when clearing a specific page + if ($parentPageId > 0) { + $pageIdsThatNeedCacheFlush[] = $parentPageId; + } + // Add grandparent as well if configured + if ($TSConfig['clearCache_pageGrandParent'] ?? false) { + $parentQuery = $this->connectionPool->getQueryBuilderForTable('pages'); + $parentQuery->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $row_tmp = $parentQuery + ->select('pid') + ->from('pages') + ->where($parentQuery->expr()->eq( + 'uid', + $parentQuery->createNamedParameter($parentPageId, Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchAssociative(); + if (!empty($row_tmp)) { + $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['pid']; + } + } + } else { + // For other tables than "pages", delete cache for the records "parent page". + $pageUid = (int)(BackendUtility::getRecord($table, $uid, 'pid', '', false)['pid'] ?? 0); + if ($pageUid > 0) { + $pageIdsThatNeedCacheFlush[] = $pageUid; + if ($TSConfig['clearCache_pageGrandParent'] ?? false) { + // Add the parent page as well + $grandPageUid = (int)(BackendUtility::getRecord('pages', $pageUid, 'pid')['pid'] ?? 0); + if ($grandPageUid > 0) { + $pageIdsThatNeedCacheFlush[] = $grandPageUid; + } + } + } + } + // Call pre-processing function for clearing of cache for page ids: + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) { + $_params = ['pageIdArray' => &$pageIdsThatNeedCacheFlush, 'table' => $table, 'uid' => $uid, 'functionID' => 'clear_cache()']; + // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array! + GeneralUtility::callUserFunction($funcName, $_params, $this); + } + // Delete cache for selected pages: + foreach ($pageIdsThatNeedCacheFlush as $pageId) { + $tagsToClear['pageId_' . $pageId] = true; + } + // Queue delete cache for current table and record + $tagsToClear[$table] = true; + $tagsToClear[$table . '_' . $uid] = true; + } + // Clear cache for pages entered in TSconfig: + if (!empty($TSConfig['clearCacheCmd'])) { + $commands = GeneralUtility::trimExplode(',', $TSConfig['clearCacheCmd'], true); + $clearCacheCommands = array_unique($commands); + } + // Call post-processing function for clear-cache: + $_params = ['table' => $table, 'uid' => $uid, 'uid_page' => $pageUid, 'TSConfig' => $TSConfig, 'tags' => &$tagsToClear, 'clearCacheCommands' => &$clearCacheCommands, 'clearCacheEnabled' => $clearCacheEnabled]; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) { + GeneralUtility::callUserFunction($_funcRef, $_params, $this); + } + return [ + $tagsToClear, + $clearCacheCommands, + ]; + } + + /** + * Clears the cache based on the command $cacheCmd. + * + * $cacheCmd='pages' + * Clears cache for all pages and page-based caches inside the cache manager. + * Requires admin-flag to be set for BE_USER. + * + * $cacheCmd='all' + * Clears all cache_tables. This is necessary if templates are updated. + * Requires admin-flag to be set for BE_USER. + * + * The following cache_* are intentionally not cleared by 'all' + * + * - all caches inside the cache manager that are inside the group "system" + * - they are only needed to build up the core system and templates. + * If the group of system caches needs to be deleted explicitly, use + * flushCachesInGroup('system') of CacheManager directly. + * + * $cacheCmd=[integer] + * Clears cache for the page pointed to by $cacheCmd (an integer). + * + * $cacheCmd='cacheTag:[string]' + * Flush page cache by given tag + * + * Can call a list of post processing functions as defined in + * $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] + * (numeric array with values being the function references, called by + * GeneralUtility::callUserFunction()). + * + * + * @param int|string $cacheCmd The cache command, see above description + */ + public function clear_cacheCmd($cacheCmd): void + { + $this->logEntryRepository->writeLogEntryForBackendUser( + $this->BE_USER, + SystemLogType::CACHE, + SystemLogCacheAction::CLEAR, + SystemLogErrorClassification::MESSAGE, + 'User {username} has cleared the cache (cacheCmd={command})', + ['username' => $this->BE_USER->user['username'], 'command' => $cacheCmd] + ); + $userTsConfig = $this->BE_USER->getTSConfig(); + switch (strtolower($cacheCmd)) { + case 'pages': + if ($this->BE_USER->isAdmin() || ($userTsConfig['options.']['clearCache.']['pages'] ?? false)) { + $this->cacheManager->flushCachesInGroup('pages'); + } + break; + case 'all': + // allow to clear all caches if the TS config option is enabled or the option is not explicitly + // disabled for admins (which could clear all caches by default). The latter option is useful + // for big production sites where it should be possible to restrict the cache clearing for some admins. + if (($userTsConfig['options.']['clearCache.']['all'] ?? false) + || ($this->BE_USER->isAdmin() && (bool)($userTsConfig['options.']['clearCache.']['all'] ?? true)) + ) { + $this->cacheManager->flushCaches(); + + // Delete Opcode Cache + $this->opcodeCacheService->clearAllActive(); + + // Delete DI Cache only on development context + if (Environment::getContext()->isDevelopment()) { + $container = GeneralUtility::makeInstance(ContainerInterface::class); + $container->get('cache.di')->getBackend()->forceFlush(); + } + } + break; + } + + $tagsToFlush = []; + // Clear cache for a page ID! + if (MathUtility::canBeInterpretedAsInteger($cacheCmd)) { + $list_cache = [$cacheCmd]; + // Call pre-processing function for clearing of cache for page ids: + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) { + $_params = ['pageIdArray' => &$list_cache, 'cacheCmd' => $cacheCmd, 'functionID' => 'clear_cacheCmd()']; + // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array! + GeneralUtility::callUserFunction($funcName, $_params, $this); + } + // Delete cache for selected pages: + if (is_array($list_cache)) { + foreach ($list_cache as $pageId) { + $tagsToFlush[] = 'pageId_' . (int)$pageId; + } + } + } + // flush cache by tag + if (str_starts_with(strtolower($cacheCmd), 'cachetag:')) { + $cacheTag = substr($cacheCmd, 9); + $tagsToFlush[] = $cacheTag; + } + // process caching framework operations + if (!empty($tagsToFlush)) { + $this->cacheManager->flushCachesInGroupByTags('pages', $tagsToFlush); + } + + // Call post-processing function for clear-cache: + $_params = ['cacheCmd' => strtolower($cacheCmd), 'tags' => $tagsToFlush]; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) { + GeneralUtility::callUserFunction($_funcRef, $_params, $this); + } + } + + /** + * Logging actions from DataHandler + * + * @param string $table Table name the log entry is concerned with. Blank if NA + * @param int $recuid Record UID. Zero if NA + * @param int $action Action number: 0=No category, 1=new record, 2=update record, 3= delete record, 4= move record, 5= Check/evaluate + * @param null $_ unused + * @param int $error The severity: 0 = message, 1 = error, 2 = System Error, 3 = security notice (admin), 4 warning + * @param string $details Default error message in english + * @param null $__ unused + * @param array $data Array with special information that may go into $details by '%s' marks / sprintf() when the log is shown + * @param int $event_pid The page_uid (pid) where the event occurred. Used to select log-content for specific pages. + * @return int Log entry UID (0 if no log entry was written or logging is disabled) + * @internal should only be used from within TYPO3 Core + */ + public function log($table, $recuid, $action, $_, $error, $details, $__ = null, array $data = [], $event_pid = -1) + { + if (!$this->enableLogging) { + return 0; + } + if ($error > 0) { + $detailMessage = $details; + $detailMessage = $this->formatLogDetails($detailMessage, $data); + $this->errorLog[] = '[' . SystemLogType::DB . '.' . $action . ']: ' . $detailMessage; + } + return $this->logEntryRepository->writeLogEntryForBackendUser( + $this->BE_USER, + SystemLogType::DB, + (int)$action, + (int)$error, + (string)$details, + $data, + (string)$table, + abs((int)$recuid), + (int)$event_pid + ); + } + + /** + * Print log error messages from the operations of this script instance and return a list of the erroneous records + * + * @internal should only be used from within TYPO3 Core + * + * @return non-empty-string[] + */ + public function printLogErrorMessages(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_log'); + $queryBuilder->getRestrictions()->removeAll(); + $result = $queryBuilder + ->select('*') + ->from('sys_log') + ->where( + $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::DB, Connection::PARAM_INT)), + $queryBuilder->expr()->eq( + 'userid', + $queryBuilder->createNamedParameter($this->BE_USER->user['uid'], Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'tstamp', + $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT) + ), + $queryBuilder->expr()->neq('error', $queryBuilder->createNamedParameter(SystemLogErrorClassification::MESSAGE, Connection::PARAM_INT)) + ) + ->executeQuery(); + + $affectedRecords = []; + while ($row = $result->fetchAssociative()) { + $affectedRecords[] = $row['tablename'] . '.' . $row['recuid']; + + $message = $this->formatLogDetails($row['details'], $row['log_data'] ?? ''); + $message = $row['error'] . ': ' . $message; + $message = $this->getLanguageService()->translate('error_during_saving', 'core.data_handler', [$message]) ?? $message; + $flashMessage = new FlashMessage($message, '', $row['error'] === SystemLogErrorClassification::WARNING ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::ERROR, true); + $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + return $affectedRecords; + } + + /** + * Find out if the record is a localization. If so, get the uid of the default language page. + * Always returns the uid of the workspace live record: No explicit workspace overlay is applied. + * + * @param int $pageId Page UID, can be the default page record, or a page translation record ID + * @return int UID of the default page record in live workspace + */ + protected function getDefaultLanguagePageId(int $pageId): int + { + $languageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language); + $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName(); + $row = BackendUtility::getRecord('pages', $pageId, '*', '', false); + $localizationParent = (int)($row[$localizationParentFieldName] ?? 0); + if ($localizationParent > 0) { + return $localizationParent; + } + return $pageId; + } + + /** + * Preprocesses field array based on field type. Some fields must be adjusted + * before going to database. This is done on the copy of the field array because + * original values are used in remap action later. + * + * @param string $table Table name + * @param array $fieldArray Field array to check + * @return array Updated field array + * @internal should only be used from within TYPO3 Core + */ + public function insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray) + { + $result = $fieldArray; + $schema = $this->tcaSchemaFactory->get($table); + foreach ($fieldArray as $field => $value) { + if (MathUtility::canBeInterpretedAsInteger($value) || !$schema->hasField($field)) { + continue; + } + $fieldType = $schema->getField($field); + if ($fieldType->isType(TableColumnType::INLINE, TableColumnType::FILE) + && ($fieldType->getConfiguration()['foreign_field'] ?? false) + ) { + $result[$field] = count(GeneralUtility::trimExplode(',', $value, true)); + } + } + return $result; + } + + /** + * Gets the automatically versionized id of a record. + * + * @param string $table Name of the table + * @param int $id Uid of the record + * @internal should only be used from within TYPO3 Core + */ + public function getAutoVersionId($table, $id): ?int + { + $result = null; + if (isset($this->autoVersionIdMap[$table][$id])) { + $result = (int)trim($this->autoVersionIdMap[$table][$id]); + } + return $result; + } + + /** + * Overlays the automatically versionized id of a record. + * + * @param string $table Name of the table + * @param int $id Uid of the record + * @return int + */ + protected function overlayAutoVersionId($table, $id) + { + $autoVersionId = $this->getAutoVersionId($table, $id); + if ($autoVersionId !== null) { + $id = $autoVersionId; + } + return $id; + } + + /** + * Resolves versioned records for the current workspace scope. + * Delete placeholders are substituted and removed. + * + * @param string $tableName Name of the table to be processed + * @param string $fieldNames List of the field names to be fetched + * @param string $sortingField Name of the sorting field to be used + * @param array $liveIds Flat array of (live) record ids + * @return array + */ + protected function resolveVersionedRecords($tableName, $fieldNames, $sortingField, array $liveIds) + { + $connection = $this->connectionPool->getConnectionForTable($tableName); + $sortingStatement = !empty($sortingField) + ? [$connection->quoteIdentifier($sortingField)] + : null; + $resolver = GeneralUtility::makeInstance( + PlainDataResolver::class, + $tableName, + $liveIds, + $sortingStatement + ); + + $resolver->setWorkspaceId($this->BE_USER->workspace); + $resolver->setKeepDeletePlaceholder(false); + $resolver->setKeepMovePlaceholder(false); + $resolver->setKeepLiveIds(true); + $recordIds = $resolver->get(); + + $records = []; + foreach ($recordIds as $recordId) { + $records[$recordId] = BackendUtility::getRecord($tableName, $recordId, $fieldNames); + } + + return $records; + } + + /** + * Evaluates if auto creation of a version of a record is allowed. + * Auto-creation of version: In offline workspace, test if versioning is + * enabled and look for workspace version of input record. + * If there is no versionized record found we will create one and save to that. + * + * @param string $table Table of the record + * @param int $id UID of record + * @param int|null $recpid PID of record + * @return bool TRUE if ok. + */ + protected function workspaceAllowAutoCreation(string $table, $id, $recpid): bool + { + // No version can be created in live workspace + if ($this->BE_USER->workspace === 0) { + return false; + } + // No versioning support for this table, so no version can be created + if (!$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) { + return false; + } + if ($recpid < 0) { + return false; + } + // There must be no existing version of this record in workspace + if (BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid')) { + return false; + } + return true; + } + + /** + * Evaluates if a user is allowed to edit the offline version + * + * @param string $table Table of record + * @param array $record array where fields are at least: pid, t3ver_wsid, t3ver_stage (if versioningWS is set) + * @return string String error code, telling the failure state. FALSE=All ok + * @internal this method will be moved to EXT:workspaces + */ + public function workspaceCannotEditOfflineVersion(string $table, array $record) + { + $versionState = VersionState::tryFrom($record['t3ver_state'] ?? 0); + if ($versionState === VersionState::NEW_PLACEHOLDER || (int)$record['t3ver_oid'] > 0) { + return $this->workspaceCannotEditRecord($table, $record); + } + return 'Not an offline version'; + } + + /** + * Checking if editing of an existing record is allowed in current workspace. + * + * @return string|false Error string, else false = ok + */ + protected function workspaceCannotEditRecord(string $table, array $record): string|false + { + if ($this->BE_USER->workspace === 0) { + // Early skip if user is not in workspace + return false; + } + if ($this->tcaSchemaFactory->get($table)->isWorkspaceAware() && (int)($record['t3ver_wsid'] ?? 0) > 0) { + // This is a workspace record + if ((int)$record['t3ver_wsid'] !== $this->BE_USER->workspace) { + // Workspaces of record and current user do not match + return 'Workspace ID of record does not match current user workspace'; + } + // Is user allowed to use the edit stage within the workspace? + return $this->BE_USER->workspaceCheckStageForCurrent(0) ? false : 'User missing workspace editing access'; + } + if ($this->BE_USER->workspaceAllowsLiveEditingInTable($table)) { + // Live records for this table are ok in current workspace + return false; + } + return 'Editing live record is not allowed'; + } + + /** + * Gets the outer most instance of \TYPO3\CMS\Core\DataHandling\DataHandler + * Since \TYPO3\CMS\Core\DataHandling\DataHandler can create nested objects of itself, + * this method helps to determine the first (= outer most) one. + * + * @return DataHandler + */ + protected function getOuterMostInstance() + { + if (!isset($this->outerMostInstance)) { + $stack = array_reverse(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS)); + foreach ($stack as $stackItem) { + if (isset($stackItem['object']) && $stackItem['object'] instanceof self) { + $this->outerMostInstance = $stackItem['object']; + break; + } + } + } + return $this->outerMostInstance; + } + + /** + * Determines whether this object is the outermost instance of itself + * Since DataHandler can create nested objects of itself, + * this method helps to determine the first (= outermost) one. + */ + public function isOuterMostInstance(): bool + { + return $this->getOuterMostInstance() === $this; + } + + /** + * Determines nested element calls. + * + * @param string $table Name of the table + * @param int $id Uid of the record + * @param string $identifier Name of the action to be checked + * @return bool + */ + protected function isNestedElementCallRegistered($table, $id, $identifier): bool + { + // @todo: Stop abusing runtime cache as singleton DTO, needs explicit modeling. + $nestedElementCalls = (array)$this->runtimeCache->get(self::CACHE_IDENTIFIER_NESTED_ELEMENT_CALLS_PREFIX); + return isset($nestedElementCalls[$identifier][$table][$id]); + } + + /** + * Registers nested elements calls. + * This is used to track nested calls (e.g. for following m:n relations). + * + * @param string $table Name of the table + * @param int $id Uid of the record + * @param string $identifier Name of the action to be tracked + */ + protected function registerNestedElementCall($table, $id, $identifier): void + { + $nestedElementCalls = (array)$this->runtimeCache->get(self::CACHE_IDENTIFIER_NESTED_ELEMENT_CALLS_PREFIX); + $nestedElementCalls[$identifier][$table][$id] = true; + $this->runtimeCache->set(self::CACHE_IDENTIFIER_NESTED_ELEMENT_CALLS_PREFIX, $nestedElementCalls); + } + + /** + * Resets the nested element calls. + */ + protected function resetNestedElementCalls(): void + { + $this->runtimeCache->remove(self::CACHE_IDENTIFIER_NESTED_ELEMENT_CALLS_PREFIX); + } + + /** + * Determines whether an element was registered to be deleted in the registry. + * + * @param string $table Name of the table + * @param int $id Uid of the record + * @return bool + * @see registerElementsToBeDeleted + * @see resetElementsToBeDeleted + * @see copyRecord_raw + * @see versionizeRecord + */ + protected function isElementToBeDeleted($table, $id) + { + // @todo: Stop abusing runtime cache as singleton DTO, needs explicit modeling. + $elementsToBeDeleted = (array)$this->runtimeCache->get(self::CACHE_IDENTIFIER_ELEMENTS_TO_BE_DELETED); + return isset($elementsToBeDeleted[$table][$id]); + } + + /** + * Registers elements to be deleted in the registry. + * + * @see process_datamap + */ + protected function registerElementsToBeDeleted(): void + { + $elementsToBeDeleted = (array)$this->runtimeCache->get(self::CACHE_IDENTIFIER_ELEMENTS_TO_BE_DELETED); + $this->runtimeCache->set(self::CACHE_IDENTIFIER_ELEMENTS_TO_BE_DELETED, array_merge($elementsToBeDeleted, $this->getCommandMapElements('delete'))); + } + + /** + * Resets the elements to be deleted in the registry. + * + * @see process_datamap + */ + protected function resetElementsToBeDeleted(): void + { + $this->runtimeCache->remove(self::CACHE_IDENTIFIER_ELEMENTS_TO_BE_DELETED); + } + + /** + * Unsets elements (e.g. of the data map) that shall be deleted. + * This avoids to modify records that will be deleted later on. + * + * @param array $elements Elements to be modified + */ + protected function unsetElementsToBeDeleted(array $elements): array + { + $elements = ArrayUtility::arrayDiffKeyRecursive($elements, $this->getCommandMapElements('delete')); + foreach ($elements as $key => $value) { + if (empty($value)) { + unset($elements[$key]); + } + } + return $elements; + } + + /** + * Gets elements of the command map that match a particular command. + * + * @param string $needle The command to be matched + */ + protected function getCommandMapElements(string $needle): array + { + $elements = []; + foreach ($this->cmdmap as $tableName => $idArray) { + foreach ($idArray as $id => $commandArray) { + foreach ($commandArray as $command => $value) { + if ($value && $command == $needle) { + $elements[$tableName][$id] = true; + } + } + } + } + return $elements; + } + + /** + * Controls active elements and sets NULL values if not active. + * Datamap is modified accordant to submitted control values. + */ + protected function controlActiveElements(): void + { + if (!empty($this->control['active'])) { + $this->setNullValues( + $this->control['active'], + $this->datamap + ); + } + } + + /** + * Sets NULL values in haystack array. + * The general behaviour in the user interface is to enable/activate fields. + * Thus, this method uses NULL as value to be stored if a field is not active. + * + * @param array $active hierarchical array with active elements + * @param array $haystack hierarchical array with haystack to be modified + */ + protected function setNullValues(array $active, array &$haystack): void + { + foreach ($active as $key => $value) { + // Nested data is processes recursively + if (is_array($value)) { + $this->setNullValues( + $value, + $haystack[$key] + ); + } elseif ($value == 0) { + // Field has not been activated in the user interface, + // thus a NULL value shall be stored in the database + $haystack[$key] = null; + } + } + } + + /** + * @deprecated since TYPO3 v15.0, will be removed in TYPO3 v16.0. Pass the CorrelationId to DataHandler->start() instead. + */ + public function setCorrelationId(CorrelationId $correlationId): void + { + trigger_error( + 'DataHandler->setCorrelationId() is deprecated since TYPO3 v15.0 and will be removed in TYPO3 v16.0. Pass the CorrelationId to DataHandler->start() instead.', + E_USER_DEPRECATED + ); + $this->correlationId = $correlationId; + } + + public function getCorrelationId(): ?CorrelationId + { + return $this->correlationId; + } + + /** + * Entry point to post process a database insert. Currently bails early unless a UID has been forced + * and the database platform is not MySQL. + */ + protected function postProcessDatabaseInsert(Connection $connection, string $tableName, int $suggestedUid): int + { + if ($suggestedUid !== 0 && $connection->getDatabasePlatform() instanceof PostgreSqlPlatform) { + $this->postProcessPostgresqlInsert($connection, $tableName); + // The last inserted id on postgresql is actually the last value generated by the sequence. + // On a forced UID insert this might not be the actual value or the sequence might not even + // have generated a value yet. + // Return the actual ID we forced on insert as a surrogate. + return $suggestedUid; + } + $id = $connection->lastInsertId(); + return (int)$id; + } + + /** + * PostgreSQL works with sequences for auto increment columns. A sequence is not updated when a value is + * written to such a column. To avoid clashes when the sequence returns an existing ID this helper will + * update the sequence to the current max value of the column. + */ + protected function postProcessPostgresqlInsert(Connection $connection, string $tableName): void + { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $row = $queryBuilder->select('PGT.schemaname', 'S.relname', 'C.attname', 'T.relname AS tablename') + ->from('pg_class', 'S') + ->from('pg_depend', 'D') + ->from('pg_class', 'T') + ->from('pg_attribute', 'C') + ->from('pg_tables', 'PGT') + ->where( + $queryBuilder->expr()->eq('S.relkind', $queryBuilder->quote('S')), + $queryBuilder->expr()->eq('S.oid', $queryBuilder->quoteIdentifier('D.objid')), + $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('T.oid')), + $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('C.attrelid')), + $queryBuilder->expr()->eq('D.refobjsubid', $queryBuilder->quoteIdentifier('C.attnum')), + $queryBuilder->expr()->eq('T.relname', $queryBuilder->quoteIdentifier('PGT.tablename')), + $queryBuilder->expr()->eq('PGT.tablename', $queryBuilder->quote($tableName)) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + if ($row !== false) { + $connection->executeStatement( + sprintf( + 'SELECT SETVAL(%s, COALESCE(MAX(%s), 0)+1, FALSE) FROM %s', + $connection->quote($row['schemaname'] . '.' . $row['relname']), + $connection->quoteIdentifier($row['attname']), + $connection->quoteIdentifier($row['schemaname'] . '.' . $row['tablename']) + ) + ); + } + } + + protected function createRelationHandlerInstance(): RelationHandler + { + $isWorkspacesLoaded = ExtensionManagementUtility::isLoaded('workspaces'); + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->setWorkspaceId($this->BE_USER->workspace); + $relationHandler->setUseLiveReferenceIds($isWorkspacesLoaded); + $relationHandler->setUseLiveParentIds($isWorkspacesLoaded); + $relationHandler->setReferenceIndexUpdater($this->referenceIndexUpdater); + return $relationHandler; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } + + /** + * @internal should only be used from within TYPO3 Core + */ + public function getHistoryRecords(): array + { + return $this->historyRecords; + } +} diff --git a/Classes/DataHandling/DataHandlerCheckModifyAccessListHookInterface.php b/Classes/DataHandling/DataHandlerCheckModifyAccessListHookInterface.php new file mode 100644 index 0000000..4cdaed3 --- /dev/null +++ b/Classes/DataHandling/DataHandlerCheckModifyAccessListHookInterface.php @@ -0,0 +1,31 @@ +linkParts; + } + + public function getContent(): string + { + return $this->content; + } + + public function getElements(): array + { + return $this->elements; + } + + public function getIdx(): int + { + return $this->idx; + } + + public function getTokenId(): string + { + return $this->tokenId; + } + + public function setLinkParts(array $linkParts): void + { + $this->linkParts = $linkParts; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function setElements(array $elements): void + { + $this->elements = $elements; + } + + public function addElements(array $elements) + { + $this->elements = array_replace_recursive($this->elements, $elements); + $this->isResolved = true; + } + + public function isResolved(): bool + { + return $this->isResolved; + } +} diff --git a/Classes/DataHandling/Event/BeforeRemoveNonCopyableFieldsEvent.php b/Classes/DataHandling/Event/BeforeRemoveNonCopyableFieldsEvent.php new file mode 100644 index 0000000..2839bce --- /dev/null +++ b/Classes/DataHandling/Event/BeforeRemoveNonCopyableFieldsEvent.php @@ -0,0 +1,61 @@ +table; + } + + public function getCallingOperation(): string + { + return $this->callingOperation; + } + + public function getRow(): array + { + return $this->row; + } + + public function getNonCopyableFields(): array + { + return $this->nonCopyableFields; + } + + public function setNonCopyableFields(array $nonCopyableFields): void + { + $this->nonCopyableFields = $nonCopyableFields; + } +} diff --git a/Classes/DataHandling/Event/IsTableExcludedFromReferenceIndexEvent.php b/Classes/DataHandling/Event/IsTableExcludedFromReferenceIndexEvent.php new file mode 100644 index 0000000..5cbcd41 --- /dev/null +++ b/Classes/DataHandling/Event/IsTableExcludedFromReferenceIndexEvent.php @@ -0,0 +1,52 @@ +table; + } + + public function markAsExcluded() + { + $this->isExcluded = true; + } + + public function isTableExcluded(): bool + { + return $this->isExcluded; + } + + public function isPropagationStopped(): bool + { + return $this->isTableExcluded(); + } +} diff --git a/Classes/DataHandling/History/RecordHistoryStore.php b/Classes/DataHandling/History/RecordHistoryStore.php new file mode 100644 index 0000000..58e19ec --- /dev/null +++ b/Classes/DataHandling/History/RecordHistoryStore.php @@ -0,0 +1,249 @@ +userType = $userType; + $this->userId = $userId; + $this->originalUserId = $originalUserId; + $this->tstamp = $tstamp ?: $GLOBALS['EXEC_TIME']; + $this->workspaceId = $workspaceId; + } + + public function addRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string + { + if ($this->workspaceId) { + $payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore + } + $data = [ + 'actiontype' => self::ACTION_ADD, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'history_data' => json_encode($payload), + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + public function modifyRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string + { + if ($this->workspaceId) { + $payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore + } + $data = [ + 'actiontype' => self::ACTION_MODIFY, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'history_data' => json_encode($payload), + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + /** + * @param CorrelationId|null $correlationId + */ + public function deleteRecord(string $table, int $uid, ?CorrelationId $correlationId = null): string + { + $data = [ + 'actiontype' => self::ACTION_DELETE, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + /** + * @param CorrelationId|null $correlationId + */ + public function undeleteRecord(string $table, int $uid, ?CorrelationId $correlationId = null): string + { + $data = [ + 'actiontype' => self::ACTION_UNDELETE, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + /** + * @param CorrelationId|null $correlationId + */ + public function moveRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string + { + if ($this->workspaceId) { + $payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore + } + $data = [ + 'actiontype' => self::ACTION_MOVE, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'history_data' => json_encode($payload), + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + public function changeStageForRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string + { + $data = [ + 'actiontype' => self::ACTION_STAGECHANGE, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'history_data' => json_encode($payload), + 'workspace' => $this->workspaceId, + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + public function publishRecord(string $table, int $uid, int $versionedId, array $payload, ?CorrelationId $correlationId = null): string + { + $this->migrateWorkspaceHistory($table, $versionedId, $uid); + $data = [ + 'actiontype' => self::ACTION_PUBLISH, + 'usertype' => $this->userType, + 'userid' => $this->userId, + 'originaluserid' => $this->originalUserId, + 'tablename' => $table, + 'recuid' => $uid, + 'tstamp' => $this->tstamp, + 'history_data' => json_encode($payload), + 'workspace' => 0, // Published to live workspace + 'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId), + ]; + $this->getDatabaseConnection()->insert('sys_history', $data); + return $this->getDatabaseConnection()->lastInsertId(); + } + + protected function migrateWorkspaceHistory(string $table, int $versionedId, int $liveUid): void + { + $connection = $this->getDatabaseConnection(); + + // Update all history entries from workspace record to point to live record + $connection->update( + 'sys_history', + [ + 'recuid' => $liveUid, + ], + ['tablename' => $table, 'recuid' => $versionedId] + ); + } + + protected function createCorrelationId(string $tableName, int $uid, ?CorrelationId $correlationId): CorrelationId + { + if ($correlationId !== null && $correlationId->getSubject() !== null) { + return $correlationId; + } + $subject = md5($tableName . ':' . $uid); + return $correlationId !== null ? $correlationId->withSubject($subject) : CorrelationId::forSubject($subject); + } + + protected function getDatabaseConnection(): Connection + { + return GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_history'); + } +} diff --git a/Classes/DataHandling/ItemProcessingService.php b/Classes/DataHandling/ItemProcessingService.php new file mode 100644 index 0000000..80c5f10 --- /dev/null +++ b/Classes/DataHandling/ItemProcessingService.php @@ -0,0 +1,198 @@ +table === 'pages' ? ($context->row['uid'] ?? $context->realPid) : ($context->row['pid'] ?? $context->realPid)); + $fieldTSconfig = $context->fieldTSconfig; + if ($fieldTSconfig === []) { + $TSconfig = BackendUtility::getPagesTSconfig($pageId); + $fieldTSconfig = $TSconfig['TCEFORM.'][$context->table . '.'][$context->field . '.'] ?? []; + } + + $site = $context->site; + // Legacy itemsProcFunc support - convert to array for backwards compatibility + $itemsArray = $items->toArray(); + $processorParameters = [ + // Function manipulates $items directly and return nothing + 'items' => &$itemsArray, + 'config' => $context->fieldConfiguration, + 'table' => $context->table, + 'row' => $context->row, + 'field' => $context->field, + 'effectivePid' => $context->realPid, + 'site' => $site, + ]; + $processorParameters = array_merge($processorParameters, $context->additionalParameters); + try { + // @todo: deprecate when the time is right + if (!empty($context->fieldConfiguration['itemsProcFunc'])) { + $processorParameters['TSconfig'] = $fieldTSconfig['itemsProcFunc.'] ?? null; + GeneralUtility::callUserFunction($context->fieldConfiguration['itemsProcFunc'], $processorParameters, $this); + // Recreate collection from potentially modified array + $items = SelectItemCollection::createFromArray($itemsArray, $context->fieldConfiguration['type']); + } + + // "itemsProcessors" is the more modern version of "itemsProcFunc", which will eventually be deprecated + $itemsProcessors = $context->fieldConfiguration['itemsProcessors'] ?? []; + ksort($itemsProcessors); + foreach ($itemsProcessors as $key => $itemsProcessorConfiguration) { + $tsConfig = $fieldTSconfig['itemsProcessors.'][$key . '.'] ?? []; + if (empty($itemsProcessorConfiguration['class'])) { + throw new ItemsProcessorExecutionFailedException( + $itemsArray, + sprintf( + 'Missing class for itemsProcessors %d, field %s, table %s', + $key, + $context->field, + $context->table + ), + 1761814167 + ); + } + $itemsProcessorObject = GeneralUtility::makeInstance($itemsProcessorConfiguration['class']); + if (!$itemsProcessorObject instanceof ItemsProcessorInterface) { + throw new ItemsProcessorExecutionFailedException( + $itemsArray, + sprintf( + 'Class %s must implement %s', + $itemsProcessorConfiguration['class'], + ItemsProcessorInterface::class + ), + 1761753898 + ); + } + $processorContext = new ItemsProcessorContext( + table: $context->table, + field: $context->field, + row: $context->row, + fieldConfiguration: $context->fieldConfiguration, + processorParameters: $itemsProcessorConfiguration['parameters'] ?? [], + realPid: $context->realPid, + site: $site, + fieldTSconfig: $tsConfig, + additionalParameters: $context->additionalParameters + ); + $items = $itemsProcessorObject->processItems($items, $processorContext); + } + } catch (\Exception $exception) { + // Catch anything here! + throw new ItemsProcessorExecutionFailedException($itemsArray, $exception->getMessage(), 1761588907, $exception); + } + return $items; + } + + /** + * Executes an itemsProcFunc or itemsProcessors if defined in TCA and returns the combined result + * (predefined + processed items) + * + * @param string $table + * @param int $realPid Record pid. This is the pid of the record. + * @param string $field + * @param array $row + * @param array $tcaConfig The TCA configuration of $field + * @param array $selectedItems The items already defined in the TCA configuration + * @return array The processed items (including the predefined items) + * @throws \TYPO3\CMS\Core\Exception + * @throws \TYPO3\CMS\Core\Schema\Exception\UndefinedFieldException + * @throws \TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException + */ + public function getProcessingItems($table, $realPid, $field, $row, $tcaConfig, $selectedItems) + { + try { + $itemsCollection = SelectItemCollection::createFromArray($selectedItems, $tcaConfig['type']); + $context = new ItemsProcessorContext( + table: $table, + field: $field, + row: $row, + fieldConfiguration: $tcaConfig, + processorParameters: [], + realPid: $realPid, + site: $this->resolveSite((int)($table === 'pages' ? ($row['uid'] ?? $realPid) : ($row['pid'] ?? $realPid))) + ); + $selectedItems = $this->processItems($itemsCollection, $context)->toArray(); + } catch (ItemsProcessorExecutionFailedException $exception) { + $fieldLabel = ''; + if ($this->tcaSchemaFactory->has($table)) { + $schema = $this->tcaSchemaFactory->get($table); + if ($schema->hasField($field)) { + $fieldLabel = $this->getLanguageService()->sL($schema->getField($field)->getLabel()); + } + } + if (!$fieldLabel) { + $fieldLabel = $field; + } + $message = sprintf( + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.items_proc_func_error'), + $fieldLabel, + $exception->getMessage() + ); + $flashMessage = new FlashMessage( + $message, + '', + ContextualFeedbackSeverity::ERROR, + true + ); + $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + return $selectedItems; + } + + public function resolveSite(int $pageId): SiteInterface + { + try { + return $this->siteFinder->getSiteByPageId($pageId); + } catch (SiteNotFoundException $e) { + return new NullSite(); + } + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/DataHandling/ItemsProcessorContext.php b/Classes/DataHandling/ItemsProcessorContext.php new file mode 100644 index 0000000..2c3f54c --- /dev/null +++ b/Classes/DataHandling/ItemsProcessorContext.php @@ -0,0 +1,39 @@ +language = (int)($suggestedValues[$item->getLanguageFieldName()] ?? $persistedValues[$item->getLanguageFieldName()] ?? 0); + $item->setParent($suggestedValues[$item->getParentFieldName()] ?? $persistedValues[$item->getParentFieldName()] ?? ''); + if ($item->getSourceFieldName() !== null) { + $item->setSource($suggestedValues[$item->getSourceFieldName()] ?? $persistedValues[$item->getSourceFieldName()] ?? ''); + } + // assign live-id of item if available + $versionSourceFieldName = $item->getVersionSourceFieldName(); + if ($versionSourceFieldName !== null && !empty($persistedValues[$versionSourceFieldName])) { + $item->liveId = $persistedValues[$versionSourceFieldName]; + } + return $item; + } + + public function __construct( + string $tableName, + string|int $id, + array $suggestedValues, + array $persistedValues, + array $configurationFieldNames + ) { + $this->tableName = $tableName; + $this->id = $id; + + $this->suggestedValues = $suggestedValues; + $this->persistedValues = $persistedValues; + $this->configurationFieldNames = $configurationFieldNames; + + $this->new = !MathUtility::canBeInterpretedAsInteger($id); + } + + /** + * Gets the current table name of this data-map item. + */ + public function getTableName(): string + { + return $this->tableName; + } + + /** + * Gets the id of this data-map item. + */ + public function getId(): string|int + { + return $this->id; + } + + public function getLiveId(): int|string + { + return $this->liveId ?? $this->id; + } + + /** + * Gets the suggested values that were initially + * submitted as the whole data-map to the DataHandler. + */ + public function getSuggestedValues(): array + { + return $this->suggestedValues; + } + + /** + * Gets the persisted values that represent the persisted state + * of the record this data-map item is a surrogate for - does only + * contain relevant field values. + */ + public function getPersistedValues(): array + { + return $this->persistedValues; + } + + public function getConfigurationFieldNames(): array + { + return $this->configurationFieldNames; + } + + public function getLanguageFieldName(): string + { + return $this->configurationFieldNames['language']; + } + + public function getParentFieldName(): string + { + return $this->configurationFieldNames['parent']; + } + + public function getSourceFieldName(): ?string + { + return $this->configurationFieldNames['source'] ?? null; + } + + protected function getVersionSourceFieldName(): ?string + { + return $this->configurationFieldNames['versionSource'] ?? null; + } + + public function isNew(): bool + { + return $this->new; + } + + public function getType(): string + { + if ($this->type === null) { + // implicit: default language, it's a parent + if ($this->language === 0) { + $this->type = static::TYPE_PARENT; + } elseif ( + // implicit: having source value different to parent value, it's a 2nd or higher level translation + $this->source !== 0 + && $this->source !== null + && $this->source !== $this->parent + ) { + $this->type = static::TYPE_GRAND_CHILD; + } else { + // implicit: otherwise, it's a 1st level translation + $this->type = static::TYPE_DIRECT_CHILD; + } + } + return $this->type; + } + + public function isParentType(): bool + { + return $this->getType() === static::TYPE_PARENT; + } + + public function isDirectChildType(): bool + { + return $this->getType() === static::TYPE_DIRECT_CHILD; + } + + public function isGrandChildType(): bool + { + return $this->getType() === static::TYPE_GRAND_CHILD; + } + + public function getState(): ?State + { + if ($this->state === null && !$this->isParentType()) { + $this->state = $this->buildState(); + } + return $this->state; + } + + public function getLanguage(): string|int + { + return $this->language; + } + + public function setLanguage(string|int $language): void + { + $this->language = $language; + } + + public function getParent(): string|int + { + return $this->parent; + } + + public function setParent(string|int $parent): void + { + $this->parent = $this->extractId($parent); + } + + public function getSource(): string|int|null + { + return $this->source; + } + + public function setSource(string|int $source): void + { + $this->source = $this->extractId($source); + } + + public function getIdForScope(string $scope): string|int + { + if ( + $scope === static::SCOPE_PARENT + || $scope === static::SCOPE_EXCLUDE + ) { + return $this->getParent(); + } + if ($scope === static::SCOPE_SOURCE) { + // $source is guaranteed to be non-null when SCOPE_SOURCE is applicable: + // getApplicableScopes() only includes it when getSourceFieldName() !== null, + // which means setSource() was called during build(). + return $this->source; + } + throw new \RuntimeException('Invalid scope', 1486325248); + } + + /** + * @return DataMapItem[][] + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + /** + * @param DataMapItem[][] $dependencies + */ + public function setDependencies(array $dependencies): void + { + $this->dependencies = $dependencies; + } + + /** + * @return DataMapItem[] + */ + public function findDependencies(string $scope): array + { + return $this->dependencies[$scope] ?? []; + } + + /** + * @return string[] + */ + public function getApplicableScopes(): array + { + $scopes = []; + if (!empty($this->getSourceFieldName())) { + $scopes[] = static::SCOPE_SOURCE; + } + $scopes[] = static::SCOPE_PARENT; + $scopes[] = static::SCOPE_EXCLUDE; + return $scopes; + } + + /** + * Extracts real id from provided id-value, which can either be a real + * integer value, a 'NEW...' id, or a combined identifier 'tt_content_13'. + */ + protected function extractId(int|string $idValue): int|string + { + if (MathUtility::canBeInterpretedAsInteger($idValue)) { + return $idValue; + } + $idValue = (string)$idValue; + if (str_starts_with($idValue, 'NEW')) { + return $idValue; + } + // @todo Handle if $tableName does not match $this->tableName + $id = BackendUtility::splitTable_Uid($idValue)[1]; + return $id; + } + + protected function buildState(): ?State + { + // build from persisted states + if (!$this->isNew()) { + $state = State::fromJSON( + $this->tableName, + $this->persistedValues['l10n_state'] ?? null + ); + } elseif (is_string($this->suggestedValues['l10n_state'] ?? null)) { + // use provided states for a new and copied element + $state = State::fromJSON( + $this->tableName, + $this->suggestedValues['l10n_state'] + ); + } else { + // provide the default states + $state = State::create($this->tableName); + } + // switch "custom" to "source" state for 2nd level translations + if ($this->isNew() && $this->isGrandChildType()) { + $state->updateStates(State::STATE_CUSTOM, State::STATE_SOURCE); + } + // apply any provided updates to the states + if (is_array($this->suggestedValues['l10n_state'] ?? null)) { + $state->update($this->suggestedValues['l10n_state']); + } + return $state; + } +} diff --git a/Classes/DataHandling/Localization/DataMapProcessor.php b/Classes/DataHandling/Localization/DataMapProcessor.php new file mode 100644 index 0000000..1282952 --- /dev/null +++ b/Classes/DataHandling/Localization/DataMapProcessor.php @@ -0,0 +1,1484 @@ +DMP->DH->DMP nesting chains. A shared + * instance would have its temporary state corrupted by the inner call. + * + * @internal should only be used by the TYPO3 Core + */ +#[Autoconfigure(public: true, shared: false)] +class DataMapProcessor +{ + protected array $allDataMap = []; + + /** + * @var array + */ + protected array $modifiedDataMap = []; + + /** + * @var array> + */ + protected array $sanitizationMap = []; + + /** + * @var DataMapItem[] + */ + protected array $allItems = []; + + /** + * @var DataMapItem[] + */ + protected array $nextItems = []; + + public function __construct( + private readonly TcaSchemaFactory $tcaSchemaFactory, + private readonly ConnectionPool $connectionPool, + private readonly SiteFinder $siteFinder, + #[Autowire(service: 'cache.runtime')] + private readonly FrontendInterface $runtimeCache, + ) {} + + /** + * Processes the submitted data-map and returns the sanitized and enriched + * version depending on accordant localization states and dependencies. + */ + public function process( + array $dataMap, + BackendUserAuthentication $backendUser, + ReferenceIndexUpdater $referenceIndexUpdater, + ?CorrelationId $correlationId = null, + ): array { + $this->allDataMap = $dataMap; + $this->modifiedDataMap = $dataMap; + $this->allItems = []; + $this->sanitizationMap = []; + $this->nextItems = []; + + $iterations = 0; + while (!empty($this->modifiedDataMap)) { + $this->nextItems = []; + foreach ($this->modifiedDataMap as $tableName => $idValues) { + $this->collectItems($tableName, $idValues, $backendUser); + } + $this->modifiedDataMap = []; + if (empty($this->nextItems)) { + break; + } + if ($iterations++ === 0) { + $this->sanitize($this->allItems); + } + $this->enrich($this->nextItems, $backendUser, $referenceIndexUpdater, $correlationId); + } + $dataMap = $this->purgeDataMap($this->allDataMap); + + // Reset class state to make clear this is temporary state, only. + $this->allDataMap = []; + $this->modifiedDataMap = []; + $this->allItems = []; + $this->sanitizationMap = []; + $this->nextItems = []; + + return $dataMap; + } + + /** + * Purges superfluous empty data-map sections. + */ + protected function purgeDataMap(array $dataMap): array + { + foreach ($dataMap as $tableName => $idValues) { + foreach ($idValues as $id => $values) { + // `l10n_state` should be serialized JSON at this point, + // in case it's not, it most probably was ignored in `collectItems()` + if (is_array($values['l10n_state'] ?? null)) { + unset($dataMap[$tableName][$id]['l10n_state'], $values['l10n_state']); + } + if (empty($values)) { + unset($dataMap[$tableName][$id]); + } + } + if (empty($dataMap[$tableName])) { + unset($dataMap[$tableName]); + } + } + return $dataMap; + } + + /** + * Create data map items of all affected rows + */ + protected function collectItems(string $tableName, array $idValues, BackendUserAuthentication $backendUser): void + { + if (!$this->isApplicable($tableName)) { + return; + } + + $schema = $this->getSchema($tableName); + if ($schema === null) { + throw new \RuntimeException('TCA schema for table "' . $tableName . '" not found, but table was considered applicable for language synchronization', 1744454610); + } + + // filter real numeric ids + $realIds = $this->filterNumericIds(array_keys($idValues)); + $versionToLiveIdMap = $this->getVersionToLiveIdMap($tableName, $backendUser); + $liveIds = $versionToLiveIdMap->update($realIds)->getLiveIds($realIds); + // @todo This is superfluous if l10n_source would store live(!) ids... + $liveAndVersionIds = array_unique( + array_merge( + $liveIds, + array_keys($idValues) + ) + ); + + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $fieldNames = [ + 'uid' => 'uid', + 'l10n_state' => 'l10n_state', + 'language' => $languageCapability->getLanguageField()->getName(), + 'parent' => $languageCapability->getTranslationOriginPointerField()->getName(), + ]; + if ($languageCapability->hasTranslationSourceField()) { + $fieldNames['source'] = $languageCapability->getTranslationSourceField()->getName(); + } + if ($schema->isWorkspaceAware()) { + $fieldNames['versionSource'] = 't3ver_oid'; + } + + $translationValues = $this->fetchTranslationValues( + $tableName, + $fieldNames, + $this->filterNewItemIds($tableName, $realIds, $this->allItems), + $backendUser, + ); + + $dependencies = $this->fetchDependencies( + $tableName, + $this->filterNewItemIds($tableName, $liveAndVersionIds, $this->allItems), + $backendUser, + $this->allDataMap, + ); + + foreach ($idValues as $id => $values) { + $item = $this->allItems[$tableName . ':' . $id] ?? null; + // build item if it has not been created in a previous iteration + if ($item === null) { + $recordValues = $translationValues[$id] ?? []; + $item = DataMapItem::build($tableName, $id, $values, $recordValues, $fieldNames); + + // elements using "all language" cannot be localized + if ($item->getLanguage() === -1) { + unset($item); + continue; + } + // must be any kind of localization and in connected mode + if ($item->getLanguage() > 0 && empty($item->getParent())) { + unset($item); + continue; + } + // add dependencies + if (!empty($dependencies[$item->getLiveId()])) { + $item->setDependencies($dependencies[$item->getLiveId()]); + // @todo This is superfluous if l10n_source would store live(!) ids... + } elseif (!empty($dependencies[$item->getId()])) { + $item->setDependencies($dependencies[$item->getId()]); + } + } + // add item to $this->allItems and $this->nextItems + $this->addNextItem($item); + } + } + + /** + * Sanitizes the submitted data-map items and removes fields which are not + * defined as custom and thus rely on either parent or source values. + * + * @param DataMapItem[] $items + */ + protected function sanitize(array $items): void + { + foreach (['directChild', 'grandChild'] as $type) { + foreach ($this->filterItemsByType($type, $items) as $item) { + $this->sanitizeTranslationItem($item); + } + } + } + + /** + * Handle synchronization of an item list + * + * @param DataMapItem[] $items + */ + protected function enrich(array $items, BackendUserAuthentication $backendUser, ReferenceIndexUpdater $referenceIndexUpdater, ?CorrelationId $correlationId = null): void + { + foreach (['directChild', 'grandChild'] as $type) { + foreach ($this->filterItemsByType($type, $items) as $item) { + foreach ($item->getApplicableScopes() as $scope) { + $fromId = $item->getIdForScope($scope); + $fieldNames = $this->getFieldNamesForItemScope($item, $scope, !$item->isNew()); + $this->synchronizeTranslationItem($item, $fieldNames, $fromId, $backendUser, $referenceIndexUpdater, $correlationId); + } + $this->populateTranslationItem($item, $backendUser, $referenceIndexUpdater, $correlationId); + $this->allDataMap = $this->finishTranslationItem($item, $this->allDataMap); + } + } + foreach ($this->filterItemsByType('parent', $items) as $item) { + $this->populateTranslationItem($item, $backendUser, $referenceIndexUpdater, $correlationId); + } + } + + /** + * Sanitizes the submitted data-map for a particular item and removes + * fields which are not defined as custom and thus rely on either parent + * or source values. + */ + protected function sanitizeTranslationItem(DataMapItem $item): void + { + $fieldNames = []; + foreach ($item->getApplicableScopes() as $scope) { + $fieldNames = array_merge($fieldNames, $this->getFieldNamesForItemScope($item, $scope, false)); + } + $fieldNameMap = array_combine($fieldNames, $fieldNames) ?: []; + // separate fields, that are submitted in data-map, but not defined as custom + $this->sanitizationMap[$item->getTableName()][$item->getId()] = array_intersect_key( + $this->allDataMap[$item->getTableName()][$item->getId()], + $fieldNameMap + ); + // remove fields, that are submitted in data-map, but not defined as custom + $this->allDataMap[$item->getTableName()][$item->getId()] = array_diff_key( + $this->allDataMap[$item->getTableName()][$item->getId()], + $fieldNameMap + ); + } + + /** + * Synchronize a single item + */ + protected function synchronizeTranslationItem(DataMapItem $item, array $fieldNames, string|int $fromId, BackendUserAuthentication $backendUser, ReferenceIndexUpdater $referenceIndexUpdater, ?CorrelationId $correlationId = null): void + { + if (empty($fieldNames)) { + return; + } + $fieldNameList = 'uid,' . implode(',', $fieldNames); + $fromRecord = ['uid' => $fromId]; + if (MathUtility::canBeInterpretedAsInteger($fromId)) { + $fromRecord = BackendUtility::getRecordWSOL($item->getTableName(), (int)$fromId, $fieldNameList); + } + $forRecord = []; + if (!$item->isNew()) { + $forRecord = BackendUtility::getRecordWSOL($item->getTableName(), $item->getId(), $fieldNameList); + } + if (is_array($fromRecord) && is_array($forRecord)) { + foreach ($fieldNames as $fieldName) { + $this->synchronizeFieldValues( + $item, + $fieldName, + $fromRecord, + $forRecord, + $backendUser, + $referenceIndexUpdater, + $correlationId, + ); + } + } + } + + /** + * Populates values downwards, either from a parent language item or + * a source language item to an accordant dependent translation item. + */ + protected function populateTranslationItem(DataMapItem $item, BackendUserAuthentication $backendUser, ReferenceIndexUpdater $referenceIndexUpdater, ?CorrelationId $correlationId = null): void + { + foreach ([DataMapItem::SCOPE_PARENT, DataMapItem::SCOPE_SOURCE] as $scope) { + foreach ($item->findDependencies($scope) as $dependentItem) { + // use suggested item, if it was submitted in data-map + $suggestedDependentItem = $this->allItems[$dependentItem->getTableName() . ':' . $dependentItem->getId()] ?? null; + if ($suggestedDependentItem !== null) { + $dependentItem = $suggestedDependentItem; + } + foreach ([$scope, DataMapItem::SCOPE_EXCLUDE] as $dependentScope) { + $fieldNames = $this->getFieldNamesForItemScope($dependentItem, $dependentScope, false); + $this->synchronizeTranslationItem( + $dependentItem, + $fieldNames, + $item->getId(), + $backendUser, + $referenceIndexUpdater, + $correlationId, + ); + } + } + } + } + + /** + * Finishes a translation item by updating states to be persisted. + */ + protected function finishTranslationItem(DataMapItem $item, array $allDataMap): array + { + if ($item->isParentType() || !State::isApplicable($item->getTableName())) { + return $allDataMap; + } + $allDataMap[$item->getTableName()][$item->getId()]['l10n_state'] = $item->getState()->export(); + return $allDataMap; + } + + /** + * Synchronize simple values like text and similar + */ + protected function synchronizeFieldValues(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord, BackendUserAuthentication $backendUser, ReferenceIndexUpdater $referenceIndexUpdater, ?CorrelationId $correlationId = null): void + { + // skip if this field has been processed already, assumed that proper sanitation happened + if ($this->isSetInDataMap($item->getTableName(), $item->getId(), $fieldName, $this->allDataMap)) { + return; + } + + $fromId = $fromRecord['uid']; + // retrieve value from in-memory data-map + if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName, $this->allDataMap)) { + $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName]; + } elseif (array_key_exists($fieldName, $fromRecord)) { + // retrieve value from record + $fromValue = $fromRecord[$fieldName]; + } else { + // otherwise abort synchronization + return; + } + + // plain values + if (!$this->isRelationField($item->getTableName(), $fieldName)) { + $this->modifyDataMap($item->getTableName(), $item->getId(), [$fieldName => $fromValue]); + } elseif (!$this->isReferenceField($item->getTableName(), $fieldName)) { + // direct relational values + $this->synchronizeDirectRelations($item, $fieldName, $fromRecord, $backendUser); + } else { + // reference values + $this->synchronizeReferences($item, $fieldName, $fromRecord, $forRecord, $backendUser, $referenceIndexUpdater, $correlationId); + } + } + + /** + * Synchronize select and group field localizations + */ + protected function synchronizeDirectRelations(DataMapItem $item, string $fieldName, array $fromRecord, BackendUserAuthentication $backendUser): void + { + $configuration = $this->getSchema($item->getTableName())?->getField($fieldName)->getConfiguration(); + $fromId = $fromRecord['uid']; + if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName, $this->allDataMap)) { + $fromValue = $this->allDataMap[$item->getTableName()][$fromId][$fieldName]; + } else { + $fromValue = $fromRecord[$fieldName]; + } + + // non-MM relations are stored as comma separated values, just use them + // if values are available in data-map already, just use them as well + if (empty($configuration['MM']) || $this->isSetInDataMap($item->getTableName(), $fromId, $fieldName, $this->allDataMap)) { + $this->modifyDataMap($item->getTableName(), $item->getId(), [$fieldName => $fromValue]); + return; + } + // fetch MM relations from storage + $type = $configuration['type']; + $manyToManyTable = $configuration['MM']; + if ($type === 'group' && !empty(trim($configuration['allowed'] ?? ''))) { + $tableNames = trim($configuration['allowed']); + } elseif ($type === 'select' || $type === 'category') { + $tableNames = $configuration['foreign_table'] ?? ''; + } else { + return; + } + + $relationHandler = $this->createRelationHandler($backendUser); + $relationHandler->start( + '', + $tableNames, + $manyToManyTable, + $fromId, + $item->getTableName(), + $configuration + ); + + // provide list of relations, optionally prepended with table name + // e.g. "13,19,23" or "tt_content_27,tx_extension_items_28" + $this->modifyDataMap( + $item->getTableName(), + $item->getId(), + [$fieldName => implode(',', $relationHandler->getValueArray())] + ); + } + + /** + * Handle synchronization of references (inline or file). + * References are always modelled as 1:n composite relation - which + * means that direct(!) children cannot exist without their parent. + * Removing a relative parent results in cascaded removal of all direct(!) + * children as well. + * + * @throws \RuntimeException + */ + protected function synchronizeReferences(DataMapItem $item, string $fieldName, array $fromRecord, array $forRecord, BackendUserAuthentication $backendUser, ReferenceIndexUpdater $referenceIndexUpdater, ?CorrelationId $correlationId = null): void + { + $configuration = $this->getSchema($item->getTableName())?->getField($fieldName)->getConfiguration() ?? []; + $isLocalizationModeExclude = ($configuration['l10n_mode'] ?? null) === 'exclude'; + $foreignTableName = $configuration['foreign_table']; + + $fieldNames = [ + 'language' => null, + 'parent' => null, + 'source' => null, + ]; + $foreignTableSchema = $this->getSchema($foreignTableName); + $isTranslatable = $foreignTableSchema?->isLanguageAware() ?? false; + $isLocalized = !empty($item->getLanguage()); + if ($isTranslatable) { + $languageCapability = $foreignTableSchema->getCapability(TcaSchemaCapability::Language); + $fieldNames = [ + 'language' => $languageCapability->getLanguageField()->getName(), + 'parent' => $languageCapability->getTranslationOriginPointerField()->getName(), + 'source' => $languageCapability->getTranslationSourceField()?->getName(), + ]; + } + + $suggestedAncestorIds = $this->resolveSuggestedInlineRelations($item, $fieldName, $fromRecord, $backendUser, $this->allDataMap); + $persistedIds = $this->resolvePersistedInlineRelations($item, $fieldName, $forRecord, $backendUser); + // Resolve suggested live-ids if available and applicable + $suggestedAncestorIds = $this->mapToLiveIds($foreignTableName, $suggestedAncestorIds, $backendUser); + + // The dependent ID map points from language parent/source record to + // localization, thus keys: parents/sources & values: localizations + $dependentIdMap = $this->fetchDependentIdMap($foreignTableName, $suggestedAncestorIds, (int)$item->getLanguage(), $backendUser); + // filter incomplete structures - this is a drawback of DataHandler's remap stack, since + // just created IRRE translations still belong to the language parent - filter them out + $suggestedAncestorIds = array_diff($suggestedAncestorIds, array_values($dependentIdMap)); + // compile element differences to be resolved + // remove elements that are persisted at the language translation, but not required anymore + $removeIds = array_diff($persistedIds, array_values($dependentIdMap)); + // remove elements that are persisted at the language parent/source, but not required anymore + $removeAncestorIds = array_diff(array_keys($dependentIdMap), $suggestedAncestorIds); + // missing elements that are persisted at the language parent/source, but not translated yet + $missingAncestorIds = array_diff($suggestedAncestorIds, array_keys($dependentIdMap)); + // persisted elements that should be copied or localized + $createAncestorIds = $this->filterNumericIds($missingAncestorIds); + // non-persisted elements that should be duplicated in data-map directly + $populateAncestorIds = array_diff($missingAncestorIds, $createAncestorIds); + // this desired state map defines the final result of child elements in their parent translation + $desiredIdMap = array_combine($suggestedAncestorIds, $suggestedAncestorIds) ?: []; + // update existing translations in the desired state map + foreach ($dependentIdMap as $ancestorId => $translationId) { + if (isset($desiredIdMap[$ancestorId])) { + $desiredIdMap[$ancestorId] = $translationId; + } + } + // no children to be synchronized, but element order could have been changed + if (empty($removeAncestorIds) && empty($missingAncestorIds)) { + $this->modifyDataMap($item->getTableName(), $item->getId(), [$fieldName => implode(',', array_values($desiredIdMap))]); + return; + } + // In case only missing elements shall be created, re-use previously sanitized + // values IF the relation parent item is new and the count of missing relations + // equals the count of previously sanitized relations. + // This is caused during copy processes, when the child relations + // already have been cloned in DataHandler::copyRecord_procBasedOnFieldType() + // without the possibility to resolve the initial connections at this point. + // Otherwise child relations would superfluously be duplicated again here. + // @todo Invalid manually injected child relations cannot be determined here + $sanitizedValue = $this->sanitizationMap[$item->getTableName()][$item->getId()][$fieldName] ?? null; + if ( + !empty($missingAncestorIds) && $item->isNew() && $sanitizedValue !== null + && count(GeneralUtility::trimExplode(',', $sanitizedValue, true)) === count($missingAncestorIds) + ) { + $this->modifyDataMap($item->getTableName(), $item->getId(), [$fieldName => $sanitizedValue]); + return; + } + + $localCommandMap = []; + foreach ($removeIds as $removeId) { + $localCommandMap[$foreignTableName][$removeId]['delete'] = true; + } + foreach ($removeAncestorIds as $removeAncestorId) { + $removeId = $dependentIdMap[$removeAncestorId]; + $localCommandMap[$foreignTableName][$removeId]['delete'] = true; + } + foreach ($createAncestorIds as $createAncestorId) { + // if child table is not aware of localization, just copy + if ($isLocalizationModeExclude || !$isTranslatable) { + $localCommandMap[$foreignTableName][$createAncestorId]['copy'] = [ + 'target' => -$createAncestorId, + 'ignoreLocalization' => true, + ]; + } else { + // otherwise, trigger the localization process + $localCommandMap[$foreignTableName][$createAncestorId]['localize'] = $item->getLanguage(); + } + } + // execute copy, localize and delete actions on persisted child records + if (!empty($localCommandMap)) { + $localDataHandler = GeneralUtility::makeInstance(DataHandler::class); + $localDataHandler->start([], $localCommandMap, $backendUser, $referenceIndexUpdater, $correlationId); + $localDataHandler->process_cmdmap(); + // update copied or localized ids + foreach ($createAncestorIds as $createAncestorId) { + if (empty($localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId])) { + $additionalInformation = ''; + if (!empty($localDataHandler->errorLog)) { + $additionalInformation = ', reason "' + . implode(', ', $localDataHandler->errorLog) . '"'; + } + throw new \RuntimeException( + 'Child record was not processed' . $additionalInformation, + 1486233164 + ); + } + $newLocalizationId = $localDataHandler->copyMappingArray_merged[$foreignTableName][$createAncestorId]; + $newLocalizationId = $localDataHandler->getAutoVersionId($foreignTableName, $newLocalizationId) ?? $newLocalizationId; + $desiredIdMap[$createAncestorId] = $newLocalizationId; + // apply localization references to l10n_mode=exclude children, + // without keeping their reference to their origin, synchronization is not possible. + if ($isLocalizationModeExclude && $isTranslatable && $isLocalized) { + $adjustCopiedValues = $this->applyLocalizationReferences( + $foreignTableName, + $createAncestorId, + (int)$item->getLanguage(), + $fieldNames, + [] + ); + $this->modifyDataMap($foreignTableName, $newLocalizationId, $adjustCopiedValues); + } + } + } + // populate new child records in data-map + foreach ($populateAncestorIds as $populateAncestorId) { + $newLocalizationId = StringUtility::getUniqueId('NEW'); + $desiredIdMap[$populateAncestorId] = $newLocalizationId; + $duplicatedValues = $this->allDataMap[$foreignTableName][$populateAncestorId] ?? []; + // applies localization references to given raw data-map item + if ($isTranslatable && $isLocalized) { + $duplicatedValues = $this->applyLocalizationReferences( + $foreignTableName, + $populateAncestorId, + (int)$item->getLanguage(), + $fieldNames, + $duplicatedValues + ); + } + // prefixes language title if applicable for the accordant field name in raw data-map item + if ($isTranslatable && $isLocalized && !$isLocalizationModeExclude) { + $duplicatedValues = $this->prefixLanguageTitle( + $foreignTableName, + $populateAncestorId, + (int)$item->getLanguage(), + $duplicatedValues + ); + } + $this->modifyDataMap($foreignTableName, $newLocalizationId, $duplicatedValues); + } + // update inline parent field references - required to update pointer fields + $this->modifyDataMap( + $item->getTableName(), + $item->getId(), + [$fieldName => implode(',', array_values($desiredIdMap))] + ); + } + + /** + * Determines suggest inline relations of either translation parent or + * source record from data-map or storage in case records have been + * persisted already. + * + * @return int[]|string[] + */ + protected function resolveSuggestedInlineRelations(DataMapItem $item, string $fieldName, array $fromRecord, BackendUserAuthentication $backendUser, array $allDataMap): array + { + $suggestedAncestorIds = []; + $fromId = $fromRecord['uid']; + $configuration = $this->getSchema($item->getTableName())?->getField($fieldName)->getConfiguration(); + $foreignTableName = $configuration['foreign_table'] ?? ''; + $manyToManyTable = $configuration['MM'] ?? ''; + + // determine suggested elements of either translation parent or source record + // from data-map, in case the accordant language parent/source record was modified + if ($this->isSetInDataMap($item->getTableName(), $fromId, $fieldName, $allDataMap)) { + $suggestedAncestorIds = GeneralUtility::trimExplode(',', $allDataMap[$item->getTableName()][$fromId][$fieldName], true); + } elseif (MathUtility::canBeInterpretedAsInteger($fromId)) { + // determine suggested elements of either translation parent or source record from storage + $relationHandler = $this->createRelationHandler($backendUser); + $relationHandler->start( + $fromRecord[$fieldName], + $foreignTableName, + $manyToManyTable, + $fromId, + $item->getTableName(), + $configuration + ); + $suggestedAncestorIds = $this->mapRelationItemId($relationHandler->itemArray); + } + + return array_filter($suggestedAncestorIds); + } + + /** + * Determine persisted inline relations for current data-map-item. + * + * @return int[] + */ + private function resolvePersistedInlineRelations(DataMapItem $item, string $fieldName, array $forRecord, BackendUserAuthentication $backendUser): array + { + $persistedIds = []; + $configuration = $this->getSchema($item->getTableName())?->getField($fieldName)->getConfiguration(); + $foreignTableName = $configuration['foreign_table'] ?? ''; + $manyToManyTable = $configuration['MM'] ?? ''; + + // determine persisted elements for the current data-map item + if (!$item->isNew()) { + $relationHandler = $this->createRelationHandler($backendUser); + $relationHandler->start( + $forRecord[$fieldName] ?? '', + $foreignTableName, + $manyToManyTable, + $item->getLiveId(), + $item->getTableName(), + $configuration + ); + $persistedIds = $this->mapRelationItemId($relationHandler->itemArray); + } + + return array_filter($persistedIds); + } + + /** + * Determines whether a combination of table name, id and field name is + * set in data-map. This method considers null values as well, that would + * not be considered by a plain isset() invocation. + */ + protected function isSetInDataMap(string $tableName, string|int $id, string $fieldName, array $allDataMap): bool + { + return + // directly look-up field name + isset($allDataMap[$tableName][$id][$fieldName]) + // check existence of field name as key for null values + || isset($allDataMap[$tableName][$id]) + && is_array($allDataMap[$tableName][$id]) + && array_key_exists($fieldName, $allDataMap[$tableName][$id]); + } + + /** + * Applies modifications to the data-map, calling this method is essential + * to determine new data-map items to be process for synchronizing chained + * record localizations. + * + * @throws \RuntimeException + */ + protected function modifyDataMap(string $tableName, string|int $id, array $values): void + { + // avoid superfluous iterations by data-map changes with values + // that actually have not been changed and were available already + $sameValues = array_intersect_assoc( + $this->allDataMap[$tableName][$id] ?? [], + $values + ); + if (!empty($sameValues)) { + $fieldNames = implode(', ', array_keys($sameValues)); + throw new \RuntimeException( + sprintf( + 'Issued data-map change for table %s with same values ' + . 'for these fields names %s', + $tableName, + $fieldNames + ), + 1488634845 + ); + } + + $this->modifiedDataMap[$tableName][$id] = array_merge( + $this->modifiedDataMap[$tableName][$id] ?? [], + $values + ); + $this->allDataMap[$tableName][$id] = array_merge( + $this->allDataMap[$tableName][$id] ?? [], + $values + ); + } + + protected function addNextItem(DataMapItem $item): void + { + $identifier = $item->getTableName() . ':' . $item->getId(); + if (!isset($this->allItems[$identifier])) { + $this->allItems[$identifier] = $item; + } + $this->nextItems[$identifier] = $item; + } + + /** + * Fetches translation related field values for the items submitted in + * the data-map. + */ + protected function fetchTranslationValues(string $tableName, array $fieldNames, array $ids, BackendUserAuthentication $backendUser): array + { + if ($ids === []) { + return []; + } + + $connection = $this->connectionPool->getConnectionForTable($tableName); + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll() + // NOT using WorkspaceRestriction here since it's wrong in this case. See ws OR restriction below. + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $expressions = []; + $isWorkspaceAware = $this->getSchema($tableName)?->isWorkspaceAware() ?? false; + if ($isWorkspaceAware) { + $expressions[] = $queryBuilder->expr()->eq('t3ver_wsid', 0); + if ($backendUser->workspace > 0) { + // If this is a workspace record (t3ver_wsid = be-user-workspace), then fetch this one + // if it is NOT a deleted placeholder (t3ver_state=2), but ok with casual overlay (t3ver_state=0), + // new ws-record (t3ver_state=1), or moved record (t3ver_state=4). + // It *might* be possible to simplify this since it may be the case that ws-deleted records are + // impossible to be incoming here at all? But this query is a safe thing, so we go with it for now. + $expressions[] = $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter($backendUser->workspace, Connection::PARAM_INT)), + $queryBuilder->expr()->in( + 't3ver_state', + $queryBuilder->createNamedParameter( + [VersionState::DEFAULT_STATE->value, VersionState::NEW_PLACEHOLDER->value, VersionState::MOVE_POINTER->value], + Connection::PARAM_INT_ARRAY + ) + ), + ); + } + } + + $translationValues = []; + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + // We are using the max bind parameter value as way to retrieve the data in chunks. However, we are not + // using up the placeholders by providing the id list directly, we keep this calculation to avoid hitting + // max query size limitation in most cases. If that is hit, it can be increased by adjusting the dbms setting. + foreach (array_chunk($ids, $maxBindParameters, true) as $chunk) { + $result = $queryBuilder + ->select(...array_values($fieldNames)) + ->from($tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->quoteArrayBasedValueListToIntegerList($chunk) + ), + $queryBuilder->expr()->or(...$expressions) + ) + ->executeQuery(); + while ($record = $result->fetchAssociative()) { + $translationValues[$record['uid']] = $record; + } + } + return $translationValues; + } + + /** + * Fetches translation dependencies for a given parent/source record ids. + * + * Existing records in database: + * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0] + * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1] + * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2] + * + * Input $ids and their results: + * + [5] -> [DataMapItem(6), DataMapItem(7)] # since 5 is parent/source + * + [6] -> [DataMapItem(7)] # since 6 is source + * + [7] -> [] # since there's nothing + * + * @param int[]|string[] $ids + * @return array>> + */ + protected function fetchDependencies(string $tableName, array $ids, BackendUserAuthentication $backendUser, array $allDataMap): array + { + if ($ids === []) { + return []; + } + + $schema = $this->getSchema($tableName); + if (!$schema?->isLanguageAware()) { + return []; + } + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $fieldNames = [ + 'uid' => 'uid', + 'l10n_state' => 'l10n_state', + 'language' => $languageCapability->getLanguageField()->getName(), + 'parent' => $languageCapability->getTranslationOriginPointerField()->getName(), + ]; + if ($languageCapability->hasTranslationSourceField()) { + $fieldNames['source'] = $languageCapability->getTranslationSourceField()->getName(); + } + + $fieldNamesMap = array_combine($fieldNames, $fieldNames); + + $persistedIds = $this->filterNumericIds($ids); + $createdIds = array_diff($ids, $persistedIds); + $dependentElements = $this->fetchDependentElements($tableName, $persistedIds, $fieldNames, $backendUser); + + foreach ($createdIds as $createdId) { + $data = $allDataMap[$tableName][$createdId] ?? null; + if ($data === null) { + continue; + } + $dependentElements[] = array_merge( + ['uid' => $createdId], + array_intersect_key($data, $fieldNamesMap) + ); + } + + $dependencyMap = []; + foreach ($dependentElements as $dependentElement) { + $dependentItem = DataMapItem::build( + $tableName, + $dependentElement['uid'], + [], + $dependentElement, + $fieldNames + ); + + if ($dependentItem->isDirectChildType()) { + $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem; + } + if ($dependentItem->isGrandChildType()) { + $dependencyMap[$dependentItem->getParent()][State::STATE_PARENT][] = $dependentItem; + $dependencyMap[$dependentItem->getSource()][State::STATE_SOURCE][] = $dependentItem; + } + } + return $dependencyMap; + } + + /** + * Fetches dependent records that depend on given record id's in in either + * their parent or source field for translatable tables or their origin + * field for non-translatable tables and creates an id mapping. + * + * This method expands the search criteria by expanding to ancestors. + * + * Existing records in database: + * + [uid:5, l10n_parent=0, l10n_source=0, sys_language_uid=0] + * + [uid:6, l10n_parent=5, l10n_source=5, sys_language_uid=1] + * + [uid:7, l10n_parent=5, l10n_source=6, sys_language_uid=2] + * + * Input $ids and $desiredLanguage and their results: + * + $ids=[5], $lang=1 -> [5 => 6] # since 5 is source of 6 + * + $ids=[5], $lang=2 -> [] # since 5 is parent of 7, but different language + * + $ids=[6], $lang=1 -> [] # since there's nothing + * + $ids=[6], $lang=2 -> [6 => 7] # since 6 has source 5, which is ancestor of 7 + * + $ids=[7], $lang=* -> [] # since there's nothing + */ + protected function fetchDependentIdMap(string $tableName, array $ids, int $desiredLanguage, BackendUserAuthentication $backendUser): array + { + if ($ids === []) { + return []; + } + + $ids = $this->filterNumericIds($ids); + $schema = $this->getSchema($tableName); + $isTranslatable = $schema?->isLanguageAware() ?? false; + $originFieldName = $schema?->hasCapability(TcaSchemaCapability::AncestorReferenceField) ? $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName() : null; + + if (!$isTranslatable && $originFieldName === null) { + // @todo Possibly throw an error, since pointing to original entity is not possible (via origin/parent) + return []; + } + + if ($isTranslatable) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $fieldNames = [ + 'uid' => 'uid', + 'l10n_state' => 'l10n_state', + 'language' => $languageCapability->getLanguageField()->getName(), + 'parent' => $languageCapability->getTranslationOriginPointerField()->getName(), + ]; + if ($languageCapability->hasTranslationSourceField()) { + $fieldNames['source'] = $languageCapability->getTranslationSourceField()->getName(); + } + } else { + $fieldNames = [ + 'uid' => 'uid', + 'origin' => $originFieldName, + ]; + } + if ($schema->isWorkspaceAware()) { + $fieldNames['versionSource'] = 't3ver_oid'; + } + $ancestorIdMap = []; + + $fetchIds = $ids; + if ($isTranslatable) { + // expand search criteria via parent and source elements + $translationValues = $this->fetchTranslationValues($tableName, $fieldNames, $ids, $backendUser); + $ancestorIdMap = $this->buildElementAncestorIdMap($fieldNames, $translationValues); + $fetchIds = array_unique(array_merge($ids, array_keys($ancestorIdMap))); + } + + $dependentElements = $this->fetchDependentElements($tableName, $fetchIds, $fieldNames, $backendUser); + + $dependentIdMap = []; + foreach ($dependentElements as $dependentElement) { + $dependentId = $dependentElement['uid']; + // always use live id + if (!empty($dependentElement['t3ver_oid'])) { + $dependentId = $dependentElement['t3ver_oid']; + } + // implicit: use origin pointer if table cannot be translated + if (!$isTranslatable) { + $ancestorId = (int)$dependentElement[$fieldNames['origin']]; + // only consider element if it reflects the desired language + } elseif ((int)$dependentElement[$fieldNames['language']] === $desiredLanguage) { + $ancestorId = $this->resolveAncestorId($fieldNames, $dependentElement); + } else { + // otherwise skip the element completely + continue; + } + // only keep ancestors that were initially requested before expanding + if (in_array($ancestorId, $ids, true)) { + $dependentIdMap[$ancestorId] = $dependentId; + } elseif (!empty($ancestorIdMap[$ancestorId])) { + // resolve from previously expanded search criteria + $possibleChainedIds = array_intersect( + $ids, + $ancestorIdMap[$ancestorId] + ); + if (!empty($possibleChainedIds)) { + // use the first found id from `$possibleChainedIds` + $ancestorId = reset($possibleChainedIds); + $dependentIdMap[$ancestorId] = $dependentId; + } + } + } + return $dependentIdMap; + } + + /** + * Fetch all elements that depend on given record id's in either their + * parent or source field for translatable tables or their origin field + * for non-translatable tables. + * + * @throws \InvalidArgumentException + */ + protected function fetchDependentElements(string $tableName, array $ids, array $fieldNames, BackendUserAuthentication $backendUser): array + { + if ($ids === []) { + return []; + } + $connection = $this->connectionPool->getConnectionForTable($tableName); + $ids = $this->filterNumericIds($ids); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + $dependentElements = []; + foreach (array_chunk($ids, $maxBindParameters, true) as $idsChunked) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $backendUser->workspace)); + + $zeroParameter = $queryBuilder->createNamedParameter(0, Connection::PARAM_INT); + $idsParameter = $queryBuilder->quoteArrayBasedValueListToIntegerList($idsChunked); + // fetch by language dependency + if (!empty($fieldNames['language']) && !empty($fieldNames['parent'])) { + $ancestorPredicates = [ + $queryBuilder->expr()->in( + $fieldNames['parent'], + $idsParameter + ), + ]; + if (!empty($fieldNames['source'])) { + $ancestorPredicates[] = $queryBuilder->expr()->in( + $fieldNames['source'], + $idsParameter + ); + } + $predicates = [ + // must be any kind of localization + $queryBuilder->expr()->gt( + $fieldNames['language'], + $zeroParameter + ), + // must be in connected mode + $queryBuilder->expr()->gt( + $fieldNames['parent'], + $zeroParameter + ), + // any parent or source pointers + $queryBuilder->expr()->or(...$ancestorPredicates), + ]; + } elseif (!empty($fieldNames['origin'])) { + // fetch by origin dependency ("copied from") + $predicates = [ + $queryBuilder->expr()->in( + $fieldNames['origin'], + $idsParameter + ), + ]; + } else { + // otherwise: stop execution + throw new \InvalidArgumentException( + 'Invalid combination of query field names given', + 1487192370 + ); + } + $statement = $queryBuilder + ->select(...array_values($fieldNames)) + ->from($tableName) + ->andWhere(...$predicates) + ->executeQuery(); + while ($record = $statement->fetchAssociative()) { + $dependentElements[] = $record; + } + } + return $dependentElements; + } + + /** + * Return array of data map items that are of given type + * + * @param DataMapItem[] $items + * @return DataMapItem[] + */ + protected function filterItemsByType(string $type, array $items): array + { + return array_filter( + $items, + static function (DataMapItem $item) use ($type): bool { + return $item->getType() === $type; + } + ); + } + + /** + * Return only ids that are integer - so no "NEW..." values + * + * @param string[]|int[] $ids + * @return int[] + */ + protected function filterNumericIds(array $ids): array + { + $ids = array_filter($ids, MathUtility::canBeInterpretedAsInteger(...)); + return array_map(intval(...), $ids); + } + + /** + * Return only ids that don't have an item equivalent in $this->allItems. + * + * @param int[] $ids + */ + protected function filterNewItemIds(string $tableName, array $ids, array $allItems): array + { + return array_filter( + $ids, + static function (string|int $id) use ($tableName, $allItems): bool { + return !isset($allItems[$tableName . ':' . $id]); + } + ); + } + + /** + * Flatten array + * + * @return int[] + */ + protected function mapRelationItemId(array $relationItems): array + { + return array_map( + static function (array $relationItem): int { + return (int)$relationItem['id']; + }, + $relationItems + ); + } + + /** + * Maps id values to live-id values (if available and applicable). + * + * @param string[]|int[] $ids + * @return string[]|int[] + */ + protected function mapToLiveIds(string $tableName, array $ids, BackendUserAuthentication $backendUser): array + { + if ($backendUser->workspace === 0) { + return $ids; + } + + $versionToLiveIdMap = $this->getVersionToLiveIdMap($tableName, $backendUser); + $versionToLiveIdMap->update($this->filterNumericIds($ids)); + return array_map( + function ($id) use ($versionToLiveIdMap) { + if (!MathUtility::canBeInterpretedAsInteger($id)) { + return $id; + } + return $versionToLiveIdMap->getLiveId((int)$id); + }, + $ids + ); + } + + /** + * @param array $fieldNames + * @param array $element + * @return int|null either a (non-empty) ancestor uid, or `null` if unresolved + */ + protected function resolveAncestorId(array $fieldNames, array $element): ?int + { + $sourceName = $fieldNames['source'] ?? null; + if ($sourceName !== null && !empty($element[$sourceName])) { + // implicit: use source pointer if given (not empty) + return (int)$element[$sourceName]; + } + $parentName = $fieldNames['parent'] ?? null; + if ($parentName !== null && !empty($element[$parentName])) { + // implicit: use parent pointer if given (not empty) + return (int)$element[$parentName]; + } + return null; + } + + /** + * Builds a map from ancestor ids to accordant localization dependents. + * + * The result of e.g. [5 => [6, 7]] refers to ids 6 and 7 being dependents + * (either used in parent or source field) of the ancestor with id 5. + */ + protected function buildElementAncestorIdMap(array $fieldNames, array $elements): array + { + $ancestorIdMap = []; + foreach ($elements as $element) { + $ancestorId = $this->resolveAncestorId($fieldNames, $element); + if ($ancestorId !== null) { + $ancestorIdMap[$ancestorId][] = (int)$element['uid']; + } + } + return $ancestorIdMap; + } + + /** + * Applies localization references to given raw data-map item. + */ + protected function applyLocalizationReferences(string $tableName, string|int $fromId, int $language, array $fieldNames, array $data): array + { + // just return if localization cannot be applied + if (empty($language)) { + return $data; + } + // apply `languageField`, e.g. `sys_language_uid` + $data[$fieldNames['language']] = $language; + // apply `transOrigPointerField`, e.g. `l10n_parent` + if (empty($data[$fieldNames['parent']])) { + $data[$fieldNames['parent']] = $fromId; + } + // apply `translationSource`, e.g. `l10n_source` + if (!empty($fieldNames['source'])) { + $data[$fieldNames['source']] = $fromId; + } + // unset field names that are expected to be handled in this processor + foreach ($this->getFieldNamesToBeHandled($tableName) as $fieldName) { + unset($data[$fieldName]); + } + return $data; + } + + /** + * Prefixes language title if applicable for the accordant field name in raw data-map item. + */ + protected function prefixLanguageTitle(string $tableName, string|int $fromId, int $language, array $data): array + { + $prefixFieldNames = array_intersect(array_keys($data), $this->getPrefixLanguageTitleFieldNames($tableName)); + if (empty($prefixFieldNames)) { + return $data; + } + + $pageId = (int)BackendUtility::getRealPageId($tableName, (int)$fromId, $data['pid'] ?? null); + $tsConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? []; + if (($translateToMessage = (string)($tsConfig['translateToMessage'] ?? '')) === '') { + // Return in case translateToMessage had been unset + return $data; + } + + $tableRelatedConfig = $tsConfig['default.'] ?? []; + ArrayUtility::mergeRecursiveWithOverrule($tableRelatedConfig, $tsConfig['table.'][$tableName . '.'] ?? []); + if ($tableRelatedConfig['disablePrependAtCopy'] ?? false) { + // Return in case "disablePrependAtCopy" is set for this table + return $data; + } + + try { + $site = $this->siteFinder->getSiteByPageId($pageId); + $siteLanguage = $site->getLanguageById($language); + $languageTitle = $siteLanguage->getTitle(); + } catch (SiteNotFoundException|\InvalidArgumentException $e) { + $languageTitle = ''; + } + + $languageService = $this->getLanguageService(); + if ($languageService !== null) { + $translateToMessage = $languageService->sL($translateToMessage); + } + $translateToMessage = sprintf($translateToMessage, $languageTitle); + + if ($translateToMessage === '') { + // Return for edge cases when the translateToMessage got empty, e.g. because the referenced LLL + // label is empty or only contained a placeholder which is replaced by an empty language title. + return $data; + } + + $translateToMessage = '[' . $translateToMessage . '] '; + $schema = $this->getSchema($tableName); + // @todo The hook in DataHandler is not applied here + foreach ($prefixFieldNames as $prefixFieldName) { + if (!isset($data[$prefixFieldName])) { + continue; + } + $fieldContent = $data[$prefixFieldName]; + if ($schema->getField($prefixFieldName)->isType(TableColumnType::TEXT) && str_starts_with($fieldContent, '<')) { + // If the field is a text field, we need to prepend the translation message to the content + // that means, it should be after the first opening HTML tag, if one exists. + // @todo: Ideally we can use TcaSchema and Subschema in the future, to resolve this issue properly + $data[$prefixFieldName] = preg_replace('/(<[^>]+>)/', '$1' . $translateToMessage, $fieldContent, 1); + } else { + $data[$prefixFieldName] = $translateToMessage . $fieldContent; + } + } + + return $data; + } + + /** + * Field names we have to deal with + * + * @return string[] + */ + protected function getFieldNamesForItemScope(DataMapItem $item, string $scope, bool $modified): array + { + if ($scope === DataMapItem::SCOPE_PARENT || $scope === DataMapItem::SCOPE_SOURCE) { + if (!State::isApplicable($item->getTableName())) { + return []; + } + return $item->getState()->filterFieldNames($scope, $modified); + } + if ($scope === DataMapItem::SCOPE_EXCLUDE) { + return $this->getLocalizationModeExcludeFieldNames($item->getTableName()); + } + return []; + } + + /** + * Field names of TCA table with columns having l10n_mode=exclude + * + * @return string[] + */ + protected function getLocalizationModeExcludeFieldNames(string $tableName): array + { + $localizationExcludeFieldNames = []; + $schema = $this->getSchema($tableName); + foreach ($schema?->getFields() ?? [] as $fieldName => $configuration) { + if (($configuration->getConfiguration()['l10n_mode'] ?? null) === 'exclude' && $configuration->getType() !== 'none') { + $localizationExcludeFieldNames[] = $fieldName; + } + } + return $localizationExcludeFieldNames; + } + + /** + * Gets a list of field names which have to be handled. Basically this + * includes fields using allowLanguageSynchronization or l10n_mode=exclude. + * + * @return string[] + */ + protected function getFieldNamesToBeHandled(string $tableName): array + { + return array_merge(State::getFieldNames($tableName), $this->getLocalizationModeExcludeFieldNames($tableName)); + } + + /** + * Field names of TCA table with columns having l10n_mode=prefixLangTitle + */ + protected function getPrefixLanguageTitleFieldNames(string $tableName): array + { + $prefixLanguageTitleFieldNames = []; + $schema = $this->getSchema($tableName); + foreach ($schema?->getFields() ?? [] as $fieldName => $configuration) { + $type = $configuration->getType(); + if ( + ($configuration->getConfiguration()['l10n_mode'] ?? null) === 'prefixLangTitle' + && ($type === 'input' || $type === 'text' || $type === 'email') + ) { + $prefixLanguageTitleFieldNames[] = $fieldName; + } + } + return $prefixLanguageTitleFieldNames; + } + + /** + * True if we're dealing with a field that has foreign db relations (type=group or select with foreign_table). + */ + protected function isRelationField(string $tableName, string $fieldName): bool + { + if (!$this->getSchema($tableName)?->hasField($fieldName)) { + return false; + } + $field = $this->getSchema($tableName)->getField($fieldName); + $fieldType = $field->getType(); + $configuration = $field->getConfiguration(); + return ($fieldType === 'group' && !empty($configuration['allowed'])) + || ( + ($fieldType === 'select' || $fieldType === 'category') + && $this->getSchema($configuration['foreign_table'] ?? '') !== null + ) + || $this->isReferenceField($tableName, $fieldName) + ; + } + + /** + * True if we're dealing with a reference field (either "inline" or "file") with foreign_table set. + */ + protected function isReferenceField(string $tableName, string $fieldName): bool + { + if (!$this->getSchema($tableName)?->hasField($fieldName)) { + return false; + } + $field = $this->getSchema($tableName)->getField($fieldName); + $fieldType = $field->getType(); + $configuration = $field->getConfiguration(); + return + ($fieldType === 'inline' || $fieldType === 'file') + && $this->getSchema($configuration['foreign_table'] ?? '') !== null + ; + } + + /** + * Determines whether the table can be localized and either has fields + * with allowLanguageSynchronization enabled or l10n_mode set to exclude. + */ + protected function isApplicable(string $tableName): bool + { + return + State::isApplicable($tableName) + || $this->getSchema($tableName)?->isLanguageAware() + && count($this->getLocalizationModeExcludeFieldNames($tableName)) > 0 + ; + } + + protected function getVersionToLiveIdMap(string $tableName, BackendUserAuthentication $backendUser): VersionToLiveIdMap + { + $cacheIdentifier = 'datamapprocessor-version-to-live-id-map-' . $backendUser->workspace . '-' . $tableName; + $map = $this->runtimeCache->get($cacheIdentifier); + if (!$map instanceof VersionToLiveIdMap) { + $map = new VersionToLiveIdMap($tableName, $backendUser->workspace); + $this->runtimeCache->set($cacheIdentifier, $map); + } + return $map; + } + + protected function createRelationHandler(BackendUserAuthentication $backendUser): RelationHandler + { + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->setWorkspaceId($backendUser->workspace); + return $relationHandler; + } + + protected function getSchema(string $table): ?TcaSchema + { + if ($this->tcaSchemaFactory->has($table)) { + return $this->tcaSchemaFactory->get($table); + } + return null; + } + + protected function getLanguageService(): ?LanguageService + { + return $GLOBALS['LANG'] ?? null; + } +} diff --git a/Classes/DataHandling/Localization/State.php b/Classes/DataHandling/Localization/State.php new file mode 100644 index 0000000..bab70b4 --- /dev/null +++ b/Classes/DataHandling/Localization/State.php @@ -0,0 +1,240 @@ +has($tableName) + && $schemaFactory->get($tableName)->isLanguageAware() + && count(static::getFieldNames($tableName)) > 0; + } + + /** + * @return string[] + */ + public static function getFieldNames(string $tableName): array + { + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + if (!$schemaFactory->has($tableName)) { + return []; + } + + return array_map( + static fn(FieldTypeInterface $field) => $field->getName(), + iterator_to_array( + $schemaFactory->get($tableName)->getFields( + static fn(FieldTypeInterface $field): bool => !empty($field->getConfiguration()['behaviour']['allowLanguageSynchronization']) + ) + ) + ); + } + + public function __construct(string $tableName, array $states = []) + { + $this->tableName = $tableName; + $this->states = $states; + $this->originalStates = $states; + + $this->states = $this->enrich( + $this->sanitize($states) + ); + } + + public function update(array $states): void + { + $this->states = array_merge( + $this->states, + $this->sanitize($states) + ); + } + + /** + * Updates field names having a particular state to a target state. + */ + public function updateStates(string $currentState, string $targetState): void + { + $states = []; + foreach ($this->filterFieldNames($currentState) as $fieldName) { + $states[$fieldName] = $targetState; + } + if (!empty($states)) { + $this->update($states); + } + } + + public function export(): string|false|null + { + if (empty($this->states)) { + return null; + } + return json_encode($this->states); + } + + public function toArray(): array + { + return $this->states; + } + + /** + * @return string[] + */ + public function getModifiedFieldNames(): array + { + return array_keys( + array_diff_assoc( + $this->states, + $this->originalStates + ) + ); + } + + public function isModified(): bool + { + return !empty($this->getModifiedFieldNames()); + } + + public function isUndefined(string $fieldName): bool + { + return !isset($this->states[$fieldName]); + } + + public function isCustomState(string $fieldName): bool + { + return ($this->states[$fieldName] ?? null) === static::STATE_CUSTOM; + } + + public function isParentState(string $fieldName): bool + { + return ($this->states[$fieldName] ?? null) === static::STATE_PARENT; + } + + public function isSourceState(string $fieldName): bool + { + return ($this->states[$fieldName] ?? null) === static::STATE_SOURCE; + } + + public function getState(string $fieldName): ?string + { + return $this->states[$fieldName] ?? null; + } + + /** + * Filters field names having a desired state. + * + * @return string[] + */ + public function filterFieldNames(string $desiredState, bool $modified = false): array + { + if (!$modified) { + $fieldNames = array_keys($this->states); + } else { + $fieldNames = $this->getModifiedFieldNames(); + } + return array_filter( + $fieldNames, + function (string $fieldName) use ($desiredState): bool { + return $this->states[$fieldName] === $desiredState; + } + ); + } + + /** + * Filter out field names that don't exist in TCA. + * + * @return string[] + */ + protected function sanitize(array $states): array + { + $fieldNames = static::getFieldNames($this->tableName); + return array_intersect_key( + $states, + array_combine($fieldNames, $fieldNames) ?: [] + ); + } + + /** + * Add missing states for field names. + */ + protected function enrich(array $states): array + { + foreach (static::getFieldNames($this->tableName) as $fieldName) { + $isValid = in_array( + $states[$fieldName] ?? null, + static::VALID_STATES, + true + ); + if ($isValid) { + continue; + } + $states[$fieldName] = static::STATE_PARENT; + } + return $states; + } +} diff --git a/Classes/DataHandling/Localization/VersionToLiveIdMap.php b/Classes/DataHandling/Localization/VersionToLiveIdMap.php new file mode 100644 index 0000000..a6a2d12 --- /dev/null +++ b/Classes/DataHandling/Localization/VersionToLiveIdMap.php @@ -0,0 +1,112 @@ +tableName = $tableName; + $this->workspaceId = $workspaceId; + } + + /** + * @param int[] $ids + */ + public function update(array $ids): self + { + $ids = array_map(intval(...), $ids); + $candidateIds = array_diff( + $ids, + array_keys($this->map), + array_values($this->map) + ); + + if (empty($candidateIds)) { + return $this; + } + + $candidateIdMap = array_combine($candidateIds, $candidateIds); + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + if ( + !$schemaFactory->has($this->tableName) + || $this->workspaceId === 0 + || !$schemaFactory->get($this->tableName)->isWorkspaceAware() + ) { + $this->map += $candidateIdMap; + return $this; + } + + $plainDataResolver = GeneralUtility::makeInstance( + PlainDataResolver::class, + $this->tableName, + [] + ); + $plainDataResolver->setWorkspaceId($this->workspaceId); + $plainDataResolver->setKeepLiveIds(true); + $versionLiveIdMap = $plainDataResolver->applyLiveIds($candidateIdMap); + $this->map += $versionLiveIdMap; + + return $this; + } + + public function getVersionId(int $liveId): int + { + $versionId = array_search($liveId, $this->map, true); + return $versionId ?: $liveId; + } + + public function getLiveId(int $versionId): string|int + { + return $this->map[$versionId] ?? $versionId; + } + + /** + * @param int[] $versionIds + * @return int[] + */ + public function getLiveIds(array $versionIds): array + { + return array_map( + function (int $versionId) { + return $this->getLiveId($versionId); + }, + $versionIds + ); + } +} diff --git a/Classes/DataHandling/Model/CorrelationId.php b/Classes/DataHandling/Model/CorrelationId.php new file mode 100644 index 0000000..72f9fdc --- /dev/null +++ b/Classes/DataHandling/Model/CorrelationId.php @@ -0,0 +1,149 @@ +[[:xdigit:]]{4})\$(?:(?P[[:alnum:]_-]+):)?(?P[[:alnum:]_-]+)(?P(?:\/[[:alnum:]._-]+)*)$#'; + protected int $version = self::DEFAULT_VERSION; + protected ?string $scope = null; + protected int $capabilities = 0; + protected ?string $subject = null; + + /** + * @var string[] + */ + protected array $aspects = []; + + public static function forScope(string $scope): self + { + $target = static::create(); + $target->scope = $scope; + return $target; + } + + public static function forSubject(string $subject, string ...$aspects): self + { + return static::create() + ->withSubject($subject) + ->withAspects(...$aspects); + } + + public static function fromString(string $correlationId): self + { + if (!preg_match(self::PATTERN_V1, $correlationId, $matches, PREG_UNMATCHED_AS_NULL)) { + throw new \InvalidArgumentException('Unknown format', 1569620858); + } + + $flags = hexdec($matches['flags']); + $aspects = $matches['aspects'] === '' ? [] : explode('/', ltrim($matches['aspects'], '/')); + $target = static::create() + ->withSubject($matches['subject']) + ->withAspects(...$aspects); + $target->scope = $matches['scope'] ?? null; + $target->version = $flags >> 10; + $target->capabilities = $flags & ((1 << 10) - 1); + return $target; + } + + protected static function create(): self + { + return GeneralUtility::makeInstance(static::class); + } + + public function __toString(): string + { + if ($this->subject === null) { + throw new \LogicException('Cannot serialize for empty subject', 1569668681); + } + return $this->serialize(); + } + + public function jsonSerialize(): string + { + return (string)$this; + } + + public function withSubject(string $subject): self + { + if ($this->subject === $subject) { + return $this; + } + $target = clone $this; + $target->subject = $subject; + return $target; + } + + public function withAspects(string ...$aspects): self + { + if ($this->aspects === $aspects) { + return $this; + } + $target = clone $this; + $target->aspects = $aspects; + return $target; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + /** + * @return string[] + */ + public function getAspects(): array + { + return $this->aspects; + } + + /** + * v1 specs (eBNF) + * + FLAGS "$" [ SCOPE ":" ] SUBJECT { "/" ASPECT } + * + FLAGS ::= XDIGIT (* 16-bit integer big-endian) + * + SCOPE ::= ALNUM { ALNUM } + * + SUBJECT ::= ALNUM { ALNUM } + * + ASPECT ::= ( ALNUM | '.' | '_' | '-' ) { ( ALNUM | '.' | '_' | '-' ) } + */ + protected function serialize(): string + { + // 6-bit version 10-bit capabilities + $flags = $this->version << 10 + $this->capabilities; + return sprintf( + '%s$%s%s%s', + bin2hex(pack('n', $flags)), + $this->scope ? $this->scope . ':' : '', + $this->subject, + $this->aspects ? '/' . implode('/', $this->aspects) : '' + ); + } +} diff --git a/Classes/DataHandling/Model/EntityContext.php b/Classes/DataHandling/Model/EntityContext.php new file mode 100644 index 0000000..432a534 --- /dev/null +++ b/Classes/DataHandling/Model/EntityContext.php @@ -0,0 +1,73 @@ +workspaceId; + } + + /** + * @return static + */ + public function withWorkspaceId(int $workspaceId): self + { + if ($this->workspaceId === $workspaceId) { + return $this; + } + $target = clone $this; + $target->workspaceId = $workspaceId; + return $target; + } + + public function getLanguageId(): int + { + return $this->languageId; + } + + /** + * @return static + */ + public function withLanguageId(int $languageId): self + { + if ($this->languageId === $languageId) { + return $this; + } + $target = clone $this; + $target->languageId = $languageId; + return $target; + } +} diff --git a/Classes/DataHandling/Model/EntityPointer.php b/Classes/DataHandling/Model/EntityPointer.php new file mode 100644 index 0000000..6909ccb --- /dev/null +++ b/Classes/DataHandling/Model/EntityPointer.php @@ -0,0 +1,32 @@ +subject = $subject; + } + + public function getSubject(): EntityPointer + { + return $this->subject; + } + + public function getHead(): EntityPointerLink + { + $head = $this; + while ($head->ancestor !== null) { + $head = $head->ancestor; + } + return $head; + } + + public function getAncestor(): ?EntityPointerLink + { + return $this->ancestor; + } + + public function withAncestor(EntityPointerLink $ancestor): self + { + if ($this->ancestor === $ancestor) { + return $this; + } + $target = clone $this; + $target->ancestor = $ancestor; + return $target; + } +} diff --git a/Classes/DataHandling/Model/EntityUidPointer.php b/Classes/DataHandling/Model/EntityUidPointer.php new file mode 100644 index 0000000..776ccb0 --- /dev/null +++ b/Classes/DataHandling/Model/EntityUidPointer.php @@ -0,0 +1,74 @@ +name = $name; + $this->identifier = $identifier; + } + + public function getName(): string + { + return $this->name; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return static + */ + public function withUid(string $identifier): self + { + if ($this->identifier === $identifier) { + return $this; + } + $target = clone $this; + $target->identifier = $identifier; + return $target; + } + + public function isNode(): bool + { + return $this->name === 'pages'; + } + + public function isEqualTo(EntityPointer $other): bool + { + return $this->identifier === $other->getIdentifier() + && $this->name === $other->getName(); + } +} diff --git a/Classes/DataHandling/Model/RecordState.php b/Classes/DataHandling/Model/RecordState.php new file mode 100644 index 0000000..0c8c41c --- /dev/null +++ b/Classes/DataHandling/Model/RecordState.php @@ -0,0 +1,178 @@ +context = $context; + $this->node = $node; + $this->subject = $subject; + } + + public function getContext(): EntityContext + { + return $this->context; + } + + public function getNode(): EntityPointer + { + return $this->node; + } + + public function getSubject(): EntityUidPointer + { + return $this->subject; + } + + /** + * @return EntityPointerLink + */ + public function getLanguageLink(): ?EntityPointerLink + { + return $this->languageLink; + } + + /** + * @return static + */ + public function withLanguageLink(?EntityPointerLink $languageLink): self + { + if ($this->languageLink === $languageLink) { + return $this; + } + $target = clone $this; + $target->languageLink = $languageLink; + return $target; + } + + /** + * @return EntityPointerLink + */ + public function getVersionLink(): ?EntityPointerLink + { + return $this->versionLink; + } + + /** + * @return static + */ + public function withVersionLink(?EntityPointerLink $versionLink): self + { + if ($this->versionLink === $versionLink) { + return $this; + } + $target = clone $this; + $target->versionLink = $versionLink; + return $target; + } + + public function isNew(): bool + { + return !MathUtility::canBeInterpretedAsInteger( + $this->subject->getIdentifier() + ); + } + + /** + * Resolves node identifier (`pid`) of current subject. For translated pages + * that would result in the `uid` of the outer-most language parent page + * otherwise it's the `pid` of the current subject. + * + * Example: + * + pages: uid: 10, pid: 5, sys_language_uid: 0, l10n_parent: 0 -> returns 5 + * + pages: uid: 11, pid: 5, sys_language_uid: 1, l10n_parent: 10 -> returns 10 + * + other: uid: 12, pid: 10 -> returns 10 + */ + public function resolveNodeIdentifier(): string + { + if ($this->subject->isNode() + && $this->context->getLanguageId() > 0 + && $this->languageLink !== null + ) { + return $this->languageLink->getHead()->getSubject()->getIdentifier(); + } + return $this->node->getIdentifier(); + } + + /** + * Resolves node identifier used as aggregate for current subject. For translated + * pages that would result in the `uid` of the outer-most language parent page, + * for pages it's the identifier of the current subject, otherwise it's + * the `pid` of the current subject. + * + * Example: + * + pages: uid: 10, pid: 5, sys_language_uid: 0, l10n_parent: 0 -> returns 10 + * + pages: uid: 11, pid: 5, sys_language_uid: 1, l10n_parent: 10 -> returns 10 + * + pages in version, return online page ID + * + other: uid: 12, pid: 10 -> returns 10 + */ + public function resolveNodeAggregateIdentifier(): string + { + if ($this->subject->isNode() + && $this->context->getLanguageId() > 0 + && $this->languageLink !== null + ) { + return $this->languageLink->getHead()->getSubject()->getIdentifier(); + } + if ($this->subject->isNode() && $this->versionLink) { + return $this->versionLink->getHead()->getSubject()->getIdentifier(); + } + if ($this->subject->isNode()) { + return $this->subject->getIdentifier(); + } + return $this->node->getIdentifier(); + } +} diff --git a/Classes/DataHandling/Model/RecordStateFactory.php b/Classes/DataHandling/Model/RecordStateFactory.php new file mode 100644 index 0000000..ccc344f --- /dev/null +++ b/Classes/DataHandling/Model/RecordStateFactory.php @@ -0,0 +1,159 @@ +name = $name; + } + + /** + * @param int|string|null $pageId + * @param int|string|null $recordId + */ + public function fromArray(array $data, $pageId = null, $recordId = null): RecordState + { + $pageId = $pageId ?? $data['pid'] ?? null; + $recordId = $recordId ?? $data['uid'] ?? null; + + $aspectFieldValues = $this->resolveAspectFieldValues($data); + + $context = GeneralUtility::makeInstance(EntityContext::class) + ->withWorkspaceId($aspectFieldValues['workspace']) + ->withLanguageId($aspectFieldValues['language']); + $node = $this->createEntityPointer($pageId, 'pages'); + $subject = $this->createEntityPointer($recordId); + + $target = GeneralUtility::makeInstance( + RecordState::class, + $context, + $node, + $subject + ); + return $target + ->withLanguageLink($this->resolveLanguageLink($aspectFieldValues)) + ->withVersionLink($this->resolveVersionLink($aspectFieldValues)); + } + + /** + * @return array + */ + protected function resolveAspectFieldNames(): array + { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->name); + $languageCapability = null; + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + } + return [ + 'workspace' => 't3ver_wsid', + 'versionParent' => 't3ver_oid', + 'language' => $languageCapability?->getLanguageField()->getName(), + 'languageParent' => $languageCapability?->getTranslationOriginPointerField()->getName(), + 'languageSource' => $languageCapability?->getTranslationSourceField()?->getName(), + ]; + } + + protected function resolveAspectFieldValues(array $data): array + { + return array_map( + static function (?string $aspectFieldName) use ($data): int { + return (int)($data[$aspectFieldName ?? ''] ?? 0); + }, + $this->resolveAspectFieldNames() + ); + } + + protected function resolveLanguageLink(array $aspectFieldNames): ?EntityPointerLink + { + $languageSourceLink = null; + $languageParentLink = null; + if (!empty($aspectFieldNames['languageSource'])) { + $languageSourceLink = GeneralUtility::makeInstance( + EntityPointerLink::class, + $this->createEntityPointer($aspectFieldNames['languageSource']) + ); + } + + if (!empty($aspectFieldNames['languageParent'])) { + $languageParentLink = GeneralUtility::makeInstance( + EntityPointerLink::class, + $this->createEntityPointer($aspectFieldNames['languageParent']) + ); + } + + if (empty($languageSourceLink) || empty($languageParentLink) + || $languageSourceLink->getSubject()->isEqualTo( + $languageParentLink->getSubject() + ) + ) { + return $languageSourceLink ?? $languageParentLink ?? null; + } + return $languageSourceLink->withAncestor($languageParentLink); + } + + protected function resolveVersionLink(array $aspectFieldNames): ?EntityPointerLink + { + if (!empty($aspectFieldNames['versionParent'])) { + return GeneralUtility::makeInstance( + EntityPointerLink::class, + $this->createEntityPointer($aspectFieldNames['versionParent']) + ); + } + return null; + } + + /** + * @param string|int|null $identifier + * @param string|null $name + * @throws \LogicException + */ + protected function createEntityPointer($identifier, ?string $name = null): EntityPointer + { + if ($identifier === null) { + throw new \LogicException( + 'Cannot create null pointer', + 1536407967 + ); + } + + $identifier = (string)$identifier; + + return GeneralUtility::makeInstance( + EntityUidPointer::class, + $name ?? $this->name, + $identifier + ); + } +} diff --git a/Classes/DataHandling/PageDoktypeRegistry.php b/Classes/DataHandling/PageDoktypeRegistry.php new file mode 100644 index 0000000..9b8663c --- /dev/null +++ b/Classes/DataHandling/PageDoktypeRegistry.php @@ -0,0 +1,150 @@ +getAllowedTypesForDoktype($doktype); + if (in_array('*', $allowedRecordTypes, true)) { + return true; + } + return in_array($type, $allowedRecordTypes, true); + } + + /** + * @internal only to be used within TYPO3 Core + * @return string[] + */ + public function getAllowedTypesForDoktype(int $doktype): array + { + $pagesSchema = $this->tcaSchemaFactory->get('pages'); + if ($pagesSchema->hasSubSchema((string)$doktype)) { + $pageTypeSchema = $pagesSchema->getSubSchema((string)$doktype); + $allowedRecordTypes = $pageTypeSchema->getRawConfiguration()['allowedRecordTypes'] ?? []; + if ($allowedRecordTypes !== []) { + return $allowedRecordTypes; + } + } + $hardDefaults = ['pages', 'sys_category', 'sys_file_reference', 'sys_file_collection']; + $defaultAllowedRecordTypes = $pagesSchema->getRawConfiguration()['defaultAllowedRecordTypes'] ?? []; + $mergedDefault = array_merge($hardDefaults, $defaultAllowedRecordTypes); + return array_unique($mergedDefault); + } + + /** + * @return SelectItem[] + */ + public function getAllDoktypes(): array + { + $doktypeLabelMap = []; + $schema = $this->tcaSchemaFactory->get('pages'); + // @todo Does not work for dynamic items, in case SubSchemaDivisorField is no StaticSelectFieldType! + $subSchemaField = $schema->getSubSchemaTypeInformation()->getFieldName(); + foreach ($schema->getField($subSchemaField)->getConfiguration()['items'] ?? [] as $doktypeItemConfig) { + $selectionItem = SelectItem::fromTcaItemArray($doktypeItemConfig); + if ($selectionItem->isDivider()) { + continue; + } + $doktypeLabelMap[] = $selectionItem; + } + return $doktypeLabelMap; + } + + /** + * Check if a page type is viewable based on TCA configuration only. + * Does NOT consider pageTsConfig overrides. + * + * By default, all page types are viewable unless explicitly set to false + * via the TCA option "isViewable". + */ + public function isPageTypeViewable(int $doktype): bool + { + $pageSchema = $this->tcaSchemaFactory->get('pages'); + if ($pageSchema->hasSubSchema((string)$doktype)) { + $subSchema = $pageSchema->getSubSchema((string)$doktype); + $config = $subSchema->getRawConfiguration(); + if (isset($config['isViewable'])) { + return (bool)$config['isViewable']; + } + } + // Default: viewable + return true; + } + + /** + * Check if a page is viewable, considering both TCA and pageTsConfig. + * Respects TCEMAIN.preview.disableButtonForDokType TSconfig. + */ + public function isPageViewable(int $doktype, int $pageId): bool + { + // check TSconfig (same logic as PreviewUriBuilder::isPreviewableDoktype) + $TSconfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.']['preview.'] ?? []; + if (isset($TSconfig['disableButtonForDokType'])) { + $excludeDokTypes = GeneralUtility::intExplode(',', (string)$TSconfig['disableButtonForDokType'], true); + return !in_array($doktype, $excludeDokTypes, true); + } + + // fallback to check TCA + if (!$this->isPageTypeViewable($doktype)) { + return false; + } + return true; + } + + /** + * Returns array of non-viewable doktype integers based on TCA only. + * Used for JavaScript tree configuration. + * + * @return int[] + */ + public function getNonViewableDoktypes(): array + { + $nonViewable = []; + foreach ($this->tcaSchemaFactory->get('pages')->getSubSchemata() as $doktype => $schema) { + $isViewable = $schema->getRawConfiguration()['isViewable'] ?? true; + if (!$isViewable) { + $nonViewable[] = (int)$doktype; + } + } + return $nonViewable; + } +} diff --git a/Classes/DataHandling/PagePermissionAssembler.php b/Classes/DataHandling/PagePermissionAssembler.php new file mode 100644 index 0000000..d9891b3 --- /dev/null +++ b/Classes/DataHandling/PagePermissionAssembler.php @@ -0,0 +1,119 @@ +assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['user'] ?? 'show,edit,delete,new,editcontent'); + $fieldArray['perms_group'] = $this->assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['group'] ?? 'show,edit,new,editcontent'); + $fieldArray['perms_everybody'] = $this->assemblePermissions($GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['everybody'] ?? ''); + // @todo: It's kinda ugly pageTS is fetched here on demand. Together with the 'fetch parent page' code in + // setTSconfigPermissions(), we should think about changing the API to have these things hand + // over instead. + $TSConfig = BackendUtility::getPagesTSconfig($pid)['TCEMAIN.'] ?? []; + if (isset($TSConfig['permissions.']) && is_array($TSConfig['permissions.'])) { + return $this->setTSconfigPermissions($fieldArray, $TSConfig['permissions.']); + } + return $fieldArray; + } + + /** + * Setting up perms_* fields in $fieldArray based on TSconfig input + * Used for new pages and pages that are copied. + * + * @param array $fieldArray Field Array, returned with modifications + * @param array $tsconfig TSconfig properties + * @return array Modified Field Array + */ + protected function setTSconfigPermissions(array $fieldArray, array $tsconfig): array + { + $parentPermissions = []; + if (in_array('copyFromParent', $tsconfig, true)) { + // @todo: Dislocated! The API should be changed to have a potential parent record hand over. + $parentPermissions = BackendUtility::getRecordWSOL('pages', $fieldArray['pid'], 'uid,perms_userid,perms_groupid,perms_user,perms_group,perms_everybody') ?? []; + } + if ((string)($tsconfig['userid'] ?? '') !== '' && ($tsconfig['userid'] !== 'copyFromParent' || isset($parentPermissions['perms_userid']))) { + $fieldArray['perms_userid'] = $tsconfig['userid'] === 'copyFromParent' ? (int)$parentPermissions['perms_userid'] : (int)$tsconfig['userid']; + } + if ((string)($tsconfig['groupid'] ?? '') !== '' && ($tsconfig['groupid'] !== 'copyFromParent' || isset($parentPermissions['perms_groupid']))) { + $fieldArray['perms_groupid'] = $tsconfig['groupid'] === 'copyFromParent' ? (int)$parentPermissions['perms_groupid'] : (int)$tsconfig['groupid']; + } + if ((string)($tsconfig['user'] ?? '') !== '' && ($tsconfig['user'] !== 'copyFromParent' || isset($parentPermissions['perms_user']))) { + $fieldArray['perms_user'] = $tsconfig['user'] === 'copyFromParent' ? (int)$parentPermissions['perms_user'] : $this->assemblePermissions($tsconfig['user']); + } + if ((string)($tsconfig['group'] ?? '') !== '' && ($tsconfig['group'] !== 'copyFromParent' || isset($parentPermissions['perms_group']))) { + $fieldArray['perms_group'] = $tsconfig['group'] === 'copyFromParent' ? (int)$parentPermissions['perms_group'] : $this->assemblePermissions($tsconfig['group']); + } + if ((string)($tsconfig['everybody'] ?? '') !== '' && ($tsconfig['everybody'] !== 'copyFromParent' || isset($parentPermissions['perms_everybody']))) { + $fieldArray['perms_everybody'] = $tsconfig['everybody'] === 'copyFromParent' ? (int)$parentPermissions['perms_everybody'] : $this->assemblePermissions($tsconfig['everybody']); + } + return $fieldArray; + } + + /** + * Calculates the bit value of the permissions given in a string, comma-separated. + * + * Even though not documented, it seems to be possible having int values in + * $GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']['...'] as bit mask + * already. To not break anything, this is kept for now. + */ + protected function assemblePermissions(int|string $listOfPermissions): int + { + // Already set as integer, so this one is used. + if (MathUtility::canBeInterpretedAsInteger($listOfPermissions)) { + return (int)$listOfPermissions; + } + $keyArr = GeneralUtility::trimExplode(',', $listOfPermissions, true); + $value = 0; + $permissionMap = Permission::getMap(); + foreach ($keyArr as $key) { + if ($key && isset($permissionMap[$key])) { + $value |= $permissionMap[$key]; + } + } + return $value; + } +} diff --git a/Classes/DataHandling/PlainDataResolver.php b/Classes/DataHandling/PlainDataResolver.php new file mode 100644 index 0000000..16eb0dd --- /dev/null +++ b/Classes/DataHandling/PlainDataResolver.php @@ -0,0 +1,403 @@ +tableName = $tableName; + $this->liveIds = $this->reindex($this->sanitizeIds($liveIds)); + $this->sortingStatement = $sortingStatement; + } + + /** + * Sets the target workspace ID the final result shall use. + * + * @param int $workspaceId + */ + public function setWorkspaceId($workspaceId) + { + $this->workspaceId = (int)$workspaceId; + } + + /** + * Sets whether live IDs shall be kept in the final result set. + * + * @param bool $keepLiveIds + * @return PlainDataResolver + */ + public function setKeepLiveIds($keepLiveIds) + { + $this->keepLiveIds = (bool)$keepLiveIds; + return $this; + } + + /** + * Sets whether delete placeholders shall be kept in the final result set. + * + * @param bool $keepDeletePlaceholder + * @return PlainDataResolver + */ + public function setKeepDeletePlaceholder($keepDeletePlaceholder) + { + $this->keepDeletePlaceholder = (bool)$keepDeletePlaceholder; + return $this; + } + + /** + * Sets whether move placeholders shall be kept in case they cannot be substituted. + * + * @param bool $keepMovePlaceholder + * @return PlainDataResolver + */ + public function setKeepMovePlaceholder($keepMovePlaceholder) + { + $this->keepMovePlaceholder = (bool)$keepMovePlaceholder; + return $this; + } + + /** + * @return int[] + */ + public function get() + { + $resolvedIds = $this->processVersionOverlays($this->liveIds); + if ($resolvedIds !== $this->liveIds) { + $resolvedIds = $this->reindex($resolvedIds); + } + + $tempIds = $this->processSorting($resolvedIds); + if ($tempIds !== $resolvedIds) { + $resolvedIds = $this->reindex($tempIds); + } + + $tempIds = $this->applyLiveIds($resolvedIds); + if ($tempIds !== $resolvedIds) { + $resolvedIds = $this->reindex($tempIds); + } + + return $resolvedIds; + } + + /** + * Processes version overlays on the final result set. + * + * @param int[] $ids + * @return int[] + * @internal + */ + public function processVersionOverlays(array $ids) + { + $ids = $this->sanitizeIds($ids); + if (empty($this->workspaceId) || !$this->isWorkspaceEnabled() || empty($ids)) { + return $ids; + } + + $ids = $this->reindex( + $this->processVersionMovePlaceholders($ids) + ); + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->tableName); + + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $result = $queryBuilder + ->select('uid', 't3ver_oid', 't3ver_state') + ->from($this->tableName) + ->where( + $queryBuilder->expr()->in( + 't3ver_oid', + $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter($this->workspaceId, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + + while ($version = $result->fetchAssociative()) { + $liveReferenceId = (int)$version['t3ver_oid']; + $versionId = (int)$version['uid']; + if (isset($ids[$liveReferenceId])) { + if (!$this->keepDeletePlaceholder + && VersionState::tryFrom((int)($version['t3ver_state'] ?? 0)) === VersionState::DELETE_PLACEHOLDER + ) { + unset($ids[$liveReferenceId]); + } else { + $ids[$liveReferenceId] = $versionId; + } + } + } + + return $ids; + } + + /** + * Processes and resolves move placeholders on the final result set. + * + * @param int[] $ids + * @return int[] + * @internal + */ + public function processVersionMovePlaceholders(array $ids) + { + $ids = $this->sanitizeIds($ids); + // Early return on insufficient data-set + if (empty($this->workspaceId) || !$this->isWorkspaceEnabled() || empty($ids)) { + return $ids; + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->tableName); + + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $result = $queryBuilder + ->select('uid', 't3ver_oid') + ->from($this->tableName) + ->where( + $queryBuilder->expr()->eq( + 't3ver_state', + $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter($this->workspaceId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->in( + 't3ver_oid', + $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY) + ) + ) + ->executeQuery(); + + while ($movedRecord = $result->fetchAssociative()) { + $liveReferenceId = (int)$movedRecord['t3ver_oid']; + $movedVersionId = (int)$movedRecord['uid']; + // Substitute moved record and purge live reference + if (isset($ids[$movedVersionId])) { + $ids[$movedVersionId] = $liveReferenceId; + unset($ids[$liveReferenceId]); + } elseif (!$this->keepMovePlaceholder) { + // Just purge live reference + unset($ids[$liveReferenceId]); + } + } + + return $ids; + } + + /** + * Processes sorting of the final result set, if + * a sorting statement (table column/expression) is given. + * + * @param int[] $ids + * @return int[] + * @internal + */ + public function processSorting(array $ids) + { + $ids = $this->sanitizeIds($ids); + // Early return on missing sorting statement or insufficient data-set + if (empty($this->sortingStatement) || count($ids) < 2) { + return $ids; + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName); + // Never apply additional restrictions like 'deleted' to the incoming id list + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder + ->select('uid') + ->from($this->tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + // do not use named parameter here as the list can get too long + array_map(intval(...), $ids) + ) + ); + + foreach ($this->sortingStatement as $sortingStatement) { + $queryBuilder->getConcreteQueryBuilder()->addOrderBy($sortingStatement); + } + // Always add explicit order by uid to have deterministic rows from dbms like postgres. + // Scenario (see workspace FAL/Modify/ActionTest modifyContentAndDeleteFileReference): + // A content element with two images - sys_file_reference uid=23 with sorting_foreign=2 (!) + // and sys_file_reference uid=42 with sorting_foreign=1. The references have been added + // and later changed their sorting that uid 42 is before 23. + // Then, in workspaces, image reference 42 is deleted and 23 is changed (eg. title). This + // creates two overlays: a 'delete placeholder' t3ver_state=2 with sorting_foreign=1 for 42, + // and a 'changed' record t3ver_state=0 with sorting_foreign=1 for 23. + // So both overlay records end up with sorting_foreign=1. This is technically ok since the + // 'delete placeholder' "does not exist" from a live relation point of view, so the next + // "real" record starts with 1 when published. + // BUT, this scenario makes the order of returned rows non-deterministic for dbms that + // do not implicitly order by uid (mysql does, postgres does not): The usual orderBy + // is 'sorting_foreign' but both are 1 now. + // We thus add a general explicit order by uid here to force deterministic row returns. + $queryBuilder->addOrderBy('uid'); + + $sortedIds = $queryBuilder->executeQuery()->fetchAllAssociative(); + + return array_map(intval(...), array_column($sortedIds, 'uid')); + } + + /** + * Applies live IDs to the final result set, if + * the current table is enabled for workspaces and + * the keepLiveIds class member is enabled. + * + * @param int[] $ids + * @return int[] + * @internal + */ + public function applyLiveIds(array $ids) + { + $ids = $this->sanitizeIds($ids); + if (!$this->keepLiveIds || !$this->isWorkspaceEnabled() || empty($ids)) { + return $ids; + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->tableName); + + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $result = $queryBuilder + ->select('uid', 't3ver_oid') + ->from($this->tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY) + ) + ) + ->executeQuery(); + + $versionIds = []; + while ($record = $result->fetchAssociative()) { + $liveId = (int)$record['uid']; + $versionIds[$liveId] = (int)$record['t3ver_oid']; + } + + foreach ($ids as $id) { + if (!empty($versionIds[$id])) { + $ids[$id] = $versionIds[$id]; + } + } + + return $ids; + } + + /** + * Re-indexes the given IDs. + * + * @param int[] $ids + * @return int[] + */ + protected function reindex(array $ids) + { + if (empty($ids)) { + return $ids; + } + $ids = array_values($ids); + $ids = array_combine($ids, $ids); + return $ids; + } + + /** + * Removes empty values (null, '0', 0, false). + * + * @param int[] $ids + */ + protected function sanitizeIds(array $ids): array + { + return array_filter($ids); + } + + /** + * @return bool + */ + protected function isWorkspaceEnabled() + { + if (ExtensionManagementUtility::isLoaded('workspaces')) { + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + return $schemaFactory->has($this->tableName) && $schemaFactory->get($this->tableName)->hasCapability(TcaSchemaCapability::Workspace); + } + return false; + } +} diff --git a/Classes/DataHandling/RecordFieldTransformer.php b/Classes/DataHandling/RecordFieldTransformer.php new file mode 100644 index 0000000..7190c3a --- /dev/null +++ b/Classes/DataHandling/RecordFieldTransformer.php @@ -0,0 +1,255 @@ +get($fieldInformation->getName()); + + // type=file needs to be handled before RelationalFieldTypeInterface + if ($fieldInformation instanceof FileFieldType) { + if ($fieldInformation->getRelationshipType()->hasOne()) { + return new RecordPropertyClosure( + function () use ($rawRecord, $fieldInformation, $context): ?FileReference { + $fileReference = $this->relationResolver->resolveFileReferences($rawRecord, $fieldInformation, $context)[0] ?? null; + if ($fileReference === null) { + return null; + } + return new FileReference($fileReference->getProperties()); + } + ); + } + return new LazyFileReferenceCollection($fieldValue, function () use ($rawRecord, $fieldInformation, $context): array { + return $this->relationResolver->resolveFileReferences($rawRecord, $fieldInformation, $context); + }); + } + + if ($fieldInformation instanceof RelationalFieldTypeInterface) { + /** @var RecordFactory $recordFactory */ + // @todo This method is called by RecordFactory -> instantiating the factory here again shows, that those classes should actually be somehow belong together. + $recordFactory = GeneralUtility::makeInstance(RecordFactory::class); + if ($fieldInformation->getRelationshipType()->hasOne()) { + return new RecordPropertyClosure( + function () use ($rawRecord, $fieldInformation, $context, $recordFactory, $recordIdentityMap): ?RecordInterface { + $recordData = $this->relationResolver->resolve($rawRecord, $fieldInformation, $context)[0] ?? null; + if ($recordData === null) { + return null; + } + $dbTable = $recordData['table']; + $row = $recordData['row']; + return $recordFactory->createResolvedRecordFromDatabaseRow($dbTable, $row, $context, $recordIdentityMap); + } + ); + } + return new LazyRecordCollection( + $fieldValue, + function () use ($rawRecord, $fieldInformation, $context, $recordFactory, $recordIdentityMap): array { + $relationalRecords = []; + $recordData = $this->relationResolver->resolve($rawRecord, $fieldInformation, $context); + foreach ($recordData as $singleRecordData) { + $dbTable = $singleRecordData['table']; + $row = $singleRecordData['row']; + $relationalRecords[] = $recordFactory->createResolvedRecordFromDatabaseRow($dbTable, $row, $context, $recordIdentityMap); + } + return $relationalRecords; + } + ); + } + + if ($fieldInformation->isType(TableColumnType::FOLDER)) { + if (in_array((string)($fieldInformation->getConfiguration()['relationship'] ?? ''), ['oneToOne', 'manyToOne'], true)) { + return new RecordPropertyClosure( + function () use ($fieldValue): ?Folder { + $folder = $this->resolveFoldersRecursive(GeneralUtility::trimExplode(',', (string)$fieldValue, true, 1))[0] ?? null; + if ($folder === null) { + return null; + } + return new Folder($folder->getStorage(), $folder->getIdentifier(), $folder->getName()); + } + ); + } + return new LazyFolderCollection($fieldValue, function () use ($fieldValue): array { + return $this->resolveFoldersRecursive(GeneralUtility::trimExplode(',', (string)$fieldValue, true)); + }); + } + + // Static select lists is transformed into an array of values + if ($fieldInformation instanceof StaticSelectFieldType) { + $selectForcedToSingle = (string)($fieldInformation->getConfiguration()['renderType'] ?? '') === 'selectSingle'; + return $selectForcedToSingle ? $fieldValue : GeneralUtility::trimExplode(',', (string)$fieldValue, true); + } + if ($fieldInformation->isType(TableColumnType::FLEX)) { + /** @var FlexFormFieldType $fieldInformation */ + return new RecordPropertyClosure(fn(): FlexFormFieldValues => $this->processFlexForm($rawRecord, $fieldInformation, (string)$fieldValue, $context, $recordIdentityMap)); + } + if ($fieldInformation->isType(TableColumnType::JSON)) { + return new RecordPropertyClosure( + fn(): array|string|int|float|bool|null => Type::getType('json')->convertToPHPValue( + (string)$fieldValue, + $this->connectionPool->getConnectionForTable($rawRecord->getMainType())->getDatabasePlatform() + ) + ); + } + if ($fieldInformation instanceof DateTimeFieldType) { + return DateTimeFactory::createFromDatabaseValue($fieldValue, $fieldInformation); + } + if ($fieldInformation->isType(TableColumnType::LINK)) { + return new RecordPropertyClosure( + fn(): ?TypolinkParameter => $fieldValue === null && $fieldInformation->isNullable() ? null : TypolinkParameter::createFromTypolinkParts($this->typoLinkCodecService->decode((string)$fieldValue)) + ); + } + if ($fieldInformation->isType(TableColumnType::COUNTRY)) { + if ($fieldValue === null && $fieldInformation->isNullable()) { + return null; + } + return $this->countryProvider->getByIsoCode((string)$fieldValue) ?? ''; + } + return $fieldValue; + } + + /** + * @return Folder[] + */ + protected function resolveFoldersRecursive(array $folders): array + { + $foldersRecursive = []; + foreach ($folders as $singleFolder) { + if ($singleFolder instanceof Folder === false) { + $singleFolder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($singleFolder); + } + $foldersRecursive[] = $singleFolder; + array_push($foldersRecursive, ...$this->resolveFoldersRecursive($singleFolder->getSubfolders())); + } + return $foldersRecursive; + } + + /** + * This method creates an array which contains all information which is valid from the + * selected Schema. Ideally, this should be "FlexRecord" objects, and also keep the original values. + * This functionality will likely change in the future. + */ + protected function processFlexForm( + RawRecord $record, + FlexFormFieldType $fieldInformation, + mixed $fieldValue, + Context $context, + RecordIdentityMap $recordIdentityMap, + ): FlexFormFieldValues { + $plainValues = $this->flexFormTools->convertFlexFormContentToSheetsArray((string)$fieldValue); + // @todo: RelationMap does not work in FlexForm currently, as we do not have this information persisted somewhere + $usedSchema = $this->flexFormSchemaFactory->getSchemaForRecord($record, $fieldInformation, new RelationMap()); + if ($usedSchema === null) { + return new FlexFormFieldValues($plainValues); + } + $recordFactory = GeneralUtility::makeInstance(RecordFactory::class); + $transformedValues = []; + foreach ($plainValues as $sheetName => $values) { + // Flatten keys (because we receive settings[mysetting] and we want settings.mysetting) + $values = ArrayUtility::flattenPlain($values); + foreach ($values as $fieldName => &$plainFieldValue) { + // That's a "fun" workaround: In order to allow to process e.g. "sDEF/header", we need + // to add this to the "rawRecord" (thus, we clone it), so it is within the array + // and then set "sDEF/header" even though this is not a DB field. Then we keep it in "$fieldName" + // which actually is the plain field name (in this case "header") + $fieldInformationOfFlexField = $usedSchema->getField($fieldName, $sheetName); + // No field given, we just skip the value, as it is not properly defined + if ($fieldInformationOfFlexField === null) { + continue; + } + $rawRecordValues = array_replace($record->toArray(), [$fieldInformationOfFlexField->getName() => $plainFieldValue]); + $fakeRawRecordWithFlexField = $recordFactory->createRawRecord($record->getMainType(), $rawRecordValues); + $transformedValue = $this->transformField($fieldInformationOfFlexField, $fakeRawRecordWithFlexField, $context, $recordIdentityMap); + $plainFieldValue = $transformedValue; + } + unset($plainFieldValue); + $transformedValues[$sheetName] = ArrayUtility::unflatten($values); + } + return new FlexFormFieldValues($transformedValues); + } +} diff --git a/Classes/DataHandling/ReferenceIndexUpdater.php b/Classes/DataHandling/ReferenceIndexUpdater.php new file mode 100644 index 0000000..e3ecdb1 --- /dev/null +++ b/Classes/DataHandling/ReferenceIndexUpdater.php @@ -0,0 +1,215 @@ + [ tableName => [ uid ] ] ] + * + * @var array>> + */ + protected array $updateRegistry = []; + + /** + * [ workspaceId => [ tableName => [ + * 'uid' => uid, + * 'targetWorkspace' => $targetWorkspace + * ] ] ] + * + * @var array>>> + */ + protected array $updateRegistryToItem = []; + + /** + * [ workspaceId => [ tableName => [ uid ] ] ] + * + * @var array>> + */ + protected array $dropRegistry = []; + + public function __construct( + private readonly TcaSchemaFactory $tcaSchemaFactory, + private readonly ConnectionPool $connectionPool, + private readonly ReferenceIndex $referenceIndex, + ) {} + + /** + * Register a workspace/table/uid row for update + * + * @param string $table Table name + * @param int $uid Record uid + * @param int $workspace Workspace the record lives in + */ + public function registerForUpdate(string $table, int $uid, int $workspace): void + { + if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) { + // If a user is in some workspace and changes relations of not workspace aware + // records, the reference index update needs to be performed as if the user + // is in live workspace. This is detected here and the update is registered for live. + $workspace = 0; + } + if (!isset($this->updateRegistry[$workspace][$table])) { + $this->updateRegistry[$workspace][$table] = []; + } + if (!in_array($uid, $this->updateRegistry[$workspace][$table], true)) { + $this->updateRegistry[$workspace][$table][] = $uid; + } + } + + /** + * Find reference index rows pointing to given table/uid combination and register them for update. Important in + * delete and publish scenarios where a child is deleted to make sure any references to this child are dropped, too. + * In publish scenarios reference index may exist for a non-live workspace, but should be updated for live workspace. + * The optional $targetWorkspace argument is used for this. + * + * @param string $table Table name, used as ref_table + * @param int $uid Record uid, used as ref_uid + * @param int $workspace The workspace given record lives in + * @param int|null $targetWorkspace The target workspace the record has been swapped to + */ + public function registerUpdateForReferencesToItem(string $table, int $uid, int $workspace, ?int $targetWorkspace = null): void + { + if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) { + // If a user is in some workspace and changes relations of not workspace aware + // records, the reference index update needs to be performed as if the user + // is in live workspace. This is detected here and the update is registered for live. + $workspace = 0; + } + if ($targetWorkspace === null) { + $targetWorkspace = $workspace; + } + if (!isset($this->updateRegistryToItem[$workspace][$table])) { + $this->updateRegistryToItem[$workspace][$table] = []; + } + $recordAndTargetWorkspace = [ + 'uid' => $uid, + 'targetWorkspace' => $targetWorkspace, + ]; + if (!in_array($recordAndTargetWorkspace, $this->updateRegistryToItem[$workspace][$table], true)) { + $this->updateRegistryToItem[$workspace][$table][] = $recordAndTargetWorkspace; + } + } + + /** + * Delete rows from sys_refindex a table / uid combination is involved in: + * Either on left side (tablename + recuid) OR right side (ref_table + ref_uid). + * Useful in scenarios like workspace-discard where parents or children are hard deleted: The + * expensive updateRefIndex() does not need to be called since we can just drop straight ahead. + * + * @param string $table Table name, used as tablename and ref_table + * @param int $uid Record uid, used as recuid and ref_uid + * @param int $workspace Workspace the record lives in + */ + public function registerForDrop(string $table, int $uid, int $workspace): void + { + if ($workspace && !$this->tcaSchemaFactory->get($table)->isWorkspaceAware()) { + // If a user is in some workspace and changes relations of not workspace aware + // records, the reference index update needs to be performed as if the user + // is in live workspace. This is detected here and the update is registered for live. + $workspace = 0; + } + if (!isset($this->dropRegistry[$workspace][$table])) { + $this->dropRegistry[$workspace][$table] = []; + } + if (!in_array($uid, $this->dropRegistry[$workspace][$table], true)) { + $this->dropRegistry[$workspace][$table][] = $uid; + } + } + + /** + * Perform the reference index update operations + */ + public function update(): void + { + // Register updates to an item for update + foreach ($this->updateRegistryToItem as $workspace => $tableArray) { + foreach ($tableArray as $table => $recordArray) { + foreach ($recordArray as $item) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $statement = $queryBuilder + ->select('tablename', 'recuid') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)), + $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($item['uid'], Connection::PARAM_INT)), + $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspace, Connection::PARAM_INT)) + ) + ->executeQuery(); + while ($row = $statement->fetchAssociative()) { + $this->registerForUpdate($row['tablename'], (int)$row['recuid'], (int)$item['targetWorkspace']); + } + } + } + } + $this->updateRegistryToItem = []; + + // Drop rows from reference index if requested. Note this is performed *after* update-to-item, to + // find rows pointing to a record and register updates before rows are dropped. Needed if a record + // changes the workspace during publish: In this case all records pointing to the record in a workspace + // need to be registered for update for live workspace and after that the workspace rows can be dropped. + foreach ($this->dropRegistry as $workspace => $tableArray) { + foreach ($tableArray as $table => $uidArray) { + foreach ($uidArray as $uid) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->delete('sys_refindex') + ->where( + $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspace, Connection::PARAM_INT)), + $queryBuilder->expr()->or( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($table)), + $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + ), + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)), + $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + ) + ) + ) + ->executeStatement(); + } + } + } + $this->dropRegistry = []; + + // Perform reference index updates + foreach ($this->updateRegistry as $workspace => $tableArray) { + foreach ($tableArray as $table => $uidArray) { + foreach ($uidArray as $uid) { + $this->referenceIndex->updateRefIndexTable($table, $uid, false, $workspace); + } + } + } + $this->updateRegistry = []; + } +} diff --git a/Classes/DataHandling/RelationResolver.php b/Classes/DataHandling/RelationResolver.php new file mode 100644 index 0000000..e0107ba --- /dev/null +++ b/Classes/DataHandling/RelationResolver.php @@ -0,0 +1,155 @@ +}> + */ + public function resolve(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array + { + $sortedAndGroupedIds = $this->getGroupedRelationIds($record, $fieldInformation, $context); + + $groupedByTable = []; + // group sorted items by table + foreach ($sortedAndGroupedIds as $item) { + $groupedByTable[$item['table']][] = (int)$item['id']; + } + $unorderedRows = $this->getRelationalRows($groupedByTable, $context); + $sortedRows = []; + // Sort the relation rows based on the field value + foreach ($sortedAndGroupedIds as $item) { + if (isset($unorderedRows[$item['table']][(int)$item['id']])) { + $sortedRows[] = $unorderedRows[$item['table']][(int)$item['id']]; + } + } + return $sortedRows; + } + + /** + * @return FileReference[] + */ + public function resolveFileReferences(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array + { + $sortedAndGroupedIds = $this->getGroupedRelationIds($record, $fieldInformation, $context); + $sortedFileReferenceIds = array_map(static fn(array $item) => (int)$item['id'], $sortedAndGroupedIds); + $unorderedRows = $this->greedyDatabaseBackend->getRows('sys_file_reference', $sortedFileReferenceIds, $context); + $unorderedRowsByUid = []; + foreach ($unorderedRows as $row) { + $unorderedRowsByUid[(int)$row['uid']] = $row; + } + $fileReferenceObjects = []; + foreach ($sortedAndGroupedIds as $item) { + if (isset($unorderedRowsByUid[(int)$item['id']])) { + $fileReferenceRow = $unorderedRowsByUid[(int)$item['id']]; + $fileReferenceObjects[] = $this->resourceFactory->createFileReferenceObject($fileReferenceRow); + } + } + return $fileReferenceObjects; + } + + /** + * We currently use the RelationHandler to resolve all records attached to a given field. + * @todo This will be replaced by querying the RefIndex directly in the future. + * + * @return array> + */ + protected function getGroupedRelationIds(RecordInterface $record, FieldTypeInterface $fieldInformation, Context $context): array + { + $rawRecord = $record instanceof Record ? $record->getRawRecord() : $record; + $recordData = $rawRecord->toArray(); + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->setWorkspaceId($context->getPropertyFromAspect('workspace', 'id', 0)); + if ($rawRecord instanceof RawRecord && $rawRecord->getComputedProperties()->getLocalizedUid() > 0) { + $relationHandler->initializeForField($record->getMainType(), $fieldInformation, $rawRecord->getComputedProperties()->getLocalizedUid(), $recordData[$fieldInformation->getName()] ?? null); + } else { + $relationHandler->initializeForField($record->getMainType(), $fieldInformation, $recordData, $recordData[$fieldInformation->getName()] ?? null); + } + $relationHandler->processDeletePlaceholder(); + return $relationHandler->itemArray; + } + + /** + * Find the relations relevant for this field. This could be multiple tables! + * + * Note: While $necessaryRelationsOfRequestedField is sorted, the result will be the plain unsorted database rows. + * + * @return array}>> + */ + protected function getRelationalRows(array $necessaryRelationsOfRequestedField, Context $context): array + { + $finalRows = []; + foreach ($necessaryRelationsOfRequestedField as $dbTable => $uids) { + // Let's loop over all tables, and fetch all records of the PIDs of the given UIDs in a greedy way + $rows = $this->greedyDatabaseBackend->getRows($dbTable, $uids, $context); + foreach ($rows as $row) { + $finalRows[$dbTable][(int)$row['uid']] = [ + 'table' => $dbTable, + 'row' => $row, + ]; + } + } + return $finalRows; + } +} diff --git a/Classes/DataHandling/SlugHelper.php b/Classes/DataHandling/SlugHelper.php new file mode 100644 index 0000000..10da836 --- /dev/null +++ b/Classes/DataHandling/SlugHelper.php @@ -0,0 +1,605 @@ +tableName = $tableName; + $this->fieldName = $fieldName; + $this->configuration = $configuration; + $this->workspaceId = $workspaceId; + + if ($this->tableName === 'pages' && $this->fieldName === 'slug') { + $this->prependSlashInSlug = true; + } else { + $this->prependSlashInSlug = $this->configuration['prependSlash'] ?? false; + } + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + $this->workspaceEnabled = $schemaFactory->has($tableName) && $schemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace); + $this->slugNormalizer = GeneralUtility::makeInstance(SlugNormalizer::class); + } + + /** + * Cleans a slug value so it is used directly in the path segment of a URL. + */ + public function sanitize(string $slug): string + { + $value = $this->slugNormalizer->normalize($slug, $this->configuration['fallbackCharacter'] ?? '-'); + if (($value[0] ?? '') !== '/' && $this->prependSlashInSlug) { + $value = '/' . $value; + } + + return $value; + } + + /** + * Extracts payload of slug and removes wrapping delimiters, + * e.g. `/hello/world/` will become `hello/world`. + */ + public function extract(string $slug): string + { + // Convert some special tokens (space, "_" and "-") to the space character + $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-'; + return trim($slug, $fallbackCharacter . '/'); + } + + /** + * Used when no slug exists for a record + * + * @param int $pid The uid of the page to generate the slug for + */ + public function generate(array $recordData, int $pid): string + { + if ($this->tableName === 'pages' && ($pid === 0 || !empty($recordData['is_siteroot']))) { + return '/'; + } + $prefix = ''; + if ($this->tableName === 'pages' && ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false)) { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName); + $languageFieldName = null; + if ($schema->isLanguageAware()) { + $languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + } + $languageId = (int)($recordData[$languageFieldName ?? ''] ?? 0); + $parentPageRecord = $this->resolveParentPageRecord($pid, $languageId); + if (is_array($parentPageRecord)) { + // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title + if (!empty($parentPageRecord['slug'])) { + $rootLineItemSlug = $parentPageRecord['slug']; + } else { + $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']); + } + $rootLineItemSlug = trim($rootLineItemSlug, '/'); + if (!empty($rootLineItemSlug)) { + $prefix = $rootLineItemSlug; + } + } + } + + $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/'; + $slugParts = []; + + $replaceConfiguration = $this->configuration['generatorOptions']['replacements'] ?? []; + $regexReplaceConfiguration = $this->configuration['generatorOptions']['regexReplacements'] ?? []; + foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldNameParts) { + if (is_string($fieldNameParts)) { + $fieldNameParts = GeneralUtility::trimExplode(',', $fieldNameParts); + } + foreach ($fieldNameParts as $fieldName) { + if (!empty($recordData[$fieldName])) { + $pieceOfSlug = (string)$recordData[$fieldName]; + foreach ($regexReplaceConfiguration as $pattern => $replacement) { + $replacedPieceOfSlug = @preg_replace( + $pattern, + $replacement, + $pieceOfSlug + ); + if (is_string($replacedPieceOfSlug)) { + $pieceOfSlug = $replacedPieceOfSlug; + } + } + $pieceOfSlug = str_replace( + array_keys($replaceConfiguration), + array_values($replaceConfiguration), + $pieceOfSlug + ); + $slugParts[] = $pieceOfSlug; + break; + } + } + } + $slug = implode($fieldSeparator, $slugParts); + $slug = $this->sanitize($slug); + // No valid data found + if ($slug === '' || $slug === '/') { + $slug = 'default-' . md5((string)json_encode($recordData)); + } + if ($this->prependSlashInSlug && ($slug[0] ?? '') !== '/') { + $slug = '/' . $slug; + } + if (!empty($prefix)) { + $slug = $prefix . $slug; + } + + // Hook for alternative ways of filling/modifying the slug data + foreach ($this->configuration['generatorOptions']['postModifiers'] ?? [] as $funcName) { + $hookParameters = [ + 'slug' => $slug, + 'workspaceId' => $this->workspaceId, + 'configuration' => $this->configuration, + 'record' => $recordData, + 'pid' => $pid, + 'prefix' => $prefix, + 'tableName' => $this->tableName, + 'fieldName' => $this->fieldName, + ]; + $slug = GeneralUtility::callUserFunction($funcName, $hookParameters, $this); + } + return $this->sanitize($slug); + } + + /** + * Checks if there are other records with the same slug that are located on the same PID. + */ + public function isUniqueInPid(string $slug, RecordState $state): bool + { + $pageId = (int)$state->resolveNodeIdentifier(); + $recordId = $state->getSubject()->getIdentifier(); + $languageId = $state->getContext()->getLanguageId(); + + $queryBuilder = $this->createPreparedQueryBuilder(); + $this->applySlugConstraint($queryBuilder, $slug); + $this->applyPageIdConstraint($queryBuilder, $pageId); + $this->applyRecordConstraint($queryBuilder, $recordId); + $this->applyLanguageConstraint($queryBuilder, $languageId); + $this->applyWorkspaceConstraint($queryBuilder, $state); + $statement = $queryBuilder->executeQuery(); + + $records = $this->resolveVersionOverlays( + $statement->fetchAllAssociative() + ); + return count($records) === 0; + } + + /** + * Check if there are other records with the same slug that are located on the same site. + * + * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException + */ + public function isUniqueInSite(string $slug, RecordState $state): bool + { + $pageId = $state->resolveNodeAggregateIdentifier(); + $recordId = $state->getSubject()->getIdentifier(); + $languageId = $state->getContext()->getLanguageId(); + + if (!MathUtility::canBeInterpretedAsInteger($pageId)) { + // If this is a new page, we use the parent page to resolve the site + $pageId = $state->getNode()->getIdentifier(); + } + $pageId = (int)$pageId; + + $queryBuilder = $this->createPreparedQueryBuilder(); + $this->applySlugConstraint($queryBuilder, $slug); + $this->applyRecordConstraint($queryBuilder, $recordId); + $this->applyLanguageConstraint($queryBuilder, $languageId); + $this->applyWorkspaceConstraint($queryBuilder, $state); + $statement = $queryBuilder->executeQuery(); + + $records = $this->resolveVersionOverlays( + $statement->fetchAllAssociative() + ); + if (count($records) === 0) { + return true; + } + + // The installation contains at least ONE other record with the same slug + // Now find out if it is the same root page ID + $this->flushRootLineCaches(); + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + try { + $siteOfCurrentRecord = $siteFinder->getSiteByPageId($pageId); + } catch (SiteNotFoundException $e) { + // Not within a site, so nothing to do + // @todo: Rather than silently ignoring this misconfiguration, + // a warning should be thrown here, or maybe even let the + // exception bubble up and catch it in places that uses this API + return true; + } + foreach ($records as $record) { + try { + $recordState = RecordStateFactory::forName($this->tableName)->fromArray($record); + $siteOfExistingRecord = $siteFinder->getSiteByPageId( + (int)$recordState->resolveNodeAggregateIdentifier() + ); + } catch (SiteNotFoundException $exception) { + // In case not site is found, the record is not + // organized in any site + continue; + } + if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) { + return false; + } + } + + // Otherwise, everything is still fine + return true; + } + + /** + * Check if there are other records with the same slug. + * + * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException + */ + public function isUniqueInTable(string $slug, RecordState $state): bool + { + $recordId = $state->getSubject()->getIdentifier(); + $languageId = $state->getContext()->getLanguageId(); + + $queryBuilder = $this->createPreparedQueryBuilder(); + $this->applySlugConstraint($queryBuilder, $slug); + $this->applyRecordConstraint($queryBuilder, $recordId); + $this->applyLanguageConstraint($queryBuilder, $languageId); + $this->applyWorkspaceConstraint($queryBuilder, $state); + $statement = $queryBuilder->executeQuery(); + + $records = $this->resolveVersionOverlays( + $statement->fetchAllAssociative() + ); + + return count($records) === 0; + } + + /** + * Ensure root line caches are flushed to avoid any issue regarding moving of pages or dynamically creating + * sites while managing slugs at the same request + */ + protected function flushRootLineCaches(): void + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->getCache('runtime')->flushByTag(RootlineUtility::RUNTIME_CACHE_TAG); + $cacheManager->getCache('rootline')->flush(); + } + + /** + * Generate a slug with a suffix "/mytitle-1" if that is in use already. + * + * @param string $slug proposed slug + * @param callable $isUnique Callback to check for uniqueness + * @throws SiteNotFoundException + */ + protected function buildSlug(string $slug, RecordState $state, callable $isUnique): string + { + $slug = $this->sanitize($slug); + $rawValue = $this->extract($slug); + $newValue = $slug; + $counter = 0; + while ( + !$isUnique($newValue, $state) + && ++$counter <= 100 + ) { + $newValue = $this->sanitize($rawValue . '-' . $counter); + } + if ($counter === 100) { + $uniqueId = StringUtility::getUniqueId(); + $newValue = $this->sanitize($rawValue . '-' . md5($uniqueId)); + } + return $newValue; + } + + /** + * Generate a slug with a suffix "/mytitle-1" if that is in use already. + * + * @param string $slug proposed slug + * @throws SiteNotFoundException + */ + public function buildSlugForUniqueInSite(string $slug, RecordState $state): string + { + return $this->buildSlug($slug, $state, [$this, 'isUniqueInSite']); + } + + /** + * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already. + * + * @param string $slug proposed slug + */ + public function buildSlugForUniqueInPid(string $slug, RecordState $state): string + { + return $this->buildSlug($slug, $state, [$this, 'isUniqueInPid']); + } + + /** + * Generate a slug with a suffix "/mytitle-1" if that is in use already. + * + * @param string $slug proposed slug + * @throws SiteNotFoundException + */ + public function buildSlugForUniqueInTable(string $slug, RecordState $state): string + { + return $this->buildSlug($slug, $state, [$this, 'isUniqueInTable']); + } + + protected function createPreparedQueryBuilder(): QueryBuilder + { + $fieldNames = ['uid', 'pid', $this->fieldName]; + if ($this->workspaceEnabled) { + $fieldNames[] = 't3ver_state'; + $fieldNames[] = 't3ver_oid'; + } + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName); + if ($schema->isLanguageAware()) { + $fieldNames[] = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + $fieldNames[] = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder + ->select(...$fieldNames) + ->from($this->tableName); + return $queryBuilder; + } + + protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder, RecordState $state) + { + if (!$this->workspaceEnabled) { + return; + } + + $queryBuilder->getRestrictions()->add( + GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->workspaceId) + ); + + // Exclude the online record of a versioned record + if ($state->getVersionLink()) { + $queryBuilder->andWhere( + $queryBuilder->expr()->neq('uid', $state->getVersionLink()->getSubject()->getIdentifier()) + ); + } + } + + /** + * Apply constraint to fetch records with same language (Slug / language should be unique). + * If language is -1 (all languages), there should not be any other records with the + * same slug of any language (or -1). + */ + protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId) + { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName); + if (!$schema->isLanguageAware()) { + return; + } + if ($languageId === -1) { + // if language is -1 "all languages" we need to check against all languages, thus not adding + // any kind of language constraints. + return; + } + $languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + + // Only check records of the given language or -1 (all languages) + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq( + $languageFieldName, + $queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $languageFieldName, + $queryBuilder->createNamedParameter(-1, Connection::PARAM_INT) + ) + ) + ); + } + + protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug) + { + $queryBuilder->where( + $queryBuilder->expr()->eq( + $this->fieldName, + $queryBuilder->createNamedParameter($slug) + ) + ); + } + + protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId) + { + if ($pageId < 0) { + throw new \RuntimeException( + sprintf( + 'Page id must be positive "%d"', + $pageId + ), + 1534962573 + ); + } + + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) + ) + ); + } + + /** + * @param string|int $recordId + */ + protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId) + { + // Exclude the current record if it is an existing record + if (!MathUtility::canBeInterpretedAsInteger($recordId)) { + return; + } + + $queryBuilder->andWhere( + $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, Connection::PARAM_INT)) + ); + if ($this->workspaceId > 0 && $this->workspaceEnabled) { + $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, (int)$recordId) ?? $recordId; + $queryBuilder->andWhere( + $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, Connection::PARAM_INT)) + ); + } + } + + protected function resolveVersionOverlays(array $records): array + { + if (!$this->workspaceEnabled) { + return $records; + } + + // filters out non-records (`null` or empty array `[]`) + return array_filter( + // performs workspace overlay and sanitization on each record + array_map( + function (array $record): ?array { + BackendUtility::workspaceOL( + $this->tableName, + $record, + $this->workspaceId, + true + ); + if (!is_array($record)) { + return null; + } + if (VersionState::tryFrom($record['t3ver_state'] ?? 0) + === VersionState::DELETE_PLACEHOLDER) { + return null; + } + return $record; + }, + $records + ) + ); + } + + /** + * Fetch a parent page, but exclude spacers and sys-folders + */ + protected function resolveParentPageRecord(int $pid, int $languageId): ?array + { + $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']); + $excludeDokTypes = [ + PageRepository::DOKTYPE_SPACER, + PageRepository::DOKTYPE_SYSFOLDER, + ]; + do { + $parentPageRecord = array_shift($rootLine); + // exclude spacers, recyclers and folders + } while (!empty($rootLine) && in_array((int)$parentPageRecord['doktype'], $excludeDokTypes, true)); + if ($languageId > 0) { + $languageIds = [$languageId]; + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + + try { + $site = $siteFinder->getSiteByPageId($pid); + $siteLanguage = $site->getLanguageById($languageId); + $languageIds = array_merge($languageIds, $siteLanguage->getFallbackLanguageIds()); + } catch (SiteNotFoundException|\InvalidArgumentException $e) { + // no site or requested language available - move on + } + + /** @var LocalizationRepository $localizationRepository */ + $localizationRepository = GeneralUtility::makeInstance(LocalizationRepository::class); + foreach ($languageIds as $languageId) { + $localizedParentPageRecord = $localizationRepository->getPageTranslations( + $parentPageRecord['uid'], + [$languageId], + $this->workspaceId + ); + if ($localizedParentPageRecord !== []) { + $parentPageRecord = reset($localizedParentPageRecord)->toArray(); + break; + } + } + } + return $parentPageRecord; + } +} diff --git a/Classes/DataHandling/SoftReference/AbstractSoftReferenceParser.php b/Classes/DataHandling/SoftReference/AbstractSoftReferenceParser.php new file mode 100644 index 0000000..7343ef1 --- /dev/null +++ b/Classes/DataHandling/SoftReference/AbstractSoftReferenceParser.php @@ -0,0 +1,59 @@ +tokenID_basePrefix . ':' . $index); + } + + /** + * @param string $parserKey The softref parser key. + * @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by "; + */ + public function setParserKey(string $parserKey, array $parameters): void + { + $this->parserKey = $parserKey; + $this->parameters = $parameters; + } + + public function getParserKey(): string + { + return $this->parserKey; + } + + protected function setTokenIdBasePrefix(string $table, string $uid, string $field, string $structurePath): void + { + $this->tokenID_basePrefix = implode(':', [$table, $uid, $field, $structurePath, $this->getParserKey()]); + } +} diff --git a/Classes/DataHandling/SoftReference/EmailSoftReferenceParser.php b/Classes/DataHandling/SoftReference/EmailSoftReferenceParser.php new file mode 100644 index 0000000..479d91c --- /dev/null +++ b/Classes/DataHandling/SoftReference/EmailSoftReferenceParser.php @@ -0,0 +1,56 @@ +setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath); + $elements = []; + // Email: + $parts = preg_split('/([\s\'":<>]+)([A-Za-z0-9._-]+[^-][@][A-Za-z0-9._-]+[.].[A-Za-z0-9]+)/', ' ' . $content . ' ', 10000, PREG_SPLIT_DELIM_CAPTURE); + foreach ($parts as $idx => $value) { + if ($idx % 3 === 2) { + // Ignore invalid emails, which haven't been filtered out by regex. + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + continue; + } + $tokenID = $this->makeTokenID((string)$idx); + $elements[$idx] = []; + $elements[$idx]['matchString'] = $value; + if (in_array('subst', $this->parameters, true)) { + $parts[$idx] = '{softref:' . $tokenID . '}'; + $elements[$idx]['subst'] = [ + 'type' => 'string', + 'tokenID' => $tokenID, + 'tokenValue' => $value, + ]; + } + } + } + + return SoftReferenceParserResult::create( + substr(implode('', $parts), 1, -1), + $elements + ); + } +} diff --git a/Classes/DataHandling/SoftReference/ExtensionPathSoftReferenceParser.php b/Classes/DataHandling/SoftReference/ExtensionPathSoftReferenceParser.php new file mode 100644 index 0000000..f979fc6 --- /dev/null +++ b/Classes/DataHandling/SoftReference/ExtensionPathSoftReferenceParser.php @@ -0,0 +1,61 @@ + $value) { + if ($idx % 3 === 2) { + $elements[$idx] = []; + $elements[$idx]['matchString'] = $value; + } + } + + return SoftReferenceParserResult::create( + substr(implode('', $parts), 1, -1), + $elements + ); + } + + /** + * @param string $parserKey The softref parser key. + * @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by "; + */ + public function setParserKey(string $parserKey, array $parameters): void + { + $this->parserKey = $parserKey; + $this->parameters = $parameters; + } + + public function getParserKey(): string + { + return $this->parserKey; + } +} diff --git a/Classes/DataHandling/SoftReference/SoftReferenceParserFactory.php b/Classes/DataHandling/SoftReference/SoftReferenceParserFactory.php new file mode 100644 index 0000000..82b0845 --- /dev/null +++ b/Classes/DataHandling/SoftReference/SoftReferenceParserFactory.php @@ -0,0 +1,144 @@ +softReferenceParsers[$parserKey])) { + $this->softReferenceParsers[$parserKey] = $softReferenceParser; + } + } + + /** + * Returns array of soft parser references + * + * @param string $parserList softRef parser list + * @return array|null Array where the parser key is the key and the value is the parameter string, FALSE if no parsers were found + */ + protected function explodeSoftRefParserList(string $parserList): ?array + { + // Return immediately if list is blank: + if ($parserList === '') { + return null; + } + $cacheId = 'backend-softRefList-' . md5($parserList); + $parserListCache = $this->runtimeCache->get($cacheId); + if ($parserListCache !== false) { + return $parserListCache; + } + // Otherwise parse the list: + $keyList = GeneralUtility::trimExplode(',', $parserList, true); + $output = []; + foreach ($keyList as $val) { + $reg = []; + if (preg_match('/^([[:alnum:]_-]+)\\[(.*)\\]$/', $val, $reg)) { + $output[$reg[1]] = GeneralUtility::trimExplode(';', $reg[2], true); + } else { + $output[$val] = ''; + } + } + $this->runtimeCache->set($cacheId, $output); + return $output; + } + + /** + * @param array|null $forcedParameters + * @return iterable + */ + public function getParsersBySoftRefParserList(string $softRefParserList, ?array $forcedParameters = null): iterable + { + foreach ($this->explodeSoftRefParserList($softRefParserList) ?? [] as $parserKey => $parameters) { + if (!is_array($parameters)) { + $parameters = $forcedParameters ?? []; + } + + if (!$this->hasSoftReferenceParser($parserKey)) { + $this->logger->warning('No soft reference parser exists for the key "{parserKey}".', ['parserKey' => $parserKey]); + continue; + } + + $parser = $this->getSoftReferenceParser($parserKey); + $parser->setParserKey($parserKey, $parameters); + + yield $parser; + } + } + + public function hasSoftReferenceParser(string $softReferenceParserKey): bool + { + return isset($this->softReferenceParsers[$softReferenceParserKey]); + } + + /** + * Get a Soft Reference Parser by the given soft reference key. + * Implementation must be registered in Configuration/Services.yaml + * + * VENDOR\YourExtension\SoftReference\UserDefinedSoftReferenceParser: + * tags: + * - name: softreference.parser + * parserKey: userdefined + */ + public function getSoftReferenceParser(string $softReferenceParserKey): SoftReferenceParserInterface + { + if ($softReferenceParserKey === '') { + throw new \InvalidArgumentException( + 'The soft reference parser key cannot be empty.', + 1627899274 + ); + } + + if (!$this->hasSoftReferenceParser($softReferenceParserKey)) { + throw new \OutOfRangeException( + sprintf('No soft reference parser found for "%s".', $softReferenceParserKey), + 1627899342 + ); + } + + return $this->softReferenceParsers[$softReferenceParserKey]; + } + + /** + * Get all registered soft reference parsers + */ + public function getSoftReferenceParsers(): array + { + return $this->softReferenceParsers; + } +} diff --git a/Classes/DataHandling/SoftReference/SoftReferenceParserInterface.php b/Classes/DataHandling/SoftReference/SoftReferenceParserInterface.php new file mode 100644 index 0000000..17746c2 --- /dev/null +++ b/Classes/DataHandling/SoftReference/SoftReferenceParserInterface.php @@ -0,0 +1,58 @@ + tag from typical bodytext fields + * is an example of this. + * This interface defines the "parse" method, which parsers have to implement. + * TYPO3 has already implemented parsers for the most well-known types. Soft Reference Parsers can also be user-defined. + * The Soft Reference Parsers are used by the system to find these references and process them accordingly in import/export actions and copy operations. + */ +interface SoftReferenceParserInterface +{ + /** + * Main function through which can parse content for a specific field. + * + * @param string $table Database table name + * @param string $field Field name for which processing occurs + * @param int $uid UID of the record + * @param string $content The content/value of the field + * @param string $structurePath If running from inside a FlexForm structure, this is the path of the tag. + * @return SoftReferenceParserResult Result object on positive matches, see description above. + * @see SoftReferenceParserResult + */ + public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult; + + /** + * The two properties parserKey and parameters may be set to generate a unique token ID from them. + * This is not needed for every parser, but useful if a parser can deal with multiple parser keys. + * + * @param string $parserKey The softref parser key. + * @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by "; + */ + public function setParserKey(string $parserKey, array $parameters): void; + + /** + * Returns the parser key, which was previously set by "setParserKey" + */ + public function getParserKey(): string; +} diff --git a/Classes/DataHandling/SoftReference/SoftReferenceParserResult.php b/Classes/DataHandling/SoftReference/SoftReferenceParserResult.php new file mode 100644 index 0000000..68c3129 --- /dev/null +++ b/Classes/DataHandling/SoftReference/SoftReferenceParserResult.php @@ -0,0 +1,87 @@ + // The value of the match. This is only for informational purposes to show what was found. + * "error" => // An error message can be set here, like "file not found" etc. + * "subst" => [ // If this array is found there MUST be a token in the output content as well! + * "tokenID" => // The tokenID string corresponding to the token in output content, {softref:[tokenID]}. This is typically an md5 hash of a string defining uniquely the position of the element. + * "tokenValue" => // The value that the token substitutes in the text. Basically, if this value is inserted instead of the token the content should match what was inputted originally. + * "type" => // file / db / string = the type of substitution. "file" means it is a relative file [automatically mapped], "db" means a database record reference [automatically mapped], "string" means it is manually modified string content (eg. an email address) + * "relFileName" => // (for "file" type): Relative filename. May not necessarily exist. This could be noticed in the error key. + * "recordRef" => // (for "db" type) : Reference to DB record on the form [table]:[uid]. May not necessarily exist. + * "title" => // Title of element (for backend information) + * "description" => // Description of element (for backend information) + * ] + */ +final class SoftReferenceParserResult +{ + private string $content = ''; + private array $elements = []; + private bool $hasMatched = false; + + public static function create(string $content, array $elements): self + { + if ($elements === []) { + return self::createWithoutMatches(); + } + + $obj = new self(); + $obj->content = $content; + $obj->elements = $elements; + $obj->hasMatched = true; + + return $obj; + } + + public static function createWithoutMatches(): self + { + // @todo: set protected, use create() with empty elements instead. + return new self(); + } + + public function hasMatched(): bool + { + return $this->hasMatched; + } + + public function hasContent(): bool + { + return $this->content !== ''; + } + + public function getContent(): string + { + return $this->content; + } + + public function getMatchedElements(): array + { + return $this->elements; + } +} diff --git a/Classes/DataHandling/SoftReference/SubstituteSoftReferenceParser.php b/Classes/DataHandling/SoftReference/SubstituteSoftReferenceParser.php new file mode 100644 index 0000000..78b981f --- /dev/null +++ b/Classes/DataHandling/SoftReference/SubstituteSoftReferenceParser.php @@ -0,0 +1,44 @@ +setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath); + $tokenID = $this->makeTokenID(); + + return SoftReferenceParserResult::create( + '{softref:' . $tokenID . '}', + [ + [ + 'matchString' => $content, + 'subst' => [ + 'type' => 'string', + 'tokenID' => $tokenID, + 'tokenValue' => $content, + ], + ], + ] + ); + } +} diff --git a/Classes/DataHandling/SoftReference/TypolinkSoftReferenceParser.php b/Classes/DataHandling/SoftReference/TypolinkSoftReferenceParser.php new file mode 100644 index 0000000..6363ad3 --- /dev/null +++ b/Classes/DataHandling/SoftReference/TypolinkSoftReferenceParser.php @@ -0,0 +1,299 @@ +eventDispatcher = $eventDispatcher; + } + + public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult + { + $this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath); + + // First, split the input string by a comma if the "linkList" parameter is set. + // An example: the link field for images in content elements of type "textpic" or "image". This field CAN be configured to define a link per image, separated by comma. + if (in_array('linkList', $this->parameters, true)) { + // Preserving whitespace on purpose. + $linkElement = explode(',', $content); + } else { + // If only one element, just set in this array to make it easy below. + $linkElement = [$content]; + } + // Traverse the links now: + $elements = []; + foreach ($linkElement as $k => $typolinkValue) { + $tLP = $this->getTypoLinkParts($typolinkValue, $table, $uid); + $linkElement[$k] = $this->setTypoLinkPartsElement($tLP, $elements, $typolinkValue, $k); + } + + return SoftReferenceParserResult::create( + implode(',', $linkElement), + $elements + ); + } + + /** + * Analyze content as a TypoLink value and return an array with properties. + * TypoLinks format is: . + * See TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::typolink() + * The syntax of the [typolink] part is: [typolink] = [page id][,[type value]][#[anchor, if integer = tt_content uid]] + * The extraction is based on how \TYPO3\CMS\Frontend\ContentObject::typolink() behaves. + * + * @param string $typolinkValue TypoLink value. + * @param string $referenceTable The reference table + * @param int $referenceUid The UID of the reference record + * @return array Array with the properties of the input link specified. The key "type" will reveal the type. If that is blank it could not be determined. + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::typolink() + * @see setTypoLinkPartsElement() + */ + protected function getTypoLinkParts(string $typolinkValue, string $referenceTable, int $referenceUid) + { + $finalTagParts = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode($typolinkValue); + + $link_param = $finalTagParts['url']; + // we define various keys below, "url" might be misleading + unset($finalTagParts['url']); + + if (stripos(rawurldecode(trim($link_param)), 'phar://') === 0) { + throw new \RuntimeException( + 'phar scheme not allowed as soft reference target', + 1530030672 + ); + } + + $linkService = GeneralUtility::makeInstance(LinkService::class); + try { + $linkData = $linkService->resolve($link_param); + switch ($linkData['type']) { + case LinkService::TYPE_RECORD: + $referencePageId = $referenceTable === 'pages' + ? $referenceUid + : (int)(BackendUtility::getRecord($referenceTable, $referenceUid)['pid'] ?? 0); + if ($referencePageId) { + $pageTsConfig = BackendUtility::getPagesTSconfig($referencePageId); + $table = $pageTsConfig['TCEMAIN.']['linkHandler.'][$linkData['identifier'] . '.']['configuration.']['table'] ?? $linkData['identifier']; + } else { + // Backwards compatibility for the old behaviour, where the identifier was saved as the table. + $table = $linkData['identifier']; + } + $finalTagParts['table'] = $table; + $finalTagParts['uid'] = $linkData['uid']; + break; + case LinkService::TYPE_PAGE: + $linkData['pageuid'] = (int)($linkData['pageuid'] ?? 0); + if (isset($linkData['pagetype'])) { + $linkData['pagetype'] = (int)$linkData['pagetype']; + } + if (isset($linkData['fragment'])) { + $finalTagParts['anchor'] = $linkData['fragment']; + } + break; + case LinkService::TYPE_FILE: + case LinkService::TYPE_UNKNOWN: + if (isset($linkData['file'])) { + $finalTagParts['type'] = LinkService::TYPE_FILE; + $linkData['file'] = $linkData['file'] instanceof AbstractFile ? $linkData['file']->getUid() : $linkData['file']; + } else { + $pU = parse_url($link_param); + parse_str($pU['query'] ?? '', $query); + if (isset($query['uid'])) { + $finalTagParts['type'] = LinkService::TYPE_FILE; + $finalTagParts['file'] = (int)$query['uid']; + } + } + break; + } + return array_merge($finalTagParts, $linkData); + } catch (UnknownLinkHandlerException $e) { + // Cannot handle anything + return $finalTagParts; + } + } + + /** + * Recompile a TypoLink value from the array of properties made with getTypoLinkParts() into an elements array + * + * @param array $tLP TypoLink properties + * @param array $elements Array of elements to be modified with substitution / information entries. + * @param string $content The content to process. + * @param int $idx Index value of the found element - user to make unique but stable tokenID + * @return string The input content, possibly containing tokens now according to the added substitution entries in $elements + * @see getTypoLinkParts() + */ + protected function setTypoLinkPartsElement($tLP, &$elements, $content, $idx) + { + // Initialize, set basic values. In any case a link will be shown + $tokenID = $this->makeTokenID('setTypoLinkPartsElement:' . $idx); + $elements[$tokenID . ':' . $idx] = []; + $elements[$tokenID . ':' . $idx]['matchString'] = $content; + // Based on link type, maybe do more: + switch ((string)($tLP['type'] ?? '')) { + case LinkService::TYPE_EMAIL: + // Mail addresses can be substituted manually: + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'string', + 'tokenID' => $tokenID, + 'tokenValue' => (string)($tLP['email'] ?? ''), + ]; + // Output content will be the token instead: + $content = '{softref:' . $tokenID . '}'; + break; + case LinkService::TYPE_TELEPHONE: + // phone number can be substituted manually: + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'string', + 'tokenID' => $tokenID, + 'tokenValue' => (string)($tLP['telephone'] ?? ''), + ]; + // Output content will be the token instead: + $content = '{softref:' . $tokenID . '}'; + break; + case LinkService::TYPE_URL: + // URLs can be substituted manually + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'external', + 'tokenID' => $tokenID, + 'tokenValue' => (string)($tLP['url'] ?? ''), + ]; + // Output content will be the token instead: + $content = '{softref:' . $tokenID . '}'; + break; + case LinkService::TYPE_FOLDER: + // This is a link to a folder... + unset($elements[$tokenID . ':' . $idx]); + return $content; + case LinkService::TYPE_FILE: + // Process files referenced by their FAL uid + if (isset($tLP['file'])) { + $fileId = $tLP['file'] instanceof AbstractFile ? $tLP['file']->getUid() : $tLP['file']; + // Token and substitute value + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'sys_file:' . $fileId, + 'tokenID' => $tokenID, + 'tokenValue' => 'file:' . $fileId, + ]; + // Output content will be the token instead: + $content = '{softref:' . $tokenID . '}'; + } elseif ($tLP['identifier'] ?? false) { + $linkHandlerValue = explode(':', trim($tLP['identifier']), 2)[1]; + if (MathUtility::canBeInterpretedAsInteger($linkHandlerValue)) { + // Token and substitute value + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'sys_file:' . $linkHandlerValue, + 'tokenID' => $tokenID, + 'tokenValue' => (string)$tLP['identifier'], + ]; + // Output content will be the token instead: + $content = '{softref:' . $tokenID . '}'; + } else { + // This is a link to a folder... + return $content; + } + } else { + return $content; + } + break; + case LinkService::TYPE_PAGE: + // Rebuild page reference typolink part: + $content = ''; + // Set page id: + if ($tLP['pageuid']) { + $content .= '{softref:' . $tokenID . '}'; + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'pages:' . $tLP['pageuid'], + 'tokenID' => $tokenID, + 'tokenValue' => (string)$tLP['pageuid'], + ]; + } + // Add type if applicable + if ((string)($tLP['pagetype'] ?? '') !== '') { + $content .= ',' . $tLP['pagetype']; + } + // Add anchor if applicable + if ((string)($tLP['anchor'] ?? '') !== '') { + // Anchor is assumed to point to a content elements: + if (MathUtility::canBeInterpretedAsInteger($tLP['anchor'])) { + // Initialize a new entry because we have a new relation: + $newTokenID = $this->makeTokenID('setTypoLinkPartsElement:anchor:' . $idx); + $elements[$newTokenID . ':' . $idx] = []; + $elements[$newTokenID . ':' . $idx]['matchString'] = 'Anchor Content Element: ' . $tLP['anchor']; + $content .= '#{softref:' . $newTokenID . '}'; + $elements[$newTokenID . ':' . $idx]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'tt_content:' . $tLP['anchor'], + 'tokenID' => $newTokenID, + 'tokenValue' => (string)$tLP['anchor'], + ]; + } else { + // Anchor is a hardcoded string + $content .= '#' . $tLP['anchor']; + } + } + break; + case LinkService::TYPE_RECORD: + $elements[$tokenID . ':' . $idx]['subst'] = [ + 'type' => 'db', + 'recordRef' => $tLP['table'] . ':' . $tLP['uid'], + 'tokenID' => $tokenID, + 'tokenValue' => (string)$content, + ]; + + $content = '{softref:' . $tokenID . '}'; + break; + default: + $event = new AppendLinkHandlerElementsEvent($tLP, $content, $elements, $idx, $tokenID); + $this->eventDispatcher->dispatch($event); + + $elements = $event->getElements(); + $tLP = $event->getLinkParts(); + $content = $event->getContent(); + + if (!$event->isResolved()) { + $elements[$tokenID . ':' . $idx]['error'] = 'Couldn\'t decide typolink mode.'; + return $content; + } + } + // Finally, for all entries that was rebuild with tokens, add target, class, title and additionalParams in the end + $tLP['url'] = $content; + // Return rebuilt typolink value + return GeneralUtility::makeInstance(TypoLinkCodecService::class)->encode($tLP); + } +} diff --git a/Classes/DataHandling/SoftReference/TypolinkTagSoftReferenceParser.php b/Classes/DataHandling/SoftReference/TypolinkTagSoftReferenceParser.php new file mode 100644 index 0000000..dbfad6a --- /dev/null +++ b/Classes/DataHandling/SoftReference/TypolinkTagSoftReferenceParser.php @@ -0,0 +1,144 @@ + and tags in the content string and process any found. + */ +class TypolinkTagSoftReferenceParser extends AbstractSoftReferenceParser +{ + protected EventDispatcherInterface $eventDispatcher; + + public function __construct(EventDispatcherInterface $eventDispatcher) + { + $this->eventDispatcher = $eventDispatcher; + } + + public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult + { + $this->setTokenIdBasePrefix($table, (string)$uid, $field, $structurePath); + + // Parse string for special TYPO3 tag: + $htmlParser = GeneralUtility::makeInstance(HtmlParser::class); + $linkService = GeneralUtility::makeInstance(LinkService::class); + $linkTags = $htmlParser->splitTags('a', $content); + // Traverse result: + $elements = []; + foreach ($linkTags as $key => $foundValue) { + if ($key % 2 && preg_match('/href="([^"]+)"/', $foundValue, $matches)) { + try { + $linkDetails = $linkService->resolve($matches[1]); + if ($linkDetails['type'] === LinkService::TYPE_FILE && preg_match('/file\?uid=(\d+)/', $matches[1], $fileIdMatch)) { + $token = $this->makeTokenID((string)$key); + $elements[$key]['matchString'] = $foundValue; + $linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue); + $elements[$key]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'sys_file:' . $fileIdMatch[1], + 'tokenID' => $token, + 'tokenValue' => 'file:' . ($linkDetails['file'] instanceof File ? $linkDetails['file']->getUid() : $fileIdMatch[1]), + ]; + } elseif ($linkDetails['type'] === LinkService::TYPE_PAGE && preg_match('/page\?[^#]*\buid=(\d+)(?:[^#]*#(\d+))?/', $matches[1], $pageAndAnchorMatches)) { + $token = $this->makeTokenID((string)$key); + $content = '{softref:' . $token . '}'; + $elements[$key]['matchString'] = $foundValue; + $elements[$key]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'pages:' . ($linkDetails['pageuid'] ?? 0), + 'tokenID' => $token, + 'tokenValue' => $linkDetails['pageuid'] ?? '', + ]; + if (isset($pageAndAnchorMatches[2])) { + // Anchor is assumed to point to a content elements: + if (MathUtility::canBeInterpretedAsInteger($pageAndAnchorMatches[2])) { + // Initialize a new entry because we have a new relation: + $newTokenID = $this->makeTokenID('setTypoLinkPartsElement:anchor:' . $key); + $elements[$newTokenID . ':' . $key] = []; + $elements[$newTokenID . ':' . $key]['matchString'] = 'Anchor Content Element: ' . $pageAndAnchorMatches[2]; + $content .= '#{softref:' . $newTokenID . '}'; + $elements[$newTokenID . ':' . $key]['subst'] = [ + 'type' => 'db', + 'recordRef' => 'tt_content:' . $pageAndAnchorMatches[2], + 'tokenID' => $newTokenID, + 'tokenValue' => $pageAndAnchorMatches[2], + ]; + } else { + // Anchor is a hardcoded string + $content .= '#' . $pageAndAnchorMatches[2]; + } + } + $linkTags[$key] = str_replace($matches[1], $content, $foundValue); + } elseif ($linkDetails['type'] === LinkService::TYPE_URL) { + $token = $this->makeTokenID((string)$key); + $elements[$key]['matchString'] = $foundValue; + $linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue); + $elements[$key]['subst'] = [ + 'type' => 'external', + 'tokenID' => $token, + 'tokenValue' => (string)($linkDetails['url'] ?? ''), + ]; + } elseif ($linkDetails['type'] === LinkService::TYPE_EMAIL) { + $token = $this->makeTokenID((string)$key); + $elements[$key]['matchString'] = $foundValue; + $linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue); + $elements[$key]['subst'] = [ + 'type' => 'string', + 'tokenID' => $token, + 'tokenValue' => (string)($linkDetails['email'] ?? ''), + ]; + } elseif ($linkDetails['type'] === LinkService::TYPE_TELEPHONE) { + $token = $this->makeTokenID((string)$key); + $elements[$key]['matchString'] = $foundValue; + $linkTags[$key] = str_replace($matches[1], '{softref:' . $token . '}', $foundValue); + $elements[$key]['subst'] = [ + 'type' => 'string', + 'tokenID' => $token, + 'tokenValue' => (string)($linkDetails['telephone'] ?? ''), + ]; + } else { + $token = $this->makeTokenID((string)$key); + $event = new AppendLinkHandlerElementsEvent($linkDetails, $content, $elements, $key, $token); + $this->eventDispatcher->dispatch($event); + + if (!$event->isResolved()) { + continue; + } + + $elements = $event->getElements(); + } + } catch (\Exception $e) { + // skip invalid links + } + } + } + // Return output: + return SoftReferenceParserResult::create( + implode('', $linkTags), + $elements + ); + } +} diff --git a/Classes/DataHandling/SoftReference/UrlSoftReferenceParser.php b/Classes/DataHandling/SoftReference/UrlSoftReferenceParser.php new file mode 100644 index 0000000..94aef24 --- /dev/null +++ b/Classes/DataHandling/SoftReference/UrlSoftReferenceParser.php @@ -0,0 +1,71 @@ + $match) { + $prefix = $match[1]; + $url = $match[2]; + + $tokenID = $this->makeTokenID((string)$idx); + $elements[$idx] = []; + $elements[$idx]['matchString'] = $url; + + if (in_array('subst', $this->parameters, true)) { + // Replace the URL with a token in the content + $modifiedContent = str_replace($prefix . $url, $prefix . '{softref:' . $tokenID . '}', $modifiedContent); + $elements[$idx]['subst'] = [ + 'type' => 'string', + 'tokenID' => $tokenID, + 'tokenValue' => $url, + ]; + } + } + } + + return SoftReferenceParserResult::create( + substr($modifiedContent, 1, -1), + $elements + ); + } +} diff --git a/Classes/DataHandling/TableColumnType.php b/Classes/DataHandling/TableColumnType.php new file mode 100644 index 0000000..e1e242b --- /dev/null +++ b/Classes/DataHandling/TableColumnType.php @@ -0,0 +1,51 @@ +container ?? throw new \LogicException('Doctrine database configuration requires a container to be set via `setContainer()`', 1782369693); + } + + public function setContainer(ContainerInterface $container): self + { + $this->container = $container; + return $this; + } +} diff --git a/Classes/Database/Connection.php b/Classes/Database/Connection.php new file mode 100644 index 0000000..a1edc50 --- /dev/null +++ b/Classes/Database/Connection.php @@ -0,0 +1,505 @@ +expressionBuilder = GeneralUtility::makeInstance(ExpressionBuilder::class, $this, $config->getContainer()); + } + + /** + * Gets the DatabasePlatform for the connection and initializes custom types and event listeners. + */ + protected function connect(): ConnectionInterface + { + if ($this->_conn !== null) { + return $this->_conn; + } + // Early return if the connection is already open and custom setup has been done. + $connection = parent::connect(); + foreach ($this->prepareConnectionCommands as $command) { + $this->executeStatement($command); + } + return $connection; + } + + /** + * Creates a new instance of a SQL query builder. + */ + public function createQueryBuilder(): QueryBuilder + { + return GeneralUtility::makeInstance(QueryBuilder::class, $this); + } + + /** + * Quotes a string so it can be safely used as a table or column name, even if + * it is a reserved name. + * EXAMPLE: tableName.fieldName => "tableName"."fieldName" + * + * Delimiting style depends on the underlying database platform that is being used. + * + * Note that this does not call the parent implementation, because both + * Doctrine DBAL `Connection::quoteIdentifier()` and `AbstractPlatform::quoteIdentifier()` + * are deprecated and will be removed with Doctrine DBAL 5.0. The quoting is done + * here instead, identical to the removed implementation. + * + * @param string $identifier The name to be quoted. + * @return string The quoted name. + */ + public function quoteIdentifier(string $identifier): string + { + if ($identifier === '*') { + return $identifier; + } + $platform = $this->getDatabasePlatform(); + if (!str_contains($identifier, '.')) { + return $platform->quoteSingleIdentifier($identifier); + } + return implode('.', array_map($platform->quoteSingleIdentifier(...), explode('.', $identifier))); + } + + /** + * Quotes an array of column names, so it can be safely used, even if the name is a reserved name. + * Delimiting style depends on the underlying database platform that is being used. + */ + public function quoteIdentifiers(array $input): array + { + return array_map($this->quoteIdentifier(...), $input); + } + + /** + * Quotes an associative array of column-value so the column names can be safely used, even + * if the name is a reserved name. + * Delimiting style depends on the underlying database platform that is being used. + */ + public function quoteColumnValuePairs(array $input): array + { + return array_combine($this->quoteIdentifiers(array_keys($input)), array_values($input)); + } + + /** + * Detect if the column types are specified by column name or using + * positional information. In the first case quote the field names + * accordingly. + */ + protected function quoteColumnTypes(array $input): array + { + if (!is_string(key($input))) { + return $input; + } + return $this->quoteColumnValuePairs($input); + } + + /** + * Quotes like wildcards for given string value. + * + * @param string $value The value to be quoted. + * @return string The quoted value. + */ + public function escapeLikeWildcards(string $value): string + { + return addcslashes($value, '_%'); + } + + /** + * Inserts a table row with specified data. + * + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string $tableName The name of the table to insert data into. + * @param array $data An associative array containing column-value pairs. + * @param array $types Types of the inserted data. + * @return int The number of affected rows. + * @throws Exception + */ + public function insert(string $tableName, array $data, array $types = []): int + { + $this->ensureDatabaseValueTypes($tableName, $data, $types); + return parent::insert( + $this->quoteIdentifier($tableName), + $this->quoteColumnValuePairs($data), + $this->quoteColumnTypes($types) + ); + } + + /** + * Bulk inserts table rows with specified data. + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string $tableName The name of the table to insert data into. + * @param array $data An array containing associative arrays of column-value pairs or just the values to be inserted. + * @param array $columns An array containing the column names of the data which should be inserted. + * @param array $types Types of the inserted data. + * @return int The number of affected rows. + */ + public function bulkInsert(string $tableName, array $data, array $columns = [], array $types = []): int + { + $totalAffectedRows = 0; + $columnLength = $columns !== [] ? count($columns) : 1000; + $maxBindParameters = PlatformInformation::getMaxBindParameters($this->getDatabasePlatform()); + $maxChunkSize = (int)(($maxBindParameters / $columnLength) / 2); + $chunks = array_chunk($data, $maxChunkSize); + foreach ($chunks as $chunk) { + $query = GeneralUtility::makeInstance(BulkInsertQuery::class, $this, $tableName, $columns); + foreach ($chunk as $values) { + $this->ensureDatabaseValueTypes($tableName, $values, $types); + $query->addValues($values, $types); + } + $totalAffectedRows += $query->execute(); + } + return $totalAffectedRows; + } + + /** + * Executes an SQL SELECT statement on a table. + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string[] $columns The columns of the table which to select. + * @param string $tableName The name of the table on which to select. + * @param array $identifiers The selection criteria. An associative array containing column-value pairs. + * @param string[] $groupBy The columns to group the results by. + * @param array $orderBy Associative array of column name/sort directions pairs. + * @param int $limit The maximum number of rows to return. + * @param int $offset The first result row to select (when used with limit) + * @return Result The executed statement. + */ + public function select( + array $columns, + string $tableName, + array $identifiers = [], + array $groupBy = [], + array $orderBy = [], + int $limit = 0, + int $offset = 0 + ) { + $query = $this->createQueryBuilder(); + $query->select(...$columns)->from($tableName); + foreach ($identifiers as $identifier => $value) { + $query->andWhere($query->expr()->eq($identifier, $query->createNamedParameter($value))); + } + foreach ($orderBy as $fieldName => $order) { + $query->addOrderBy($fieldName, $order); + } + if (!empty($groupBy)) { + $query->groupBy(...$groupBy); + } + if ($limit > 0) { + $query->setMaxResults($limit); + $query->setFirstResult($offset); + } + return $query->executeQuery(); + } + + /** + * Executes an SQL UPDATE statement on a table. + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string $tableName The name of the table to update. + * @param array $data An associative array containing column-value pairs. + * @param array $identifier The update criteria. An associative array containing column-value pairs. + * @param array $types Types of the merged $data and $identifier arrays in that order. + * @return int The number of affected rows. + * @throws Exception + */ + public function update(string $tableName, array $data, array $identifier = [], array $types = []): int + { + $this->ensureDatabaseValueTypes($tableName, $data, $types); + return parent::update( + $this->quoteIdentifier($tableName), + $this->quoteColumnValuePairs($data), + $this->quoteColumnValuePairs($identifier), + $this->quoteColumnTypes($types) + ); + } + + /** + * Executes an SQL DELETE statement on a table. + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string $tableName The name of the table on which to delete. + * @param array $identifier The deletion criteria. An associative array containing column-value pairs. + * @param array $types The types of identifiers. + * @return int The number of affected rows. + */ + public function delete(string $tableName, array $identifier = [], array $types = []): int + { + return parent::delete( + $this->quoteIdentifier($tableName), + $this->quoteColumnValuePairs($identifier), + $this->quoteColumnTypes($types) + ); + } + + /** + * Executes an SQL TRUNCATE statement on a table. + * All SQL identifiers are expected to be unquoted and will be quoted when building the query. + * + * @param string $tableName The name of the table to truncate. + * @param bool $cascade Not supported on many platforms but would cascade the truncate by following foreign keys. + * @return int The number of affected rows. For a truncate this is unreliable as there is no meaningful information. + */ + public function truncate(string $tableName, bool $cascade = false): int + { + return $this->executeStatement( + $this->getDatabasePlatform()->getTruncateTableSQL( + $this->quoteIdentifier($tableName), + $cascade + ) + ); + } + + /** + * Executes an SQL SELECT COUNT() statement on a table and returns the count result. + * + * @param string $item The column/expression of the table which to count + * @param string $tableName The name of the table on which to count. + * @param array $identifiers The selection criteria. An associative array containing column-value pairs. + * @return int The number of rows counted + */ + public function count(string $item, string $tableName, array $identifiers): int + { + $query = $this->createQueryBuilder(); + $query->count($item)->from($tableName); + foreach ($identifiers as $identifier => $value) { + $query->andWhere($query->expr()->eq($identifier, $query->createNamedParameter($value))); + } + return (int)$query->executeQuery()->fetchOne(); + } + + /** + * Returns the version of the current platform if applicable, containing the platform as prefix. + * + * If no version information is available only the platform name will be shown. + * If the platform name is unknown or unsupported the driver name will be shown. + * + * @internal only and not part of public API. + */ + public function getPlatformServerVersion(): string + { + $platform = $this->getDatabasePlatform(); + $version = trim($this->typo3_getServerVersionProvider()->getServerVersion()); + if ($version !== '') { + $version = ' ' . $version; + } + return match (true) { + // @todo Check if we should use 'MariaDB' now directly instead of MySQL as an alias. + $platform instanceof DoctrineMariaDBPlatform => 'MySQL' . $version, + $platform instanceof DoctrineMySQLPlatform => 'MySQL' . $version, + $platform instanceof DoctrinePostgreSQLPlatform => 'PostgreSQL' . $version, + default => (str_replace('Platform', '', array_reverse(explode('\\', $platform::class))[0])) . $version, + }; + } + + /** + * Execute commands after initializing a new connection. + */ + public function prepareConnection(string $commands): void + { + if (empty($commands)) { + return; + } + $this->prepareConnectionCommands = GeneralUtility::trimExplode( + LF, + str_replace( + '\' . LF . \'', + LF, + $commands + ), + true + ); + } + + /** + * Returns the ID of the last inserted row. + * If the underlying driver does not support identity columns, an exception is thrown. + * + * @return numeric-string + */ + public function lastInsertId(): string + { + return (string)parent::lastInsertId(); + } + + /** + * Gets the ExpressionBuilder for the connection. + */ + public function getExpressionBuilder(): ExpressionBuilder + { + return $this->expressionBuilder; + } + + /** + * This method ensures that data values a properly converted to their database equivalent. + * Additionally, it adds the proper types to the type-array, if this has no manual preset types. + * Note: Types are *only* added if not given externally. + * + * @internal Should be private, but mocked in tests currently. + */ + protected function ensureDatabaseValueTypes(string $tableName, array &$data, array &$types): void + { + $tableInfo = $this->getSchemaInformation()->getTableInfo($tableName); + array_walk($data, function (mixed &$value, string $key) use ($tableInfo, &$types): void { + // Use database schema field type in case no Type or ParameterType has been provided manually + // for field `$key`, falling back to ParameterType::STRING in case field does not exists in + // the schema, which is the default ParameterType used by doctrine anyway. + if (!isset($types[$key]) && $tableInfo->hasColumnInfo($key)) { + $types[$key] = $tableInfo->getColumnInfo($key)->getType(); + } + }); + } + + /** + * @internal May vanish anytime, currently used core-internal at some places. + */ + public function getSchemaInformation(): SchemaInformation + { + return new SchemaInformation( + $this, + GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'), + GeneralUtility::makeInstance(CacheManager::class)->getCache('database_schema'), + GeneralUtility::makeInstance(PackageDependentCacheIdentifier::class), + ); + } + + /** + * Executes a function in a transaction. + * + * The function gets passed this Connection instance as an (optional) parameter. + * + * If an exception occurs during execution of the function or transaction commit, + * the transaction is rolled back and the exception re-thrown. + * + * @param \Closure(self):T $func The function to execute transactionally. + * @return T The value returned by $func + * @throws \Throwable + * @template T + */ + public function transactional(\Closure $func): mixed + { + /** @var \Closure(DoctrineConnection):T $func Required to satisfy PHPStan. */ + return parent::transactional($func); + } + + /** + * Returns the suitable `ServerVersionProvider`, which could be the connection itself or + * a `StaticServerVersionProvider` based on either of following configuration values: + * + * - $params['serverVersion'] + * - $params['primary']['serverVersion'] + * + * This is an extract from {@see \Doctrine\DBAL\Connection::getDatabasePlatform()} and handled as internal for + * now and will be tried to provide upstream making it API and is the reason why it is a prefixed method. + * + * It's currently only used in internal {@see self::getPlatformServerVersion()}. + * + * @internal only and not part of public API. + */ + protected function typo3_getServerVersionProvider(): ServerVersionProvider + { + $params = $this->getParams(); + return match (true) { + isset($params['serverVersion']) => new StaticServerVersionProvider($params['serverVersion']), + isset($params['primary']['serverVersion']) => new StaticServerVersionProvider($params['primary']['serverVersion']), + default => $this, + }; + } +} diff --git a/Classes/Database/ConnectionPool.php b/Classes/Database/ConnectionPool.php new file mode 100644 index 0000000..6d098c9 --- /dev/null +++ b/Classes/Database/ConnectionPool.php @@ -0,0 +1,413 @@ + + * @todo Needs to be refactored. Only MySQL and MariaDB support this type, using this to register the type AND + * add mappings to all connections, even unsupported connections for SQLite or PostgreSQL is not correct, + * and needs to be respected. Or the type needs to provide working fallbacks for unsupported platforms. + */ + protected static array $customDoctrineTypes = [ + SetType::TYPE => SetType::class, + ]; + + /** + * @var array + * @todo Needs to be refactored to differentiate between type registration and platform specific type mapping. + */ + protected static array $overrideDoctrineTypes = [ + Types::DATE_MUTABLE => DateType::class, + Types::DATETIME_MUTABLE => DateTimeType::class, + Types::DATETIME_IMMUTABLE => DateTimeType::class, + Types::TIME_MUTABLE => TimeType::class, + ]; + + public function __construct( + protected readonly ContainerInterface $container, + protected readonly CoreSchemaManagerFactory $coreSchemaManagerFactory, + protected readonly DriverMiddlewareService $driverMiddlewareService, + ) {} + + /** + * Creates a connection object based on the specified table name. + * + * This is the official entry point to get a database connection to ensure + * that the mapping of table names to database connections is honored. + * + * @param string $tableName + */ + public function getConnectionForTable(string $tableName): Connection + { + if (empty($tableName)) { + throw new \UnexpectedValueException( + 'ConnectionPool->getConnectionForTable() requires a table name to be provided.', + 1459421719 + ); + } + + $connectionName = self::DEFAULT_CONNECTION_NAME; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName])) { + $connectionName = (string)$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName]; + } + + return $this->getConnectionByName($connectionName); + } + + /** + * Creates a connection object based on the specified identifier. + * + * This method should only be used in edge cases. Use getConnectionForTable() so + * that the tablename<>databaseConnection mapping will be taken into account. + * + * @param string $connectionName + * @throws \Doctrine\DBAL\Exception + */ + public function getConnectionByName(string $connectionName): Connection + { + if (empty($connectionName)) { + throw new \UnexpectedValueException( + 'ConnectionPool->getConnectionByName() requires a connection name to be provided.', + 1459422125 + ); + } + + if (isset($this->connections[$connectionName])) { + return $this->connections[$connectionName]; + } + + $this->connections[$connectionName] = $this->getDatabaseConnection( + $connectionName, + $this->getConnectionParams($connectionName), + ); + + return $this->connections[$connectionName]; + } + + protected function getConnectionParams(string $connectionName): array + { + $connectionParams = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][$connectionName] ?? []; + if (empty($connectionParams)) { + throw new \RuntimeException( + 'The requested database connection named "' . $connectionName . '" has not been configured.', + 1459422492 + ); + } + if (!empty($connectionParams['url'])) { + $dsnUrl = $connectionParams['url']; + unset($connectionParams['url']); + try { + $parsedParams = (new DsnParser())->parse($dsnUrl); + } catch (MalformedDsnException $e) { + throw new \UnexpectedValueException('Malformed connection parameter "url".', 1750964898, $e); + } + $connectionParams = [...$connectionParams, ...$parsedParams]; + } + if (empty($connectionParams['wrapperClass'])) { + $connectionParams['wrapperClass'] = Connection::class; + } + if (!is_a($connectionParams['wrapperClass'], Connection::class, true)) { + throw new \UnexpectedValueException( + 'The "wrapperClass" for the connection name "' . $connectionName + . '" needs to be a subclass of "' . Connection::class . '".', + 1459422968 + ); + } + // Ensure integer value for port. + if (array_key_exists('port', $connectionParams)) { + $connectionParams['port'] = (int)($connectionParams['port'] ?? 0); + } + return $this->migrateConnectionParams($connectionName, $connectionParams); + } + + private function migrateConnectionParams(string $connectionName, #[\SensitiveParameter] array $params): array + { + $params['defaultTableOptions'] ??= []; + $params = $this->removeInvalidConnectionParams($params); + return $this->ensureDefaultConnectionCharset($params); + } + + /** + * Clean up invalid connection parameters. + */ + private function removeInvalidConnectionParams(#[\SensitiveParameter] array $params): array + { + // Remove defaultTableOptions for unsupported databases + unset($params['tableoptions']); + // Ensure to remove `defaultTableOptions` for drivers not supporting it. + if (!in_array((string)($params['driver'] ?? ''), ['mysqli', 'pdo_mysql'], true)) { + unset($params['defaultTableOptions']); + return $params; + } + // ENGINE is a TYPO3 custom option not handled by doctrine/dbal by a custom implementation, + // see `MySQLCompatibleAlterTablePlatformAwareTrait` + $allowedDefaultTableOptions = ['charset', 'collation', 'engine']; + $currentDefaultTableOptionsArrayKeys = array_keys($params['defaultTableOptions']); + foreach ($currentDefaultTableOptionsArrayKeys as $optionIdentifier) { + if (!in_array($optionIdentifier, $allowedDefaultTableOptions, true)) { + unset($params['defaultTableOptions'][$optionIdentifier]); + } + } + // Remove if empty. + if ($params['defaultTableOptions'] === []) { + unset($params['defaultTableOptions']); + } + return $params; + } + + /** + * Set a suiting UTF-8 connection charset when nothing is set in connection configuration for `charset`. + * + * @todo Investigate how to deal with missing defaultTableOptions for MariaDB and MySQL connections, + * which may be already partially set even when charset is missing. + */ + private function ensureDefaultConnectionCharset(#[\SensitiveParameter] array $params): array + { + if (!array_key_exists('charset', $params) || !is_string($params['charset']) || $params['charset'] === '') { + $params['charset'] = 'utf8'; + // @todo Add `charset = utf8mb4` for MySQL/MariaDB as default connection charset in 14.0 as breaking change. + } + return $params; + } + + /** + * Return any doctrine driver middlewares, that may have been set up in: + * - for all configured connections + * - $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares'] for a specific connection + */ + protected function getDriverMiddlewares(string $connectionName, #[\SensitiveParameter] array $connectionParams): array + { + $driverMiddlewares = $this->getOrderedConnectionDriverMiddlewareConfiguration($connectionName, $connectionParams); + $middlewares = []; + foreach ($driverMiddlewares as $middlewareConfiguration) { + $className = $middlewareConfiguration['target']; + $disabled = $middlewareConfiguration['disabled']; + if ($disabled === true) { + // Middleware disabled, skip to next middleware. + continue; + } + + $middlewares[] = GeneralUtility::makeInstance($className); + } + + return $middlewares; + } + + /** + * @internal only for `ext:lowlevel` usage to retrieve configuration overview. * + * @return array + */ + public function getConnectionMiddlewareConfigurationArrayForLowLevelConfiguration(): array + { + $configurationArray = [ + 'Raw' => [ + 'GlobalDriverMiddlewares' => $GLOBALS['TYPO3_CONF_VARS']['DB']['globalDriverMiddlewares'] ?? [], + 'Connections' => [], + ], + 'Connections' => [], + ]; + foreach (array_keys($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']) as $connectionName) { + $connectionParams = $this->getConnectionParams($connectionName); + $configurationArray['Raw']['Connections'][$connectionName] = $connectionParams; + $configurationArray['Connections'][$connectionName] = $this->getOrderedConnectionDriverMiddlewareConfiguration($connectionName, $connectionParams); + } + return $configurationArray; + } + + /** + * @param array $connectionParams + * @return array + */ + protected function getOrderedConnectionDriverMiddlewareConfiguration(string $connectionName, #[\SensitiveParameter] array $connectionParams): array + { + /** @var array $driverMiddlewares */ + $driverMiddlewares = []; + foreach ($GLOBALS['TYPO3_CONF_VARS']['DB']['globalDriverMiddlewares'] ?? [] as $identifier => $middleware) { + $identifier = (string)$identifier; + $driverMiddlewares[$identifier] = $this->driverMiddlewareService->ensureCompleteMiddlewareConfiguration($middleware); + $driverMiddlewares[$identifier]['type'] = 'global'; + } + foreach ($connectionParams['driverMiddlewares'] ?? [] as $identifier => $middleware) { + $identifier = (string)$identifier; + // Merge driverMiddlewares over globalDriverMiddlewares + $middleware = array_replace($driverMiddlewares[$identifier] ?? [], $middleware); + $middleware = $this->driverMiddlewareService->ensureCompleteMiddlewareConfiguration($middleware); + $driverMiddlewares[$identifier] = $middleware; + $driverMiddlewares[$identifier]['type'] = $driverMiddlewares[$identifier]['type'] + ? 'global-with-connection-override' + : 'connection'; + } + $driverMiddlewares = array_filter($driverMiddlewares, static function (array $middleware) use ($connectionName, $connectionParams): bool { + $className = $middleware['target']; + $classImplements = class_exists($className) ? (class_implements($className) ?: []) : []; + if (!in_array(DriverMiddleware::class, $classImplements, true)) { + throw new \UnexpectedValueException( + sprintf( + 'Doctrine Driver Middleware "%s" must implement \Doctrine\DBAL\Driver\Middleware', + $className + ), + 1677958727 + ); + } + if (in_array(UsableForConnectionInterface::class, $classImplements, true)) { + return GeneralUtility::makeInstance($middleware['target'])->canBeUsedForConnection($connectionName, $connectionParams); + } + + return true; + }); + + return $this->driverMiddlewareService->order($driverMiddlewares); + } + + /** + * Creates a connection object based on the specified parameters + */ + protected function getDatabaseConnection(string $connectionName, #[\SensitiveParameter] array $connectionParams): Connection + { + self::registerDoctrineTypes(); + + $middlewares = $this->getDriverMiddlewares($connectionName, $connectionParams); + $configuration = (new Configuration()) + ->setContainer($this->container) + ->setMiddlewares($middlewares) + // @link https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-not-setting-a-schema-manager-factory + ->setSchemaManagerFactory($this->coreSchemaManagerFactory); + + /** @var Connection $conn */ + $conn = DriverManager::getConnection($connectionParams, $configuration); + $conn->prepareConnection($connectionParams['initCommands'] ?? ''); + + // Register all custom data types in the type mapping + foreach (self::$customDoctrineTypes as $type => $className) { + $conn->getDatabasePlatform()->registerDoctrineTypeMapping($type, $type); + } + + // Register all override data types in the type mapping + foreach (self::$overrideDoctrineTypes as $type => $className) { + $conn->getDatabasePlatform()->registerDoctrineTypeMapping($type, $type); + } + + return $conn; + } + + /** + * Returns the connection specific query builder object that can be used to build + * complex SQL queries using and object-oriented approach. + */ + public function getQueryBuilderForTable(string $tableName): QueryBuilder + { + if (empty($tableName)) { + throw new \UnexpectedValueException( + 'ConnectionPool->getQueryBuilderForTable() requires a connection name to be provided.', + 1459423448 + ); + } + + return $this->getConnectionForTable($tableName)->createQueryBuilder(); + } + + /** + * Returns an array containing the names of all currently configured connections. + * + * This method should only be used in edge cases. Use getConnectionForTable() so + * that the tablename<>databaseConnection mapping will be taken into account. + * + * @internal + */ + public function getConnectionNames(): array + { + return array_keys($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']); + } + + /** + * Register custom and override Doctrine data types implemented by TYPO3. + * This method is needed by Schema parser to register the types as it does + * not require a database connection and thus the types don't get registered + * automatically. + * + * @internal + */ + public static function registerDoctrineTypes(): void + { + // Register custom data types + foreach (self::$customDoctrineTypes as $type => $className) { + if (!Type::hasType($type)) { + Type::addType($type, $className); + } + } + // Override data types + foreach (self::$overrideDoctrineTypes as $type => $className) { + if (!Type::hasType($type)) { + Type::addType($type, $className); + continue; + } + Type::overrideType($type, $className); + } + } + + /** + * Used to be used by functional tests + * to close statically stored connections, in order + * to use new connection in between single tests. + * + * This is a no-op nowadays since `$this->connections` + * is no longer static and can be removed without replacement, + * once testing framework is adapted to avoid calling this method. + */ + public function resetConnections(): void {} +} diff --git a/Classes/Database/Driver/CustomPdoResultDriverDecorator.php b/Classes/Database/Driver/CustomPdoResultDriverDecorator.php new file mode 100644 index 0000000..80daf20 --- /dev/null +++ b/Classes/Database/Driver/CustomPdoResultDriverDecorator.php @@ -0,0 +1,33 @@ +elevatePlatform(parent::getDatabasePlatform($versionProvider)); + } + + /** + * Due to the deprecation doctrine/event-manager usage in doctrine/dbal the platform classes needs to be extended + * to still provide the same behaviour as before. Therefore, we replace the doctrine platform instances with our + * extended classes. + * + * @param AbstractPlatform $platform + * @return AbstractPlatform + */ + private function elevatePlatform(AbstractPlatform $platform): AbstractPlatform + { + return match ($platform::class) { + DoctrineMySQLPlatform::class => new Typo3MySQLPlatform(), + DoctrineMySQL80Platform::class => new Typo3MySQL80Platform(), + DoctrineMySQL84Platform::class => new Typo3MySQL84Platform(), + DoctrineMariaDB110700Platform::class => new Typo3MariaDB110700Platform(), + DoctrineMariaDB1010Platform::class => new Typo3MariaDB1010Platform(), + DoctrineMariaDB1060Platform::class => new Typo3MariaDB1060Platform(), + DoctrineMariaDB1052Platform::class => new Typo3MariaDB1052Platform(), + DoctrineMariaDBPlatform::class => new Typo3MariaDBPlatform(), + DoctrineSQLitePlatform::class => new Typo3SQLitePlatform(), + DoctrinePostgreSQL120Platform::class => new Typo3PostgreSQL120Platform(), + DoctrinePostgreSQLPlatform::class => new Typo3PostgreSQLPlatform(), + default => $platform, + }; + } +} diff --git a/Classes/Database/Driver/DriverConnection.php b/Classes/Database/Driver/DriverConnection.php new file mode 100644 index 0000000..19ad4f0 --- /dev/null +++ b/Classes/Database/Driver/DriverConnection.php @@ -0,0 +1,41 @@ +mapResourceToString(parent::fetchNumeric()); + } + + /** + * {@inheritDoc} + */ + public function fetchAssociative(): array|false + { + return $this->mapResourceToString(parent::fetchAssociative()); + } + + /** + * {@inheritDoc} + */ + public function fetchOne(): mixed + { + return $this->mapResourceToString(parent::fetchOne()); + } + + /** + * {@inheritDoc} + */ + public function fetchAllNumeric(): array + { + $data = $this->mapResourceToString(parent::fetchAllNumeric()); + assert(is_array($data)); + return array_map($this->mapResourceToString(...), $data); + } + + /** + * {@inheritDoc} + */ + public function fetchAllAssociative(): array + { + $data = $this->mapResourceToString(parent::fetchAllAssociative()); + assert(is_array($data)); + return array_map($this->mapResourceToString(...), $data); + } + + /** + * {@inheritDoc} + */ + public function fetchFirstColumn(): array + { + $data = $this->mapResourceToString(parent::fetchFirstColumn()); + assert(is_array($data)); + return array_map($this->mapResourceToString(...), $data); + } + + /** + * Map resources to string like is done for e.g. in mysqli driver + * + * @param mixed $record + * @return mixed + */ + protected function mapResourceToString($record) + { + if (is_array($record)) { + foreach ($record as $k => $value) { + if (is_resource($value)) { + $record[$k] = stream_get_contents($value); + } + } + } + return $record; + } +} diff --git a/Classes/Database/Driver/DriverStatement.php b/Classes/Database/Driver/DriverStatement.php new file mode 100644 index 0000000..725ba98 --- /dev/null +++ b/Classes/Database/Driver/DriverStatement.php @@ -0,0 +1,41 @@ +dependencyOrderingService->orderByDependencies($middlewares); + } + + /** + * @param array $middleware + * @return array{target: class-string, disabled: bool, after: string[], before: string[], type: string} + */ + public function ensureCompleteMiddlewareConfiguration(array $middleware): array + { + $target = (string)($middleware['target'] ?? ''); + if ($target === '' || !class_exists($target)) { + throw new \RuntimeException( + 'Doctrine DBAL driver middleware registration requires a valid class-name as "target".', + 1701546655 + ); + } + return [ + 'target' => $target, + 'disabled' => (bool)($middleware['disabled'] ?? false), + 'after' => (array)($middleware['after'] ?? []), + 'before' => (array)($middleware['before'] ?? []), + 'type' => '', + ]; + } +} diff --git a/Classes/Database/Event/AlterTableDefinitionStatementsEvent.php b/Classes/Database/Event/AlterTableDefinitionStatementsEvent.php new file mode 100644 index 0000000..0bccefe --- /dev/null +++ b/Classes/Database/Event/AlterTableDefinitionStatementsEvent.php @@ -0,0 +1,42 @@ +sqlData[] = $data; + } + + public function getSqlData(): array + { + return $this->sqlData; + } + + public function setSqlData(array $sqlData): void + { + $this->sqlData = $sqlData; + } +} diff --git a/Classes/Database/Middleware/CustomPdoDriverResultMiddleware.php b/Classes/Database/Middleware/CustomPdoDriverResultMiddleware.php new file mode 100644 index 0000000..d6ccb5e --- /dev/null +++ b/Classes/Database/Middleware/CustomPdoDriverResultMiddleware.php @@ -0,0 +1,55 @@ + true, + default => false, + }; + } +} diff --git a/Classes/Database/Middleware/CustomPlatformDriverMiddleware.php b/Classes/Database/Middleware/CustomPlatformDriverMiddleware.php new file mode 100644 index 0000000..817a91e --- /dev/null +++ b/Classes/Database/Middleware/CustomPlatformDriverMiddleware.php @@ -0,0 +1,34 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MariaDB1052Platform.php b/Classes/Database/Platform/MariaDB1052Platform.php new file mode 100644 index 0000000..d767b0d --- /dev/null +++ b/Classes/Database/Platform/MariaDB1052Platform.php @@ -0,0 +1,49 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MariaDB1060Platform.php b/Classes/Database/Platform/MariaDB1060Platform.php new file mode 100644 index 0000000..215a646 --- /dev/null +++ b/Classes/Database/Platform/MariaDB1060Platform.php @@ -0,0 +1,49 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MariaDB110700Platform.php b/Classes/Database/Platform/MariaDB110700Platform.php new file mode 100644 index 0000000..299a29e --- /dev/null +++ b/Classes/Database/Platform/MariaDB110700Platform.php @@ -0,0 +1,49 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MariaDBPlatform.php b/Classes/Database/Platform/MariaDBPlatform.php new file mode 100644 index 0000000..55967df --- /dev/null +++ b/Classes/Database/Platform/MariaDBPlatform.php @@ -0,0 +1,51 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MySQL80Platform.php b/Classes/Database/Platform/MySQL80Platform.php new file mode 100644 index 0000000..5e6cf64 --- /dev/null +++ b/Classes/Database/Platform/MySQL80Platform.php @@ -0,0 +1,51 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MySQL84Platform.php b/Classes/Database/Platform/MySQL84Platform.php new file mode 100644 index 0000000..d3f9a56 --- /dev/null +++ b/Classes/Database/Platform/MySQL84Platform.php @@ -0,0 +1,51 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/MySQLPlatform.php b/Classes/Database/Platform/MySQLPlatform.php new file mode 100644 index 0000000..4fc2e22 --- /dev/null +++ b/Classes/Database/Platform/MySQLPlatform.php @@ -0,0 +1,54 @@ + + */ + public function getAlterTableSQL(TableDiff|DoctrineTableDiff $diff): array + { + return $this->getCustomAlterTableSQLEngineOptions($this, $diff, parent::getAlterTableSQL($diff)); + } +} diff --git a/Classes/Database/Platform/PlatformHelper.php b/Classes/Database/Platform/PlatformHelper.php new file mode 100644 index 0000000..6476fe8 --- /dev/null +++ b/Classes/Database/Platform/PlatformHelper.php @@ -0,0 +1,45 @@ +quoteSingleIdentifier('fake')[0]; + } +} diff --git a/Classes/Database/Platform/PlatformInformation.php b/Classes/Database/Platform/PlatformInformation.php new file mode 100644 index 0000000..4a35f93 --- /dev/null +++ b/Classes/Database/Platform/PlatformInformation.php @@ -0,0 +1,140 @@ + 63, + 'postgresql' => 63, + 'sqlite' => 1024, // arbitrary limit, SQLite is only limited by the total statement length + ]; + + protected static array $bindParameterLimits = [ + 'mysql' => 65535, + 'postgresql' => 34464, + 'sqlite' => 999, + ]; + + /** + * @var string[] + */ + protected static array $charSetMap = [ + 'mysql' => 'utf8mb4', + 'postgresql' => 'UTF8', + 'sqlite' => 'utf8', + ]; + + /** + * @var string[] + */ + protected static array $databaseCreateWithCharsetMap = [ + 'mysql' => 'CHARACTER SET %s', + 'postgresql' => "ENCODING '%s'", + ]; + + /** + * Return the encoding of the given platform + */ + public static function getCharset(DoctrineAbstractPlatform $platform): string + { + $platformName = static::getPlatformIdentifier($platform); + return static::$charSetMap[$platformName]; + } + + /** + * Return the statement to create a database with the desired encoding for the given platform + */ + public static function getDatabaseCreateStatementWithCharset(DoctrineAbstractPlatform $platform, string $databaseName): string + { + try { + $createStatement = $platform->getCreateDatabaseSQL($databaseName); + } catch (DBALException $exception) { + // just silently ignore that error as the selected database does not support any creation of a database + return ''; + } + + $platformName = static::getPlatformIdentifier($platform); + $charset = static::getCharset($platform); + + return $createStatement . ' ' . sprintf(static::$databaseCreateWithCharsetMap[$platformName], $charset); + } + + /** + * Return information about the maximum supported length for a SQL identifier. + * + * @internal + */ + public static function getMaxIdentifierLength(DoctrineAbstractPlatform $platform): int + { + $platformName = static::getPlatformIdentifier($platform); + return self::$identifierLimits[$platformName]; + } + + /** + * Return information about the maximum number of bound parameters supported on this platform + * + * @internal + */ + public static function getMaxBindParameters(DoctrineAbstractPlatform $platform): int + { + $platformName = static::getPlatformIdentifier($platform); + return self::$bindParameterLimits[$platformName]; + } + + /** + * Return the platform shortname to use as a lookup key + * + * @throws \RuntimeException + * @internal + */ + protected static function getPlatformIdentifier(DoctrineAbstractPlatform $platform): string + { + // @todo: In doctrine/dbal 3 MariaDBPlatform extended from MySQLPlatform, since doctrine/dbal 4+ from + // AbstractMySQLPlatform. Consider to returning directly 'mariadb' here if consuming code is + // prepared for the change. + if ($platform instanceof DoctrineMariaDBPlatform) { + return 'mysql'; + } + if ($platform instanceof DoctrineMySQLPlatform) { + return 'mysql'; + } + if ($platform instanceof DoctrinePostgreSqlPlatform) { + return 'postgresql'; + } + if ($platform instanceof DoctrineSQLitePlatform) { + return 'sqlite'; + } + throw new \RuntimeException( + 'Unsupported Databaseplatform "' . get_class($platform) . '" detected in PlatformInformation', + 1500958070 + ); + } +} diff --git a/Classes/Database/Platform/PostgreSQL120Platform.php b/Classes/Database/Platform/PostgreSQL120Platform.php new file mode 100644 index 0000000..266bcac --- /dev/null +++ b/Classes/Database/Platform/PostgreSQL120Platform.php @@ -0,0 +1,35 @@ +addTypeCommentIfNeeded($column)); + } + + /** + * Add type comment (`DC2Type:`) to column comment if required. + * + * Adopted from Doctrine DBAL 3.9: + * - https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Platforms/AbstractPlatform.php#L555-L572 + * returning comment addition for column comment. + * - https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Platforms/AbstractPlatform.php#L574-L597 + * adding the type comment to column comment, if overall type comments has not been disabled (not the case for TYPO3) + * and the column type method `requiresSQLCommentHint()` returned true for that type and platform, which no longer + * exits and are now simplified processed with {@see self::typeRequiresCommentHint()}. + */ + private function addTypeCommentIfNeeded(array $column): array + { + if ($this->typeRequiresCommentHint($column['type'])) { + $column['comment'] .= '(DC2Type:' . Type::lookupName($column['type']) . ')'; + } + return $column; + } + + /** + * Platform specific type selection requiring column comment type specification. + * + * Up to Doctrine DBAL v3, this has been handled throughout various places, for example using the removed + * `requiresSQLCommentHint()` method on DoctrineType implementations. + * https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Types/Type.php#L275-L295 for + * the basic implementation, where each type could override that method. + * + * Instead of extending all types to restore that behaviour, a mapping logic is now added with that method, + * for example: + * - https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Types/JsonType.php#L80-L95 + */ + private function typeRequiresCommentHint(Type $type): bool + { + $map = [ + SQLitePlatform::class => [ + JsonType::class, + GuidType::class, + ], + MariaDBPlatform::class => [ + GuidType::class, + ], + MySQLPlatform::class => [ + GuidType::class, + ], + ]; + foreach ($map as $platformClass => $platformTypes) { + if (!$this instanceof $platformClass) { + continue; + } + foreach ($platformTypes as $platformType) { + if ($type instanceof $platformType) { + return true; + } + } + } + return false; + } +} diff --git a/Classes/Database/Platform/Traits/MySQLCompatibleAlterTablePlatformAwareTrait.php b/Classes/Database/Platform/Traits/MySQLCompatibleAlterTablePlatformAwareTrait.php new file mode 100644 index 0000000..28abcda --- /dev/null +++ b/Classes/Database/Platform/Traits/MySQLCompatibleAlterTablePlatformAwareTrait.php @@ -0,0 +1,87 @@ + $result + * @return list + */ + protected function getCustomAlterTableSQLEngineOptions(DoctrineMariaDBPlatform|DoctrineMySQLPlatform $platform, TableDiff|DoctrineTableDiff $tableDiff, array $result): array + { + // Original Doctrine TableDiff without table options, continue default processing + if (!$tableDiff instanceof TableDiff) { + return $result; + } + + // No changes in table options, continue default processing + if (count($tableDiff->getTableOptions()) === 0) { + return $result; + } + $options = ''; + + if ($tableDiff->hasTableOption('engine')) { + $options .= ' ENGINE = ' . $tableDiff->getTableOption('engine'); + } + + if ($tableDiff->hasTableOption('row_format')) { + $options .= ' ROW_FORMAT = ' . $tableDiff->getTableOption('row_format'); + } elseif ($tableDiff->hasTableOption('engine') && $tableDiff->getOldTable()->hasOption('row_format')) { + // Ensure ROW_FORMAT is always explicitly applied if ENGINE is changed, + // as "old" CREATE TABLE ROW_FORMAT options are cached and are re-applied if ENGINE is changed + // (which would result in a "Wrong create options" error if MyISAM/FIXED is tried to be changed to InnoDB(+implicit FIXED) + // + // See https://bugs.mysql.com/bug.php?id=26214#c104034 into account: + // > Row_format column in SHOW TABLE STATUS shows the actual row format of the table. + // > Create_options in SHOW TABLE STATUS and SHOW CREATE TABLE show the options (including row format) that you specified at CREATE TABLE time. + // > + // > The original options are preserved because you may do ALTER TABLE ... ENGINE= and change the storage engine of the table, and a new storage + // > engine may support the row format that you specified back then during CREATE TABLE. + $options .= ' ROW_FORMAT = ' . $tableDiff->getOldTable()->getOption('row_format'); + } + + if ($tableDiff->hasTableOption('charset')) { + $options .= ' DEFAULT CHARACTER SET = ' . $tableDiff->getTableOption('charset'); + } + if ($tableDiff->hasTableOption('collation')) { + $options .= ' COLLATE = ' . $tableDiff->getTableOption('collation'); + } + + // Add an ALTER TABLE statement to change the table engine to the list of statements. + if ($options !== '') { + $quotedTableName = $tableDiff->getOldTable()->getQuotedName($platform); + $result[] = 'ALTER TABLE ' . $quotedTableName . $options; + } + + return $result; + } +} diff --git a/Classes/Database/Platform/Traits/MySQLDefaultValueDeclarationSQLOverrideTrait.php b/Classes/Database/Platform/Traits/MySQLDefaultValueDeclarationSQLOverrideTrait.php new file mode 100644 index 0000000..30a844c --- /dev/null +++ b/Classes/Database/Platform/Traits/MySQLDefaultValueDeclarationSQLOverrideTrait.php @@ -0,0 +1,96 @@ +quoteStringLiteral($default) . ')'; + } + + if (! isset($column['default'])) { + return empty($column['notnull']) ? ' DEFAULT NULL' : ''; + } + + $default = $column['default']; + + if (! isset($column['type'])) { + return " DEFAULT '" . $default . "'"; + } + + if ($type instanceof Types\PhpIntegerMappingType) { + return ' DEFAULT ' . $default; + } + + if ($type instanceof Types\PhpDateTimeMappingType && $default === $this->getCurrentTimestampSQL()) { + return ' DEFAULT ' . $this->getCurrentTimestampSQL(); + } + + if ($type instanceof Types\PhpTimeMappingType && $default === $this->getCurrentTimeSQL()) { + return ' DEFAULT ' . $this->getCurrentTimeSQL(); + } + + if ($type instanceof Types\PhpDateMappingType && $default === $this->getCurrentDateSQL()) { + return ' DEFAULT ' . $this->getCurrentDateSQL(); + } + + if ($type instanceof Types\BooleanType) { + return ' DEFAULT ' . $this->convertBooleans($default); + } + + if (is_int($default) || is_float($default)) { + return ' DEFAULT ' . $default; + } + + return ' DEFAULT ' . $this->quoteStringLiteral($default); + } +} diff --git a/Classes/Database/Query/BulkInsertQuery.php b/Classes/Database/Query/BulkInsertQuery.php new file mode 100644 index 0000000..fffc8ee --- /dev/null +++ b/Classes/Database/Query/BulkInsertQuery.php @@ -0,0 +1,255 @@ + for the Doctrine project, + * licensed under the MIT license. + * + * This class will be removed from core and the functionality will be provided by + * the upstream implementation once the pull request has been merged into Doctrine DBAL. + * + * @see https://github.com/doctrine/dbal/pull/682 + * @internal + */ +class BulkInsertQuery +{ + /** + * @var string[] + */ + protected $columns; + + /** + * @var DoctrineConnection + */ + protected $connection; + + /** + * @var string + */ + protected $table; + + /** + * @var array + */ + protected $parameters = []; + + /** + * @var array + */ + protected $types = []; + + /** + * @var array + */ + protected $values = []; + + /** + * Constructor. + * + * @param DoctrineConnection $connection The connection to use for query execution. + * @param string $table The name of the table to insert rows into. + * @param string[] $columns The names of the columns to insert values into. + * Can be left empty to allow arbitrary row inserts based on the table's column order. + */ + public function __construct(DoctrineConnection $connection, string $table, array $columns = []) + { + $this->connection = $connection; + $this->table = $connection->quoteIdentifier($table); + $this->columns = $columns; + } + + /** + * Render the bulk insert statement as string. + */ + public function __toString(): string + { + return $this->getSQL(); + } + + /** + * Adds a set of values to the bulk insert query to be inserted as a row into the specified table. + * + * @param array $values The set of values to be inserted as a row into the table. + * If no columns have been specified for insertion, this can be + * an arbitrary list of values to be inserted into the table. + * Otherwise the values' keys have to match either one of the + * specified column names or indexes. + * @param array $types The types for the given values to bind to the query. + * If no columns have been specified for insertion, the types' + * keys will be matched against the given values' keys. + * Otherwise the types' keys will be matched against the + * specified column names and indexes. + * Non-matching keys will be discarded, missing keys will not + * be bound to a specific type. + * + * @throws \InvalidArgumentException if columns were specified for this query + * and either no value for one of the specified + * columns is given or multiple values are given + * for a single column (named and indexed) or + * multiple types are given for a single column + * (named and indexed). + */ + public function addValues(array $values, array $types = []) + { + $valueSet = []; + + if (empty($this->columns)) { + foreach ($values as $index => $value) { + $this->parameters[] = $value; + $this->types[] = $types[$index] ?? null; + $valueSet[] = '?'; + } + + $this->values[] = $valueSet; + + return; + } + + foreach ($this->columns as $index => $column) { + $namedValue = isset($values[$column]) || array_key_exists($column, $values); + $positionalValue = isset($values[$index]) || array_key_exists($index, $values); + + if (!$namedValue && !$positionalValue) { + throw new \InvalidArgumentException( + sprintf('No value specified for column %s (index %d).', $column, $index), + 1476049651 + ); + } + + if ($namedValue && $positionalValue && $values[$column] !== $values[$index]) { + throw new \InvalidArgumentException( + sprintf('Multiple values specified for column %s (index %d).', $column, $index), + 1476049652 + ); + } + + $this->parameters[] = $namedValue ? $values[$column] : $values[$index]; + $valueSet[] = '?'; + + $namedType = isset($types[$column]); + $positionalType = isset($types[$index]); + + if ($namedType && $positionalType && $types[$column] !== $types[$index]) { + throw new \InvalidArgumentException( + sprintf('Multiple types specified for column %s (index %d).', $column, $index), + 1476049653 + ); + } + + if ($namedType) { + $this->types[] = $types[$column]; + + continue; + } + + if ($positionalType) { + $this->types[] = $types[$index]; + + continue; + } + + $this->types[] = Typo3Connection::PARAM_STR; + } + + $this->values[] = $valueSet; + } + + /** + * Executes this INSERT query using the bound parameters and their types. + * + * @return int The number of affected rows. + * + * @throws \LogicException if this query contains more rows than acceptable + * for a single INSERT statement by the underlying platform. + */ + public function execute(): int + { + return $this->connection->executeStatement($this->getSQL(), $this->parameters, $this->types); + } + + /** + * Returns the parameters for this INSERT query being constructed indexed by parameter index. + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * Returns the parameter types for this INSERT query being constructed indexed by parameter index. + */ + public function getParameterTypes(): array + { + return $this->types; + } + + /** + * Returns the SQL formed by the current specifications of this INSERT query. + * + * + * @throws \LogicException if no values have been specified yet. + */ + public function getSQL(): string + { + if (empty($this->values)) { + throw new \LogicException( + 'You need to add at least one set of values before generating the SQL.', + 1476049702 + ); + } + + $connection = $this->connection; + $columnList = ''; + + if (!empty($this->columns)) { + $columnList = sprintf( + ' (%s)', + implode( + ', ', + array_map( + static function (string $column) use ($connection): string { + return $connection->quoteIdentifier($column); + }, + $this->columns + ) + ) + ); + } + + return sprintf( + 'INSERT INTO %s%s VALUES (%s)', + $this->table, + $columnList, + implode( + '), (', + array_map( + static function (array $valueSet): string { + return implode(', ', $valueSet); + }, + $this->values + ) + ) + ); + } +} diff --git a/Classes/Database/Query/ConcreteQueryBuilder.php b/Classes/Database/Query/ConcreteQueryBuilder.php new file mode 100644 index 0000000..94ceb68 --- /dev/null +++ b/Classes/Database/Query/ConcreteQueryBuilder.php @@ -0,0 +1,924 @@ + + */ + protected array $join = []; + + /** + * The WHERE part of a SELECT, UPDATE or DELETE query. + */ + protected string|CompositeExpression|null $where = null; + + /** + * The GROUP BY part of a SELECT query. + * + * @var string[] + */ + protected array $groupBy = []; + + /** + * The HAVING part of a SELECT query. + */ + protected string|CompositeExpression|null $having = null; + + /** + * The ORDER BY parts of a SELECT query. + * + * @var string[] + */ + protected array $orderBy = []; + + /** + * The WITH query parts. + */ + protected WithCollection $typo3_with; + + /** + * The QueryBuilder for the union parts. + * + * @var Union[] + */ + protected array $typo3_unionParts = []; + + /** + * Initializes a new QueryBuilder. + * + * @param Connection $connection The DBAL Connection. + */ + public function __construct(protected readonly Connection $connection) + { + parent::__construct($this->connection); + $this->typo3_with = new WithCollection(); + } + + /** + * Deep clone of all expression objects in the SQL parts. + */ + public function __clone() + { + parent::__clone(); + foreach ($this->from as $key => $from) { + $this->from[$key] = clone $from; + } + foreach ($this->join as $fromAlias => $joins) { + foreach ($joins as $key => $join) { + $this->join[$fromAlias][$key] = clone $join; + } + } + if (is_object($this->where)) { + $this->where = clone $this->where; + } + if (is_object($this->having)) { + $this->having = clone $this->having; + } + } + + /** + * Specifies union parts to be used to build a UNION query. + * Replaces any previously specified parts. + * + * + * $qb = $conn->createQueryBuilder() + * ->union('SELECT 1 AS field1', 'SELECT 2 AS field1'); + * + * + * @return $this + */ + public function union(string|ConcreteQueryBuilder|DoctrineQueryBuilder $part): self + { + parent::union($part); + $this->type = QueryType::UNION; + $this->typo3_unionParts = [new Union($part)]; + return $this; + } + + /** + * Add parts to be used to build a UNION query. + * + * + * $qb = $conn->createQueryBuilder() + * ->union('SELECT 1 AS field1') + * ->addUnion('SELECT 2 AS field1', 'SELECT 3 AS field1') + * + * + * @return $this + */ + public function addUnion(string|ConcreteQueryBuilder|DoctrineQueryBuilder $part, UnionType $type = UnionType::DISTINCT): self + { + parent::addUnion($part, $type); + $this->type = QueryType::UNION; + $this->typo3_unionParts[] = new Union($part, $type); + return $this; + } + + /** + * Specifies an item that is to be returned in the query result. + * Replaces any previously specified selections, if any. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.id', 'p.id') + * ->from('users', 'u') + * ->leftJoin('u', 'phonenumbers', 'p', 'u.id = p.user_id'); + * + * + * @param string ...$expressions The selection expressions. + * + * @return $this This QueryBuilder instance. + */ + public function select(string ...$expressions): self + { + parent::select(...$expressions); + $this->type = QueryType::SELECT; + if (count($expressions) < 1) { + return $this; + } + $this->select = $expressions; + $this->sql = null; + return $this; + } + + /** + * Adds or removes DISTINCT to/from the query. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.id') + * ->distinct() + * ->from('users', 'u') + * + * + * @return $this This QueryBuilder instance. + */ + public function distinct(bool $distinct = true): self + { + parent::distinct($distinct); + $this->distinct = $distinct; + $this->sql = null; + return $this; + } + + /** + * Adds an item that is to be returned in the query result. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.id') + * ->addSelect('p.id') + * ->from('users', 'u') + * ->leftJoin('u', 'phonenumbers', 'u.id = p.user_id'); + * + * + * @param string $expression The selection expression. + * @param string ...$expressions Additional selection expressions. + * + * @return $this This QueryBuilder instance. + */ + public function addSelect(string $expression, string ...$expressions): self + { + parent::addSelect($expression, ...$expressions); + $this->type = QueryType::SELECT; + $this->select = array_merge($this->select, [$expression], $expressions); + $this->sql = null; + return $this; + } + + /** + * Turns the query being built into a bulk delete query that ranges over + * a certain table. + * + * + * $qb = $conn->createQueryBuilder() + * ->delete('users', 'u') + * ->where('u.id = :user_id') + * ->setParameter(':user_id', 1); + * + * + * @param string $table The table whose rows are subject to the deletion. + * + * @return $this This QueryBuilder instance. + */ + public function delete(string $table): self + { + parent::delete($table); + $this->type = QueryType::DELETE; + $this->sql = null; + return $this; + } + + /** + * Turns the query being built into a bulk update query that ranges over + * a certain table + * + * + * $qb = $conn->createQueryBuilder() + * ->update('counters', 'c') + * ->set('c.value', 'c.value + 1') + * ->where('c.id = ?'); + * + * + * @param string $table The table whose rows are subject to the update. + * + * @return $this This QueryBuilder instance. + */ + public function update(string $table): self + { + parent::update($table); + $this->type = QueryType::UPDATE; + $this->sql = null; + return $this; + } + + /** + * Turns the query being built into an insert query that inserts into + * a certain table + * + * + * $qb = $conn->createQueryBuilder() + * ->insert('users') + * ->values( + * array( + * 'name' => '?', + * 'password' => '?' + * ) + * ); + * + * + * @param string $table The table into which the rows should be inserted. + * + * @return $this This QueryBuilder instance. + */ + public function insert(string $table): self + { + parent::insert($table); + $this->type = QueryType::INSERT; + $this->sql = null; + return $this; + } + + /** + * Creates and adds a query root corresponding to the table identified by the + * given alias, forming a cartesian product with any existing query roots. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.id') + * ->from('users', 'u') + * + * + * @param string $table The table. + * @param string|null $alias The alias of the table. + * + * @return $this This QueryBuilder instance. + */ + public function from(string $table, ?string $alias = null): self + { + parent::from($table, $alias); + $this->from[] = new From($table, $alias); + $this->sql = null; + return $this; + } + + /** + * Creates and adds a join to the query. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->innerJoin('u', 'phonenumbers', 'p', 'p.is_primary = 1'); + * + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string $condition The condition for the join. + * + * @return $this This QueryBuilder instance. + */ + public function innerJoin(string $fromAlias, string $join, string $alias, ?string $condition = null): self + { + parent::innerJoin($fromAlias, $join, $alias, $condition); + $this->join[$fromAlias][] = Join::inner($join, $alias, $condition); + $this->sql = null; + return $this; + } + + /** + * Creates and adds a left join to the query. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->leftJoin('u', 'phonenumbers', 'p', 'p.is_primary = 1'); + * + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string $condition The condition for the join. + * + * @return $this This QueryBuilder instance. + */ + public function leftJoin(string $fromAlias, string $join, string $alias, ?string $condition = null): self + { + parent::leftJoin($fromAlias, $join, $alias, $condition); + $this->join[$fromAlias][] = Join::left($join, $alias, $condition); + $this->sql = null; + return $this; + } + + /** + * Creates and adds a right join to the query. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->rightJoin('u', 'phonenumbers', 'p', 'p.is_primary = 1'); + * + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string $condition The condition for the join. + * + * @return $this This QueryBuilder instance. + */ + public function rightJoin(string $fromAlias, string $join, string $alias, ?string $condition = null): self + { + parent::rightJoin($fromAlias, $join, $alias, $condition); + $this->join[$fromAlias][] = Join::right($join, $alias, $condition); + $this->sql = null; + return $this; + } + + /** + * Specifies one or more restrictions to the query result. + * Replaces any previously specified restrictions, if any. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('c.value') + * ->from('counters', 'c') + * ->where('c.id = ?'); + * + * // You can optionally programmatically build and/or expressions + * $qb = $conn->createQueryBuilder(); + * + * $or = $qb->expr()->orx(); + * $or->add($qb->expr()->eq('c.id', 1)); + * $or->add($qb->expr()->eq('c.id', 2)); + * + * $qb->update('counters', 'c') + * ->set('c.value', 'c.value + 1') + * ->where($or); + * + * + * @param string|CompositeExpression $predicate The WHERE clause predicate. + * @param string|CompositeExpression ...$predicates Additional WHERE clause predicates. + * + * @return $this This QueryBuilder instance. + */ + public function where(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $where = $this->where = $this->createPredicate($predicate, ...$predicates); + parent::where($where); + $this->sql = null; + return $this; + } + + /** + * Adds one or more restrictions to the query results, forming a logical + * conjunction with any previously specified restrictions. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u') + * ->from('users', 'u') + * ->where('u.username LIKE ?') + * ->andWhere('u.is_active = 1'); + * + * + * @see where() + * + * @param string|CompositeExpression $predicate The predicate to append. + * @param string|CompositeExpression ...$predicates Additional predicates to append. + * + * @return $this This QueryBuilder instance. + */ + public function andWhere(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $where = $this->where = $this->appendToPredicate( + $this->where, + CompositeExpression::TYPE_AND, + $predicate, + ...$predicates, + ); + $this->where($where); + return $this; + } + + /** + * Adds one or more restrictions to the query results, forming a logical + * disjunction with any previously specified restrictions. + * + * + * $qb = $em->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->where('u.id = 1') + * ->orWhere('u.id = 2'); + * + * + * @see where() + * + * @param string|CompositeExpression $predicate The predicate to append. + * @param string|CompositeExpression ...$predicates Additional predicates to append. + * + * @return $this This QueryBuilder instance. + */ + public function orWhere(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $where = $this->where = $this->appendToPredicate($this->where, CompositeExpression::TYPE_OR, $predicate, ...$predicates); + $this->where($where); + return $this; + } + + /** + * Specifies one or more grouping expressions over the results of the query. + * Replaces any previously specified groupings, if any. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->groupBy('u.id'); + * + * + * @param string $expression The grouping expression + * @param string ...$expressions Additional grouping expressions + * + * @return $this This QueryBuilder instance. + */ + public function groupBy(string $expression, string ...$expressions): self + { + $groupBy = $this->groupBy = array_merge([$expression], $expressions); + parent::groupBy(...$groupBy); + $this->sql = null; + return $this; + } + + /** + * Adds one or more grouping expressions to the query. + * + * + * $qb = $conn->createQueryBuilder() + * ->select('u.name') + * ->from('users', 'u') + * ->groupBy('u.lastLogin') + * ->addGroupBy('u.createdAt'); + * + * + * @param string $expression The grouping expression + * @param string ...$expressions Additional grouping expressions + * + * @return $this This QueryBuilder instance. + */ + public function addGroupBy(string $expression, string ...$expressions): self + { + $groupBy = $this->groupBy = array_merge($this->groupBy, [$expression], $expressions); + $this->groupBy(...$groupBy); + return $this; + } + + /** + * Specifies a restriction over the groups of the query. + * Replaces any previous having restrictions, if any. + * + * @param string|CompositeExpression $predicate The HAVING clause predicate. + * @param string|CompositeExpression ...$predicates Additional HAVING clause predicates. + * + * @return $this This QueryBuilder instance. + */ + public function having(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $having = $this->having = $this->createPredicate($predicate, ...$predicates); + parent::having($having); + $this->sql = null; + return $this; + } + + /** + * Adds a restriction over the groups of the query, forming a logical + * conjunction with any existing having restrictions. + * + * @param string|CompositeExpression $predicate The predicate to append. + * @param string|CompositeExpression ...$predicates Additional predicates to append. + * + * @return $this This QueryBuilder instance. + */ + public function andHaving(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $having = $this->having = $this->appendToPredicate( + $this->having, + CompositeExpression::TYPE_AND, + $predicate, + ...$predicates, + ); + $this->having($having); + return $this; + } + + /** + * Adds a restriction over the groups of the query, forming a logical + * disjunction with any existing having restrictions. + * + * @param string|CompositeExpression $predicate The predicate to append. + * @param string|CompositeExpression ...$predicates Additional predicates to append. + * + * @return $this This QueryBuilder instance. + */ + public function orHaving(string|CompositeExpression $predicate, string|CompositeExpression ...$predicates): self + { + $having = $this->having = $this->appendToPredicate( + $this->having, + CompositeExpression::TYPE_OR, + $predicate, + ...$predicates, + ); + $this->having($having); + return $this; + } + + /** + * Creates a CompositeExpression from one or more predicates combined by the AND logic. + */ + private function createPredicate( + string|CompositeExpression $predicate, + string|CompositeExpression ...$predicates, + ): string|CompositeExpression { + if (count($predicates) === 0) { + return $predicate; + } + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + return new CompositeExpression(CompositeExpression::TYPE_AND, $predicate, ...$predicates); + } + + /** + * Appends the given predicates combined by the given type of logic to the current predicate. + */ + private function appendToPredicate( + string|CompositeExpression|null $currentPredicate, + string $type, + string|CompositeExpression ...$predicates, + ): string|CompositeExpression { + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if ($currentPredicate instanceof CompositeExpression && $currentPredicate->getType() === $type) { + return $currentPredicate->with(...$predicates); + } + if ($currentPredicate !== null) { + array_unshift($predicates, $currentPredicate); + } elseif (count($predicates) === 1) { + return $predicates[0]; + } + return new CompositeExpression($type, ...$predicates); + } + + /** + * Specifies an ordering for the query results. + * Replaces any previously specified orderings, if any. + * + * @param string $sort The ordering expression. + * @param string $order The ordering direction. + * + * @return $this This QueryBuilder instance. + */ + public function orderBy(string $sort, ?string $order = null): self + { + parent::orderBy($sort, $order); + $orderBy = $sort; + if ($order !== null) { + $orderBy .= ' ' . $order; + } + $this->orderBy = [$orderBy]; + $this->sql = null; + return $this; + } + + /** + * Adds an ordering to the query results. + * + * @param string $sort The ordering expression. + * @param string $order The ordering direction. + * + * @return $this This QueryBuilder instance. + */ + public function addOrderBy(string $sort, ?string $order = null): self + { + parent::addOrderBy($sort, $order); + $orderBy = $sort; + if ($order !== null) { + $orderBy .= ' ' . $order; + } + $this->orderBy[] = $orderBy; + $this->sql = null; + return $this; + } + + /** + * Resets the WHERE conditions for the query. + * + * @return $this This QueryBuilder instance. + */ + public function resetWhere(): self + { + parent::resetWhere(); + $this->where = null; + $this->sql = null; + return $this; + } + + /** + * Resets the grouping for the query. + * + * @return $this This QueryBuilder instance. + */ + public function resetGroupBy(): self + { + parent::resetGroupBy(); + $this->groupBy = []; + $this->sql = null; + return $this; + } + + /** + * Resets the HAVING conditions for the query. + * + * @return $this This QueryBuilder instance. + */ + public function resetHaving(): self + { + parent::resetHaving(); + $this->having = null; + $this->sql = null; + return $this; + } + + /** + * Resets the ordering for the query. + * + * @return $this This QueryBuilder instance. + */ + public function resetOrderBy(): self + { + parent::resetOrderBy(); + $this->orderBy = []; + $this->sql = ''; + return $this; + } + + public function setMaxResults(?int $maxResults): ConcreteQueryBuilder + { + parent::setMaxResults($maxResults); + $this->sql = null; + return $this; + } + + public function setFirstResult(int $firstResult): ConcreteQueryBuilder + { + parent::setFirstResult($firstResult); + $this->sql = null; + return $this; + } + + public function forUpdate(ConflictResolutionMode $conflictResolutionMode = ConflictResolutionMode::ORDINARY): DoctrineQueryBuilder + { + parent::forUpdate($conflictResolutionMode); + $this->forUpdate = new ForUpdate($conflictResolutionMode); + + $this->sql = null; + + return $this; + } + + public function getSQL(): string + { + if ($this->typo3_with->isEmpty()) { + return parent::getSQL(); + } + return $this->sql ??= $this->prependWith(parent::getSQL()); + } + + //################################################################################################################## + // Below are added methods not originated from Doctrine DBAL QueryBuilder + //################################################################################################################## + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_with( + string $name, + string|QueryBuilder $expression, + array $fields = [], + array $dependsOn = [], + ): self { + $this->typo3_with->set(new With($name, $fields, $dependsOn, $expression, false)); + + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_addWith( + string $name, + string|QueryBuilder $expression, + array $fields = [], + array $dependsOn = [], + ): self { + $this->typo3_with->add(new With($name, $fields, $dependsOn, $expression, false)); + + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_withRecursive( + string $name, + bool $uniqueRows, + string|QueryBuilder $initialExpression, + string|QueryBuilder $recursiveExpression, + array $fields = [], + array $dependsOn = [], + ): self { + $unionExpression = $this->connection->createQueryBuilder() + ->union($initialExpression) + ->addUnion($recursiveExpression, $uniqueRows ? UnionType::DISTINCT : UnionType::ALL); + $this->typo3_with->set(new With($name, $fields, $dependsOn, $unionExpression, true)); + + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_addWithRecursive( + string $name, + bool $uniqueRows, + string|QueryBuilder $initialExpression, + string|QueryBuilder $recursiveExpression, + array $fields = [], + array $dependsOn = [], + ): self { + $unionExpression = $this->connection->createQueryBuilder() + ->union($initialExpression) + ->addUnion($recursiveExpression, $uniqueRows ? UnionType::DISTINCT : UnionType::ALL); + $this->typo3_with->add(new With($name, $fields, $dependsOn, $unionExpression, true)); + + return $this; + } + + /** + * Determine if a query part used for where or having is empty. Used as array_filter in ConcreteQueryBuilder + * methods. This is needed to avoid invalid sql syntax by empty parts, which can happen to relaxed custom + * CompositeExpression handling. + * + * For example used to avoid : (uid = 1) and () and (pid = 2). + * + * @see ConcreteQueryBuilder::createPredicate() + * @see ConcreteQueryBuilder::appendToPredicate() + * @see \TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression::isEmptyPart() + * + * @param CompositeExpression|string|null $value + * @return bool + */ + protected static function isEmptyPart(CompositeExpression|string|null $value): bool + { + return $value === null + || ($value instanceof CompositeExpression && $value->count() === 0) + || trim((string)$value, '() ') === '' + ; + } + + /** + * @todo Should be handled in {@see AbstractPlatform} class hierarchy directly in doctrine directly if support gets + * accepted or handled internally here to avoid the force to extend and replace platform classes. + */ + private function supportsCommonTableExpressions(): bool + { + $platform = $this->connection->getDatabasePlatform(); + return $platform instanceof DoctrineMariaDBPlatform + || $platform instanceof DoctrineMySQL80Platform + || $platform instanceof DoctrineSQLitePlatform + || $platform instanceof DoctrinePostgreSQLPlatform + ; + } + + private function prependWith(string $sql): string + { + if (!$this->typo3_with->isEmpty() && !$this->supportsCommonTableExpressions()) { + throw new QueryException( + 'WITH not supported for current connection.', + 1717762530, + ); + } + return $this->typo3_with . $sql; + } +} diff --git a/Classes/Database/Query/Expression/CompositeExpression.php b/Classes/Database/Query/Expression/CompositeExpression.php new file mode 100644 index 0000000..bcb3f16 --- /dev/null +++ b/Classes/Database/Query/Expression/CompositeExpression.php @@ -0,0 +1,165 @@ + $parts + * @internal Use factory methods `and()` or `or()` methods instead. Signature will change along with doctrine/dbal 4. + */ + public function __construct(string $type, array $parts = [], bool $isOuter = false) + { + $this->isOuter = $isOuter; + // parent::__construct() call is left out by intention. doctrine/dbal works with private properties, which + // make it otherwise impossible to keep compat method signature and providing the features needed. + $this->type = $type; + if ($parts !== []) { + // doctrine/dbal solved the issue to avoid empty parts by making it mandatory to avoid instantiating this + // class without a part. As we allow this and handle empty parts later on, we apply the empty check here. + // @see https://github.com/doctrine/dbal/issues/2388 + $parts = array_filter($parts, static fn(CompositeExpression|DoctrineCompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + } + $this->parts = $parts; + } + + /** + * Retrieves the string representation of this composite expression. + * If expression is empty, just return an empty string. + * Native Doctrine expression would return () instead. + */ + public function __toString(): string + { + $count = $this->count(); + if ($count === 0) { + return ''; + } + if ($count === 1) { + return (string)$this->parts[0]; + } + if ($this->isOuter) { + return '(' . implode(') ' . $this->type . ' (', $this->parts) . ')'; + } + return '((' . implode(') ' . $this->type . ' (', $this->parts) . '))'; + } + + /** + * @param self|string|null $part + * @param self|string|null ...$parts + */ + public static function and($part = null, ...$parts): self + { + return (new self(self::TYPE_AND, []))->with($part, ...$parts); + } + + /** + * @param self|string|null $part + * @param self|string|null ...$parts + */ + public static function or($part = null, ...$parts): self + { + return (new self(self::TYPE_OR, []))->with($part, ...$parts); + } + + /** + * Returns a new CompositeExpression with the given parts added. + * + * @param self|string|null $part + * @param self|string|null ...$parts + */ + public function with($part = null, ...$parts): self + { + $mergedParts = array_merge([$part], $parts); + $mergedParts = array_filter($mergedParts, static fn(CompositeExpression|DoctrineCompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + $that = clone $this; + foreach ($mergedParts as $singlePart) { + $that->parts[] = $singlePart; + } + + return $that; + } + + /** + * Retrieves the amount of expressions on composite expression. + */ + public function count(): int + { + return count($this->parts); + } + + /** + * Returns the type of this composite expression (AND/OR). + */ + public function getType(): string + { + return $this->type; + } + + /** + * Determine if a part is considerable empty. + * + * doctrine/dbal solved the issue to avoid empty parts by making it mandatory to avoid instantiating this + * class without a part. As we allow this and handle empty parts later on, we apply the empty check here. + * @see https://github.com/doctrine/dbal/issues/2388 + */ + private static function isEmptyPart(CompositeExpression|DoctrineCompositeExpression|string|null $value): bool + { + if ($value === null) { + return true; + } + if (is_string($value)) { + return trim($value, '() ') === ''; + } + if ($value instanceof CompositeExpression) { + // TYPO3 implementation filters empty parts on setting and count is reliable in that case. + return $value->parts === []; + } + // We need to use the count method, because the property is private in Doctrine and cannot be checked + // against an empty array like it can be done for the own instance. Using Reflection would negate the + // benefit. That's life. + if ($value->count() === 0) { + // Note that this should not be possible with plain Doctrine DBAL + // composite expression, still lets ensure a fallback here. + return true; + } + // Doctrine DBAL CompositeExpression does not filter empty parts, so we need to build the string to + // evaluate if it is empty or not, which comes with some performance impact hitting only when TYPO3 + // extension authors are using the Doctrine Composite Expression instead of the TYPO3 variant. + return trim((string)$value, '() ') === ''; + } +} diff --git a/Classes/Database/Query/Expression/ExpressionBuilder.php b/Classes/Database/Query/Expression/ExpressionBuilder.php new file mode 100644 index 0000000..019c805 --- /dev/null +++ b/Classes/Database/Query/Expression/ExpressionBuilder.php @@ -0,0 +1,1143 @@ +comparison($this->connection->quoteIdentifier($fieldName), static::EQ, $value); + } + + /** + * Creates a non equality comparison expression with the given arguments. + * First argument is considered the left expression and the second is the right expression. + * When converted to string, it will generate a <> . Example:: + * + * [php] + * // u.id <> 1 + * $q->where($q->expr()->neq('u.id', '1')); + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value The value. No automatic quoting/escaping is done. + */ + public function neq(string $fieldName, $value): string + { + return $this->comparison($this->connection->quoteIdentifier($fieldName), static::NEQ, $value); + } + + /** + * Creates a lower-than comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value The value. No automatic quoting/escaping is done. + */ + public function lt(string $fieldName, $value): string + { + return $this->comparison($this->connection->quoteIdentifier($fieldName), static::LT, $value); + } + + /** + * Creates a lower-than-equal comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value The value. No automatic quoting/escaping is done. + */ + public function lte(string $fieldName, $value): string + { + return $this->comparison($this->connection->quoteIdentifier($fieldName), static::LTE, $value); + } + + /** + * Creates a greater-than comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value The value. No automatic quoting/escaping is done. + */ + public function gt(string $fieldName, $value): string + { + return $this->comparison($this->connection->quoteIdentifier($fieldName), static::GT, $value); + } + + /** + * Creates a greater-than-equal comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value The value. No automatic quoting/escaping is done. + */ + public function gte(string $fieldName, $value): string + { + return $this->comparison($this->connection->quoteIdentifier($fieldName), static::GTE, $value); + } + + /** + * Creates an IS NULL expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + */ + public function isNull(string $fieldName): string + { + return $this->connection->quoteIdentifier($fieldName) . ' IS NULL'; + } + + /** + * Creates an IS NOT NULL expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + */ + public function isNotNull(string $fieldName): string + { + return $this->connection->quoteIdentifier($fieldName) . ' IS NOT NULL'; + } + + /** + * Creates a LIKE() comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value Argument to be used in LIKE() comparison. No automatic quoting/escaping is done. + */ + public function like(string $fieldName, mixed $value, ?string $escapeChar = null): string + { + $fieldName = $this->connection->quoteIdentifier($fieldName); + $platform = $this->connection->getDatabasePlatform(); + $escapeChar ??= '\\'; + $escapeChar = $this->connection->quote($escapeChar); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + // Use ILIKE to mimic case-insensitive search like most people are trained from MySQL/MariaDB. + return $this->comparison($this->castText($fieldName), 'ILIKE', $value); + } + // Note: SQLite does not properly work with non-ascii letters as search word for case-insensitive + // matching, UPPER() and LOWER() have the same issue, it only works with ascii letters. + // See: https://www.sqlite.org/src/doc/trunk/ext/icu/README.txt + return $this->comparison($fieldName, 'LIKE', $value) + . ($escapeChar !== '' ? sprintf(' ESCAPE %s', $escapeChar) : ''); + } + + /** + * Creates a NOT LIKE() comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param mixed $value Argument to be used in NOT LIKE() comparison. No automatic quoting/escaping is done. + */ + public function notLike(string $fieldName, mixed $value, ?string $escapeChar = null): string + { + $fieldName = $this->connection->quoteIdentifier($fieldName); + $platform = $this->connection->getDatabasePlatform(); + $escapeChar ??= '\\'; + $escapeChar = $this->connection->quote($escapeChar); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + // Use ILIKE to mimic case-insensitive search like most people are trained from MySQL/MariaDB. + return $this->comparison($this->castText($fieldName), 'NOT ILIKE', $value); + } + // Note: SQLite does not properly work with non-ascii letters as search word for case-insensitive + // matching, UPPER() and LOWER() have the same issue, it only works with ascii letters. + // See: https://www.sqlite.org/src/doc/trunk/ext/icu/README.txt + return $this->comparison($fieldName, 'NOT LIKE', $value) + . ($escapeChar !== '' ? sprintf(' ESCAPE %s', $escapeChar) : ''); + } + + /** + * Creates an IN () comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param string|array $value The placeholder or the array of values to be used by IN() comparison. + * No automatic quoting/escaping is done. + */ + public function in(string $fieldName, $value): string + { + if ($value === []) { + throw new \InvalidArgumentException( + 'ExpressionBuilder::in() can not be used with an empty array value.', + 1701857902 + ); + } + if ($value === '') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::in() can not be used with an empty string value.', + 1701857903 + ); + } + return $this->comparison( + $this->connection->quoteIdentifier($fieldName), + 'IN', + '(' . implode(', ', (array)$value) . ')' + ); + } + + /** + * Creates a NOT IN () comparison expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param string|array $value The placeholder or the array of values to be used by NOT IN() comparison. + * No automatic quoting/escaping is done. + */ + public function notIn(string $fieldName, $value): string + { + if ($value === []) { + throw new \InvalidArgumentException( + 'ExpressionBuilder::notIn() can not be used with an empty array value.', + 1701857904 + ); + } + if ($value === '') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::notIn() can not be used with an empty string value.', + 1701857905 + ); + } + return $this->comparison( + $this->connection->quoteIdentifier($fieldName), + 'NOT IN', + '(' . implode(', ', (array)$value) . ')' + ); + } + + /** + * Returns a comparison that can find a value in a list field (CSV). + * + * @param string $fieldName The field name. Will be quoted according to database platform automatically. + * @param string $value Argument to be used in FIND_IN_SET() comparison. No automatic quoting/escaping is done. + * @param bool $isColumn Set when the value to compare is a column on a table to activate casting + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function inSet(string $fieldName, string $value, bool $isColumn = false): string + { + if ($value === '') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::inSet() can not be used with an empty string value.', + 1459696089 + ); + } + if (str_contains($value, ',')) { + throw new \InvalidArgumentException( + 'ExpressionBuilder::inSet() can not be used with values that contain a comma (",").', + 1459696090 + ); + } + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + return $this->comparison( + $isColumn ? $value . '::text' : $this->literal($this->unquoteLiteral($value)), + self::EQ, + sprintf( + 'ANY(string_to_array(%s, %s))', + $this->connection->quoteIdentifier($fieldName) . '::text', + $this->literal(',') + ) + ); + } + if ($platform instanceof DoctrineSQLitePlatform) { + if (str_starts_with($value, ':') || $value === '?') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::inSet() for SQLite can not be used with placeholder arguments.', + 1476029421 + ); + } + return sprintf( + 'instr(%s, %s)', + implode( + '||', + [ + $this->literal(','), + $this->connection->quoteIdentifier($fieldName), + $this->literal(','), + ] + ), + $isColumn + ? implode( + '||', + [ + $this->literal(','), + // do not explicitly quote value as it is expected to be + // quoted by the caller + 'cast(' . $value . ' as text)', + $this->literal(','), + ] + ) + : $this->literal( + ',' . $this->unquoteLiteral($value) . ',' + ) + ); + } + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + return sprintf( + 'FIND_IN_SET(%s, %s)', + $value, + $this->connection->quoteIdentifier($fieldName) + ); + } + throw new \RuntimeException( + sprintf('FIND_IN_SET support for database platform "%s" not yet implemented.', $platform::class), + 1459696680 + ); + } + + /** + * Returns a comparison that can find a value in a list field (CSV) but is negated. + * + * @param string $fieldName The field name. Will be quoted according to database platform automatically. + * @param string $value Argument to be used in FIND_IN_SET() comparison. No automatic quoting/escaping is done. + * @param bool $isColumn Set when the value to compare is a column on a table to activate casting + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function notInSet(string $fieldName, string $value, bool $isColumn = false): string + { + if ($value === '') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::notInSet() can not be used with an empty string value.', + 1627573099 + ); + } + if (str_contains($value, ',')) { + throw new \InvalidArgumentException( + 'ExpressionBuilder::notInSet() can not be used with values that contain a comma (",").', + 1627573100 + ); + } + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + return $this->comparison( + $isColumn ? $value . '::text' : $this->literal($this->unquoteLiteral($value)), + self::NEQ, + sprintf( + 'ALL(string_to_array(%s, %s))', + $this->connection->quoteIdentifier($fieldName) . '::text', + $this->literal(',') + ) + ); + } + if ($platform instanceof DoctrineSQLitePlatform) { + if (str_starts_with($value, ':') || $value === '?') { + throw new \InvalidArgumentException( + 'ExpressionBuilder::inSet() for SQLite can not be used with placeholder arguments.', + 1627573103 + ); + } + return sprintf( + 'instr(%s, %s) = 0', + implode( + '||', + [ + $this->literal(','), + $this->connection->quoteIdentifier($fieldName), + $this->literal(','), + ] + ), + $isColumn + ? implode( + '||', + [ + $this->literal(','), + // do not explicitly quote value as it is expected to be + // quoted by the caller + 'cast(' . $value . ' as text)', + $this->literal(','), + ] + ) + : $this->literal( + ',' . $this->unquoteLiteral($value) . ',' + ) + ); + } + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + return sprintf( + 'NOT FIND_IN_SET(%s, %s)', + $value, + $this->connection->quoteIdentifier($fieldName) + ); + } + throw new \RuntimeException( + sprintf('negative FIND_IN_SET support for database platform "%s" not yet implemented.', $platform::class), + 1627573101 + ); + } + + /** + * Creates a bitwise AND expression with the given arguments. + * + * @param string $fieldName The fieldname. Will be quoted according to database platform automatically. + * @param int $value Argument to be used in the bitwise AND operation + */ + public function bitAnd(string $fieldName, int $value): string + { + return $this->comparison( + $this->connection->quoteIdentifier($fieldName), + '&', + $value + ); + } + + /** + * Creates a MIN expression for the given field/alias. + * + * @param string|null $alias + */ + public function min(string $fieldName, ?string $alias = null): string + { + return $this->calculation('MIN', $fieldName, $alias); + } + + /** + * Creates a MAX expression for the given field/alias. + * + * @param string|null $alias + */ + public function max(string $fieldName, ?string $alias = null): string + { + return $this->calculation('MAX', $fieldName, $alias); + } + + /** + * Creates an AVG expression for the given field/alias. + * + * @param string|null $alias + */ + public function avg(string $fieldName, ?string $alias = null): string + { + return $this->calculation('AVG', $fieldName, $alias); + } + + /** + * Creates a SUM expression for the given field/alias. + * + * @param string|null $alias + */ + public function sum(string $fieldName, ?string $alias = null): string + { + return $this->calculation('SUM', $fieldName, $alias); + } + + /** + * Creates a COUNT expression for the given field/alias. + * + * @param string|null $alias + */ + public function count(string $fieldName, ?string $alias = null): string + { + return $this->calculation('COUNT', $fieldName, $alias); + } + + /** + * Creates a LENGTH expression for the given field/alias. + * + * @param string|null $alias + */ + public function length(string $fieldName, ?string $alias = null): string + { + return $this->calculation('LENGTH', $fieldName, $alias); + } + + /** + * Creates an expression to alias a value, field value or sub-expression. + * + * **Example:** + * ``` + * $queryBuilder->selectLiteral( + * $queryBuilder->quoteIdentifier('uid'), + * $queryBuilder->expr()->as('(1 + 1 + 1)', 'calculated_field'), + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * (1 + 1 + 1) AS `calculated_field` + * ``` + * + * @param string $expression Value, identifier or expression which should be aliased + * @param string $asIdentifier Alias identifier + * @return string Returns aliased expression + */ + public function as(string $expression, string $asIdentifier = ''): string + { + if (trim($this->trimIdentifierQuotes(trim($expression))) === '') { + throw new \InvalidArgumentException( + sprintf('Value or expression must be provided as first argument for "%s"', __METHOD__), + 1709826333 + ); + } + $asIdentifier = trim($this->trimIdentifierQuotes(trim($this->unquoteLiteral($asIdentifier)))); + if ($asIdentifier !== '') { + $asIdentifier = ' AS ' . $this->connection->quoteIdentifier($asIdentifier); + } + return $expression . $asIdentifier; + } + + /** + * Concatenate multiple values or expressions into one string value. + * No automatic quoting or value casting! Ensure each part evaluates to a valid varchar value! + * + * **Example:** + * ``` + * // Combine value of two fields with a space + * $concatExpressionAsString = $queryBuilder->expr()->concat( + * $queryBuilder->quoteIdentifier('first_name_field'), + * $queryBuilder->quote(' '), + * $queryBuilder->quoteIdentifier('last_name_field') + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * CONCAT(`first_name_field`, " ", `last_name_field`) + * ``` + * + * @param string ...$parts Unquoted value or expression part to concatenated with the other parts + * @return string Returns the concatenation expression compatible with the database connection platform + */ + public function concat(string ...$parts): string + { + return $this->connection->getDatabasePlatform()->getConcatExpression(...$parts); + } + + /** + * Create a `CAST()` statement to cast value or expression to a varchar with a given dynamic max length. + * MySQL does not support `VARCHAR` as cast type, therefor `CHAR` is used. + * + * **Example:** + * ``` + * $fieldVarcharCastExpression = $queryBuilder->expr()->castVarchar( + * $queryBuilder->quote('123'), // integer as string + * 255, // convert to varchar(255) field - dynamic length + * 'new_field_identifier', + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * CAST("123" AS VARCHAR(255)) + * ``` + * + * @param string $value Unquoted value or expression, which should be cast + * @param int $length Dynamic varchar field length + * @param string $asIdentifier Used to add a field identifier alias (`AS`) if non-empty string (optional) + * @return string Returns the cast expression compatible for the database platform + */ + public function castVarchar(string $value, int $length = 255, string $asIdentifier = ''): string + { + $platform = $this->connection->getDatabasePlatform(); + $pattern = match (true) { + $platform instanceof DoctrinePostgreSQLPlatform => '(%s::%s(%s))', + default => '(CAST(%s AS %s(%s)))' + }; + $type = match (true) { + // MariaDB added VARCHAR as alias for CHAR to the CAST function, therefore we + // need to use CHAR here - albeit this still creates a VARCHAR type as long as + // length is not ZERO. + // https://dev.mysql.com/doc/refman/8.0/en/cast-functions.html#function_cast + $platform instanceof DoctrineMySQLPlatform => 'CHAR', + default => 'VARCHAR' + }; + return $this->as(sprintf($pattern, $value, $type, $length), $asIdentifier); + } + + /** + * Create a `CAST` statement to cast a value or expression result to signed integer type. + * + * Be aware that some database vendors will emit an error if an invalid type has been provided (PostgreSQL), + * and other silently return valid integer from the string discarding the non-integer part (starting with digits) + * or silently returning unrelated integer value. Use with care. + * + * No automatic quoting or value casting! Ensure each part evaluates to a valid value! + * + * **Example:** + * ``` + * $queryBuilder->expr()->castInt( + * '(' . '1 * 10' . ')', + * 'virtual_field', + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * CAST(('1 * 10') AS INTEGER) AS `virtual_field` + * ``` + * + * @param string $value Quoted value or expression result which should be cast to integer type + * @param string $asIdentifier Optionally add a field identifier alias (`AS`) + * @return string Returns the integer cast expression compatible with the connection database platform + */ + public function castInt(string $value, string $asIdentifier = ''): string + { + // @todo Consider to add a flag to allow unsigned integer casting, except for PostgresSQL + // which does not support unsigned integer type at all. + $type = 'SIGNED INTEGER'; + $pattern = '(CAST(%s AS %s))'; + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + $pattern = '%s::%s'; + $type = 'INTEGER'; + } + return $this->as(sprintf($pattern, $value, $type), $asIdentifier); + } + + /** + * Creates a cast for the `$expression` result to a text datatype depending on the database management system. + * + * Note that for MySQL/MariaDB the corresponding CHAR/VARCHAR types are used with a length of `16383` reflecting + * 65554 bytes with `utf8mb4` and working with default `max_packet_size=16KB`. For SQLite and PostgreSQL the text + * type conversion is used. + * + * Main purpose of this expression is to use it in a expression chain to convert non-text values to text in chain + * with other expressions, for example to {@see self::concat()} multiple values or to ensure the type, within + * `UNION/UNION ALL` query parts for example in recursive `Common Table Expressions` parts. + * + * This is a replacement for {@see QueryBuilder::castFieldToTextType()} with minor adjustments like enforcing and + * limiting the size to a fixed variant to be more usable in sensible areas like `Common Table Expressions`. + * + * Alternatively the {@see self::castVarchar()} can be used which allows for dynamic length setting per expression + * call. + * + * **Example:** + * ``` + * $queryBuilder->expr()->castText( + * '(' . '1 * 10' . ')', + * 'virtual_field' + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * CAST((1 * 10) AS CHAR(16383) AS `virtual_field` + * ``` + * + * @throws \RuntimeException when used with a unsupported platform. + */ + public function castText(CompositeExpression|\Stringable|string $expression, string $asIdentifier = ''): string + { + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrinePostgreSQLPlatform) { + return $this->as(sprintf('((%s)::%s)', $expression, 'text'), $asIdentifier); + } + if ($platform instanceof DoctrineSQLitePlatform) { + return $this->as(sprintf('(CAST((%s) AS %s))', $expression, 'TEXT'), $asIdentifier); + } + if ($platform instanceof DoctrineMariaDBPlatform) { + // 16383 is the maximum for a VARCHAR field with `utf8mb4` + return $this->as(sprintf('(CAST((%s) AS %s(%s)))', $expression, 'VARCHAR', '16383'), $asIdentifier); + } + if ($platform instanceof DoctrineMySQLPlatform) { + // 16383 is the maximum for a VARCHAR field with `utf8mb4` + return $this->as(sprintf('(CAST((%s) AS %s(%s)))', $expression, 'CHAR', '16383'), $asIdentifier); + } + throw new \RuntimeException( + sprintf( + '%s is not implemented for the used database platform "%s", yet!', + __METHOD__, + get_class($this->connection->getDatabasePlatform()) + ), + 1722105672 + ); + } + + /** + * Create an SQL aggregate function. + * + * @param string|null $alias + */ + protected function calculation(string $aggregateName, string $fieldName, ?string $alias = null): string + { + $aggregateSQL = sprintf( + '%s(%s)', + $aggregateName, + $this->connection->quoteIdentifier($fieldName) + ); + + if (!empty($alias)) { + $aggregateSQL .= ' AS ' . $this->connection->quoteIdentifier($alias); + } + + return $aggregateSQL; + } + + /** + * Creates a TRIM expression for the given field. + * + * @param string $fieldName Field name to build expression for + * @param TrimMode $position Either constant out of LEADING, TRAILING, BOTH + * @param string|null $char Character to be trimmed (defaults to space) + */ + public function trim(string $fieldName, TrimMode $position = TrimMode::UNSPECIFIED, ?string $char = null): string + { + return $this->connection->getDatabasePlatform()->getTrimExpression( + $this->connection->quoteIdentifier($fieldName), + $position, + ($char === null ? null : $this->literal($char)) + ); + } + + /** + * Create a statement to generate a value repeating defined $value for $numberOfRepeats times. + * This method can be used to provide the repeat number as a sub-expression or calculation. + * + * This method does not quote anything! Ensure proper quoting (value/identifier) for $numberOfRepeats and $value. + * + * **Example:** + * ``` + * $queryBuilder->expr()->repeat( + * 20, + * $queryBuilder->quote('0'), + * $queryBuilder->quoteIdentifier('aliased_field'), + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * REPEAT("0", 20) AS `aliased_field` + * ``` + * + * @param int|string $numberOfRepeats Statement or value defining how often the $value should be repeated. Proper quoting must be ensured. + * @param string $value Value which should be repeated. Proper quoting must be ensured + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Returns the platform compatible statement to create the x-times repeated value + */ + public function repeat(int|string $numberOfRepeats, string $value, string $asIdentifier = ''): string + { + $numberOfRepeats = $this->castInt((string)$numberOfRepeats); + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineSQLitePlatform) { + $pattern = "replace(printf('%%.' || %s || 'c', '/'),'/', %s)"; + return $this->as( + sprintf($pattern, $numberOfRepeats, $value), + $asIdentifier + ); + } + $pattern = 'REPEAT(%s, %s)'; + return $this->as( + sprintf($pattern, $value, $numberOfRepeats), + $asIdentifier, + ); + } + + /** + * Create statement containing $numberOfSpaces spaces. + * This method does not quote anything! Ensure proper quoting (value/identifier) for $numberOfSpaces! + * + * **Example:** + * ``` + * $queryBuilder->expr()->space( + * $queryBuilder->expr()->castInt( + * $queryBuilder->quoteIdentifier('table_repeat_number_field') + * ), + * $queryBuilder->quoteIdentifier('aliased_field'), + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * SPACE(CAST(`table_repeat_number_field` AS INTEGER)) AS `aliased_field` + * ``` + * + * @param int|string $numberOfSpaces Statement or value defining how often a space should be repeated. Proper quoting must be ensured. + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Returns the platform compatible statement to create the x-times repeated space(s). + */ + public function space(int|string $numberOfSpaces, string $asIdentifier = ''): string + { + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + // Use `SPACE()` method supported by MySQL and MariaDB + $pattern = 'SPACE(%s)'; + $numberOfSpaces = $this->castInt((string)$numberOfSpaces); + return $this->as( + sprintf($pattern, $numberOfSpaces), + $asIdentifier, + ); + } + // Emulate `SPACE()` by using the `repeat()` expression. + return $this->repeat($numberOfSpaces, $this->connection->quote(' '), $asIdentifier); + } + + /** + * Extract $length character of $value from the right side. + * $length can be an integer like value or a sub-expression evaluating to an integer value + * to define the length of the extracted length from the right side. + * This method does not quote anything! Ensure proper quoting (value/identifier) $length and $value! + * + * **Example:** + * ``` + * $queryBuilder->expr()->left( + * $queryBuilder->castInt('(' . '23' . ')'), + * $queryBuilder->quoteIdentifier('table_field_name'), + * 'virtual_field' + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * LEFT(CAST(`table_field_name` AS INTEGER), CAST("23" AS INTEGER)) AS `virtual_field` + * ``` + * + * @param int|string $length Integer value or expression providing the length as integer + * @param string $value Value, identifier or expression defining the value to extract from the left + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Return the expression to extract defined substring from the right side. + */ + public function left(int|string $length, string $value, string $asIdentifier = ''): string + { + $length = (is_string($length)) ? $this->castInt($length) : (string)$length; + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineSQLitePlatform) { + // SQLite does not support `LEFT()`, use `SUBSTRING()` instead. Weirdly, we need to increment the length by + // one to get the correct substring length. + return $this->as( + $platform->getSubstringExpression($value, '0', $length . ' + 1'), + $asIdentifier, + ); + } + return $this->as( + sprintf('LEFT(%s, %s)', $value, $length), + $asIdentifier, + ); + } + + /** + * Extract $length character of $value from the right side. + * $length can be an integer like value or a sub-expression evaluating to an integer value to + * define the length of the extracted length from the right side. + * This method does not quote anything! Ensure proper quoting (value/identifier) $length and $value! + * + * **Example:** + * ``` + * $expression5 = $queryBuilder->expr()->right( + * 6, + * $queryBuilder->quote('some-string'), + * 'calculated_row_field', + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * RIGHT("some-string", CAST(6 AS INTEGER)) AS `calculated_row_field` + * ``` + * + * @param int|string $length Integer value or expression providing the length as integer + * @param string $value Value, identifier or expression defining the value to extract from the left + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Return the expression to extract defined substring from the right side + */ + public function right(int|string $length, string $value, string $asIdentifier = ''): string + { + if ($asIdentifier !== '') { + $asIdentifier = ' AS ' . $this->connection->quoteIdentifier($this->unquoteLiteral($this->trimIdentifierQuotes($asIdentifier))); + } + $length = (is_string($length)) ? $this->castInt($length) : (string)$length; + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineSQLitePlatform) { + // SQLite does not support `RIGHT()`, use `SUBSTRING()` instead. + return $this->connection->getDatabasePlatform() + ->getSubstringExpression($value, $length . ' * -1') . $asIdentifier; + } + return sprintf('RIGHT(%s, %s)', $value, $length) . $asIdentifier; + } + + /** + * Left-pad the value or sub-expression result with $paddingValue, to a total length of $length. + * No automatic quoting or escaping is done, which allows the usage of a sub-expression for $value! + * + * **Example:** + * ``` + * $queryBuilder->expr()->leftPad( + * $queryBuilder->quote('123'), + * 10, + * '0', + * 'padded_value' + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * LPAD("123", CAST("10" AS INTEGER), "0") AS `padded_value` + * ``` + * + * @param string $value Value, identifier or expression defining the value which should be left padded + * @param int|string $length Padded length, to either fill up with $paddingValue on the left side or crop to + * @param string $paddingValue Padding character used to fill up if characters are missing on the left side + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Returns database connection platform compatible left-pad expression. + */ + public function leftPad(string $value, int|string $length, string $paddingValue, string $asIdentifier = ''): string + { + if (trim($this->unquoteLiteral($paddingValue), ' ') === '') { + throw new \InvalidArgumentException( + sprintf('Empty $paddingValue provided for "%s".', __METHOD__), + 1709658914 + ); + } + if (strlen(trim($this->unquoteLiteral($paddingValue), ' ')) > 1) { + throw new \InvalidArgumentException( + sprintf('Invalid $paddingValue "%s" provided for "%s". Exactly one char allowed.', $paddingValue, __METHOD__), + 1709659006 + ); + } + // PostgresSQL is really picky about types when calling functions, therefore we ensure that the value or + // expression result is ensured to be string-typed by casting it to a varchar result for all platforms. + $value = $this->castVarchar($value); + $paddingValue = $this->connection->quote($this->unquoteLiteral($paddingValue)); + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineSQLitePlatform) { + // SQLite does not support `LPAD()`, therefore we need to build up a generic nested method construct to + // mimic that method for now. Basically, the length is checked and either the substring up to the length + // returned OR the substring from the right side up to the length on the concentrated repeated-value with + // the value to cut of the overlapped prefixed repeated value. + $repeat = $this->repeat( + $length, + $paddingValue + ); + $pattern = 'IIF(LENGTH(%s) >= %s, %s, %s)'; + return $this->as(sprintf( + $pattern, + // Value and length for the length check to consider which part to use. + $value, + $this->castInt((string)$length), + // Return substring with $length from left side to mimic `LPAD()` behaviour of other platforms. + $platform->getSubstringExpression($value, '0', $this->castInt((string)$length) . ' + 1'), + // Concatenate `repeat + value` and fetch the substring with length from the right side, + // so we cut of overlapping prefixed repeat placeholders. + $this->right($length, $this->concat($repeat, $value)) + ), $asIdentifier); + } + return $this->as(sprintf('LPAD(%s, %s, %s)', $value, $this->castInt((string)$length), $paddingValue), $asIdentifier); + } + + /** + * Right-pad the value or sub-expression result with $paddingValue, to a total length of $length. + * No automatic quoting or escaping is done, which allows the usage of a sub-expression for $value! + * + * **Example:** + * ``` + * $queryBuilder->expr()->rightPad( + * $queryBuilder->quote('123'), + * 10, + * '0', + * 'padded_value' + * ); + * ``` + * + * **Result with MySQL:** + * ``` + * RPAD("123", CAST("10" AS INTEGER), "0") AS `padded_value` + * ``` + * + * @param string $value Value, identifier or expression defining the value which should be right padded + * @param int|string $length Value, identifier or expression defining the padding length to fill up or crop + * @param string $paddingValue Padding character used to fill up if characters are missing on the right side + * @param string $asIdentifier Provide `AS` identifier if not empty + * @return string Returns database connection platform compatible right-pad expression + */ + public function rightPad(string $value, int|string $length, string $paddingValue, string $asIdentifier = ''): string + { + if (trim($this->unquoteLiteral($paddingValue), ' ') === '') { + throw new \InvalidArgumentException( + sprintf('Empty $paddingValue provided for "%s".', __METHOD__), + 1709664589 + ); + } + if (strlen(trim($this->unquoteLiteral($paddingValue), ' ')) > 1) { + throw new \InvalidArgumentException( + sprintf('Invalid $paddingValue "%s" provided for "%s". Exactly one char allowed.', $paddingValue, __METHOD__), + 1709664598 + ); + } + // PostgresSQL is really picky about types when calling functions, therefore we ensure that the value or + // expression result is ensured to be string-type by casting it to a varchar result for all platforms. + $value = $this->castVarchar($value); + $paddingValue = $this->connection->quote($this->unquoteLiteral($paddingValue)); + $platform = $this->connection->getDatabasePlatform(); + if ($platform instanceof DoctrineSQLitePlatform) { + $repeat = $this->repeat( + $length, + $paddingValue + ); + $pattern = 'IIF(LENGTH(%s) >= %s, %s, %s)'; + return $this->as(sprintf( + $pattern, + // Value and length for the length check to consider which part to use. + $value, + $this->castInt((string)$length), + // Return substring with $length from left side to mimic `RPAD()` behaviour of other platforms. + // Note: `RPAD()` cuts the value from the left like LPAD(), which is brain melt. Therefore, + // this is adopted here to be concise with this behaviour. + $this->left($length, $value), + // Concatenate `repeat + value` and fetch the substring with length from the right side, so we + // cut off overlapping prefixed repeat placeholders. + $this->left($length, $this->castVarchar($this->concat($value, $this->castVarchar($repeat)))) + ), $asIdentifier); + } + return $this->as(sprintf('RPAD(%s, %s, %s)', $value, $this->castInt((string)$length), $paddingValue), $asIdentifier); + } + + /** + * Creates IF-THEN-ELSE expression construct compatible with all supported database vendors. + * No automatic quoting or escaping is done, which allows to build up nested expression statements. + * + * **Example:** + * ``` + * $queryBuilder + * ->selectLiteral( + * $queryBuilder->expr()->if( + * $queryBuilder->expr()->eq('hidden', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + * $queryBuilder->quote('page-is-visible'), + * $queryBuilder->quote('page-is-not-visible'), + * 'result_field_name' + * ), + * ) + * ->from('pages'); + * ``` + * + * **Result with MySQL:** + * ``` + * SELECT (IF(`hidden` = 0, 'page-is-visible', 'page-is-not-visible')) AS `result_field_name` FROM `pages` + * ``` + */ + public function if( + CompositeExpression|\Doctrine\DBAL\Query\Expression\CompositeExpression|\Stringable|string $condition, + \Stringable|string $truePart, + \Stringable|string $falsePart, + \Stringable|string|null $as = null + ): string { + $platform = $this->connection->getDatabasePlatform(); + $pattern = match (true) { + $platform instanceof DoctrineSQLitePlatform => 'IIF(%s, %s, %s)', + $platform instanceof DoctrinePostgreSQLPlatform => 'CASE WHEN %s THEN %s ELSE %s END', + $platform instanceof DoctrineMariaDBPlatform, + $platform instanceof DoctrineMySQLPlatform => 'IF(%s, %s, %s)', + default => throw new \RuntimeException( + sprintf('Platform "%s" not supported for "%s"', $platform::class, __METHOD__), + 1721806463 + ) + }; + $expression = sprintf($pattern, $condition, $truePart, $falsePart); + if ($as !== null) { + $expression = $this->as(sprintf('(%s)', $expression), $as); + } + return $expression; + } + + /** + * Quotes a given input parameter. + * + * @param string $input The parameter to be quoted. + */ + public function literal(string $input): string + { + return $this->connection->quote($input); + } + + public function getContainer(): ContainerInterface + { + return $this->container; + } + + /** + * Unquote a string literal. Used to unquote values for internal platform adjustments. + * + * @param string $value The value to be unquoted + * @return string The unquoted value + */ + protected function unquoteLiteral(string $value): string + { + if (str_starts_with($value, "'") && str_ends_with($value, "'")) { + $map = [ + "''" => "'", + ]; + if ($this->connection->getDatabasePlatform() instanceof DoctrineMySQLPlatform) { + // MySQL needs escaped backslashes for quoted value, which we need to revert in case of unquoting. + $map['\\\\'] = '\\'; + } + return str_replace(array_keys($map), array_values($map), substr($value, 1, -1)); + } + return $value; + } + + /** + * Trim all possible identifier quotes from identifier. + * + * @see \Doctrine\DBAL\Schema\AbstractAsset::trimQuotes() + */ + private function trimIdentifierQuotes(string $identifier): string + { + return str_replace(['`', '"', '[', ']'], '', $identifier); + } +} diff --git a/Classes/Database/Query/NamedParameterNotSupportedForPreparedStatementException.php b/Classes/Database/Query/NamedParameterNotSupportedForPreparedStatementException.php new file mode 100644 index 0000000..1676fc6 --- /dev/null +++ b/Classes/Database/Query/NamedParameterNotSupportedForPreparedStatementException.php @@ -0,0 +1,32 @@ + + * $query->select('aField', 'anotherField') + * ->from('aTable') + * ->where($query->expr()->eq('aField', 1)) + * ->andWhere($query->expr()->gte('anotherField',10')) + * ->execute() + * + * + * Additional functionality included is support for COUNT() and TRUNCATE() statements. + */ +class QueryBuilder extends ConcreteQueryBuilder +{ + protected ConcreteQueryBuilder $concreteQueryBuilder; + + protected QueryRestrictionContainerInterface $restrictionContainer; + + protected array $additionalRestrictions; + + /** + * List of table aliases which are completely ignored + * when generating the table restrictions in the where-clause. + * + * Aliases added here are part of a LEFT/RIGHT JOIN, having + * their restrictions applied in the JOIN's ON condition already. + * + * @var string[] + */ + private array $restrictionsAppliedInJoinCondition = []; + + /** + * Initializes a new QueryBuilder. + * + * @param Connection $connection The DBAL Connection. + * @param QueryRestrictionContainerInterface|null $restrictionContainer + * @param ConcreteQueryBuilder|null $concreteQueryBuilder + * @param array|null $additionalRestrictions + */ + public function __construct( + Connection $connection, + ?QueryRestrictionContainerInterface $restrictionContainer = null, + ?ConcreteQueryBuilder $concreteQueryBuilder = null, + ?array $additionalRestrictions = null + ) { + parent::__construct($connection); + $this->additionalRestrictions = $additionalRestrictions ?: $GLOBALS['TYPO3_CONF_VARS']['DB']['additionalQueryRestrictions'] ?? []; + $this->setRestrictions($restrictionContainer ?: GeneralUtility::makeInstance(DefaultRestrictionContainer::class)); + $this->concreteQueryBuilder = $concreteQueryBuilder ?: GeneralUtility::makeInstance(ConcreteQueryBuilder::class, $connection); + } + + /** + * Deep clone of the QueryBuilder + * @see \Doctrine\DBAL\Query\QueryBuilder::__clone() + */ + public function __clone() + { + $this->concreteQueryBuilder = clone $this->concreteQueryBuilder; + $this->restrictionContainer = clone $this->restrictionContainer; + } + + /** + * Gets a string representation of this QueryBuilder which corresponds to + * the final SQL query being constructed. + * + * @return string The string representation of this QueryBuilder. + */ + public function __toString(): string + { + return $this->getSQL(); + } + + public function getRestrictions(): QueryRestrictionContainerInterface + { + return $this->restrictionContainer; + } + + public function setRestrictions(QueryRestrictionContainerInterface $restrictionContainer): void + { + foreach ($this->additionalRestrictions as $restrictionClass => $options) { + if (empty($options['disabled'])) { + /** @var QueryRestrictionInterface $restriction */ + $restriction = GeneralUtility::makeInstance($restrictionClass); + $restrictionContainer->add($restriction); + } + } + $this->restrictionContainer = $restrictionContainer; + } + + /** + * Limits ALL currently active restrictions of the restriction container to the table aliases given + */ + public function limitRestrictionsToTables(array $tableAliases): void + { + $this->restrictionContainer = GeneralUtility::makeInstance(LimitToTablesRestrictionContainer::class)->addForTables($this->restrictionContainer, $tableAliases); + } + + /** + * Re-apply default restrictions + */ + public function resetRestrictions(): void + { + $this->setRestrictions(GeneralUtility::makeInstance(DefaultRestrictionContainer::class)); + } + + /** + * Gets an ExpressionBuilder used for object-oriented construction of query expressions. + * This producer method is intended for convenient inline usage. Example: + * + * For more complex expression construction, consider storing the expression + * builder object in a local variable. + */ + public function expr(): ExpressionBuilder + { + return $this->connection->getExpressionBuilder(); + } + + /** + * Gets the associated DBAL Connection for this query builder. + */ + public function getConnection(): Connection + { + return $this->connection; + } + + /** + * Gets the concrete implementation of the query builder + * + * @internal + */ + public function getConcreteQueryBuilder(): \Doctrine\DBAL\Query\QueryBuilder + { + return $this->concreteQueryBuilder; + } + + /** + * Create prepared statement out of QueryBuilder instance. + * + * doctrine/dbal does not provide support for prepared statement + * in QueryBuilder, but as TYPO3 uses the API throughout the code + * via QueryBuilder, so the functionality of + * prepared statements for multiple executions is added. + * + * You should be aware that this method will throw a named + * 'UnsupportedPreparedStatementParameterTypeException()' + * exception, if 'PARAM_INT_ARRAY' or 'PARAM_STR_ARRAY' is set, + * as this is not supported for prepared statements directly. + * + * NamedPlaceholder are not supported, and if one or + * more are set a 'NamedParameterNotSupportedForPreparedStatementException' + * will be thrown. + */ + public function prepare(): Statement + { + $connection = $this->getConnection(); + $concreteQueryBuilder = $this->concreteQueryBuilder; + $originalWhereConditions = null; + try { + if ($concreteQueryBuilder->type === QueryType::SELECT) { + $originalWhereConditions = $this->addAdditionalWhereConditions(); + } + $sql = $concreteQueryBuilder->getSQL(); + $params = $concreteQueryBuilder->getParameters(); + $types = $concreteQueryBuilder->getParameterTypes(); + $this->throwExceptionOnInvalidPreparedStatementParamArrayType($types); + $this->throwExceptionOnNamedParameterForPreparedStatement($params); + $statement = $connection->prepare($sql)->getWrappedStatement(); + $this->bindTypedValues($statement, $params, $types); + } finally { + if ($concreteQueryBuilder->type === QueryType::SELECT) { + $concreteQueryBuilder->resetWhere(); + if ($originalWhereConditions !== null) { + $concreteQueryBuilder->where($originalWhereConditions); + } + } + } + return new Statement($connection, $statement, $sql); + } + + /** + * Executes an SQL query (SELECT) and returns a Result. + * + * doctrine/dbal decided to split execute() into executeQuery() and + * executeStatement() for doctrine/dbal:^3.0, like it was done on + * connection level already, thus these methods are added to this + * decorator class also as preparation for extension authors, that + * they are able to write code which is compatible across two core + * versions and avoid deprecation warning. Additional this will ease + * backport without the need to switch if execute() is not used anymore. + */ + public function executeQuery(): Result + { + $this->throwExceptionForUpdateOrDeleteQueriesWithDefinedTableJoins(); + // Set additional query restrictions + $originalWhereConditions = $this->addAdditionalWhereConditions(); + $concreteQueryBuilder = $this->concreteQueryBuilder; + try { + return $concreteQueryBuilder->executeQuery(); + } finally { + $concreteQueryBuilder->resetWhere(); + if ($originalWhereConditions !== null) { + $concreteQueryBuilder->where($originalWhereConditions); + } + } + } + + /** + * Executes an SQL statement (INSERT, UPDATE and DELETE) and returns + * the number of affected rows. + * + * doctrine/dbal decided to split execute() into executeQuery() and + * executeStatement() for doctrine/dbal:^3.0, like it was done on + * connection level already, thus these methods are added to this + * decorator class also as preparation for extension authors, that + * they are able to write code which is compatible across two core + * versions and avoid deprecation warning. Additional this will ease + * backport without the need to switch if execute() is not used anymore. + * + * @return int The number of affected rows. + */ + public function executeStatement(): int + { + $this->throwExceptionForUpdateOrDeleteQueriesWithDefinedTableJoins(); + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->executeStatement(); + } + + /** + * Gets the complete SQL string formed by the current specifications of this QueryBuilder. + * + * If the statement is a SELECT TYPE query restrictions based on TCA settings will + * automatically be applied based on the current QuerySettings. + * + * @return string The SQL query string. + */ + public function getSQL(): string + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + if ($concreteQueryBuilder->type !== QueryType::SELECT) { + return $concreteQueryBuilder->getSQL(); + } + // Set additional query restrictions + $originalWhereConditions = $this->addAdditionalWhereConditions(); + try { + $sql = $concreteQueryBuilder->getSQL(); + } finally { + $concreteQueryBuilder->resetWhere(); + if ($originalWhereConditions !== null) { + $concreteQueryBuilder->where($originalWhereConditions); + } + } + return $sql; + } + + /** + * Sets a query parameter for the query being constructed. + * + * @param int<0, max>|string $key Parameter position or name + */ + public function setParameter( + int|string $key, + mixed $value, + string|ParameterType|Type|ArrayParameterType $type = ParameterType::STRING, + ): QueryBuilder { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->setParameter($key, $value, $type); + return $this; + } + + /** + * Sets a collection of query parameters for the query being constructed. + * + * @param list|array $params The query parameters to set. + * @param array, string|Type|ParameterType|ArrayParameterType>|array $types The query parameters types to set. + * + * @return QueryBuilder This QueryBuilder instance. + */ + public function setParameters(array $params, array $types = []): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->setParameters($params, $types); + return $this; + } + + /** + * Gets all defined query parameters for the query being constructed indexed by parameter index or name. + * + * @return list|array The currently defined query parameters indexed by parameter index or name. + */ + public function getParameters(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getParameters(); + } + + /** + * Gets a (previously set) query parameter of the query being constructed. + * + * @param string|int $key The key (index or name) of the bound parameter. + * + * @return mixed The value of the bound parameter. + */ + public function getParameter(string|int $key): mixed + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getParameter($key); + } + + /** + * Gets all defined query parameter types for the query being constructed indexed by parameter index or name. + * + * @return array, string|Type|ParameterType|ArrayParameterType>|array The currently defined query parameter types indexed by parameter index or name. + */ + public function getParameterTypes(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getParameterTypes(); + } + + /** + * Gets a (previously set) query parameter type of the query being constructed. + * + * @param string|int $key The key (index or name) of the bound parameter type. + * + * @return string|ParameterType|Type|ArrayParameterType The value of the bound parameter type. + */ + public function getParameterType(string|int $key): string|ParameterType|Type|ArrayParameterType + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getParameterType($key); + } + + /** + * Sets the position of the first result to retrieve (the "offset"). + * + * @param int $firstResult The first result to return. + * + * @return QueryBuilder This QueryBuilder instance. + */ + public function setFirstResult(int $firstResult): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->setFirstResult($firstResult); + return $this; + } + + /** + * Gets the position of the first result the query object was set to retrieve (the "offset"). + * Returns NULL if {@link setFirstResult} was not applied to this QueryBuilder. + * + * @return int The position of the first result. + */ + public function getFirstResult(): int + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getFirstResult(); + } + + /** + * Sets the maximum number of results to retrieve (the "limit"). + * + * @param int|null $maxResults The maximum number of results to retrieve or NULL to retrieve all results. + * + * @return QueryBuilder This QueryBuilder instance. + */ + public function setMaxResults(?int $maxResults = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->setMaxResults($maxResults); + return $this; + } + + /** + * Gets the maximum number of results the query object was set to retrieve (the "limit"). + * Returns 0 if setMaxResults was not applied to this query builder. + * + * @return int|null The maximum number of results. + */ + public function getMaxResults(): ?int + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->getMaxResults(); + } + + /** + * Specifies the item that is to be counted in the query result. + * Replaces any previously specified selections, if any. + * + * @param string $item Will be quoted according to database platform automatically. + * @return QueryBuilder This QueryBuilder instance. + */ + public function count(string $item): QueryBuilder + { + $countExpr = $this->getCountExpression( + $item === '*' ? $item : $this->quoteIdentifier($item) + ); + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->select($countExpr); + + return $this; + } + + protected function getCountExpression(string $column): string + { + return 'COUNT(' . $column . ')'; + } + + /** + * Specifies union parts to be used to build a UNION query. + * Replaces any previously specified parts. + * + * ```php + * $qb = $conn->createQueryBuilder() + * ->union('SELECT 1 AS field1', 'SELECT 2 AS field1'); + * ``` + * + * @return $this + */ + public function union(string|QueryBuilder|ConcreteQueryBuilder|DoctrineQueryBuilder $part): QueryBuilder + { + $this->type = QueryType::UNION; + $concreteQueryBuilder = $this->getConcreteQueryBuilder(); + $concreteQueryBuilder->union($part); + return $this; + } + + /** + * Add parts to be used to build a UNION query. + * + * ```php + * $qb = $conn->createQueryBuilder() + * ->union('SELECT 1 AS field1') + * ->addUnion('SELECT 2 AS field1', 'SELECT 3 AS field1') + * ``` + * + * @return $this + */ + public function addUnion(string|QueryBuilder|ConcreteQueryBuilder|DoctrineQueryBuilder $part, UnionType $type = UnionType::DISTINCT): QueryBuilder + { + $this->type = QueryType::UNION; + $concreteQueryBuilder = $this->getConcreteQueryBuilder(); + $concreteQueryBuilder->addUnion($part, $type); + return $this; + } + + /** + * Specifies items that are to be returned in the query result. + * Replaces any previously specified selections, if any. + */ + public function select(string ...$selects): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->select(...$this->quoteIdentifiersForSelect($selects)); + return $this; + } + + /** + * Adds or removes DISTINCT to/from the query. + */ + public function distinct(bool $distinct = true): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->distinct($distinct); + return $this; + } + + /** + * Adds an item that is to be returned in the query result. + */ + public function addSelect(string ...$selects): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->addSelect(...$this->quoteIdentifiersForSelect($selects)); + return $this; + } + + /** + * Specifies items that are to be returned in the query result. + * Replaces any previously specified selections, if any. + * This should only be used for literal SQL expressions as no + * quoting/escaping of any kind will be performed on the items. + * + * @param string ...$selects Literal SQL expressions to be selected. Warning: No quoting will be done! + */ + public function selectLiteral(string ...$selects): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->select(...$selects); + return $this; + } + + /** + * Adds an item that is to be returned in the query result. This should + * only be used for literal SQL expressions as no quoting/escaping of + * any kind will be performed on the items. + * + * @param string ...$selects Literal SQL expressions to be selected. + */ + public function addSelectLiteral(string ...$selects): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->addSelect(...$selects); + return $this; + } + + /** + * Turns the query being built into a bulk delete query that ranges over + * a certain table. + * + * @param string $table The table whose rows are subject to the deletion. + * Will be quoted according to database platform automatically. + */ + public function delete(string $table): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->delete($this->quoteIdentifier($table)); + return $this; + } + + /** + * Turns the query being built into a bulk update query that ranges over + * a certain table + * + * @param string $table The table whose rows are subject to the update. + */ + public function update(string $table): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->update($this->quoteIdentifier($table)); + return $this; + } + + /** + * Turns the query being built into an insert query that inserts into + * a certain table + * + * @param string $table The table into which the rows should be inserted. + */ + public function insert(string $table): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->insert($this->quoteIdentifier($table)); + return $this; + } + + /** + * Creates and adds a query root corresponding to the table identified by the + * given alias, forming a cartesian product with any existing query roots. + * + * @param string $table The table. Will be quoted according to database platform automatically. + * @param string|null $alias The alias of the table. Will be quoted according to database platform automatically. + */ + public function from(string $table, ?string $alias = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->from( + $this->quoteIdentifier($table), + empty($alias) ? $alias : $this->quoteIdentifier($alias) + ); + return $this; + } + + /** + * Creates and adds a join to the query. + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string|null $condition The condition for the join. + */ + public function join(string $fromAlias, string $join, string $alias, ?string $condition = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->innerJoin( + $this->quoteIdentifier($fromAlias), + $this->quoteIdentifier($join), + $this->quoteIdentifier($alias), + $condition + ); + return $this; + } + + /** + * Creates and adds a join to the query. + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string|null $condition The condition for the join. + */ + public function innerJoin(string $fromAlias, string $join, string $alias, ?string $condition = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->innerJoin( + $this->quoteIdentifier($fromAlias), + $this->quoteIdentifier($join), + $this->quoteIdentifier($alias), + $condition + ); + return $this; + } + + /** + * Creates and adds a left join to the query. + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param CompositeExpression|string|null $condition The condition for the join. + */ + public function leftJoin(string $fromAlias, string $join, string $alias, CompositeExpression|string|null $condition = null): QueryBuilder + { + $conditionExpression = (string)$this->expr()->and( + $condition, + $this->restrictionContainer->buildExpression([$alias => $join], $this->expr()) + ); + $this->restrictionsAppliedInJoinCondition[] = $alias; + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->leftJoin( + $this->quoteIdentifier($fromAlias), + $this->quoteIdentifier($join), + $this->quoteIdentifier($alias), + $conditionExpression + ); + return $this; + } + + /** + * Creates and adds a right join to the query. + * + * @param string $fromAlias The alias that points to a from clause. + * @param string $join The table name to join. + * @param string $alias The alias of the join table. + * @param string|null $condition The condition for the join. + */ + public function rightJoin(string $fromAlias, string $join, string $alias, ?string $condition = null): QueryBuilder + { + $fromTable = $fromAlias; + // find the table belonging to the $fromAlias, if it's an alias at all + foreach ($this->concreteQueryBuilder->from as $from) { + if ($from->alias !== null && $from->alias !== '' && $this->unquoteSingleIdentifier($from->alias) === $fromAlias) { + $fromTable = $this->unquoteSingleIdentifier($from->alias); + break; + } + } + $conditionExpression = (string)$this->expr()->and( + $condition, + $this->restrictionContainer->buildExpression([$fromAlias => $fromTable], $this->expr()) + ); + $this->restrictionsAppliedInJoinCondition[] = $fromAlias; + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->rightJoin( + $this->quoteIdentifier($fromAlias), + $this->quoteIdentifier($join), + $this->quoteIdentifier($alias), + $conditionExpression + ); + return $this; + } + + /** + * Sets a new value for a column in a bulk update query. + * + * @param string $key The column to set. + * @param mixed $value The value, expression, placeholder, etc. + * @param bool $createNamedParameter Automatically create a named parameter for the value + */ + public function set(string $key, $value, bool $createNamedParameter = true, ParameterType|ArrayParameterType $type = Connection::PARAM_STR): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->set( + $this->quoteIdentifier($key), + $createNamedParameter ? $this->createNamedParameter($value, $type) : $value + ); + return $this; + } + + /** + * Specifies one or more restrictions to the query result. + * Replaces any previously specified restrictions, if any. + * + * @param string|CompositeExpression ...$predicates + */ + public function where(...$predicates): QueryBuilder + { + // Doctrine DBAL 3.x requires a non-empty $predicate, however TYPO3 uses static values + // such as PageRepository->$where_hid_del which could be empty + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + $this->resetWhere(); + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->where(...$predicates); + return $this; + } + + /** + * Adds one or more restrictions to the query results, forming a logical + * conjunction with any previously specified restrictions. + * + * @param string|CompositeExpression ...$predicates The query restrictions. + * @see where() + */ + public function andWhere(...$predicates): QueryBuilder + { + // Doctrine DBAL 3.x requires a non-empty $predicate, however TYPO3 uses static values + // such as PageRepository->$where_hid_del which could be empty + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->andWhere(...$predicates); + return $this; + } + + /** + * Adds one or more restrictions to the query results, forming a logical + * disjunction with any previously specified restrictions. + * + * @param string|CompositeExpression ...$predicates The WHERE statement. + * @see where() + */ + public function orWhere(...$predicates): QueryBuilder + { + // Doctrine DBAL 3.x requires a non-empty $predicate, however TYPO3 uses static values + // such as PageRepository->$where_hid_del which could be empty + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->orWhere(...$predicates); + return $this; + } + + /** + * Specifies a grouping over the results of the query. + * Replaces any previously specified groupings, if any. + * + * @param string ...$groupBy The grouping expression. + */ + public function groupBy(...$groupBy): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->groupBy(...$this->quoteIdentifiers($groupBy)); + return $this; + } + + /** + * Adds a grouping expression to the query. + * + * @param string ...$groupBy The grouping expression. + */ + public function addGroupBy(...$groupBy): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->addGroupBy(...$this->quoteIdentifiers($groupBy)); + return $this; + } + + /** + * Sets a value for a column in an insert query. + * + * @param string $column The column into which the value should be inserted. + * @param mixed $value The value that should be inserted into the column. + * @param bool $createNamedParameter Automatically create a named parameter for the value + */ + public function setValue(string $column, $value, bool $createNamedParameter = true): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->setValue( + $this->quoteIdentifier($column), + $createNamedParameter ? $this->createNamedParameter($value) : $value + ); + return $this; + } + + /** + * Specifies values for an insert query indexed by column names. + * Replaces any previous values, if any. + * + * @param array $values The values to specify for the insert query indexed by column names. + * @param bool $createNamedParameters Automatically create named parameters for all values + */ + public function values(array $values, bool $createNamedParameters = true): QueryBuilder + { + if ($createNamedParameters === true) { + foreach ($values as &$value) { + $value = $this->createNamedParameter($value); + } + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->values($this->quoteColumnValuePairs($values)); + return $this; + } + + /** + * Specifies a restriction over the groups of the query. + * Replaces any previous having restrictions, if any. + * + * @param mixed ...$predicates The restriction over the groups. + */ + public function having(...$predicates): QueryBuilder + { + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + $this->resetHaving(); + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->having(...$predicates); + return $this; + } + + /** + * Adds a restriction over the groups of the query, forming a logical + * conjunction with any existing having restrictions. + * + * @param mixed ...$predicates The restriction to append. + */ + public function andHaving(...$predicates): QueryBuilder + { + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->andHaving(...$predicates); + return $this; + } + + /** + * Adds a restriction over the groups of the query, forming a logical + * disjunction with any existing having restrictions. + * + * @param mixed ...$predicates The restriction to add. + */ + public function orHaving(...$predicates): QueryBuilder + { + $predicates = array_filter($predicates, static fn(CompositeExpression|string|null $value): bool => !self::isEmptyPart($value)); + if (empty($predicates)) { + return $this; + } + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->orHaving(...$predicates); + return $this; + } + + /** + * Specifies an ordering for the query results. + * Replaces any previously specified orderings, if any. + * + * @param string $fieldName The fieldName to order by. Will be quoted according to database platform automatically. + * @param string|null $order The ordering direction. No automatic quoting/escaping. + */ + public function orderBy(string $fieldName, ?string $order = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->orderBy($this->connection->quoteIdentifier($fieldName), $order); + return $this; + } + + /** + * Adds an ordering to the query results. + * + * @param string $fieldName The fieldName to order by. Will be quoted according to database platform automatically. + * @param string|null $order The ordering direction. + */ + public function addOrderBy(string $fieldName, ?string $order = null): QueryBuilder + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->addOrderBy($this->connection->quoteIdentifier($fieldName), $order); + return $this; + } + + /** + * Resets the WHERE conditions for the query. + */ + public function resetWhere(): self + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->resetWhere(); + return $this; + } + + /** + * Resets the grouping for the query. + */ + public function resetGroupBy(): self + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->resetGroupBy(); + return $this; + } + + /** + * Resets the HAVING conditions for the query. + */ + public function resetHaving(): self + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->resetHaving(); + return $this; + } + + /** + * Resets the ordering for the query. + */ + public function resetOrderBy(): self + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->resetOrderBy(); + return $this; + } + + /** + * Creates a new named parameter and bind the value $value to it. + * + * This method provides a shortcut for {@see Statement::bindValue()} + * when using prepared statements. + * + * The parameter $value specifies the value that you want to bind. If + * $placeholder is not provided createNamedParameter() will automatically + * create a placeholder for you. An automatic placeholder will be of the + * name ':dcValue1', ':dcValue2' etc. + * + * Example: + * + * $value = 2; + * $q->eq( 'id', $q->createNamedParameter( $value ) ); + * $stmt = $q->executeQuery(); // executed with 'id = 2' + * + * + * @link http://www.zetacomponents.org + * @param string|null $placeHolder The name to bind with. The string must start with a colon ':'. + * @return string the placeholder name used. + */ + public function createNamedParameter( + mixed $value, + string|ParameterType|Type|ArrayParameterType $type = ParameterType::STRING, + ?string $placeHolder = null + ): string { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->createNamedParameter($value, $type, $placeHolder); + } + + /** + * Creates a new positional parameter and bind the given value to it. + * + * Attention: If you are using positional parameters with the query builder you have + * to be very careful to bind all parameters in the order they appear in the SQL + * statement , otherwise they get bound in the wrong order which can lead to serious + * bugs in your code. + * + * Example: + * + * $qb = $conn->createQueryBuilder(); + * $qb->select('u.*') + * ->from('users', 'u') + * ->where('u.username = ' . $qb->createPositionalParameter('Foo', ParameterType::STRING)) + * ->orWhere('u.username = ' . $qb->createPositionalParameter('Bar', ParameterType::STRING)) + * + */ + public function createPositionalParameter( + mixed $value, + string|ParameterType|Type|ArrayParameterType $type = ParameterType::STRING, + ): string { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->createPositionalParameter($value, $type); + } + + /** + * Quotes like wildcards for given string value. + * + * @param string $value The value to be quoted. + * @return string The quoted value. + */ + public function escapeLikeWildcards(string $value): string + { + return $this->connection->escapeLikeWildcards($value); + } + + /** + * Quotes a given input parameter. + * + * @param string $input The parameter to be quoted. + * @return string Often string, but also int or float or similar depending on $input and platform + */ + public function quote(string $input): string + { + return $this->getConnection()->quote($input); + } + + /** + * Quotes a string so it can be safely used as a table or column name, even if + * it is a reserved name. + * + * Delimiting style depends on the underlying database platform that is being used. + * + * @param string $identifier The name to be quoted. + * @return string The quoted name. + */ + public function quoteIdentifier(string $identifier): string + { + return $this->getConnection()->quoteIdentifier($identifier); + } + + /** + * Quotes an array of column names so it can be safely used, even if the name is a reserved name. + * + * Delimiting style depends on the underlying database platform that is being used. + */ + public function quoteIdentifiers(array $input): array + { + return $this->getConnection()->quoteIdentifiers($input); + } + + /** + * Quotes an array of column names so it can be safely used, even if the name is a reserved name. + * Takes into account the special case of the * placeholder that can only be used in SELECT type + * statements. + * + * Delimiting style depends on the underlying database platform that is being used. + * + * @throws \InvalidArgumentException + */ + public function quoteIdentifiersForSelect(array $input): array + { + foreach ($input as &$select) { + [$fieldName, $alias, $suffix] = array_pad( + GeneralUtility::trimExplode( + ' AS ', + str_ireplace(' as ', ' AS ', $select), + true, + 3 + ), + 3, + null + ); + if (!empty($suffix)) { + throw new \InvalidArgumentException( + 'QueryBuilder::quoteIdentifiersForSelect() could not parse the select ' . $select . '.', + 1461170686 + ); + } + + // The SQL * operator must not be quoted. As it can only occur either by itself + // or preceded by a tablename (tablename.*) check if the last character of a select + // expression is the * and quote only prepended table name. In all other cases the + // full expression is being quoted. + if (substr($fieldName, -2) === '.*') { + $select = $this->quoteIdentifier(substr($fieldName, 0, -2)) . '.*'; + } elseif ($fieldName !== '*') { + $select = $this->quoteIdentifier($fieldName); + } + + // Quote the alias for the current fieldName, if given + if (!empty($alias)) { + $select .= ' AS ' . $this->quoteIdentifier($alias); + } + } + return $input; + } + + /** + * Quotes an associative array of column-value so the column names can be safely used, even + * if the name is a reserved name. + * + * Delimiting style depends on the underlying database platform that is being used. + */ + public function quoteColumnValuePairs(array $input): array + { + return $this->getConnection()->quoteColumnValuePairs($input); + } + + /** + * Implode array to comma separated list with database int-quoted values to be used as direct + * value list for database 'in(...)' or 'notIn(...') expressions. Empty array will return 'NULL' + * as string to avoid database query failure, as 'IN()' is invalid, but 'IN(NULL)' is fine. + * + * This method should be used with care, the preferred way is to use placeholders. It is however + * useful when dealing with potentially many values, which could reach placeholder limit quickly. + * + * When working with prepared statement from QueryBuilder, use this method to proper quote array + * with integer values. + * + * The method can not be used in queries that re-bind a prepared statement to change values for + * subsequent execution due to a PDO limitation. + * + * Return value should only be used as value list for database queries 'in()' and 'notIn()' . + */ + public function quoteArrayBasedValueListToIntegerList(array $values): string + { + if (empty($values)) { + return 'NULL'; + } + // Ensure values are all integer + $values = GeneralUtility::intExplode(',', implode(',', $values)); + // Ensure all values are quoted as int for used dbms + $connection = $this; + array_walk($values, static function (mixed &$value) use ($connection): void { + $value = $connection->quote((string)$value); + }); + return implode(',', $values); + } + + /** + * Implode array to comma separated list with database string-quoted values to be used as direct + * value list for database 'in(...)' or 'notIn(...') expressions. Empty array will return 'NULL' + * as string to avoid database query failure, as 'IN()' is invalid, but 'IN(NULL)' is fine. + * + * This method should be used with care, the preferred way is to use placeholders. It is however + * useful when dealing with potentially many values, which could reach placeholder limit quickly. + * + * When working with prepared statement from QueryBuilder, use this method to proper quote array + * with integer values. + * + * The method can not be used in queries that re-bind a prepared statement to change values for + * subsequent execution due to a PDO limitation. + * + * Return value should only be used as value list for database queries 'in()' and 'notIn()' . + */ + public function quoteArrayBasedValueListToStringList(array $values): string + { + if (empty($values)) { + return 'NULL'; + } + // Ensure values are all strings + $values = GeneralUtility::trimExplode(',', implode(',', $values)); + // Ensure all values are quoted as string values for used dbmns + $connection = $this; + array_walk($values, static function (mixed &$value) use ($connection): void { + $value = $connection->quote((string)$value); + }); + return implode(',', $values); + } + + /** + * Creates a cast of the $fieldName to a text datatype depending on the database management system. + * + * @param string $fieldName The fieldname will be quoted and casted according to database platform automatically + * + * @todo Deprecate this method in favor of {@see ExpressionBuilder::castText()}. + */ + public function castFieldToTextType(string $fieldName): string + { + $databasePlatform = $this->connection->getDatabasePlatform(); + // https://dev.mysql.com/doc/refman/5.7/en/cast-functions.html#function_convert + if ($databasePlatform instanceof DoctrineMariaDBPlatform || $databasePlatform instanceof DoctrineMySQLPlatform) { + return sprintf('CONVERT(%s, CHAR)', $this->connection->quoteIdentifier($fieldName)); + } + // https://www.postgresql.org/docs/current/sql-createcast.html + if ($databasePlatform instanceof DoctrinePostgreSQLPlatform) { + return sprintf('%s::text', $this->connection->quoteIdentifier($fieldName)); + } + // https://www.sqlite.org/lang_expr.html#castexpr + if ($databasePlatform instanceof DoctrineSQLitePlatform) { + return sprintf('CAST(%s as TEXT)', $this->connection->quoteIdentifier($fieldName)); + } + throw new \RuntimeException( + sprintf( + '%s is not implemented for the used database platform "%s", yet!', + __METHOD__, + get_class($this->connection->getDatabasePlatform()) + ), + 1584637096 + ); + } + + /** + * Unquote a single identifier (no dot expansion). Used to unquote the table names + * from the expressionBuilder so that the table can be found in the TCA definition. + * + * @param string $identifier The identifier / table name + * @return string The unquoted table name / identifier + */ + protected function unquoteSingleIdentifier(string $identifier): string + { + $identifier = trim($identifier); + $quoteChar = GeneralUtility::makeInstance(PlatformHelper::class) + ->getIdentifierQuoteCharacter($this->getConnection()->getDatabasePlatform()); + $identifier = trim($identifier, $quoteChar); + $identifier = str_replace($quoteChar . $quoteChar, $quoteChar, $identifier); + return $identifier; + } + + /** + * @internal This method reflects needed quoted char determination for `unquoteSingleIdentifier()`. Until `doctrine/dbal ^4` + * the corresponding information has been used from the database platform class which is not available + * anymore. + */ + protected function getIdentifierQuoteCharacter(): string + { + return substr($this->connection->getDatabasePlatform()->quoteSingleIdentifier('fake'), 0, 1); + } + + /** + * Returns selected fields from internal query state. + * + * @internal only, used for Extbase internal handling and core tests. Don't use it. + * @return string[] + */ + public function getSelect(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->select; + } + + /** + * Returns from tables from internal query state. + * + * @see Typo3DbBackend::getObjectDataByQuery() + * @see Typo3DbBackend::getObjectCountByQuery() + * @internal only, used for Extbase internal handling and core tests. Don't use it. + * @return From[] + */ + public function getFrom() + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->from; + } + + /** + * Returns where expressions from internal query state. + * + * @see Typo3DbQueryParserTest + * @internal only, used for Extbase internal handling and core tests. Don't use it. + * @return CompositeExpression|string|null + */ + public function getWhere(): CompositeExpression|string|null + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->where; + } + + /** + * Returns having expressions from internal query state. + * + * @internal only, used for Extbase internal handling and core tests. Don't use it. + * @return CompositeExpression|string|null + */ + public function getHaving(): CompositeExpression|string|null + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->having; + } + + /** + * Returns order-by definitions from internal query state. + * + * @return string[] + * @see RelationHandler::readForeignField() + * @see Typo3DbQueryParserTest + * @internal only, used for Extbase internal handling and core tests. Don't use it. + */ + public function getOrderBy(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->orderBy; + } + + /** + * Returns selected group-by definitions from internal query state. + * + * @see Typo3DbQueryParserTest + * @return string[] + * @internal only, used for Extbase internal handling and core tests. Don't use it. + */ + public function getGroupBy(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->groupBy; + } + + /** + * Returns the list of joins, indexed by `from-alias` from the internal query state. + * + * @return array + * @internal only, used for Extbase internal handling and core tests. Don't use it. + */ + public function getJoin(): array + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + return $concreteQueryBuilder->join; + } + + /** + * Return all tables/aliases used in FROM or JOIN query parts from the query builder. + * + * The table names are automatically unquoted. This is a helper for to build the list + * of queried tables for the AbstractRestrictionContainer. + * + * @return array + */ + protected function getQueriedTables(): array + { + /** @var array $queriedTables */ + $queriedTables = []; + // Loop through all FROM tables + foreach ($this->concreteQueryBuilder->from as $from) { + $tableName = $this->unquoteSingleIdentifier($from->table); + $tableAlias = $from->alias !== null && $from->alias !== '' ? $this->unquoteSingleIdentifier($from->alias) : $tableName; + if (!in_array($tableAlias, $this->restrictionsAppliedInJoinCondition, true)) { + $queriedTables[$tableAlias] = $tableName; + } + } + + // Loop through all JOIN tables + foreach ($this->concreteQueryBuilder->join as $joins) { + foreach ($joins as $join) { + $tableName = $this->unquoteSingleIdentifier($join->table); + $tableAlias = $join->alias !== null && $join->alias !== '' ? $this->unquoteSingleIdentifier($join->alias) : $tableName; + if (!in_array($tableAlias, $this->restrictionsAppliedInJoinCondition, true)) { + $queriedTables[$tableAlias] = $tableName; + } + } + } + return $queriedTables; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_with( + string $name, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $expression, + array $fields = [], + array $dependsOn = [], + ): self { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->typo3_with($name, $expression, $fields, $dependsOn); + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_addWith( + string $name, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $expression, + array $fields = [], + array $dependsOn = [], + ): self { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->typo3_addWith($name, $expression, $fields, $dependsOn); + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_withRecursive( + string $name, + bool $uniqueRows, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $expression, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $initialExpression, + array $fields = [], + array $dependsOn = [], + ): self { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->typo3_withRecursive($name, $uniqueRows, $expression, $initialExpression, $fields, $dependsOn); + return $this; + } + + /** + * @param string[] $fields + * @param string[] $dependsOn + * + * @internal not part of public API, experimental and may change at any given time. + */ + public function typo3_addWithRecursive( + string $name, + bool $uniqueRows, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $expression, + string|DoctrineQueryBuilder|ConcreteQueryBuilder|QueryBuilder $initialExpression, + array $fields = [], + array $dependsOn = [], + ): self { + $concreteQueryBuilder = $this->concreteQueryBuilder; + $concreteQueryBuilder->typo3_addWithRecursive($name, $uniqueRows, $expression, $initialExpression, $fields, $dependsOn); + return $this; + } + + /** + * Add the additional query conditions returned by the QueryRestrictionBuilder + * to the current query and return the original set of conditions so that they + * can be restored after the query has been built/executed. + */ + protected function addAdditionalWhereConditions(): CompositeExpression|string|null + { + $originalWhereConditions = $this->getWhere(); + $expression = $this->restrictionContainer->buildExpression($this->getQueriedTables(), $this->expr()); + // This check would be obsolete, as the composite expression would not add empty expressions anyway + // But we keep it here to only clone the previous state, in case we really will change it. + // Once we remove this state preserving functionality, we can remove the count check here + // and just add the expression to the query builder. + if ($expression->count() > 0) { + $this->concreteQueryBuilder->andWhere($expression); + } + return $originalWhereConditions; + } + + private function throwExceptionOnInvalidPreparedStatementParamArrayType(array $types): void + { + foreach ($types as $type) { + $invalidTypeLabel = match ($type) { + Connection::PARAM_INT_ARRAY => 'PARAM_INT_ARRAY', + Connection::PARAM_STR_ARRAY => 'PARAM_STR_ARRAY', + default => false, + }; + if ($invalidTypeLabel !== false) { + throw UnsupportedPreparedStatementParameterTypeException::new($invalidTypeLabel); + } + } + } + + private function throwExceptionOnNamedParameterForPreparedStatement(array $params): void + { + foreach ($params as $key => $value) { + if (is_string($key) && !MathUtility::canBeInterpretedAsInteger($key)) { + throw NamedParameterNotSupportedForPreparedStatementException::new($key); + } + } + } + + /** + * Binds a set of parameters, some or all of which are typed with a PDO binding type + * or DBAL mapping type, to a given statement. + * + * Cloned from doctrine/dbal connection, as we need to call from external + * to support and work with prepared statement from QueryBuilder instance + * directly. + * + * This needs to be checked with each doctrine/dbal release raise. + * + * @see \Doctrine\DBAL\Connection::bindParameters() + * @param DriverStatement $stmt + * @param list|array $params + * @param array|array $types + */ + private function bindTypedValues(DriverStatement $stmt, array $params, array $types): void + { + // Check whether parameters are positional or named. Mixing is not allowed. + $stringType = new StringType(); + if (is_int(key($params))) { + $bindIndex = 1; + + foreach ($params as $key => $value) { + $type = (isset($types[$key])) ? $types[$key] : $stringType; + [$value, $bindingType] = $this->getBindingInfo($value, $type); + $stmt->bindValue($bindIndex, $value, $bindingType); + + ++$bindIndex; + } + } else { + // Named parameters + foreach ($params as $name => $value) { + $type = (isset($types[$name])) ? $types[$name] : $stringType; + [$value, $bindingType] = $this->getBindingInfo($value, $type); + $stmt->bindValue($name, $value, $bindingType); + } + } + } + + /** + * Gets the binding type of given type. + * + * Cloned from doctrine/dbal connection, as we need to call from external + * to support and work with prepared statement from QueryBuilder instance + * directly. + * + * This needs to be checked with each doctrine/dbal release raise. + * + * @see \Doctrine\DBAL\Connection::getBindingInfo() + * @param mixed $value The value to bind. + * @param string|ParameterType|Type $type The type to bind. + * @return array{mixed, ParameterType} [0] => the (escaped) value, [1] => the binding type. + */ + private function getBindingInfo(mixed $value, string|ParameterType|Type $type): array + { + if (is_string($type)) { + $type = Type::getType($type); + } + if ($type instanceof Type) { + $value = $type->convertToDatabaseValue($value, $this->connection->getDatabasePlatform()); + $bindingType = $type->getBindingType(); + } else { + $bindingType = $type; + } + return [$value, $bindingType]; + } + + private function throwExceptionForUpdateOrDeleteQueriesWithDefinedTableJoins(): void + { + $concreteQueryBuilder = $this->concreteQueryBuilder; + if ($concreteQueryBuilder->join === []) { + return; + } + if ($concreteQueryBuilder->type !== QueryType::UPDATE + && $concreteQueryBuilder->type !== QueryType::DELETE + ) { + return; + } + throw new QueryException( + sprintf( + 'Doctrine DBAL does not support to use the joined tables with "%s" queries.', + $concreteQueryBuilder->type->name, + ), + 1734984009 + ); + } +} diff --git a/Classes/Database/Query/QueryHelper.php b/Classes/Database/Query/QueryHelper.php new file mode 100644 index 0000000..8ce0cb8 --- /dev/null +++ b/Classes/Database/Query/QueryHelper.php @@ -0,0 +1,290 @@ + '`', + '"' => '"', + '[' => '[]', + ]; + + // Check if the tableName is quoted + $firstCharOfInputValue = $input[0] ?? ''; + if ($matchQuotingStartCharacters[$firstCharOfInputValue] ?? false) { + $quoteCharacter .= $matchQuotingStartCharacters[$firstCharOfInputValue]; + $input = substr($input, 1); + $tableName = strtok($input, $quoteCharacter); + } else { + $tableName = strtok($input, $quoteCharacter); + } + + $tableAlias = (string)strtok($quoteCharacter); + if (strtolower($tableAlias) === 'as') { + $tableAlias = (string)strtok($quoteCharacter); + // Skip the next token which must be ON + strtok(' '); + $joinCondition = strtok(''); + } elseif (strtolower($tableAlias) === 'on') { + $tableAlias = null; + $joinCondition = strtok(''); + } else { + // Skip the next token which must be ON + strtok(' '); + $joinCondition = strtok(''); + } + + // Catch the edge case that the table name is unquoted and the + // table alias is actually quoted. This will not work in the case + // that the quoted table alias contains whitespace. + $firstCharacterOfTableAlias = $tableAlias[0] ?? ''; + if ($matchQuotingStartCharacters[$firstCharacterOfTableAlias] ?? false) { + $tableAlias = substr((string)$tableAlias, 1, -1); + } + + $tableAlias = $tableAlias ?: $tableName; + + return ['tableName' => $tableName, 'tableAlias' => $tableAlias, 'joinCondition' => $joinCondition]; + } + + /** + * Removes the prefixes AND/OR from the input string. + * + * This function should be used when you can't guarantee that the string + * that you want to use as a WHERE fragment is not prefixed. + * + * @param string $constraint The where part fragment with a possible leading AND or OR operator + * @return string The modified where part without leading operator + */ + public static function stripLogicalOperatorPrefix(string $constraint): string + { + return preg_replace('/^(?:(AND|OR)[[:space:]]*)+/i', '', trim($constraint)) ?: ''; + } + + /** + * Returns the date and time formats compatible with the given database. + * This simple method should probably be deprecated and removed later. + */ + public static function getDateTimeFormats(): array + { + return [ + 'date' => [ + 'empty' => '0000-00-00', + 'format' => 'Y-m-d', + ], + 'datetime' => [ + 'empty' => '0000-00-00 00:00:00', + 'format' => 'Y-m-d H:i:s', + ], + 'time' => [ + 'empty' => '00:00:00', + 'format' => 'H:i:s', + ], + ]; + } + + /** + * Returns the date and time types compatible with the given database. + * This simple method should probably be deprecated and removed later. + */ + public static function getDateTimeTypes(): array + { + return [ + 'date', + 'datetime', + 'time', + ]; + } + + public static function transformDateTimeToDatabaseValue( + ?\DateTimeInterface $datetime, + bool $isNullable, + string $format, + ?string $persistenceType, + ): int|string|null { + if ($datetime === null) { + if ($isNullable) { + return null; + } + if ($persistenceType === null) { + return 0; + } + return self::getDateTimeFormats()[$persistenceType]['empty'] ?? null; + } + + if (!$datetime instanceof \DateTimeImmutable) { + $datetime = \DateTimeImmutable::createFromInterface($datetime); + } + + // Apply format-specific normalizations + if ($format === 'time') { + // time(sec) is stored as elapsed seconds in DB, hence we base the time on 1970-01-01 + $datetime = $datetime->setDate(1970, 01, 01)->setTime((int)$datetime->format('H'), (int)$datetime->format('i'), 0); + } elseif ($format === 'timesec' || $persistenceType === 'time') { + $datetime = $datetime->setDate(1970, 01, 01); + } elseif ($format === 'date' || $persistenceType === 'date') { + $datetime = $datetime->setTime(0, 0, 0); + } + // datetimesec is a "normal" date and needs no removal/adjustment of seconds or date. + + // Native DATETIME, DATE or TIME field + if (in_array($persistenceType, self::getDateTimeTypes(), true)) { + $dateTimeFormats = self::getDateTimeFormats(); + $persistenceFormat = $dateTimeFormats[$persistenceType]['format']; + if ($persistenceType === 'datetime') { + // native DATETIME values are stored in server LOCALTIME. Force conversion to the servers current timezone. + $datetime = $datetime->setTimezone(new \DateTimeZone(date_default_timezone_get())); + } + + return $datetime->format($persistenceFormat); + } + + // Time is stored in seconds for integer fields + if ($format === 'timesec' || $format === 'time') { + return (int)$datetime->format('H') * 3600 + (int)$datetime->format('i') * 60 + (int)$datetime->format('s'); + } + + // Encode as unix timestamp (int) if no native field is used + return $datetime->getTimestamp(); + } + + /** + * Quote database table/column names indicated by {#identifier} markup in a SQL fragment string. + * This is an intermediate step to make SQL fragments in Typoscript and TCA database agnostic. + */ + public static function quoteDatabaseIdentifiers(Connection $connection, string $sql): string + { + if (str_contains($sql, '{#')) { + $sql = preg_replace_callback( + '/{#(?P[^}]+)}/', + static function (array $matches) use ($connection) { + return $connection->quoteIdentifier($matches['identifier']); + }, + $sql + ); + } + return $sql; + } +} diff --git a/Classes/Database/Query/Restriction/AbstractRestrictionContainer.php b/Classes/Database/Query/Restriction/AbstractRestrictionContainer.php new file mode 100644 index 0000000..18b2dee --- /dev/null +++ b/Classes/Database/Query/Restriction/AbstractRestrictionContainer.php @@ -0,0 +1,111 @@ +restrictions as $restriction) { + $constraints[] = $restriction->buildExpression($queriedTables, $expressionBuilder); + } + return $expressionBuilder->and(...$constraints); + } + + /** + * Removes all restrictions stored within this container + */ + public function removeAll(): QueryRestrictionContainerInterface + { + $this->restrictions = $this->enforcedRestrictions; + return $this; + } + + /** + * Remove restriction of a given type + * + * @param string $restrictionType Class name of the restriction to be removed + */ + public function removeByType(string $restrictionType): QueryRestrictionContainerInterface + { + foreach ($this->restrictions as $type => $instance) { + if ($instance instanceof $restrictionType) { + unset($this->restrictions[$type]); + break; + } + } + + foreach ($this->enforcedRestrictions as $type => $instance) { + if ($instance instanceof $restrictionType) { + unset($this->enforcedRestrictions[$type]); + break; + } + } + + return $this; + } + + /** + * Add a new restriction instance to this collection + */ + public function add(QueryRestrictionInterface $restriction): QueryRestrictionContainerInterface + { + $this->restrictions[get_class($restriction)] = $restriction; + if ($restriction instanceof EnforceableQueryRestrictionInterface && $restriction->isEnforced()) { + $this->enforcedRestrictions[get_class($restriction)] = $restriction; + } + return $this; + } + + /** + * Factory method for restrictions. + * Currently only instantiates the class. + * + * @param string $restrictionClass + */ + protected function createRestriction($restrictionClass): QueryRestrictionInterface + { + return GeneralUtility::makeInstance($restrictionClass); + } +} diff --git a/Classes/Database/Query/Restriction/DefaultRestrictionContainer.php b/Classes/Database/Query/Restriction/DefaultRestrictionContainer.php new file mode 100644 index 0000000..74b4c99 --- /dev/null +++ b/Classes/Database/Query/Restriction/DefaultRestrictionContainer.php @@ -0,0 +1,46 @@ +defaultRestrictionTypes as $restrictionType) { + $this->add($this->createRestriction($restrictionType)); + } + } +} diff --git a/Classes/Database/Query/Restriction/DeletedRestriction.php b/Classes/Database/Query/Restriction/DeletedRestriction.php new file mode 100644 index 0000000..9ffa7c3 --- /dev/null +++ b/Classes/Database/Query/Restriction/DeletedRestriction.php @@ -0,0 +1,57 @@ +getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $constraints[] = $expressionBuilder->eq( + $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(), + 0 + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/DocumentTypeExclusionRestriction.php b/Classes/Database/Query/Restriction/DocumentTypeExclusionRestriction.php new file mode 100644 index 0000000..2b3fffe --- /dev/null +++ b/Classes/Database/Query/Restriction/DocumentTypeExclusionRestriction.php @@ -0,0 +1,66 @@ +doktypes = $doktype; + } else { + $this->doktypes = [$doktype]; + } + } + + /** + * Main method to build expressions for given tables + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName !== 'pages') { + continue; + } + + $constraints[] = $expressionBuilder->notIn($tableAlias . '.doktype', $this->doktypes); + } + + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/EndTimeRestriction.php b/Classes/Database/Query/Restriction/EndTimeRestriction.php new file mode 100644 index 0000000..715272a --- /dev/null +++ b/Classes/Database/Query/Restriction/EndTimeRestriction.php @@ -0,0 +1,74 @@ +accessTimeStamp = $accessTimeStamp ?: ($GLOBALS['SIM_ACCESS_TIME'] ?? null); + } + + /** + * Main method to build expressions for given tables + * Evaluates the ctrl/enablecolumns/endtime flag of the table and adds the according restriction if set + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + * @throws \RuntimeException + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $tcaSchemaFactory = $expressionBuilder->getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) { + if (empty($this->accessTimeStamp)) { + throw new \RuntimeException( + 'accessTimeStamp needs to be set to an integer value, but is empty! Maybe $GLOBALS[\'SIM_ACCESS_TIME\'] has been overridden somewhere?', + 1462821084 + ); + } + $fieldName = $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + $constraints[] = $expressionBuilder->or( + $expressionBuilder->eq($fieldName, 0), + $expressionBuilder->gt($fieldName, (int)$this->accessTimeStamp) + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/EnforceableQueryRestrictionInterface.php b/Classes/Database/Query/Restriction/EnforceableQueryRestrictionInterface.php new file mode 100644 index 0000000..43517c5 --- /dev/null +++ b/Classes/Database/Query/Restriction/EnforceableQueryRestrictionInterface.php @@ -0,0 +1,33 @@ +frontendGroupIds = $frontendGroupIds; + } else { + /** @var UserAspect $frontendUserAspect */ + $frontendUserAspect = GeneralUtility::makeInstance(Context::class)->getAspect('frontend.user'); + $this->frontendGroupIds = $frontendUserAspect->getGroupIds(); + } + } + + /** + * Main method to build expressions for given tables + * Evaluates the ctrl/enablecolumns/fe_group flag of the table and adds the according restriction if set + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $tcaSchemaFactory = $expressionBuilder->getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::RestrictionUserGroup)) { + $fieldName = $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName(); + // Allow records where no group access has been configured (field values NULL, 0 or empty string) + $tableConstraints = [ + $expressionBuilder->isNull($fieldName), + $expressionBuilder->eq($fieldName, $expressionBuilder->literal('')), + $expressionBuilder->eq($fieldName, $expressionBuilder->literal('0')), + ]; + foreach ($this->frontendGroupIds as $frontendGroupId) { + $tableConstraints[] = $expressionBuilder->inSet( + $fieldName, + $expressionBuilder->literal((string)($frontendGroupId ?? '')) + ); + } + $constraints[] = $expressionBuilder->or(...$tableConstraints); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/FrontendRestrictionContainer.php b/Classes/Database/Query/Restriction/FrontendRestrictionContainer.php new file mode 100644 index 0000000..895ee08 --- /dev/null +++ b/Classes/Database/Query/Restriction/FrontendRestrictionContainer.php @@ -0,0 +1,104 @@ +context = $context ?? GeneralUtility::makeInstance(Context::class); + foreach ($this->defaultRestrictionTypes as $restrictionType) { + $this->add($this->createRestriction($restrictionType)); + } + } + + /** + * Main method to build expressions for given tables + * Iterates over all registered restrictions and removes the hidden restriction if preview is requested + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($this->restrictions as $restriction) { + foreach ($queriedTables as $tableAlias => $tableName) { + $disableRestriction = false; + if ($restriction instanceof HiddenRestriction || $restriction instanceof StartTimeRestriction || $restriction instanceof EndTimeRestriction) { + $visibilityAspect = $this->context->getAspect('visibility'); + if ($restriction instanceof HiddenRestriction) { + // If display of hidden records is requested, we must disable the hidden restriction. + if ($tableName === 'pages') { + $disableRestriction = $visibilityAspect->includeHiddenPages(); + } else { + $disableRestriction = $visibilityAspect->includeHiddenContent(); + } + } + if ($restriction instanceof StartTimeRestriction || $restriction instanceof EndTimeRestriction) { + $disableRestriction = $visibilityAspect->includeScheduledRecords(); + } + } + if (!$disableRestriction) { + $constraints[] = $restriction->buildExpression([$tableAlias => $tableName], $expressionBuilder); + } + } + } + return $expressionBuilder->and(...$constraints); + } + + protected function createRestriction($restrictionClass): QueryRestrictionInterface + { + if ($restrictionClass === WorkspaceRestriction::class) { + return GeneralUtility::makeInstance($restrictionClass, (int)$this->context->getPropertyFromAspect('workspace', 'id', 0)); + } + if ($restrictionClass === FrontendGroupRestriction::class) { + return GeneralUtility::makeInstance($restrictionClass, $this->context->getPropertyFromAspect('frontend.user', 'groupIds', [])); + } + + return parent::createRestriction($restrictionClass); + } +} diff --git a/Classes/Database/Query/Restriction/HiddenRestriction.php b/Classes/Database/Query/Restriction/HiddenRestriction.php new file mode 100644 index 0000000..b4822cc --- /dev/null +++ b/Classes/Database/Query/Restriction/HiddenRestriction.php @@ -0,0 +1,56 @@ +getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $constraints[] = $expressionBuilder->eq( + $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(), + 0 + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php b/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php new file mode 100644 index 0000000..6b352b7 --- /dev/null +++ b/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php @@ -0,0 +1,113 @@ +applicableTableAliases = $this->restrictions = $this->restrictionContainer = []; + return $this; + } + + public function removeByType(string $restrictionType): QueryRestrictionContainerInterface + { + unset($this->applicableTableAliases[$restrictionType], $this->restrictions[$restrictionType]); + foreach ($this->restrictionContainer as $restrictionContainer) { + $restrictionContainer->removeByType($restrictionType); + } + return $this; + } + + public function add(QueryRestrictionInterface $restriction): QueryRestrictionContainerInterface + { + $this->restrictions[get_class($restriction)] = $restriction; + if ($restriction instanceof QueryRestrictionContainerInterface) { + $this->restrictionContainer[get_class($restriction)] = $restriction; + } + return $this; + } + + /** + * Adds the restriction, but also remembers which table aliases it should be applied to + * + * @param array $tableAliases flat array of table aliases, not table names + */ + public function addForTables(QueryRestrictionInterface $restriction, array $tableAliases): QueryRestrictionContainerInterface + { + $this->applicableTableAliases[get_class($restriction)] = $tableAliases; + return $this->add($restriction); + } + + /** + * Main method to build expressions for given tables, but respecting configured filters. + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($this->restrictions as $name => $restriction) { + $constraints[] = $restriction->buildExpression( + $this->filterApplicableTableAliases($queriedTables, $name), + $expressionBuilder + ); + } + return $expressionBuilder->and(...$constraints); + } + + private function filterApplicableTableAliases(array $queriedTables, string $name): array + { + if (!isset($this->applicableTableAliases[$name])) { + return $queriedTables; + } + + $filteredTables = []; + foreach ($this->applicableTableAliases[$name] as $tableAlias) { + if (isset($queriedTables[$tableAlias])) { + $filteredTables[$tableAlias] = $queriedTables[$tableAlias]; + } + } + + return $filteredTables; + } +} diff --git a/Classes/Database/Query/Restriction/PageIdListRestriction.php b/Classes/Database/Query/Restriction/PageIdListRestriction.php new file mode 100644 index 0000000..93086f4 --- /dev/null +++ b/Classes/Database/Query/Restriction/PageIdListRestriction.php @@ -0,0 +1,53 @@ + $tableName) { + if (empty($this->tableNames) || in_array($tableAlias, $this->tableNames, true)) { + $constraints[] = $expressionBuilder->in( + $tableAlias . '.pid', + array_map(intval(...), $this->pageIds) + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/PagePermissionRestriction.php b/Classes/Database/Query/Restriction/PagePermissionRestriction.php new file mode 100644 index 0000000..ba0c77d --- /dev/null +++ b/Classes/Database/Query/Restriction/PagePermissionRestriction.php @@ -0,0 +1,132 @@ +user is not an array), then "AND 1=0" is returned (will cause no selection results at all) + * + * The 95% use of this function is "->getPagePermsClause(1)" which will + * return WHERE clauses for *selecting* pages in backend listings - in other words this will check read permissions. + */ +class PagePermissionRestriction implements QueryRestrictionInterface +{ + /** + * @var int + */ + protected $permissions; + + /** + * @var UserAspect + */ + protected $userAspect; + + public function __construct(UserAspect $userAspect, int $permissions) + { + $this->permissions = $permissions; + $this->userAspect = $userAspect; + } + + /** + * Main method to build expressions for given tables + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName !== 'pages') { + continue; + } + + $constraint = $this->buildUserConstraints($expressionBuilder, $tableAlias); + if ($constraint) { + $constraints[] = $expressionBuilder->and($constraint); + } + } + + return $expressionBuilder->and(...$constraints); + } + + /** + * @return string|CompositeExpression|null + * @throws \TYPO3\CMS\Core\Context\Exception\AspectPropertyNotFoundException + */ + protected function buildUserConstraints(ExpressionBuilder $expressionBuilder, string $tableAlias) + { + if (!$this->userAspect->isLoggedIn()) { + return $expressionBuilder->comparison(1, ExpressionBuilder::EQ, 0); + } + if ($this->userAspect->isAdmin()) { + return null; + } + // User permissions + $constraint = $expressionBuilder->or( + $expressionBuilder->comparison( + $expressionBuilder->bitAnd($tableAlias . '.perms_everybody', $this->permissions), + ExpressionBuilder::EQ, + $this->permissions + ), + $expressionBuilder->and( + $expressionBuilder->eq($tableAlias . '.perms_userid', $this->userAspect->get('id')), + $expressionBuilder->comparison( + $expressionBuilder->bitAnd($tableAlias . '.perms_user', $this->permissions), + ExpressionBuilder::EQ, + $this->permissions + ) + ) + ); + + // User groups (if any are set) + $groupIds = array_map(intval(...), $this->userAspect->getGroupIds()); + if (!empty($groupIds)) { + $constraint = $constraint->with( + $expressionBuilder->and( + $expressionBuilder->in( + $tableAlias . '.perms_groupid', + $groupIds + ), + $expressionBuilder->comparison( + $expressionBuilder->bitAnd($tableAlias . '.perms_group', $this->permissions), + ExpressionBuilder::EQ, + $this->permissions + ) + ) + ); + } + return $constraint; + } +} diff --git a/Classes/Database/Query/Restriction/QueryRestrictionContainerInterface.php b/Classes/Database/Query/Restriction/QueryRestrictionContainerInterface.php new file mode 100644 index 0000000..9335c73 --- /dev/null +++ b/Classes/Database/Query/Restriction/QueryRestrictionContainerInterface.php @@ -0,0 +1,47 @@ +tableNames = $tableNames; + } + + /** + * Main method to build expressions for given tables + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (empty($this->tableNames) || in_array($tableAlias, $this->tableNames, true)) { + $constraints[] = $expressionBuilder->eq( + $tableAlias . '.pid', + 0 + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/StartTimeRestriction.php b/Classes/Database/Query/Restriction/StartTimeRestriction.php new file mode 100644 index 0000000..d7a964a --- /dev/null +++ b/Classes/Database/Query/Restriction/StartTimeRestriction.php @@ -0,0 +1,73 @@ +accessTimeStamp = $accessTimeStamp ?: ($GLOBALS['SIM_ACCESS_TIME'] ?? null); + } + + /** + * Main method to build expressions for given tables + * Evaluates the ctrl/enablecolumns/starttime flag of the table and adds the according restriction if set + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + * @throws \RuntimeException + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $tcaSchemaFactory = $expressionBuilder->getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) { + if (empty($this->accessTimeStamp)) { + throw new \RuntimeException( + 'accessTimeStamp needs to be set to an integer value, but is empty! Maybe $GLOBALS[\'SIM_ACCESS_TIME\'] has been overridden somewhere?', + 1462820645 + ); + } + $constraints[] = $expressionBuilder->lte( + $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(), + (int)$this->accessTimeStamp + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/Restriction/WorkspaceRestriction.php b/Classes/Database/Query/Restriction/WorkspaceRestriction.php new file mode 100644 index 0000000..d933117 --- /dev/null +++ b/Classes/Database/Query/Restriction/WorkspaceRestriction.php @@ -0,0 +1,113 @@ +versionOL() + * - PlainDataResolver (when having lots of records) + */ +class WorkspaceRestriction implements QueryRestrictionInterface +{ + protected int $workspaceId; + + /** + * Used to also query records within a workspace, which is useful for DB queries + * that check for a specific field (e.g. "slug") which might have changed within a workspace. + * Please note that some duplicates might be shown and the "reduce" logic needs to be + * added after querying. Setting this flag might also be a problem when using the DB query + * with limit and offset settings. + */ + protected bool $includeAllVersionedRecords; + + public function __construct(int $workspaceId = 0, bool $includeAllVersionedRecords = false) + { + $this->workspaceId = $workspaceId; + $this->includeAllVersionedRecords = $includeAllVersionedRecords; + } + + /** + * Main method to build expressions for given tables + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $tcaSchemaFactory = $expressionBuilder->getContainer()->get(TcaSchemaFactory::class); + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!$tcaSchemaFactory->has($tableName) || !$tcaSchemaFactory->get($tableName)->isWorkspaceAware()) { + continue; + } + if ($this->workspaceId === 0) { + // Only include records from live workspace + $workspaceIdExpression = $expressionBuilder->eq($tableAlias . '.t3ver_wsid', 0); + } else { + // Include live records PLUS records from the given workspace + $workspaceIdExpression = $expressionBuilder->in( + $tableAlias . '.t3ver_wsid', + [0, $this->workspaceId] + ); + } + // Always filter out versioned records that have an "offline" record + // But include moved records AND newly created records (t3ver_oid=0) + if ($this->includeAllVersionedRecords === false) { + $constraints[] = $expressionBuilder->and( + $workspaceIdExpression, + $expressionBuilder->or( + $expressionBuilder->eq( + $tableAlias . '.t3ver_oid', + 0 + ), + $expressionBuilder->eq( + $tableAlias . '.t3ver_state', + VersionState::MOVE_POINTER->value + ) + ) + ); + } else { + // Include live records plus records from the given workspace + // but never include versioned records marked as deleted + $constraints[] = $expressionBuilder->and( + $workspaceIdExpression, + $expressionBuilder->neq( + $tableAlias . '.t3ver_state', + VersionState::DELETE_PLACEHOLDER->value + ) + ); + } + } + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Database/Query/UnsupportedPreparedStatementParameterTypeException.php b/Classes/Database/Query/UnsupportedPreparedStatementParameterTypeException.php new file mode 100644 index 0000000..a7f3adf --- /dev/null +++ b/Classes/Database/Query/UnsupportedPreparedStatementParameterTypeException.php @@ -0,0 +1,32 @@ +name; + } + + public function isRecursive(): bool + { + return $this->recursive; + } + + /** @return string[] */ + public function getDependencies(): array + { + return $this->dependencies; + } + + public function getSQL(): string + { + $fields = ''; + + if ($this->fields !== []) { + $fields = sprintf(' (%s)', implode(', ', $this->fields)); + } + + return sprintf( + '%s%s AS (%s)', + $this->getName(), + $fields, + $this->expression, + ); + } + + public function __toString(): string + { + return $this->getSQL(); + } +} diff --git a/Classes/Database/Query/WithCollection.php b/Classes/Database/Query/WithCollection.php new file mode 100644 index 0000000..80b5e38 --- /dev/null +++ b/Classes/Database/Query/WithCollection.php @@ -0,0 +1,104 @@ +reset()->add(...array_values($with)); + } + + public function add(With ...$with): WithCollection + { + foreach ($with as $singleWith) { + $this->with[] = $singleWith; + if ($singleWith->isRecursive()) { + $this->recursive = true; + } + } + return $this; + } + + public function reset(): WithCollection + { + $this->recursive = false; + $this->with = []; + return $this; + } + + public function isEmpty(): bool + { + return $this->with === []; + } + + public function __toString(): string + { + if ($this->with === []) { + return ''; + } + $parts = []; + foreach ($this->getSortedParts() as $part) { + $parts[] = (string)$part; + } + return sprintf( + '%s %s', + ($this->recursive ? 'WITH RECURSIVE' : 'WITH'), + implode(', ', $parts) + ); + } + + /** + * @return With[] + */ + private function getSortedParts(): array + { + $parts = []; + foreach ($this->prepareParts() as $part) { + $parts[] = $part['instance']; + } + return $parts; + } + + /** + * @return array + */ + private function prepareParts(): array + { + $parts = []; + foreach ($this->with as $part) { + $parts[$part->getName()] = [ + 'instance' => $part, + 'before' => [], + 'after' => $part->getDependencies(), + ]; + } + return (new DependencyOrderingService())->orderByDependencies($parts); + } +} diff --git a/Classes/Database/ReferenceIndex.php b/Classes/Database/ReferenceIndex.php new file mode 100644 index 0000000..3bd4deb --- /dev/null +++ b/Classes/Database/ReferenceIndex.php @@ -0,0 +1,1204 @@ +connectionPool->getQueryBuilderForTable('sys_refindex'); + return (int)$queryBuilder + ->count('*')->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($tableName)), + $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + )->executeQuery()->fetchOne(); + } + + /** + * Update full refindex. Used by 'referenceindex:update' CLI and ext:lowlevel BE UI. + * + * @param ProgressListenerInterface|null $progressListener If set, the current progress is added to the listener + * @return array Header and body status content + * @internal + */ + public function updateIndex(bool $testOnly, ?ProgressListenerInterface $progressListener = null): array + { + $errors = []; + $numberOfHandledRecords = 0; + + $isWorkspacesLoaded = ExtensionManagementUtility::isLoaded('workspaces'); + $tcaTableNames = $this->tcaSchemaFactory->all()->getNames(); + // @todo: Ensure tcaSchemaFactory->all() always sorts alphabetically (or add test to verify), then remove this sort() + sort($tcaTableNames); + + $progressListener?->log('Remember to create missing tables and columns before running this.', LogLevel::WARNING); + + // Remove dangling workspace sys_refindex rows + $listOfActiveWorkspaces = $this->getListOfActiveWorkspaces(); + $numberOfUnusedWorkspaceRows = $testOnly + ? $this->getNumberOfUnusedWorkspaceRowsInReferenceIndex($listOfActiveWorkspaces) + : $this->removeUnusedWorkspaceRowsFromReferenceIndex($listOfActiveWorkspaces); + if ($numberOfUnusedWorkspaceRows > 0) { + $error = 'Index table hosted ' . $numberOfUnusedWorkspaceRows . ' indexes for non-existing or deleted workspaces, now removed.'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + + // Remove sys_refindex rows of tables no longer defined in TCA + $numberOfRowsOfOldTables = $testOnly + ? $this->getNumberOfUnusedTablesInReferenceIndex($tcaTableNames) + : $this->removeReferenceIndexDataFromUnusedDatabaseTables($tcaTableNames); + if ($numberOfRowsOfOldTables > 0) { + $error = 'Index table hosted ' . $numberOfRowsOfOldTables . ' indexes for non-existing tables, now removed'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + + // Main loop traverses all records of all TCA tables + foreach ($tcaTableNames as $tableName) { + $tableTcaSchema = $this->tcaSchemaFactory->get($tableName); + + // Count number of records in table to have a correct $numberOfHandledRecords in the end + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $numberOfRecordsInTargetTable = $queryBuilder + ->count('uid') + ->from($tableName) + ->executeQuery() + ->fetchOne(); + + $progressListener?->start($numberOfRecordsInTargetTable, $tableName); + + if ($numberOfRecordsInTargetTable === 0 || $this->shouldExcludeTableFromReferenceIndex($tableName) || empty($this->getTableRelationFields($tableName))) { + // Table is empty, should be excluded, or can not have relations. Blindly remove any existing sys_refindex rows. + $numberOfHandledRecords += $numberOfRecordsInTargetTable; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + if ($testOnly) { + $countDeleted = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where($queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName))) + ->executeQuery() + ->fetchOne(); + } else { + $countDeleted = $queryBuilder + ->delete('sys_refindex') + ->where($queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName))) + ->executeStatement(); + } + if ($countDeleted > 0) { + $error = 'Index table hosted ' . $countDeleted . ' ignored or outdated indexed, now removed.'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + $progressListener?->finish(); + continue; + } + + // Delete lost indexes of table: sys_refindex rows where the uid no longer exists in target table. + $subQueryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $subQueryBuilder->getRestrictions()->removeAll(); + $subQueryBuilder + ->select('uid') + ->from($tableName, 'sub_' . $tableName) + ->where( + $subQueryBuilder->expr()->eq('sub_' . $tableName . '.uid', $subQueryBuilder->quoteIdentifier('sys_refindex.recuid')) + ); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + if ($testOnly) { + $numberOfRefindexRowsWithoutExistingTableRow = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)), + 'NOT EXISTS (' . $subQueryBuilder->getSQL() . ')' + ) + ->executeQuery() + ->fetchOne(); + } else { + $numberOfRefindexRowsWithoutExistingTableRow = $queryBuilder + ->delete('sys_refindex') + ->where( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)), + 'NOT EXISTS (' . $subQueryBuilder->getSQL() . ')' + ) + ->executeStatement(); + } + if ($numberOfRefindexRowsWithoutExistingTableRow > 0) { + $error = 'Table ' . $tableName . ' hosted ' . $numberOfRefindexRowsWithoutExistingTableRow . ' lost indexes, now removed.'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + + // Delete rows in sys_refindex related to this table where the record is soft-deleted=1. + if ($tableTcaSchema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $softDeleteFieldName = $tableTcaSchema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $numberOfDeletedRecordsInTargetTable = $queryBuilder + ->count('uid') + ->from($tableName) + ->where($queryBuilder->expr()->eq($softDeleteFieldName, 1)) + ->executeQuery() + ->fetchOne(); + if ($numberOfDeletedRecordsInTargetTable > 0) { + $numberOfHandledRecords += $numberOfDeletedRecordsInTargetTable; + // List of deleted=0 records in target table that have records in sys_refindex. + if ($testOnly) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + // $subQueryBuilder actually fills parameter placeholders for the main $queryBuilder. + // The subQuery is never meant to be executed on its own, only used to be filled-in + // via $subQueryBuilder->getSQL(). + $subQueryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $subQueryBuilder->getRestrictions()->removeAll(); + $subQueryBuilder + ->select('sub_' . $tableName . '.uid') + ->distinct() + ->from($tableName, 'sub_' . $tableName) + ->join( + 'sub_' . $tableName, + 'sys_refindex', + 'sub_refindex', + $queryBuilder->expr()->eq('sub_refindex.recuid', $queryBuilder->quoteIdentifier('sub_' . $tableName . '.uid')) + ) + ->where( + $queryBuilder->expr()->eq('sub_refindex.tablename', $queryBuilder->createNamedParameter($tableName)), + $queryBuilder->expr()->eq('sub_' . $tableName . '.' . $softDeleteFieldName, 1), + ); + $numberOfRemovedIndexes = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)), + $queryBuilder->quoteIdentifier('recuid') . ' IN ( ' . $subQueryBuilder->getSQL() . ' )' + ) + ->executeQuery() + ->fetchOne(); + } else { + // MySQL is picky when using the same table in a sub-query and an outer delete query, if + // it is not materialized into a temporary table. Enforcing a temporary table would mitigate this + // MySQL limit, but we simply fetch the affected uid list instead and fire a chunked delete query. + // In contrast to $testOnly above, we execute the subQuery, named parameter placeholders need + // to be relative to its QueryBuilder. + $uidListQueryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $uidListQueryBuilder->getRestrictions()->removeAll(); + $uidListQueryBuilder + ->select('sub_' . $tableName . '.uid') + ->distinct() + ->from($tableName, 'sub_' . $tableName) + ->join( + 'sub_' . $tableName, + 'sys_refindex', + 'sub_refindex', + $uidListQueryBuilder->expr()->eq('sub_refindex.recuid', $uidListQueryBuilder->quoteIdentifier('sub_' . $tableName . '.uid')) + ) + ->where( + $uidListQueryBuilder->expr()->eq('sub_refindex.tablename', $uidListQueryBuilder->createNamedParameter($tableName)), + $uidListQueryBuilder->expr()->eq('sub_' . $tableName . '.' . $softDeleteFieldName, 1), + ); + $uidListOfRemovableIndexes = $uidListQueryBuilder->executeQuery()->fetchFirstColumn(); + $numberOfRemovedIndexes = 0; + // Another variant to solve this would be a limit/offset query for the upper query, feeding delete. + // This would be more memory efficient. We however think there shouldn't be *that* many affected + // rows to delete in casual scenarios, so we skip that optimization for now since chunking isn't + // needed in most cases anyway. + // 10k is an arbitrary number. Reasoning: 1MB max query length with 10-char uids (9mio uid-range with comma) + // would allow ~10k uids. Combi tablename/recuid is indexed, so delete should be relatively quick even with + // larger sets, so delete-hard-locking on for instance innodb shouldn't be a huge issue here. + foreach (array_chunk($uidListOfRemovableIndexes, 10000) as $uidChunkOfRemovableIndexes) { + $chunkQueryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $chunkQueryBuilder->getRestrictions()->removeAll(); + $chunkedNumberOfRemovedIndexes = $chunkQueryBuilder + ->delete('sys_refindex') + ->where( + $chunkQueryBuilder->expr()->eq('tablename', $chunkQueryBuilder->createNamedParameter($tableName)), + $chunkQueryBuilder->expr()->in('recuid', $uidChunkOfRemovableIndexes) + ) + ->executeStatement(); + $numberOfRemovedIndexes += $chunkedNumberOfRemovedIndexes; + } + } + if ($numberOfRemovedIndexes > 0) { + $error = 'Table ' . $tableName . ' hosted ' . $numberOfRemovedIndexes . ' indexes from soft-deleted records, now removed.'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + $progressListener?->advance($numberOfDeletedRecordsInTargetTable); + } + } + + // Some additional magic is needed if the table has a field that is the local side of + // a mm relation. See the variable usage below for details. + $tableHasLocalSideMmRelation = false; + foreach ($tableTcaSchema->getFields() as $field) { + $fieldConfig = $field->getConfiguration(); + if (!empty($fieldConfig['MM'] ?? '') + // Catch type=group 'allowed' and type=select 'foreign_table' MM scenarios + && (!empty($fieldConfig['allowed'] ?? '') || !empty($fieldConfig['foreign_table'] ?? '')) + && empty($fieldConfig['MM_opposite_field'] ?? '') + ) { + $tableHasLocalSideMmRelation = true; + } + } + + // Traverse all records in table, not including soft-deleted records + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryResult = $queryBuilder + ->select('*') + ->from($tableName) + ->orderBy('uid') + ->executeQuery(); + while ($record = $queryResult->fetchAssociative()) { + $progressListener?->advance(); + if ($isWorkspacesLoaded && $tableHasLocalSideMmRelation && (int)($record['t3ver_wsid'] ?? 0) === 0) { + // If we have a record that can be the local side of a workspace relation, workspace records + // may point to it, even though the record has no workspace overlay. See workspace ManyToMany + // Modify addCategoryRelation as example. In those cases, we need to iterate all active workspaces + // and update refindex for all foreign workspace records that point to it. + foreach ($listOfActiveWorkspaces as $workspaceId) { + $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, $workspaceId, $record); + $numberOfHandledRecords++; + if ($result['addedNodes'] || $result['deletedNodes']) { + $error = 'Record ' . $tableName . ':' . $record['uid'] . ' had ' . $result['addedNodes'] . ' added indexes and ' . $result['deletedNodes'] . ' deleted indexes'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + } + } else { + $result = $this->updateRefIndexTable($tableName, (int)$record['uid'], $testOnly, (int)($record['t3ver_wsid'] ?? 0), $record); + $numberOfHandledRecords++; + if ($result['addedNodes'] || $result['deletedNodes']) { + $error = 'Record ' . $tableName . ':' . $record['uid'] . ' had ' . $result['addedNodes'] . ' added indexes and ' . $result['deletedNodes'] . ' deleted indexes'; + $errors[] = $error; + $progressListener?->log($error, LogLevel::WARNING); + } + } + } + $progressListener?->finish(); + } + + $errorCount = count($errors); + $recordsCheckedString = $numberOfHandledRecords . ' records from ' . count($tcaTableNames) . ' tables were checked/updated.'; + if ($errorCount) { + $progressListener?->log($recordsCheckedString . ' Updates: ' . $errorCount, LogLevel::WARNING); + } else { + $progressListener?->log($recordsCheckedString . ' Index Integrity was perfect.'); + } + if (!$testOnly) { + $this->registry->set('core', 'sys_refindex_lastUpdate', $GLOBALS['EXEC_TIME']); + } + return ['resultText' => trim($recordsCheckedString), 'errors' => $errors]; + } + + /** + * Update the sys_refindex table for a record, even one just deleted. + * This is used by DataHandler ReferenceIndexUpdater as entry method to take care of single records. + * It is also used internally via updateIndex() by CLI "referenceindex:update" and lowlevel BE module. + * + * @param array|null $currentRecord Current full (select *) record from DB. Optimization for updateIndex(). + * @return array Statistics about how many index records were added, deleted and not altered. + * @internal + */ + public function updateRefIndexTable(string $tableName, int $uid, bool $testOnly = false, int $workspaceUid = 0, ?array $currentRecord = null): array + { + $result = [ + 'keptNodes' => 0, + 'deletedNodes' => 0, + 'addedNodes' => 0, + ]; + if ($uid < 1 || $this->shouldExcludeTableFromReferenceIndex($tableName) || empty($this->getTableRelationFields($tableName))) { + // Not a valid uid, the table is excluded, or can not contain relations. + return $result; + } + if ($currentRecord === null) { + // Fetch record if not provided. + $currentRecord = BackendUtility::getRecord($tableName, $uid); + } + $currentRelationHashes = $this->getCurrentRelationHashes($tableName, $uid, $workspaceUid); + if ($currentRecord === null) { + // If there is no record because it was hard or soft-deleted, remove any existing sys_refindex rows of it. + $numberOfLeftOverRelationHashes = count($currentRelationHashes); + $result['deletedNodes'] = $numberOfLeftOverRelationHashes; + if ($numberOfLeftOverRelationHashes > 0 && !$testOnly) { + $this->removeRelationHashes($currentRelationHashes); + } + return $result; + } + + $relations = $this->compileReferenceIndexRowsForRecord($tableName, $currentRecord, $workspaceUid); + $connection = $this->connectionPool->getConnectionForTable('sys_refindex'); + $relationsToInsert = []; + foreach ($relations as $relation) { + if (!is_array($relation)) { + continue; + } + // Exclude any relations TO a specific table + if (($relation['ref_table'] ?? '') && $this->shouldExcludeTableFromReferenceIndex($relation['ref_table'])) { + continue; + } + $relation['hash'] = hash(algo: 'xxh128', data: implode(',', $relation), options: ['seed' => self::HASH_VERSION]); + // First, check if already indexed and if so, unset that row (so in the end we know which rows to remove!) + if (isset($currentRelationHashes[$relation['hash']])) { + unset($currentRelationHashes[$relation['hash']]); + $result['keptNodes']++; + } else { + // If new, register for bulk add: + if (!$testOnly) { + $relationsToInsert[] = $relation; + } + $result['addedNodes']++; + } + } + if (!$testOnly && !empty($relationsToInsert)) { + try { + $connection->bulkInsert('sys_refindex', $relationsToInsert, array_keys(current($relationsToInsert))); + } catch (\Exception $e) { + // Do nothing for the time being + } + } + + // If any existing are left, they are not in the current set anymore. Remove them. + $numberOfLeftOverRelationHashes = count($currentRelationHashes); + $result['deletedNodes'] = $numberOfLeftOverRelationHashes; + if ($numberOfLeftOverRelationHashes > 0 && !$testOnly) { + $this->removeRelationHashes($currentRelationHashes); + } + + return $result; + } + + /** + * Returns relation information for a record from a TCA table. + * + * @return array Array with information about relations + * @internal + */ + public function getRelations(string $tableName, array $record, int $workspaceUid): array + { + $result = []; + $relationFields = $this->getTableRelationFields($tableName); + $tableTcaSchema = $this->tcaSchemaFactory->get($tableName); + foreach ($relationFields as $fieldName) { + $value = $record[$fieldName] ?? null; + if (!$tableTcaSchema->hasField($fieldName)) { + continue; + } + $field = $tableTcaSchema->getField($fieldName); + $fieldConfig = $field->getConfiguration(); + $resultsFromDatabase = $this->getRelationsFromRelationField($tableName, $value, $fieldConfig, (int)$record['uid'], $workspaceUid, $record); + if (!empty($resultsFromDatabase)) { + // Create an entry for the field with all DB relations: + $result[$fieldName] = [ + 'type' => 'db', + 'itemArray' => $resultsFromDatabase, + ]; + } + if ($field->isType(TableColumnType::FLEX) && is_string($value) && $value !== '') { + // Traverse the flex data structure looking for db references for flex fields. + $flexFormRelations = $this->getRelationsFromFlexData($tableName, $fieldName, $record, $workspaceUid); + if (!empty($flexFormRelations)) { + $result[$fieldName] = [ + 'type' => 'flex', + 'flexFormRels' => $flexFormRelations, + ]; + } + } + if ((string)$value !== '') { + // Soft References + $softRefValue = $value; + $softReferenceKeys = $field->getSoftReferenceKeys(); + if ($softReferenceKeys !== false) { + foreach ($this->softReferenceParserFactory->getParsersBySoftRefParserList(implode(',', $softReferenceKeys)) as $softReferenceParser) { + $parserResult = $softReferenceParser->parse($tableName, $fieldName, (int)$record['uid'], $softRefValue); + if ($parserResult->hasMatched()) { + $result[$fieldName]['softrefs']['keys'][$softReferenceParser->getParserKey()] = $parserResult->getMatchedElements(); + if ($parserResult->hasContent()) { + $softRefValue = $parserResult->getContent(); + } + } + } + } + if (!empty($result[$fieldName]['softrefs']) && (string)$value !== (string)$softRefValue && str_contains($softRefValue, '{softref:')) { + $result[$fieldName]['softrefs']['tokenizedContent'] = $softRefValue; + } + } + } + return $result; + } + + /** + * Get current sys_refindex rows of table:uid from database with hash as index. + * + * @return array + */ + private function getCurrentRelationHashes(string $tableName, int $uid, int $workspaceUid): array + { + $connection = $this->connectionPool->getConnectionForTable('sys_refindex'); + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryResult = $queryBuilder->select('hash')->from('sys_refindex')->where( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($tableName)), + $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($workspaceUid, Connection::PARAM_INT)) + )->executeQuery(); + $currentRelationHashes = []; + while ($relation = $queryResult->fetchAssociative()) { + $currentRelationHashes[$relation['hash']] = true; + } + return $currentRelationHashes; + } + + /** + * Remove sys_refindex rows by hash. + * + * @param array $currentRelationHashes + */ + private function removeRelationHashes(array $currentRelationHashes): void + { + $connection = $this->connectionPool->getConnectionForTable('sys_refindex'); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + $chunks = array_chunk(array_keys($currentRelationHashes), $maxBindParameters - 10, true); + foreach ($chunks as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder + ->delete('sys_refindex') + ->where( + $queryBuilder->expr()->in('hash', $queryBuilder->createNamedParameter($chunk, Connection::PARAM_STR_ARRAY)) + ) + ->executeStatement(); + } + } + + private function compileReferenceIndexRowsForRecord(string $tableName, array $record, int $workspaceUid): array + { + $relations = []; + + $tableTcaSchema = $this->tcaSchemaFactory->get($tableName); + $hiddenFieldValue = $tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? (int)$record[$tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()] + : 0; + $starttimeFieldValue = $tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionStartTime) + ? (int)$record[$tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()] + : 0; + $endtimeFieldValue = $tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionEndTime) + ? (int)($record[$tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()] ?: 2147483647) + : 2147483647; // @todo: 2^31-1 (year 2038) and not 2^32-1 since postgres 32-bit int is always signed + + $recordRelations = $this->getRelations($tableName, $record, $workspaceUid); + foreach ($recordRelations as $fieldName => $fieldRelations) { + $field = $tableTcaSchema->getField($fieldName); + if ($tableTcaSchema->isWorkspaceAware() + && isset($record['t3ver_wsid']) && (int)$record['t3ver_wsid'] !== $workspaceUid + && $field instanceof RelationalFieldTypeInterface + && $field->getRelationshipType() !== RelationshipType::ManyToMany + ) { + // The given record is workspace-enabled but doesn't live in the selected workspace. Don't add index, it's not actually there. + // We still add those rows if the record is a local side live record of an MM relation and can be a target of a workspace record. + // See workspaces ManyToMany Modify addCategoryRelation for details on this case. + continue; + } + if (is_array($fieldRelations['itemArray'] ?? false) && !empty($fieldRelations['itemArray'])) { + // DB relations in a db field + $itemArray = $fieldRelations['itemArray']; + if ($field->isType(TableColumnType::INLINE, TableColumnType::FILE) + && $field instanceof RelationalFieldTypeInterface + && $field->getRelationshipType()->isSingularRelationship() + ) { + // RelationHandler does not return info on hidden, starttime, endtime for inline non-MM, yet. Add this now. + // @todo: Refactor RelationHandler / PlainDataResolver to (optionally?) return full child row record + $itemArray = $this->enrichInlineRelations(current($itemArray)['table'], $fieldRelations['itemArray']); + } + if ($field->isType(TableColumnType::INLINE, TableColumnType::GROUP, TableColumnType::SELECT) + && $field instanceof RelationalFieldTypeInterface + && $field->getRelationshipType() === RelationshipType::ManyToMany + ) { + foreach ($itemArray as $itemKey => $item) { + // Get rid of soft-deleted foreign records, those should not create refindex entries + // @todo: It would be better if RelationHandler would not return soft-deleted MM rows. Unsure + // how to do that since RH only works on the MM table, but it could then also return + // the full foreign row along the way. + // @todo: Expensive. This code could be optimized to fetch multiple records at once per foreign + // table, or make RH return full foreign rows in some hopefully efficient way. + $foreignSideRecord = BackendUtility::getRecord($item['table'], (int)$item['id']); + if ($foreignSideRecord === null) { + // @todo: This mixes up ref_sorting when rows are removed here. Shouldn't be + // very problematic, though. + unset($itemArray[$itemKey]); + continue; + } + $itemTableSchema = $this->tcaSchemaFactory->get((string)$item['table']); + if ($itemTableSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $disabledFieldName = $itemTableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + $itemArray[$itemKey][$disabledFieldName] = $foreignSideRecord[$disabledFieldName]; + } + if ($itemTableSchema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) { + $starttimeFieldName = $itemTableSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(); + $itemArray[$itemKey][$starttimeFieldName] = $foreignSideRecord[$starttimeFieldName]; + } + if ($itemTableSchema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) { + $endtimeFieldName = $itemTableSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + $itemArray[$itemKey][$endtimeFieldName] = $foreignSideRecord[$endtimeFieldName]; + } + if ($itemTableSchema->hasCapability(TcaSchemaCapability::Workspace)) { + $itemArray[$itemKey]['t3ver_state'] = $foreignSideRecord['t3ver_state']; + } + } + } + $sorting = 0; + foreach ($itemArray as $refRecord) { + $refTable = (string)$refRecord['table']; + $refTcaTableSchema = $this->tcaSchemaFactory->get($refTable); + $relations[] = [ + 'tablename' => $tableName, + 'recuid' => (int)$record['uid'], + 'field' => $fieldName, + 'hidden' => $hiddenFieldValue, + 'starttime' => $starttimeFieldValue, + 'endtime' => $endtimeFieldValue, + 't3ver_state' => (int)($record['t3ver_state'] ?? 0), + 'flexpointer' => '', + 'softref_key' => '', + 'softref_id' => '', + 'sorting' => $sorting, + 'workspace' => $workspaceUid, + 'ref_table' => $refTable, + 'ref_uid' => (int)$refRecord['id'], + 'ref_field' => (string)($refRecord['fieldname'] ?? ''), + 'ref_hidden' => $refTcaTableSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()] ?? 0) + : 0, + 'ref_starttime' => $refTcaTableSchema->hasCapability(TcaSchemaCapability::RestrictionStartTime) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()] ?? 0) + : 0, + 'ref_endtime' => $refTcaTableSchema->hasCapability(TcaSchemaCapability::RestrictionEndTime) + ? (int)(($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()] ?? 0) ?: 2147483647) + : 2147483647, + 'ref_t3ver_state' => (int)($refRecord['t3ver_state'] ?? 0), + 'ref_sorting' => (int)($refRecord['sorting_foreign'] ?? 0), + 'ref_string' => '', + ]; + $sorting++; + } + } + if (is_array($fieldRelations['softrefs']['keys'] ?? false)) { + // Soft reference relations in a db field + foreach ($fieldRelations['softrefs']['keys'] as $softrefKey => $elements) { + if (!is_array($elements)) { + continue; + } + foreach ($elements as $softrefId => $element) { + if (!in_array($element['subst']['type'] ?? '', ['db', 'string'], true)) { + continue; + } + $refTable = '_STRING'; + $refUid = 0; + $refString = ''; + $refRecord = []; + $refTcaTableSchema = null; + if ($element['subst']['type'] === 'db') { + $explodedRefTableUid = explode(':', $element['subst']['recordRef']); + $refTable = $explodedRefTableUid[0]; + $refUid = (int)$explodedRefTableUid[1]; + if ($refRecord = BackendUtility::getRecord($refTable, $refUid)) { + // @todo: It would be great to refactor the softref parser mess "data structure" + // and let it return the reference record along the way - "db" type softrefs + // fetch those already. + $refTcaTableSchema = $this->tcaSchemaFactory->get($refTable); + } else { + // Sanitize the target record: If it does not exist, we should not create a softref + // entry to it. Also ref_uid is the uid of the target record. If a softref parser + // goes rogue and misinterprets for instance a huge number ("telephone") as uid, + // creating this as softref would try to insert a "bigger than 2^31 int" for ref_uid, + // leading to an out of bound insert. + // @todo: This late validation of softref stuff should probably be relocated to the + // parsers directly. Refactor this together with the above comment. + // @todo: It is fishy that DataHandler does not sanitize fields with attached softref + // parsers, either. Example: tt_content header_link with manual editor edit + // "+49 123456789" which looks like a phone number, but the internal phone + // syntax is "tel:49123456789". DH still happily writes the invalid thing into DB. + // This should be rethought. + continue; + } + } else { + $refString = mb_substr($element['subst']['tokenValue'], 0, 1024); + } + $relations[] = [ + 'tablename' => $tableName, + 'recuid' => (int)$record['uid'], + 'field' => $fieldName, + 'hidden' => $hiddenFieldValue, + 'starttime' => $starttimeFieldValue, + 'endtime' => $endtimeFieldValue, + 't3ver_state' => (int)($record['t3ver_state'] ?? 0), + 'flexpointer' => '', + 'softref_key' => (string)$softrefKey, + 'softref_id' => (string)$softrefId, + 'sorting' => 0, + 'workspace' => $workspaceUid, + 'ref_table' => $refTable, + 'ref_uid' => $refUid, + 'ref_field' => '', + 'ref_hidden' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()] ?? 0) + : 0, + 'ref_starttime' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionStartTime) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()] ?? 0) + : 0, + 'ref_endtime' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionEndTime) + ? (int)(($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()] ?? 0) ?: 2147483647) + : 2147483647, + 'ref_t3ver_state' => (int)($refRecord['t3ver_state'] ?? 0), + 'ref_sorting' => 0, + 'ref_string' => $refString, + ]; + } + } + } + if (is_array($fieldRelations['flexFormRels']['db'] ?? false)) { + // DB relations in a flex field + foreach ($fieldRelations['flexFormRels']['db'] as $flexPointer => $subList) { + $sorting = 0; + foreach ($subList as $refRecord) { + // @todo: This has no proper test setup in ReferenceIndexTest and ReferenceIndexWorkspaceLoadedTest. + // We probably need to fetch the target record for inline relations here, as done with + // $fieldRelations['itemArray'] enrichInlineRelations() above, to set ref_ fields hidden, starttime, + // endtime and t3ver_state. Additionally, a test based on categories should verify MM details. + $relations[] = [ + 'tablename' => $tableName, + 'recuid' => (int)$record['uid'], + 'field' => $fieldName, + 'hidden' => $hiddenFieldValue, + 'starttime' => $starttimeFieldValue, + 'endtime' => $endtimeFieldValue, + 't3ver_state' => (int)($record['t3ver_state'] ?? 0), + 'flexpointer' => (string)$flexPointer, + 'softref_key' => '', + 'softref_id' => '', + 'sorting' => $sorting, + 'workspace' => $workspaceUid, + 'ref_table' => $refRecord['table'], + 'ref_uid' => (int)$refRecord['id'], + 'ref_field' => (string)($refRecord['fieldname'] ?? ''), + // @todo: ref_hidden, ref_starttime, ref_endtime, ref_t3ver_state, ref_t3ver_state and ref_sorting need coverage and handling. + 'ref_hidden' => 0, + 'ref_starttime' => 0, + 'ref_endtime' => 2147483647, + 'ref_t3ver_state' => 0, + 'ref_sorting' => 0, + 'ref_string' => '', + ]; + $sorting++; + } + } + } + if (is_array($fieldRelations['flexFormRels']['softrefs'] ?? false)) { + // Soft reference relations in a flex field + foreach ($fieldRelations['flexFormRels']['softrefs'] as $flexPointer => $subList) { + foreach ($subList['keys'] as $softrefKey => $elements) { + if (!is_array($elements)) { + continue; + } + foreach ($elements as $softrefId => $element) { + if (!in_array($element['subst']['type'] ?? '', ['db', 'string'], true)) { + continue; + } + $refTable = '_STRING'; + $refUid = 0; + $refString = ''; + $refRecord = []; + $refTcaTableSchema = null; + if ($element['subst']['type'] === 'db') { + $explodedRefTableUid = explode(':', $element['subst']['recordRef']); + $refTable = $explodedRefTableUid[0]; + $refUid = (int)$explodedRefTableUid[1]; + if ($refRecord = BackendUtility::getRecord($refTable, $refUid)) { + // @todo: It would be great to refactor the softref parser mess "data structure" + // and let it return the reference record along the way - "db" type softrefs + // fetch those already. + $refTcaTableSchema = $this->tcaSchemaFactory->get($refTable); + } + } else { + $refString = mb_substr($element['subst']['tokenValue'], 0, 1024); + } + $relations[] = [ + 'tablename' => $tableName, + 'recuid' => (int)$record['uid'], + 'field' => $fieldName, + 'hidden' => $hiddenFieldValue, + 'starttime' => $starttimeFieldValue, + 'endtime' => $endtimeFieldValue, + 't3ver_state' => (int)($record['t3ver_state'] ?? 0), + 'flexpointer' => $flexPointer, + 'softref_key' => (string)$softrefKey, + 'softref_id' => (string)$softrefId, + 'sorting' => 0, + 'workspace' => $workspaceUid, + 'ref_table' => $refTable, + 'ref_uid' => $refUid, + 'ref_field' => '', + 'ref_hidden' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()] ?? 0) + : 0, + 'ref_starttime' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionStartTime) + ? (int)($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()] ?? 0) + : 0, + 'ref_endtime' => $refTcaTableSchema?->hasCapability(TcaSchemaCapability::RestrictionEndTime) + ? (int)(($refRecord[$refTcaTableSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()] ?? 0) ?: 2147483647) + : 2147483647, + 'ref_t3ver_state' => (int)($refRecord['t3ver_state'] ?? 0), + 'ref_sorting' => 0, + 'ref_string' => $refString, + ]; + } + } + } + } + } + return $relations; + } + + /** + * RelationHandler does not return relation record details when dealing with + * inline foreign_table relations. We need fields like hidden and starrtime, + * though. Fetch them now. + */ + private function enrichInlineRelations(string $tableName, array $itemArray): array + { + $selectFields = ['uid']; + $tableTcaSchema = $this->tcaSchemaFactory->get($tableName); + if ($tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $selectFields[] = $tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + } + if ($tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) { + $selectFields[] = $tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(); + } + if ($tableTcaSchema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) { + $selectFields[] = $tableTcaSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + } + if ($tableTcaSchema->isWorkspaceAware()) { + $selectFields[] = 't3ver_state'; + } + if (count($selectFields) === 1) { + return $itemArray; + } + $connection = $this->connectionPool->getConnectionForTable($tableName); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $rows = []; + $uidList = array_column($itemArray, 'id'); + foreach (array_chunk($uidList, $maxBindParameters - 10, true) as $chunk) { + $result = $queryBuilder->select(...$selectFields)->from($tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ) + ) + ->orderBy('uid', 'ASC')->executeQuery(); + while ($row = $result->fetchAssociative()) { + $rows[(int)$row['uid']] = $row; + } + } + foreach ($itemArray as &$item) { + if (isset($rows[$item['id']])) { + // @todo: The isset() prevents a PHP array access warning here. It seems this can happen with + // inline CSV since RelationHandler->realList() does not verify if attached records + // really exist. There is probably a deeper issue with CSV lists here, see #106428 for + // more information and reproduce. This area should have a closer look, it looks as if + // "count" value instead of uid fields are hand over here - at least with tx_styleguide_inline_11. + $item = array_merge($item, $rows[$item['id']]); + } + } + return $itemArray; + } + + private function getRelationsFromFlexData(string $tableName, string $fieldName, array $row, int $workspaceUid): array + { + $valueArray = GeneralUtility::xml2array($row[$fieldName] ?? ''); + if (!is_array($valueArray)) { + // Current flex form values can not be parsed to an array. No relations. + return []; + } + try { + $tableTcaSchema = $this->tcaSchemaFactory->get($tableName); + $fieldConfig['config'] = $tableTcaSchema->getField($fieldName)->getConfiguration(); + $dataStructureArray = $this->flexFormTools->parseDataStructureByIdentifier( + $this->flexFormTools->getDataStructureIdentifier($fieldConfig, $tableName, $fieldName, $row, $tableTcaSchema), + $tableTcaSchema + ); + } catch (InvalidIdentifierException) { + // Data structure can not be resolved or parsed. No relations. + return []; + } + if (!is_array($dataStructureArray['sheets'] ?? false)) { + // No sheet in DS. Shouldn't happen, though. + return []; + } + $flexRelations = []; + foreach ($dataStructureArray['sheets'] as $sheetKey => $sheetData) { + foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementTca) { + // For all elements allowed in Data Structure. + if (($sheetElementTca['type'] ?? '') === 'array') { + // This is a section. + if (!is_array($sheetElementTca['el'] ?? false) || !is_array($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) { + // No possible containers defined for this section in DS, or no values set for this section. + continue; + } + foreach ($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) { + // We have containers for this section in values. + if (!is_array($valueSectionContainers ?? false)) { + // Values don't validate to an array, skip. + continue; + } + foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) { + // For all value containers in this section. + if (!is_array($sheetElementTca['el'][$valueContainerType]['el'] ?? false)) { + // There is no DS for this container type, skip. + continue; + } + foreach ($sheetElementTca['el'][$valueContainerType]['el'] as $containerElement => $containerElementTca) { + // Container type of this value container exists in DS. Iterate DS container to find value relations. + if (isset($valueContainerElements['el'][$containerElement]['vDEF'])) { + $fieldValue = $valueContainerElements['el'][$containerElement]['vDEF']; + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/el/' . $valueSectionContainerKey . '/' . $valueContainerType . '/el/' . $containerElement . '/vDEF/'; + if ($fieldValue !== '' && ($containerElementTca['config']['softref'] ?? '') !== '') { + $tokenizedContent = $fieldValue; + foreach ($this->softReferenceParserFactory->getParsersBySoftRefParserList($containerElementTca['config']['softref']) as $softReferenceParser) { + $parserResult = $softReferenceParser->parse($tableName, $fieldName, (int)$row['uid'], $fieldValue, $structurePath); + if ($parserResult->hasMatched()) { + $flexRelations['softrefs'][$structurePath]['keys'][$softReferenceParser->getParserKey()] = $parserResult->getMatchedElements(); + if ($parserResult->hasContent()) { + $tokenizedContent = $parserResult->getContent(); + } + } + } + if (!empty($flexRelations['softrefs'][$structurePath]) && $fieldValue !== $tokenizedContent) { + $flexRelations['softrefs'][$structurePath]['tokenizedContent'] = $tokenizedContent; + } + } + } + } + } + } + } elseif (isset($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) { + // Not a section but a simple field. Get its relations. + $fieldValue = $valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF']; + $structurePath = $sheetKey . '/lDEF/' . $sheetElementKey . '/vDEF/'; + $databaseRelations = $this->getRelationsFromRelationField($tableName, $fieldValue, $sheetElementTca['config'] ?? [], (int)$row['uid'], $workspaceUid, $row); + if (!empty($databaseRelations)) { + $flexRelations['db'][$structurePath] = $databaseRelations; + } + if ($fieldValue !== '' && ($sheetElementTca['config']['softref'] ?? '') !== '') { + $tokenizedContent = $fieldValue; + foreach ($this->softReferenceParserFactory->getParsersBySoftRefParserList($sheetElementTca['config']['softref']) as $softReferenceParser) { + $parserResult = $softReferenceParser->parse($tableName, $fieldName, (int)$row['uid'], $fieldValue, $structurePath); + if ($parserResult->hasMatched()) { + $flexRelations['softrefs'][$structurePath]['keys'][$softReferenceParser->getParserKey()] = $parserResult->getMatchedElements(); + if ($parserResult->hasContent()) { + $tokenizedContent = $parserResult->getContent(); + } + } + } + if (!empty($flexRelations['softrefs'][$structurePath]) && $fieldValue !== $tokenizedContent) { + $flexRelations['softrefs'][$structurePath]['tokenizedContent'] = $tokenizedContent; + } + } + } + } + } + return $flexRelations; + } + + /** + * Check field configuration if it is a DB relation field and extract DB relations if any + */ + private function getRelationsFromRelationField(string $tableName, mixed $fieldValue, array $conf, int $uid, int $workspaceUid, array $row): array + { + if (empty($conf)) { + return []; + } + if (($conf['type'] === 'inline' || $conf['type'] === 'file') && !empty($conf['foreign_table']) && empty($conf['MM'])) { + $dbAnalysis = GeneralUtility::makeInstance(RelationHandler::class); + $dbAnalysis->setUseLiveReferenceIds(false); + $dbAnalysis->setWorkspaceId($workspaceUid); + $dbAnalysis->start($fieldValue, $conf['foreign_table'], '', $uid, $tableName, $conf); + return $dbAnalysis->itemArray; + } + if ($this->isDbReferenceField($conf)) { + $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table']; + if ($conf['MM_opposite_field'] ?? false) { + // Never handle sys_refindex when looking at MM from foreign side + return []; + } + $dbAnalysis = GeneralUtility::makeInstance(RelationHandler::class); + $dbAnalysis->setWorkspaceId($workspaceUid); + $dbAnalysis->start($fieldValue, $allowedTables, $conf['MM'] ?? '', $uid, $tableName, $conf); + $itemArray = $dbAnalysis->itemArray; + if (ExtensionManagementUtility::isLoaded('workspaces') + && $workspaceUid > 0 + && !empty($conf['MM'] ?? '') + // Catch type=group 'allowed' and type=select 'foreign_table' MM scenarios + && (!empty($conf['allowed'] ?? '') || !empty($conf['foreign_table'] ?? '')) + && empty($conf['MM_opposite_field'] ?? '') + && (int)($row['t3ver_wsid'] ?? 0) === 0 + ) { + // When dealing with local side mm relations in workspace 0, there may be workspace records on the foreign + // side, for instance when those got an additional category. See ManyToMany Modify addCategoryRelations test. + // In those cases, the full set of relations must be written to sys_refindex as workspace rows. + // But, if the relations in this workspace and live are identical, no sys_refindex workspace rows + // have to be added. + $dbAnalysis = GeneralUtility::makeInstance(RelationHandler::class); + $dbAnalysis->setWorkspaceId(0); + $dbAnalysis->start($fieldValue, $allowedTables, $conf['MM'], $uid, $tableName, $conf); + $itemArrayLive = $dbAnalysis->itemArray; + if ($itemArrayLive === $itemArray) { + $itemArray = []; + } + } + return $itemArray; + } + return []; + } + + /** + * Returns true if the TCA/columns field type is a DB reference field + * + * @param array $configuration Config array for TCA/columns field + * @return bool TRUE if DB reference field (group/db or select with foreign-table) + */ + private function isDbReferenceField(array $configuration): bool + { + return + $configuration['type'] === 'group' + || ( + in_array($configuration['type'], ['select', 'category', 'inline', 'file'], true) + && !empty($configuration['foreign_table']) + ); + } + + /** + * Returns true if the TCA/columns field may carry references. True for + * group, inline and friends, for flex, and if there is a 'softref' definition. + */ + private function isReferenceField(FieldTypeInterface $field): bool + { + return + $this->isDbReferenceField($field->getConfiguration()) + || $field->isType(TableColumnType::FLEX) + || $field->getSoftReferenceKeys() !== false + ; + } + + /** + * List of TCA columns that can have relations. Typically inline, group + * and friends, as well as flex fields and fields with 'softref' config. + * If empty, the table can not have relations. + * Uses a class cache to be quick for multiple calls on same table. + */ + private function getTableRelationFields(string $tableName): array + { + if (isset($this->tableRelationFieldCache[$tableName])) { + return $this->tableRelationFieldCache[$tableName]; + } + if (!$this->tcaSchemaFactory->has($tableName)) { + $this->tableRelationFieldCache[$tableName] = []; + return []; + } + $tableTcaFields = $this->tcaSchemaFactory->get($tableName)->getFields(); + $relationFields = []; + foreach ($tableTcaFields as $field) { + if ($this->isReferenceField($field)) { + $relationFields[] = $field->getName(); + } + } + $this->tableRelationFieldCache[$tableName] = $relationFields; + return $relationFields; + } + + /** + * Create list of non-deleted "active" workspace uids. This contains at least 0 "live workspace". + * + * @return int[] + */ + private function getListOfActiveWorkspaces(): array + { + if (!ExtensionManagementUtility::isLoaded('workspaces')) { + // If ext:workspaces is not loaded, "0" is the only valid one. + return [0]; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_workspace'); + // Workspaces can't be 'hidden', so we only use deleted restriction here. + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $result = $queryBuilder->select('uid')->from('sys_workspace')->orderBy('uid')->executeQuery(); + // "0", plus non-deleted workspaces are active + return array_merge([0 => 0], $result->fetchFirstColumn()); + } + + /** + * Helper method of updateIndex() to find number of rows in sys_refindex that + * relate to a non-existing or deleted workspace record, even if workspaces is + * not loaded at all, but has been loaded somewhere in the past and sys_refindex + * rows have been created. + */ + private function getNumberOfUnusedWorkspaceRowsInReferenceIndex(array $activeWorkspaces): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + $numberOfInvalidWorkspaceRecords = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->notIn('workspace', $queryBuilder->createNamedParameter($activeWorkspaces, Connection::PARAM_INT_ARRAY)) + ) + ->executeQuery() + ->fetchOne(); + return (int)$numberOfInvalidWorkspaceRecords; + } + + /** + * Delete sys_refindex rows of deleted / not existing workspace records, or all if ext:workspace is not loaded. + */ + private function removeUnusedWorkspaceRowsFromReferenceIndex(array $activeWorkspaces): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + return $queryBuilder + ->delete('sys_refindex') + ->where( + $queryBuilder->expr()->notIn('workspace', $queryBuilder->createNamedParameter($activeWorkspaces, Connection::PARAM_INT_ARRAY)) + ) + ->executeStatement(); + } + + /** + * When a TCA table with references has been removed, there may be old sys_refindex + * rows for it. The query finds the number of affected rows. + */ + private function getNumberOfUnusedTablesInReferenceIndex(array $tableNames): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + $numberOfRowsOfUnusedTables = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->notIn('tablename', $queryBuilder->createNamedParameter($tableNames, Connection::PARAM_STR_ARRAY)) + ) + ->executeQuery() + ->fetchOne(); + return (int)$numberOfRowsOfUnusedTables; + } + + /** + * When a TCA table with references has been removed, there may be old sys_refindex + * rows for it. The query deletes those. + */ + private function removeReferenceIndexDataFromUnusedDatabaseTables(array $tableNames): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + return $queryBuilder + ->delete('sys_refindex') + ->where( + $queryBuilder->expr()->notIn('tablename', $queryBuilder->createNamedParameter($tableNames, Connection::PARAM_STR_ARRAY)) + ) + ->executeStatement(); + } + + /** + * Checks if a given table should be excluded from ReferenceIndex + */ + private function shouldExcludeTableFromReferenceIndex(string $tableName): bool + { + if (isset($this->excludedTables[$tableName])) { + return $this->excludedTables[$tableName]; + } + $event = new IsTableExcludedFromReferenceIndexEvent($tableName); + $event = $this->eventDispatcher->dispatch($event); + $this->excludedTables[$tableName] = $event->isTableExcluded(); + return $this->excludedTables[$tableName]; + } +} diff --git a/Classes/Database/RelationHandler.php b/Classes/Database/RelationHandler.php new file mode 100644 index 0000000..ea17137 --- /dev/null +++ b/Classes/Database/RelationHandler.php @@ -0,0 +1,1598 @@ +> + */ + public array $itemArray = []; + + /** + * Array for NON-table elements + */ + public array $nonTableArray = []; + + public array $additionalWhere = []; + + /** + * Deleted-column is added to additionalWhere... if this is set... + */ + public bool $checkIfDeleted = true; + + /** + * Will contain the first table name in the $tablelist (for positive ids) + */ + protected string $firstTable = ''; + + /** + * If TRUE, uid_local and uid_foreign are switched, and the current table + * is inserted as tablename - this means you display a foreign relation "from the opposite side" + */ + protected bool $MM_is_foreign = false; + + /** + * Is empty by default; if MM_is_foreign is set and there is more than one table + * allowed (on the "local" side), then it contains the first table (as a fallback) + */ + protected string $MM_isMultiTableRelationship = ''; + + /** + * Current table => Only needed for reverse relations + */ + protected string $currentTable = ''; + + /** + * If a record should be undeleted + * (so do not use the $useDeleteClause on \TYPO3\CMS\Backend\Utility\BackendUtility) + */ + public bool $undeleteRecord = false; + + /** + * Array of fields value pairs that should match while SELECT. + */ + protected array $MM_match_fields = []; + + /** + * When "multiple" is true, the MM table has the "uid" column as primary key. With + * "multiple", items can be selected more than once, combination "uid_local" and "uid_foreign" + * (plus "tablenames" and "fieldname" for "multi-foreign" setups) are not unique and can not + * be primary-keyed. + * Query results and insert/update operations are influenced by this a bit. + */ + protected bool $multiple = false; + + /** + * Extra MM table where + */ + protected string $MM_table_where = ''; + + /** + * Usage of an MM field on the opposite relation. + */ + protected array $MM_oppositeUsage; + + protected ?ReferenceIndexUpdater $referenceIndexUpdater = null; + + protected bool $useLiveParentIds = true; + + protected bool $useLiveReferenceIds = true; + + protected ?int $workspaceId = null; + + protected bool $purged = false; + + /** + * This array will be filled by getFromDB(). + */ + public array $results = []; + + protected TcaSchemaFactory $tcaSchemaFactory; + + public function __construct() + { + $this->tcaSchemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + } + + /** + * Gets the current workspace id. + */ + protected function getWorkspaceId(): int + { + if ($this->workspaceId === null) { + $backendUser = $GLOBALS['BE_USER'] ?? null; + $this->workspaceId = $backendUser instanceof BackendUserAuthentication ? (int)($backendUser->workspace) : 0; + } + return $this->workspaceId; + } + + /** + * Sets the current workspace id. + * + * @param int $workspaceId + */ + public function setWorkspaceId($workspaceId): void + { + $this->workspaceId = (int)$workspaceId; + } + + /** + * Setter to carry the 'deferred' reference index updater registry around. + * + * @internal Used internally within DataHandler only + */ + public function setReferenceIndexUpdater(ReferenceIndexUpdater $updater): void + { + $this->referenceIndexUpdater = $updater; + } + + /** + * Whether item array has been purged in this instance. + */ + public function isPurged(): bool + { + return $this->purged; + } + + /** + * Use this method to find relations for a specific field / table of a record. + * Once the initializeForField() method was called, the resolved IDs can be used via + * ->getValueArray() et al. + */ + public function initializeForField( + string $tableName, + array|FieldTypeInterface $fieldConfiguration, + array|string|int|null $baseRecordOrUid, + string|int|float|array|null $currentValue = null + ): void { + if ($fieldConfiguration instanceof FieldTypeInterface) { + $fieldConfiguration = $fieldConfiguration->getConfiguration(); + } + + $manyToManyConfiguration = $fieldConfiguration['MM'] ?? ''; + $recordUid = $baseRecordOrUid; + if (is_array($baseRecordOrUid)) { + $recordUid = (int)($baseRecordOrUid['uid'] ?? 0); + // If not dealing with MM relations, use default live uid, not versioned uid for record relations + // MM relations point to their versioned ID + if (!$manyToManyConfiguration + && $this->tcaSchemaFactory->has($tableName) + && $this->tcaSchemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace) + && ($baseRecordOrUid['t3ver_oid'] ?? 0) > 0 + ) { + $recordUid = (int)$baseRecordOrUid['t3ver_oid']; + } + } + + $this->registerNonTableValues = (bool)($fieldConfiguration['allowNonIdValues'] ?? false); + $foreignTable = $fieldConfiguration['allowed'] ?? $fieldConfiguration['foreign_table'] ?? ''; + // type=file uses allowed AND foreign_table + if ($fieldConfiguration['type'] === 'file') { + $foreignTable = $fieldConfiguration['foreign_table']; + } + $this->start( + is_array($currentValue) ? implode(',', $currentValue) : (string)$currentValue, + $foreignTable, + $manyToManyConfiguration, + $recordUid, + $tableName, + $fieldConfiguration + ); + } + + /** + * Initialization of the class. + * + * @param string $itemlist List of group/select items + * @param string $tablelist Comma list of tables, first table takes priority if no table is set for an entry in the list. + * @param string $MMtable Name of a MM table. + * @param int|string $MMuid Local UID for MM lookup. May be a string for newly created elements. + * @param string $currentTable Current table name + * @param array $conf TCA configuration for current field + */ + public function start($itemlist, $tablelist, $MMtable = '', $MMuid = 0, $currentTable = '', $conf = []) + { + $conf = (array)$conf; + // SECTION: MM reverse relations + $this->MM_is_foreign = (bool)($conf['MM_opposite_field'] ?? false); + $this->MM_table_where = (string)($conf['MM_table_where'] ?? ''); + $this->multiple = (bool)($conf['multiple'] ?? false); + $this->MM_match_fields = (isset($conf['MM_match_fields']) && is_array($conf['MM_match_fields'])) ? $conf['MM_match_fields'] : []; + $this->currentTable = $currentTable; + if (!empty($conf['MM_oppositeUsage']) && is_array($conf['MM_oppositeUsage'])) { + $this->MM_oppositeUsage = $conf['MM_oppositeUsage']; + } + $mmOppositeTable = ''; + if ($this->MM_is_foreign) { + $allowedTableList = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table']; + // Normally, $conf['allowed'] can contain a list of tables, + // but as we are looking at an MM relation from the foreign side, + // it only makes sense to allow one table in $conf['allowed']. + [$mmOppositeTable] = GeneralUtility::trimExplode(',', $allowedTableList); + // Only add the current table name if there is more than one allowed + // field. We must be sure this has been done at least once before accessing + // the "columns" part of TCA for a table. + if ($this->tcaSchemaFactory->has($mmOppositeTable)) { + $oppositeSchema = $this->tcaSchemaFactory->get($mmOppositeTable); + $mmOppositeAllowed = $oppositeSchema->hasField($conf['MM_opposite_field']) ? ($oppositeSchema->getField($conf['MM_opposite_field'])->getConfiguration()['allowed'] ?? '') : ''; + if ($mmOppositeAllowed !== '') { + $mmOppositeAllowedTables = explode(',', $mmOppositeAllowed); + if ($mmOppositeAllowed === '*' || count($mmOppositeAllowedTables) > 1) { + $this->MM_isMultiTableRelationship = $mmOppositeAllowedTables[0]; + } + } + } + } + // SECTION: normal MM relations + // If the table list is "*" then all tables are used in the list: + if (trim($tablelist) === '*') { + $tables = $this->tcaSchemaFactory->all()->getNames(); + } else { + $tables = GeneralUtility::trimExplode(',', $tablelist, true); + } + // The tables are traversed and internal arrays are initialized: + foreach ($tables as $tableName) { + // @todo: Loop could be restricted in MM local when MM_oppositeUsage is used. + if (!$this->tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $this->tcaSchemaFactory->get($tableName); + $this->tableArray[$tableName] = []; + if ($this->checkIfDeleted && $schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + if (!isset($this->additionalWhere[$tableName])) { + $this->additionalWhere[$tableName] = ''; + } + // @todo: Omit ' AND ' and QueryHelper::stripLogicalOperatorPrefix() in consumers + $this->additionalWhere[$tableName] .= ' AND ' . $tableName . '.' . $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() . '=0'; + } + } + if ($this->tableArray !== []) { + reset($this->tableArray); + } else { + // No tables + return; + } + // Set first and second tables: + // Is the first table + $this->firstTable = (string)key($this->tableArray); + next($this->tableArray); + // Now, populate the internal itemArray and tableArray arrays: + // If MM, then call this function to do that: + if ($MMtable) { + if ($MMuid) { + $this->readMM($MMtable, $MMuid, $mmOppositeTable); + $this->purgeItemArray(); + } else { + // Revert to readList() for new records in order to load possible default values from $itemlist + $this->readList($itemlist, $conf); + $this->purgeItemArray(); + } + } elseif ($MMuid && ($conf['foreign_field'] ?? false)) { + // If not MM but foreign_field, the read the records by the foreign_field + $this->readForeignField((int)$MMuid, $conf); + } else { + // If not MM, then explode the itemlist by "," and traverse the list: + $this->readList($itemlist, $conf); + // Do automatic default_sortby, if any + if (isset($conf['foreign_default_sortby']) && $conf['foreign_default_sortby']) { + $this->sortList($conf['foreign_default_sortby']); + } + } + } + + /** + * @param bool $useLiveParentIds + */ + public function setUseLiveParentIds($useLiveParentIds) + { + $this->useLiveParentIds = (bool)$useLiveParentIds; + } + + /** + * @param bool $useLiveReferenceIds + */ + public function setUseLiveReferenceIds($useLiveReferenceIds) + { + $this->useLiveReferenceIds = (bool)$useLiveReferenceIds; + } + + /** + * Explodes the item list and stores the parts in the internal arrays itemArray and tableArray from MM records. + * + * @param string $itemlist Item list + * @param array $configuration Parent field configuration + */ + protected function readList($itemlist, array $configuration) + { + if (trim((string)$itemlist) !== '') { + // Changed to trimExplode 31/3 04; HMENU special type "list" didn't work + // if there were spaces in the list... I suppose this is better overall... + $tempItemArray = GeneralUtility::trimExplode(',', $itemlist); + // If the second table is set and the ID number is less than zero (later) + // then the record is regarded to come from the second table... + $secondTable = (string)(key($this->tableArray) ?? ''); + foreach ($tempItemArray as $key => $val) { + // Will be set to "true" if the entry was a real table/id + $isSet = false; + // Extract table name and id. This is in the formula [tablename]_[id] + // where table name MIGHT contain "_", hence the reversion of the string! + $val = strrev($val); + $parts = explode('_', $val, 2); + $theID = strrev($parts[0]); + // Check that the id IS an integer: + if (MathUtility::canBeInterpretedAsInteger($theID)) { + // Get the table name: If a part of the exploded string, use that. + // Otherwise if the id number is LESS than zero, use the second table, otherwise the first table + $theTable = trim($parts[1] ?? '') + ? strrev(trim($parts[1] ?? '')) + : ($secondTable && $theID < 0 ? $secondTable : $this->firstTable); + // If the ID is not blank and the table name is among the names in the inputted tableList + if ((string)$theID != '' && $theID && $theTable && isset($this->tableArray[$theTable])) { + // Get ID as the right value: + $theID = $secondTable ? abs((int)$theID) : (int)$theID; + // Register ID/table name in internal arrays: + $this->itemArray[$key]['id'] = $theID; + $this->itemArray[$key]['table'] = $theTable; + $this->tableArray[$theTable][] = $theID; + // Set update-flag + $isSet = true; + } + } + // If it turns out that the value from the list was NOT a valid reference to a table-record, + // then we might still set it as a NO_TABLE value: + if (!$isSet && $this->registerNonTableValues) { + $this->itemArray[$key]['id'] = $tempItemArray[$key]; + $this->itemArray[$key]['table'] = '_NO_TABLE'; + $this->nonTableArray[] = $tempItemArray[$key]; + } + } + + // Skip if not dealing with IRRE in a CSV list on a workspace + if (!isset($configuration['type']) || ($configuration['type'] !== 'inline' && $configuration['type'] !== 'file') + || empty($configuration['foreign_table']) || !empty($configuration['foreign_field']) + || !empty($configuration['MM']) || count($this->tableArray) !== 1 || empty($this->tableArray[$configuration['foreign_table']]) + || $this->getWorkspaceId() === 0 || (!$this->tcaSchemaFactory->has($configuration['foreign_table']) || !$this->tcaSchemaFactory->get($configuration['foreign_table'])->hasCapability(TcaSchemaCapability::Workspace)) + ) { + return; + } + + // Fetch live record data + if ($this->useLiveReferenceIds) { + foreach ($this->itemArray as &$item) { + $item['id'] = $this->getLiveDefaultId($item['table'], $item['id']); + } + } else { + // Directly overlay workspace data + $this->itemArray = []; + $foreignTable = $configuration['foreign_table']; + $ids = $this->getResolver($foreignTable, $this->tableArray[$foreignTable])->get(); + foreach ($ids as $id) { + $this->itemArray[] = [ + 'id' => $id, + 'table' => $foreignTable, + ]; + } + } + } + } + + /** + * Does a sorting on $this->itemArray depending on a default sortby field. + * This is only used for automatic sorting of comma separated lists. + * This function is only relevant for data that is stored in comma separated lists! + * + * @param string $sortby The default_sortby field/command (e.g. 'price DESC') + */ + protected function sortList($sortby) + { + // Sort directly without fetching additional data + if ($sortby === 'uid') { + usort( + $this->itemArray, + static function ($a, $b) { + return $a['id'] < $b['id'] ? -1 : 1; + } + ); + } elseif (count($this->tableArray) === 1) { + reset($this->tableArray); + $table = (string)key($this->tableArray); + $connection = $this->getConnectionForTableName($table); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + + foreach (array_chunk(current($this->tableArray), $maxBindParameters - 10, true) as $chunk) { + if (empty($chunk)) { + continue; + } + $this->itemArray = []; + $this->tableArray = []; + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('uid') + ->from($table) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ) + ); + foreach (QueryHelper::parseOrderBy((string)$sortby) as $orderPair) { + [$fieldName, $order] = $orderPair; + $queryBuilder->addOrderBy($fieldName, $order); + } + $statement = $queryBuilder->executeQuery(); + while ($row = $statement->fetchAssociative()) { + $this->itemArray[] = ['id' => $row['uid'], 'table' => $table]; + $this->tableArray[$table][] = $row['uid']; + } + } + } + } + + /** + * Reads the record tablename/id into the internal arrays itemArray and tableArray from MM records. + * + * @todo: The source record is not checked for correct workspace. Say there is a category 5 in + * workspace 1. setWorkspace(0) is called, after that readMM('sys_category_record_mm', 5 ...). + * readMM will *still* return the list of records connected to this workspace 1 item, + * even though workspace 0 has been set. + * + * @param string $tableName MM Tablename + * @param int|string $uid Local UID + * @param string $mmOppositeTable Opposite table name + */ + protected function readMM($tableName, $uid, $mmOppositeTable) + { + $theTable = null; + $queryBuilder = $this->getConnectionForTableName($tableName)->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*')->from($tableName); + // Default + $uidLocal_field = 'uid_local'; + $uidForeign_field = 'uid_foreign'; + $sorting_field = 'sorting'; + $sortingForeign_field = 'sorting_foreign'; + if ($this->MM_is_foreign) { + // In case of a reverse relation + $uidLocal_field = 'uid_foreign'; + $uidForeign_field = 'uid_local'; + $sorting_field = 'sorting_foreign'; + $sortingForeign_field = 'sorting'; + if ($this->MM_isMultiTableRelationship) { + // Be backwards compatible! When allowing more than one table after + // having previously allowed only one table, this case applies. + if ($this->currentTable == $this->MM_isMultiTableRelationship) { + $expression = $queryBuilder->expr()->or( + $queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($this->currentTable) + ), + $queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter('') + ) + ); + } else { + $expression = $queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($this->currentTable) + ); + } + $queryBuilder->andWhere($expression); + } + $theTable = $mmOppositeTable; + } + if ($this->MM_table_where) { + $queryBuilder->andWhere( + QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$uid, QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $this->MM_table_where))) + ); + } + foreach ($this->MM_match_fields as $field => $value) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value)) + ); + } + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + $uidLocal_field, + $queryBuilder->createNamedParameter((int)$uid, Connection::PARAM_INT) + ) + ); + $queryBuilder->orderBy($sorting_field); + $queryBuilder->addOrderBy($uidForeign_field); + // @todo: It would be more safe adding an order-by fieldname if field exists (MM_oppositeUsage set) to avoid + // arbitrary sorting if 2 mm rows to 2 different fields have same sorting and sorting_foreign values. + $statement = $queryBuilder->executeQuery(); + $itemArray = []; + while ($row = $statement->fetchAssociative()) { + // Default + if (!$this->MM_is_foreign) { + // If tablenames columns exists and contain a name, then this value is the table, else it's the firstTable... + $theTable = !empty($row['tablenames']) ? $row['tablenames'] : $this->firstTable; + } + if (($row[$uidForeign_field] || $theTable === 'pages') && $theTable && isset($this->tableArray[$theTable])) { + $item = [ + 'id' => $row[$uidForeign_field], + 'table' => $theTable, + ]; + if (!empty($row['fieldname'])) { + $item['fieldname'] = $row['fieldname']; + } + if (isset($row[$sorting_field])) { + $item[$sorting_field] = $row[$sorting_field]; + } + if (isset($row[$sortingForeign_field])) { + $item[$sortingForeign_field] = $row[$sortingForeign_field]; + } + $itemArray[] = $item; + $this->tableArray[$theTable][] = $row[$uidForeign_field]; + } + } + $this->itemArray = $itemArray; + } + + /** + * Writes the internal itemArray to MM table: + * + * @param string $MM_tableName MM table name + * @param int $uid Local UID + * @param bool $prependTableName If set, then table names will always be written. + */ + public function writeMM($MM_tableName, $uid, $prependTableName = false) + { + $connection = $this->getConnectionForTableName($MM_tableName); + $expressionBuilder = $connection->createQueryBuilder()->expr(); + + // In case of a reverse relation + if ($this->MM_is_foreign) { + $uidLocal_field = 'uid_foreign'; + $uidForeign_field = 'uid_local'; + $sorting_field = 'sorting_foreign'; + } else { + // default + $uidLocal_field = 'uid_local'; + $uidForeign_field = 'uid_foreign'; + $sorting_field = 'sorting'; + } + // If there are tables... + $tableC = count($this->tableArray); + if ($tableC) { + // Boolean: does the field "tablename" need to be filled? + $prep = $tableC > 1 || $prependTableName || $this->MM_isMultiTableRelationship; + $c = 0; + $additionalWhere_tablenames = ''; + if ($this->MM_is_foreign && $prep) { + $additionalWhere_tablenames = $expressionBuilder->eq( + 'tablenames', + $expressionBuilder->literal($this->currentTable) + ); + } + $additionalWhere = $expressionBuilder->and(); + // Add WHERE clause if configured + if ($this->MM_table_where) { + $additionalWhere = $additionalWhere->with( + QueryHelper::stripLogicalOperatorPrefix( + str_replace('###THIS_UID###', (string)$uid, $this->MM_table_where) + ) + ); + } + // Select, update or delete only those relations that match the configured fields + foreach ($this->MM_match_fields as $field => $value) { + $additionalWhere = $additionalWhere->with($expressionBuilder->eq($field, $expressionBuilder->literal((string)$value))); + } + + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select($uidForeign_field) + ->from($MM_tableName) + ->where($queryBuilder->expr()->eq( + $uidLocal_field, + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + )) + ->orderBy($sorting_field); + + if ($prep) { + $queryBuilder->addSelect('tablenames'); + } + if ($this->multiple) { + $queryBuilder->addSelect('uid'); + } + if ($additionalWhere_tablenames) { + $queryBuilder->andWhere($additionalWhere_tablenames); + } + if ($additionalWhere->count()) { + $queryBuilder->andWhere($additionalWhere); + } + + $result = $queryBuilder->executeQuery(); + $oldMMs = []; + // This array is similar to $oldMMs but also holds the uid of the MM-records if 'multiple' is true. + // If the UID is present it will be used to update sorting and delete MM-records. + // $oldMMs is still needed for the in_array() search used to look if an item from $this->itemArray is in $oldMMs. + $oldMMs_inclUid = []; + while ($row = $result->fetchAssociative()) { + if (!$this->MM_is_foreign && $prep) { + $oldMMs[] = [$row['tablenames'], $row[$uidForeign_field]]; + } else { + $oldMMs[] = $row[$uidForeign_field]; + } + $oldMMs_inclUid[] = (int)($row['uid'] ?? 0); + } + // For each item, insert it: + foreach ($this->itemArray as $val) { + $c++; + if ($prep || $val['table'] === '_NO_TABLE') { + // Insert current table if needed + if ($this->MM_is_foreign) { + $tablename = $this->currentTable; + } else { + $tablename = $val['table']; + } + } else { + $tablename = ''; + } + if (!$this->MM_is_foreign && $prep) { + $item = [$val['table'], $val['id']]; + } else { + $item = $val['id']; + } + if (in_array($item, $oldMMs)) { + $oldMMs_index = array_search($item, $oldMMs); + // In principle, selecting on the UID is all we need to do + // if a uid field is available since that is unique! + // But as long as it "doesn't hurt" we just add it to the where clause. It should all match up. + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->update($MM_tableName) + ->set($sorting_field, $c) + ->where( + $expressionBuilder->eq( + $uidLocal_field, + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $expressionBuilder->eq( + $uidForeign_field, + $queryBuilder->createNamedParameter($val['id'], Connection::PARAM_INT) + ) + ); + + if ($additionalWhere->count()) { + $queryBuilder->andWhere($additionalWhere); + } + if ($this->multiple) { + $queryBuilder->andWhere( + $expressionBuilder->eq( + 'uid', + $queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMMs_index], Connection::PARAM_INT) + ) + ); + } + if ($tablename) { + $queryBuilder->andWhere( + $expressionBuilder->eq( + 'tablenames', + $queryBuilder->createNamedParameter($tablename) + ) + ); + } + + $queryBuilder->executeStatement(); + // Remove the item from the $oldMMs array so after this + // foreach loop only the ones that need to be deleted are in there. + unset($oldMMs[$oldMMs_index]); + // Remove the item from the $oldMMs_inclUid array so after this + // foreach loop only the ones that need to be deleted are in there. + unset($oldMMs_inclUid[$oldMMs_index]); + } else { + $insertFields = $this->MM_match_fields; + $insertFields[$uidLocal_field] = $uid; + $insertFields[$uidForeign_field] = $val['id']; + $insertFields[$sorting_field] = $c; + if ($tablename) { + $insertFields['tablenames'] = $tablename; + $insertFields = $this->completeOppositeUsageValues($tablename, $insertFields); + } + $connection->insert($MM_tableName, $insertFields); + if ($this->MM_is_foreign) { + $this->referenceIndexUpdater?->registerForUpdate($val['table'], (int)$val['id'], $this->getWorkspaceId()); + } + } + } + // Delete all not-used relations: + if ($oldMMs !== []) { + $queryBuilder = $connection->createQueryBuilder(); + $removeClauses = $queryBuilder->expr()->or(); + foreach ($oldMMs as $oldMM_key => $mmItem) { + // If UID field is present, of course we need only use that for deleting. + if ($this->multiple) { + $removeClauses = $removeClauses->with($queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($oldMMs_inclUid[$oldMM_key], Connection::PARAM_INT) + )); + } else { + if (is_array($mmItem)) { + $removeClauses = $removeClauses->with( + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($mmItem[0]) + ), + $queryBuilder->expr()->eq( + $uidForeign_field, + $queryBuilder->createNamedParameter($mmItem[1], Connection::PARAM_INT) + ) + ) + ); + } else { + $removeClauses = $removeClauses->with( + $queryBuilder->expr()->eq( + $uidForeign_field, + $queryBuilder->createNamedParameter($mmItem, Connection::PARAM_INT) + ) + ); + } + } + if ($this->MM_is_foreign) { + if (is_array($mmItem)) { + $this->referenceIndexUpdater?->registerForUpdate((string)$mmItem[0], (int)$mmItem[1], $this->getWorkspaceId()); + } else { + $this->referenceIndexUpdater?->registerForUpdate($this->firstTable, (int)$mmItem, $this->getWorkspaceId()); + } + } + } + + $queryBuilder->delete($MM_tableName) + ->where( + $queryBuilder->expr()->eq( + $uidLocal_field, + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $removeClauses + ); + + if ($additionalWhere_tablenames) { + $queryBuilder->andWhere($additionalWhere_tablenames); + } + if ($additionalWhere->count()) { + $queryBuilder->andWhere($additionalWhere); + } + + $queryBuilder->executeStatement(); + } + // Update ref index; In DataHandler it is not certain that this will happen because + // if only the MM field is changed the record itself is not updated and so the ref-index is not either. + // This could also have been fixed in updateDB in DataHandler, however I decided to do it here ... + $this->referenceIndexUpdater?->registerForUpdate($this->currentTable, (int)$uid, $this->getWorkspaceId()); + } + } + + /** + * Reads items from a foreign_table, that has a foreign_field (uid of the parent record) and + * stores the parts in the internal array itemArray and tableArray. + * + * @param int $uid The uid of the parent record (this value is also on the foreign_table in the foreign_field) + * @param array $conf TCA configuration for current field + */ + protected function readForeignField(int $uid, array $conf): void + { + if ($this->useLiveParentIds) { + $uid = $this->getLiveDefaultId($this->currentTable, $uid); + } + + if ($uid === 0) { + // Skip further processing if uid does not point to a valid parent record + return; + } + + $foreign_table = $conf['foreign_table']; + $foreign_table_field = $conf['foreign_table_field'] ?? ''; + $useDeleteClause = !$this->undeleteRecord; + $foreign_match_fields = is_array($conf['foreign_match_fields'] ?? false) ? $conf['foreign_match_fields'] : []; + $queryBuilder = $this->getConnectionForTableName($foreign_table)->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('uid')->from($foreign_table); + // Use the deleteClause (e.g. "deleted=0") on this table + if ($useDeleteClause) { + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + } + if (!$this->tcaSchemaFactory->has($foreign_table)) { + return; + } + $schema = $this->tcaSchemaFactory->get($foreign_table); + + // Search for $uid in foreign_field, and if we have symmetric relations, do this also on symmetric_field + if (!empty($conf['symmetric_field'])) { + $queryBuilder->where( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq( + $conf['foreign_field'], + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $conf['symmetric_field'], + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ); + } else { + $queryBuilder->where($queryBuilder->expr()->eq( + $conf['foreign_field'], + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + )); + } + // If it's requested to look for the parent uid AND the parent table, add a where clause + if ($foreign_table_field && $this->currentTable) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + $foreign_table_field, + $queryBuilder->createNamedParameter($this->currentTable) + ) + ); + } + // Add additional where clause if foreign_match_fields are defined + foreach ($foreign_match_fields as $field => $value) { + $queryBuilder->andWhere( + $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($value)) + ); + } + // Select children from the live(!) workspace only + if ($schema->isWorkspaceAware()) { + $queryBuilder->getRestrictions()->add( + GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getWorkspaceId()) + ); + } + + // Set sorting criteria + $sortby = ''; + if (!empty($conf['foreign_sortby'])) { + // Specific manual sortby for data handled by this field + if (!empty($conf['symmetric_sortby']) && !empty($conf['symmetric_field'])) { + // Sorting depends on, from which side of the relation we're looking at it + // This requires bypassing automatic quoting and setting of the default sort direction + // @todo Doctrine - generalize to standard SQL to guarantee database independence + $queryBuilder->getConcreteQueryBuilder()->orderBy( + 'CASE + WHEN ' . $queryBuilder->expr()->eq($conf['foreign_field'], $uid) . ' + THEN ' . $queryBuilder->quoteIdentifier($conf['foreign_sortby']) . ' + ELSE ' . $queryBuilder->quoteIdentifier($conf['symmetric_sortby']) . ' + END' + ); + } else { + // Regular single-side behaviour + $sortby = $conf['foreign_sortby']; + } + } elseif (!empty($conf['foreign_default_sortby'])) { + // Specific default sortby for data handled by this field + $sortby = $conf['foreign_default_sortby']; + } elseif ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + // Manual sortby for all table records + $sortby = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) { + // Default sortby for all table records + $sortby = (string)$schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue(); + } + if (!empty($sortby)) { + foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) { + [$fieldName, $sorting] = $orderPair; + $queryBuilder->addOrderBy($fieldName, $sorting); + } + } + + $rows = []; + $result = $queryBuilder->executeQuery(); + while ($row = $result->fetchAssociative()) { + $rows[(int)$row['uid']] = $row; + } + if (!empty($rows)) { + $sortby = $queryBuilder->getOrderBy(); + $ids = $this->getResolver($foreign_table, array_keys($rows), $sortby)->get(); + $itemArray = []; + foreach ($ids as $id) { + $item = [ + 'id' => $id, + 'table' => $foreign_table, + ]; + $itemArray[] = $item; + $this->tableArray[$foreign_table][] = $id; + } + $this->itemArray = $itemArray; + } + } + + /** + * Write the sorting values to a foreign_table, that has a foreign_field (uid of the parent record) + * + * @param array $conf TCA configuration for current field + * @param int $parentUid The uid of the parent record + * @param int $updateToUid If this is larger than zero it will be used as foreign UID instead of the given $parentUid (on Copy) + */ + public function writeForeignField(array $conf, $parentUid, $updateToUid = 0): void + { + if ($this->useLiveParentIds) { + $parentUid = $this->getLiveDefaultId($this->currentTable, $parentUid); + if (!empty($updateToUid)) { + $updateToUid = $this->getLiveDefaultId($this->currentTable, $updateToUid); + } + } + + // Ensure all values are set. + $conf += [ + 'foreign_table' => '', + 'foreign_field' => '', + 'symmetric_field' => '', + 'foreign_table_field' => '', + 'foreign_match_fields' => [], + ]; + + $c = 0; + $foreign_table = $conf['foreign_table']; + $foreign_field = $conf['foreign_field']; + $symmetric_field = $conf['symmetric_field'] ?? ''; + $foreign_table_field = $conf['foreign_table_field']; + $foreign_match_fields = $conf['foreign_match_fields']; + if (!$this->tcaSchemaFactory->has($foreign_table)) { + return; + } + $schema = $this->tcaSchemaFactory->get($foreign_table); + // If there are table items and we have a proper $parentUid + if (MathUtility::canBeInterpretedAsInteger($parentUid) && !empty($this->tableArray)) { + // If updateToUid is not a positive integer, set it to '0', so it will be ignored + if (!(MathUtility::canBeInterpretedAsInteger($updateToUid) && $updateToUid > 0)) { + $updateToUid = 0; + } + $fields = ['uid', 'pid', $foreign_field]; + // Consider the symmetric field if defined: + if ($symmetric_field) { + $fields[] = $symmetric_field; + } + // Consider workspaces if defined and currently used: + if ($schema->isWorkspaceAware()) { + $fields = [...$fields, 't3ver_wsid', 't3ver_state', 't3ver_oid']; + } + // Update all items + foreach ($this->itemArray as $val) { + $uid = $val['id']; + $table = $val['table']; + $row = []; + // Fetch the current (not overwritten) relation record if we should handle symmetric relations + if ($symmetric_field || $schema->isWorkspaceAware()) { + $row = BackendUtility::getRecord($table, $uid, $fields, '', true); + if (empty($row)) { + continue; + } + } + $isOnSymmetricSide = false; + if ($symmetric_field) { + $isOnSymmetricSide = $this->isOnSymmetricSide((string)$parentUid, $conf, $row); + } + $updateValues = $foreign_match_fields; + // No update to the uid is requested, so this is the normal behaviour + // just update the fields and care about sorting + if (!$updateToUid) { + // Always add the pointer to the parent uid + if ($isOnSymmetricSide) { + $updateValues[$symmetric_field] = $parentUid; + } else { + $updateValues[$foreign_field] = $parentUid; + } + // If it is configured in TCA also to store the parent table in the child record, just do it + if ($foreign_table_field && $this->currentTable) { + $updateValues[$foreign_table_field] = $this->currentTable; + } + // Get the correct sorting field + // Specific manual sortby for data handled by this field + $sortby = ''; + if ($conf['foreign_sortby'] ?? false) { + $sortby = $conf['foreign_sortby']; + } elseif ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + // manual sortby for all table records + $sortby = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } + // Apply sorting on the symmetric side + // (it depends on who created the relation, so what uid is in the symmetric_field): + if ($isOnSymmetricSide && isset($conf['symmetric_sortby']) && $conf['symmetric_sortby']) { + $sortby = $conf['symmetric_sortby']; + } else { + $tempSortBy = []; + foreach (QueryHelper::parseOrderBy($sortby) as $orderPair) { + [$fieldName, $order] = $orderPair; + if ($order !== null) { + $tempSortBy[] = implode(' ', $orderPair); + } else { + $tempSortBy[] = $fieldName; + } + } + $sortby = implode(',', $tempSortBy); + } + if ($sortby) { + $updateValues[$sortby] = ++$c; + } + } else { + if ($isOnSymmetricSide) { + $updateValues[$symmetric_field] = $updateToUid; + } else { + $updateValues[$foreign_field] = $updateToUid; + } + } + // Update accordant fields in the database: + if (!empty($updateValues)) { + // Update tstamp if any foreign field value has changed + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $updateValues[$schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()] = $GLOBALS['EXEC_TIME']; + } + $this->getConnectionForTableName($table) + ->update( + $table, + $updateValues, + ['uid' => (int)$uid] + ); + $this->referenceIndexUpdater?->registerForUpdate($table, (int)$uid, $this->getWorkspaceId()); + } + } + } + } + + /** + * After initialization, you can extract an array of the elements from the object. Use this function for that. + * + * @param bool $prependTableName If set, then table names will ALWAYS be prepended (unless its a _NO_TABLE value) + * @return array A numeric array. + */ + public function getValueArray(bool $prependTableName = false): array + { + $valueArray = []; + $tableC = count($this->tableArray); + // If there are tables in the table array: + if ($tableC) { + // If there are more than ONE table in the table array, then always prepend table names: + $prep = $tableC > 1 || $prependTableName; + // Traverse the array of items: + foreach ($this->itemArray as $val) { + $valueArray[] = ($prep && $val['table'] !== '_NO_TABLE' ? $val['table'] . '_' : '') . $val['id']; + } + } + // Return the array + return $valueArray; + } + + /** + * Reads all records from internal tableArray into the internal ->results array + * where keys are table names and for each table, records are stored with uids as their keys. + * + * @return array + */ + public function getFromDB(): array + { + // Traverses the tables listed: + foreach ($this->tableArray as $table => $ids) { + if (is_array($ids) && !empty($ids)) { + $connection = $this->getConnectionForTableName($table); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + + foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*') + ->from($table) + ->where($queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + )); + if ($this->additionalWhere[$table] ?? false) { + $queryBuilder->andWhere( + QueryHelper::stripLogicalOperatorPrefix($this->additionalWhere[$table]) + ); + } + $statement = $queryBuilder->executeQuery(); + while ($row = $statement->fetchAssociative()) { + $this->results[$table][$row['uid']] = $row; + } + } + } + } + return $this->results; + } + + /** + * This method is typically called after getFromDB(). + * $this->results holds a list of resolved and valid relations, + * $this->itemArray hold a list of "selected" relations from the incoming selection array. + * The difference is that "itemArray" may hold a single table/uid combination multiple times, + * for instance in a type=group relation having multiple=true, while "results" hold each + * resolved relation only once. + * The method creates a sanitized "itemArray" from resolved "results" list, normalized + * the return array to always contain both table name and uid, and keep incoming + * "itemArray" sort order and keeps "multiple" selections. + * + * In addition, the item array contains the full record to be used later-on and save database queries. + * This method keeps the ordering intact. + */ + public function getResolvedItemArray(): array + { + $itemArray = []; + foreach ($this->itemArray as $item) { + if (isset($this->results[$item['table']][$item['id']])) { + $itemArray[] = [ + 'table' => $item['table'], + 'uid' => $item['id'], + 'record' => $this->results[$item['table']][$item['id']], + ]; + } + } + return $itemArray; + } + + /** + * Counts the items in $this->itemArray and puts this value in an array by default. + * + * @param bool $returnAsArray Whether to put the count value in an array + * @return mixed The plain count as integer or the same inside an array + */ + public function countItems(bool $returnAsArray = true) + { + $count = count($this->itemArray); + if ($returnAsArray) { + $count = [$count]; + } + return $count; + } + + /** + * Converts elements in the local item array to use version ids instead of + * live ids, if possible. The most common use case is, to call that prior + * to processing with MM relations in a workspace context. For tha special + * case, ids on both side of the MM relation must use version ids if + * available. + * + * @return bool Whether items have been converted + */ + public function convertItemArray(): bool + { + // conversion is only required in a workspace context + // (the case that version ids are submitted in a live context are rare) + if ($this->getWorkspaceId() === 0) { + return false; + } + + $hasBeenConverted = false; + foreach ($this->tableArray as $tableName => $ids) { + if (empty($ids)) { + continue; + } + if (!$this->tcaSchemaFactory->has($tableName)) { + continue; + } + $schema = $this->tcaSchemaFactory->get($tableName); + if ($schema->isWorkspaceAware()) { + continue; + } + + // convert live ids to version ids if available + $convertedIds = $this->getResolver($tableName, $ids) + ->setKeepDeletePlaceholder(false) + ->setKeepMovePlaceholder(false) + ->processVersionOverlays($ids); + foreach ($this->itemArray as $index => $item) { + if ($item['table'] !== $tableName) { + continue; + } + $currentItemId = $item['id']; + if ( + !isset($convertedIds[$currentItemId]) + || $currentItemId === $convertedIds[$currentItemId] + ) { + continue; + } + // adjust local item to use resolved version id + $this->itemArray[$index]['id'] = $convertedIds[$currentItemId]; + $hasBeenConverted = true; + } + // update per-table reference for ids + if ($hasBeenConverted) { + $this->tableArray[$tableName] = array_values($convertedIds); + } + } + + return $hasBeenConverted; + } + + /** + * @todo: It *should* be possible to drop all three 'purge' methods by using + * a clever join within readMM - that sounds doable now with pid -1 and + * ws-pair records being gone since v11. It would resolve this indirect + * callback logic and would reduce some queries. The (workspace) mm tests + * should be complete enough now to verify if a change like that would do. + * + * @return bool Whether items have been purged + * @internal + */ + public function purgeItemArray(?int $workspaceId = null): bool + { + if ($workspaceId === null) { + $workspaceId = $this->getWorkspaceId(); + } + + // Ensure, only live relations are in the items Array + if ($workspaceId === 0) { + $purgeCallback = 'purgeVersionedIds'; + } else { + // Otherwise, ensure that live relations are purged if version exists + $purgeCallback = 'purgeLiveVersionedIds'; + } + + $itemArrayHasBeenPurged = $this->purgeItemArrayHandler($purgeCallback, $workspaceId); + $this->purged = ($this->purged || $itemArrayHasBeenPurged); + return $itemArrayHasBeenPurged; + } + + /** + * Removes items having a delete placeholder from $this->itemArray + * + * @return bool Whether items have been purged + */ + public function processDeletePlaceholder(): bool + { + if (!$this->useLiveReferenceIds || $this->getWorkspaceId() === 0) { + return false; + } + + return $this->purgeItemArrayHandler('purgeDeletePlaceholder', $this->getWorkspaceId()); + } + + /** + * Handles a purge callback on $this->itemArray + * + * @return bool Whether items have been purged + */ + protected function purgeItemArrayHandler(string $purgeCallback, int $workspaceId): bool + { + $itemArrayHasBeenPurged = false; + + foreach ($this->tableArray as $itemTableName => $itemIds) { + if (empty($itemIds) + || !$this->tcaSchemaFactory->has($itemTableName) + || !$this->tcaSchemaFactory->get($itemTableName)->hasCapability(TcaSchemaCapability::Workspace) + ) { + continue; + } + + $purgedItemIds = []; + $callable = [$this, $purgeCallback]; + if (is_callable($callable)) { + $purgedItemIds = $callable($itemTableName, $itemIds, $workspaceId); + } + + $removedItemIds = array_diff($itemIds, $purgedItemIds); + foreach ($removedItemIds as $removedItemId) { + $this->removeFromItemArray($itemTableName, $removedItemId); + } + $this->tableArray[$itemTableName] = $purgedItemIds; + if (!empty($removedItemIds)) { + $itemArrayHasBeenPurged = true; + } + } + + return $itemArrayHasBeenPurged; + } + + /** + * Purges ids that are versioned. + */ + protected function purgeVersionedIds(string $tableName, array $ids): array + { + $ids = $this->sanitizeIds($ids); + $ids = array_combine($ids, $ids); + $connection = $this->getConnectionForTableName($tableName); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + + foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state') + ->from($tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->neq( + 't3ver_wsid', + $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) + ) + ) + ->orderBy('t3ver_state', 'DESC') + ->executeQuery(); + + while ($version = $result->fetchAssociative()) { + $versionId = $version['uid']; + if (isset($ids[$versionId])) { + unset($ids[$versionId]); + } + } + } + + return array_values($ids); + } + + /** + * Clean up the list of incoming MM connection candidates. + * readMM() results in a uid list that contains: + * * uids of all live MM connections + * * uids of workspace connections of all workspaces + * The method filters this candidate list: + * * Remove candidates of different workspaces + * * Remove live candidates that do have a workspace overlay + * + * @todo: It should be possible to merge this method into main query of readMM() + * directly to avoid the chunked query. Note purgeVersionedIds() does a + * similar thing when requesting live, to throw away workspace connections. + * @todo: It would be possible to filter delete placeholder rows here as well, + * but this needs bigger refactoring of the class, since purgeDeletePlaceholder() + * is public and only called on demand. + */ + protected function purgeLiveVersionedIds(string $tableName, array $candidateUidList, int $targetWorkspaceUid): array + { + $candidateUidList = $this->sanitizeIds($candidateUidList); + $candidateUidList = array_combine($candidateUidList, $candidateUidList); + $connection = $this->getConnectionForTableName($tableName); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + + foreach (array_chunk($candidateUidList, $maxBindParameters - 10, true) as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state', 't3ver_wsid') + ->from($tableName) + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->neq( + 't3ver_wsid', + $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) + ) + ) + ->orderBy('t3ver_state', 'DESC') + ->executeQuery(); + while ($workspaceRow = $result->fetchAssociative()) { + $rowVersionUid = (int)$workspaceRow['uid']; + $rowLiveUid = (int)$workspaceRow['t3ver_oid']; + $rowWorkspaceUid = (int)$workspaceRow['t3ver_wsid']; + if ($rowWorkspaceUid !== $targetWorkspaceUid) { + // If this row t3ver_wsid does not match requested workspace, + // the row is a row of a different workspace and has to be + // removed from result set. + unset($candidateUidList[$rowVersionUid]); + continue; + } + if (isset($candidateUidList[$rowLiveUid]) && isset($candidateUidList[$rowVersionUid])) { + // This is a workspace row that overlays a live candidate, + // so live needs to be removed from the candidate list. + unset($candidateUidList[$rowLiveUid]); + } + } + } + + return array_values($candidateUidList); + } + + /** + * Purges ids that have a delete placeholder + */ + protected function purgeDeletePlaceholder(string $tableName, array $ids): array + { + $ids = $this->sanitizeIds($ids); + $ids = array_combine($ids, $ids) ?: []; + $connection = $this->getConnectionForTableName($tableName); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + + foreach (array_chunk($ids, $maxBindParameters - 10, true) as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder->getRestrictions()->removeAll(); + $result = $queryBuilder->select('uid', 't3ver_oid', 't3ver_state') + ->from($tableName) + ->where( + $queryBuilder->expr()->in( + 't3ver_oid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter( + $this->getWorkspaceId(), + Connection::PARAM_INT + ) + ), + $queryBuilder->expr()->eq( + 't3ver_state', + $queryBuilder->createNamedParameter( + VersionState::DELETE_PLACEHOLDER->value, + Connection::PARAM_INT + ) + ) + ) + ->executeQuery(); + + while ($version = $result->fetchAssociative()) { + $liveId = $version['t3ver_oid']; + if (isset($ids[$liveId])) { + unset($ids[$liveId]); + } + } + } + + return array_values($ids); + } + + protected function removeFromItemArray(string $tableName, $id): bool + { + foreach ($this->itemArray as $index => $item) { + if ($item['table'] === $tableName && (string)$item['id'] === (string)$id) { + unset($this->itemArray[$index]); + return true; + } + } + return false; + } + + /** + * Checks, if we're looking from the "other" side, the symmetric side, to a symmetric relation. + * + * @param string $parentUid The uid of the parent record + * @param array $parentConf The TCA configuration of the parent field embedding the child records + * @param array $childRec The record row of the child record + * @return bool Returns TRUE if looking from the symmetric ("other") side to the relation. + */ + protected function isOnSymmetricSide(string $parentUid, array $parentConf, array $childRec): bool + { + return MathUtility::canBeInterpretedAsInteger($childRec['uid']) + && $parentConf['symmetric_field'] + && $parentUid == $childRec[$parentConf['symmetric_field']]; + } + + /** + * Completes MM values to be written by values from the opposite relation. + * This method used MM insert field or MM match fields if defined. + * + * @param string $tableName Name of the opposite table + * @param array $referenceValues Values to be written + * @return array Values to be written, possibly modified + */ + protected function completeOppositeUsageValues(string $tableName, array $referenceValues): array + { + if (empty($this->MM_oppositeUsage[$tableName]) || count($this->MM_oppositeUsage[$tableName]) > 1) { + // @todo: count($this->MM_oppositeUsage[$tableName]) > 1 is buggy. + // Scenario: Suppose a foreign table has two (!) fields that link to a sys_category. Relations can + // then be correctly set for both fields when editing the foreign records. But when editing a sys_category + // record (local side) and adding a relation to a table that has two category relation fields, the 'fieldname' + // entry in mm-table can not be decided and ends up empty. Neither of the foreign table fields then recognize + // the relation as being set. + // One simple solution is to either simply pick the *first* field, or set *both* relations, but this + // is a) guesswork and b) it may be that in practice only *one* field is actually shown due to record + // types "showitem". + // Brain melt increases with tt_content field 'selected_category' in combination with + // 'category_field' for record types 'menu_categorized_pages' and 'menu_categorized_content' next + // to casual 'categories' field. However, 'selected_category' is a 'oneToMany' and not a 'manyToMany'. + // Hard nut ... + return $referenceValues; + } + + $fieldName = $this->MM_oppositeUsage[$tableName][0]; + $schema = $this->tcaSchemaFactory->get($tableName); + if (!$schema->hasField($fieldName)) { + return $referenceValues; + } + + $configuration = $schema->getField($fieldName)->getConfiguration(); + if (!empty($configuration['MM_match_fields'])) { + // @todo: In the end, MM_match_fields does not make sense. The 'tablename' and 'fieldname' restriction + // in addition to uid_local and uid_foreign used when multiple 'foreign' tables and/or multiple fields + // of one table refer to a single 'local' table having an mm table with these four fields, is already + // clear when looking at 'MM_oppositeUsage' of the local table. 'MM_match_fields' should thus probably + // fall altogether. The only information carried here are the field names of 'tablename' and 'fieldname' + // within the mm table itself, which we should hard code. This is partially assumed in DefaultTcaSchema + // already. + $referenceValues = array_merge($configuration['MM_match_fields'], $referenceValues); + } + + return $referenceValues; + } + + /** + * Gets the record uid of the live default record. If already + * pointing to the live record, the submitted record uid is returned. + * + * @param int|string $id + */ + protected function getLiveDefaultId(string $tableName, $id): int + { + $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $id); + if ($liveDefaultId === null) { + $liveDefaultId = $id; + } + return (int)$liveDefaultId; + } + + /** + * Removes empty values (null, '0', 0, false). + * + * @param int[] $ids + */ + protected function sanitizeIds(array $ids): array + { + return array_filter($ids); + } + + /** + * @param int[] $ids + */ + protected function getResolver(string $tableName, array $ids, ?array $sortingStatement = null): PlainDataResolver + { + $resolver = GeneralUtility::makeInstance( + PlainDataResolver::class, + $tableName, + $ids, + $sortingStatement + ); + $resolver->setWorkspaceId($this->getWorkspaceId()); + $resolver->setKeepDeletePlaceholder(true); + $resolver->setKeepLiveIds($this->useLiveReferenceIds); + return $resolver; + } + + protected function getConnectionForTableName(string $tableName): Connection + { + return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName); + } +} diff --git a/Classes/Database/Schema/Comparator.php b/Classes/Database/Schema/Comparator.php new file mode 100644 index 0000000..9dcdc0b --- /dev/null +++ b/Classes/Database/Schema/Comparator.php @@ -0,0 +1,95 @@ +comparator->compareSchemas($oldSchema, $newSchema); + + $alteredTables = $this->mapAlteredTablesToTypo3TableDiff($schemaDiff->getAlteredTables()); + $alteredTables = SchemaDiff::ensureCollection(...$alteredTables); + $alteredTables = $this->compareTableOptions($oldSchema, $newSchema, $alteredTables); + + return SchemaDiff::ensure( + $schemaDiff, + [ + 'alteredTables' => $alteredTables, + ] + ); + } + + /** + * @param array $alteredTables + * @return array + */ + private function mapAlteredTablesToTypo3TableDiff(array $alteredTables): array + { + return array_map( + static fn(DoctrineTableDiff $tableDiff): TableDiff => TableDiff::ensure($tableDiff), + $alteredTables + ); + } + + /** + * Provide change information about table options like the ENGINE (#77786) + * which are not implemented by doctrine/dbal itself + * + * @param array $alteredTables + * @return array + */ + private function compareTableOptions(Schema $oldSchema, Schema $newSchema, array $alteredTables): array + { + foreach ($newSchema->getTables() as $newTable) { + $newTableName = $newTable->getShortestName($newSchema->getName()); + if (!$oldSchema->hasTable($newTableName)) { + // new table, no ALTER TABLE needed + continue; + } + + $oldTable = $oldSchema->getTable($newTableName); + $newTableOptions = array_merge($oldTable->getOptions(), $newTable->getOptions()); + $optionDiff = ArrayUtility::arrayDiffAssocRecursive($newTableOptions, $oldTable->getOptions()); + if ($optionDiff === []) { + continue; + } + + $key = $newTable->getName(); + $tableDiff = $alteredTables[$key] ?? new TableDiff($newTable); + $tableDiff->setTableOptions($optionDiff); + $alteredTables[$key] = $tableDiff; + } + + return $alteredTables; + } +} diff --git a/Classes/Database/Schema/ConnectionMigrator.php b/Classes/Database/Schema/ConnectionMigrator.php new file mode 100644 index 0000000..bb2450f --- /dev/null +++ b/Classes/Database/Schema/ConnectionMigrator.php @@ -0,0 +1,2185 @@ +buildSchemaDiff(false); + } + + /** + * Compare current and expected schema definitions and provide updates + * suggestions in the form of SQL statements. + */ + public function getUpdateSuggestions(bool $remove = false): array + { + $schemaDiff = $this->buildSchemaDiff(); + if ($remove === false) { + return array_merge_recursive( + ['create_table' => [], 'change' => [], 'change_currentValue' => [], 'add' => []], + $this->getNewFieldUpdateSuggestions($schemaDiff), + $this->getNewTableUpdateSuggestions($schemaDiff), + $this->getChangedFieldUpdateSuggestions($schemaDiff), + $this->getChangedTableOptions($schemaDiff) + ); + } + return array_merge_recursive( + ['change' => [], 'change_table' => [], 'drop' => [], 'drop_table' => [], 'tables_count' => []], + $this->getUnusedFieldUpdateSuggestions($schemaDiff), + $this->getUnusedTableUpdateSuggestions($schemaDiff), + $this->getDropTableUpdateSuggestions($schemaDiff), + $this->getDropFieldUpdateSuggestions($schemaDiff) + ); + } + + /** + * Perform add/change/create operations on tables and fields in an optimized, non-interactive, mode. + */ + public function install(bool $createOnly = false): array + { + $result = []; + $schemaDiff = $this->buildSchemaDiff(false); + + $schemaDiff->droppedTables = []; + foreach ($schemaDiff->alteredTables as $key => $changedTable) { + $schemaDiff->alteredTables[$key]->droppedColumns = []; + $schemaDiff->alteredTables[$key]->droppedIndexes = []; + + // With partial ext_tables.sql files the SchemaManager is detecting + // existing columns as false positives for a column rename. In this + // context every rename is actually a new column. + foreach ($changedTable->changedColumns as $columnName => $changedColumn) { + if (!$changedColumn->hasNameChanged()) { + continue; + } + $changedTable->addedColumns[$changedColumn->getNewColumn()->getName()] = new Column( + $changedColumn->getNewColumn()->getName(), + $changedColumn->getNewColumn()->getType(), + $this->prepareColumnOptions($changedColumn->getNewColumn()) + ); + unset($changedTable->changedColumns[$columnName]); + } + + if ($createOnly) { + // Ignore new indexes that work on columns that need changes + foreach ($changedTable->addedIndexes as $indexName => $addedIndex) { + $indexColumns = array_map( + static function (string $columnName): string { + // Strip MySQL prefix length information to get real column names + $columnName = preg_replace('/\(\d+\)$/', '', $columnName) ?? ''; + // Strip sqlite '"' from column names + return trim($columnName, '"'); + }, + $addedIndex->getColumns() + ); + $columnChanges = array_intersect($indexColumns, array_keys($changedTable->changedColumns)); + if (!empty($columnChanges)) { + unset($schemaDiff->alteredTables[$key]->addedIndexes[$indexName]); + } + } + $schemaDiff->alteredTables[$key]->changedColumns = []; + $schemaDiff->alteredTables[$key]->modifiedIndexes = []; + $schemaDiff->alteredTables[$key]->renamedIndexes = []; + } + } + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($schemaDiff); + foreach ($statements as $statement) { + try { + $this->connection->executeStatement($statement); + $result[$statement] = ''; + } catch (DBALException $e) { + $result[$statement] = $e->getMessage(); + } + } + + return $result; + } + + /** + * If the schema is not for the Default connection remove all tables from the schema + * that have no mapping in the TYPO3 configuration. This avoids update suggestions + * for tables that are in the database but have no direct relation to the TYPO3 instance. + * + * @throws \Doctrine\DBAL\Exception + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function buildSchemaDiff(bool $renameUnused = true): Typo3SchemaDiff + { + // Unmapped tables in a non-default connection are ignored by TYPO3 + $tablesForConnection = []; + if ($this->connectionName !== ConnectionPool::DEFAULT_CONNECTION_NAME) { + // If there are no mapped tables return a SchemaDiff without any changes + // to avoid update suggestions for tables not related to TYPO3. + if (empty($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'] ?? null)) { + return new SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [], + // droppedTables: + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + } + + // Collect the table names that have been mapped to this connection. + $connectionName = $this->connectionName; + /** @var string[] $tablesForConnection */ + $tablesForConnection = array_keys( + array_filter( + $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'], + static function (string $tableConnectionName) use ($connectionName): bool { + return $tableConnectionName === $connectionName; + } + ) + ); + + // Ignore all tables without mapping if not in the default connection + $this->connection->getConfiguration()->setSchemaAssetsFilter( + static function ($assetName) use ($tablesForConnection) { + return in_array($assetName, $tablesForConnection, true); + } + ); + } + + $schemaManager = $this->connection->createSchemaManager(); + + // Build the schema definitions + $fromSchema = $this->buildExistingSchemaDefinitions($schemaManager); + $toSchema = $this->buildExpectedSchemaDefinitions($this->connectionName); + + // Add current table options to the fromSchema + $tableOptions = $this->getTableOptions($this->getSchemaTableNames($fromSchema)); + foreach ($fromSchema->getTables() as $table) { + $tableName = $table->getName(); + if (!array_key_exists($tableName, $tableOptions)) { + continue; + } + foreach ($tableOptions[$tableName] as $optionName => $optionValue) { + $table->addOption($optionName, $optionValue); + } + } + + // Build SchemaDiff and handle renames of tables and columns + $comparator = GeneralUtility::makeInstance(Comparator::class, $schemaManager->createComparator()); + $schemaDiff = $comparator->compareSchemas($fromSchema, $toSchema); + $schemaDiff = $this->migrateColumnRenamesToDistinctActions($schemaDiff); + + if ($renameUnused) { + $schemaDiff = $this->migrateUnprefixedRemovedTablesToRenames($schemaDiff); + $schemaDiff = $this->migrateUnprefixedRemovedFieldsToRenames($schemaDiff); + } + + // All tables in the default connection are managed by TYPO3 + if ($this->connectionName === ConnectionPool::DEFAULT_CONNECTION_NAME) { + return $schemaDiff; + } + + // Remove all tables that are not assigned to this connection from the diff + $schemaDiff->createdTables = $this->removeUnrelatedTables($schemaDiff->createdTables, $tablesForConnection); + $schemaDiff->alteredTables = $this->removeUnrelatedTables($schemaDiff->alteredTables, $tablesForConnection); + $schemaDiff->droppedTables = $this->removeUnrelatedTables($schemaDiff->droppedTables, $tablesForConnection); + + return $schemaDiff; + } + + protected function buildExistingSchemaDefinitions(AbstractSchemaManager $schemaManager): Schema + { + $platform = $this->connection->getDatabasePlatform(); + $schema = $schemaManager->introspectSchema(); + // Only MySQL has variable length versions of TEXT/BLOB. + // Move the platform into the foreach loop as soon as more normalization needs to be applied, taking it + // now as early avoiding the loop. + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + foreach ($schema->getTables() as $table) { + foreach ($table->getColumns() as $column) { + $columnType = $column->getType(); + if ($columnType instanceof BlobType || $columnType instanceof TextType) { + // Doctrine does not provide a length for LONGTEXT/LONGBLOB columns, thus + // ensuring a default length. This is essential for column comparison. + $column->setLength($column->getLength() ?? 2147483647); + } + } + } + } + return $schema; + } + + /** + * Build the expected schema definitions from raw SQL statements. + * + * @throws \Doctrine\DBAL\Exception + * @throws \InvalidArgumentException + */ + protected function buildExpectedSchemaDefinitions(string $connectionName): Schema + { + $schemaConfig = new SchemaConfig(); + $schemaConfig->setName($this->connection->getDatabase()); + if (isset($this->connection->getParams()['defaultTableOptions'])) { + $schemaConfig->setDefaultTableOptions($this->connection->getParams()['defaultTableOptions']); + } + /** @var Table[] $tablesForConnection */ + $tablesForConnection = []; + foreach ($this->tables as $table) { + $tableName = $table->getName(); + + // Skip tables for a different connection + if ($connectionName !== $this->getConnectionNameForTable($tableName)) { + continue; + } + $table->setSchemaConfig($schemaConfig); + $tablesForConnection[$tableName] = $table; + } + $tablesForConnection = $this->normalizeTablesForTargetConnection($this->connection, $schemaConfig, $tablesForConnection); + return new Schema($tablesForConnection, [], $schemaConfig); + } + + /** + * Extract the update suggestions (SQL statements) for newly added tables + * from the complete schema diff. + * + * @throws \InvalidArgumentException + */ + protected function getNewTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + // Build a new schema diff that only contains added tables + $addTableSchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + $schemaDiff->getCreatedTables(), + // alteredTables + [], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($addTableSchemaDiff); + + return ['create_table' => $this->calculateUpdateSuggestionsHashes($statements)]; + } + + /** + * Extract the update suggestions (SQL statements) for newly added fields + * from the complete schema diff. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getNewFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $changedTables = []; + + foreach ($schemaDiff->alteredTables as $index => $changedTable) { + if (count($changedTable->addedColumns) !== 0) { + // Treat each added column with a new diff to get a dedicated suggestions + // just for this single column. + foreach ($changedTable->addedColumns as $columnName => $addedColumn) { + $changedTables[$index . ':tbl_' . $columnName] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [$columnName => $addedColumn], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + } + } + + if (count($changedTable->addedIndexes) !== 0) { + // Treat each added index with a new diff to get a dedicated suggestions + // just for this index. + foreach ($changedTable->addedIndexes as $indexName => $addedIndex) { + $changedTables[$index . ':idx_' . $indexName] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [$indexName => $this->buildQuotedIndex($addedIndex)], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + } + } + + if (count($changedTable->addedForeignKeys) !== 0) { + // Treat each added foreign key with a new diff to get a dedicated suggestions + // just for this foreign key. + foreach ($changedTable->addedForeignKeys as $addedForeignKey) { + $fkIndex = $index . ':fk_' . $addedForeignKey->getName(); + $changedTables[$fkIndex] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [$this->buildQuotedForeignKey($addedForeignKey)], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + } + } + } + + // Build a new schema diff that only contains added fields + $addFieldSchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + $changedTables, + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($addFieldSchemaDiff); + + return ['add' => $this->calculateUpdateSuggestionsHashes($statements)]; + } + + /** + * Extract update suggestions (SQL statements) for changed options (like ENGINE) from the complete schema diff. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getChangedTableOptions(Typo3SchemaDiff $schemaDiff): array + { + $updateSuggestions = []; + + foreach ($schemaDiff->alteredTables as $index => $tableDiff) { + // Skip processing if this is the base TableDiff class or has no table options set. + if (!$tableDiff instanceof Typo3TableDiff || count($tableDiff->getTableOptions()) === 0) { + continue; + } + + $tableOptions = $tableDiff->getTableOptions(); + $tableOptionsDiff = new Typo3TableDiff( + // oldTable + $tableDiff->getOldTable(), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + $tableOptionsDiff->setTableOptions($tableOptions); + + $tableOptionsSchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [$index => $tableOptionsDiff], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($tableOptionsSchemaDiff); + foreach ($statements as $statement) { + $updateSuggestions['change'][md5($statement)] = $statement; + } + } + + return $updateSuggestions; + } + + /** + * Extract update suggestions (SQL statements) for changed fields + * from the complete schema diff. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getChangedFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $databasePlatform = $this->connection->getDatabasePlatform(); + $updateSuggestions = []; + + foreach ($schemaDiff->alteredTables as $changedTable) { + // Treat each changed index with a new diff to get a dedicated suggestions + // just for this index. + if (count($changedTable->modifiedIndexes) !== 0) { + foreach ($changedTable->modifiedIndexes as $indexName => $changedIndex) { + $indexDiff = new Typo3TableDiff( + // oldTable + $changedTable->getOldTable(), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [$indexName => $changedIndex], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + + $temporarySchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [$changedTable->getOldTable()->getName() => $indexDiff], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff); + foreach ($statements as $statement) { + $updateSuggestions['change'][md5($statement)] = $statement; + } + } + } + + // Treat renamed indexes as a field change as it's a simple rename operation + if (count($changedTable->renamedIndexes) !== 0) { + // Create a base table diff without any changes, there's no constructor + // argument to pass in renamed indexes. + $tableDiff = new Typo3TableDiff( + // oldTable + $changedTable->getOldTable(), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + + // Treat each renamed index with a new diff to get a dedicated suggestions + // just for this index. + foreach ($changedTable->renamedIndexes as $key => $renamedIndex) { + $indexDiff = clone $tableDiff; + $indexDiff->renamedIndexes = [ + $changedTable->getOldTable()->getIndex($key)->getQuotedName($databasePlatform) => $renamedIndex, + ]; + + $temporarySchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [$indexDiff->getOldTable()->getQuotedName($databasePlatform) => $indexDiff], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff); + foreach ($statements as $statement) { + $updateSuggestions['change'][md5($statement)] = $statement; + } + } + } + + if (count($changedTable->changedColumns) !== 0) { + // Treat each changed column with a new diff to get a dedicated suggestions + // just for this single column. + foreach ($changedTable->changedColumns as $columnName => &$changedColumn) { + // Field has been renamed and will be handled separately + if ($changedColumn->hasNameChanged()) { + continue; + } + + $changedColumn = new ColumnDiff( + $this->buildQuotedColumn($changedColumn->getOldColumn()), + $changedColumn->getNewColumn(), + ); + + // Get the current SQL declaration for the column + $currentColumn = $changedColumn->getOldColumn(); + $currentDeclaration = $databasePlatform->getColumnDeclarationSQL( + $currentColumn->getQuotedName($this->connection->getDatabasePlatform()), + $currentColumn->toArray() + ); + + // Build a dedicated diff just for the current column + $tableDiff = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [$columnName => $changedColumn], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + $temporarySchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [$tableDiff->getOldTable()->getName() => $tableDiff], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + // Get missing update statements to mimic documented Doctrine DBAL 4 SERIAL to IDENTITY column + // migration without loosing sequence table data. + // @see https://github.com/doctrine/dbal/blob/4.0.x/docs/en/how-to/postgresql-identity-migration.rst + $postgreSQLMigrationStatements = $this->getPostgreSQLMigrationStatements($this->connection, $changedTable, $changedColumn); + $indexedSearchPrerequisiteStatements = $this->getIndexedSearchTruncateTablePrerequisiteStatements($this->connection, $changedTable, $changedColumn); + if ($indexedSearchPrerequisiteStatements !== []) { + foreach ($indexedSearchPrerequisiteStatements as $statement => $reason) { + $updateSuggestions['change'][md5($statement)] = $statement; + if ($reason !== '') { + $updateSuggestions['change_currentValue'][md5($statement)] = $reason; + } + } + } + $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff); + foreach ($statements as $statement) { + // Combine SERIAL to IDENTITY COLUMN date migration statements to the statement + // @todo This is a hackish way to provide data migration along with DDL changes in a connected + // way. There is currently no other way to archive this and again emphasizes the need to + // refactor the complete database analyzer stack and handling. + if ($postgreSQLMigrationStatements !== []) { + if (str_contains($statement, 'DROP DEFAULT')) { + $statement = rtrim($statement, '; ') . ';' . implode(';', $postgreSQLMigrationStatements); + } + if (str_contains($statement, 'ADD GENERATED BY DEFAULT AS IDENTITY')) { + // Due to the proper migration replacement we need to skip the Doctrine DBAL add statement + // which will fail anyway - and is covered by the manual update above. This ensures, that + // the sequence table is not dropped and recreated with empty state. + continue; + } + } + $updateSuggestions['change'][md5($statement)] = $statement; + $updateSuggestions['change_currentValue'][md5($statement)] = $currentDeclaration; + } + } + } + + // Treat each changed foreign key with a new diff to get a dedicated suggestions + // just for this foreign key. + if (count($changedTable->modifiedForeignKeys) !== 0) { + $tableDiff = new Typo3TableDiff( + // oldTable + $changedTable->getOldTable(), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + + foreach ($changedTable->modifiedForeignKeys as $changedForeignKey) { + $foreignKeyDiff = clone $tableDiff; + $foreignKeyDiff->modifiedForeignKeys = [$this->buildQuotedForeignKey($changedForeignKey)]; + + $temporarySchemaDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [$foreignKeyDiff->getOldTable()->getName() => $foreignKeyDiff], + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $databasePlatform->getAlterSchemaSQL($temporarySchemaDiff); + foreach ($statements as $statement) { + $updateSuggestions['change'][md5($statement)] = $statement; + } + } + } + } + + return $updateSuggestions; + } + + /** + * Extract update suggestions (SQL statements) for tables that are + * no longer present in the expected schema from the schema diff. + * In this case the update suggestions are renames of the tables + * with a prefix to mark them for deletion in a second sweep. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getUnusedTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $databasePlatform = $this->connection->getDatabasePlatform(); + $updateSuggestions = []; + foreach ($schemaDiff->alteredTables as $tableDiff) { + // Skip tables that are not being renamed or where the new name isn't prefixed + // with the deletion marker. + if ($tableDiff->getNewName() === null + || !str_starts_with($this->trimIdentifierQuotes($tableDiff->getNewName()), $this->deletedPrefix) + ) { + continue; + } + + $statement = $databasePlatform->getRenameTableSQL( + $tableDiff->getOldTable()->getQuotedName($databasePlatform), + $tableDiff->newName + ); + $updateSuggestions['change_table'][md5($statement)] = $statement; + $updateSuggestions['tables_count'][md5($statement)] = $this->getTableRecordCount($tableDiff->getOldTable()->getName()); + } + + return $updateSuggestions; + } + + /** + * Extract update suggestions (SQL statements) for fields that are + * no longer present in the expected schema from the schema diff. + * In this case the update suggestions are renames of the fields + * with a prefix to mark them for deletion in a second sweep. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getUnusedFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $databasePlatform = $this->connection->getDatabasePlatform(); + $changedTables = []; + foreach ($schemaDiff->alteredTables as $tableName => $changedTable) { + if (count($changedTable->changedColumns) === 0) { + continue; + } + + // Treat each changed column with a new diff to get a dedicated suggestions + // just for this single column. + foreach ($changedTable->changedColumns as $index => $changedColumn) { + // Field has not been renamed + if (!$changedColumn->hasNameChanged()) { + continue; + } + + $oldFieldName = $changedColumn->getOldColumn()->getQuotedName($databasePlatform); + $renameColumnTableDiff = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [$oldFieldName => $changedColumn], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + $changedTables[$tableName . ':' . $changedColumn->getNewColumn()->getName()] = $renameColumnTableDiff; + + if ($databasePlatform instanceof DoctrineSQLitePlatform) { + break; + } + } + } + + // Build a new schema diff that only contains unused fields + $changedFieldDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + $changedTables, + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($changedFieldDiff); + + return ['change' => $this->calculateUpdateSuggestionsHashes($statements)]; + } + + /** + * Extract update suggestions (SQL statements) for fields that can + * be removed from the complete schema diff. + * Fields that can be removed have been prefixed in a previous run + * of the schema migration. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getDropFieldUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $changedTables = []; + + foreach ($schemaDiff->alteredTables as $index => $changedTable) { + $isSqlite = $this->getDatabasePlatformForTable($index) instanceof DoctrineSQLitePlatform; + $addMoreOperations = true; + + if (count($changedTable->droppedColumns) !== 0) { + // Treat each changed column with a new diff to get a dedicated suggestions + // just for this single column. + foreach ($changedTable->droppedColumns as $columnName => $removedColumn) { + $changedTables[$index . ':tbl_' . $removedColumn->getName()] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [$columnName => $this->buildQuotedColumn($removedColumn)], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + if ($isSqlite) { + $addMoreOperations = false; + break; + } + } + } + + if ($addMoreOperations && count($changedTable->droppedIndexes) !== 0) { + // Treat each removed index with a new diff to get a dedicated suggestions + // just for this index. + foreach ($changedTable->droppedIndexes as $indexName => $removedIndex) { + $changedTables[$index . ':idx_' . $removedIndex->getName()] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [$indexName => $this->buildQuotedIndex($removedIndex)], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + if ($isSqlite) { + $addMoreOperations = false; + break; + } + } + } + + if ($addMoreOperations && count($changedTable->droppedForeignKeys) !== 0) { + // Treat each removed foreign key with a new diff to get a dedicated suggestions + // just for this foreign key. + foreach ($changedTable->droppedForeignKeys as $removedForeignKey) { + $fkIndex = $index . ':fk_' . $removedForeignKey->getName(); + $changedTables[$fkIndex] = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($changedTable->getOldTable()), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [$this->buildQuotedForeignKey($removedForeignKey)], + ); + if ($isSqlite) { + break; + } + } + } + } + + // Build a new schema diff that only contains removable fields + $removedFieldDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + $changedTables, + // droppedTables + [], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($removedFieldDiff); + + return ['drop' => $this->calculateUpdateSuggestionsHashes($statements)]; + } + + /** + * Extract update suggestions (SQL statements) for tables that can + * be removed from the complete schema diff. + * Tables that can be removed have been prefixed in a previous run + * of the schema migration. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function getDropTableUpdateSuggestions(Typo3SchemaDiff $schemaDiff): array + { + $updateSuggestions = []; + foreach ($schemaDiff->droppedTables as $index => $removedTable) { + // Build a new schema diff that only contains this table + $tableDiff = new Typo3SchemaDiff( + // createdSchemas + [], + // droppedSchemas + [], + // createdTables + [], + // alteredTables + [], + // droppedTables + [$index => $this->buildQuotedTable($removedTable)], + // createdSequences + [], + // alteredSequences + [], + // droppedSequences + [], + ); + + $statements = $this->connection->getDatabasePlatform()->getAlterSchemaSQL($tableDiff); + foreach ($statements as $statement) { + $updateSuggestions['drop_table'][md5($statement)] = $statement; + } + + // Only store the record count for this table for the first statement, + // assuming that this is the actual DROP TABLE statement. + $updateSuggestions['tables_count'][md5($statements[0])] = $this->getTableRecordCount( + $removedTable->getName() + ); + } + + return $updateSuggestions; + } + + /** + * Move tables to be removed that are not prefixed with the deleted prefix to the list + * of changed tables and set a new prefixed name. + * Without this help the Doctrine SchemaDiff has no idea if a table has been renamed and + * performs a drop of the old table and creates a new table, which leads to all data in + * the old table being lost. + * + * @throws \InvalidArgumentException + */ + protected function migrateUnprefixedRemovedTablesToRenames(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff + { + foreach ($schemaDiff->droppedTables as $index => $removedTable) { + if (str_starts_with($this->trimIdentifierQuotes($removedTable->getName()), $this->deletedPrefix)) { + continue; + } + $tableDiff = new Typo3TableDiff( + // oldTable + $this->buildQuotedTable($removedTable), + // addedColumns + [], + // changedColumns + [], + // droppedColumns + [], + // addedIndexes + [], + // modifiedIndexes + [], + // droppedIndexes + [], + // renamedIndexes + [], + // addedForeignKeys + [], + // modifiedForeignKeys + [], + // droppedForeignKeys + [], + ); + + $tableDiff->newName = $this->connection->getDatabasePlatform()->quoteIdentifier( + substr( + $this->deletedPrefix . $removedTable->getName(), + 0, + PlatformInformation::getMaxIdentifierLength($this->connection->getDatabasePlatform()) + ) + ); + $schemaDiff->alteredTables[$index] = $tableDiff; + unset($schemaDiff->droppedTables[$index]); + } + + return $schemaDiff; + } + + /** + * Scan the list of changed tables for fields that are going to be dropped. If + * the name of the field does not start with the deleted prefix mark the column + * for a rename instead of a drop operation. + * + * @throws \InvalidArgumentException + */ + protected function migrateUnprefixedRemovedFieldsToRenames(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff + { + foreach ($schemaDiff->alteredTables as $tableIndex => $changedTable) { + if (count($changedTable->droppedColumns) === 0) { + continue; + } + + foreach ($changedTable->droppedColumns as $columnIndex => $removedColumn) { + if (str_starts_with($this->trimIdentifierQuotes($removedColumn->getName()), $this->deletedPrefix)) { + continue; + } + + // Build a new column object with the same properties as the removed column + $renamedColumnName = substr( + $this->deletedPrefix . $removedColumn->getName(), + 0, + PlatformInformation::getMaxIdentifierLength($this->connection->getDatabasePlatform()) + ); + $renamedColumn = new Column( + $this->connection->quoteIdentifier($renamedColumnName), + $removedColumn->getType(), + $this->prepareColumnOptions($removedColumn) + ); + + // Build the diff object for the column to rename + $columnDiff = new ColumnDiff($this->buildQuotedColumn($removedColumn), $renamedColumn); + + // Add the column with the required rename information to the changed column list + $schemaDiff->alteredTables[$tableIndex]->changedColumns[$columnIndex] = $columnDiff; + + // Remove the column from the list of columns to be dropped + unset($schemaDiff->alteredTables[$tableIndex]->droppedColumns[$columnIndex]); + } + } + + return $schemaDiff; + } + + /** + * Revert the automatic rename optimization that Doctrine performs when it detects + * a column being added and a column being dropped that only differ by name. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function migrateColumnRenamesToDistinctActions(Typo3SchemaDiff $schemaDiff): Typo3SchemaDiff + { + foreach ($schemaDiff->alteredTables as $changedTable) { + if (count($changedTable->getChangedColumns()) === 0) { + continue; + } + + // Treat each renamed column with a new diff to get a dedicated + // suggestion just for this single column. + foreach ($changedTable->changedColumns as $originalColumnName => $changedColumn) { + if (!$changedColumn->hasNameChanged()) { + continue; + } + $changedTable->addedColumns[$changedColumn->getNewColumn()->getName()] = new Column( + $changedColumn->getNewColumn()->getName(), + $changedColumn->getNewColumn()->getType(), + $this->prepareColumnOptions($changedColumn->getNewColumn()) + ); + $changedTable->droppedColumns[$changedColumn->getOldColumn()->getName()] = new Column( + $changedColumn->getOldColumn()->getName(), + $changedColumn->getOldColumn()->getType(), + $this->prepareColumnOptions($changedColumn->getOldColumn()) + ); + + unset($changedTable->changedColumns[$originalColumnName]); + } + } + + return $schemaDiff; + } + + /** + * Return the amount of records in the given table. + * + * @throws \InvalidArgumentException + */ + protected function getTableRecordCount(string $tableName): int + { + return $this->connectionPool + ->getConnectionForTable($tableName) + ->count('*', $tableName, []); + } + + /** + * Determine the connection name for a table + * + * @throws \InvalidArgumentException + */ + protected function getConnectionNameForTable(string $tableName): string + { + $connectionNames = $this->connectionPool->getConnectionNames(); + + if (isset($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName])) { + return in_array($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName], $connectionNames, true) + ? $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName] + : ConnectionPool::DEFAULT_CONNECTION_NAME; + } + + return ConnectionPool::DEFAULT_CONNECTION_NAME; + } + + /** + * Replace the array keys with a md5 sum of the actual SQL statement + * + * @param string[] $statements + * @return array + */ + protected function calculateUpdateSuggestionsHashes(array $statements): array + { + return array_combine(array_map(md5(...), $statements), $statements); + } + + /** + * Helper for buildSchemaDiff to filter an array of TableDiffs against a list of valid table names. + * + * @param array $tableDiffs + * @param string[] $validTableNames + * @return array + * @throws \InvalidArgumentException + */ + protected function removeUnrelatedTables(array $tableDiffs, array $validTableNames): array + { + $tableDiffs = array_filter( + $tableDiffs, + function (Typo3TableDiff|Table $table) use ($validTableNames): bool { + if ($table instanceof Table) { + $tableName = $table->getName(); + } else { + $tableName = $table->getNewName() ?? $table->getOldTable()->getName(); + } + + // If the tablename has a deleted prefix strip it of before comparing + // it against the list of valid table names so that drop operations + // don't get removed. + if (str_starts_with($this->trimIdentifierQuotes($tableName), $this->deletedPrefix)) { + $tableName = substr($tableName, strlen($this->deletedPrefix)); + } + return in_array($tableName, $validTableNames, true) + || in_array($this->deletedPrefix . $tableName, $validTableNames, true); + } + ); + foreach ($tableDiffs as &$tableDiff) { + if ($tableDiff instanceof Table) { + continue; + } + } + return $tableDiffs; + } + + /** + * MariaDB 11.4 introduced the UCA-1400 collations along with a new + * `FULL_COLLATION_NAME` column in `information_schema.COLLATION_CHARACTER_SET_APPLICABILITY`. + * For these collations `COLLATION_NAME` only holds the character-set independent part, for + * example `uca1400_ai_ci`, while `FULL_COLLATION_NAME` holds `utf8mb4_uca1400_ai_ci` which is + * what `information_schema.TABLES.TABLE_COLLATION` reports. Joining on `COLLATION_NAME` would + * therefore not match at all for tables using such a collation. + */ + protected function hasFullCollationNameSupport(): bool + { + // Low level, concrete Doctrine QueryBuilder is used here intentionally to avoid dependency injection + // conflicts with TYPO3 QueryRestrictions. These are not required here. + $queryBuilder = new DoctrineQueryBuilder($this->connection); + $count = $queryBuilder + ->select('COUNT(*)') + ->from($this->connection->quoteIdentifier('information_schema.COLUMNS')) + ->where( + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier('TABLE_SCHEMA'), + $queryBuilder->createNamedParameter('information_schema') + ), + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier('TABLE_NAME'), + $queryBuilder->createNamedParameter('COLLATION_CHARACTER_SET_APPLICABILITY') + ), + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier('COLUMN_NAME'), + $queryBuilder->createNamedParameter('FULL_COLLATION_NAME') + ) + ) + ->executeQuery() + ->fetchOne(); + + return (int)$count > 0; + } + + /** + * Get COLLATION, ROW_FORMAT, COMMENT and ENGINE table options on MySQL connections. + * + * @param string[] $tableNames + * @return array[] + * @throws \InvalidArgumentException + */ + protected function getTableOptions(array $tableNames): array + { + $tableOptions = []; + $platform = $this->connection->getDatabasePlatform(); + if (!($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform)) { + foreach ($tableNames as $tableName) { + $tableOptions[$tableName] = []; + } + + return $tableOptions; + } + + // Low level, concrete Doctrine QueryBuilder is used here intentionally to avoid dependency injection + // conflicts with TYPO3 QueryRestrictions. These are not required here. + $queryBuilder = new DoctrineQueryBuilder($this->connection); + $result = $queryBuilder + ->select( + $this->connection->quoteIdentifier('tables.TABLE_NAME') . ' AS ' . $this->connection->quoteIdentifier('table'), + $this->connection->quoteIdentifier('tables.ENGINE') . ' AS ' . $this->connection->quoteIdentifier('engine'), + $this->connection->quoteIdentifier('tables.ROW_FORMAT') . ' AS ' . $this->connection->quoteIdentifier('row_format'), + $this->connection->quoteIdentifier('tables.TABLE_COLLATION') . ' AS ' . $this->connection->quoteIdentifier('collate'), + $this->connection->quoteIdentifier('tables.TABLE_COMMENT') . ' AS ' . $this->connection->quoteIdentifier('comment'), + $this->connection->quoteIdentifier('CCSA.character_set_name') . ' AS ' . $this->connection->quoteIdentifier('charset') + ) + ->from($this->connection->quoteIdentifier('information_schema.TABLES'), $this->connection->quoteIdentifier('tables')) + ->join( + $this->connection->quoteIdentifier('tables'), + $this->connection->quoteIdentifier('information_schema.COLLATION_CHARACTER_SET_APPLICABILITY'), + $this->connection->quoteIdentifier('CCSA'), + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier( + $this->hasFullCollationNameSupport() ? 'CCSA.full_collation_name' : 'CCSA.collation_name' + ), + $this->connection->quoteIdentifier('tables.table_collation') + ) + ) + ->where( + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier('TABLE_TYPE'), + $queryBuilder->createNamedParameter('BASE TABLE') + ), + $queryBuilder->expr()->eq( + $this->connection->quoteIdentifier('TABLE_SCHEMA'), + $queryBuilder->createNamedParameter($this->connection->getDatabase()) + ) + ) + ->executeQuery(); + + while ($row = $result->fetchAssociative()) { + $index = $row['table']; + unset($row['table']); + $tableOptions[$index] = $row; + } + + return $tableOptions; + } + + /** + * Helper function to build a table object that has the _quoted attribute set so that the SchemaManager + * will use quoted identifiers when creating the final SQL statements. This is needed as Doctrine doesn't + * provide a method to set the flag after the object has been instantiated and there's no possibility to + * hook into the createSchema() method early enough to influence the original table object. + */ + protected function buildQuotedTable(Table $table): Table + { + $databasePlatform = $this->connection->getDatabasePlatform(); + + return new Table( + $databasePlatform->quoteIdentifier($table->getName()), + $table->getColumns(), + $table->getIndexes(), + [], + $table->getForeignKeys(), + $table->getOptions() + ); + } + + /** + * Helper function to build a column object that has the _quoted attribute set so that the SchemaManager + * will use quoted identifiers when creating the final SQL statements. This is needed as Doctrine doesn't + * provide a method to set the flag after the object has been instantiated and there's no possibility to + * hook into the createSchema() method early enough to influence the original column object. + */ + protected function buildQuotedColumn(Column $column): Column + { + $databasePlatform = $this->connection->getDatabasePlatform(); + + return new Column( + $databasePlatform->quoteIdentifier($this->trimIdentifierQuotes($column->getName())), + $column->getType(), + $this->prepareColumnOptions($column) + ); + } + + /** + * Helper function to build an index object that has the _quoted attribute set so that the SchemaManager + * will use quoted identifiers when creating the final SQL statements. This is needed as Doctrine doesn't + * provide a method to set the flag after the object has been instantiated and there's no possibility to + * hook into the createSchema() method early enough to influence the original column object. + */ + protected function buildQuotedIndex(Index $index): Index + { + $databasePlatform = $this->connection->getDatabasePlatform(); + + return new Index( + $databasePlatform->quoteIdentifier($index->getName()), + $index->getColumns(), + $index->isUnique(), + $index->isPrimary(), + $index->getFlags(), + $index->getOptions() + ); + } + + /** + * Helper function to build a foreign key constraint object that has the _quoted attribute set so that the + * SchemaManager will use quoted identifiers when creating the final SQL statements. This is needed as Doctrine + * doesn't provide a method to set the flag after the object has been instantiated and there's no possibility to + * hook into the createSchema() method early enough to influence the original column object. + */ + protected function buildQuotedForeignKey(ForeignKeyConstraint $index): ForeignKeyConstraint + { + $databasePlatform = $this->connection->getDatabasePlatform(); + + return new ForeignKeyConstraint( + $index->getLocalColumns(), + $databasePlatform->quoteIdentifier($index->getForeignTableName()), + $index->getForeignColumns(), + $databasePlatform->quoteIdentifier($index->getName()), + $index->getOptions() + ); + } + + protected function prepareColumnOptions(Column $column): array + { + $options = $column->toArray(); + $platformOptions = $column->getPlatformOptions(); + foreach ($platformOptions as $optionName => $optionValue) { + unset($options[$optionName]); + if (!isset($options['platformOptions'])) { + $options['platformOptions'] = []; + } + $options['platformOptions'][$optionName] = $optionValue; + } + unset($options['name'], $options['type']); + return $options; + } + + protected function getDatabasePlatformForTable(string $tableName): AbstractPlatform + { + $databasePlatform = $this->connectionPool->getConnectionForTable($tableName)->getDatabasePlatform(); + return match (true) { + $databasePlatform instanceof DoctrinePostgreSQLPlatform, + $databasePlatform instanceof DoctrineSQLitePlatform, + $databasePlatform instanceof DoctrineMariaDBPlatform, + $databasePlatform instanceof DoctrineMySQLPlatform => $databasePlatform, + default => throw new \RuntimeException( + sprintf( + 'Platform "%s" not supported for table "%s" connection.', + get_class($databasePlatform), + $tableName, + ), + 1701619871 + ), + }; + } + + protected function getSchemaTableNames(Schema $schema) + { + $tableNames = []; + foreach ($schema->getTables() as $table) { + $tableNames[] = $table->getName(); + } + ksort($tableNames); + return $tableNames; + } + + /** + * Due to portability reasons it is necessary to normalize the virtual generated schema against the target + * connection platform. + * + * - SQLite: Needs some special treatment regarding autoincrement fields. [1] + * - MySQL/MariaDB: varchar fields needs to have a length, but doctrine dropped the default size. This need's to be + * addressed in application code. [2][3] + * + * @see https://github.com/doctrine/dbal/commit/33555d36e7e7d07a5880e01 [1] + * @see https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-abstractplatform-methods-that-describe-the-default-and-the-maximum-column-lengths [2] + * @see https://github.com/doctrine/dbal/blob/4.0.x/UPGRADE.md#bc-break-changes-in-handling-string-and-binary-columns [3] + * + * @param Table[] $tables + * @return Table[] + * @throws DBALException + */ + protected function normalizeTablesForTargetConnection(Typo3Connection $connection, SchemaConfig $schemaConfig, array $tables): array + { + $databasePlatform = $connection->getDatabasePlatform(); + array_walk($tables, function (Table &$table) use ($connection, $databasePlatform, $schemaConfig): void { + $table->setSchemaConfig($schemaConfig); + $this->normalizeTableIdentifiers($databasePlatform, $table); + $this->applyDefaultOptionsToTable($databasePlatform, $schemaConfig, $table); + $this->applyDefaultPlatformOptionsToColumns($databasePlatform, $schemaConfig, $table); + $this->normalizeDecimalTypeColumnDefaultValue($databasePlatform, $table); + $this->normalizeTableForMariaDBOrMySQL($databasePlatform, $table); + $this->normalizeTableForPostgreSQL($databasePlatform, $table); + $this->normalizeTableForSQLite($databasePlatform, $table); + $this->normalizeTableIndex($databasePlatform, $connection, $schemaConfig, $table); + }); + + return $tables; + } + + /** + * @param AbstractPlatform $platform + * @param Table &$table + */ + protected function normalizeTableIdentifiers(AbstractPlatform $platform, Table &$table): void + { + $table = new Table( + // name + $platform->quoteIdentifier($this->trimIdentifierQuotes($table->getName())), + // columns + $this->normalizeTableColumnIdentifiers($platform, $table->getColumns()), + // indexes + $this->normalizeTableIndexIdentifiers($platform, $table->getIndexes()), + // uniqueConstraints + $this->normalizeTableUniqueConstraintIdentifiers($platform, $table->getUniqueConstraints()), + // fkConstraints + $this->normalizeTableForeignKeyConstraints($platform, $table->getForeignKeys()), + // options + $table->getOptions(), + ); + } + + protected function applyDefaultOptionsToTable(AbstractPlatform $platform, SchemaConfig $schemaConfig, Table $table): void + { + $defaultTableOptions = $schemaConfig->getDefaultTableOptions(); + $defaultColumnCollation = $defaultTableOptions['collation'] ?? null; + $defaultColumCharset = $defaultTableOptions['charset'] ?? null; + $defaultTableEngine = $defaultTableOptions['engine'] ?? 'InnoDB'; + + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + if (!$table->hasOption('charset') && $defaultColumCharset !== null) { + $table->addOption('charset', $defaultColumCharset); + } + if (!$table->hasOption('collation') && $defaultColumnCollation !== null) { + $table->addOption('collation', $defaultColumnCollation); + } + if (!$table->hasOption('engine')) { + $table->addOption('engine', $defaultTableEngine); + } + if (!$table->hasOption('row_format')) { + $table->addOption('row_format', 'Dynamic'); + } + } + } + + protected function applyDefaultPlatformOptionsToColumns(AbstractPlatform $platform, SchemaConfig $schemaConfig, Table $table): void + { + $defaultTableOptions = $schemaConfig->getDefaultTableOptions(); + $defaultColumnCollation = $defaultTableOptions['collation'] ?? ''; + $defaultColumCharset = $defaultTableOptions['charset'] ?? ''; + foreach ($table->getColumns() as $column) { + $columnType = $column->getType(); + if (($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) + && (($columnType instanceof StringType || $columnType instanceof TextType)) + ) { + $columnCollation = (string)($column->getPlatformOptions()['collation'] ?? ''); + $columnCharset = (string)($column->getPlatformOptions()['charset'] ?? ''); + if ($defaultColumnCollation !== '' && $columnCollation === '') { + $column->setPlatformOption('collation', $defaultColumnCollation); + } + if ($defaultColumCharset !== '' && $columnCharset === '') { + $column->setPlatformOption('charset', $defaultColumCharset); + } + } + if ($platform instanceof DoctrinePostgreSQLPlatform + && (($columnType instanceof StringType || $columnType instanceof TextType)) + ) { + // Unset collation and charset in platformOptions + $column->setPlatformOption('collation', null); + $column->setPlatformOption('charset', null); + } + if ($platform instanceof DoctrineSQLitePlatform) { + if ($columnType instanceof StringType || $columnType instanceof TextType || $columnType instanceof JsonType) { + $column->setPlatformOption('collation', 'BINARY'); + } + if ($columnType instanceof StringType || $columnType instanceof TextType) { + // Unset charset in platformOptions + $column->setPlatformOption('charset', null); + } + } + } + } + + /** + * Normalize DecimalType fields default values to have the correct format defined by the column + * scale settings to ensure working comparison with Doctrine DBAL v4 {@see AbstractPlatform::columnsEqual()}. + */ + protected function normalizeDecimalTypeColumnDefaultValue(AbstractPlatform $platform, Table $table): void + { + if (!($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform)) { + return; + } + foreach ($table->getColumns() as $column) { + $columnType = $column->getType(); + if (!($columnType instanceof DecimalType)) { + continue; + } + if (!$column->getNotnull() && $column->getDefault() === null) { + continue; + } + $column->setDefault(number_format( + (float)$column->getDefault(), + // Scale defines the count of decimal digits after the decimal separator + $column->getScale(), + '.', + '', + )); + } + } + + /** + * @param AbstractPlatform $platform + * @param ForeignKeyConstraint[] $foreignKeyConstraints + * @return ForeignKeyConstraint[] + */ + protected function normalizeTableForeignKeyConstraints(AbstractPlatform $platform, array $foreignKeyConstraints): array + { + $normalizedForeignKeyConstraints = []; + foreach ($foreignKeyConstraints as $foreignKeyConstraint) { + $normalizedForeignKeyConstraints[] = new ForeignKeyConstraint( + // localColumnNames + $foreignKeyConstraint->getQuotedLocalColumns($platform), + // foreignTableName + $platform->quoteIdentifier($this->trimIdentifierQuotes($foreignKeyConstraint->getForeignTableName())), + // foreignColumnNames + $foreignKeyConstraint->getQuotedForeignColumns($platform), + // name + $platform->quoteIdentifier($foreignKeyConstraint->getName()), + // options + $foreignKeyConstraint->getOptions(), + ); + } + return $normalizedForeignKeyConstraints; + } + + /** + * Ensure correct initialized identifier names for table unique constraints. + * + * @param UniqueConstraint[] $uniqueConstraints + * @return UniqueConstraint[] + */ + protected function normalizeTableUniqueConstraintIdentifiers(AbstractPlatform $platform, array $uniqueConstraints): array + { + $normalizedUniqueConstraints = []; + foreach ($uniqueConstraints as $uniqueConstraint) { + $columns = $uniqueConstraint->getColumns(); + foreach ($columns as &$column) { + $column = $platform->quoteIdentifier($this->trimIdentifierQuotes($column)); + } + $normalizedUniqueConstraints[] = new UniqueConstraint( + // name + $platform->quoteIdentifier($this->trimIdentifierQuotes($uniqueConstraint->getName())), + // columns + $columns, + // flags + $uniqueConstraint->getFlags(), + // options + $uniqueConstraint->getOptions(), + ); + } + return $normalizedUniqueConstraints; + } + + /** + * Ensure correct initialized identifier names for table indexes. + * + * @param AbstractPlatform $platform + * @param Index[] $indexes + * @return Index[] + */ + protected function normalizeTableIndexIdentifiers(AbstractPlatform $platform, array $indexes): array + { + $normalizedIndexes = []; + foreach ($indexes as $index) { + $columns = $index->getColumns(); + foreach ($columns as &$column) { + $column = $platform->quoteIdentifier($this->trimIdentifierQuotes($column)); + } + $normalizedIndexes[] = new Index( + // name + $platform->quoteIdentifier($this->trimIdentifierQuotes($index->getName())), + // columns + $columns, + // isUnique + $index->isUnique(), + // isPrimary + $index->isPrimary(), + // flags + $index->getFlags(), + // options + $index->getOptions(), + ); + } + return $normalizedIndexes; + } + + /** + * Ensure correct initialized identifier names for table columns. + * + * @param AbstractPlatform $platform + * @param Column[] $columns + * @return Column[] + */ + protected function normalizeTableColumnIdentifiers(AbstractPlatform $platform, array $columns): array + { + $normalizedColumns = []; + foreach ($columns as $column) { + // It seems that since Doctrine DBAL 4 matching the autoincrement column, when defined as `UNSIGNED` is + // not working anymore. The platform always create a signed autoincrement primary key, and it looks that + // this code has not changed between v3 and v4. It's mysterious why we need to remove the UNSIGNED flag + // for autoincrement columns for SQLite. + // @todo This needs further validation and investigation. + if ($column->getAutoincrement() === true && $platform instanceof DoctrineSQLitePlatform) { + // @todo why do we need this with Doctrine DBAL 4 ??? + $column->setUnsigned(false); + } + $columnData = $this->prepareColumnOptions($column); + unset($columnData['name'], $columnData['type']); + $normalizedColumns[] = new Column( + // name + $platform->quoteIdentifier($this->trimIdentifierQuotes($column->getName())), + // type + $column->getType(), + // options + $columnData, + ); + } + return $normalizedColumns; + } + + /** + * Doctrine DBAL 4+ removed the default length for string and binary fields, but they are required for MariaDB and + * MySQL database backends. Therefore, we need to normalize the tables and set column length for fields not having + * them. + * + * Missing column length may happen by the `DefaultTCASchema` enriched structure information, which is and should + * be database vendor unaware. Therefore, we normalize this here now. + * + * @see https://github.com/doctrine/dbal/blob/4.0.x/UPGRADE.md#bc-break-changes-in-handling-string-and-binary-columns + * @see https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-abstractplatform-methods-that-describe-the-default-and-the-maximum-column-lengths + */ + protected function normalizeTableForMariaDBOrMySQL(AbstractPlatform $databasePlatform, Table $table): void + { + if (!($databasePlatform instanceof DoctrineMariaDBPlatform || $databasePlatform instanceof DoctrineMySQLPlatform)) { + return; + } + foreach ($table->getColumns() as $column) { + $columnType = $column->getType(); + if ($columnType instanceof StringType || $columnType instanceof BinaryType) { + $column->setLength($column->getLength() ?? 255); + if ($column->getLength() > 4000) { + $column->setLength(4000); + } + } + if ($columnType instanceof BlobType || $columnType instanceof TextType) { + // Doctrine does not provide a length for LONGTEXT/LONGBLOB columns, thus + // ensuring a default length. This is essential for column comparison. + $column->setLength($column->getLength() ?? 2147483647); + } + } + } + + /** + * Normalize fields towards PostgreSQL compatibility. + */ + protected function normalizeTableForPostgreSQL(AbstractPlatform $databasePlatform, Table $table): void + { + if (!($databasePlatform instanceof DoctrinePostgreSQLPlatform)) { + return; + } + + foreach ($table->getColumns() as $column) { + // PostgreSQL does not support length definition for integer type fields. Therefore, we remove the pseudo + // MySQL length information to avoid compare issues. + if (( + $column->getType() instanceof SmallIntType + || $column->getType() instanceof IntegerType + || $column->getType() instanceof BigIntType + ) && $column->getLength() !== null + ) { + $column->setLength(null); + } + } + } + + /** + * Normalize fields towards SQLite compatibility. + * + * @see https://github.com/doctrine/dbal/commit/33555d36e7e7d07a5880e01 + */ + protected function normalizeTableForSQLite(AbstractPlatform $databasePlatform, Table $table): void + { + if (!($databasePlatform instanceof DoctrineSQLitePlatform)) { + return; + } + + // doctrine/dbal detects both sqlite autoincrement variants (row_id alias and autoincrement) through assumptions + // which have been made. TYPO3 reads the ext_tables.sql files as MySQL/MariaDB variant, thus not setting the + // autoincrement value to true for the row_id alias variant, which leads to an endless mismatch during database + // comparison. This method adopts the doctrine/dbal assumption and apply it to the meta schema to mitigate + // endless database compare detections in these cases. + // + // @see https://github.com/doctrine/dbal/commit/33555d36e7e7d07a5880e01 + $primaryColumns = $table->getPrimaryKey()?->getColumns() ?? []; + $primaryKeyColumnCount = count($primaryColumns); + $firstPrimaryKeyColumnName = $primaryColumns[0] ?? ''; + $singlePrimaryKeyColumn = $table->hasColumn($firstPrimaryKeyColumnName) + ? $table->getColumn($firstPrimaryKeyColumnName) + : null; + if ($primaryKeyColumnCount === 1 + && $singlePrimaryKeyColumn !== null + && $singlePrimaryKeyColumn->getType() instanceof IntegerType + ) { + $singlePrimaryKeyColumn->setAutoincrement(true); + } + } + + protected function normalizeTableIndex(AbstractPlatform $platform, Typo3Connection $connection, SchemaConfig $schemaConfig, Table &$table): void + { + $indexes = []; + foreach ($table->getIndexes() as $key => $index) { + $indexName = $index->getName(); + // PostgreSQL and sqlite require index names to be unique per database/schema. + if ($platform instanceof DoctrinePostgreSQLPlatform || $platform instanceof DoctrineSQLitePlatform) { + $indexName = $indexName . '_' . hash('crc32b', $table->getName() . '_' . $indexName); + } + // Remove the length information from column names for indexes if required. + $cleanedColumnNames = array_map( + static function (string $columnName) use ($connection): string { + $platform = $connection->getDatabasePlatform(); + if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) { + // Returning the unquoted, unmodified version of the column name since + // it can include the length information for BLOB/TEXT columns which + // may not be quoted. + return $columnName; + } + return $connection->quoteIdentifier(preg_replace('/\(\d+\)$/', '', $columnName)); + }, + $index->getUnquotedColumns() + ); + $indexes[$key] = new Index( + $connection->quoteIdentifier($indexName), + $cleanedColumnNames, + $index->isUnique(), + $index->isPrimary(), + $index->getFlags(), + $index->getOptions() + ); + } + $table = new Table( + $table->getQuotedName($connection->getDatabasePlatform()), + $table->getColumns(), + $indexes, + [], + $table->getForeignKeys(), + array_merge($schemaConfig->getDefaultTableOptions(), $table->getOptions()) + ); + $table->setSchemaConfig($schemaConfig); + } + + /** + * Trim all possible identifier quotes from identifier. This method has been cloned from Doctrine DBAL. + * + * @see \Doctrine\DBAL\Schema\AbstractAsset::trimQuotes() + */ + private function trimIdentifierQuotes(string $identifier): string + { + return str_replace(['`', '"', '[', ']'], '', $identifier); + } + + /** + * Retrieve data migration statements for PostgreSQL SERIAL to IDENTITY autoincrement column changes. + * + * @see ConnectionMigrator::getChangedFieldUpdateSuggestions() + * + * @return string[] + * @throws DBALException + */ + private function getPostgreSQLMigrationStatements(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): array + { + $sequenceInfo = $this->getTableSequenceInformation($connection, $changedTable, $modifiedColumn); + if ($sequenceInfo === null) { + return []; + } + $newColumn = $modifiedColumn->getNewColumn(); + $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName()); + $fieldName = $this->trimIdentifierQuotes($newColumn->getName()); + $seqId = $sequenceInfo['seqid']; + $combinedStatementParts = []; + // @todo use QueryBuilder to generate the upgrade statement + $combinedStatementParts[] = sprintf( + 'UPDATE %s SET deptype = %s WHERE (classid, objid, objsubid) = (%s::regclass, %s, 0) AND deptype = %s', + $connection->quoteIdentifier('pg_depend'), + $connection->quote('i'), + $connection->quote('pg_class'), + $connection->quote((string)$seqId), + $connection->quote('a'), + ); + // mark the column as identity column + // @todo use QueryBuilder to generate the upgrade statement + $combinedStatementParts[] = sprintf( + 'UPDATE %s SET attidentity = %s WHERE attrelid = %s::regclass AND attname = %s::name', + $connection->quoteIdentifier('pg_attribute'), + $connection->quote('d'), + $connection->quote($tableName), + $connection->quote($fieldName) + ); + return $combinedStatementParts; + } + + /** + * Fetch PostgreSQL table sequence information. If existing, that means that a old Doctrine DBAL v3 autoincrement + * sequence has not been migrated and altered yet. + * + * @see https://github.com/doctrine/dbal/blob/4.0.x/UPGRADE.md#bc-break-auto-increment-columns-on-postgresql-are-implemented-as-identity-not-serial + * @see ConnectionMigrator::getPostgreSQLMigrationStatements() + * + * @return array{seqid: int, objid: int}|null + * @throws DBALException + */ + private function getTableSequenceInformation(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): ?array + { + $oldColumn = $modifiedColumn->getOldColumn(); + $newColumn = $modifiedColumn->getNewColumn(); + $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName()); + $fieldName = $this->trimIdentifierQuotes($newColumn->getName()); + $isAutoIncrementChange = ($newColumn->getAutoincrement() === true && $newColumn->getAutoincrement() !== $oldColumn->getAutoincrement()); + + if (!($connection->getDatabasePlatform() instanceof DoctrinePostgreSQLPlatform && $isAutoIncrementChange)) { + return null; + } + $colNum = $this->getTableFieldColumnNumber($connection, $tableName, $fieldName); + if ($colNum === null) { + return null; + } + return $this->getSequenceInfo($connection, $tableName, $fieldName, $colNum); + } + + /** + * Fetch PostgreSQL table sequence information. If existing, that means that a old Doctrine DBAL v3 autoincrement + * sequence has not been migrated and altered yet. + * + * @see https://github.com/doctrine/dbal/blob/4.0.x/UPGRADE.md#bc-break-auto-increment-columns-on-postgresql-are-implemented-as-identity-not-serial + * @see ConnectionMigrator::getTableSequenceInformation() + * + * @return array{seqid: int, objid: int}|null + * @throws DBALException + */ + private function getSequenceInfo(Typo3Connection $connection, string $table, string $field, int $colNum): ?array + { + $quotedTable = $connection->quote($table); + $colNum = $connection->quote((string)$colNum); + $quotedPgClass = $connection->quote('pg_class'); + $depType = $connection->quote('a'); + // @todo Use QueryBuilder to retrieve the data + $sql = sprintf( + 'SELECT classid as seqid, objid FROM pg_depend WHERE (refclassid, refobjid, refobjsubid) = (%s::regclass, %s::regclass, %s) AND classid = %s::regclass AND objsubid = 0 AND deptype = %s;', + $quotedPgClass, + $quotedTable, + $colNum, + $quotedPgClass, + $depType + ); + $rows = $connection->executeQuery($sql)->fetchAllAssociative(); + $count = count($rows); + if ($count === 1) { + $row = reset($rows); + if (is_array($row)) { + return $row; + } + } elseif ($count > 1) { + // @todo Throw a concrete exception class + throw new \RuntimeException( + sprintf( + 'Found more than one linked sequence table for %s.%s', + $table, + $field + ), + 1705673988 + ); + } + + return null; + } + + /** + * Fetch PostgreSQL table field column nummber from schema definition. + * + * @see https://github.com/doctrine/dbal/blob/4.0.x/UPGRADE.md#bc-break-auto-increment-columns-on-postgresql-are-implemented-as-identity-not-serial + * @see ConnectionMigrator::getTableSequenceInformation() + */ + private function getTableFieldColumnNumber(Typo3Connection $connection, string $table, string $field): ?int + { + $table = $connection->quote($table); + $field = $connection->quote($field); + // @todo Use QueryBuilder to retrieve the data + $sql = sprintf( + 'SELECT attnum FROM pg_attribute WHERE attrelid = %s::regclass AND attname = %s::name;', + $table, + $field + ); + $rows = $connection->executeQuery($sql)->fetchAllAssociative(); + $row = reset($rows); + if (is_array($row)) { + return (int)$row['attnum']; + } + return null; + } + + /** + * @todo DataMigration - handle this in another way after refactoring the connection migration stuff. + * + * @param Typo3Connection $connection + * @param TableDiff $changedTable + * @param ColumnDiff $modifiedColumn + * @return array + */ + private function getIndexedSearchTruncateTablePrerequisiteStatements(Typo3Connection $connection, TableDiff $changedTable, ColumnDiff $modifiedColumn): array + { + /** @var array $tableFields */ + $tableFields = [ + 'index_phash' => ['phash', 'phash_grouping', 'contentHash'], + 'index_fulltext' => ['phash'], + 'index_rel' => ['phash', 'wid'], + 'index_words' => ['wid'], + 'index_section' => ['phash', 'phash_t3'], + 'index_grlist' => ['phash', 'phash_x', 'hash_gr_list'], + ]; + $tableName = $this->trimIdentifierQuotes($changedTable->getOldTable()->getName()); + $oldType = $modifiedColumn->getOldColumn()->getType(); + $newType = $modifiedColumn->getNewColumn()->getType(); + if (($tableFields[$tableName] ?? []) === [] + || !($oldType instanceof IntegerType) + || !($newType instanceof StringType) + ) { + return []; + } + $databasePlatform = $connection->getDatabasePlatform(); + if (in_array($this->trimIdentifierQuotes($modifiedColumn->getOldColumn()->getName()), $tableFields[$tableName], true)) { + return [ + $databasePlatform->getTruncateTableSQL($changedTable->getOldTable()->getQuotedName($databasePlatform)) => 'Truncate table needed due to type change', + ]; + } + return []; + } +} diff --git a/Classes/Database/Schema/DefaultTcaSchema.php b/Classes/Database/Schema/DefaultTcaSchema.php new file mode 100644 index 0000000..f30278b --- /dev/null +++ b/Classes/Database/Schema/DefaultTcaSchema.php @@ -0,0 +1,1275 @@ + $tables + * @return array Modified tables + */ + public function enrich(array $tables): array + { + // Sanity check to ensure all TCA tables are already defined in the incoming table list. + // This prevents misuse, calling code needs to ensure there is at least an empty + // table object (no columns) for all TCA tables. + $existingTableNames = array_keys($tables); + foreach ($this->tcaSchemaFactory->all() as $tableName => $schema) { + if (!in_array($tableName, $existingTableNames, true)) { + throw new \RuntimeException( + 'Table name ' . $tableName . ' does not exist in incoming table list', + 1696424993 + ); + } + } + + $tables = $this->enrichSingleTableFieldsFromTcaCtrl($tables); + $tables = $this->enrichSingleTableFieldsFromTcaColumns($tables); + return $this->enrichMmTables($tables); + } + + /** + * Add single fields like uid, sorting and similar, based on tables TCA 'ctrl' settings. + * + * @param array $tables + * @return array + */ + protected function enrichSingleTableFieldsFromTcaCtrl(array $tables): array + { + foreach ($this->tcaSchemaFactory->all() as $tableName => $schema) { + if (!$this->isColumnDefinedForTable($tables, $tableName, 'uid')) { + $tables[$tableName]->addColumn( + $this->quote('uid'), + Types::INTEGER, + [ + 'notnull' => true, + 'unsigned' => true, + 'autoincrement' => true, + ] + ); + $tables[$tableName]->setPrimaryKey(['uid']); + } + + // pid column and prepare parent key if pid is not defined + $pidColumnAdded = false; + if (!$this->isColumnDefinedForTable($tables, $tableName, 'pid')) { + $options = [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ]; + $tables[$tableName]->addColumn($this->quote('pid'), Types::INTEGER, $options); + $pidColumnAdded = true; + } + + // tstamp column + // not converted to bigint because already unsigned and date before 1970 not needed + if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // crdate column + if ($schema->hasCapability(TcaSchemaCapability::CreatedAt) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // deleted column - soft delete + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName()), + Types::SMALLINT, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // disabled column + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName()), + Types::SMALLINT, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // starttime column + // not converted to bigint because already unsigned and date before 1970 not needed + if ($schema->hasCapability(TcaSchemaCapability::RestrictionStartTime) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // endtime column + // not converted to bigint because already unsigned and date before 1970 not needed + if ($schema->hasCapability(TcaSchemaCapability::RestrictionEndTime) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // fe_group column + if ($schema->hasCapability(TcaSchemaCapability::RestrictionUserGroup) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName()), + Types::STRING, + [ + 'default' => '0', + 'notnull' => true, + 'length' => 255, + ] + ); + } + + // sorting column + if ($schema->hasCapability(TcaSchemaCapability::SortByField) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => false, + ] + ); + } + + // index on pid column and maybe others - only if pid has not been defined via ext_tables.sql before + if ($pidColumnAdded && !$this->isIndexDefinedForTable($tables, $tableName, 'parent')) { + $parentIndexFields = ['pid']; + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $parentIndexFields[] = $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + } + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $parentIndexFields[] = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + } + $tables[$tableName]->addIndex($parentIndexFields, 'parent'); + } + + // description column + if ($schema->hasCapability(TcaSchemaCapability::InternalDescription) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::InternalDescription)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::InternalDescription)->getFieldName()), + Types::TEXT, + [ + 'notnull' => false, + 'length' => 65535, + ] + ); + } + + // editlock column + if ($schema->hasCapability(TcaSchemaCapability::EditLock) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()), + Types::SMALLINT, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // sys_language_uid column + $languageColumnAdded = false; + if ($schema->isLanguageAware() + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => false, + ] + ); + $languageColumnAdded = true; + } + + // language_tag column + if ($schema->isLanguageAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 'language_tag') + ) { + $tables[$tableName]->addColumn( + $this->quote('language_tag'), + Types::STRING, + [ + 'default' => '', + 'notnull' => true, + 'length' => 35, + ] + ); + } + + // l10n_parent column + $translationOriginPointerColumnAdded = false; + if ($schema->isLanguageAware() + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + $translationOriginPointerColumnAdded = true; + } + + // Add index for sys_language_uid and l10n_parent + if ($languageColumnAdded + && $translationOriginPointerColumnAdded + && !$this->isIndexDefinedForTable($tables, $tableName, 'language_identifier') + ) { + $tables[$tableName]->addIndex([ + (string)$schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(), + (string)$schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(), + ], 'language_identifier'); + } + + // l10n_source column + if ($schema->isLanguageAware() + && $schema->getCapability(TcaSchemaCapability::Language)->hasTranslationSourceField() + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::Language)->getTranslationSourceField()->getName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::Language)->getTranslationSourceField()->getName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + $tables[$tableName]->addIndex([$schema->getCapability(TcaSchemaCapability::Language)->getTranslationSourceField()->getName()], 'translation_source'); + } + + // l10n_state column, this is not defined in TCA, but always added if the table is language-aware + if ($schema->isLanguageAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 'l10n_state') + ) { + $tables[$tableName]->addColumn( + $this->quote('l10n_state'), + Types::TEXT, + [ + 'notnull' => false, + 'length' => 65535, + ] + ); + } + + // t3_origuid column + if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField) + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName()), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // l18n_diffsource column + if ($schema->isLanguageAware() && $schema->getCapability(TcaSchemaCapability::Language)->hasDiffSourceField() + && !$this->isColumnDefinedForTable($tables, $tableName, $schema->getCapability(TcaSchemaCapability::Language)->getDiffSourceField()->getName()) + ) { + $tables[$tableName]->addColumn( + $this->quote($schema->getCapability(TcaSchemaCapability::Language)->getDiffSourceField()->getName()), + Types::BLOB, + [ + // mediumblob (16MB) on mysql + 'length' => 16777215, + 'notnull' => false, + ] + ); + } + + // workspaces t3ver_oid column + if ($schema->isWorkspaceAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 't3ver_oid') + ) { + $tables[$tableName]->addColumn( + $this->quote('t3ver_oid'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // workspaces t3ver_wsid column + if ($schema->isWorkspaceAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 't3ver_wsid') + ) { + $tables[$tableName]->addColumn( + $this->quote('t3ver_wsid'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + // workspaces t3ver_state column + if ($schema->isWorkspaceAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 't3ver_state') + ) { + $tables[$tableName]->addColumn( + $this->quote('t3ver_state'), + Types::SMALLINT, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => false, + ] + ); + } + + // workspaces t3ver_stage column + if ($schema->isWorkspaceAware() + && !$this->isColumnDefinedForTable($tables, $tableName, 't3ver_stage') + ) { + $tables[$tableName]->addColumn( + $this->quote('t3ver_stage'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => false, + ] + ); + } + + // workspaces index on t3ver_oid and t3ver_wsid fields + if ($schema->isWorkspaceAware() + && !$this->isIndexDefinedForTable($tables, $tableName, 't3ver_oid') + ) { + $tables[$tableName]->addIndex(['t3ver_oid', 't3ver_wsid'], 't3ver_oid'); + } + } + + return $tables; + } + + /** + * Add single fields based on tables TCA 'columns'. + * + * @param array $tables + * @return array + */ + protected function enrichSingleTableFieldsFromTcaColumns(array $tables): array + { + foreach ($this->tcaSchemaFactory->all() as $tableName => $schema) { + /** @var TcaSchema $schema */ + // In the following, columns for TCA fields with a dedicated TCA type are + // added. In the unlikely case that no columns exist, we can skip the table. + if ($schema->getFields()->count() === 0) { + continue; + } + $tableConnectionPlatform = $this->connectionPool->getConnectionForTable($tableName)->getDatabasePlatform(); + + foreach ($schema->getFields() as $fieldName => $fieldType) { + if ($this->isColumnDefinedForTable($tables, $tableName, $fieldName)) { + continue; + } + $fieldTypeConfiguration = $fieldType->getConfiguration(); + switch (true) { + case $fieldType instanceof CategoryFieldType: + if ($fieldType->getRelationshipType() === RelationshipType::OneToMany) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'notnull' => false, + ] + ); + } else { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + break; + + case $fieldType instanceof DateTimeFieldType: + $dbType = $fieldType->getPersistenceType() ?? ''; + // Add datetime fields for all tables, defining datetime columns (TCA type=datetime), except + // those columns, which had already been added due to definition in "ctrl", e.g. "starttime". + if ($dbType) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + $dbType, + [ + 'notnull' => !$fieldType->isNullable(), + ] + ); + } else { + // int unsigned: from 1970 to 2106. + // int signed: from 1901 to 2038. + // bigint unsigned/signed: from whenever to whenever + // + // Anything like crdate,tstamp,starttime,endtime is good with + // "int unsigned" and can survive the 2038 apocalypse (until 2106). + // + // However, anything that has birthdates or dates + // from the past (sys_file_metadata.content_creation_date) was saved + // as a SIGNED INT. It allowed birthdays of people older than 1970, + // but with the downside that it ends in 2038. + // + // This is now changed to utilize BIGINT everywhere, even when smaller + // date ranges are requested. To reduce complexity, we specifically + // do not evaluate "range.upper/lower" fields and use a unified type here. + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::BIGINT, + [ + 'default' => $fieldType->isNullable() ? null : 0, + 'notnull' => !$fieldType->isNullable(), + 'unsigned' => false, + ] + ); + } + break; + + case $fieldType instanceof SlugFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'length' => 65535, + 'notnull' => false, + ] + ); + break; + + case $fieldType instanceof JsonFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::JSON, + [ + 'notnull' => false, + ] + ); + break; + + case $fieldType instanceof UuidFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::GUID, + [ + 'notnull' => true, + ] + ); + break; + + case $fieldType instanceof FileFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + break; + + case $fieldType instanceof FolderFieldType: + case $fieldType instanceof ImageManipulationFieldType: + case $fieldType instanceof FlexFormFieldType: + case $fieldType instanceof TextFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'notnull' => false, + ] + ); + break; + + case $fieldType instanceof EmailFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => 255, + 'default' => ($fieldType->isNullable() ? null : ''), + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + + case $fieldType instanceof CheckboxFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::SMALLINT, + [ + // Even though CheckboxFieldType::getDefaultValue() returns null, the DB stores "0" + // as this was like that before, and might have complications, so should be analyzed separately + 'default' => $fieldType->getDefaultValue() ?? 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + break; + + case $fieldType instanceof LanguageFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => false, + ] + ); + break; + + case $fieldType instanceof GroupFieldType: + if ($fieldType->getRelationshipType() === RelationshipType::ManyToMany) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } else { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'notnull' => false, + ] + ); + } + break; + + case $fieldType instanceof PasswordFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'default' => ($fieldType->isNullable() ? null : ''), + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + + case $fieldType instanceof ColorFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => $fieldType->supportsOpacity() ? 9 : 7, + 'default' => ($fieldType->isNullable() ? null : ''), + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + + case $fieldType instanceof RadioFieldType: + $hasItemsProcFunc = ($fieldTypeConfiguration['itemsProcFunc'] ?? '') !== '' + || ($fieldTypeConfiguration['itemsProcessors'] ?? []) !== []; + $items = $fieldTypeConfiguration['items'] ?? []; + // With itemsProcFunc we can't be sure, which values are persisted. Use type string. + if ($hasItemsProcFunc) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => 255, + 'default' => '', + 'notnull' => true, + ] + ); + break; + } + // If no items are configured, use type string to be safe for values added directly. + if ($items === []) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => 255, + 'default' => '', + 'notnull' => true, + ] + ); + break; + } + // If only one value is NOT an integer use type string. + foreach ($items as $item) { + if (!MathUtility::canBeInterpretedAsInteger($item['value'])) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => 255, + 'default' => '', + 'notnull' => true, + ] + ); + // continue with next $tableDefinition['columns'] + // see: DefaultTcaSchemaTest->enrichAddsRadioStringVerifyThatCorrectLoopIsContinued() + break 2; + } + } + // Use integer type. + $allValues = array_map(fn(array $item): int => (int)$item['value'], $items); + $minValue = min($allValues); + $maxValue = max($allValues); + // Try to safe some bytes - can be reconsidered to simply use Types::INTEGER. + $integerType = ($minValue >= -32768 && $maxValue < 32768) + ? Types::SMALLINT + : Types::INTEGER; + $tables[$tableName]->addColumn( + $this->quote($fieldName), + $integerType, + [ + 'default' => 0, + 'notnull' => true, + ] + ); + break; + + case $fieldType instanceof LinkFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'length' => 65535, + 'default' => $fieldType->isNullable() ? null : '', + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + + case $fieldType instanceof InputFieldType: + $length = (int)($fieldTypeConfiguration['max'] ?? 255); + if ($length > 255) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'length' => 65535, + 'default' => $fieldType->isNullable() ? null : '', + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + } + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => $length, + 'default' => $fieldType->isNullable() ? null : '', + 'notnull' => !$fieldType->isNullable(), + ] + ); + break; + + case $fieldType instanceof InlineFieldType: + // Must be MM or foreign_field + if (in_array($fieldType->getRelationshipType(), [RelationshipType::OneToOne, RelationshipType::ManyToMany, RelationshipType::OneToMany], true) + || ($fieldType->getRelationshipType() === RelationshipType::ManyToOne && ($fieldTypeConfiguration['foreign_field'] ?? '') !== '') + ) { + // Parent "count" field + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } else { + // Inline "csv" + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'default' => '', + 'notnull' => true, + 'length' => 255, + ] + ); + } + if (($fieldTypeConfiguration['foreign_field'] ?? '') !== '') { + // Add definition for "foreign_field" (contains parent uid) in the child table if it is not defined + // in child TCA, or if it is "just" a "passthrough" field, and not manually configured in ext_tables.sql + $childTable = $fieldTypeConfiguration['foreign_table']; + if (!(($tables[$childTable] ?? null) instanceof Table)) { + throw new DefaultTcaSchemaTablePositionException('Table ' . $childTable . ' not found in schema list', 1527854474); + } + $childTableForeignFieldName = $fieldTypeConfiguration['foreign_field']; + if ($this->tcaSchemaFactory->has($childTable)) { + $childSchema = $this->tcaSchemaFactory->get($childTable); + if ((!$childSchema->hasField($childTableForeignFieldName) || $childSchema->getField($childTableForeignFieldName)->isType(TableColumnType::PASSTHROUGH)) + && !$this->isColumnDefinedForTable($tables, $childTable, $childTableForeignFieldName) + ) { + $tables[$childTable]->addColumn( + $this->quote($childTableForeignFieldName), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + // Add definition for "foreign_table_field" (contains name of parent table) in the child table if it is not + // defined in child TCA or if it is "just" a "passthrough" field, and not manually configured in ext_tables.sql + $childTableForeignTableFieldName = $fieldTypeConfiguration['foreign_table_field'] ?? ''; + if ($childTableForeignTableFieldName !== '' + && (!$childSchema->hasField($childTableForeignTableFieldName) || $childSchema->getField($childTableForeignTableFieldName)->isType(TableColumnType::PASSTHROUGH)) + && !$this->isColumnDefinedForTable($tables, $childTable, $childTableForeignTableFieldName) + ) { + $tables[$childTable]->addColumn( + $this->quote($childTableForeignTableFieldName), + Types::STRING, + [ + 'default' => '', + 'notnull' => true, + 'length' => 255, + ] + ); + } + } + } + break; + + case $fieldType instanceof NumberFieldType: + $type = $fieldType->getFormat() === 'decimal' ? Types::DECIMAL : Types::INTEGER; + $lowerRange = $fieldTypeConfiguration['range']['lower'] ?? -1; + // Integer type for all database platforms. + if ($type === Types::INTEGER) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'default' => $fieldType->isNullable() === true ? null : 0, + 'notnull' => !$fieldType->isNullable(), + 'unsigned' => $lowerRange >= 0, + ] + ); + break; + } + // SQLite internally defines NUMERIC() fields as real, and therefore as floating numbers. pdo_sqlite + // then returns PHP float which can lead to rounding issues. See https://bugs.php.net/bug.php?id=81397 + // for more details. We create a 'string' field on SQLite as workaround. + // @todo: Database schema should be created with MySQL in mind and not mixed. Transforming to the + // concrete database platform is handled in the database compare area. Sadly, this is not + // possible right now but upcoming preparation towards doctrine/dbal 4 makes it possible to + // move this "hack" to a different place. + if ($tableConnectionPlatform instanceof DoctrineSQLitePlatform) { + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'default' => $fieldType->isNullable() === true ? null : '0.00', + 'notnull' => !$fieldType->isNullable(), + 'length' => 255, + ] + ); + break; + } + // Decimal for all supported platforms except SQLite + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::DECIMAL, + [ + 'default' => $fieldType->isNullable() === true ? null : 0.00, + 'notnull' => !$fieldType->isNullable(), + 'unsigned' => $lowerRange >= 0, + 'precision' => 10, + 'scale' => 2, + ] + ); + break; + + case $fieldType instanceof SelectRelationFieldType || $fieldType instanceof StaticSelectFieldType: + if (($fieldTypeConfiguration['MM'] ?? '') !== '') { + // MM relation, this is a "parent count" field. Have an int. + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ] + ); + break; + } + $dbFieldLength = (int)($fieldTypeConfiguration['dbFieldLength'] ?? 0); + // If itemsProcFunc is not set, check the item values + if ( + ($fieldTypeConfiguration['itemsProcFunc'] ?? '') === '' + || ($fieldTypeConfiguration['itemsProcessors'] ?? []) !== [] + ) { + $items = $fieldTypeConfiguration['items'] ?? []; + $itemsContainsOnlyIntegers = true; + $itemsContainNull = false; + foreach ($items as $item) { + // Null values are valid for integer columns (stored as database NULL) + if ($item['value'] === null) { + $itemsContainNull = true; + continue; + } + if (!MathUtility::canBeInterpretedAsInteger($item['value'])) { + $itemsContainsOnlyIntegers = false; + break; + } + } + $itemsAreAllPositive = true; + foreach ($items as $item) { + // Skip null values for positive check (null is neither positive nor negative) + if ($item['value'] === null) { + continue; + } + if ($item['value'] < 0) { + $itemsAreAllPositive = false; + break; + } + } + // @todo: The dependency to renderType is unfortunate here. It's only purpose is to potentially have int fields + // instead of string when this is a 'single' relation / value. However, renderType should usually not + // influence DB layer at all. Maybe 'selectSingle' should be changed to an own 'type' instead to make + // this more explicit. Maybe DataHandler could benefit from this as well? + if (($fieldTypeConfiguration['renderType'] ?? '') === 'selectSingle' || ($fieldTypeConfiguration['maxitems'] ?? 0) === 1) { + // With 'selectSingle' or with 'maxitems = 1', only a single value can be selected. + if ( + !is_array($fieldTypeConfiguration['fileFolderConfig'] ?? false) + && ($items !== [] || ($fieldTypeConfiguration['foreign_table'] ?? '') !== '') + && $itemsContainsOnlyIntegers === true + ) { + // If the item list is empty, or if it contains only int values, an int field is enough. + // Also, the config must not be a 'fileFolderConfig' field which takes string values. + // When items contain a null value, allow NULL in the database column. + $defaultValue = $fieldType->getDefaultValue(); + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::INTEGER, + [ + 'notnull' => !$itemsContainNull, + 'default' => $itemsContainNull && $defaultValue === null ? null : (int)($defaultValue ?? 0), + 'unsigned' => $itemsAreAllPositive, + ] + ); + break; + } + // If int is no option, have a string field. + // When items contain a null value, allow NULL in the database column. + $defaultValue = $fieldType->getDefaultValue(); + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'notnull' => !$itemsContainNull, + 'default' => $itemsContainNull && $defaultValue === null ? null : (string)($defaultValue ?? ''), + 'length' => $dbFieldLength > 0 ? $dbFieldLength : 255, + ] + ); + break; + } + if ($itemsContainsOnlyIntegers) { + // Multiple values can be selected and will be stored comma separated. When manual item values are + // all integers, or if there is a foreign_table, we end up with a comma separated list of integers. + // Using string / varchar 255 here should be long enough to store plenty of values, and can be + // changed by setting 'dbFieldLength'. + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + // @todo: nullable = true is not a good default here. This stems from the fact that this + // if triggers a lot of TEXT->VARCHAR() field changes during upgrade, where TEXT + // is always nullable, but varchar() is not. As such, we for now declare this + // nullable, but could have a look at it later again when a value upgrade + // for such cases is in place that updates existing null fields to empty string. + 'notnull' => false, + 'default' => (string)($fieldType->getDefaultValue() ?? ''), + 'length' => $dbFieldLength > 0 ? $dbFieldLength : 255, + ] + ); + break; + } + } + if ($dbFieldLength > 0) { + // If nothing else matches, but there is a dbFieldLength set, have varchar with that length. + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'notnull' => true, + 'default' => (string)($fieldType->getDefaultValue() ?? ''), + 'length' => $dbFieldLength, + ] + ); + } else { + // Final fallback creates a (nullable) text field. + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::TEXT, + [ + 'notnull' => false, + ] + ); + } + break; + case $fieldType instanceof CountryFieldType: + $tables[$tableName]->addColumn( + $this->quote($fieldName), + Types::STRING, + [ + 'length' => 16, // Even though ISO2 is stored by default, custom additional items may need some (limited) storage + 'notnull' => false, + ] + ); + break; + + } + } + } + + return $tables; + } + + /** + * Find table fields that configure a "true" MM relation and define the + * according mm table schema for them. True MM tables are intermediate tables + * that have NO TCA itself. Those are indicated by type=select and type=group + * and type=inline fields with MM property. + * + * @param array $tables + * @return array + */ + protected function enrichMmTables(array $tables): array + { + foreach ($this->tcaSchemaFactory->all() as $schema) { + foreach ($schema->getFields() as $field) { + // Broken TCA or not of expected type, or no MM, or foreign side + if (!$field->isType(TableColumnType::SELECT, TableColumnType::GROUP, TableColumnType::INLINE, TableColumnType::CATEGORY)) { + continue; + } + $fieldConfiguration = $field->getConfiguration(); + if (!is_string($fieldConfiguration['MM'] ?? false) + // Consider this mm only if looking at it from the local side + || ($fieldConfiguration['MM_opposite_field'] ?? false) + ) { + continue; + } + $mmTableName = $fieldConfiguration['MM']; + if (!array_key_exists($mmTableName, $tables)) { + // If the mm table is defined, work with it. Else add at and. + $tables[$mmTableName] = GeneralUtility::makeInstance( + Table::class, + $mmTableName + ); + } + + // Add 'uid' field with primary key if multiple is set: 'multiple' allows using a left or right + // side more than once in a relation which would lead to duplicate primary key entries. To + // avoid this, we add a uid column and make it primary key instead. + $needsUid = (bool)($fieldConfiguration['multiple'] ?? false); + if ($needsUid && !$this->isColumnDefinedForTable($tables, $mmTableName, 'uid')) { + $tables[$mmTableName]->addColumn( + $this->quote('uid'), + Types::INTEGER, + [ + 'notnull' => true, + 'unsigned' => true, + 'autoincrement' => true, + ] + ); + $tables[$mmTableName]->setPrimaryKey(['uid']); + } + + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'uid_local')) { + $tables[$mmTableName]->addColumn( + $this->quote('uid_local'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + // Without "multiple", the primary key set below starts with "uid_local" and already + // indexes it, so a dedicated single-column index would be a redundant prefix. With + // "multiple" the primary key is the "uid" field instead, so a "uid_local" index is + // kept to serve relation lookups by the local side. + if ($needsUid && !$this->isIndexDefinedForTable($tables, $mmTableName, 'uid_local')) { + $tables[$mmTableName]->addIndex(['uid_local'], 'uid_local'); + } + + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'uid_foreign')) { + $tables[$mmTableName]->addColumn( + $this->quote('uid_foreign'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + if (!$this->isIndexDefinedForTable($tables, $mmTableName, 'uid_foreign')) { + $tables[$mmTableName]->addIndex(['uid_foreign'], 'uid_foreign'); + } + + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'sorting')) { + $tables[$mmTableName]->addColumn( + $this->quote('sorting'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'sorting_foreign')) { + $tables[$mmTableName]->addColumn( + $this->quote('sorting_foreign'), + Types::INTEGER, + [ + 'default' => 0, + 'notnull' => true, + 'unsigned' => true, + ] + ); + } + + $hasTablenamesFieldname = false; + if ( // Local side of MM with MM_oppositeUsage forces tablenames and fieldname + !empty($fieldConfiguration['MM_oppositeUsage']) + || ( + // MM group with allowed more than one table forces tablenames and fieldname + $field->isType(TableColumnType::GROUP) && !empty($fieldConfiguration['allowed']) + && ( + count(GeneralUtility::trimExplode(',', $fieldConfiguration['allowed'])) > 1 + || $fieldConfiguration['allowed'] === '*' + ) + ) + ) { + $hasTablenamesFieldname = true; + // This local table can be the target of multiple foreign tables and table fields. The mm table + // thus needs two further fields to specify which foreign/table field combination links is used. + // Those are stored in two additional fields called "tablenames" and "fieldname". + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'tablenames')) { + $tables[$mmTableName]->addColumn( + $this->quote('tablenames'), + Types::STRING, + [ + 'default' => '', + 'length' => 64, + 'notnull' => true, + ] + ); + } + if (!$this->isColumnDefinedForTable($tables, $mmTableName, 'fieldname')) { + $tables[$mmTableName]->addColumn( + $this->quote('fieldname'), + Types::STRING, + [ + 'default' => '', + 'length' => 64, + 'notnull' => true, + ] + ); + } + } + + // Primary key handling: If there is a uid field, PK has been added above already. + // Otherwise, the PK combination is either "uid_local, uid_foreign", or + // "uid_local, uid_foreign, tablenames, fieldname" if this is a multi-foreign setup. + if (!$needsUid && $tables[$mmTableName]->getPrimaryKey() === null && $hasTablenamesFieldname) { + $tables[$mmTableName]->setPrimaryKey(['uid_local', 'uid_foreign', 'tablenames', 'fieldname']); + } elseif (!$needsUid && $tables[$mmTableName]->getPrimaryKey() === null) { + $tables[$mmTableName]->setPrimaryKey(['uid_local', 'uid_foreign']); + } + } + } + return $tables; + } + + /** + * True if a column with a given name is defined within the incoming + * array of Table's. + * + * @param array $tables + */ + protected function isColumnDefinedForTable(array $tables, string $tableName, string $fieldName): bool + { + return ($tables[$tableName] ?? null)?->hasColumn($fieldName) ?? false; + } + + /** + * True if an index with a given name is defined within the incoming + * array of Table's. + * + * @param array $tables + */ + protected function isIndexDefinedForTable(array $tables, string $tableName, string $indexName): bool + { + return ($tables[$tableName] ?? null)?->hasIndex($indexName) ?? false; + } + + protected function quote(string $identifier): string + { + return '`' . $identifier . '`'; + } +} diff --git a/Classes/Database/Schema/Exception/DefaultTcaSchemaTablePositionException.php b/Classes/Database/Schema/Exception/DefaultTcaSchemaTablePositionException.php new file mode 100644 index 0000000..c268272 --- /dev/null +++ b/Classes/Database/Schema/Exception/DefaultTcaSchemaTablePositionException.php @@ -0,0 +1,27 @@ +typeName); + } + + /** + * Used in {@see SchemaInformation::buildTableInformation()} to transform doctrine Columns to ColumnInfo. + */ + public static function convertFromDoctrineColumn(Column $column): self + { + // `Column->getType()` is not passed here by intention to mitigate cache issues getting information from + // persisted cache due to `sbl_object_id()` usage in the Doctrine DBAL TypesRegistry not matching the + // type later on. Skipping it here and not having it as class property is part of the mitigation strategy + // and resolves the cache issues with `Column` directly. + return new self( + name: $column->getName(), + typeName: Type::lookupName($column->getType()), + default: $column->getDefault(), + notNull: $column->getNotnull(), + length: $column->getLength(), + precision: $column->getPrecision(), + scale: $column->getScale(), + fixed: $column->getFixed(), + unsigned: $column->getUnsigned(), + autoincrement: $column->getAutoincrement(), + values: $column->getValues(), + ); + } +} diff --git a/Classes/Database/Schema/Information/TableInfo.php b/Classes/Database/Schema/Information/TableInfo.php new file mode 100644 index 0000000..fe12df6 --- /dev/null +++ b/Classes/Database/Schema/Information/TableInfo.php @@ -0,0 +1,64 @@ + $columnInfos + */ + public function __construct( + private string $name, + private array $columnInfos, + ) {} + + public function getName(): string + { + return $this->name; + } + + public function hasColumnInfo(string $columnName): bool + { + return in_array($columnName, $this->getColumnNames(), true); + } + + public function getColumnInfo(string $columnName): ?ColumnInfo + { + return $this->columnInfos[$columnName] ?? null; + } + + public function getColumnNames(): array + { + return array_keys($this->columnInfos); + } + + /** + * @return array + */ + public function getColumnInfos(): array + { + return $this->columnInfos; + } +} diff --git a/Classes/Database/Schema/Parser/AST/AbstractCreateDefinitionItem.php b/Classes/Database/Schema/Parser/AST/AbstractCreateDefinitionItem.php new file mode 100644 index 0000000..fbb3108 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/AbstractCreateDefinitionItem.php @@ -0,0 +1,24 @@ +tableName = $createTableClause->tableName; + $this->isTemporary = $createTableClause->isTemporary; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/AbstractDataType.php b/Classes/Database/Schema/Parser/AST/DataType/AbstractDataType.php new file mode 100644 index 0000000..fa379d7 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/AbstractDataType.php @@ -0,0 +1,112 @@ +length; + } + + public function setLength(int $length): void + { + $this->length = $length; + } + + public function getPrecision(): int + { + return $this->precision; + } + + public function setPrecision(int $precision): void + { + $this->precision = $precision; + } + + public function getScale(): int + { + return $this->scale; + } + + public function setScale(int $scale): void + { + $this->scale = $scale; + } + + public function isFixed(): bool + { + return $this->fixed; + } + + public function setFixed(bool $fixed): void + { + $this->fixed = $fixed; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setOptions(array $options): void + { + $this->options = $options; + } + + public function isUnsigned(): bool + { + return $this->unsigned; + } + + public function setUnsigned(bool $unsigned): void + { + $this->unsigned = $unsigned; + } + + public function getValues(): array + { + return $this->values; + } + + public function setValues(array $values): void + { + $this->values = $values; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/BigIntDataType.php b/Classes/Database/Schema/Parser/AST/DataType/BigIntDataType.php new file mode 100644 index 0000000..17dd73e --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/BigIntDataType.php @@ -0,0 +1,25 @@ +fixed = true; + $this->length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/BitDataType.php b/Classes/Database/Schema/Parser/AST/DataType/BitDataType.php new file mode 100644 index 0000000..94dff8b --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/BitDataType.php @@ -0,0 +1,31 @@ +length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/BlobDataType.php b/Classes/Database/Schema/Parser/AST/DataType/BlobDataType.php new file mode 100644 index 0000000..0ff07de --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/BlobDataType.php @@ -0,0 +1,32 @@ +length = 65535; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/CharDataType.php b/Classes/Database/Schema/Parser/AST/DataType/CharDataType.php new file mode 100644 index 0000000..83ba69a --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/CharDataType.php @@ -0,0 +1,37 @@ +fixed = true; + $this->length = $length; + $this->options = $options; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/DateDataType.php b/Classes/Database/Schema/Parser/AST/DataType/DateDataType.php new file mode 100644 index 0000000..5531afd --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/DateDataType.php @@ -0,0 +1,25 @@ +length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/DecimalDataType.php b/Classes/Database/Schema/Parser/AST/DataType/DecimalDataType.php new file mode 100644 index 0000000..f554a6f --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/DecimalDataType.php @@ -0,0 +1,33 @@ +precision = $dataTypeDecimals['length'] ?? -1; + $this->scale = $dataTypeDecimals['decimals'] ?? -1; + $this->options = $dataTypeOptions; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/DoubleDataType.php b/Classes/Database/Schema/Parser/AST/DataType/DoubleDataType.php new file mode 100644 index 0000000..93ba018 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/DoubleDataType.php @@ -0,0 +1,25 @@ +values = $values; + $this->options = $options; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/FloatDataType.php b/Classes/Database/Schema/Parser/AST/DataType/FloatDataType.php new file mode 100644 index 0000000..74b904c --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/FloatDataType.php @@ -0,0 +1,34 @@ +precision = $dataTypeDecimals['length'] ?? -1; + $this->scale = $dataTypeDecimals['decimals'] ?? -1; + $this->options = $dataTypeOptions; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/IntegerDataType.php b/Classes/Database/Schema/Parser/AST/DataType/IntegerDataType.php new file mode 100644 index 0000000..e438550 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/IntegerDataType.php @@ -0,0 +1,35 @@ +length = $length; + $this->options = $options; + if (array_key_exists('unsigned', $options) && $options['unsigned']) { + $this->setUnsigned(true); + } + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/JsonDataType.php b/Classes/Database/Schema/Parser/AST/DataType/JsonDataType.php new file mode 100644 index 0000000..0abb573 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/JsonDataType.php @@ -0,0 +1,34 @@ +length = 2147483647; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/LongBlobDataType.php b/Classes/Database/Schema/Parser/AST/DataType/LongBlobDataType.php new file mode 100644 index 0000000..d2fe588 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/LongBlobDataType.php @@ -0,0 +1,33 @@ +length = 2147483647; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/LongTextDataType.php b/Classes/Database/Schema/Parser/AST/DataType/LongTextDataType.php new file mode 100644 index 0000000..5bc9ab9 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/LongTextDataType.php @@ -0,0 +1,33 @@ +length = 2147483647; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/MediumBlobDataType.php b/Classes/Database/Schema/Parser/AST/DataType/MediumBlobDataType.php new file mode 100644 index 0000000..602e388 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/MediumBlobDataType.php @@ -0,0 +1,33 @@ +length = 16777215; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/MediumIntDataType.php b/Classes/Database/Schema/Parser/AST/DataType/MediumIntDataType.php new file mode 100644 index 0000000..d974cfa --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/MediumIntDataType.php @@ -0,0 +1,25 @@ +length = 16777215; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/NumericDataType.php b/Classes/Database/Schema/Parser/AST/DataType/NumericDataType.php new file mode 100644 index 0000000..be5e182 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/NumericDataType.php @@ -0,0 +1,25 @@ +values = $values; + $this->options = $options; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/SmallIntDataType.php b/Classes/Database/Schema/Parser/AST/DataType/SmallIntDataType.php new file mode 100644 index 0000000..7b0ba2a --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/SmallIntDataType.php @@ -0,0 +1,25 @@ +length = 65535; + $this->options = $options; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/TimeDataType.php b/Classes/Database/Schema/Parser/AST/DataType/TimeDataType.php new file mode 100644 index 0000000..cd1aae4 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/TimeDataType.php @@ -0,0 +1,31 @@ +length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/TimestampDataType.php b/Classes/Database/Schema/Parser/AST/DataType/TimestampDataType.php new file mode 100644 index 0000000..790c50d --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/TimestampDataType.php @@ -0,0 +1,31 @@ +length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/TinyBlobDataType.php b/Classes/Database/Schema/Parser/AST/DataType/TinyBlobDataType.php new file mode 100644 index 0000000..242b405 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/TinyBlobDataType.php @@ -0,0 +1,33 @@ +length = 255; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/TinyIntDataType.php b/Classes/Database/Schema/Parser/AST/DataType/TinyIntDataType.php new file mode 100644 index 0000000..aac5282 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/TinyIntDataType.php @@ -0,0 +1,25 @@ +length = 255; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/UuidDataType.php b/Classes/Database/Schema/Parser/AST/DataType/UuidDataType.php new file mode 100644 index 0000000..eaf6b25 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/UuidDataType.php @@ -0,0 +1,23 @@ +length = $length; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/VarCharDataType.php b/Classes/Database/Schema/Parser/AST/DataType/VarCharDataType.php new file mode 100644 index 0000000..a0eb3a0 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/VarCharDataType.php @@ -0,0 +1,32 @@ +length = $length; + $this->options = $options; + } +} diff --git a/Classes/Database/Schema/Parser/AST/DataType/YearDataType.php b/Classes/Database/Schema/Parser/AST/DataType/YearDataType.php new file mode 100644 index 0000000..e78ebe9 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/DataType/YearDataType.php @@ -0,0 +1,25 @@ +quoteChar; + return $c . str_replace($c, $c . $c, $this->schemaObjectName) . $c; + } +} diff --git a/Classes/Database/Schema/Parser/AST/IndexColumnName.php b/Classes/Database/Schema/Parser/AST/IndexColumnName.php new file mode 100644 index 0000000..451fd13 --- /dev/null +++ b/Classes/Database/Schema/Parser/AST/IndexColumnName.php @@ -0,0 +1,34 @@ += 100 + public const T_IDENTIFIER = 100; + + // All tokens that could be considered as a data type should be >= 200 + public const T_BIT = 201; + public const T_TINYINT = 202; + public const T_SMALLINT = 203; + public const T_MEDIUMINT = 204; + public const T_INT = 205; + public const T_INTEGER = 206; + public const T_BIGINT = 207; + public const T_REAL = 208; + public const T_DOUBLE = 209; + public const T_FLOAT = 210; + public const T_DECIMAL = 211; + public const T_NUMERIC = 212; + public const T_DATE = 213; + public const T_TIME = 214; + public const T_TIMESTAMP = 215; + public const T_DATETIME = 216; + public const T_YEAR = 217; + public const T_CHAR = 218; + public const T_VARCHAR = 219; + public const T_BINARY = 220; + public const T_VARBINARY = 221; + public const T_TINYBLOB = 222; + public const T_BLOB = 223; + public const T_MEDIUMBLOB = 224; + public const T_LONGBLOB = 225; + public const T_TINYTEXT = 226; + public const T_TEXT = 227; + public const T_MEDIUMTEXT = 228; + public const T_LONGTEXT = 229; + public const T_ENUM = 230; + public const T_SET = 231; + public const T_JSON = 232; + public const T_UUID = 233; + + // All keyword tokens should be >= 300 + public const T_CREATE = 300; + public const T_TEMPORARY = 301; + public const T_TABLE = 302; + public const T_IF = 303; + public const T_NOT = 304; + public const T_EXISTS = 305; + public const T_CONSTRAINT = 306; + public const T_INDEX = 307; + public const T_KEY = 308; + public const T_FULLTEXT = 309; + public const T_SPATIAL = 310; + public const T_PRIMARY = 311; + public const T_UNIQUE = 312; + public const T_CHECK = 313; + public const T_DEFAULT = 314; + public const T_AUTO_INCREMENT = 315; + public const T_COMMENT = 316; + public const T_COLUMN_FORMAT = 317; + public const T_STORAGE = 318; + public const T_REFERENCES = 319; + public const T_NULL = 320; + public const T_FIXED = 321; + public const T_DYNAMIC = 322; + public const T_MEMORY = 323; + public const T_DISK = 324; + public const T_UNSIGNED = 325; + public const T_ZEROFILL = 326; + public const T_CURRENT_TIMESTAMP = 327; + public const T_CHARACTER = 328; + public const T_COLLATE = 329; + public const T_ASC = 330; + public const T_DESC = 331; + public const T_MATCH = 332; + public const T_FULL = 333; + public const T_PARTIAL = 334; + public const T_SIMPLE = 335; + public const T_ON = 336; + public const T_UPDATE = 337; + public const T_DELETE = 338; + public const T_RESTRICT = 339; + public const T_CASCADE = 340; + public const T_NO = 341; + public const T_ACTION = 342; + public const T_USING = 343; + public const T_BTREE = 344; + public const T_HASH = 345; + public const T_KEY_BLOCK_SIZE = 346; + public const T_WITH = 347; + public const T_PARSER = 348; + public const T_FOREIGN = 349; + public const T_ENGINE = 350; + public const T_AVG_ROW_LENGTH = 351; + public const T_CHECKSUM = 352; + public const T_COMPRESSION = 353; + public const T_CONNECTION = 354; + public const T_DATA = 355; + public const T_DIRECTORY = 356; + public const T_DELAY_KEY_WRITE = 357; + public const T_ENCRYPTION = 358; + public const T_INSERT_METHOD = 359; + public const T_MAX_ROWS = 360; + public const T_MIN_ROWS = 361; + public const T_PACK_KEYS = 362; + public const T_PASSWORD = 363; + public const T_ROW_FORMAT = 364; + public const T_STATS_AUTO_RECALC = 365; + public const T_STATS_PERSISTENT = 366; + public const T_STATS_SAMPLE_PAGES = 367; + public const T_TABLESPACE = 368; + public const T_UNION = 369; + public const T_PRECISION = 370; + + /** + * Lexical catchable patterns. + */ + protected function getCatchablePatterns(): array + { + return [ + '(?:-?[0-9]+(?:[\.][0-9]+)*)(?:e[+-]?[0-9]+)?', // numbers + '`(?:[^`]|``)*`', // quoted identifiers + "'(?:[^']|'')*'", // quoted strings + '\)', // closing parenthesis + '[a-z0-9$_][\w$]*', // unquoted identifiers + ]; + } + + /** + * Lexical non-catchable patterns. + */ + protected function getNonCatchablePatterns(): array + { + return ['\s+']; + } + + /** + * Retrieve token type. Also processes the token value if necessary. + * + * @param string $value + */ + protected function getType(&$value): int + { + $type = self::T_NONE; + + // Recognize numeric values + if (is_numeric($value)) { + if (str_contains($value, '.') || stripos($value, 'e') !== false) { + return self::T_FLOAT; + } + + return self::T_INTEGER; + } + + // Recognize quoted strings + if ($value[0] === "'") { + $value = str_replace("''", "'", substr($value, 1, -1)); + + return self::T_STRING; + } + + // Recognize quoted strings + if ($value[0] === '`') { + $value = str_replace('``', '`', substr($value, 1, -1)); + + return self::T_IDENTIFIER; + } + + // Recognize identifiers, aliased or qualified names + if (ctype_alpha($value[0])) { + $name = 'TYPO3\\CMS\\Core\\Database\\Schema\\Parser\\Lexer::T_' . strtoupper($value); + + if (defined($name)) { + $type = constant($name); + + if ($type > 100) { + return $type; + } + } + + return self::T_STRING; + } + + switch ($value) { + // Recognize symbols + case '.': + return self::T_DOT; + case ';': + return self::T_SEMICOLON; + case ',': + return self::T_COMMA; + case '(': + return self::T_OPEN_PARENTHESIS; + case ')': + return self::T_CLOSE_PARENTHESIS; + case '=': + return self::T_EQUALS; + case '>': + return self::T_GREATER_THAN; + case '<': + return self::T_LOWER_THAN; + case '+': + return self::T_PLUS; + case '-': + return self::T_MINUS; + case '*': + return self::T_MULTIPLY; + case '/': + return self::T_DIVIDE; + case '!': + return self::T_NEGATE; + case '{': + return self::T_OPEN_CURLY_BRACE; + case '}': + return self::T_CLOSE_CURLY_BRACE; + // Default + default: + // Do nothing + } + + return $type; + } +} diff --git a/Classes/Database/Schema/Parser/Parser.php b/Classes/Database/Schema/Parser/Parser.php new file mode 100644 index 0000000..1aba87b --- /dev/null +++ b/Classes/Database/Schema/Parser/Parser.php @@ -0,0 +1,1488 @@ + + * @throws SchemaException + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @throws StatementException + */ + public function parse(string $statement): array + { + $ast = $this->getAST($statement); + if (!$ast instanceof CreateTableStatement) { + return []; + } + $tableBuilder = new TableBuilder(); + $table = $tableBuilder->create($ast); + return [$table]; + } + + /** + * Parses and builds AST for the given Query. + * Only public for testing, the core API method is parse(). + * + * @throws StatementException + */ + public function getAST(string $statement): AbstractCreateStatement + { + // Parse & build AST + $this->statement = $statement; + $this->lexer->setInput($statement); + $this->lexer->moveNext(); + if (($this->lexer->lookahead->type ?? null) !== Lexer::T_CREATE) { + $this->syntaxError('CREATE'); + } + $createStatement = $this->createStatement(); + // Check for end of string + if ($this->lexer->lookahead !== null) { + $this->syntaxError('end of string'); + } + return $createStatement; + } + + /** + * Attempts to match the given token with the current lookahead token. + * + * If they match, updates the lookahead token; otherwise raises a syntax + * error. + * + * @param int $token The token type. + * @throws StatementException If the tokens don't match. + */ + private function match(int $token): void + { + $lookaheadType = $this->lexer->lookahead->type; + // Short-circuit on first condition, usually types match + if ($lookaheadType !== $token) { + // If parameter is not identifier (1-99) must be exact match + if ($token < Lexer::T_IDENTIFIER) { + $this->syntaxError((string)$this->lexer->getLiteral($token)); + } + // If parameter is keyword (200+) must be exact match + if ($token > Lexer::T_IDENTIFIER) { + $this->syntaxError((string)$this->lexer->getLiteral($token)); + } + // If parameter is MATCH then FULL, PARTIAL or SIMPLE must follow + if ($token === Lexer::T_MATCH + && $lookaheadType !== Lexer::T_FULL + && $lookaheadType !== Lexer::T_PARTIAL + && $lookaheadType !== Lexer::T_SIMPLE + ) { + $this->syntaxError((string)$this->lexer->getLiteral($token)); + } + if ($token === Lexer::T_ON && $lookaheadType !== Lexer::T_DELETE && $lookaheadType !== Lexer::T_UPDATE) { + $this->syntaxError((string)$this->lexer->getLiteral($token)); + } + } + $this->lexer->moveNext(); + } + + /** + * Generates a new syntax error. + * + * @param string $expected Expected string. + * @param Token|null $token Got token. + * @throws StatementException + */ + private function syntaxError(string $expected = '', ?Token $token = null): void + { + if ($token === null) { + $token = $this->lexer->lookahead; + } + $tokenPos = $token->position; + + $message = "line 0, col {$tokenPos}: Error: "; + $message .= ($expected !== '') ? "Expected {$expected}, got " : 'Unexpected '; + $message .= ($this->lexer->lookahead === null) ? 'end of string.' : "'{$token->value}'"; + + throw StatementException::syntaxError($message, StatementException::sqlError($this->statement)); + } + + /** + * Generates a new semantic error. + * + * @param string $message Optional message. + * @throws StatementException + */ + private function semanticError(string $message = ''): void + { + $token = $this->lexer->lookahead ?? []; + $tokenPos = $token->position; + + // Minimum exposed chars ahead of token + $distance = 12; + + // Find a position of a final word to display in error string + $createTableStatement = $this->statement; + $length = strlen($createTableStatement); + $pos = $tokenPos + $distance; + $pos = strpos($createTableStatement, ' ', ($length > $pos) ? $pos : $length); + $length = ($pos !== false) ? $pos - $tokenPos : $distance; + + $tokenStr = substr($createTableStatement, $tokenPos, $length); + + // Building informative message + $message = 'line 0, col ' . $tokenPos . " near '" . $tokenStr . "': Error: " . $message; + + throw StatementException::semanticError($message, StatementException::sqlError($this->statement)); + } + + /** + * CreateStatement ::= CREATE [TEMPORARY] TABLE + * Abstraction to allow for support of other schema objects like views in the future. + * + * @throws StatementException + */ + private function createStatement(): AbstractCreateStatement + { + $this->match(Lexer::T_CREATE); + $statement = match ($this->lexer->lookahead->type) { + Lexer::T_TEMPORARY, Lexer::T_TABLE => $this->createTableStatement(), + default => $this->syntaxError('TEMPORARY or TABLE'), + }; + $this->match(Lexer::T_SEMICOLON); + return $statement; + } + + /** + * CreateTableStatement ::= CREATE [TEMPORARY] TABLE [IF NOT EXISTS] tbl_name (create_definition,...) [tbl_options] + * + * @throws StatementException + */ + private function createTableStatement(): CreateTableStatement + { + $createTableStatement = new CreateTableStatement($this->createTableClause(), $this->createDefinition()); + if (!$this->lexer->isNextToken(Lexer::T_SEMICOLON)) { + $createTableStatement->tableOptions = $this->tableOptions(); + } + return $createTableStatement; + } + + /** + * CreateTableClause ::= CREATE [TEMPORARY] TABLE [IF NOT EXISTS] tbl_name + * + * @throws StatementException + */ + private function createTableClause(): CreateTableClause + { + $isTemporary = false; + // Check for TEMPORARY + if ($this->lexer->isNextToken(Lexer::T_TEMPORARY)) { + $this->match(Lexer::T_TEMPORARY); + $isTemporary = true; + } + + $this->match(Lexer::T_TABLE); + + // Check for IF NOT EXISTS + if ($this->lexer->isNextToken(Lexer::T_IF)) { + $this->match(Lexer::T_IF); + $this->match(Lexer::T_NOT); + $this->match(Lexer::T_EXISTS); + } + + // Process schema object name (table name) + $tableName = $this->schemaObjectName(); + + return new CreateTableClause($tableName, $isTemporary); + } + + /** + * Parses the table field/index definition + * + * createDefinition ::= ( + * col_name column_definition + * | [CONSTRAINT [symbol]] PRIMARY KEY [index_type] (index_col_name,...) [index_option] ... + * | {INDEX|KEY} [index_name] [index_type] (index_col_name,...) [index_option] ... + * | [CONSTRAINT [symbol]] UNIQUE [INDEX|KEY] [index_name] [index_type] (index_col_name,...) [index_option] ... + * | {FULLTEXT|SPATIAL} [INDEX|KEY] [index_name] (index_col_name,...) [index_option] ... + * | [CONSTRAINT [symbol]] FOREIGN KEY [index_name] (index_col_name,...) reference_definition + * | CHECK (expr) + * ) + * + * @throws StatementException + */ + private function createDefinition(): CreateDefinition + { + $createDefinitions = []; + + // Process opening parenthesis + $this->match(Lexer::T_OPEN_PARENTHESIS); + + if ($this->lexer->lookahead->type === Lexer::T_CLOSE_PARENTHESIS) { + // No columns defined in this table for now. This is invalid in most DBMS, but core may + // auto add fields later. Swallow ")" and return empty CreateDefinition for "no columns". + $this->match(Lexer::T_CLOSE_PARENTHESIS); + return new CreateDefinition([]); + } + + $createDefinitions[] = $this->createDefinitionItem(); + + while ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + + // TYPO3 previously accepted invalid SQL files where a "create" definition + // item terminated with a comma before the final closing parenthesis. + // Silently swallow the extra comma and stop the "create" definition parsing. + if ($this->lexer->isNextToken(Lexer::T_CLOSE_PARENTHESIS)) { + break; + } + + $createDefinitions[] = $this->createDefinitionItem(); + } + + // Process closing parenthesis + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + return new CreateDefinition($createDefinitions); + } + + /** + * Parse the definition of a single column or index + * + * @throws StatementException + */ + private function createDefinitionItem(): AbstractCreateDefinitionItem + { + $definitionItem = null; + + switch ($this->lexer->lookahead->type) { + case Lexer::T_FULLTEXT: + // Intentional fall-through + case Lexer::T_SPATIAL: + // Intentional fall-through + case Lexer::T_PRIMARY: + // Intentional fall-through + case Lexer::T_UNIQUE: + // Intentional fall-through + case Lexer::T_KEY: + // Intentional fall-through + case Lexer::T_INDEX: + $definitionItem = $this->createIndexDefinitionItem(); + break; + case Lexer::T_FOREIGN: + $definitionItem = $this->createForeignKeyDefinitionItem(); + break; + case Lexer::T_CONSTRAINT: + $this->semanticError('CONSTRAINT [symbol] index definition part not supported'); + break; + case Lexer::T_CHECK: + $this->semanticError('CHECK (expr) create definition not supported'); + break; + default: + $definitionItem = $this->createColumnDefinitionItem(); + } + + return $definitionItem; + } + + /** + * Parses an index definition item contained in the create definition + * + * @throws StatementException + */ + private function createIndexDefinitionItem(): CreateIndexDefinitionItem + { + $indexName = null; + $isPrimary = false; + $isFulltext = false; + $isSpatial = false; + $isUnique = false; + $indexDefinition = new CreateIndexDefinitionItem(); + + switch ($this->lexer->lookahead->type) { + case Lexer::T_PRIMARY: + $this->match(Lexer::T_PRIMARY); + // KEY is a required keyword for PRIMARY index + $this->match(Lexer::T_KEY); + $isPrimary = true; + break; + case Lexer::T_KEY: + // Plain index, no special configuration + $this->match(Lexer::T_KEY); + break; + case Lexer::T_INDEX: + // Plain index, no special configuration + $this->match(Lexer::T_INDEX); + break; + case Lexer::T_UNIQUE: + $this->match(Lexer::T_UNIQUE); + // INDEX|KEY are optional keywords for UNIQUE index + if ($this->lexer->isNextTokenAny([Lexer::T_INDEX, Lexer::T_KEY])) { + $this->lexer->moveNext(); + } + $isUnique = true; + break; + case Lexer::T_FULLTEXT: + $this->match(Lexer::T_FULLTEXT); + // INDEX|KEY are optional keywords for FULLTEXT index + if ($this->lexer->isNextTokenAny([Lexer::T_INDEX, Lexer::T_KEY])) { + $this->lexer->moveNext(); + } + $isFulltext = true; + break; + case Lexer::T_SPATIAL: + $this->match(Lexer::T_SPATIAL); + // INDEX|KEY are optional keywords for SPATIAL index + if ($this->lexer->isNextTokenAny([Lexer::T_INDEX, Lexer::T_KEY])) { + $this->lexer->moveNext(); + } + $isSpatial = true; + break; + default: + $this->syntaxError('PRIMARY, KEY, INDEX, UNIQUE, FULLTEXT or SPATIAL'); + } + + // PRIMARY KEY has no name in MySQL + if (!$indexDefinition->isPrimary) { + $indexName = $this->indexName(); + } + + $indexDefinition = new CreateIndexDefinitionItem( + $indexName, + $isPrimary, + $isUnique, + $isSpatial, + $isFulltext + ); + + // FULLTEXT and SPATIAL indexes can not have a type definition + if (!$isFulltext && !$isSpatial) { + $indexDefinition->indexType = $this->indexType(); + } + + $this->match(Lexer::T_OPEN_PARENTHESIS); + + $indexDefinition->columnNames[] = $this->indexColumnName(); + + while ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + $indexDefinition->columnNames[] = $this->indexColumnName(); + } + + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + $indexDefinition->options = $this->indexOptions(); + + return $indexDefinition; + } + + /** + * Parses a foreign key definition item contained in the create definition + * + * @throws StatementException + */ + private function createForeignKeyDefinitionItem(): CreateForeignKeyDefinitionItem + { + $this->match(Lexer::T_FOREIGN); + $this->match(Lexer::T_KEY); + + $indexName = $this->indexName(); + + $this->match(Lexer::T_OPEN_PARENTHESIS); + + $indexColumns = []; + $indexColumns[] = $this->indexColumnName(); + + while ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + $indexColumns[] = $this->indexColumnName(); + } + + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + return new CreateForeignKeyDefinitionItem( + $indexName, + $indexColumns, + $this->referenceDefinition() + ); + } + + /** + * Return the name of an index. No name has been supplied if the next token is USING + * which defines the index type. + * + * @throws StatementException + */ + private function indexName(): Identifier + { + $indexName = new Identifier(''); + if (!$this->lexer->isNextTokenAny([Lexer::T_USING, Lexer::T_OPEN_PARENTHESIS])) { + $indexName = $this->schemaObjectName(); + } + return $indexName; + } + + /** + * IndexType ::= USING { BTREE | HASH } + * + * @throws StatementException + */ + private function indexType(): string + { + $indexType = ''; + if (!$this->lexer->isNextToken(Lexer::T_USING)) { + return $indexType; + } + + $this->match(Lexer::T_USING); + + switch ($this->lexer->lookahead->type) { + case Lexer::T_BTREE: + $this->match(Lexer::T_BTREE); + $indexType = 'BTREE'; + break; + case Lexer::T_HASH: + $this->match(Lexer::T_HASH); + $indexType = 'HASH'; + break; + default: + $this->syntaxError('BTREE or HASH'); + } + + return $indexType; + } + + /** + * IndexOptions ::= KEY_BLOCK_SIZE [=] value + * | index_type + * | WITH PARSER parser_name + * | COMMENT 'string' + * + * @throws StatementException + */ + private function indexOptions(): array + { + $options = []; + + while ($this->lexer->lookahead && !$this->lexer->isNextTokenAny([Lexer::T_COMMA, Lexer::T_CLOSE_PARENTHESIS])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_KEY_BLOCK_SIZE: + $this->match(Lexer::T_KEY_BLOCK_SIZE); + if ($this->lexer->isNextToken(Lexer::T_EQUALS)) { + $this->match(Lexer::T_EQUALS); + } + $this->lexer->moveNext(); + $options['key_block_size'] = (int)$this->lexer->token->value; + break; + case Lexer::T_USING: + $options['index_type'] = $this->indexType(); + break; + case Lexer::T_WITH: + $this->match(Lexer::T_WITH); + $this->match(Lexer::T_PARSER); + $options['parser'] = $this->schemaObjectName(); + break; + case Lexer::T_COMMENT: + $this->match(Lexer::T_COMMENT); + $this->match(Lexer::T_STRING); + $options['comment'] = $this->lexer->token->value; + break; + default: + $this->syntaxError('KEY_BLOCK_SIZE, USING, WITH PARSER or COMMENT'); + } + } + + return $options; + } + + /** + * CreateColumnDefinitionItem ::= col_name column_definition + * + * column_definition: + * data_type [NOT NULL | NULL] [DEFAULT default_value] + * [AUTO_INCREMENT] [UNIQUE [KEY] | [PRIMARY] KEY] + * [COMMENT 'string'] + * [COLUMN_FORMAT {FIXED|DYNAMIC|DEFAULT}] + * [STORAGE {DISK|MEMORY|DEFAULT}] + * [reference_definition] + * + * @throws StatementException + */ + private function createColumnDefinitionItem(): CreateColumnDefinitionItem + { + $columnName = $this->schemaObjectName(); + $dataType = $this->columnDataType(); + + $columnDefinitionItem = new CreateColumnDefinitionItem($columnName, $dataType); + + while ($this->lexer->lookahead && !$this->lexer->isNextTokenAny([Lexer::T_COMMA, Lexer::T_CLOSE_PARENTHESIS])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_NOT: + $columnDefinitionItem->allowNull = false; + $this->match(Lexer::T_NOT); + $this->match(Lexer::T_NULL); + break; + case Lexer::T_NULL: + $columnDefinitionItem->allowNull = true; + $this->match(Lexer::T_NULL); + break; + case Lexer::T_DEFAULT: + $columnDefinitionItem->hasDefaultValue = true; + $columnDefinitionItem->defaultValue = $this->columnDefaultValue(); + break; + case Lexer::T_AUTO_INCREMENT: + $columnDefinitionItem->autoIncrement = true; + $this->match(Lexer::T_AUTO_INCREMENT); + break; + case Lexer::T_UNIQUE: + $columnDefinitionItem->unique = true; + $this->match(Lexer::T_UNIQUE); + if ($this->lexer->isNextToken(Lexer::T_KEY)) { + $this->match(Lexer::T_KEY); + } + break; + case Lexer::T_PRIMARY: + $columnDefinitionItem->primary = true; + $this->match(Lexer::T_PRIMARY); + if ($this->lexer->isNextToken(Lexer::T_KEY)) { + $this->match(Lexer::T_KEY); + } + break; + case Lexer::T_KEY: + $columnDefinitionItem->index = true; + $this->match(Lexer::T_KEY); + break; + case Lexer::T_COMMENT: + $this->match(Lexer::T_COMMENT); + if ($this->lexer->isNextToken(Lexer::T_STRING)) { + $columnDefinitionItem->comment = $this->lexer->lookahead->value; + $this->match(Lexer::T_STRING); + } + break; + case Lexer::T_COLUMN_FORMAT: + $this->match(Lexer::T_COLUMN_FORMAT); + if ($this->lexer->isNextToken(Lexer::T_FIXED)) { + $columnDefinitionItem->columnFormat = 'fixed'; + $this->match(Lexer::T_FIXED); + } elseif ($this->lexer->isNextToken(Lexer::T_DYNAMIC)) { + $columnDefinitionItem->columnFormat = 'dynamic'; + $this->match(Lexer::T_DYNAMIC); + } else { + $this->match(Lexer::T_DEFAULT); + } + break; + case Lexer::T_STORAGE: + $this->match(Lexer::T_STORAGE); + if ($this->lexer->isNextToken(Lexer::T_MEMORY)) { + $columnDefinitionItem->storage = 'memory'; + $this->match(Lexer::T_MEMORY); + } elseif ($this->lexer->isNextToken(Lexer::T_DISK)) { + $columnDefinitionItem->storage = 'disk'; + $this->match(Lexer::T_DISK); + } else { + $this->match(Lexer::T_DEFAULT); + } + break; + case Lexer::T_REFERENCES: + $columnDefinitionItem->reference = $this->referenceDefinition(); + break; + case Lexer::T_CHARACTER: + switch (true) { + case $columnDefinitionItem->dataType instanceof CharDataType: + case $columnDefinitionItem->dataType instanceof VarCharDataType: + case $columnDefinitionItem->dataType instanceof TextDataType: + case $columnDefinitionItem->dataType instanceof MediumTextDataType: + case $columnDefinitionItem->dataType instanceof LongTextDataType: + $this->match(Lexer::T_CHARACTER); + $this->match(Lexer::T_SET); + $this->match(Lexer::T_STRING); + $options = $columnDefinitionItem->dataType->getOptions(); + $options['charset'] = $this->lexer->token->value; + $columnDefinitionItem->dataType->setOptions($options); + break; + default: + $this->syntaxError( + 'CHARACTER SET only supported for CHAR, VARCHAR, TEXT, MEDIUMTEXT, LONGTEXT, ' + . 'ENUM or SET columns' + ); + } + $b = 1; + break; + case Lexer::T_COLLATE: + switch (true) { + case $columnDefinitionItem->dataType instanceof CharDataType: + case $columnDefinitionItem->dataType instanceof VarCharDataType: + case $columnDefinitionItem->dataType instanceof TextDataType: + case $columnDefinitionItem->dataType instanceof MediumTextDataType: + case $columnDefinitionItem->dataType instanceof LongTextDataType: + $this->match(Lexer::T_COLLATE); + $this->match(Lexer::T_STRING); + $options = $columnDefinitionItem->dataType->getOptions(); + $options['collation'] = $this->lexer->token->value; + $columnDefinitionItem->dataType->setOptions($options); + break; + default: + $this->syntaxError( + 'COLLATE only supported for CHAR, VARCHAR, TEXT, MEDIUMTEXT, LONGTEXT, ' + . 'ENUM or SET columns' + ); + } + $b = 1; + break; + default: + $this->syntaxError( + 'NOT, NULL, DEFAULT, AUTO_INCREMENT, UNIQUE, ' + . 'PRIMARY, COMMENT, COLUMN_FORMAT, STORAGE, REFERENCES, ' + . 'CHARACTER SET or COLLATE' + ); + } + } + + return $columnDefinitionItem; + } + + /** + * DataType ::= BIT[(length)] + * | TINYINT[(length)] [UNSIGNED] [ZEROFILL] + * | SMALLINT[(length)] [UNSIGNED] [ZEROFILL] + * | MEDIUMINT[(length)] [UNSIGNED] [ZEROFILL] + * | INT[(length)] [UNSIGNED] [ZEROFILL] + * | INTEGER[(length)] [UNSIGNED] [ZEROFILL] + * | BIGINT[(length)] [UNSIGNED] [ZEROFILL] + * | REAL[(length,decimals)] [UNSIGNED] [ZEROFILL] + * | DOUBLE[(length,decimals)] [UNSIGNED] [ZEROFILL] + * | FLOAT[(length,decimals)] [UNSIGNED] [ZEROFILL] + * | DECIMAL[(length[,decimals])] [UNSIGNED] [ZEROFILL] + * | NUMERIC[(length[,decimals])] [UNSIGNED] [ZEROFILL] + * | DATE + * | TIME[(fsp)] + * | TIMESTAMP[(fsp)] + * | DATETIME[(fsp)] + * | YEAR + * | CHAR[(length)] [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | VARCHAR(length) [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | BINARY[(length)] + * | VARBINARY(length) + * | TINYBLOB + * | BLOB + * | MEDIUMBLOB + * | LONGBLOB + * | TINYTEXT [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | TEXT [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | MEDIUMTEXT [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | LONGTEXT [BINARY] [CHARACTER SET charset_name] [COLLATE collation_name] + * | ENUM(value1,value2,value3,...) [CHARACTER SET charset_name] [COLLATE collation_name] + * | SET(value1,value2,value3,...) [CHARACTER SET charset_name] [COLLATE collation_name] + * | JSON + * | UUID + * + * @throws StatementException + */ + private function columnDataType(): AbstractDataType + { + $dataType = null; + + switch ($this->lexer->lookahead->type) { + case Lexer::T_BIT: + $this->match(Lexer::T_BIT); + $dataType = new BitDataType( + $this->dataTypeLength() + ); + break; + case Lexer::T_TINYINT: + $this->match(Lexer::T_TINYINT); + $dataType = new TinyIntDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_SMALLINT: + $this->match(Lexer::T_SMALLINT); + $dataType = new SmallIntDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_MEDIUMINT: + $this->match(Lexer::T_MEDIUMINT); + $dataType = new MediumIntDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_INT: + $this->match(Lexer::T_INT); + $dataType = new IntegerDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_INTEGER: + $this->match(Lexer::T_INTEGER); + $dataType = new IntegerDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_BIGINT: + $this->match(Lexer::T_BIGINT); + $dataType = new BigIntDataType( + $this->dataTypeLength(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_REAL: + $this->match(Lexer::T_REAL); + $dataType = new RealDataType( + $this->dataTypeDecimals(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_DOUBLE: + $this->match(Lexer::T_DOUBLE); + if ($this->lexer->isNextToken(Lexer::T_PRECISION)) { + $this->match(Lexer::T_PRECISION); + } + $dataType = new DoubleDataType( + $this->dataTypeDecimals(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_FLOAT: + $this->match(Lexer::T_FLOAT); + $dataType = new FloatDataType( + $this->dataTypeDecimals(), + $this->numericDataTypeOptions() + ); + + break; + case Lexer::T_DECIMAL: + $this->match(Lexer::T_DECIMAL); + $dataType = new DecimalDataType( + $this->dataTypeDecimals(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_NUMERIC: + $this->match(Lexer::T_NUMERIC); + $dataType = new NumericDataType( + $this->dataTypeDecimals(), + $this->numericDataTypeOptions() + ); + break; + case Lexer::T_DATE: + $this->match(Lexer::T_DATE); + $dataType = new DateDataType(); + break; + case Lexer::T_TIME: + $this->match(Lexer::T_TIME); + $dataType = new TimeDataType($this->fractionalSecondsPart()); + break; + case Lexer::T_TIMESTAMP: + $this->match(Lexer::T_TIMESTAMP); + $dataType = new TimestampDataType($this->fractionalSecondsPart()); + break; + case Lexer::T_DATETIME: + $this->match(Lexer::T_DATETIME); + $dataType = new DateTimeDataType($this->fractionalSecondsPart()); + break; + case Lexer::T_YEAR: + $this->match(Lexer::T_YEAR); + $dataType = new YearDataType(); + break; + case Lexer::T_CHAR: + $this->match(Lexer::T_CHAR); + $dataType = new CharDataType( + $this->dataTypeLength(), + $this->characterDataTypeOptions() + ); + break; + case Lexer::T_VARCHAR: + $this->match(Lexer::T_VARCHAR); + $dataType = new VarCharDataType( + $this->dataTypeLength(true), + $this->characterDataTypeOptions() + ); + break; + case Lexer::T_BINARY: + $this->match(Lexer::T_BINARY); + $dataType = new BinaryDataType($this->dataTypeLength()); + break; + case Lexer::T_VARBINARY: + $this->match(Lexer::T_VARBINARY); + $dataType = new VarBinaryDataType($this->dataTypeLength(true)); + break; + case Lexer::T_TINYBLOB: + $this->match(Lexer::T_TINYBLOB); + $dataType = new TinyBlobDataType(); + break; + case Lexer::T_BLOB: + $this->match(Lexer::T_BLOB); + $dataType = new BlobDataType(); + break; + case Lexer::T_MEDIUMBLOB: + $this->match(Lexer::T_MEDIUMBLOB); + $dataType = new MediumBlobDataType(); + break; + case Lexer::T_LONGBLOB: + $this->match(Lexer::T_LONGBLOB); + $dataType = new LongBlobDataType(); + break; + case Lexer::T_TINYTEXT: + $this->match(Lexer::T_TINYTEXT); + $dataType = new TinyTextDataType($this->characterDataTypeOptions()); + break; + case Lexer::T_TEXT: + $this->match(Lexer::T_TEXT); + $dataType = new TextDataType($this->characterDataTypeOptions()); + break; + case Lexer::T_MEDIUMTEXT: + $this->match(Lexer::T_MEDIUMTEXT); + $dataType = new MediumTextDataType($this->characterDataTypeOptions()); + break; + case Lexer::T_LONGTEXT: + $this->match(Lexer::T_LONGTEXT); + $dataType = new LongTextDataType($this->characterDataTypeOptions()); + break; + case Lexer::T_ENUM: + $this->match(Lexer::T_ENUM); + $dataType = new EnumDataType($this->valueList(), $this->enumerationDataTypeOptions()); + break; + case Lexer::T_SET: + $this->match(Lexer::T_SET); + $dataType = new SetDataType($this->valueList(), $this->enumerationDataTypeOptions()); + break; + case Lexer::T_JSON: + $this->match(Lexer::T_JSON); + $dataType = new JsonDataType(); + break; + case Lexer::T_UUID: + $this->match(Lexer::T_UUID); + $dataType = new UuidDataType(); + break; + default: + $this->syntaxError( + 'BIT, TINYINT, SMALLINT, MEDIUMINT, INT, INTEGER, BIGINT, REAL, DOUBLE, FLOAT, DECIMAL, NUMERIC, ' + . 'DATE, TIME, TIMESTAMP, DATETIME, YEAR, CHAR, VARCHAR, BINARY, VARBINARY, TINYBLOB, BLOB, ' + . 'MEDIUMBLOB, LONGBLOB, TINYTEXT, TEXT, MEDIUMTEXT, LONGTEXT, ENUM, SET, or JSON' + ); + } + + return $dataType; + } + + /** + * DefaultValue::= DEFAULT default_value + * + * @throws StatementException + */ + private function columnDefaultValue(): string|int|float|null + { + $this->match(Lexer::T_DEFAULT); + $value = match ($this->lexer->lookahead->type) { + Lexer::T_INTEGER => (int)$this->lexer->lookahead->value, + Lexer::T_FLOAT => (float)$this->lexer->lookahead->value, + Lexer::T_STRING => (string)$this->lexer->lookahead->value, + Lexer::T_CURRENT_TIMESTAMP => 'CURRENT_TIMESTAMP', + Lexer::T_NULL => null, + default => $this->syntaxError('String, Integer, Float, NULL or CURRENT_TIMESTAMP'), + }; + $this->lexer->moveNext(); + return $value; + } + + /** + * Determine length parameter of a column field definition, i.E. INT(11) or VARCHAR(255) + * + * @throws StatementException + */ + private function dataTypeLength(bool $required = false): int + { + $length = 0; + if (!$this->lexer->isNextToken(Lexer::T_OPEN_PARENTHESIS)) { + if ($required) { + $this->semanticError('The current data type requires a field length definition.'); + } + return $length; + } + + $this->match(Lexer::T_OPEN_PARENTHESIS); + $length = (int)$this->lexer->lookahead->value; + $this->match(Lexer::T_INTEGER); + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + return $length; + } + + /** + * Determine length and optional decimal parameter of a column field definition, i.E. DECIMAL(10,6) + * + * @throws StatementException + */ + private function dataTypeDecimals(): array + { + $options = []; + if (!$this->lexer->isNextToken(Lexer::T_OPEN_PARENTHESIS)) { + return $options; + } + + $this->match(Lexer::T_OPEN_PARENTHESIS); + $options['length'] = (int)$this->lexer->lookahead->value; + $this->match(Lexer::T_INTEGER); + + if ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + $options['decimals'] = (int)$this->lexer->lookahead->value; + $this->match(Lexer::T_INTEGER); + } + + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + return $options; + } + + /** + * Parse common options for numeric data types + * + * @throws StatementException + */ + private function numericDataTypeOptions(): array + { + $options = ['unsigned' => false, 'zerofill' => false]; + + if (!$this->lexer->isNextTokenAny([Lexer::T_UNSIGNED, Lexer::T_ZEROFILL])) { + return $options; + } + + while ($this->lexer->isNextTokenAny([Lexer::T_UNSIGNED, Lexer::T_ZEROFILL])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_UNSIGNED: + $this->match(Lexer::T_UNSIGNED); + $options['unsigned'] = true; + break; + case Lexer::T_ZEROFILL: + $this->match(Lexer::T_ZEROFILL); + $options['zerofill'] = true; + break; + default: + $this->syntaxError('USIGNED or ZEROFILL'); + } + } + + return $options; + } + + /** + * Determine the fractional seconds part support for TIME, DATETIME and TIMESTAMP columns + * + * @throws StatementException + */ + private function fractionalSecondsPart(): int + { + $fractionalSecondsPart = $this->dataTypeLength(); + if ($fractionalSecondsPart < 0) { + $this->semanticError('the fractional seconds part for TIME, DATETIME or TIMESTAMP columns must >= 0'); + } + if ($fractionalSecondsPart > 6) { + $this->semanticError('the fractional seconds part for TIME, DATETIME or TIMESTAMP columns must <= 6'); + } + return $fractionalSecondsPart; + } + + /** + * Parse common options for numeric data types + * + * @throws StatementException + */ + private function characterDataTypeOptions(): array + { + $options = ['binary' => false, 'charset' => null, 'collation' => null]; + + if (!$this->lexer->isNextTokenAny([Lexer::T_CHARACTER, Lexer::T_COLLATE, Lexer::T_BINARY])) { + return $options; + } + + while ($this->lexer->isNextTokenAny([Lexer::T_CHARACTER, Lexer::T_COLLATE, Lexer::T_BINARY])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_BINARY: + $this->match(Lexer::T_BINARY); + $options['binary'] = true; + break; + case Lexer::T_CHARACTER: + $this->match(Lexer::T_CHARACTER); + $this->match(Lexer::T_SET); + $this->match(Lexer::T_STRING); + $options['charset'] = $this->lexer->token->value; + break; + case Lexer::T_COLLATE: + $this->match(Lexer::T_COLLATE); + $this->match(Lexer::T_STRING); + $options['collation'] = $this->lexer->token->value; + break; + default: + $this->syntaxError('BINARY, CHARACTER SET or COLLATE'); + } + } + + return $options; + } + + /** + * Parse shared options for enumeration datatypes (ENUM and SET) + * + * @throws StatementException + */ + private function enumerationDataTypeOptions(): array + { + $options = ['charset' => null, 'collation' => null]; + + if (!$this->lexer->isNextTokenAny([Lexer::T_CHARACTER, Lexer::T_COLLATE])) { + return $options; + } + + while ($this->lexer->isNextTokenAny([Lexer::T_CHARACTER, Lexer::T_COLLATE])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_CHARACTER: + $this->match(Lexer::T_CHARACTER); + $this->match(Lexer::T_SET); + $this->match(Lexer::T_STRING); + $options['charset'] = $this->lexer->token->value; + break; + case Lexer::T_COLLATE: + $this->match(Lexer::T_COLLATE); + $this->match(Lexer::T_STRING); + $options['collation'] = $this->lexer->token->value; + break; + default: + $this->syntaxError('CHARACTER SET or COLLATE'); + } + } + + return $options; + } + + /** + * Return all defined values for an enumeration datatype (ENUM, SET) + * + * @throws StatementException + */ + private function valueList(): array + { + $this->match(Lexer::T_OPEN_PARENTHESIS); + + $values = []; + $values[] = $this->valueListItem(); + + while ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + $values[] = $this->valueListItem(); + } + + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + return $values; + } + + /** + * Return a value list item for an enumeration set + * + * @throws StatementException + */ + private function valueListItem(): string + { + $this->match(Lexer::T_STRING); + + return (string)$this->lexer->token->value; + } + + /** + * ReferenceDefinition ::= REFERENCES tbl_name (index_col_name,...) + * [MATCH FULL | MATCH PARTIAL | MATCH SIMPLE] + * [ON DELETE reference_option] + * [ON UPDATE reference_option] + * + * @throws StatementException + */ + private function referenceDefinition(): ReferenceDefinition + { + $this->match(Lexer::T_REFERENCES); + $tableName = $this->schemaObjectName(); + $this->match(Lexer::T_OPEN_PARENTHESIS); + + $referenceColumns = []; + $referenceColumns[] = $this->indexColumnName(); + + while ($this->lexer->isNextToken(Lexer::T_COMMA)) { + $this->match(Lexer::T_COMMA); + $referenceColumns[] = $this->indexColumnName(); + } + + $this->match(Lexer::T_CLOSE_PARENTHESIS); + + $referenceDefinition = new ReferenceDefinition($tableName, $referenceColumns); + + while (!$this->lexer->isNextTokenAny([Lexer::T_COMMA, Lexer::T_CLOSE_PARENTHESIS])) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_MATCH: + $this->match(Lexer::T_MATCH); + $referenceDefinition->match = $this->lexer->lookahead->value; + $this->lexer->moveNext(); + break; + case Lexer::T_ON: + $this->match(Lexer::T_ON); + if ($this->lexer->isNextToken(Lexer::T_DELETE)) { + $this->match(Lexer::T_DELETE); + $referenceDefinition->onDelete = $this->referenceOption(); + } else { + $this->match(Lexer::T_UPDATE); + $referenceDefinition->onUpdate = $this->referenceOption(); + } + break; + default: + $this->syntaxError('MATCH, ON DELETE or ON UPDATE'); + } + } + + return $referenceDefinition; + } + + /** + * IndexColumnName ::= col_name [(length)] [ASC | DESC] + * + * @throws StatementException + */ + private function indexColumnName(): IndexColumnName + { + $columnName = $this->schemaObjectName(); + $length = $this->dataTypeLength(); + $direction = null; + + if ($this->lexer->isNextToken(Lexer::T_ASC)) { + $this->match(Lexer::T_ASC); + $direction = 'ASC'; + } elseif ($this->lexer->isNextToken(Lexer::T_DESC)) { + $this->match(Lexer::T_DESC); + $direction = 'DESC'; + } + + return new IndexColumnName($columnName, $length, $direction); + } + + /** + * ReferenceOption ::= RESTRICT | CASCADE | SET NULL | NO ACTION + * + * @throws StatementException + */ + private function referenceOption(): string + { + $action = null; + + switch ($this->lexer->lookahead->type) { + case Lexer::T_RESTRICT: + $this->match(Lexer::T_RESTRICT); + $action = 'RESTRICT'; + break; + case Lexer::T_CASCADE: + $this->match(Lexer::T_CASCADE); + $action = 'CASCADE'; + break; + case Lexer::T_SET: + $this->match(Lexer::T_SET); + $this->match(Lexer::T_NULL); + $action = 'SET NULL'; + break; + case Lexer::T_NO: + $this->match(Lexer::T_NO); + $this->match(Lexer::T_ACTION); + $action = 'NO ACTION'; + break; + default: + $this->syntaxError('RESTRICT, CASCADE, SET NULL or NO ACTION'); + } + + return $action; + } + + /** + * Parse MySQL table options + * + * ENGINE [=] engine_name + * | AUTO_INCREMENT [=] value + * | AVG_ROW_LENGTH [=] value + * | [DEFAULT] CHARACTER SET [=] charset_name + * | CHECKSUM [=] {0 | 1} + * | [DEFAULT] COLLATE [=] collation_name + * | COMMENT [=] 'string' + * | COMPRESSION [=] {'ZLIB'|'LZ4'|'NONE'} + * | CONNECTION [=] 'connect_string' + * | DATA DIRECTORY [=] 'absolute path to directory' + * | DELAY_KEY_WRITE [=] {0 | 1} + * | ENCRYPTION [=] {'Y' | 'N'} + * | INDEX DIRECTORY [=] 'absolute path to directory' + * | INSERT_METHOD [=] { NO | FIRST | LAST } + * | KEY_BLOCK_SIZE [=] value + * | MAX_ROWS [=] value + * | MIN_ROWS [=] value + * | PACK_KEYS [=] {0 | 1 | DEFAULT} + * | PASSWORD [=] 'string' + * | ROW_FORMAT [=] {DEFAULT|DYNAMIC|FIXED|COMPRESSED|REDUNDANT|COMPACT} + * | STATS_AUTO_RECALC [=] {DEFAULT|0|1} + * | STATS_PERSISTENT [=] {DEFAULT|0|1} + * | STATS_SAMPLE_PAGES [=] value + * | TABLESPACE tablespace_name + * | UNION [=] (tbl_name[,tbl_name]...) + * + * @throws StatementException + */ + private function tableOptions(): array + { + $options = []; + + while ($this->lexer->lookahead && !$this->lexer->isNextToken(Lexer::T_SEMICOLON)) { + switch ($this->lexer->lookahead->type) { + case Lexer::T_DEFAULT: + // DEFAULT prefix is optional for COLLATE/CHARACTER SET, do nothing + $this->match(Lexer::T_DEFAULT); + break; + case Lexer::T_ENGINE: + $this->match(Lexer::T_ENGINE); + $options['engine'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_AUTO_INCREMENT: + $this->match(Lexer::T_AUTO_INCREMENT); + $options['auto_increment'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_AVG_ROW_LENGTH: + $this->match(Lexer::T_AVG_ROW_LENGTH); + $options['average_row_length'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_CHARACTER: + $this->match(Lexer::T_CHARACTER); + $this->match(Lexer::T_SET); + $options['character_set'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_CHECKSUM: + $this->match(Lexer::T_CHECKSUM); + $options['checksum'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_COLLATE: + $this->match(Lexer::T_COLLATE); + $options['collation'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_COMMENT: + $this->match(Lexer::T_COMMENT); + $options['comment'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_COMPRESSION: + $this->match(Lexer::T_COMPRESSION); + $options['compression'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['compression'], ['ZLIB', 'LZ4', 'NONE'], true)) { + $this->syntaxError('ZLIB, LZ4 or NONE', $this->lexer->token); + } + break; + case Lexer::T_CONNECTION: + $this->match(Lexer::T_CONNECTION); + $options['connection'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_DATA: + $this->match(Lexer::T_DATA); + $this->match(Lexer::T_DIRECTORY); + $options['data_directory'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_DELAY_KEY_WRITE: + $this->match(Lexer::T_DELAY_KEY_WRITE); + $options['delay_key_write'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_ENCRYPTION: + $this->match(Lexer::T_ENCRYPTION); + $options['encryption'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['encryption'], ['Y', 'N'], true)) { + $this->syntaxError('Y or N', $this->lexer->token); + } + break; + case Lexer::T_INDEX: + $this->match(Lexer::T_INDEX); + $this->match(Lexer::T_DIRECTORY); + $options['index_directory'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_INSERT_METHOD: + $this->match(Lexer::T_INSERT_METHOD); + $options['insert_method'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['insert_method'], ['NO', 'FIRST', 'LAST'], true)) { + $this->syntaxError('NO, FIRST or LAST', $this->lexer->token); + } + break; + case Lexer::T_KEY_BLOCK_SIZE: + $this->match(Lexer::T_KEY_BLOCK_SIZE); + $options['key_block_size'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_MAX_ROWS: + $this->match(Lexer::T_MAX_ROWS); + $options['max_rows'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_MIN_ROWS: + $this->match(Lexer::T_MIN_ROWS); + $options['min_rows'] = (int)$this->tableOptionValue(); + break; + case Lexer::T_PACK_KEYS: + $this->match(Lexer::T_PACK_KEYS); + $options['pack_keys'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['pack_keys'], ['0', '1', 'DEFAULT'], true)) { + $this->syntaxError('0, 1 or DEFAULT', $this->lexer->token); + } + break; + case Lexer::T_PASSWORD: + $this->match(Lexer::T_PASSWORD); + $options['password'] = (string)$this->tableOptionValue(); + break; + case Lexer::T_ROW_FORMAT: + $this->match(Lexer::T_ROW_FORMAT); + $options['row_format'] = (string)$this->tableOptionValue(); + $validRowFormats = ['DEFAULT', 'DYNAMIC', 'FIXED', 'COMPRESSED', 'REDUNDANT', 'COMPACT']; + if (!in_array($options['row_format'], $validRowFormats, true)) { + $this->syntaxError( + 'DEFAULT, DYNAMIC, FIXED, COMPRESSED, REDUNDANT, COMPACT', + $this->lexer->token + ); + } + break; + case Lexer::T_STATS_AUTO_RECALC: + $this->match(Lexer::T_STATS_AUTO_RECALC); + $options['stats_auto_recalc'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['stats_auto_recalc'], ['0', '1', 'DEFAULT'], true)) { + $this->syntaxError('0, 1 or DEFAULT', $this->lexer->token); + } + break; + case Lexer::T_STATS_PERSISTENT: + $this->match(Lexer::T_STATS_PERSISTENT); + $options['stats_persistent'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['stats_persistent'], ['0', '1', 'DEFAULT'], true)) { + $this->syntaxError('0, 1 or DEFAULT', $this->lexer->token); + } + break; + case Lexer::T_STATS_SAMPLE_PAGES: + $this->match(Lexer::T_STATS_SAMPLE_PAGES); + $options['stats_sample_pages'] = strtoupper((string)$this->tableOptionValue()); + if (!in_array($options['stats_sample_pages'], ['0', '1', 'DEFAULT'], true)) { + $this->syntaxError('0, 1 or DEFAULT', $this->lexer->token); + } + break; + case Lexer::T_TABLESPACE: + $this->match(Lexer::T_TABLESPACE); + $options['tablespace'] = (string)$this->tableOptionValue(); + break; + default: + $this->syntaxError( + 'DEFAULT, ENGINE, AUTO_INCREMENT, AVG_ROW_LENGTH, CHARACTER SET, ' + . 'CHECKSUM, COLLATE, COMMENT, COMPRESSION, CONNECTION, DATA DIRECTORY, ' + . 'DELAY_KEY_WRITE, ENCRYPTION, INDEX DIRECTORY, INSERT_METHOD, KEY_BLOCK_SIZE, ' + . 'MAX_ROWS, MIN_ROWS, PACK_KEYS, PASSWORD, ROW_FORMAT, STATS_AUTO_RECALC, ' + . 'STATS_PERSISTENT, STATS_SAMPLE_PAGES or TABLESPACE' + ); + } + } + + return $options; + } + + /** + * Return the value of an option, skipping the optional equal sign. + * + * @throws StatementException + */ + private function tableOptionValue(): mixed + { + // Skip the optional equals sign + if ($this->lexer->isNextToken(Lexer::T_EQUALS)) { + $this->match(Lexer::T_EQUALS); + } + $this->lexer->moveNext(); + return $this->lexer->token->value; + } + + /** + * Certain objects within MySQL, including database, table, index, column, alias, view, stored procedure, + * partition, tablespace, and other object names are known as identifiers. + */ + private function schemaObjectName(): Identifier + { + $schemaObjectName = $this->lexer->lookahead->value; + $this->lexer->moveNext(); + return new Identifier((string)$schemaObjectName); + } +} diff --git a/Classes/Database/Schema/Parser/TableBuilder.php b/Classes/Database/Schema/Parser/TableBuilder.php new file mode 100644 index 0000000..fc47d6c --- /dev/null +++ b/Classes/Database/Schema/Parser/TableBuilder.php @@ -0,0 +1,451 @@ +platform = $platform ?: GeneralUtility::makeInstance(MySQLPlatform::class); + } + + /** + * Create a Doctrine Table object based on the parsed MySQL SQL command. + * + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \RuntimeException + * @throws \InvalidArgumentException + */ + public function create(CreateTableStatement $tableStatement): Table + { + $this->table = GeneralUtility::makeInstance( + Table::class, + $tableStatement->tableName->getQuotedName(), + [], + [], + [], + [], + $this->buildTableOptions($tableStatement->tableOptions) + ); + + foreach ($tableStatement->createDefinition->items as $item) { + switch (get_class($item)) { + case CreateColumnDefinitionItem::class: + $this->addColumn($item); + break; + case CreateIndexDefinitionItem::class: + $this->addIndex($item); + break; + case CreateForeignKeyDefinitionItem::class: + $this->addForeignKey($item); + break; + default: + throw new \RuntimeException( + 'Unknown item definition of type "' . get_class($item) . '" encountered.', + 1472044085 + ); + } + } + + return $this->table; + } + + /** + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \RuntimeException + */ + protected function addColumn(CreateColumnDefinitionItem $item): Column + { + $column = $this->table->addColumn( + $item->columnName->getQuotedName(), + $this->getDoctrineColumnTypeName($item->dataType) + ); + + $column->setNotnull($item->allowNull === false); + $column->setAutoincrement($item->autoIncrement); + $column->setComment((string)$item->comment); + + // Set default value (unless it's an auto increment column) + if ($item->hasDefaultValue && !$column->getAutoincrement()) { + $column->setDefault($item->defaultValue); + } + + if ($item->dataType->getLength()) { + $column->setLength($item->dataType->getLength()); + } + + if ($item->dataType->getPrecision() >= 0) { + $column->setPrecision($item->dataType->getPrecision()); + } + + if ($item->dataType->getScale() >= 0) { + $column->setScale($item->dataType->getScale()); + } + + if ($item->dataType->isUnsigned()) { + $column->setUnsigned(true); + } + + // Select CHAR/VARCHAR or BINARY/VARBINARY + if ($item->dataType->isFixed()) { + $column->setFixed(true); + } + + if ($item->dataType instanceof SetDataType) { + $column->setValues($item->dataType->getValues()); + } + if ($item->dataType instanceof EnumDataType) { + $column->setValues($item->dataType->getValues()); + } + + $dataTypeSupportsCharsetAndCollation = ( + $item->dataType instanceof CharDataType + || $item->dataType instanceof VarCharDataType + || $item->dataType instanceof TextDataType + ); + $options = $item->dataType->getOptions(); + if ($dataTypeSupportsCharsetAndCollation && ($options['charset'] ?? null)) { + $column->setPlatformOption('charset', $options['charset']); + } + if ($dataTypeSupportsCharsetAndCollation && ($options['charset'] ?? null)) { + $column->setPlatformOption('collation', $options['collation']); + } + + if ($item->index) { + $this->table->addIndex([$item->columnName->getQuotedName()]); + } + + if ($item->unique) { + $this->table->addUniqueIndex([$item->columnName->getQuotedName()]); + } + + if ($item->primary) { + $this->table->setPrimaryKey([$item->columnName->getQuotedName()]); + } + + if ($item->reference !== null) { + $this->addForeignKeyConstraint( + [$item->columnName->getQuotedName()], + $item->reference + ); + } + + return $column; + } + + /** + * @throws \Doctrine\DBAL\Schema\SchemaException + * @throws \InvalidArgumentException + */ + protected function addIndex(CreateIndexDefinitionItem $item): Index + { + $indexName = $item->indexName->getQuotedName(); + + $columnNames = array_map( + static function (IndexColumnName $columnName): string { + if ($columnName->length) { + return $columnName->columnName->getQuotedName() . '(' . $columnName->length . ')'; + } + return $columnName->columnName->getQuotedName(); + }, + $item->columnNames + ); + + if ($item->isPrimary) { + $this->table->setPrimaryKey($columnNames); + $index = $this->table->getPrimaryKey(); + } else { + $index = GeneralUtility::makeInstance( + Index::class, + $indexName, + $columnNames, + $item->isUnique, + $item->isPrimary + ); + + if ($item->isFulltext) { + $index->addFlag('fulltext'); + } elseif ($item->isSpatial) { + $index->addFlag('spatial'); + } + + // Doctrine keys the indexes by a normalized name of its own. Do not rely on that key + // here, but build the list explicitly from the index names, so that re-defining an + // index replaces the previously added one - independent of how Doctrine keys them. + $indexes = []; + foreach ($this->table->getIndexes() as $existingIndex) { + $indexes[strtolower($existingIndex->getName())] = $existingIndex; + } + $indexes[strtolower($index->getName())] = $index; + + $this->table = new Table( + $this->table->getQuotedName($this->platform), + $this->table->getColumns(), + array_values($indexes), + [], + $this->table->getForeignKeys(), + $this->table->getOptions() + ); + } + + return $index; + } + + /** + * Prepare an explicit foreign key definition item to be added to the table being built. + */ + protected function addForeignKey(CreateForeignKeyDefinitionItem $item) + { + $indexName = $item->indexName->getQuotedName() ?: null; + $localColumnNames = array_map( + static function (IndexColumnName $columnName): string { + return $columnName->columnName->getQuotedName(); + }, + $item->columnNames + ); + $this->addForeignKeyConstraint($localColumnNames, $item->reference, $indexName); + } + + /** + * Add a foreign key constraint to the table being built. + * + * @param string[] $localColumnNames + */ + protected function addForeignKeyConstraint( + array $localColumnNames, + ReferenceDefinition $referenceDefinition, + ?string $indexName = null + ) { + $foreignTableName = $referenceDefinition->tableName->getQuotedName(); + $foreignColumnNames = array_map( + static function (IndexColumnName $columnName): string { + return $columnName->columnName->getQuotedName(); + }, + $referenceDefinition->columnNames + ); + + $options = [ + 'onDelete' => $referenceDefinition->onDelete, + 'onUpdate' => $referenceDefinition->onUpdate, + ]; + + $this->table->addForeignKeyConstraint( + $foreignTableName, + $localColumnNames, + $foreignColumnNames, + $options, + $indexName + ); + } + + /** + * @throws \RuntimeException + */ + protected function getDoctrineColumnTypeName(AbstractDataType $dataType): string + { + switch (get_class($dataType)) { + case TinyIntDataType::class: + // TINYINT is MySQL specific and mapped to a standard SMALLINT + case SmallIntDataType::class: + $doctrineType = Types::SMALLINT; + break; + case MediumIntDataType::class: + // MEDIUMINT is MySQL specific and mapped to a standard INT + case IntegerDataType::class: + $doctrineType = Types::INTEGER; + break; + case BigIntDataType::class: + $doctrineType = Types::BIGINT; + break; + case BinaryDataType::class: + case VarBinaryDataType::class: + // CHAR/VARCHAR is determined by "fixed" column property + $doctrineType = Types::BINARY; + break; + case TinyBlobDataType::class: + case MediumBlobDataType::class: + case BlobDataType::class: + case LongBlobDataType::class: + // Actual field type is determined by field length + $doctrineType = Types::BLOB; + break; + case DateDataType::class: + $doctrineType = Types::DATE_MUTABLE; + break; + case TimestampDataType::class: + case DateTimeDataType::class: + // TIMESTAMP or DATETIME are determined by "version" column property + $doctrineType = Types::DATETIME_MUTABLE; + break; + case NumericDataType::class: + case DecimalDataType::class: + $doctrineType = Types::DECIMAL; + break; + case RealDataType::class: + case FloatDataType::class: + case DoubleDataType::class: + $doctrineType = Types::FLOAT; + break; + case TimeDataType::class: + $doctrineType = Types::TIME_MUTABLE; + break; + case TinyTextDataType::class: + case MediumTextDataType::class: + case TextDataType::class: + case LongTextDataType::class: + $doctrineType = Types::TEXT; + break; + case CharDataType::class: + case VarCharDataType::class: + $doctrineType = Types::STRING; + break; + case EnumDataType::class: + $doctrineType = Types::ENUM; + break; + case SetDataType::class: + $doctrineType = SetType::TYPE; + break; + case JsonDataType::class: + $doctrineType = Types::JSON; + break; + case YearDataType::class: + // The YEAR data type is MySQL specific and offers little to no benefit. + // The two-digit year logic implemented in this data type (1-69 mapped to + // 2001-2069, 70-99 mapped to 1970-1999) can be easily implemented in the + // application and for all other accounts it's an integer with a valid + // range of 1901 to 2155. + // Using a SMALLINT covers the value range and ensures database compatibility. + $doctrineType = Types::SMALLINT; + break; + case UuidDataType::class: + // UUID/GUID is only supported by PostgreSQL for now, but Doctrine DBAL implemented a fallback + // for other platforms and we can safely use `Types::GUID` here in case `UUID` has been set in + // `ext_tables.sql` for a table column. + $doctrineType = Types::GUID; + break; + default: + throw new \RuntimeException( + 'Unsupported data type: ' . get_class($dataType) . '!', + 1472046376 + ); + } + + return $doctrineType; + } + + /** + * Build the table specific options as far as they are supported by Doctrine. + */ + protected function buildTableOptions(array $tableOptions): array + { + $options = []; + + if (!empty($tableOptions['engine'])) { + $options['engine'] = (string)$tableOptions['engine']; + } + if (!empty($tableOptions['character_set'])) { + $options['charset'] = (string)$tableOptions['character_set']; + } + if (!empty($tableOptions['collation'])) { + $options['collate'] = (string)$tableOptions['collation']; + } + if (!empty($tableOptions['auto_increment'])) { + $options['auto_increment'] = (string)$tableOptions['auto_increment']; + } + if (!empty($tableOptions['comment'])) { + $options['comment'] = (string)$tableOptions['comment']; + } + if (!empty($tableOptions['row_format'])) { + $options['row_format'] = (string)$tableOptions['row_format']; + } + + return $options; + } +} diff --git a/Classes/Database/Schema/SchemaDiff.php b/Classes/Database/Schema/SchemaDiff.php new file mode 100644 index 0000000..525b78a --- /dev/null +++ b/Classes/Database/Schema/SchemaDiff.php @@ -0,0 +1,162 @@ + $createdSchemas + * @param array $droppedSchemas + * @param array $createdTables + * @param array $alteredTables + * @param array $droppedTables + * @param array $createdSequences + * @param array $alteredSequences + * @param array $droppedSequences + */ + public function __construct( + public array $createdSchemas, + public array $droppedSchemas, + public array $createdTables, + public array $alteredTables, + public array $droppedTables, + public array $createdSequences, + public array $alteredSequences, + public array $droppedSequences, + ) { + $this->alteredTables = array_filter($alteredTables, static function (TableDiff $diff): bool { + return !$diff->isEmpty(); + }); + // NOTE: parent::__construct() not called by intention. + } + + /** @return array */ + public function getCreatedSchemas(): array + { + return $this->createdSchemas; + } + + /** @return array */ + public function getDroppedSchemas(): array + { + return $this->droppedSchemas; + } + + /** @return array */ + public function getCreatedTables(): array + { + return $this->createdTables; + } + + /** @return array */ + public function getAlteredTables(): array + { + return $this->alteredTables; + } + + /** @return array */ + public function getDroppedTables(): array + { + return $this->droppedTables; + } + + /** @return array */ + public function getCreatedSequences(): array + { + return $this->createdSequences; + } + + /** @return array */ + public function getAlteredSequences(): array + { + return $this->alteredSequences; + } + + /** @return array */ + public function getDroppedSequences(): array + { + return $this->droppedSequences; + } + + /** + * Returns whether the diff is empty (contains no changes). + */ + public function isEmpty(): bool + { + return count($this->createdSchemas) === 0 + && count($this->droppedSchemas) === 0 + && count($this->createdTables) === 0 + && count($this->alteredTables) === 0 + && count($this->droppedTables) === 0 + && count($this->createdSequences) === 0 + && count($this->alteredSequences) === 0 + && count($this->droppedSequences) === 0; + } + + public static function ensure(SchemaDiff|DoctrineSchemaDiff $schemaDiff, array $additionalArguments = []): self + { + return new self(...[ + 'createdSchemas' => $schemaDiff->getCreatedSchemas(), + 'droppedSchemas' => $schemaDiff->getDroppedSchemas(), + 'createdTables' => self::ensureCollection(...$schemaDiff->getCreatedTables()), + 'alteredTables' => self::ensureCollection(...$schemaDiff->getAlteredTables()), + 'droppedTables' => self::ensureCollection(...$schemaDiff->getDroppedTables()), + 'createdSequences' => $schemaDiff->getCreatedSequences(), + 'alteredSequences' => $schemaDiff->getAlteredSequences(), + 'droppedSequences' => $schemaDiff->getDroppedSequences(), + ...$additionalArguments, + ]); + } + + /** + * @param DoctrineTableDiff|TableDiff|Table ...$tableDiffs + * @return TableDiff[]|Table[] + */ + public static function ensureCollection(DoctrineTableDiff|TableDiff|Table ...$tableDiffs): array + { + $collection = []; + foreach ($tableDiffs as $key => $tableDiff) { + if ($tableDiff instanceof DoctrineTableDiff) { + $tableDiff = TableDiff::ensure($tableDiff); + } + if (is_int($key) || MathUtility::canBeInterpretedAsInteger($key)) { + $key = $tableDiff instanceof Table + ? $tableDiff->getName() + : $tableDiff->getOldTable()->getName(); + } + $collection[$key] = $tableDiff; + } + return $collection; + } +} diff --git a/Classes/Database/Schema/SchemaInformation.php b/Classes/Database/Schema/SchemaInformation.php new file mode 100644 index 0000000..f5bb56e --- /dev/null +++ b/Classes/Database/Schema/SchemaInformation.php @@ -0,0 +1,150 @@ +connectionIdentifier = $this->packageDependentCacheIdentifier + ->withPrefix(str_replace( + ['.', ':', '/', '\\', '!', '?'], + '_', + (string)($connection->getParams()['dbname'] ?? 'generic') + )) + // hash connection params, which holds various information like host, + // port etc. to get a descriptive hash for this connection. + ->withAdditionalHashedIdentifier(serialize($connection->getParams())) + ->toString(); + } + + /** + * Similar to doctrine DBAL/AbstractSchemaManager, but with a cache-layer. + * This is used core internally to auto-add types, for instance in Connection::insert(). + * + * @return string[] + */ + public function listTableNames(): array + { + $identifier = $this->connectionIdentifier . '-tablenames'; + // Level 1 cache + $tableNames = $this->runtime->get($identifier); + if (is_array($tableNames)) { + return $tableNames; + } + // Level 2 cache + $tableNames = $this->cache->get($identifier); + if (is_array($tableNames)) { + // Retrieved from level 2, set to level 1 cache. + $this->runtime->set($identifier, $tableNames); + return $tableNames; + } + return $this->buildTableNames(); + } + + /** + * @param string $tableName + * @return array + */ + public function listTableColumnInfos(string $tableName): array + { + return $this->getTableInfo($tableName)->getColumnInfos(); + } + + /** + * @param string $tableName + * @return string[] + */ + public function listTableColumnNames(string $tableName): array + { + return $this->getTableInfo($tableName)->getColumnNames(); + } + + public function getTableInfo(string $tableName): TableInfo + { + $identifier = $this->connectionIdentifier . '-tableinfo-' . $tableName; + $tableInfo = $this->runtime->get($identifier); + // Level 1 cache + if ($tableInfo instanceof TableInfo) { + return $tableInfo; + } + // Level 2 cache + $tableInfo = $this->cache->get($identifier); + if ($tableInfo instanceof TableInfo) { + // Retrieved from level 2, set to level 1 cache. + $this->runtime->set($identifier, $tableInfo); + return $tableInfo; + } + return $this->buildTableInformation($tableName); + } + + /** + * @return string[] + */ + private function buildTableNames(): array + { + $identifier = $this->connectionIdentifier . '-tablenames'; + $names = array_values($this->connection->createSchemaManager()->listTableNames()); + // Level 1 cache + $this->runtime->set($identifier, $names); + // Level 2 cache + $this->cache->set($identifier, $names); + return $names; + } + + private function buildTableInformation(string $tableName): TableInfo + { + $identifier = $this->connectionIdentifier . '-tableinfo-' . $tableName; + // Transform doctrine columns into ColumnInfo and add to new associative array using column name with + // unmodified casing as array keys and not the lowercased from doctrine dbal associative array, which + // leads to comparison issues in the core using the names. We need the untouched casing. + $columns = $this->connection->createSchemaManager()->listTableColumns($tableName); + $columnInfos = []; + foreach ($columns as $column) { + $columnInfo = ColumnInfo::convertFromDoctrineColumn($column); + $columnInfos[$columnInfo->name] = $columnInfo; + } + $tableInfo = new TableInfo( + name: $tableName, + columnInfos: $columnInfos, + ); + // Level 1 cache + $this->runtime->set($identifier, $tableInfo); + // Level 2 cache + $this->cache->set($identifier, $tableInfo); + return $tableInfo; + } +} diff --git a/Classes/Database/Schema/SchemaManager/ColumnTypeCommentMethodsTrait.php b/Classes/Database/Schema/SchemaManager/ColumnTypeCommentMethodsTrait.php new file mode 100644 index 0000000..a075cc3 --- /dev/null +++ b/Classes/Database/Schema/SchemaManager/ColumnTypeCommentMethodsTrait.php @@ -0,0 +1,105 @@ +platform; + $type = $dbType !== '' ? $platform->getDoctrineTypeMapping($dbType) : ''; + if (isset($tableColumn['comment'])) { + $type = $this->extractDoctrineTypeFromComment($tableColumn['comment'], $type); + $tableColumn['comment'] = $this->removeDoctrineTypeFromComment($tableColumn['comment'], $type); + } + return $type; + } + + /** + * Doctrine DBAL 4 removed this from the {@see AbstractSchemaManager} hierarchy, and is here cloned, see: + * https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Schema/AbstractSchemaManager.php#L1730-L1752 + * + * Given a table comment this method tries to extract a typehint for Doctrine Type, or returns + * the type given as default. + * + * @param string|null $comment + * @param string $currentType + * + * @return string + *@internal This method should be only used from within the extended AbstractSchemaManager class hierarchy. + */ + private function extractDoctrineTypeFromComment(?string $comment, string $currentType): string + { + if ($comment !== null && preg_match('(\(DC2Type:(((?!\)).)+)\))', $comment, $match) === 1) { + return $match[1]; + } + + return $currentType; + } + + /** + * Doctrine DBAL 4 removed this from the {@see AbstractSchemaManager} hierarchy, and is here cloned, see: + * https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Schema/AbstractSchemaManager.php#L1754-L1773 + * + * @param string|null $comment + * @param string|null $type + * + * @return string|null + *@internal This method should be only used from within the extended AbstractSchemaManager class hierarchy. + */ + private function removeDoctrineTypeFromComment(?string $comment = null, ?string $type = null): ?string + { + if ($comment === null) { + return null; + } + + return str_replace('(DC2Type:' . $type . ')', '', $comment); + } +} diff --git a/Classes/Database/Schema/SchemaManager/CoreSchemaManagerFactory.php b/Classes/Database/Schema/SchemaManager/CoreSchemaManagerFactory.php new file mode 100644 index 0000000..88890df --- /dev/null +++ b/Classes/Database/Schema/SchemaManager/CoreSchemaManagerFactory.php @@ -0,0 +1,64 @@ +getDatabasePlatform(); + // Platform specific SchemaManager are extended to manipulate the schema handling. TYPO3 needs to + // do that to provide additional doctrine type handling and other workarounds or alignments. Long + // time this have been done by using the `doctrine EventManager` to hook into several places, which + // no longer exists. + // + // @link https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-not-setting-a-schema-manager-factory + // @link https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-extension-via-doctrine-event-manager + // @todo Consider make check on SchemaManager instance retrieved from $platform->createSchemaManager() + return match (true) { + $platform instanceof DoctrineSQLitePlatform => new SQLiteSchemaManager($connection, $platform), + $platform instanceof DoctrinePostgreSQLPlatform => new PostgreSQLSchemaManager($connection, $platform), + $platform instanceof DoctrineMariaDBPlatform, + $platform instanceof DoctrineMySQLPlatform, + $platform instanceof DoctrineAbstractMySQLPlatform => new MySQLSchemaManager($connection, $platform), + default => $platform->createSchemaManager($connection), + }; + } +} diff --git a/Classes/Database/Schema/SchemaManager/MySQLSchemaManager.php b/Classes/Database/Schema/SchemaManager/MySQLSchemaManager.php new file mode 100644 index 0000000..8c97d4a --- /dev/null +++ b/Classes/Database/Schema/SchemaManager/MySQLSchemaManager.php @@ -0,0 +1,392 @@ + "\0", + "\\'" => "'", + '\\"' => '"', + '\\b' => "\b", + '\\n' => "\n", + '\\r' => "\r", + '\\t' => "\t", + '\\Z' => "\x1a", + '\\\\' => '\\', + '\\%' => '%', + '\\_' => '_', + + // Internally, MariaDB escapes single quotes using the standard syntax + "''" => "'", + ]; + + private const array MYSQL_ESCAPE_SEQUENCES = [ + '\\0' => "\0", + "\\'" => "'", + '\\"' => '"', + '\\b' => "\b", + '\\n' => "\n", + '\\r' => "\r", + '\\t' => "\t", + '\\Z' => "\x1a", + '\\\\' => '\\', + '\\%' => '%', + '\\_' => '_', + + // internally + "''" => "'", + ]; + + private const array MYSQL_UNQUOTE_SEQUENCES = [ + "\\'" => "'", + '\\"' => '"', + ]; + + /** + * Gets Table Column Definition. + * + * @param array $tableColumn + */ + protected function _getPortableTableColumnDefinition(array $tableColumn): Column + { + /** @var DoctrineMariaDBPlatform|DoctrineMySQLPlatform $platform */ + $platform = $this->platform; + $tableColumn = $this->normalizeTableColumnData($tableColumn, $platform); + return $this->parentGetPortableTableColumnDefinition($tableColumn); + } + + /** + * @param array $tableColumn + * @return array + */ + protected function normalizeTableColumnData(array $tableColumn, DoctrineMariaDBPlatform|DoctrineMySQLPlatform $platform): array + { + if (!($platform instanceof DoctrineMySQLPlatform)) { + return $tableColumn; + } + + $tableColumn = array_change_key_case($tableColumn, CASE_LOWER); + $dbType = strtolower($tableColumn['type']); + + $columnDefault = $tableColumn['default'] ?? null; + $type = Type::getType($platform->getDoctrineTypeMapping($dbType)); + if ($type instanceof TextType || $type instanceof BlobType || $type instanceof JsonType) { + $tableColumn['default'] = $this->getMySQLTextAndBlobColumnDefault($columnDefault); + } + + return $tableColumn; + } + + protected function getMySQLTextAndBlobColumnDefault(?string $columnDefault): ?string + { + if ($columnDefault === null || $columnDefault === 'NULL') { + return null; + } + if (str_starts_with($columnDefault, '_')) { + $columnDefault = substr($columnDefault, (mb_strpos($columnDefault, '\'') - 1)); + } + if ($columnDefault === "\'\'") { + return ''; + } + if (preg_match("/^\\\'(.*)\\\'$/", trim($columnDefault), $matches) === 1) { + return strtr( + strtr($matches[1], self::MYSQL_ESCAPE_SEQUENCES), + // MySQL saves quoted single-quote as escaped single-quote in the INFORMATION SCHEMA table, even + // if it has been provided with double-quote quoting and is inconsistent for itself and enforces + // a additional unquoting after the un-escaping step + self::MYSQL_UNQUOTE_SEQUENCES + ); + } + return $columnDefault; + } + + /** + * @param array> $tableIndexes + * @param string $tableName + * + * @return array + */ + protected function _getPortableTableIndexesList(array $tableIndexes, string $tableName): array + { + // Get doctrine generated list. + $tableIndexesList = parent::_getPortableTableIndexesList( + // tableIndexes + $tableIndexes, + // tableName + $tableName, + ); + + // Concatenate index prefix length to column name + // @todo Adapt TYPO3 schema comparison to use Index::getOption('lengths') + // instead of assuming that the length is concatenated to the column name. + return array_map( + static function (Index $index): Index { + if (!$index->hasOption('lengths')) { + return $index; + } + + $options = $index->getOptions(); + $lengths = $options['lengths']; + unset($options['lengths']); + + $columns = $index->getColumns(); + foreach ($columns as $id => $column) { + if (!isset($lengths[$id])) { + continue; + } + $columns[$id] = $column . '(' . $lengths[$id] . ')'; + } + + return new Index( + $index->getName(), + $columns, + $index->isUnique(), + $index->isPrimary(), + $index->getFlags(), + $options + ); + }, + $tableIndexesList + ); + } + + /** + * Gets Table Column Definition. + * + * This is a copy of {@see DoctrineMySQLSchemaManager::_getPortableTableColumnDefinition()} with a minor change + * to respect column comments for Doctrine Type matching and thus restoring Doctrine DBAL behaviour before v4.x. + * + * @param array $tableColumn + * + * @throws Exception + */ + private function parentGetPortableTableColumnDefinition(array $tableColumn): Column + { + $tableColumn = array_change_key_case($tableColumn, CASE_LOWER); + + $dbType = $tableColumn['type']; + $length = null; + $scale = 0; + $precision = null; + $fixed = false; + $values = []; + + // This is the change required for TYPO3 - rest of method is kept (cloned) from original. + // Following line differs from \Doctrine\DBAL\Schema\MySQLSchemaManager::_getPortableTableColumnDefinition, + // taken from: + // - https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Schema/MySQLSchemaManager.php#L186-L192 + $type = $this->determineColumnType($dbType, $tableColumn); + + switch ($dbType) { + case 'char': + case 'varchar': + $length = $tableColumn['character_maximum_length']; + break; + + case 'binary': + case 'varbinary': + $length = $tableColumn['character_octet_length']; + break; + + case 'tinytext': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_TINYTEXT; + break; + + case 'text': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_TEXT; + break; + + case 'mediumtext': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_MEDIUMTEXT; + break; + + case 'tinyblob': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_TINYBLOB; + break; + + case 'blob': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_BLOB; + break; + + case 'mediumblob': + $length = AbstractMySQLPlatform::LENGTH_LIMIT_MEDIUMBLOB; + break; + + case 'float': + case 'double': + case 'real': + case 'numeric': + case 'decimal': + $precision = $tableColumn['numeric_precision']; + if (isset($tableColumn['numeric_scale'])) { + $scale = $tableColumn['numeric_scale']; + } + break; + } + + switch ($dbType) { + case 'char': + case 'binary': + $fixed = true; + break; + + case 'enum': + $values = $this->parseEnumExpression($tableColumn['column_type']); + break; + + case 'set': + // -------------------------------------------------------------- + // `SET` handling and parsing is a custom TYPO3 implementation + // -------------------------------------------------------------- + $values = $this->parseSetExpression($tableColumn['column_type']); + // -------------------------------------------------------------- + } + + if ($this->platform instanceof MariaDBPlatform) { + $columnDefault = $this->getMariaDBColumnDefault($this->platform, $tableColumn['default']); + } else { + $columnDefault = $tableColumn['default']; + } + + $options = [ + 'length' => $length, + 'unsigned' => str_contains($tableColumn['column_type'], 'unsigned'), + 'fixed' => $fixed, + 'default' => $columnDefault, + 'notnull' => $tableColumn['null'] !== 'YES', + 'scale' => $scale, + 'precision' => $precision, + 'autoincrement' => str_contains($tableColumn['extra'], 'auto_increment'), + 'values' => $values, + ]; + + if (isset($tableColumn['comment'])) { + $options['comment'] = $tableColumn['comment']; + } + + $column = new Column($tableColumn['field'], Type::getType($type), $options); + $column->setPlatformOption('charset', $tableColumn['characterset']); + $column->setPlatformOption('collation', $tableColumn['collation']); + + return $column; + } + + /** + * Return Doctrine/Mysql-compatible column default values for MariaDB 10.2.7+ servers. + * + * - Since MariaDb 10.2.7 column defaults stored in information_schema are now quoted + * to distinguish them from expressions (see MDEV-10134). + * - CURRENT_TIMESTAMP, CURRENT_TIME, CURRENT_DATE are stored in information_schema + * as current_timestamp(), currdate(), currtime() + * - Quoted 'NULL' is not enforced by Maria, it is technically possible to have + * null in some circumstances (see https://jira.mariadb.org/browse/MDEV-14053) + * - \' is always stored as '' in information_schema (normalized) + * + * @link https://mariadb.com/kb/en/library/information-schema-columns-table/ + * @link https://jira.mariadb.org/browse/MDEV-13132 + * + * Copy of {@see DoctrineMySQLSchemaManager::getMariaDBColumnDefault()} + * + * @param string|null $columnDefault default value as stored in information_schema for MariaDB >= 10.2.7 + */ + private function getMariaDBColumnDefault(MariaDBPlatform $platform, ?string $columnDefault): ?string + { + if ($columnDefault === 'NULL' || $columnDefault === null) { + return null; + } + + if (preg_match('/^\'(.*)\'$/', $columnDefault, $matches) === 1) { + return strtr($matches[1], self::MARIADB_ESCAPE_SEQUENCES); + } + + return match ($columnDefault) { + 'current_timestamp()' => $platform->getCurrentTimestampSQL(), + 'curdate()' => $platform->getCurrentDateSQL(), + 'curtime()' => $platform->getCurrentTimeSQL(), + default => $columnDefault, + }; + } + + /** + * Cloned from {@see DoctrineMySQLSchemaManager::parseEnumExpression()} (4.3.x). + * + * @return list + */ + private function parseEnumExpression(string $expression): array + { + $result = preg_match_all("/'([^']*(?:''[^']*)*)'/", $expression, $matches); + assert($result !== false); + + return array_map( + static fn(string $match): string => strtr($match, ["''" => "'"]), + $matches[1], + ); + } + + /** + * Adopted from {@see DoctrineMySQLSchemaManager::parseEnumExpression()} (4.3.x). + * + * @return list + */ + private function parseSetExpression(string $expression): array + { + $result = preg_match_all("/'([^']*(?:''[^']*)*)'/", $expression, $matches); + assert($result !== false); + + return array_map( + static fn(string $match): string => strtr($match, ["''" => "'"]), + $matches[1], + ); + } +} diff --git a/Classes/Database/Schema/SchemaManager/PostgreSQLSchemaManager.php b/Classes/Database/Schema/SchemaManager/PostgreSQLSchemaManager.php new file mode 100644 index 0000000..5b5912a --- /dev/null +++ b/Classes/Database/Schema/SchemaManager/PostgreSQLSchemaManager.php @@ -0,0 +1,200 @@ + $tableColumn + */ + protected function _getPortableTableColumnDefinition(array $tableColumn): Column + { + return $this->parentGetPortableTableColumnDefinition($tableColumn); + } + + /** + * Gets Table Column Definition. + * + * This is a copy of {@see DoctrinePostgreSQLSchemaManager::_getPortableTableColumnDefinition()} with a minor change + * to respect column comments for Doctrine Type matching and thus restoring Doctrine DBAL behaviour before v4.x. + * + * @param array $tableColumn + * + * @throws Exception + */ + protected function parentGetPortableTableColumnDefinition(array $tableColumn): Column + { + $tableColumn = array_change_key_case($tableColumn, CASE_LOWER); + + $length = null; + $precision = null; + $scale = 0; + $fixed = false; + $jsonb = false; + + $dbType = $tableColumn['type']; + + if ( + $tableColumn['domain_type'] !== null + && ! $this->platform->hasDoctrineTypeMappingFor($dbType) + ) { + $dbType = $tableColumn['domain_type']; + $completeType = $tableColumn['domain_complete_type']; + } else { + $completeType = $tableColumn['complete_type']; + } + + // This is the change required for TYPO3 - rest of method is kept (cloned) from original. + // Following line differs from \Doctrine\DBAL\Schema\MySQLSchemaManager::_getPortableTableColumnDefinition, + // taken from: + // - https://github.com/doctrine/dbal/blob/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba/src/Schema/PostgreSQLSchemaManager.php#L427-L429 + $type = $this->determineColumnType($dbType, $tableColumn); + + switch ($dbType) { + case 'bpchar': + case 'varchar': + $parameters = $this->parseColumnTypeParameters($completeType); + if (count($parameters) > 0) { + $length = $parameters[0]; + } + + break; + + case 'double': + case 'decimal': + case 'money': + case 'numeric': + $parameters = $this->parseColumnTypeParameters($completeType); + if (count($parameters) > 0) { + $precision = $parameters[0]; + } + + if (count($parameters) > 1) { + $scale = $parameters[1]; + } + + break; + } + + if ($dbType === 'bpchar') { + $fixed = true; + } elseif ($dbType === 'jsonb') { + $jsonb = true; + } + + $options = [ + 'length' => $length, + 'notnull' => (bool)$tableColumn['isnotnull'], + 'default' => $this->parseDefaultExpression($tableColumn['default']), + 'precision' => $precision, + 'scale' => $scale, + 'fixed' => $fixed, + 'autoincrement' => $tableColumn['attidentity'] === 'd', + ]; + + if ($tableColumn['comment'] !== null) { + $options['comment'] = $tableColumn['comment']; + } + + $column = new Column($tableColumn['field'], Type::getType($type), $options); + + if (! empty($tableColumn['collation'])) { + $column->setPlatformOption('collation', $tableColumn['collation']); + } + + if ($column->getType() instanceof JsonType) { + $column->setPlatformOption('jsonb', $jsonb); + } + + return $column; + } + + /** + * Parses a default value expression as given by PostgreSQL + * + * Copy of {@see DoctrinePostgreSQLSchemaManager::parseDefaultExpression()} (Doctrine DBAL 4.3.x) + */ + private function parseDefaultExpression(?string $expression): mixed + { + if ($expression === null || str_starts_with($expression, 'NULL::')) { + return null; + } + + if ($expression === 'true') { + return true; + } + + if ($expression === 'false') { + return false; + } + + if (preg_match("/^'(.*)'::/s", $expression, $matches) === 1) { + return str_replace("''", "'", $matches[1]); + } + + return $expression; + } + + /** + * Parses the parameters between parenthesis in the data type. + * + * Copy of {@see DoctrinePostgreSQLSchemaManager::parseColumnTypeParameters()} + * + * @return list + */ + private function parseColumnTypeParameters(string $type): array + { + if (preg_match('/\((\d+)(?:,(\d+))?\)/', $type, $matches) !== 1) { + return []; + } + + $parameters = [(int)$matches[1]]; + + if (isset($matches[2])) { + $parameters[] = (int)$matches[2]; + } + + return $parameters; + } +} diff --git a/Classes/Database/Schema/SchemaManager/SQLiteSchemaManager.php b/Classes/Database/Schema/SchemaManager/SQLiteSchemaManager.php new file mode 100644 index 0000000..817e5ba --- /dev/null +++ b/Classes/Database/Schema/SchemaManager/SQLiteSchemaManager.php @@ -0,0 +1,79 @@ + $column) { + $fakeTableColumn = [ + 'type' => $column->getType(), + 'comment' => $column->getComment(), + ]; + $type = $this->determineColumnType('', $fakeTableColumn); + if ($type !== '') { + $column->setType(Type::getType($type)); + } + $column->setComment($fakeTableColumn['comment']); + } + + return $list; + } + + /** + * Gets Table Column Definition. + * + * @param array $tableColumn + */ + protected function _getPortableTableColumnDefinition(array $tableColumn): Column + { + return parent::_getPortableTableColumnDefinition($tableColumn); + } +} diff --git a/Classes/Database/Schema/SchemaMigrator.php b/Classes/Database/Schema/SchemaMigrator.php new file mode 100644 index 0000000..a8cc606 --- /dev/null +++ b/Classes/Database/Schema/SchemaMigrator.php @@ -0,0 +1,515 @@ + SQL statements to migrate the database to the expected schema, indexed by performed operation + * @throws DBALException + * @throws SchemaException + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @throws StatementException + */ + public function getUpdateSuggestions(array $statements, bool $remove = false): array + { + $tables = $this->parseCreateTableStatements($statements); + $updateSuggestions = []; + foreach ($this->connectionPool->getConnectionNames() as $connectionName) { + $connection = $this->connectionPool->getConnectionByName($connectionName); + $connectionMigrator = new ConnectionMigrator($connectionName, $connection, $this->connectionPool, $tables); + $updateSuggestions[$connectionName] = $connectionMigrator->getUpdateSuggestions($remove); + } + return $updateSuggestions; + } + + /** + * Return the raw Doctrine SchemaDiff objects for each connection. This diff contains + * all changes without any pre-processing. + * + * @return array + * @throws DBALException + * @throws SchemaException + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @throws StatementException + */ + public function getSchemaDiffs(array $statements): array + { + $tables = $this->parseCreateTableStatements($statements); + $schemaDiffs = []; + foreach ($this->connectionPool->getConnectionNames() as $connectionName) { + $connection = $this->connectionPool->getConnectionByName($connectionName); + $connectionMigrator = new ConnectionMigrator($connectionName, $connection, $this->connectionPool, $tables); + $schemaDiffs[$connectionName] = $connectionMigrator->getSchemaDiff(); + } + return $schemaDiffs; + } + + /** + * This method executes statements from the update suggestions, or a subset of them + * filtered by the statements hashes, one by one. + * + * @param string[] $statements The CREATE TABLE statements + * @param string[] $selectedStatements The hashes of the update suggestions to execute + * @throws DBALException + * @throws SchemaException + * @throws \InvalidArgumentException + * @throws StatementException + * @throws \RuntimeException + */ + public function migrate(array $statements, array $selectedStatements): array + { + $result = []; + $updateSuggestionsPerConnection = array_replace_recursive( + $this->getUpdateSuggestions($statements), + $this->getUpdateSuggestions($statements, true) + ); + + foreach ($updateSuggestionsPerConnection as $connectionName => $updateSuggestions) { + unset($updateSuggestions['tables_count'], $updateSuggestions['change_currentValue']); + $updateSuggestions = array_merge(...array_values($updateSuggestions)); + $statementsToExecute = array_intersect_key($updateSuggestions, $selectedStatements); + if (count($statementsToExecute) === 0) { + continue; + } + + $connection = $this->connectionPool->getConnectionByName($connectionName); + foreach ($statementsToExecute as $hash => $statement) { + try { + $connection->executeStatement($statement); + } catch (DBALException $e) { + $result[$hash] = $e->getMessage(); + } + } + } + $this->flushDatabaseSchemaCache(); + + return $result; + } + + /** + * Perform add/change/create operations on tables and fields in an optimized, non-interactive, mode. + * + * @param string[] $statements The CREATE TABLE statements + * @param bool $createOnly Only perform changes that add fields or create tables + * @return array Error messages for statements that occurred during the installation procedure. + * @throws DBALException + * @throws SchemaException + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @throws StatementException + */ + public function install(array $statements, bool $createOnly = false): array + { + $tables = $this->parseCreateTableStatements($statements); + $result = []; + foreach ($this->connectionPool->getConnectionNames() as $connectionName) { + $connection = $this->connectionPool->getConnectionByName($connectionName); + $connectionMigrator = new ConnectionMigrator($connectionName, $connection, $this->connectionPool, $tables); + $lastResult = $connectionMigrator->install($createOnly); + $result = array_merge($result, $lastResult); + } + $this->flushDatabaseSchemaCache(); + + return $result; + } + + /** + * Import static data (INSERT statements) + */ + public function importStaticData(array $statements, bool $truncate = false): array + { + $result = []; + $insertStatements = []; + + foreach ($statements as $statement) { + // Only handle insert statements and extract the table at the same time. Extracting + // the table name is required to perform the inserts on the right connection. + if (preg_match('/^INSERT\s+INTO\s+`?(\w+)`?(.*)/i', $statement, $matches)) { + [, $tableName, $sqlFragment] = $matches; + $insertStatements[$tableName][] = sprintf( + 'INSERT INTO %s %s', + $this->connectionPool->getConnectionForTable($tableName)->quoteIdentifier($tableName), + rtrim($sqlFragment, ';') + ); + } + } + + foreach ($insertStatements as $tableName => $perTableStatements) { + $connection = $this->connectionPool->getConnectionForTable($tableName); + + if ($truncate) { + $connection->truncate($tableName); + } + + foreach ((array)$perTableStatements as $statement) { + try { + $connection->executeStatement($statement); + $result[$statement] = ''; + } catch (DBALException $e) { + $result[$statement] = $e->getMessage(); + } + } + } + + return $result; + } + + /** + * Parse CREATE TABLE statements into Doctrine Table objects. + * + * @param string[] $statements The SQL CREATE TABLE statements + * @return array + * @throws SchemaException + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @throws StatementException + */ + protected function parseCreateTableStatements(array $statements): array + { + $tables = $this->prepareTablesFromStatements($statements); + $tables = $this->ensureTableDefinitionForAllTCAManagedTables($tables); + $tables = $this->mergeTableDefinitions($tables); + $tables = $this->enrichTablesFromDefaultTCASchema($tables); + $tables = $this->ensureDefaultTCAFieldsAreOrdered($tables); + return $tables; + } + + /** + * Have fields triggered by 'ctrl' settings first in the list. This is done for cosmetic + * reasons to improve readability of db schema when opening tables in a database browser. + * + * @return string[] + */ + protected function getPrioritizedFieldNames(string $tableName): array + { + if (!$this->tcaSchemaFactory->has($tableName)) { + return []; + } + + $prioritizedFieldNames = [ + 'uid', + 'pid', + ]; + $tableSchema = $this->tcaSchemaFactory->get($tableName); + + if ($tableSchema->hasCapability(TcaSchemaCapability::CreatedAt)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::UpdatedAt)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::RestrictionUserGroup)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName(); + } + if ($tableSchema->isLanguageAware()) { + $languageField = $tableSchema->getCapability(TcaSchemaCapability::Language); + $prioritizedFieldNames[] = $languageField->getLanguageField()->getName(); + $prioritizedFieldNames[] = $languageField->getTranslationOriginPointerField()->getName(); + // @todo `l10n_state` is automatically added in `DefaultTcaSchema->enrichSingleTableFieldsFromTcaCtrl()` + // if `ctrl->languageField` and `ctrl->transOrigPointerField` are configured, and not provided + // by extension `ext_tables.sql`. This field has no representation in TcaSchema language field + // handling yet, nor is this covered within TcaEnrichment thus adding it here directly for now. + $prioritizedFieldNames[] = 'l10n_state'; + if (!empty($languageField->hasTranslationSourceField())) { + $prioritizedFieldNames[] = $languageField->getTranslationSourceField()->getName(); + } + if (!empty($languageField->hasDiffSourceField())) { + $prioritizedFieldNames[] = $languageField->getDiffSourceField()->getName(); + } + } + if ($tableSchema->hasCapability(TcaSchemaCapability::SortByField)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::InternalDescription)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::InternalDescription)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::EditLock)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::EditLock)->getFieldName(); + } + if ($tableSchema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) { + $prioritizedFieldNames[] = $tableSchema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName(); + } + if ($tableSchema->isWorkspaceAware()) { + // @todo Adding hardcoded field names directly thus not having a representation within the TcaSchema. These + // fields do not get proper TCA either within `TcaEnrichment` albeit ensured to be created within + // `DefaultTcaSchema->enrichSingleTableFieldsFromTcaCtrl()` as soon as `ctr->versioningWS` is true. + $prioritizedFieldNames[] = 't3ver_wsid'; + $prioritizedFieldNames[] = 't3ver_oid'; + $prioritizedFieldNames[] = 't3ver_state'; + $prioritizedFieldNames[] = 't3ver_stage'; + } + + return $prioritizedFieldNames; + } + + /** + * To give extensions the ability to extend or modify the database schema for core or other extension tables, a + * collection of DDL statement parts are parsed into partial table classes. This method merges the table definition + * parts to end up with a single table representation to ease further handling. + * + * @param Table[] $tables + * @return array + */ + private function mergeTableDefinitions(array $tables): array + { + $return = []; + foreach ($tables as $table) { + $tableName = $this->trimIdentifierQuotes($table->getName()); + if (!array_key_exists($tableName, $return)) { + $return[$tableName] = $table; + continue; + } + + // Merge multiple table definitions. Later definitions overrule identical + // columns, indexes and foreign_keys. Order of definitions is based on + // extension load order. + $currentTableDefinition = $return[$tableName]; + $return[$tableName] = new Table( + $tableName, + $this->mergeColumns(...$currentTableDefinition->getColumns(), ...$table->getColumns()), + $this->mergeIndexes(...array_values($currentTableDefinition->getIndexes()), ...array_values($table->getIndexes())), + [], + $this->mergeForeignKeys(...array_values($currentTableDefinition->getForeignKeys()), ...array_values($table->getForeignKeys())), + array_merge($currentTableDefinition->getOptions(), $table->getOptions()) + ); + } + + return $return; + } + + /** + * @param Column ...$columns + * @return Column[] + */ + private function mergeColumns(Column ...$columns): array + { + $mergedColumns = []; + foreach ($columns as $column) { + $mergedColumns[$column->getName()] = $column; + } + return array_values($mergedColumns); + } + + /** + * @param Index ...$indexes + * @return Index[] + */ + private function mergeIndexes(Index ...$indexes): array + { + $mergedIndexes = []; + foreach ($indexes as $index) { + $mergedIndexes[$index->getName()] = $index; + } + return array_values($mergedIndexes); + } + + /** + * Unnamed foreign key constraints cannot be identified by name and are therefore kept as they are. + * Doctrine generates a name for them, but only as the array key - not on the constraint itself. + * + * @param ForeignKeyConstraint ...$foreignKeys + * @return ForeignKeyConstraint[] + */ + private function mergeForeignKeys(ForeignKeyConstraint ...$foreignKeys): array + { + $mergedForeignKeys = []; + foreach ($foreignKeys as $foreignKey) { + $foreignKeyName = $foreignKey->getName(); + if ($foreignKeyName === '') { + $mergedForeignKeys[] = $foreignKey; + continue; + } + $mergedForeignKeys[$foreignKeyName] = $foreignKey; + } + return array_values($mergedForeignKeys); + } + + /** + * Trim all possible identifier quotes from identifier. This method has been cloned from Doctrine DBAL. + * + * @see \Doctrine\DBAL\Schema\AbstractAsset::trimQuotes() + */ + private function trimIdentifierQuotes(string $identifier): string + { + return str_replace(['`', '"', '[', ']'], '', $identifier); + } + + /** + * @param string[] $statements + * @return Table[] + * @throws SchemaException + * @throws StatementException + */ + protected function prepareTablesFromStatements(array $statements): array + { + $tables = []; + foreach ($statements as $statement) { + // We need to keep multiple table definitions at this point so + // that Extensions can modify existing tables. + try { + $tables[] = $this->parser->parse($statement); + } catch (StatementException $statementException) { + // Enrich the error message with the full invalid statement + throw new StatementException( + $statementException->getMessage() . ' in statement: ' . LF . $statement, + 1476171315, + $statementException + ); + } + } + + // Flatten the array of arrays by one level + $tables = array_merge(...$tables); + + return $tables; + } + + /** + * Ensure we have a table definition for all tables within TCA, add missing ones + * as "empty" tables without columns. This is needed for DefaultTcaSchema: It goes + * through TCA to add columns automatically, but needs a table definition of all + * TCA tables. We're not doing this in DefaultTcaSchema to not introduce a dependency + * to the Parser class in there, which we have here so conveniently already. + * + * @param Table[] $tables + * @return Table[] + * @throws SchemaException + * @throws StatementException + */ + protected function ensureTableDefinitionForAllTCAManagedTables(array $tables): array + { + $tableNamesFromTca = $this->tcaSchemaFactory->all()->getNames(); + $tableNamesFromExtTables = []; + foreach ($tables as $table) { + $tableNamesFromExtTables[] = $table->getName(); + } + $tableNamesFromExtTables = array_unique($tableNamesFromExtTables); + $missingTableNames = array_diff($tableNamesFromTca, $tableNamesFromExtTables); + foreach ($missingTableNames as $tableName) { + $createTableSql = 'CREATE TABLE ' . $tableName . '();'; + $tables[] = $this->parser->parse($createTableSql)[0]; + } + return $tables; + } + + /** + * @param array $tables + * @return array + */ + protected function enrichTablesFromDefaultTCASchema(array $tables): array + { + return $this->defaultTcaSchema->enrich($tables); + } + + /** + * Ensure the default TCA fields are ordered. + * + * @param array $tables + * @return array + */ + protected function ensureDefaultTCAFieldsAreOrdered(array $tables): array + { + foreach ($tables as $k => $table) { + $prioritizedColumnNames = $this->getPrioritizedFieldNames($table->getName()); + // no TCA table + if (empty($prioritizedColumnNames)) { + continue; + } + + $prioritizedColumns = []; + $nonPrioritizedColumns = []; + + foreach ($table->getColumns() as $columnObject) { + if (in_array($columnObject->getName(), $prioritizedColumnNames, true)) { + $prioritizedColumns[] = $columnObject; + } else { + $nonPrioritizedColumns[] = $columnObject; + } + } + + $tables[$k] = new Table( + $table->getName(), + array_merge($prioritizedColumns, $nonPrioritizedColumns), + $table->getIndexes(), + [], + $table->getForeignKeys(), + $table->getOptions() + ); + } + return $tables; + } + + protected function flushDatabaseSchemaCache(): void + { + Bootstrap::createCache('database_schema')->flush(); + $this->runtime->flush(); + } +} diff --git a/Classes/Database/Schema/SqlReader.php b/Classes/Database/Schema/SqlReader.php new file mode 100644 index 0000000..9445c25 --- /dev/null +++ b/Classes/Database/Schema/SqlReader.php @@ -0,0 +1,146 @@ +eventDispatcher = $eventDispatcher; + $this->packageManager = $packageManager; + } + + /** + * Cycle through all loaded extensions and get full table definitions as concatenated string + * + * @param bool $withStatic TRUE if sql from ext_tables_static+adt.sql should be loaded, too. + * @return string Concatenated SQL of loaded extensions ext_tables.sql + */ + public function getTablesDefinitionString(bool $withStatic = false): string + { + $sqlString = []; + + // Find all ext_tables.sql of loaded extensions + foreach ($this->packageManager->getActivePackages() as $package) { + $packagePath = $package->getPackagePath(); + if (@file_exists($packagePath . 'ext_tables.sql')) { + $sqlString[] = (string)file_get_contents($packagePath . 'ext_tables.sql'); + } + if ($withStatic && @file_exists($packagePath . 'ext_tables_static+adt.sql')) { + $sqlString[] = (string)file_get_contents($packagePath . 'ext_tables_static+adt.sql'); + } + } + + $event = $this->eventDispatcher->dispatch(new AlterTableDefinitionStatementsEvent($sqlString)); + $sqlString = $event->getSqlData(); + + return implode(LF . LF, $sqlString); + } + + /** + * Returns an array where every entry is a single SQL-statement. + * Input must be formatted like an ordinary MySQL dump file. Every statements needs to be terminated by a ';' + * and there may only be one statement (or partial statement) per line. + * + * @param string $dumpContent The SQL dump content. + * @param string|null $queryRegex Regex to select which statements to return. + * @return array Array of SQL statements + */ + public function getStatementArray(string $dumpContent, ?string $queryRegex = null): array + { + $statementArray = []; + $statementArrayPointer = 0; + $isInMultilineComment = false; + foreach (explode(LF, $dumpContent) as $lineContent) { + $lineContent = trim($lineContent); + + // Skip empty lines and comments + if ($lineContent === '' + || $lineContent[0] === '#' + || str_starts_with($lineContent, '--') + || str_starts_with($lineContent, '/*') + || str_ends_with($lineContent, '*/') + || $isInMultilineComment + ) { + // skip c style multiline comments + if (str_starts_with($lineContent, '/*') && !str_ends_with($lineContent, '*/')) { + $isInMultilineComment = true; + } + if (str_ends_with($lineContent, '*/')) { + $isInMultilineComment = false; + } + continue; + } + + $statementArray[$statementArrayPointer] = ($statementArray[$statementArrayPointer] ?? '') . $lineContent; + + if (str_ends_with($lineContent, ';')) { + $statement = trim($statementArray[$statementArrayPointer]); + if (!$statement || ($queryRegex && !preg_match('/' . $queryRegex . '/i', $statement))) { + unset($statementArray[$statementArrayPointer]); + } + $statementArrayPointer++; + } else { + $statementArray[$statementArrayPointer] .= ' '; + } + } + + return $statementArray; + } + + /** + * Extract only INSERT statements from SQL dump + */ + public function getInsertStatementArray(string $dumpContent): array + { + return $this->getStatementArray($dumpContent, '^INSERT'); + } + + /** + * Extract only CREATE TABLE statements from SQL dump + */ + public function getCreateTableStatementArray(string $dumpContent): array + { + return $this->getStatementArray($dumpContent, '^CREATE TABLE'); + } +} diff --git a/Classes/Database/Schema/TableDiff.php b/Classes/Database/Schema/TableDiff.php new file mode 100644 index 0000000..07cec18 --- /dev/null +++ b/Classes/Database/Schema/TableDiff.php @@ -0,0 +1,342 @@ + $addedColumns + * @param array $changedColumns + * @param array $droppedColumns + * @param array $addedIndexes + * @param array $modifiedIndexes + * @param array $droppedIndexes + * @param array $renamedIndexes + * @param array $addedForeignKeys + * @param array $modifiedForeignKeys + * @param array $droppedForeignKeys + * @param array $tableOptions + * + * @internal The diff can be only instantiated by a {@see Comparator}. + * + * @todo Consider to change from array to typed collections with array access support. + */ + public function __construct( + public Table $oldTable, + public array $addedColumns = [], + public array $changedColumns = [], + public array $droppedColumns = [], + public array $addedIndexes = [], + public array $modifiedIndexes = [], + public array $droppedIndexes = [], + public array $renamedIndexes = [], + public array $addedForeignKeys = [], + public array $modifiedForeignKeys = [], + public array $droppedForeignKeys = [], + public array $tableOptions = [], + ) { + // NOTE: parent::__construct() not called by intention. + } + + /** + * Getter for table options. + * + * @return array + */ + public function getTableOptions(): array + { + return $this->tableOptions; + } + + /** + * Setter for table options + * + * @param array $tableOptions + */ + public function setTableOptions(array $tableOptions): self + { + $this->tableOptions = $tableOptions; + return $this; + } + + /** + * Check if a table options has been set. + */ + public function hasTableOption(string $optionName): bool + { + return array_key_exists($optionName, $this->tableOptions); + } + + public function getTableOption(string $optionName): string + { + if ($this->hasTableOption($optionName)) { + return (string)$this->tableOptions[$optionName]; + } + + return ''; + } + + public function getOldTable(): Table + { + return $this->oldTable; + } + + /** @return array */ + public function getAddedColumns(): array + { + return $this->addedColumns; + } + + /** @return array */ + public function getChangedColumns(): array + { + return $this->changedColumns; + } + + /** @return array */ + public function getDroppedColumns(): array + { + return $this->droppedColumns; + } + + /** @return array */ + public function getAddedIndexes(): array + { + return $this->addedIndexes; + } + + /** + * @deprecated Use {@see getAddedIndexes()} and {@see getDroppedIndexes()} instead. + * + * @return array + */ + public function getModifiedIndexes(): array + { + return $this->modifiedIndexes; + } + + /** @return array */ + public function getDroppedIndexes(): array + { + return $this->droppedIndexes; + } + + /** @return array */ + public function getRenamedIndexes(): array + { + return $this->renamedIndexes; + } + + /** @return array */ + public function getAddedForeignKeys(): array + { + return $this->addedForeignKeys; + } + + /** + * @deprecated Use {@see getAddedForeignKeys()} and {@see getDroppedForeignKeys()} instead. + * + * @return array + */ + public function getModifiedForeignKeys(): array + { + return $this->modifiedForeignKeys; + } + + /** + * @deprecated Use {@see getDroppedForeignKeyConstraintNames()}. + * + * @return array + */ + public function getDroppedForeignKeys(): array + { + return $this->droppedForeignKeys; + } + + /** + * Overridden, because the parent implementation reads the parent property directly. As the parent + * constructor is not called by intention, that property is never initialized, making the inherited + * method raise an `Error`. Reads the redeclared property here instead, keeping the parent behaviour. + * + * @return array + */ + public function getDroppedForeignKeyConstraintNames(): array + { + $names = []; + foreach ($this->droppedForeignKeys as $droppedForeignKey) { + $name = $droppedForeignKey->getObjectName(); + if ($name === null) { + throw InvalidState::tableDiffContainsUnnamedDroppedForeignKeyConstraints(); + } + $names[] = $name; + } + return $names; + } + + public function isEmpty(): bool + { + return count($this->getAddedColumns()) === 0 + && count($this->getChangedColumns()) === 0 + && count($this->getDroppedColumns()) === 0 + && count($this->getAddedIndexes()) === 0 + && count($this->getModifiedIndexes()) === 0 + && count($this->getDroppedIndexes()) === 0 + && count($this->getRenamedIndexes()) === 0 + && count($this->getAddedForeignKeys()) === 0 + && count($this->getModifiedForeignKeys()) === 0 + && count($this->getDroppedForeignKeys()) === 0 + // doctrine/dbal 4.x removed the newName. TYPO3 needs that to provide a rename to prefix logic before + // really dropping tables instead. Therefore, we need to add here an empty check for the reintroduced + // property.See for example: ConnectionMigrator->migrateUnprefixedRemovedTablesToRenames + && $this->getNewName() !== null && $this->getNewName() !== '' + && $this->getTableOptions() === []; + } + + public function getNewName(): ?string + { + return $this->newName; + } + + public static function ensure(DoctrineTableDiff|TableDiff $tableDiff): self + { + $diff = new self( + // oldTable + $tableDiff->getOldTable(), + // addedColumns + $tableDiff->getAddedColumns(), + // changedColumns + [], + // droppedColumns + $tableDiff->getDroppedColumns(), + // addedIndexes + $tableDiff->getAddedIndexes(), + // modifiedIndexes + [], + // droppedIndexes + $tableDiff->getDroppedIndexes(), + // renamedIndexes + $tableDiff->getRenamedIndexes(), + // addedForeignKeys + $tableDiff->getAddedForeignKeys(), + // modifiedForeignKeys + $tableDiff->getModifiedForeignKeys(), + // droppedForeignKeys + $tableDiff->getDroppedForeignKeys(), + // tableOptions + ($tableDiff instanceof TableDiff ? $tableDiff->tableOptions : []), + ); + + // doctrine/dbal 4+ removed the column name as array index for modified column definitions, + // but we rely on it. Restore it ! + // Ensure to use custom ColumnDiff instance with more data and + foreach ($tableDiff->getChangedColumns() as $changedColumn) { + $diff->changedColumns[$changedColumn->getOldColumn()->getName()] = new ColumnDiff( + // oldColumn + $changedColumn->getOldColumn(), + // newColumn + $changedColumn->getNewColumn(), + ); + } + + // doctrine/dbal 4+ removed the index name as array index for modified index definitions, + // but we rely on it. Restore it !. + foreach ($tableDiff->getModifiedIndexes() as $modifiedIndex) { + $diff->modifiedIndexes[$modifiedIndex->getName()] = $modifiedIndex; + } + + // Accumulate modified index separated into added and dropped information to modifiedIndexes again, + // otherwise required drop action may not be executed before trying to add an existing index first. + // Required for planned doctrine/dbal 4.3.0 change (deprecation) and currently breaking with an open + // discussion to mitigate that before dbal release. We still prepare for this case to be on the safer + // side here. + // Needs to be done in a two-step strategy to avoid changing array while iterating over it. + // - https://github.com/doctrine/dbal/pull/6831 + // - https://github.com/doctrine/dbal/issues/6880 + /** + * @var array $transformIndexOperations + */ + $transformIndexOperations = []; + foreach ($diff->getAddedIndexes() as $addedIndex) { + foreach ($diff->getDroppedIndexes() as $droppedIndex) { + if ($droppedIndex->getName() === $addedIndex->getName()) { + $transformIndexOperations[] = [ + 'added' => $addedIndex, + 'dropped' => $droppedIndex, + ]; + } + } + } + foreach ($transformIndexOperations as $data) { + $diff->unsetAddedIndex($data['added']); + $diff->unsetDroppedIndex($data['dropped']); + $diff->modifiedIndexes[$data['added']->getName()] = $data['added']; + } + + return $diff; + } + + /** + * @internal This method exists only for compatibility with the current implementation of schema managers + * that modify the diff while processing it. + */ + public function unsetAddedIndex(Index $index): void + { + $this->addedIndexes = array_filter( + $this->addedIndexes, + static function (Index $addedIndex) use ($index): bool { + return $addedIndex !== $index; + }, + ); + } + + /** + * @internal This method exists only for compatibility with the current implementation of schema managers + * that modify the diff while processing it. + */ + public function unsetDroppedIndex(Index $index): void + { + $this->droppedIndexes = array_filter( + $this->droppedIndexes, + static function (Index $droppedIndex) use ($index): bool { + return $droppedIndex !== $index; + }, + ); + } +} diff --git a/Classes/Database/Schema/Types/DateTimeType.php b/Classes/Database/Schema/Types/DateTimeType.php new file mode 100644 index 0000000..cbc31f8 --- /dev/null +++ b/Classes/Database/Schema/Types/DateTimeType.php @@ -0,0 +1,43 @@ +format($platform->getDateTimeFormatString()); + } + + throw InvalidType::new($value, self::getTypeRegistry()->lookupName($this), ['null', 'string', 'DateTime']); + } +} diff --git a/Classes/Database/Schema/Types/DateType.php b/Classes/Database/Schema/Types/DateType.php new file mode 100644 index 0000000..cdfde9a --- /dev/null +++ b/Classes/Database/Schema/Types/DateType.php @@ -0,0 +1,43 @@ +format($platform->getDateFormatString()); + } + + throw InvalidType::new($value, self::getTypeRegistry()->lookupName($this), ['null', 'string', 'DateTime']); + } +} diff --git a/Classes/Database/Schema/Types/SetType.php b/Classes/Database/Schema/Types/SetType.php new file mode 100644 index 0000000..baa155f --- /dev/null +++ b/Classes/Database/Schema/Types/SetType.php @@ -0,0 +1,51 @@ +getSetDeclarationSQL($fieldDeclaration); + } + $quotedValues = array_map($platform->quoteStringLiteral(...), $fieldDeclaration['values']); + return sprintf('SET(%s)', implode(', ', $quotedValues)); + + } +} diff --git a/Classes/Database/Schema/Types/TimeType.php b/Classes/Database/Schema/Types/TimeType.php new file mode 100644 index 0000000..7842e9a --- /dev/null +++ b/Classes/Database/Schema/Types/TimeType.php @@ -0,0 +1,43 @@ +format($platform->getTimeFormatString()); + } + + throw InvalidType::new($value, self::getTypeRegistry()->lookupName($this), ['null', 'DateTime']); + } +} diff --git a/Classes/DependencyInjection/AllowedCallablePass.php b/Classes/DependencyInjection/AllowedCallablePass.php new file mode 100644 index 0000000..15ff588 --- /dev/null +++ b/Classes/DependencyInjection/AllowedCallablePass.php @@ -0,0 +1,50 @@ +hasDefinition(AllowedCallableAssertion::class)) { + return; + } + $definition = $container->findDefinition(AllowedCallableAssertion::class); + $definition->setArgument('$items', $this->resolveItems($container)); + } + + /** + * @return list + */ + private function resolveItems(ContainerBuilder $container): array + { + $items = []; + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + foreach ($tags as $tag) { + $items[] = [$id, $tag['method']]; + } + } + return $items; + } +} diff --git a/Classes/DependencyInjection/AssetFileSystemPublisherPass.php b/Classes/DependencyInjection/AssetFileSystemPublisherPass.php new file mode 100644 index 0000000..337acbf --- /dev/null +++ b/Classes/DependencyInjection/AssetFileSystemPublisherPass.php @@ -0,0 +1,71 @@ +orderingService = new DependencyOrderingService(); + } + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition(DefaultSystemResourcePublisher::class)) { + // If there's no default system resource publisher registered to begin with, don't bother registering file system publishers with it. + return; + } + $publishers = []; + $unorderedPublishers = $this->collectPublishers($container); + foreach ($this->orderingService->orderByDependencies($unorderedPublishers) as $publisher) { + $publishers[] = $container->findDefinition($publisher['service']); + } + $publisherDefinition = $container->findDefinition(DefaultSystemResourcePublisher::class); + $publisherDefinition->addArgument($publishers); + } + + /** + * Collects all listeners from the container. + */ + private function collectPublishers(ContainerBuilder $container): array + { + $unorderedPublishers = []; + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + foreach ($tags as $attributes) { + $publisherIdentifier = $attributes['identifier'] ?? $serviceName; + $unorderedPublishers[$publisherIdentifier] = [ + 'service' => $serviceName, + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + ]; + } + } + return $unorderedPublishers; + } +} diff --git a/Classes/DependencyInjection/AutowireInjectMethodsPass.php b/Classes/DependencyInjection/AutowireInjectMethodsPass.php new file mode 100644 index 0000000..7ce4e1c --- /dev/null +++ b/Classes/DependencyInjection/AutowireInjectMethodsPass.php @@ -0,0 +1,116 @@ +isAutowired() || $value->isAbstract() || !$value->getClass()) { + return $value; + } + if (!$reflectionClass = $this->container->getReflectionClass($value->getClass(), false)) { + return $value; + } + + $alreadyCalledMethods = []; + + foreach ($value->getMethodCalls() as [$method]) { + $alreadyCalledMethods[strtolower($method)] = true; + } + + $addInitCall = false; + + foreach ($reflectionClass->getMethods() as $reflectionMethod) { + $r = $reflectionMethod; + + if ($r->isConstructor() || isset($alreadyCalledMethods[strtolower($r->name)])) { + continue; + } + + if ($reflectionMethod->isPublic() && str_starts_with($reflectionMethod->name, 'inject')) { + $this->addInjectMethodCall($value, $reflectionMethod); + } + + if ($reflectionMethod->name === 'initializeObject' && $reflectionMethod->isPublic()) { + $addInitCall = true; + } + } + + if ($addInitCall) { + // Add call to initializeObject() which is required by classes that need to perform + // constructions tasks after the inject* method based injection of dependencies. + $value->addMethodCall('initializeObject'); + } + + return $value; + } + + private function addInjectMethodCall(Definition $definition, \ReflectionMethod $reflectionMethod): void + { + $definition->addMethodCall( + $reflectionMethod->name, + $this->getRequiredInjectMethodArguments($definition, $reflectionMethod) + ); + } + + /** + * @return array + */ + private function getRequiredInjectMethodArguments(Definition $definition, \ReflectionMethod $reflectionMethod): array + { + $channelExtractor = new LogChannelExtractor(); + $arguments = []; + foreach ($reflectionMethod->getParameters() as $parameter) { + if (!$parameter->hasType()) { + continue; + } + + $type = $parameter->getType(); + if (!$type instanceof \ReflectionNamedType || $type->getName() !== LoggerInterface::class) { + continue; + } + + $channel = $channelExtractor->getParameterChannelName($parameter) ?? $channelExtractor->getClassChannelName($this->container->getReflectionClass($definition->getClass(), false)) ?? $definition->getClass(); + + $logger = new Definition(Logger::class); + $logger->setFactory([new Reference(LogManager::class), 'getLogger']); + $logger->setArguments([$channel]); + $logger->setShared(false); + + $name = '$' . $parameter->getName(); + $arguments[$name] = $logger; + } + return $arguments; + } +} diff --git a/Classes/DependencyInjection/Cache/ContainerBackend.php b/Classes/DependencyInjection/Cache/ContainerBackend.php new file mode 100644 index 0000000..8c9f36c --- /dev/null +++ b/Classes/DependencyInjection/Cache/ContainerBackend.php @@ -0,0 +1,36 @@ + true, + ]); + } +} diff --git a/Classes/DependencyInjection/ConsoleCommandPass.php b/Classes/DependencyInjection/ConsoleCommandPass.php new file mode 100644 index 0000000..9ce680f --- /dev/null +++ b/Classes/DependencyInjection/ConsoleCommandPass.php @@ -0,0 +1,91 @@ +tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition(CommandRegistry::class)) { + return; + } + $commandRegistryDefinition = $container->findDefinition(CommandRegistry::class); + + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $commandServiceDefinition = $container->findDefinition($serviceName)->setPublic(true); + $commandName = null; + $description = null; + $hidden = false; + $aliases = []; + foreach ($tags as $attributes) { + $command = $attributes['command'] ?? null; + $description = $attributes['description'] ?? $description; + $hidden = $attributes['hidden'] ?? $hidden; + $schedulable = $attributes['schedulable'] ?? true; + $aliasFor = null; + if ($command === null) { + continue; + } + + $isAlias = $commandName !== null || ($attributes['alias'] ?? false); + if (!$isAlias) { + $commandName = $attributes['command']; + } else { + $aliasFor = $commandName; + $aliases[] = $attributes['command']; + } + + $commandRegistryDefinition->addMethodCall('addLazyCommand', [ + $command, + $serviceName, + $description, + $hidden, + $schedulable, + $aliasFor, + ]); + } + $commandServiceDefinition->addMethodCall('setName', [$commandName]); + if ($description) { + $commandServiceDefinition->addMethodCall('setDescription', [$description]); + } + if ($hidden) { + $commandServiceDefinition->addMethodCall('setHidden', [true]); + } + if ($aliases) { + $commandServiceDefinition->addMethodCall('setAliases', [$aliases]); + } + } + } +} diff --git a/Classes/DependencyInjection/ContainerBuilder.php b/Classes/DependencyInjection/ContainerBuilder.php new file mode 100644 index 0000000..5ba1097 --- /dev/null +++ b/Classes/DependencyInjection/ContainerBuilder.php @@ -0,0 +1,188 @@ +defaultServices = $earlyInstances + [ self::class => $this ]; + } + + /** + * @internal + */ + public function warmupCache(PackageManager $packageManager, FrontendInterface $cache): void + { + $registry = new ServiceProviderRegistry($packageManager); + $containerBuilder = $this->buildContainer($packageManager, $registry); + $cacheIdentifier = $this->getCacheIdentifier($packageManager); + $this->dumpContainer($containerBuilder, $cache, $cacheIdentifier); + } + + public function createDependencyInjectionContainer(PackageManager $packageManager, FrontendInterface $cache, bool $failsafe = false): ContainerInterface + { + if (!$cache instanceof PhpFrontend) { + throw new \RuntimeException('Cache must be instance of PhpFrontend', 1582022226); + } + $serviceProviderRegistry = new ServiceProviderRegistry($packageManager, $failsafe); + + if ($failsafe) { + return new FailsafeContainer($serviceProviderRegistry, $this->defaultServices); + } + + $cacheIdentifier = $this->getCacheIdentifier($packageManager); + $containerClassName = $cacheIdentifier; + + $hasCache = $cache->requireOnce($cacheIdentifier) !== false; + if (!$hasCache) { + $containerBuilder = $this->buildContainer($packageManager, $serviceProviderRegistry); + $this->dumpContainer($containerBuilder, $cache, $cacheIdentifier); + $cache->requireOnce($cacheIdentifier); + } + $container = new $containerClassName(); + + foreach ($this->defaultServices as $id => $service) { + $container->set('_early.' . $id, $service); + } + + $container->set($this->serviceProviderRegistryServiceName, $serviceProviderRegistry); + + return $container; + } + + protected function buildContainer(PackageManager $packageManager, ServiceProviderRegistry $registry): SymfonyContainerBuilder + { + $containerBuilder = new SymfonyContainerBuilder(); + + $containerBuilder->addCompilerPass(new ResolveClassPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 1000); + $containerBuilder->addCompilerPass(new ServiceProviderCompilationPass($registry, $this->serviceProviderRegistryServiceName)); + + $globalConfigDir = Environment::getConfigPath(); + // If the config folder is outside of the document root, we allow further services per-project + // This is usually the case in composer-based installations + if (Environment::getPublicPath() !== Environment::getProjectPath()) { + if (file_exists($globalConfigDir . '/system/services.php')) { + $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator($globalConfigDir . '/system')); + $phpFileLoader->load('services.php'); + } + if (file_exists($globalConfigDir . '/system/services.yaml')) { + $yamlFileLoader = new YamlFileLoader($containerBuilder, new FileLocator($globalConfigDir . '/system')); + $yamlFileLoader->load('services.yaml'); + } + } + + $packages = $packageManager->getActivePackages(); + foreach ($packages as $package) { + $diConfigDir = $package->getPackagePath() . 'Configuration/'; + if (file_exists($diConfigDir . 'Services.php')) { + $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator($diConfigDir)); + $phpFileLoader->load('Services.php'); + } + if (file_exists($diConfigDir . 'Services.yaml')) { + $yamlFileLoader = new YamlFileLoader($containerBuilder, new FileLocator($diConfigDir)); + $yamlFileLoader->load('Services.yaml'); + } + } + // Store defaults entries in the DIC container + // We need to use a workaround using aliases for synthetic services + // But that's common in symfony (same technique is used to provide the + // Symfony container interface as well). + foreach (array_keys($this->defaultServices) as $id) { + $syntheticId = '_early.' . $id; + $containerBuilder->register($syntheticId)->setSynthetic(true)->setPublic(true); + $containerBuilder->setAlias($id, $syntheticId)->setPublic(true); + } + + // Optional service, set by BootService as back reference to the original bootService + $containerBuilder->register('_early.boot-service')->setSynthetic(true)->setPublic(true); + + $containerBuilder->compile(); + + return $containerBuilder; + } + + protected function dumpContainer(SymfonyContainerBuilder $containerBuilder, FrontendInterface $cache, string $cacheIdentifier): string + { + $containerClassName = $cacheIdentifier; + + $phpDumper = new PhpDumper($containerBuilder); + $code = $phpDumper->dump(['class' => $containerClassName]); + $code = str_replace('set($cacheIdentifier, $code); + + return $code; + } + + /** + * @internal may only be used in this class or in functional tests + */ + public function getCacheIdentifier(PackageManager $packageManager): string + { + $packageManagerCacheIdentifier = $packageManager->getCacheIdentifier() ?? ''; + return $this->cacheIdentifiers[$packageManagerCacheIdentifier] ?? $this->createCacheIdentifier($packageManager, $packageManagerCacheIdentifier); + } + + protected function createCacheIdentifier(PackageManager $packageManager, string $additionalIdentifier): string + { + return $this->cacheIdentifiers[$additionalIdentifier] = (new PackageDependentCacheIdentifier($packageManager)) + ->withPrefix('DependencyInjectionContainer') + ->withAdditionalHashedIdentifier('PHP-' . PHP_MAJOR_VERSION . '-' . PHP_MINOR_VERSION) + ->toString(); + } +} diff --git a/Classes/DependencyInjection/ContainerException.php b/Classes/DependencyInjection/ContainerException.php new file mode 100644 index 0000000..e919be8 --- /dev/null +++ b/Classes/DependencyInjection/ContainerException.php @@ -0,0 +1,26 @@ + 'string|bool', + ]; + } +} diff --git a/Classes/DependencyInjection/FailsafeContainer.php b/Classes/DependencyInjection/FailsafeContainer.php new file mode 100644 index 0000000..0340d1b --- /dev/null +++ b/Classes/DependencyInjection/FailsafeContainer.php @@ -0,0 +1,113 @@ +entries = $entries; + + $factories = []; + foreach ($providers as $provider) { + /** @var ServiceProviderInterface $provider */ + $factories = $provider->getFactories() + $factories; + foreach ($provider->getExtensions() as $id => $extension) { + // Decorate a previously defined extension or if that is not available, + // create a lazy lookup to a factory from the list of vanilla factories. + // Lazy because we currently can not know whether a factory will only + // become available due to a subsequent provider. + $innerFactory = $this->factories[$id] ?? static function (ContainerInterface $c) use (&$factories, $id) { + return isset($factories[$id]) ? $factories[$id]($c) : null; + }; + + $this->factories[$id] = static function (ContainerInterface $container) use ($extension, $innerFactory) { + $previous = $innerFactory($container); + return $extension($container, $previous); + }; + } + } + + // Add factories to the list of factories for services that were not extended. + // (i.e those that have not been specified in getExtensions) + $this->factories += $factories; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->entries) || array_key_exists($id, $this->factories); + } + + /** + * @return mixed + */ + private function create(string $id) + { + $factory = $this->factories[$id] ?? null; + + if ((bool)$factory) { + // Remove factory as it is no longer required. + // Set factory to false to be able to detect + // cyclic dependency loops. + $this->factories[$id] = false; + + return $this->entries[$id] = $factory($this); + } + if (array_key_exists($id, $this->entries)) { + // This condition is triggered in the unlikely case that the entry is null + // Note: That is because the coalesce operator used in get() can not handle that + return $this->entries[$id]; + } + if ($factory === null) { + throw new NotFoundException('Container entry "' . $id . '" is not available.', 1519978105); + } + // if ($factory === false) + throw new ContainerException('Container entry "' . $id . '" is part of a cyclic dependency chain.', 1520175002); + } + + /** + * @return mixed + */ + public function get(string $id) + { + return $this->entries[$id] ?? $this->create($id); + } +} diff --git a/Classes/DependencyInjection/FileRendererPass.php b/Classes/DependencyInjection/FileRendererPass.php new file mode 100644 index 0000000..2ef9292 --- /dev/null +++ b/Classes/DependencyInjection/FileRendererPass.php @@ -0,0 +1,49 @@ +findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if ($definition->isAbstract()) { + continue; + } + $className = $definition->getClass() ?? $id; + if (!is_a($className, FileRendererInterface::class, true)) { + throw new \InvalidArgumentException( + 'Service "' . $id . '" is tagged as "' . $this->tagName . '", but its class "' . $className . '" does not implement ' . FileRendererInterface::class . '.', + 1784818672 + ); + } + } + } +} diff --git a/Classes/DependencyInjection/ListenerProviderPass.php b/Classes/DependencyInjection/ListenerProviderPass.php new file mode 100644 index 0000000..072ceb9 --- /dev/null +++ b/Classes/DependencyInjection/ListenerProviderPass.php @@ -0,0 +1,173 @@ +orderer = new DependencyOrderingService(); + } + + public function process(ContainerBuilder $container): void + { + $this->container = $container; + + if (!$container->hasDefinition(ListenerProvider::class)) { + // If there's no listener provider registered to begin with, don't bother registering listeners with it. + return; + } + $listenerProviderDefinition = $container->findDefinition(ListenerProvider::class); + + $unorderedEventListeners = $this->collectListeners($container); + + foreach ($unorderedEventListeners as $eventName => $listeners) { + // Configure ListenerProvider factory to include these listeners + foreach ($this->orderer->orderByDependencies($listeners) as $listenerIdentifier => $listener) { + $listenerProviderDefinition->addMethodCall('addListener', [ + $eventName, + $listener['service'], + $listener['method'], + $listenerIdentifier, + ]); + } + } + } + + /** + * Collects all listeners from the container. + */ + private function collectListeners(ContainerBuilder $container): array + { + $unorderedEventListeners = []; + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $service = $container->findDefinition($serviceName); + $service->setPublic(true); + foreach ($tags as $attributes) { + $eventIdentifiers = $attributes['event'] ?? $this->getParameterType($serviceName, $service, $attributes['method'] ?? '__invoke'); + if ($eventIdentifiers === null || $eventIdentifiers === '' || $eventIdentifiers === []) { + throw new \InvalidArgumentException( + 'Service tag "event.listener" requires an event attribute to be defined or the listener method must declare a parameter type. Missing in: ' . $serviceName, + 1563217364 + ); + } + if (is_string($eventIdentifiers)) { + $eventIdentifiers = [$eventIdentifiers]; + } + foreach ($eventIdentifiers as $eventIdentifier) { + $listenerIdentifier = $attributes['identifier'] ?? $serviceName; + $unorderedEventListeners[$eventIdentifier][$listenerIdentifier] = [ + 'service' => $serviceName, + 'method' => $attributes['method'] ?? null, + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + ]; + } + } + } + return $unorderedEventListeners; + } + + /** + * Derives the class type(s) of the first argument of a given method. + * Supporting union types, this method returns the class type(s) as list. + * + * @return string[]|null A list of class types or NULL on failure + */ + private function getParameterType(string $serviceName, Definition $definition, string $method = '__invoke'): ?array + { + // A Reflection exception should never actually get thrown here, but linters want a try-catch just in case. + try { + if (!$definition->isAutowired()) { + throw new \InvalidArgumentException( + sprintf('Service "%s" has event listeners defined but does not declare an event to listen to and is not configured to autowire it from the listener method. Set autowire: true to enable auto-detection of the listener event.', $serviceName), + 1623881314, + ); + } + $params = $this->getReflectionMethod($serviceName, $definition, $method)->getParameters(); + $rType = count($params) ? $params[0]->getType() : null; + if ($rType instanceof \ReflectionNamedType) { + return [$rType->getName()]; + } + if ($rType instanceof \ReflectionUnionType) { + $types = []; + foreach ($rType->getTypes() as $type) { + if ($type instanceof \ReflectionNamedType) { + $types[] = $type->getName(); + } + } + if ($types === []) { + throw new \InvalidArgumentException( + sprintf('Service "%s" registers method "%s" as an event listener, but does not specify an event type and the method\'s first parameter does not contain a valid class type. Declare valid class types for the method parameter or specify the event classes explicitly', $serviceName, $method), + 1688646662, + ); + } + return $types; + } + throw new \InvalidArgumentException( + sprintf('Service "%s" registers method "%s" as an event listener, but does not specify an event type and the method does not type a parameter. Declare a class type for the method parameter or specify an event class explicitly', $serviceName, $method), + 1623881315, + ); + } catch (\ReflectionException $e) { + // The collectListeners() method will convert this to an exception. + return null; + } + } + + /** + * @throws RuntimeException + * + * This method borrowed very closely from Symfony's AbstractRecurisvePass. + */ + private function getReflectionMethod(string $serviceName, Definition $definition, string $method): \ReflectionFunctionAbstract + { + if (!$class = $definition->getClass()) { + throw new RuntimeException(sprintf('Invalid service "%s": the class is not set.', $serviceName), 1623881310); + } + + if (!$r = $this->container->getReflectionClass($class)) { + throw new RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceName, $class), 1623881311); + } + + if (!$r->hasMethod($method)) { + throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" does not exist.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1623881312); + } + + $r = $r->getMethod($method); + if (!$r->isPublic()) { + throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" must be public.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1623881313); + } + + return $r; + } +} diff --git a/Classes/DependencyInjection/LogChannelExtractor.php b/Classes/DependencyInjection/LogChannelExtractor.php new file mode 100644 index 0000000..84730f8 --- /dev/null +++ b/Classes/DependencyInjection/LogChannelExtractor.php @@ -0,0 +1,47 @@ +getAttributes(Channel::class, \ReflectionAttribute::IS_INSTANCEOF); + if ($attributes !== []) { + return $attributes[0]->newInstance()->name; + } + if ($class->getParentClass() !== false) { + return $this->getClassChannelName($class->getParentClass()); + } + return null; + } + + public function getParameterChannelName(\ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(Channel::class, \ReflectionAttribute::IS_INSTANCEOF); + if ($attributes !== []) { + return $attributes[0]->newInstance()->name; + } + return null; + } +} diff --git a/Classes/DependencyInjection/LoggerAwarePass.php b/Classes/DependencyInjection/LoggerAwarePass.php new file mode 100644 index 0000000..90fdf13 --- /dev/null +++ b/Classes/DependencyInjection/LoggerAwarePass.php @@ -0,0 +1,67 @@ +tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + $channelExtractor = new LogChannelExtractor(); + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutowired() || $definition->isAbstract()) { + continue; + } + + $channel = $id; + if ($definition->getClass()) { + $reflectionClass = $container->getReflectionClass($definition->getClass(), false); + if ($reflectionClass) { + $channel = $channelExtractor->getClassChannelName($reflectionClass) ?? $definition->getClass(); + } + } + + $logger = new Definition(Logger::class); + $logger->setFactory([new Reference(LogManager::class), 'getLogger']); + $logger->setArguments([$channel]); + $logger->setShared(false); + + $definition->addMethodCall('setLogger', [$logger]); + } + } +} diff --git a/Classes/DependencyInjection/LoggerInterfacePass.php b/Classes/DependencyInjection/LoggerInterfacePass.php new file mode 100644 index 0000000..d31d688 --- /dev/null +++ b/Classes/DependencyInjection/LoggerInterfacePass.php @@ -0,0 +1,106 @@ +isAutowired() || $value->isAbstract() || !$value->getClass()) { + return $value; + } + if (!$reflectionClass = $this->container->getReflectionClass($value->getClass(), false)) { + return $value; + } + + $constructor = $reflectionClass->getConstructor(); + if ($constructor === null) { + return $value; + } + + $arguments = $value->getArguments(); + foreach ($reflectionClass->getConstructor()->getParameters() as $index => $parameter) { + $name = '$' . $parameter->getName(); + + if (isset($arguments[$name]) || isset($arguments[$index])) { + continue; + } + + if (!$parameter->hasType()) { + continue; + } + + $type = $parameter->getType(); + if (!($type instanceof \ReflectionNamedType && $type->getName() === LoggerInterface::class)) { + continue; + } + + $channel = $this->getParameterChannelName($parameter) ?? $this->getClassChannelName($reflectionClass) ?? $value->getClass(); + + $logger = new Definition(Logger::class); + $logger->setFactory([new Reference(LogManager::class), 'getLogger']); + $logger->setArguments([$channel]); + $logger->setShared(false); + + $value->setArgument($name, $logger); + } + + return $value; + } + + protected function getParameterChannelName(\ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(Channel::class, \ReflectionAttribute::IS_INSTANCEOF); + if ($attributes !== []) { + return $attributes[0]->newInstance()->name; + } + + return null; + } + + protected function getClassChannelName(\ReflectionClass $class): ?string + { + $attributes = $class->getAttributes(Channel::class, \ReflectionAttribute::IS_INSTANCEOF); + if ($attributes !== []) { + return $attributes[0]->newInstance()->name; + } + + if ($class->getParentClass() !== false) { + return $this->getClassChannelName($class->getParentClass()); + } + + return null; + } +} diff --git a/Classes/DependencyInjection/MessageHandlerPass.php b/Classes/DependencyInjection/MessageHandlerPass.php new file mode 100644 index 0000000..ecf4a7a --- /dev/null +++ b/Classes/DependencyInjection/MessageHandlerPass.php @@ -0,0 +1,170 @@ +orderer = new DependencyOrderingService(); + } + + public function process(ContainerBuilder $container): void + { + $this->container = $container; + + if (!$container->hasDefinition(HandlersLocatorFactory::class)) { + // If there's no listener provider registered to begin with, don't bother registering listeners with it. + return; + } + + $handlersLocatorFactory = $container->findDefinition(HandlersLocatorFactory::class); + + foreach ($this->collectHandlers($container) as $message => $handlers) { + foreach ($this->orderer->orderByDependencies($handlers) as $handler) { + $handlersLocatorFactory->addMethodCall('addHandler', [ + $message, + $handler['service'], + $handler['method'] ?? '__invoke', + ]); + } + } + } + + /** + * Collects all handlers from the container. + */ + private function collectHandlers(ContainerBuilder $container): array + { + $unorderedHandlers = []; + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $service = $container->findDefinition($serviceName); + $service->setPublic(true); + foreach ($tags as $attributes) { + $messageHandlers = $attributes['message'] ?? $this->getParameterType($serviceName, $service, $attributes['method'] ?? '__invoke'); + if ($messageHandlers === null || $messageHandlers === '' || $messageHandlers === []) { + throw new \InvalidArgumentException( + 'Service tag "messenger.message_handler" requires a message attribute to be defined or the method must declare a parameter type. Missing in: ' . $serviceName, + 1606732015 + ); + } + if (is_string($messageHandlers)) { + $messageHandlers = [$messageHandlers]; + } + foreach ($messageHandlers as $messageHandler) { + $messageIdentifier = sprintf('%s->%s', $serviceName, $attributes['method'] ?? '__invoke'); + $unorderedHandlers[$messageHandler][$messageIdentifier] = [ + 'service' => $serviceName, + 'method' => $attributes['method'] ?? null, + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + ]; + } + } + } + return $unorderedHandlers; + } + + /** + * Derives the class type(s) of the first argument of a given method. + * Supporting union types, this method returns the class type(s) as list. + * + * @return string[]|null A list of class types or NULL on failure + */ + private function getParameterType(string $serviceName, Definition $definition, string $method = '__invoke'): ?array + { + // A Reflection exception should never actually get thrown here, but linters want a try-catch just in case. + try { + if (!$definition->isAutowired()) { + throw new \InvalidArgumentException( + sprintf('Service "%s" has message handlers defined but does not declare a message to handle to and is not configured to autowire it from the handle method. Set autowire: true to enable auto-detection of the handled message.', $serviceName), + 1606732016, + ); + } + $params = $this->getReflectionMethod($serviceName, $definition, $method)->getParameters(); + $rType = count($params) ? $params[0]->getType() : null; + if ($rType instanceof \ReflectionNamedType) { + return [$rType->getName()]; + } + if ($rType instanceof \ReflectionUnionType) { + $types = []; + foreach ($rType->getTypes() as $type) { + if ($type instanceof \ReflectionNamedType) { + $types[] = $type->getName(); + } + } + if ($types === []) { + throw new \InvalidArgumentException( + sprintf('Service "%s" registers method "%s" as a message handler, but does not specify a message type and the method\'s first parameter does not contain a valid class type. Declare a valid class type for the method parameter or specify a message class explicitly', $serviceName, $method), + 1606732017, + ); + } + return $types; + } + throw new \InvalidArgumentException( + sprintf('Service "%s" registers method "%s" as a message handler, but does not specify a message type and the method does not type a parameter. Declare a class type for the method parameter or specify a event message explicitly', $serviceName, $method), + 1606732022, + ); + } catch (\ReflectionException $e) { + // The collectHandlers() method will convert this to an exception. + return null; + } + } + + /** + * @throws RuntimeException + * This method borrowed very closely from Symfony's AbstractRecursivePass (and the ListenerProviderPass). + * @see \TYPO3\CMS\Core\DependencyInjection\ListenerProviderPass::getReflectionMethod() + */ + private function getReflectionMethod(string $serviceName, Definition $definition, string $method): \ReflectionFunctionAbstract + { + if (!$class = $definition->getClass()) { + throw new RuntimeException(sprintf('Invalid service "%s": the class is not set.', $serviceName), 1606732018); + } + + if (!$r = $this->container->getReflectionClass($class)) { + throw new RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceName, $class), 1606732019); + } + + if (!$r->hasMethod($method)) { + throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" does not exist.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1606732020); + } + + $r = $r->getMethod($method); + if (!$r->isPublic()) { + throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" must be public.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1606732021); + } + + return $r; + } +} diff --git a/Classes/DependencyInjection/MessengerMiddlewarePass.php b/Classes/DependencyInjection/MessengerMiddlewarePass.php new file mode 100644 index 0000000..f015e4f --- /dev/null +++ b/Classes/DependencyInjection/MessengerMiddlewarePass.php @@ -0,0 +1,78 @@ +tagName = $tagName; + $this->orderer = new DependencyOrderingService(); + } + + public function process(ContainerBuilder $container): void + { + $busFactory = $container->findDefinition(BusFactory::class); + $groupedMiddlewares = $this->collectMiddlewares($container); + $middlewares = []; + foreach ($groupedMiddlewares as $bus => $unorderedMiddlewares) { + $middlewares[$bus] = []; + foreach ($this->orderer->orderByDependencies($unorderedMiddlewares) as $middleware) { + $middlewares[$bus][] = new Reference($middleware['service']); + } + } + $busFactory->setArgument('$middlewares', array_map( + static fn(array $busMiddlewares): IteratorArgument => new IteratorArgument($busMiddlewares), + $middlewares + )); + } + + /** + * Collects all messenger middlewares from the container and prepares them for ordering + */ + private function collectMiddlewares(ContainerBuilder $container): array + { + $unorderedMiddlewares = []; + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + foreach ($tags as $attributes) { + $bus = $attributes['bus'] ?? 'default'; + $unorderedMiddlewares[$bus][$serviceName] = [ + 'service' => $serviceName, + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + ]; + } + } + return $unorderedMiddlewares; + } +} diff --git a/Classes/DependencyInjection/MfaProviderPass.php b/Classes/DependencyInjection/MfaProviderPass.php new file mode 100644 index 0000000..35e181d --- /dev/null +++ b/Classes/DependencyInjection/MfaProviderPass.php @@ -0,0 +1,89 @@ +tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + $mfaProviderRegistryDefinition = $container->findDefinition(MfaProviderRegistry::class); + $providers = []; + + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + $definition->setPublic(true); + + foreach ($tags as $attributes) { + $identifier = $attributes['identifier'] ?? $id; + $providers[$identifier] = [ + 'title' => $attributes['title'] ?? '', + 'description' => $attributes['description'] ?? '', + 'setupInstructions' => $attributes['setupInstructions'] ?? '', + 'iconIdentifier' => $attributes['icon'] ?? '', + 'isDefaultProviderAllowed' => (bool)($attributes['defaultProviderAllowed'] ?? true), + 'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true), + 'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true), + 'serviceName' => $id, + ]; + } + } + + foreach ((new DependencyOrderingService())->orderByDependencies($providers) as $identifier => $properties) { + $manifest = new Definition(MfaProviderManifest::class); + $manifest->setArguments([ + $identifier, + $properties['title'], + $properties['description'], + $properties['setupInstructions'], + $properties['iconIdentifier'], + $properties['isDefaultProviderAllowed'], + $properties['serviceName'], + new Reference(ContainerInterface::class), + ]); + $manifest->setShared(false); + + $mfaProviderRegistryDefinition->addMethodCall('registerProvider', [$manifest]); + } + } +} diff --git a/Classes/DependencyInjection/NotFoundException.php b/Classes/DependencyInjection/NotFoundException.php new file mode 100644 index 0000000..31ecae6 --- /dev/null +++ b/Classes/DependencyInjection/NotFoundException.php @@ -0,0 +1,26 @@ +tagName = $tagName; + $this->stateful = $stateful; + } + + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + $definition->setPublic(true); + + if ($this->stateful) { + $definition->setShared(false); + } + } + } +} diff --git a/Classes/DependencyInjection/ResolveClassPass.php b/Classes/DependencyInjection/ResolveClassPass.php new file mode 100644 index 0000000..615cdbc --- /dev/null +++ b/Classes/DependencyInjection/ResolveClassPass.php @@ -0,0 +1,49 @@ +getDefinitions() as $id => $definition) { + if ($definition->isSynthetic() || $definition->getClass() !== null) { + continue; + } + if (preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)++$/', $id)) { + if ($definition instanceof ChildDefinition && !class_exists($id)) { + throw new InvalidArgumentException(sprintf('Service definition "%s" has a parent but no class, and its name looks like an FQCN. Either the class is missing or you want to inherit it from the parent service. To resolve this ambiguity, please rename this service to a non-FQCN (e.g. using dots), or create the missing class.', $id), 1764317265); + } + $definition->setClass($id); + } + } + } +} diff --git a/Classes/DependencyInjection/ServiceProviderCompilationPass.php b/Classes/DependencyInjection/ServiceProviderCompilationPass.php new file mode 100644 index 0000000..b7f3b54 --- /dev/null +++ b/Classes/DependencyInjection/ServiceProviderCompilationPass.php @@ -0,0 +1,255 @@ +registry = $registry; + $this->registryServiceName = $registryServiceName; + } + + /** + * You can modify the container here before it is dumped to PHP code. + * + * @param ContainerBuilder $container + */ + public function process(ContainerBuilder $container): void + { + // Now, let's store the registry in the container (an empty version of it... it has to be added dynamically at runtime): + $this->registerRegistry($container); + + foreach ($this->registry as $serviceProviderKey => $serviceProvider) { + $this->registerFactories($container, $serviceProviderKey); + } + + foreach ($this->registry as $serviceProviderKey => $serviceProvider) { + $this->registerExtensions($container, $serviceProviderKey); + } + } + + /** + * @param ContainerBuilder $container + */ + private function registerRegistry(ContainerBuilder $container): void + { + $definition = new Definition(ServiceProviderRegistry::class); + $definition->setSynthetic(true); + $definition->setPublic(true); + + $container->setDefinition($this->registryServiceName, $definition); + } + + /** + * @param ContainerBuilder $container + */ + private function registerFactories(ContainerBuilder $container, string $serviceProviderKey): void + { + $serviceFactories = $this->registry->getFactories($serviceProviderKey); + + foreach ($serviceFactories as $serviceName => $callable) { + $this->registerService($container, $serviceName, $serviceProviderKey, $callable); + } + } + + /** + * @param ContainerBuilder $container + */ + private function registerExtensions(ContainerBuilder $container, string $serviceProviderKey): void + { + $serviceExtensions = $this->registry->getExtensions($serviceProviderKey); + + foreach ($serviceExtensions as $serviceName => $callable) { + $this->extendService($container, $serviceName, $serviceProviderKey, $callable); + } + } + + /** + * @param ContainerBuilder $container + */ + private function registerService( + ContainerBuilder $container, + string $serviceName, + string $serviceProviderKey, + callable $callable + ): void { + if (!$container->hasDefinition($serviceName)) { + // Create a new definition + $factoryDefinition = new Definition(); + $container->setDefinition($serviceName, $factoryDefinition); + } else { + // Merge into an existing definition to keep possible addMethodCall/properties configurations + // (which act like a service extension) + // Retrieve the existing factory and overwrite it. + $factoryDefinition = $container->getDefinition($serviceName); + if ($factoryDefinition->isAutowired()) { + $factoryDefinition->setAutowired(false); + } + } + + $className = $this->getReturnType($this->getReflection($callable), $serviceName) ?? 'object'; + $factoryDefinition->setClass($className); + $factoryDefinition->setPublic(true); + + $staticallyCallable = $this->getStaticallyCallable($callable); + if ($staticallyCallable !== null) { + $factoryDefinition->setFactory($staticallyCallable); + $factoryDefinition->setArguments([ + new Reference('service_container'), + ]); + } else { + $factoryDefinition->setFactory([ new Reference($this->registryServiceName), 'createService' ]); + $factoryDefinition->setArguments([ + $serviceProviderKey, + $serviceName, + new Reference('service_container'), + ]); + } + } + + /** + * @param ContainerBuilder $container + */ + private function extendService(ContainerBuilder $container, string $serviceName, string $serviceProviderKey, callable $callable): void + { + $finalServiceName = $serviceName; + $innerName = null; + + $reflection = $this->getReflection($callable); + $previousClass = $container->hasDefinition($serviceName) ? $container->getDefinition($serviceName)->getClass() : null; + $className = $this->getReturnType($reflection, $serviceName) ?? $previousClass ?? 'object'; + + $factoryDefinition = new Definition($className); + $factoryDefinition->setClass($className); + $factoryDefinition->setPublic(true); + + if ($container->has($serviceName)) { + [$finalServiceName, $previousServiceName] = $this->getDecoratedServiceName($container, $serviceName); + $innerName = $finalServiceName . '.inner'; + + $factoryDefinition->setDecoratedService($previousServiceName, $innerName); + } elseif ($reflection->getNumberOfRequiredParameters() > 1) { + throw new \Exception('A registered extension for the service "' . $serviceName . '" requires the service to be available, which is missing.', 1550092654); + } + + $staticallyCallable = $this->getStaticallyCallable($callable); + if ($staticallyCallable !== null) { + $factoryDefinition->setFactory($staticallyCallable); + $factoryDefinition->setArguments([ + new Reference('service_container'), + ]); + } else { + $factoryDefinition->setFactory([ new Reference($this->registryServiceName), 'extendService' ]); + $factoryDefinition->setArguments([ + $serviceProviderKey, + $serviceName, + new Reference('service_container'), + ]); + } + + if ($innerName !== null) { + $factoryDefinition->addArgument(new Reference($innerName)); + } + + $container->setDefinition($finalServiceName, $factoryDefinition); + } + + private function getStaticallyCallable(callable $callable): ?callable + { + if (is_string($callable)) { + return $callable; + } + if (is_array($callable) && is_string($callable[0])) { + return $callable; + } + + if ($callable instanceof \Closure) { + $reflection = new \ReflectionFunction($callable); + $reflectionScope = $reflection->getClosureScopeClass(); + $usedVariables = count($reflection->getClosureUsedVariables()); + + if ($reflection->isStatic() && !$reflection->isAnonymous() && $reflectionScope !== null && $usedVariables === 0) { + return [ + $reflectionScope->getName(), + $reflection->getName(), + ]; + } + } + + return null; + } + + private function getReturnType(\ReflectionFunctionAbstract $reflection, string $serviceName): ?string + { + if ($reflection->getReturnType() instanceof \ReflectionNamedType) { + return $reflection->getReturnType()->getName(); + } + + if (class_exists($serviceName, true) || interface_exists($serviceName, true)) { + return $serviceName; + } + + return null; + } + + private function getReflection(callable $callable): \ReflectionFunctionAbstract + { + if (is_array($callable) && count($callable) === 2) { + return new \ReflectionMethod($callable[0], $callable[1]); + } + if (is_object($callable) && !$callable instanceof \Closure) { + return new \ReflectionMethod($callable, '__invoke'); + } + + return new \ReflectionFunction($callable); + } + + /** + * @param ContainerBuilder $container + */ + private function getDecoratedServiceName(ContainerBuilder $container, string $serviceName): array + { + $counter = 1; + while ($container->has($serviceName . '_decorated_' . $counter)) { + $counter++; + } + return [ + $serviceName . '_decorated_' . $counter, + $counter === 1 ? $serviceName : $serviceName . '_decorated_' . ($counter - 1), + ]; + } +} diff --git a/Classes/DependencyInjection/ServiceProviderInterface.php b/Classes/DependencyInjection/ServiceProviderInterface.php new file mode 100644 index 0000000..2371f0c --- /dev/null +++ b/Classes/DependencyInjection/ServiceProviderInterface.php @@ -0,0 +1,58 @@ + + */ + private $instances; + + /** + * An array of service factories (the result of the call to 'getFactories'), + * indexed by service provider. + * + * @var array An array> + */ + private $serviceFactories = []; + + /** + * An array of service extensions (the result of the call to 'getExtensions'), + * indexed by service provider. + * + * @var array An array> + */ + private $serviceExtensions = []; + + /** + * Initializes the registry from a list of service providers. + * This list of service providers can be passed as ServiceProvider instances, class name string, + * or an array of ['class name', [constructor params...]]. + */ + public function __construct(PackageManager $packageManager, bool $failsafe = false) + { + $this->packageManager = $packageManager; + $this->failsafe = $failsafe; + } + + /** + * Whether an id exists. + * + * @param string $packageKey Key of the service provider in the registry + * @return bool true on success or false on failure. + */ + public function has(string $packageKey): bool + { + if (isset($this->instances[$packageKey])) { + return true; + } + + if ($this->packageManager->isPackageActive($packageKey)) { + if ($this->failsafe && $this->packageManager->getPackage($packageKey)->isPartOfMinimalUsableSystem() === false) { + return false; + } + return true; + } + + return false; + } + + /** + * Returns service provider by id. + * + * @param string $packageKey Key of the service provider in the registry + */ + public function get(string $packageKey): ServiceProviderInterface + { + return $this->instances[$packageKey] ?? $this->create($packageKey); + } + + /** + * Returns service provider by id. + * + * @param string $packageKey Key of the service provider in the registry + * @param Package|null $package + */ + private function create(string $packageKey, ?Package $package = null): ServiceProviderInterface + { + if ($package === null) { + if (!$this->packageManager->isPackageActive($packageKey)) { + throw new \InvalidArgumentException('Package ' . $packageKey . ' is not active', 1550351445); + } + $package = $this->packageManager->getPackage($packageKey); + } + $serviceProviderClassName = $package->getServiceProvider(); + $instance = new $serviceProviderClassName($package); + + if (!$instance instanceof ServiceProviderInterface) { + throw new \InvalidArgumentException('Service providers need to implement ' . ServiceProviderInterface::class, 1550302554); + } + + return $this->instances[$packageKey] = $instance; + } + + /** + * Returns the result of the getFactories call on service provider whose key in the registry is $packageKey. + * The result is cached in the registry so two successive calls will trigger `getFactories` only once. + * + * @param string $packageKey Key of the service provider in the registry + */ + public function getFactories(string $packageKey): array + { + return $this->serviceFactories[$packageKey] ?? ($this->serviceFactories[$packageKey] = $this->get($packageKey)->getFactories()); + } + + /** + * Returns the result of the getExtensions call on service provider whose key in the registry is $packageKey. + * The result is cached in the registry so two successive calls will trigger `getExtensions` only once. + * + * @param string $packageKey Key of the service provider in the registry + */ + public function getExtensions(string $packageKey): array + { + return $this->serviceExtensions[$packageKey] ?? ($this->serviceExtensions[$packageKey] = $this->get($packageKey)->getExtensions()); + } + + /** + * @param string $packageKey Key of the service provider in the registry + * @param string $serviceName Name of the service to fetch + * @return mixed + */ + public function createService(string $packageKey, string $serviceName, ContainerInterface $container) + { + $factory = $this->getFactories($packageKey)[$serviceName]; + return $factory($container); + } + + /** + * @param string $packageKey Key of the service provider in the registry + * @param string $serviceName Name of the service to fetch + * @param mixed $previous + * @return mixed + */ + public function extendService(string $packageKey, string $serviceName, ContainerInterface $container, $previous = null) + { + $extension = $this->getExtensions($packageKey)[$serviceName]; + return $extension($container, $previous); + } + + public function getIterator(): \Generator + { + foreach ($this->packageManager->getActivePackages() as $package) { + if ($this->failsafe && $package->isPartOfMinimalUsableSystem() === false) { + continue; + } + $packageKey = $package->getPackageKey(); + yield $packageKey => ($this->instances[$packageKey] ?? $this->create($packageKey, $package)); + } + } +} diff --git a/Classes/DependencyInjection/SingletonPass.php b/Classes/DependencyInjection/SingletonPass.php new file mode 100644 index 0000000..a68baf4 --- /dev/null +++ b/Classes/DependencyInjection/SingletonPass.php @@ -0,0 +1,51 @@ +tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + // Singletons need to be shared (that's symfony's configuration for singletons) + // They need to be public to be available for legacy makeInstance usage. + $definition->setShared(true)->setPublic(true); + } + } +} diff --git a/Classes/DependencyInjection/SoftReferenceParserPass.php b/Classes/DependencyInjection/SoftReferenceParserPass.php new file mode 100644 index 0000000..24394b2 --- /dev/null +++ b/Classes/DependencyInjection/SoftReferenceParserPass.php @@ -0,0 +1,58 @@ +tagName = $tagName; + } + + public function process(ContainerBuilder $container): void + { + $parserFactoryDefinition = $container->findDefinition(SoftReferenceParserFactory::class); + foreach ($container->findTaggedServiceIds($this->tagName) as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + $definition->setPublic(true); + foreach ($tags as $attributes) { + if (!($attributes['parserKey'] ?? false)) { + throw new \InvalidArgumentException( + 'Service tag "softreference.parser" requires the attribute "parserKey" to be set. Missing in: ' . $id, + 1628736154 + ); + } + $parserFactoryDefinition->addMethodCall('addParser', [$definition, $attributes['parserKey']]); + } + } + } +} diff --git a/Classes/Domain/Access/RecordAccessGrantedEvent.php b/Classes/Domain/Access/RecordAccessGrantedEvent.php new file mode 100644 index 0000000..57fa549 --- /dev/null +++ b/Classes/Domain/Access/RecordAccessGrantedEvent.php @@ -0,0 +1,79 @@ +accessGranted !== null; + } + + /** + * @internal + */ + public function accessGranted(): bool + { + if ($this->accessGranted === null) { + throw new \RuntimeException('Access was not yet defined.', 1645506529); + } + + return $this->accessGranted; + } + + public function setAccessGranted(bool $accessGranted): void + { + $this->accessGranted = $accessGranted; + } + + public function getTable(): string + { + return $this->tableName; + } + + public function getRecord(): array + { + return $this->record; + } + + public function updateRecord(array $record): void + { + $this->record = $record; + } + + public function getContext(): Context + { + return $this->context; + } +} diff --git a/Classes/Domain/Access/RecordAccessVoter.php b/Classes/Domain/Access/RecordAccessVoter.php new file mode 100644 index 0000000..a0d8716 --- /dev/null +++ b/Classes/Domain/Access/RecordAccessVoter.php @@ -0,0 +1,143 @@ +eventDispatcher->dispatch($event); + if ($event->isPropagationStopped()) { + return $event->accessGranted(); + } + $record = $event->getRecord(); + + $schema = $this->tcaSchemaFactory->get($table); + $visibilityAspect = $context->getAspect('visibility'); + $includeHidden = $table === 'pages' + ? $visibilityAspect->includeHiddenPages() + : $visibilityAspect->includeHiddenContent(); + + // Hidden field is active and hidden records should not be included + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) { + $fieldName = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(); + if (($record[$fieldName] ?? false) && !$includeHidden) { + return false; + } + } + // Records' starttime set AND is HIGHER than the current access time + if ($schema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) { + $fieldName = $schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(); + if (isset($record[$fieldName]) + && (int)$record[$fieldName] > $GLOBALS['SIM_ACCESS_TIME'] + && !$visibilityAspect->includeScheduledRecords() + ) { + return false; + } + } + // Records' endtime is set AND NOT "0" AND LOWER than the current access time + if ($schema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) { + $fieldName = $schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + if (isset($record[$fieldName]) + && ((int)$record[$fieldName] !== 0) + && ((int)$record[$fieldName] < $GLOBALS['SIM_ACCESS_TIME']) + && !$visibilityAspect->includeScheduledRecords() + ) { + return false; + } + } + // Insufficient group access + if ($this->groupAccessGranted($table, $record, $context) === false) { + return false; + } + // Record is available + return true; + } + + /** + * Check group access against a record, if the current users' groups match the fe_group values of the record. + * + * @param string $table the TCA table to check for + * @param array $record The record to evaluate (needs enableField: fe_group) + * @param Context $context Context API to check against + * @return bool TRUE, if group access is granted. + */ + public function groupAccessGranted(string $table, array $record, Context $context): bool + { + if (!$this->tcaSchemaFactory->has($table)) { + return true; + } + $schema = $this->tcaSchemaFactory->get($table); + // No fe_group field in TCA, so no group access check + if (!$schema->hasCapability(TcaSchemaCapability::RestrictionUserGroup)) { + return true; + } + $fieldName = $schema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName(); + // Field not given, so no group access check + if (!($record[$fieldName] ?? false)) { + return true; + } + // No frontend user, but 'fe_group' is not empty, so shut this down. + if (!$context->hasAspect('frontend.user')) { + return false; + } + $pageGroupList = explode(',', (string)$record[$fieldName]); + return count(array_intersect($context->getAspect('frontend.user')->getGroupIds(), $pageGroupList)) > 0; + } + + /** + * Checks if the current page of the root line is visible. + * + * If the field extendToSubpages is 0, access is granted, + * else the fields hidden, starttime, endtime, fe_group are evaluated. + * + * @internal this is a special use case and should only be used with care, not part of TYPO3's Public API. + */ + public function accessGrantedForPageInRootLine(array $pageRecord, Context $context): bool + { + return !($pageRecord['extendToSubpages'] ?? false) || $this->accessGranted('pages', $pageRecord, $context); + } +} diff --git a/Classes/Domain/ConsumableString.php b/Classes/Domain/ConsumableString.php new file mode 100644 index 0000000..219882e --- /dev/null +++ b/Classes/Domain/ConsumableString.php @@ -0,0 +1,53 @@ +value = $value; + } + + public function __toString(): string + { + return $this->consume(); + } + + public function count(): int + { + return $this->counter; + } + + public function consume(): string + { + $this->counter++; + return $this->value; + } +} diff --git a/Classes/Domain/DateTimeFactory.php b/Classes/Domain/DateTimeFactory.php new file mode 100644 index 0000000..65476f4 --- /dev/null +++ b/Classes/Domain/DateTimeFactory.php @@ -0,0 +1,147 @@ +isNullable(), + $fieldInformation->getFormat(), + $fieldInformation->getPersistenceType(), + ); + } + + public static function createFromDatabaseValueAndTCAConfig(int|string|null $value, array $fieldConfig): ?\DateTimeImmutable + { + $persistenceType = in_array($fieldConfig['dbType'] ?? null, QueryHelper::getDateTimeTypes(), true) ? $fieldConfig['dbType'] : null; + $isNative = $persistenceType !== null; + $isNullable = (bool)($fieldConfig['nullable'] ?? $isNative); + $format = self::getFormatFromTCAConfig($fieldConfig); + return self::fromDatabase( + $value, + $isNullable, + $format, + $persistenceType + ); + } + + public static function getFormatFromTCAConfig(array $fieldConfig): string + { + $format = $fieldConfig['format'] ?? null; + $persistenceType = in_array($fieldConfig['dbType'] ?? null, QueryHelper::getDateTimeTypes(), true) ? $fieldConfig['dbType'] : null; + // A native time field must not be formatted as date + if (($format === 'datetime' || $format === 'datetimesec' || $format === 'date') && $persistenceType === 'time') { + return 'timesec'; + } + // A native date field must not be formatted as time + if (($format === 'time' || $format === 'timesec' || $format === 'datetime' || $format === 'datetimesec') && $persistenceType === 'date') { + return 'date'; + } + if (in_array($format, ['datetime', 'date', 'time', 'timesec', 'datetimesec'], true)) { + return $format; + } + if ($persistenceType !== null) { + return $persistenceType === 'time' ? 'timesec' : $persistenceType; + } + return 'datetime'; + } + + /** + * Create a DateTimeImmutable object from a unix timestamp in server localtime + * + * Alternative to \DateTimeImmutable('@…') which forces UTC timezone + */ + public static function createFromTimestamp(int $timestamp): \DateTimeImmutable + { + // Create a new DateTime object in current timezone + // + // Note: As documented by PHP, `\DateTime` or `\DateTimeImmutable` + // objects created from timestamps (e.g., '@12345678') as the + // first constructor argument will use UTC as timezone instead of localtime, + // therefore we must not initialize with a timestamp directly. + $datetime = new \DateTimeImmutable(); + + // Apply timestamp (which will not change the objects timezone) + return $datetime->setTimestamp($timestamp); + } + + private static function fromDatabase( + int|string|null $value, + bool $isNullable, + string $format, + ?string $persistenceType + ): ?\DateTimeImmutable { + if ($value === null || $value === '') { + return null; + } + + $emptyFormat = QueryHelper::getDateTimeFormats()[$persistenceType ?? '']['empty'] ?? null; + // A regular empty value is null for nullable fields + $emptyValue = $isNullable ? null : ($emptyFormat ?? 0); + // A legacy empty value is "0000-00-00" or "0000-00-00 00:00:00" stored + // in a nullable native DATE or DATETIME field (which should already use + // a proper `null` value, but still has a legacy empty value set). + $legacyEmptyValue = $persistenceType === 'date' || $persistenceType === 'datetime' ? $emptyFormat : null; + + if (MathUtility::canBeInterpretedAsInteger($value)) { + $value = (int)$value; + } + if ($value === $emptyValue || $value === $legacyEmptyValue) { + return null; + } + + try { + $datetime = match (true) { + is_int($value) && ($format === 'time' || $format === 'timesec') => new \DateTimeImmutable( + // time(sec) is stored as elapsed seconds in DB and has no defined date associated. + // Per convention we map to 1970-01-01 for the sake of a reliable date. + // We still want a PHP localtime timezone in the DateTime object set, + // therefore we interpret the second as UTC time on 1970-01-01T00:00:00 + // and map the resulting value to PHP localtime + gmdate(DateTimeFormat::ISO8601_LOCALTIME, $value) + ), + // Unix timestamp + is_int($value) => self::createFromTimestamp($value), + // The database always contains server localtime in native fields. + // The field value is something like "2016-01-01" or "2016-01-01 10:11:12. + default => new \DateTimeImmutable($value), + }; + } catch (\DateMalformedStringException $e) { + throw new \InvalidArgumentException('Invalid date provided', 1743159490, $e); + } + + return match ($format) { + // time(sec) is stored as elapsed seconds in DB, hence we normalize it as time on 1970-01-01 for consistency + 'time' => $datetime->setDate(1970, 1, 1)->setTime((int)$datetime->format('H'), (int)$datetime->format('i'), 0), + 'timesec' => $datetime->setDate(1970, 1, 1), + 'date' => $datetime->setTime(0, 0, 0), + // default case also for 'datetimesec' + default => $datetime, + }; + } +} diff --git a/Classes/Domain/DateTimeFormat.php b/Classes/Domain/DateTimeFormat.php new file mode 100644 index 0000000..9c6bf77 --- /dev/null +++ b/Classes/Domain/DateTimeFormat.php @@ -0,0 +1,32 @@ +table; + } + + public function getRecord(): array + { + return $this->record; + } + + public function getLanguageAspect(): LanguageAspect + { + return $this->languageAspect; + } + + public function setLocalizedRecord(?array $localizedRecord): void + { + $this->overlayingWasAttempted = true; + $this->localizedRecord = $localizedRecord; + } + + public function getLocalizedRecord(): ?array + { + return $this->localizedRecord; + } + + /** + * Determines if the overlay functionality happened, thus, returning the lo + */ + public function overlayingWasAttempted(): bool + { + return $this->overlayingWasAttempted; + } +} diff --git a/Classes/Domain/Event/BeforePageIsRetrievedEvent.php b/Classes/Domain/Event/BeforePageIsRetrievedEvent.php new file mode 100644 index 0000000..571434a --- /dev/null +++ b/Classes/Domain/Event/BeforePageIsRetrievedEvent.php @@ -0,0 +1,83 @@ +page; + } + + public function setPage(Page $page): void + { + $this->page = $page; + } + + public function hasPage(): bool + { + return $this->page !== null; + } + + public function getPageId(): int + { + return $this->pageId; + } + + public function setPageId(int $pageId): void + { + $this->pageId = $pageId; + } + + public function skipGroupAccessCheck(): void + { + $this->skipGroupAccessCheck = true; + } + + public function respectGroupAccessCheck(): void + { + $this->skipGroupAccessCheck = false; + } + + public function isGroupAccessCheckSkipped(): bool + { + return $this->skipGroupAccessCheck; + } + + public function getContext(): Context + { + return $this->context; + } +} diff --git a/Classes/Domain/Event/BeforePageLanguageOverlayEvent.php b/Classes/Domain/Event/BeforePageLanguageOverlayEvent.php new file mode 100644 index 0000000..0dd5cf5 --- /dev/null +++ b/Classes/Domain/Event/BeforePageLanguageOverlayEvent.php @@ -0,0 +1,63 @@ +pageInput; + } + + public function setPageInput(array $pageInput): void + { + $this->pageInput = $pageInput; + } + + public function getPageIds(): array + { + return $this->pageIds; + } + + public function setPageIds(array $pageIds): void + { + $this->pageIds = array_map(intval(...), $pageIds); + } + + public function getLanguageAspect(): LanguageAspect + { + return $this->languageAspect; + } + + public function setLanguageAspect(LanguageAspect $languageAspect): void + { + $this->languageAspect = $languageAspect; + } +} diff --git a/Classes/Domain/Event/BeforeRecordLanguageOverlayEvent.php b/Classes/Domain/Event/BeforeRecordLanguageOverlayEvent.php new file mode 100644 index 0000000..7115981 --- /dev/null +++ b/Classes/Domain/Event/BeforeRecordLanguageOverlayEvent.php @@ -0,0 +1,58 @@ +table; + } + + public function getRecord(): array + { + return $this->record; + } + + public function setRecord(array $record): void + { + $this->record = $record; + } + + public function getLanguageAspect(): LanguageAspect + { + return $this->languageAspect; + } + + public function setLanguageAspect(LanguageAspect $languageAspect): void + { + $this->languageAspect = $languageAspect; + } +} diff --git a/Classes/Domain/Event/ModifyDefaultConstraintsForDatabaseQueryEvent.php b/Classes/Domain/Event/ModifyDefaultConstraintsForDatabaseQueryEvent.php new file mode 100644 index 0000000..bd7d61a --- /dev/null +++ b/Classes/Domain/Event/ModifyDefaultConstraintsForDatabaseQueryEvent.php @@ -0,0 +1,83 @@ + */ + private array $constraints, + /** @var array */ + private readonly array $enableFieldsToIgnore, + private readonly Context $context + ) {} + + public function getTable(): string + { + return $this->table; + } + + public function getTableAlias(): string + { + return $this->tableAlias; + } + + public function getExpressionBuilder(): ExpressionBuilder + { + return $this->expressionBuilder; + } + + /** + * @return array + */ + public function getConstraints(): array + { + return $this->constraints; + } + + public function setConstraints(array $constraints): void + { + $this->constraints = $constraints; + } + + public function getEnableFieldsToIgnore(): array + { + return array_keys(array_filter($this->enableFieldsToIgnore)); + } + + public function getContext(): Context + { + return $this->context; + } +} diff --git a/Classes/Domain/Event/RecordCreationEvent.php b/Classes/Domain/Event/RecordCreationEvent.php new file mode 100644 index 0000000..5f57953 --- /dev/null +++ b/Classes/Domain/Event/RecordCreationEvent.php @@ -0,0 +1,123 @@ +record = $record; + } + + public function isPropagationStopped(): bool + { + return $this->record !== null; + } + + public function hasProperty(string $name): bool + { + return array_key_exists($name, $this->properties); + } + + public function setProperty(string $name, mixed $propertyValue): void + { + $this->properties[$name] = $propertyValue; + } + + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + public function unsetProperty(string $name): bool + { + if (!$this->hasProperty($name)) { + return false; + } + unset($this->properties[$name]); + return true; + } + + public function getProperty(string $name): mixed + { + return $this->properties[$name] ?? null; + } + + public function getProperties(): array + { + return $this->properties; + } + + public function getRawRecord(): RawRecord + { + return $this->rawRecord; + } + + public function getSystemProperties(): SystemProperties + { + return $this->systemProperties; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getRecordIdentityMap(): RecordIdentityMap + { + return $this->recordIdentityMap; + } + + /** + * If available this is the subSchema for the current record type + */ + public function getSchema(): TcaSchema + { + return $this->schema; + } + + /** + * @internal + */ + public function getRecord(): ?RecordInterface + { + return $this->record; + } +} diff --git a/Classes/Domain/Exception/FlexFieldPropertyException.php b/Classes/Domain/Exception/FlexFieldPropertyException.php new file mode 100644 index 0000000..d16e84d --- /dev/null +++ b/Classes/Domain/Exception/FlexFieldPropertyException.php @@ -0,0 +1,26 @@ +has($id)) { + throw new FlexFieldPropertyNotFoundException('Flex property "' . $id . '" is not available.', 1731962637); + } + + [$sheetName, $propertyPath] = $this->processId($id); + + if ($sheetName === '' && $this->hasMultipleSheets()) { + // Get the sheet name for the requested property path - There is one, since has() returned true. + foreach ($this->sheets as $name => $sheet) { + if (ArrayUtility::isValidPath($sheet, $propertyPath, '.')) { + $sheetName = $name; + break; + } + } + } + + $propertyValue = ArrayUtility::getValueByPath($this->sheets[$sheetName], $propertyPath, '.'); + if (is_array($propertyValue)) { + array_walk_recursive($propertyValue, fn(mixed &$value): mixed => $value = $this->resolveRecordPropertyClosure($propertyPath, $value)); + } else { + $propertyValue = $this->resolveRecordPropertyClosure($propertyPath, $propertyValue); + } + ArrayUtility::setValueByPath($this->sheets[$sheetName], $propertyPath, $propertyValue, '.'); + return $propertyValue; + } + + public function has(string $id): bool + { + // If there are no sheets, no value can be determined. + if ($this->sheets === []) { + return false; + } + + [$sheetName, $propertyPath] = $this->processId($id); + + if ($sheetName !== '' && !isset($this->sheets[$sheetName])) { + // Given sheet name does not exist + return false; + } + if ($this->hasMultipleSheets()) { + if ($sheetName !== '') { + return ArrayUtility::isValidPath($this->sheets[$sheetName], $propertyPath, '.'); + } + // In case no sheet name is given, we try to execute fallback handling + // by searching for the requested $propertyPath in all sheets. + $occurences = []; + foreach ($this->sheets as $sheetName => $sheet) { + if (ArrayUtility::isValidPath($sheet, $propertyPath, '.')) { + $occurences[$sheetName] = $propertyPath; + } + } + + if (count($occurences) > 1) { + // This is a special case, which we handle with an exception to create awareness for the error. + throw new FlexFieldPropertyException('Given id is ambigious since the field exists in multiple sheets and no sheet is defined.', 1731962638); + } + // Whether the requested $propertyPath name exist in a sheet + return count($occurences) === 1; + } + + // Standard case, whether the $propertyPath exists in the given sheet name + return ArrayUtility::isValidPath($this->sheets[$sheetName], $propertyPath, '.'); + } + + public function offsetExists(mixed $offset): bool + { + return $this->has($offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get($offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + // Not implemented + } + + public function offsetUnset(mixed $offset): void + { + // Not implemented + } + + /** + * Getter to be used in fluid for accessing field values + */ + public function getSheets(): array + { + return $this->sheets; + } + + public function toArray(): array + { + return $this->getSheets(); + } + + protected function hasMultipleSheets(): bool + { + return count($this->sheets) > 1; + } + + protected function processId(string $id): array + { + if (str_contains($id, '/')) { + // $id contains a sheet name + return explode('/', $id, 2); + } + if ($this->hasMultipleSheets()) { + // $id does not contain a sheet name while there are multiple sheets. Therefore, we + // return an empty sheet name. This allows executing fallback handling in has() and get(). + return ['', $id]; + } + + // In case the $id does not contain a sheet name, but we have a + // single sheet flex form, we fall back to this name automatically. + return [key($this->sheets), $id]; + } + + protected function resolveRecordPropertyClosure(string $id, mixed $propertyValue): mixed + { + if ($propertyValue instanceof RecordPropertyClosure) { + try { + $propertyValue = $propertyValue->instantiate(); + } catch (\Exception $e) { + // Consumers of this method can rely on catching ContainerExceptionInterface + throw new FlexFieldPropertyException( + 'An exception occurred while instantiating flex field property "' . $id . '"', + 1731962735, + $e + ); + } + } + return $propertyValue; + } +} diff --git a/Classes/Domain/Page.php b/Classes/Domain/Page.php new file mode 100644 index 0000000..47b0739 --- /dev/null +++ b/Classes/Domain/Page.php @@ -0,0 +1,218 @@ +extractSpecialPropertiesFromComputed($rawRecordOrProperties); + } else { + $this->initFromArray($rawRecordOrProperties); + } + } + + public function has(string $id): bool + { + if (parent::has($id)) { + return true; + } + return array_key_exists($id, $this->specialProperties); + } + + public function get(string $id): mixed + { + if (parent::has($id)) { + return parent::get($id); + } + if (array_key_exists($id, $this->specialProperties)) { + return $this->specialProperties[$id]; + } + throw new RecordPropertyNotFoundException('Record property "' . $id . '" is not available.', 1725892141); + } + + public function getLanguageId(): int + { + if ($this->systemProperties?->getLanguage() !== null) { + return $this->systemProperties->getLanguage()->getLanguageId(); + } + return (int)($this->specialProperties['_language'] ?? $this->properties['language_tag'] ?? 0); + } + + public function getPageId(): int + { + if ($this->systemProperties?->getLanguage() !== null) { + $translationParent = $this->systemProperties->getLanguage()->getTranslationParent(); + return $translationParent > 0 ? $translationParent : $this->getUid(); + } + $pageId = isset($this->properties['l10n_parent']) && $this->properties['l10n_parent'] > 0 ? $this->properties['l10n_parent'] : $this->getUid(); + return (int)$pageId; + } + + public function getTranslationSource(): ?Page + { + return $this->specialProperties['_TRANSLATION_SOURCE'] ?? null; + } + + public function getRequestedLanguage(): ?int + { + return $this->specialProperties['_REQUESTED_OVERLAY_LANGUAGE'] ?? null; + } + + public function toArray(bool $includeSystemProperties = false): array + { + if ($includeSystemProperties) { + // When including system properties, return the full raw record overlaid + // with resolved properties and special properties for backward compatibility. + $result = $this->rawRecord->toArray(); + foreach ($this->properties as $key => $property) { + if ($property instanceof RecordPropertyClosure) { + $this->properties[$key] = $property->instantiate(); + } + $result[$key] = $this->properties[$key]; + } + $result += ['_system' => $this->systemProperties?->toArray() ?? []]; + $result += $this->specialProperties; + return $result; + } + return parent::toArray(); + } + + public function offsetExists(mixed $offset): bool + { + return $this->has((string)$offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->has((string)$offset) ? $this->get((string)$offset) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->properties[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->properties[$offset]); + } + + private function extractSpecialPropertiesFromComputed(RawRecord $rawRecord): void + { + $computedProperties = $rawRecord->getComputedProperties(); + if ($computedProperties->getLocalizedUid() !== null) { + $this->specialProperties['_LOCALIZED_UID'] = $computedProperties->getLocalizedUid(); + } + if ($computedProperties->getRequestedOverlayLanguageId() !== null) { + $this->specialProperties['_REQUESTED_OVERLAY_LANGUAGE'] = $computedProperties->getRequestedOverlayLanguageId(); + } + if ($computedProperties->getTranslationSource() !== null) { + $this->specialProperties['_TRANSLATION_SOURCE'] = $computedProperties->getTranslationSource(); + } + if ($computedProperties->getVersionedUid() !== null) { + $this->specialProperties['_ORIG_uid'] = $computedProperties->getVersionedUid(); + } + // Extract remaining special properties from the raw record + $rawProperties = $rawRecord->toArray(); + foreach ($this->specialPropertyNames as $name) { + if (isset($rawProperties[$name]) && !isset($this->specialProperties[$name])) { + $this->specialProperties[$name] = $rawProperties[$name]; + } + } + } + + private function initFromArray(array $properties): void + { + $regularProperties = []; + $translationSource = null; + $localizedUid = null; + $versionedUid = null; + $requestedOverlayLanguageId = null; + + foreach ($properties as $propertyName => $propertyValue) { + if (in_array($propertyName, $this->specialPropertyNames)) { + if ($propertyName === '_TRANSLATION_SOURCE' && !$propertyValue instanceof Page) { + $translationSource = new Page($propertyValue); + $this->specialProperties[$propertyName] = $translationSource; + } elseif ($propertyName === '_TRANSLATION_SOURCE') { + $translationSource = $propertyValue; + $this->specialProperties[$propertyName] = $propertyValue; + } elseif ($propertyName === '_LOCALIZED_UID') { + $localizedUid = $propertyValue; + $this->specialProperties[$propertyName] = $propertyValue; + } elseif ($propertyName === '_ORIG_uid') { + $versionedUid = $propertyValue; + $this->specialProperties[$propertyName] = $propertyValue; + } elseif ($propertyName === '_REQUESTED_OVERLAY_LANGUAGE') { + $requestedOverlayLanguageId = $propertyValue; + $this->specialProperties[$propertyName] = $propertyValue; + } else { + $this->specialProperties[$propertyName] = $propertyValue; + } + } else { + $regularProperties[$propertyName] = $propertyValue; + } + } + + $computedProperties = new ComputedProperties( + versionedUid: $versionedUid, + localizedUid: $localizedUid, + requestedOverlayLanguageId: $requestedOverlayLanguageId, + translationSource: $translationSource + ); + + $recordType = isset($regularProperties['doktype']) ? (string)$regularProperties['doktype'] : null; + $fullType = $recordType !== null ? 'pages.' . $recordType : 'pages'; + + $rawRecord = new RawRecord( + uid: (int)($regularProperties['uid'] ?? 0), + pid: (int)($regularProperties['pid'] ?? 0), + properties: $regularProperties, + computedProperties: $computedProperties, + fullType: $fullType + ); + + parent::__construct($rawRecord, $regularProperties, null); + } +} diff --git a/Classes/Domain/Persistence/GreedyDatabaseBackend.php b/Classes/Domain/Persistence/GreedyDatabaseBackend.php new file mode 100644 index 0000000..e8b2f3a --- /dev/null +++ b/Classes/Domain/Persistence/GreedyDatabaseBackend.php @@ -0,0 +1,238 @@ +createRuntimeCacheIdentifier($tableName, $uids, $context); + $allRows = $this->getRowsFromCache($cacheIdentifier, $tableName, $uids, $context); + if ($allRows === null) { + $allRows = $this->getRowsFromDatabase($tableName, $uids, $context); + $this->setCache($cacheIdentifier, $tableName, $context, $allRows); + } + return $this->handleOverlays( + // Only use the records from the given UIDs + array_filter($allRows, static fn(array $row) => in_array((int)$row['uid'], $uids, true)), + $tableName, + $context + ); + } + + protected function setCache(string $cacheIdentifier, string $tableName, Context $context, array $allRows): void + { + $resultUids = array_map(fn(array $row): int => (int)$row['uid'], $allRows); + foreach ($resultUids as $resultUid) { + $resultUidCacheIdentifier = $this->createRuntimeCacheIdentifier($tableName, [$resultUid], $context, 'pointer'); + // Set pointer to actual rows cache entry. + $this->runtimeCache->set($resultUidCacheIdentifier, $cacheIdentifier); + } + $this->runtimeCache->set($cacheIdentifier, $allRows); + } + + /** + * This method creates cache identifier pointers for each provided uid. + * These uids come from the same pid, so they will have the same result set. + * If at a later point in time one of these uids is requested again, + * the pointer will be used to retrieve the actual cache entry of the db row. + * Without this mechanism, the runtime cache would be filled quickly with the + * same database rows over and over again. + * + * Example: Having 1000 records on the same pid with each having a relation to + * a file reference. When RecordFactory is used to resolve all of these 1000 records + * at a time, each of these 1000 relations would produce a cache entry with 1000 + * file reference database rows (1000*1000 = 1.000.000 database rows). + * + * Instead, only 1 cache entry is created with 1000 database rows and in addition + * 1000 lightweight cache identifier pointers, pointing to the actual value of the + * cache identifier. + */ + protected function getRowsFromCache(string $cacheIdentifier, string $tableName, array $uids, Context $context): ?array + { + if ($this->runtimeCache->has($cacheIdentifier)) { + return $this->runtimeCache->get($cacheIdentifier); + } + foreach ($uids as $uid) { + $cacheIdentifierPointer = $this->createRuntimeCacheIdentifier($tableName, [$uid], $context, 'pointer'); + if ($this->runtimeCache->has($cacheIdentifierPointer)) { + $cacheIdentifier = $this->runtimeCache->get($cacheIdentifierPointer); + if ($this->runtimeCache->has($cacheIdentifier)) { + return $this->runtimeCache->get($cacheIdentifier); + } + } + } + return null; + } + + protected function getRowsFromDatabase(string $tableName, array $uids, Context $context): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder + ->select('*') + ->from($tableName); + // @todo: consider a context-based query restriction container here! + // @todo: we should not remove the restrictions but rather add them based on the given Context + /** @var DefaultRestrictionContainer $restrictions */ + $restrictions = $queryBuilder->getRestrictions(); + $visibilityAspect = $context->getAspect('visibility'); + if ($visibilityAspect->includeHidden()) { + $restrictions->removeByType(HiddenRestriction::class); + } + if ($visibilityAspect->includeDeletedRecords()) { + $restrictions->removeByType(DeletedRestriction::class); + } + if ($visibilityAspect->includeScheduledRecords()) { + $restrictions->removeByType(StartTimeRestriction::class); + $restrictions->removeByType(EndTimeRestriction::class); + } + if ($context->hasAspect('frontend.user')) { + $groupIds = $context->getAspect('frontend.user')->getGroupIds(); + $restrictions->add(GeneralUtility::makeInstance(FrontendGroupRestriction::class, $groupIds)); + } + // Workspace Restriction is never added + $restrictions->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $context->getAspect('workspace')->getId())); + + // Subselect is doing: give me the PID of the given UIDs + // So we can get a greedy query for all records of these PIDs + $queryBuilderForSubselect = $queryBuilder->getConnection()->createQueryBuilder(); + // Subselect must use same restrictions as main query + $queryBuilderForSubselect->setRestrictions($restrictions); + $queryBuilderForSubselect + ->select('pid') + ->from($tableName) + ->where( + $queryBuilderForSubselect->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY) + ) + ); + + // Inject the subselect in the WHERE part of the main query + $queryBuilder->where( + $queryBuilder->expr()->comparison( + $queryBuilder->quoteIdentifier('pid'), + 'IN', + '(' . $queryBuilderForSubselect->getSQL() . ')' + ) + ); + + $allRows = $queryBuilder->executeQuery()->fetchAllAssociative(); + return $allRows; + } + + protected function handleOverlays(array $rows, string $dbTable, Context $context): array + { + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context); + $finalRows = []; + foreach ($rows as $row) { + $pageRepository->versionOL($dbTable, $row); + if ($row === false) { + continue; + } + $row = $pageRepository->getLanguageOverlay($dbTable, $row); + if ($row === null) { + continue; + } + // Check fe_group, hidden, starttime, endtime etc. + if (!$this->recordAccessVoter->accessGranted($dbTable, $row, $context)) { + continue; + } + $finalRows[] = $row; + } + return $finalRows; + } + + protected function createRuntimeCacheIdentifier(string $tableName, array $uids, Context $context, string $suffix = ''): string + { + sort($uids); + $cacheIdentifier = $tableName . '-' . md5(implode('_', $uids)) . '-'; + $cacheIdentifier .= $context->getAspect('workspace')->getId() . '-'; + /** @var LanguageAspect $languageAspect */ + $languageAspect = $context->getAspect('language'); + $cacheIdentifier .= $languageAspect->getId() . '-' . $languageAspect->getOverlayType() . '-' . md5(implode('_', $languageAspect->getFallbackChain())) . '-'; + /** @var VisibilityAspect $visibilityAspect */ + $visibilityAspect = $context->getAspect('visibility'); + $cacheIdentifier .= $visibilityAspect->includeHiddenPages() ? '1' : '0'; + $cacheIdentifier .= $visibilityAspect->includeHiddenContent() ? '1' : '0'; + $cacheIdentifier .= $visibilityAspect->includeScheduledRecords() ? '1' : '0'; + $cacheIdentifier .= $visibilityAspect->includeDeletedRecords() ? '1' : '0'; + + /** @var DateTimeAspect $dateAspect */ + $dateAspect = $context->getAspect('date'); + $cacheIdentifier .= '-' . $dateAspect->get('timestamp'); + + /** @var UserAspect $userAspect */ + $userAspect = $context->getAspect('frontend.user'); + $groupIds = $userAspect->getGroupIds(); + $cacheIdentifier .= '-' . implode('_', $groupIds); + $cacheIdentifier .= '-' . $suffix; + + return 'greedy_database_backend_' . hash('xxh3', $cacheIdentifier); + } +} diff --git a/Classes/Domain/Persistence/RecordIdentityMap.php b/Classes/Domain/Persistence/RecordIdentityMap.php new file mode 100644 index 0000000..d837401 --- /dev/null +++ b/Classes/Domain/Persistence/RecordIdentityMap.php @@ -0,0 +1,79 @@ + + */ + protected array $recordMap = []; + + public function add(RecordInterface $record): void + { + $this->recordMap[$record->getMainType()][$record->getUid()] = $record; + } + + public function has(RecordInterface $record): bool + { + return isset($this->recordMap[$record->getMainType()][$record->getUid()]); + } + + public function findByIdentifier(string $mainType, int $identifier): RecordInterface + { + if ($this->hasIdentifier($mainType, $identifier)) { + return $this->recordMap[$mainType][$identifier]; + } + throw new \InvalidArgumentException( + 'Record with type "' . $mainType . '" and identifier "' . $identifier . '" not found in the Identity Map.', + 1720730774 + ); + } + + public function hasIdentifier(string $mainType, int $identifier): bool + { + return isset($this->recordMap[$mainType][$identifier]); + } +} diff --git a/Classes/Domain/RawRecord.php b/Classes/Domain/RawRecord.php new file mode 100644 index 0000000..27d71ed --- /dev/null +++ b/Classes/Domain/RawRecord.php @@ -0,0 +1,119 @@ +normalizeTypeParts($this->fullType); + $this->mainType = $parts[0] ?? ''; + $this->recordType = $parts[1] ?? null; + } + + public function getUid(): int + { + return $this->uid; + } + + public function getPid(): int + { + return $this->pid; + } + + public function getFullType(): string + { + return $this->fullType; + } + + /** + * @return non-empty-string|null + */ + public function getRecordType(): ?string + { + return $this->recordType; + } + + public function getMainType(): string + { + return $this->mainType; + } + + public function toArray(bool $includeComputedProperties = false): array + { + $properties = ['uid' => $this->uid, 'pid' => $this->pid] + $this->properties; + if ($includeComputedProperties) { + $properties += ['_computed' => $this->computedProperties->toArray()]; + } + return $properties; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->properties); + } + + public function get(string $id): mixed + { + if (!$this->has($id)) { + throw new RecordPropertyNotFoundException( + 'Record property "' . $id . '" is not available.', + 1725892140 + ); + } + + return $this->properties[$id] ?? null; + } + + public function getComputedProperties(): ComputedProperties + { + return $this->computedProperties; + } + + public function getRawRecord(): RawRecord + { + return $this; + } + + /** + * @return array{0?: string, 1?: string} + */ + protected function normalizeTypeParts(string $type): array + { + return array_filter( + array_map(trim(...), explode('.', $type, 2)), + static fn(string $part): bool => $part !== '' + ); + } +} diff --git a/Classes/Domain/Record.php b/Classes/Domain/Record.php new file mode 100644 index 0000000..a3fdba5 --- /dev/null +++ b/Classes/Domain/Record.php @@ -0,0 +1,175 @@ +rawRecord->getUid(); + } + + public function getPid(): int + { + return $this->rawRecord->getPid(); + } + + public function getFullType(): string + { + return $this->rawRecord->getFullType(); + } + + public function getRecordType(): ?string + { + return $this->rawRecord->getRecordType(); + } + + public function getMainType(): string + { + return $this->rawRecord->getMainType(); + } + + public function toArray(bool $includeSystemProperties = false): array + { + $properties = ['uid' => $this->getUid(), 'pid' => $this->getPid()]; + foreach ($this->properties as $key => $property) { + if ($property instanceof RecordPropertyClosure) { + $this->properties[$key] = $property->instantiate(); + } + } + $properties += $this->properties; + if ($includeSystemProperties) { + $properties += ['_system' => $this->systemProperties?->toArray() ?? []]; + } + return $properties; + } + + public function has(string $id): bool + { + if (array_key_exists($id, $this->properties)) { + return true; + } + + if (in_array($id, ['uid', 'pid'], true)) { + // Enable access of uid and pid via array access + return true; + } + + if ($this->getRecordType() === null && $this->rawRecord->has($id)) { + // Only fall back to the raw record in case no record type is defined. + // This allows to properly check for only record type specific fields. + return true; + } + + return false; + } + + public function get(string $id): mixed + { + if (array_key_exists($id, $this->properties)) { + $property = $this->properties[$id]; + if ($property instanceof RecordPropertyClosure) { + try { + $property = $property->instantiate(); + } catch (\Exception $e) { + // Consumers of this method can rely on catching ContainerExceptionInterface + throw new RecordPropertyException( + 'An exception occurred while instantiating record property "' . $id . '"', + 1725892139, + $e + ); + } + $this->properties[$id] = $property; + } + return $property; + } + + if (in_array($id, ['uid', 'pid'], true)) { + // Enable access of uid and pid via array access + return $this->rawRecord->get($id); + } + + if ($this->getRecordType() === null && $this->rawRecord->has($id)) { + // Only fall back to the raw record in case no record type is defined. + // This ensures that only record type specific fields are being returned. + return $this->rawRecord->get($id); + } + + throw new RecordPropertyNotFoundException('Record property "' . $id . '" is not available.', 1725892138); + } + + public function getVersionInfo(): ?VersionInfo + { + return $this->systemProperties?->getVersion(); + } + + public function getLanguageInfo(): ?LanguageInfo + { + return $this->systemProperties?->getLanguage(); + } + + public function getLanguageId(): ?int + { + return $this->systemProperties?->getLanguage()?->getLanguageId(); + } + + public function getSystemProperties(): ?SystemProperties + { + return $this->systemProperties; + } + + public function getComputedProperties(): ComputedProperties + { + return $this->rawRecord->getComputedProperties(); + } + + public function getRawRecord(): RawRecord + { + return $this->rawRecord; + } + + public function getOverlaidUid(): int + { + $computedProperties = $this->getComputedProperties(); + if ($computedProperties->getLocalizedUid() !== null) { + return $computedProperties->getLocalizedUid(); + } + if ($computedProperties->getVersionedUid() !== null) { + return $computedProperties->getVersionedUid(); + } + return $this->getUid(); + } +} diff --git a/Classes/Domain/Record/ComputedProperties.php b/Classes/Domain/Record/ComputedProperties.php new file mode 100644 index 0000000..4fe2ef9 --- /dev/null +++ b/Classes/Domain/Record/ComputedProperties.php @@ -0,0 +1,65 @@ +versionedUid; + } + + public function getLocalizedUid(): ?int + { + return $this->localizedUid; + } + + public function getRequestedOverlayLanguageId(): ?int + { + return $this->requestedOverlayLanguageId; + } + + public function getTranslationSource(): ?Page + { + return $this->translationSource; + } + + public function toArray(): array + { + return [ + 'versionedUid' => $this->versionedUid, + 'localizedUid' => $this->localizedUid, + 'requestedOverlayLanguageId' => $this->requestedOverlayLanguageId, + 'translationSource' => $this->translationSource, + ]; + } +} diff --git a/Classes/Domain/Record/LanguageInfo.php b/Classes/Domain/Record/LanguageInfo.php new file mode 100644 index 0000000..4a0a2b7 --- /dev/null +++ b/Classes/Domain/Record/LanguageInfo.php @@ -0,0 +1,47 @@ +languageId; + } + + public function getTranslationParent(): ?int + { + return $this->translationParent; + } + + public function getTranslationSource(): ?int + { + return $this->translationSource; + } +} diff --git a/Classes/Domain/Record/SystemProperties.php b/Classes/Domain/Record/SystemProperties.php new file mode 100644 index 0000000..676be96 --- /dev/null +++ b/Classes/Domain/Record/SystemProperties.php @@ -0,0 +1,119 @@ +languageInfo; + } + + public function getVersion(): ?VersionInfo + { + return $this->versionInfo; + } + + public function isDeleted(): ?bool + { + return $this->isDeleted; + } + + public function isDisabled(): ?bool + { + return $this->isDisabled; + } + + public function isLockedForEditing(): ?bool + { + return $this->isLockedForEditing; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function getLastUpdatedAt(): ?\DateTimeInterface + { + return $this->lastUpdatedAt; + } + + public function getPublishAt(): ?\DateTimeInterface + { + return $this->publishAt; + } + + public function getPublishUntil(): ?\DateTimeInterface + { + return $this->publishUntil; + } + + public function getUserGroupRestriction(): ?array + { + return $this->userGroupRestriction; + } + + public function getSorting(): ?int + { + return $this->sorting; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function toArray(): array + { + return [ + 'language' => $this->languageInfo, + 'version' => $this->versionInfo, + 'isDeleted' => $this->isDeleted, + 'isDisabled' => $this->isDisabled, + 'isLockedForEditing' => $this->isLockedForEditing, + 'createdAt' => $this->createdAt, + 'lastUpdatedAt' => $this->lastUpdatedAt, + 'publishAt' => $this->publishAt, + 'publishUntil' => $this->publishUntil, + 'userGroupRestriction' => $this->userGroupRestriction, + 'sorting' => $this->sorting, + 'description' => $this->description, + ]; + } +} diff --git a/Classes/Domain/Record/VersionInfo.php b/Classes/Domain/Record/VersionInfo.php new file mode 100644 index 0000000..211b6c9 --- /dev/null +++ b/Classes/Domain/Record/VersionInfo.php @@ -0,0 +1,55 @@ +workspaceId; + } + + public function getLiveId(): int + { + return $this->liveId; + } + + public function getState(): VersionState + { + return $this->state; + } + + public function getStageId(): int + { + return $this->stage; + } +} diff --git a/Classes/Domain/RecordFactory.php b/Classes/Domain/RecordFactory.php new file mode 100644 index 0000000..43f10ed --- /dev/null +++ b/Classes/Domain/RecordFactory.php @@ -0,0 +1,350 @@ +createRawRecord($table, $record); + $schema = $this->schemaFactory->get($table); + $subSchema = null; + if ($schema->hasSubSchema($rawRecord->getRecordType() ?? '')) { + $subSchema = $schema->getSubSchema($rawRecord->getRecordType()); + } + + // Only use the fields that are defined in the schema + $properties = []; + foreach ($record as $fieldName => $fieldValue) { + if ($subSchema) { + if (!$subSchema->hasField($fieldName)) { + continue; + } + $schema = $subSchema; + } elseif (!$schema->hasField($fieldName)) { + continue; + } + $properties[$fieldName] = $fieldValue; + } + return $this->createRecord($rawRecord, $properties, $schema); + } + + /** + * Create a "resolved" record. Resolved means that the fields will have + * their values resolved and extended. A typical use-case is resolving + * of related records, or using \DateTimeImmutable objects for datetime fields. + */ + public function createResolvedRecordFromDatabaseRow(string $table, array $record, ?Context $context = null, ?RecordIdentityMap $recordIdentityMap = null): RecordInterface + { + $context = $context ?? GeneralUtility::makeInstance(Context::class); + /** @var RecordIdentityMap $recordIdentityMap */ + $recordIdentityMap = $recordIdentityMap ?? GeneralUtility::makeInstance(RecordIdentityMap::class); + if ($recordIdentityMap->hasIdentifier($table, (int)($record['uid'] ?? 0))) { + return $recordIdentityMap->findByIdentifier($table, (int)$record['uid']); + } + $properties = []; + $rawRecord = $this->createRawRecord($table, $record); + $schema = $this->schemaFactory->get($table); + $subSchema = null; + if ($schema->hasSubSchema($rawRecord->getRecordType() ?? '')) { + $subSchema = $schema->getSubSchema($rawRecord->getRecordType()); + } + + // Only use the fields that are defined in the schema + foreach ($record as $fieldName => $fieldValue) { + if ($subSchema) { + if (!$subSchema->hasField($fieldName)) { + continue; + } + $schema = $subSchema; + } elseif (!$schema->hasField($fieldName)) { + continue; + } + $fieldInformation = $schema->getField($fieldName); + $properties[$fieldName] = $this->fieldTransformer->transformField( + $fieldInformation, + $rawRecord, + $context, + $recordIdentityMap + ); + } + $resolvedRecord = $this->createRecord($rawRecord, $properties, $schema, $context, $recordIdentityMap); + $recordIdentityMap->add($resolvedRecord); + return $resolvedRecord; + } + + /** + * Creates a raw record object from a table and a record array. + */ + public function createRawRecord(string $table, array $record): RawRecord + { + if (!$this->schemaFactory->has($table)) { + throw new \InvalidArgumentException( + 'Unable to create Record from non-TCA table "' . $table . '".', + 1715266929 + ); + } + $schema = $this->schemaFactory->get($table); + $fullType = $table; + if ($schema->supportsSubSchema() && ($subSchemaTypeInformation = $schema->getSubSchemaTypeInformation())->isPointerToForeignFieldInForeignSchema() === false) { + // @todo Limitation to local SubSchemaDivisorField, because the actual record type is defined in foreign record. + $subSchemaDivisorFieldName = $subSchemaTypeInformation->getFieldName(); + if (!isset($record[$subSchemaDivisorFieldName])) { + throw new \InvalidArgumentException( + 'Missing typeField "' . $subSchemaDivisorFieldName . '" in record of requested table "' . $table . '".', + 1715267513, + ); + } + $recordType = (string)$record[$subSchemaDivisorFieldName]; + $fullType .= '.' . $recordType; + } + $computedProperties = $this->extractComputedProperties($record); + // @todo We might want to throw an exception in case uid / pid are not defined. + return new RawRecord((int)($record['uid'] ?? 0), (int)($record['pid'] ?? 0), $record, $computedProperties, $fullType); + } + + /** + * Quick helper function in order to avoid duplicate code. + */ + protected function createRecord(RawRecord $rawRecord, array $properties, TcaSchema $schema, ?Context $context = null, ?RecordIdentityMap $recordIdentityMap = null): RecordInterface + { + $context = $context ?? GeneralUtility::makeInstance(Context::class); + $mainSchema = $this->schemaFactory->get($rawRecord->getMainType()); + $recordIdentityMap = $recordIdentityMap ?? GeneralUtility::makeInstance(RecordIdentityMap::class); + [$properties, $systemProperties] = $this->extractSystemInformation( + $mainSchema, + $rawRecord, + $properties, + ); + $event = new RecordCreationEvent($properties, $rawRecord, $systemProperties, $context, $recordIdentityMap, $schema); + $this->eventDispatcher->dispatch($event); + if ($event->isPropagationStopped()) { + return $event->getRecord(); + } + if ($event->getRawRecord()->getMainType() === 'pages') { + return new Page($event->getRawRecord(), $event->getProperties(), $event->getSystemProperties()); + } + return new Record($event->getRawRecord(), $event->getProperties(), $event->getSystemProperties()); + } + + protected function extractComputedProperties(array &$record): ComputedProperties + { + $computed = $record['_computed'] ?? null; + if (is_array($computed)) { + $computedProperties = new ComputedProperties( + $computed['versionedUid'] ?? null, + $computed['localizedUid'] ?? null, + $computed['requestedOverlayLanguageId'] ?? null, + $computed['translationSource'] ?? null + ); + unset($record['_computed']); + return $computedProperties; + } + $computedProperties = new ComputedProperties( + $record['_ORIG_uid'] ?? null, + $record['_LOCALIZED_UID'] ?? null, + $record['_REQUESTED_OVERLAY_LANGUAGE'] ?? null, + $record['_TRANSLATION_SOURCE'] ?? null + ); + unset( + $record['_ORIG_uid'], + $record['_LOCALIZED_UID'], + $record['_REQUESTED_OVERLAY_LANGUAGE'], + $record['_TRANSLATION_SOURCE'] + ); + return $computedProperties; + } + + protected function extractSystemInformation(TcaSchema $schema, RawRecord $rawRecord, array $properties): array + { + // Language information. + $systemProperties = []; + if ($schema->isLanguageAware()) { + /** @var LanguageAwareSchemaCapability $languageCapability */ + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + $translationSourceField = $languageCapability->getTranslationSourceField()?->getName() ?? ''; + try { + $systemProperties['language'] = new LanguageInfo( + (int)$rawRecord->get($languageField), + (int)$rawRecord->get($transOrigPointerField), + $rawRecord->has($translationSourceField) ? (int)$rawRecord->get($translationSourceField) : null, + ); + } catch (RecordPropertyNotFoundException $e) { + throw new IncompleteRecordException( + 'Table "' . $schema->getName() . '" is defined as language aware but the record misses necessary fields: ' . $e->getMessage(), + 1726046917 + ); + } + unset($properties[$languageField]); + unset($properties[$transOrigPointerField]); + if ($translationSourceField !== '') { + unset($properties[$translationSourceField]); + } + if ($languageCapability->hasDiffSourceField()) { + unset($properties[$languageCapability->getDiffSourceField()?->getName()]); + } + unset($properties['l10n_state']); + } + + // Workspaces. + if ($schema->isWorkspaceAware()) { + try { + $systemProperties['version'] = new VersionInfo( + (int)$rawRecord->get('t3ver_wsid'), + (int)$rawRecord->get('t3ver_oid'), + VersionState::tryFrom((int)$rawRecord->get('t3ver_state')), + (int)$rawRecord->get('t3ver_stage'), + ); + } catch (RecordPropertyNotFoundException $e) { + throw new IncompleteRecordException( + 'Table "' . $schema->getName() . '" is defined as workspace aware but the record misses necessary fields: ' . $e->getMessage(), + 1726046918 + ); + } + unset( + $properties['t3ver_wsid'], + $properties['t3ver_oid'], + $properties['t3ver_state'], + $properties['t3ver_stage'] + ); + } + + // Date-related fields + foreach (TcaSchemaCapability::getSystemCapabilities() as $capability) { + if (!$schema->hasCapability($capability)) { + continue; + } + /** @var SystemInternalFieldCapability|FieldCapability $capabilityInstance */ + $capabilityInstance = $schema->getCapability($capability); + $fieldName = $capabilityInstance->getFieldName(); + if (!$rawRecord->has($fieldName)) { + throw new IncompleteRecordException( + 'Table "' . $schema->getName() . '" has capability "' . $capability->name . '" set but the record misses the corresponding field "' . $fieldName . '"', + 1726046919 + ); + } + switch ($capability) { + case TcaSchemaCapability::CreatedAt: + $systemProperties['createdAt'] = DateTimeFactory::createFromTimestamp($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::UpdatedAt: + $systemProperties['lastUpdatedAt'] = DateTimeFactory::createFromTimestamp($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::RestrictionStartTime: + $systemProperties['publishAt'] = DateTimeFactory::createFromTimestamp($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::RestrictionEndTime: + $systemProperties['publishUntil'] = DateTimeFactory::createFromTimestamp($rawRecord->get($fieldName)); + break; + + case TcaSchemaCapability::SoftDelete: + $systemProperties['isDeleted'] = (bool)($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::EditLock: + $systemProperties['isLockedForEditing'] = (bool)($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::RestrictionDisabledField: + $systemProperties['isDisabled'] = (bool)($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::InternalDescription: + $systemProperties['description'] = $rawRecord->get($fieldName); + break; + case TcaSchemaCapability::SortByField: + $systemProperties['sorting'] = (int)($rawRecord->get($fieldName)); + break; + case TcaSchemaCapability::RestrictionUserGroup: + $systemProperties['userGroupRestriction'] = GeneralUtility::intExplode( + ',', + $rawRecord->get($fieldName), + true + ); + break; + } + unset($properties[$fieldName]); + } + + $systemProperties = new SystemProperties( + $systemProperties['language'] ?? null, + $systemProperties['version'] ?? null, + $systemProperties['isDeleted'] ?? null, + $systemProperties['isDisabled'] ?? null, + $systemProperties['isLockedForEditing'] ?? null, + $systemProperties['createdAt'] ?? null, + $systemProperties['lastUpdatedAt'] ?? null, + $systemProperties['publishAt'] ?? null, + $systemProperties['publishUntil'] ?? null, + $systemProperties['userGroupRestriction'] ?? null, + $systemProperties['sorting'] ?? null, + $systemProperties['description'] ?? null, + ); + return [$properties, $systemProperties]; + } +} diff --git a/Classes/Domain/RecordInterface.php b/Classes/Domain/RecordInterface.php new file mode 100644 index 0000000..a7267a9 --- /dev/null +++ b/Classes/Domain/RecordInterface.php @@ -0,0 +1,53 @@ +instantiator)(); + } +} diff --git a/Classes/Domain/Repository/PageRepository.php b/Classes/Domain/Repository/PageRepository.php new file mode 100644 index 0000000..3bb2584 --- /dev/null +++ b/Classes/Domain/Repository/PageRepository.php @@ -0,0 +1,2188 @@ + 0, versioning preview of other record versions is allowed. This should only + * be set if the page is not cached and truly previewed by a backend user! + */ +readonly class PageRepository +{ + /** + * Named constants for "magic numbers" of the field doktype + */ + public const DOKTYPE_DEFAULT = 1; + public const DOKTYPE_LINK = 3; + public const DOKTYPE_SHORTCUT = 4; + public const DOKTYPE_BE_USER_SECTION = 6; + public const DOKTYPE_MOUNTPOINT = 7; + public const DOKTYPE_SPACER = 199; + public const DOKTYPE_SYSFOLDER = 254; + + /** + * Named constants for "magic numbers" of the field shortcut_mode + */ + public const SHORTCUT_MODE_NONE = 0; + public const SHORTCUT_MODE_FIRST_SUBPAGE = 1; + public const SHORTCUT_MODE_PARENT_PAGE = 3; + + /** + * Computed properties that are added to database rows. + */ + protected const COMPUTED_PROPERTY_NAMES = [ + '_LOCALIZED_UID', + '_REQUESTED_OVERLAY_LANGUAGE', + '_MP_PARAM', + '_ORIG_uid', + '_ORIG_pid', + '_SHORTCUT_ORIGINAL_PAGE_UID', + ]; + + protected Context $context; + protected TcaSchemaFactory $tcaSchemaFactory; + protected PageTypeLinkResolver $pageTypeLinkResolver; + protected LoggerInterface $logger; + + /** + * PageRepository constructor to set the base context, this will effectively remove the necessity for + * setting properties from the outside. + */ + public function __construct(?Context $context = null, ?TcaSchemaFactory $tcaSchemaFactory = null, ?PageTypeLinkResolver $pageTypeLinkResolver = null, ?LoggerInterface $logger = null) + { + $this->context = $context ?? GeneralUtility::makeInstance(Context::class); + $this->tcaSchemaFactory = $tcaSchemaFactory ?? GeneralUtility::makeInstance(TcaSchemaFactory::class); + $this->pageTypeLinkResolver = $pageTypeLinkResolver ?? GeneralUtility::makeInstance(PageTypeLinkResolver::class); + $this->logger = $logger ?? GeneralUtility::makeInstance(LogManager::class)->getLogger(static::class); + } + + /** + * Builds the where clause for page records taking + * deleted/hidden/starttime/endtime/t3ver_state into account. + * + * The result is kept in the runtime cache, keyed by the relevant context aspects, so + * it is built at most once per distinct workspace/user/date/visibility state. + */ + protected function getEnableFieldsConstraint(): string + { + $workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id'); + // As PageRepository may be used multiple times during the frontend request, and may + // actually be used before the usergroups have been resolved, self::getDefaultConstraints() + // and the Event ModifyDefaultConstraintsForDatabaseQueryEvent need to be reconsidered when the usergroup state changes. + // When something changes in the context, a second runtime cache entry is built. + // However, the PageRepository is generally in use for generating e.g. hundreds of links, so they would all use + // the same cache identifier. + $userAspect = $this->context->getAspect('frontend.user'); + $frontendUserIdentifier = 'user_' . (int)$userAspect->get('id') . '_groups_' . md5(implode(',', $userAspect->getGroupIds())); + + // We need to respect the date aspect as we might have subrequests with a different time (e.g. backend preview links) + $dateTimeIdentifier = $this->context->getAspect('date')->get('timestamp'); + + // If TRUE, the hidden-field is ignored. Normally this should be FALSE. Is used for previewing. + $includeHiddenPages = $this->context->getPropertyFromAspect('visibility', 'includeHiddenPages'); + $includeScheduledRecords = $this->context->getPropertyFromAspect('visibility', 'includeScheduledRecords'); + + $cache = $this->getRuntimeCache(); + $cacheIdentifier = implode( + '', + [ + 'PageRepository_hidDelWhere', + ($includeHiddenPages ? '_ShowHidden' : ''), + ($includeScheduledRecords ? '_Scheduled' : ''), + '_', + (string)$workspaceId, + '_', + $frontendUserIdentifier, + '_', + (string)$dateTimeIdentifier, + ] + ); + $cacheEntry = $cache->get($cacheIdentifier); + if ($cacheEntry) { + return $cacheEntry; + } + $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages') + ->expr(); + if ($workspaceId > 0) { + // For version previewing, make sure that enable-fields are not + // de-selecting hidden pages - we need versionOL() to unset them only + // if the overlay record instructs us to. + // Restrict to live and current workspaces + $enableFieldsConstraint = (string)$expressionBuilder->and( + $expressionBuilder->eq('pages.deleted', 0), + $expressionBuilder->or( + $expressionBuilder->eq('pages.t3ver_wsid', 0), + $expressionBuilder->eq('pages.t3ver_wsid', $workspaceId) + ) + ); + } else { + // add starttime / endtime, and check for hidden/deleted + // Filter out new/deleted place-holder pages in case we are NOT in a + // versioning preview (that means we are online!) + $constraints = $this->getDefaultConstraints('pages', ['fe_group' => true]); + $enableFieldsConstraint = $constraints === [] ? '' : (string)$expressionBuilder->and(...$constraints); + } + $cache->set($cacheIdentifier, $enableFieldsConstraint); + return $enableFieldsConstraint; + } + + /************************** + * + * Selecting page records + * + **************************/ + + /** + * Loads the full page record for the given page ID. + * + * The page record is either served from a first-level cache or loaded from the + * database. If no page can be found, an empty array is returned. + * + * Language overlay and versioning overlay are applied. Mount Point + * handling is not done, an overlaid Mount Point is not replaced. + * + * The result is constrained by the enable-field and fe_group access clauses, + * which are computed lazily on first use. + * + * By default, the usergroup access check is enabled. Use the second method argument + * to disable the usergroup access check. + * + * The given Page ID can be preprocessed by registering an Event. + * + * @param int $uid The page id to look up + * @param bool $disableGroupAccessCheck set to true to disable group access check + * @return array The resulting page record with overlays or empty array + * @throws \UnexpectedValueException + * @see PageRepository::getPage_noCheck() + */ + public function getPage(int $uid, bool $disableGroupAccessCheck = false): array + { + // Dispatch Event to manipulate the page uid for special overlay handling + $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch( + new BeforePageIsRetrievedEvent($uid, $disableGroupAccessCheck, $this->context) + ); + if ($event->hasPage()) { + // In case an event listener resolved the page on its own, directly return it + return $event->getPage()->toArray(true); + } + $disableGroupAccessCheck = $event->isGroupAccessCheckSkipped(); + $uid = $event->getPageId(); + $enableFieldsConstraint = $this->getEnableFieldsConstraint(); + $whereGroupAccess = $disableGroupAccessCheck ? '' : $this->getMultipleGroupsWhereClause('pages.fe_group', 'pages'); + $cacheIdentifier = 'PageRepository_getPage_' . md5( + implode( + '-', + [ + $uid, + $whereGroupAccess, + $enableFieldsConstraint, + $this->context->getPropertyFromAspect('language', 'id', 0), + ] + ) + ); + $cache = $this->getRuntimeCache(); + $cacheEntry = $cache->get($cacheIdentifier); + if (is_array($cacheEntry)) { + return $cacheEntry; + } + $result = []; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter((int)$uid, Connection::PARAM_INT)), + $enableFieldsConstraint + ); + + if (!$disableGroupAccessCheck) { + $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($whereGroupAccess)); + } + + $row = $queryBuilder->executeQuery()->fetchAssociative(); + if ($row) { + $this->versionOL('pages', $row); + if (is_array($row)) { + $result = $this->getLanguageOverlay('pages', $row); + } + } + + $cache->set($cacheIdentifier, $result); + return $result; + } + + /** + * Return the $row for the page with uid = $uid WITHOUT checking the + * enable-field constraints (start- and endtime or hidden). Only "deleted" is checked! + * + * @param int $uid The page id to look up + * @return array The page row with overlaid localized fields. Empty array if no page. + * @see getPage() + */ + public function getPage_noCheck(int $uid): array + { + $cache = $this->getRuntimeCache(); + $cacheIdentifier = 'PageRepository_getPage_noCheck_' . $uid . '_' . $this->context->getPropertyFromAspect('language', 'id', 0) . '_' . (int)$this->context->getPropertyFromAspect('workspace', 'id'); + $cacheEntry = $cache->get($cacheIdentifier); + if ($cacheEntry !== false) { + return $cacheEntry; + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $row = $queryBuilder->select('*') + ->from('pages') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + $result = []; + if ($row) { + $this->versionOL('pages', $row); + if (is_array($row)) { + $result = $this->getLanguageOverlay('pages', $row); + } + } + $cache->set($cacheIdentifier, $result); + return $result; + } + + /** + * Master helper method to overlay a record to a language. + * + * Be aware that for pages the languageId is taken, and for all other records the contentId of the Aspect is used. + * + * @param string $table the name of the table, should be a TCA table with localization enabled + * @param array $originalRow the current (full-fletched) record. + * @param LanguageAspect|null $languageAspect an alternative language aspect if needed (optional) + * @return array|null NULL If overlays were activated but no overlay was found and LanguageAspect was NOT set to MIXED + */ + public function getLanguageOverlay(string $table, array $originalRow, ?LanguageAspect $languageAspect = null): ?array + { + $schema = $this->tcaSchemaFactory->get($table); + // table is not localizable, so return directly + if (!$schema->isLanguageAware()) { + return $originalRow; + } + + try { + /** @var LanguageAspect $languageAspect */ + $languageAspect = $languageAspect ?? $this->context->getAspect('language'); + } catch (AspectNotFoundException $e) { + // no overlays + return $originalRow; + } + + $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + + $event = $eventDispatcher->dispatch(new BeforeRecordLanguageOverlayEvent($table, $originalRow, $languageAspect)); + $languageAspect = $event->getLanguageAspect(); + $originalRow = $event->getRecord(); + + $attempted = false; + $localizedRecord = null; + if ($languageAspect->doOverlays()) { + $attempted = true; + // Mixed = if nothing is available in the selected language, try the fallbacks + // Fallbacks work as follows (happens in the actual methods): + // 1. We have a default language record and then start doing overlays (= the basis for fallbacks) + // 2. Check if the actual requested language version is available in the DB (language=3 = canadian-french) + // 3. If not, we check the next language version in the chain (e.g. language=2 = french) and so forth until we find a record + if ($languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_MIXED) { + if ($table === 'pages') { + $localizedRecord = $this->getPageOverlay( + $originalRow, + $languageAspect + ); + if (empty($localizedRecord)) { + $localizedRecord = $originalRow; + } + } else { + // Loop through each (fallback) language and see if there is a record + $localizedRecord = $this->getRecordOverlay( + $table, + $originalRow, + $languageAspect + ); + if ($localizedRecord === null) { + // If nothing was found, we set the localized record to the originalRow to simulate + // that the default language is "kept" (we want fallback to default language). + // Note: Most installations might have "type=fallback" set but do not set the default language + // as fallback. In the future - once we want to get rid of the magic "default language", + // this needs to behave different, and the "pageNotFound" special handling within fallbacks should be removed + // plus: we need to check explicitly on in_array(0, $languageAspect->getFallbackChain()) + // However, getPageOverlay() a few lines above also returns the "default language page" as well. + $localizedRecord = $originalRow; + } + } + } else { + // The option to hide records if they were not explicitly selected, was chosen (OVERLAYS_ON/WITH_FLOATING) + // in the language configuration. So, here no changes are done. + if ($table === 'pages') { + $localizedRecord = $this->getPageOverlay($originalRow, $languageAspect); + } else { + $localizedRecord = $this->getRecordOverlay($table, $originalRow, $languageAspect); + } + } + } else { + // Free mode. + // For "pages": Pages are usually retrieved by fetching the page record in the default language. + // However, the originalRow should still fetch the page in a specific language (with fallbacks). + // The method "getPageOverlay" should still be called in order to get the page record in the correct language. + if ($table === 'pages' && $languageAspect->getId() > 0) { + $attempted = true; + $localizedRecord = $this->getPageOverlay($originalRow, $languageAspect); + } elseif ($table === 'sys_file_metadata') { + $attempted = true; + $localizedRecord = $this->getRecordOverlay($table, $originalRow, $languageAspect); + } + } + + $event = new AfterRecordLanguageOverlayEvent($table, $originalRow, $localizedRecord, $attempted, $languageAspect); + $event = $eventDispatcher->dispatch($event); + + // Return localized record or the original row, if no overlays were done + return $event->overlayingWasAttempted() ? $event->getLocalizedRecord() : $originalRow; + } + + /** + * Returns the relevant page overlay record fields + * + * @param int|array $pageInput If $pageInput is an integer, it's the pid of the pageOverlay record and thus the page overlay record is returned. If $pageInput is an array, it's a page-record and based on this page record the language record is found and OVERLAID before the page record is returned. + * @param int|LanguageAspect|null $language language UID if you want to set an alternative value to the given context which is default. Should be >=0 + * @throws \UnexpectedValueException + * @return array Page row which is overlaid with language_overlay record (or the overlay record alone) + */ + public function getPageOverlay(int|array $pageInput, LanguageAspect|int|null $language = null): array + { + $rows = $this->getPagesOverlay([$pageInput], $language); + // Always an array in return + return $rows[0] ?? []; + } + + /** + * Returns the relevant page overlay record fields + * + * @param array $pagesInput Array of integers or array of arrays. If each value is an integer, it's the pids of the pageOverlay records and thus the page overlay records are returned. If each value is an array, it's page-records and based on this page records the language records are found and OVERLAID before the page records are returned. + * @param int|LanguageAspect|null $language Language UID if you want to set an alternative value to the given context aspect which is default. Should be >=0 + * @throws \UnexpectedValueException + * @return array Page rows which are overlaid with language_overlay record. + * If the input was an array of integers, missing records are not + * included. If the input were page rows, untranslated pages + * are returned. + */ + public function getPagesOverlay(array $pagesInput, int|LanguageAspect|null $language = null): array + { + if (empty($pagesInput)) { + return []; + } + if (is_int($language)) { + $languageAspect = new LanguageAspect($language, $language); + } else { + $languageAspect = $language ?? $this->context->getAspect('language'); + } + + $overlays = []; + // If language UID is different from zero, do overlay: + if ($languageAspect->getId() > 0) { + $pageIds = []; + foreach ($pagesInput as $origPage) { + if (is_array($origPage)) { + // Was the whole record + $pageIds[] = (int)($origPage['uid'] ?? 0); + } else { + // Was the id + $pageIds[] = (int)$origPage; + } + } + + $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch( + new BeforePageLanguageOverlayEvent($pagesInput, $pageIds, $languageAspect) + ); + $pagesInput = $event->getPageInput(); + $overlays = $this->getPageOverlaysForLanguage($event->getPageIds(), $event->getLanguageAspect()); + } + + // Create output: + $pagesOutput = []; + foreach ($pagesInput as $key => $origPage) { + if (is_array($origPage)) { + $pagesOutput[$key] = $origPage; + if (isset($origPage['uid'], $overlays[$origPage['uid']])) { + // Overwrite the original field with the overlay + foreach ($overlays[$origPage['uid']] as $fieldName => $fieldValue) { + if ($fieldName !== 'uid' && $fieldName !== 'pid') { + $pagesOutput[$key][$fieldName] = $fieldValue; + } + } + $pagesOutput[$key]['_TRANSLATION_SOURCE'] = new Page($origPage); + } + } elseif (isset($overlays[$origPage])) { + $pagesOutput[$key] = $overlays[$origPage]; + } + } + return $pagesOutput; + } + + /** + * Checks whether the passed (translated or default language) page is accessible with the given language settings. + * + * @param array $page the page translation record or the page in the default language + * @return bool true if the given page translation record is suited for the given language ID + * @internal + */ + public function isPageSuitableForLanguage(array $page, LanguageAspect $languageAspect): bool + { + $languageUid = $languageAspect->getId(); + // Checks if the default language version can be shown + // Block page is set, if l18n_cfg allows plus: 1) Either default language or 2) another language but NO overlay record set for page! + $pageTranslationVisibility = new PageTranslationVisibility((int)($page['l18n_cfg'] ?? 0)); + if ((!$languageUid || !isset($page['_LOCALIZED_UID'])) + && $pageTranslationVisibility->shouldBeHiddenInDefaultLanguage() + ) { + return false; + } + if ($languageUid > 0 && $pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists()) { + if (!isset($page['_LOCALIZED_UID']) || (int)($page['sys_language_uid'] ?? 0) !== $languageUid) { + return false; + } + } elseif ($languageUid > 0) { + $languageUids = array_merge([$languageUid], $this->getLanguageFallbackChain($languageAspect)); + return in_array((int)($page['sys_language_uid'] ?? 0), $languageUids, true); + } + return true; + } + + /** + * Returns the cleaned fallback chain from the current language aspect, if there is one. + * + * @return int[] + */ + protected function getLanguageFallbackChain(?LanguageAspect $languageAspect): array + { + $languageAspect = $languageAspect ?? $this->context->getAspect('language'); + return array_filter($languageAspect->getFallbackChain(), MathUtility::canBeInterpretedAsInteger(...)); + } + + /** + * Returns the first match of overlays for pages in the passed languages. + * + * NOTE regarding the query restrictions: + * Currently the visibility aspect within the FrontendRestrictionContainer will allow + * page translation records to be selected as they are child-records of a page. + * However, you may argue that the visibility flag should determine this. + * But that's not how it's done right now. + * + * @param LanguageAspect $languageAspect Used for the fallback chain + */ + protected function getPageOverlaysForLanguage(array $pageUids, LanguageAspect $languageAspect): array + { + if ($pageUids === []) { + return []; + } + + $schema = $this->tcaSchemaFactory->get('pages'); + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + + $languageUids = array_merge([$languageAspect->getId()], $this->getLanguageFallbackChain($languageAspect)); + // Remove default language ("0") + $languageUids = array_filter($languageUids); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class, $this->context)); + // Because "fe_group" is an exclude field, so it is synced between overlays, the group restriction is removed for language overlays of pages + $queryBuilder->getRestrictions()->removeByType(FrontendGroupRestriction::class); + + $candidates = []; + $maxChunk = PlatformInformation::getMaxBindParameters($queryBuilder->getConnection()->getDatabasePlatform()); + foreach (array_chunk($pageUids, (int)floor($maxChunk / 3)) as $pageUidsChunk) { + $query = $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->in( + $languageField, + $queryBuilder->createNamedParameter($languageUids, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->in( + $transOrigPointerField, + $queryBuilder->createNamedParameter($pageUidsChunk, Connection::PARAM_INT_ARRAY) + ) + ); + + // This has cache hits for the current page and for menus (little performance gain). + $cacheIdentifier = 'PageRepository_getPageOverlaysForLanguage_' + . hash('xxh3', $query->getSQL() . json_encode($query->getParameters())); + $rows = $this->getRuntimeCache()->get($cacheIdentifier); + if (!is_array($rows)) { + $rows = $query->executeQuery()->fetchAllAssociative(); + $this->getRuntimeCache()->set($cacheIdentifier, $rows); + } + + foreach ($rows as $row) { + $pageId = $row[$transOrigPointerField]; + $priority = array_search($row[$languageField], $languageUids); + $candidates[$pageId][$priority] = $row; + } + } + + $overlays = []; + foreach ($pageUids as $pageId) { + $languageRows = $candidates[$pageId] ?? []; + ksort($languageRows, SORT_NATURAL); + foreach ($languageRows as $row) { + // Found a result for the current language id + $this->versionOL('pages', $row); + if (is_array($row)) { + $row['_LOCALIZED_UID'] = (int)$row['uid']; + $row['_REQUESTED_OVERLAY_LANGUAGE'] = $languageUids[0]; + // Unset vital fields that are NOT allowed to be overlaid: + unset($row['uid'], $row['pid']); + $overlays[$pageId] = $row; + + // Language fallback found, stop querying further languages + break; + } + } + } + + return $overlays; + } + + /** + * Creates language-overlay for records in general (where translation is found + * in records from the same DB table). + * + * Since TYPO3 v13, this also works for a LanguageAspect with OVERLAYS_OFF (= free mode). Why? + * Mainly because there are cases where we ALWAYS have a default language (sys_file_metadata), + * and the check for the overlays is done outside of this method. That's why this method should + * never be called directly (it is protected since v13 for this reason). + * + * The record receives a language overlay and a workspace overlay of the language overlay. + * + * @param string $table Table name + * @param array $row Record to overlay. Must contain uid, pid and language field. + * @return array|null Returns the input record, possibly overlaid with a translation. But if overlays are not mixed ("fallback to default language") then it will return NULL if no translation is found. + */ + protected function getRecordOverlay(string $table, array $row, LanguageAspect $languageAspect): ?array + { + if (!$this->tcaSchemaFactory->has($table)) { + return $row; + } + + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isLanguageAware()) { + return $row; + } + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + + // Only try overlays for tables with localization support + if (empty($languageField)) { + return $row; + } + if (empty($transOrigPointerField)) { + return $row; + } + $incomingLanguageId = (int)($row[$languageField] ?? 0); + + // Return record for ALL languages untouched + if ($incomingLanguageId === -1) { + return $row; + } + + $recordUid = (int)($row['uid'] ?? 0); + $incomingRecordPid = (int)($row['pid'] ?? 0); + + // @todo: Fix call stack to prevent this situation in the first place + if ($recordUid <= 0) { + return $row; + } + if ($incomingRecordPid <= 0 && !in_array($schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->getRootLevelType(), [true, 1, -1], true)) { + return $row; + } + // When default language is displayed, we never want to return a record carrying + // another language. + if ($languageAspect->getContentId() === 0 && $incomingLanguageId > 0) { + return null; + } + + // Will try to overlay a record only if the contentId value is larger than zero, + // contentId is used for regular records, whereas getId() is used for "pages" only. + if ($languageAspect->getContentId() === 0) { + return $row; + } + // Must be default language, otherwise no overlaying + if ($incomingLanguageId === 0) { + // Select overlay record: + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($table); + $queryBuilder->setRestrictions( + GeneralUtility::makeInstance(FrontendRestrictionContainer::class, $this->context) + ); + if ((int)$this->context->getPropertyFromAspect('workspace', 'id') > 0) { + // If not in live workspace, remove query based "enable fields" checks, it will be done in versionOL() + // @see functional workspace test createLocalizedNotHiddenWorkspaceContentHiddenInLive() + $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class); + $queryBuilder->getRestrictions()->removeByType(StartTimeRestriction::class); + $queryBuilder->getRestrictions()->removeByType(EndTimeRestriction::class); + // We keep the WorkspaceRestriction in this case, because we need to get the LIVE record + // of the language record before doing the version overlay of the language again. WorkspaceRestriction + // does this for us, PLUS we need to ensure to get a possible LIVE record first (that's why + // the "orderBy" query is there, so the LIVE record is found first), as there might only be a + // versioned record (e.g. new version) or both (common for modifying, moving etc). + if ($schema->isWorkspaceAware()) { + $queryBuilder->orderBy('t3ver_wsid', 'ASC'); + } + } + + $pid = $incomingRecordPid; + $languageUids = array_merge([$languageAspect->getContentId()], $this->getLanguageFallbackChain($languageAspect)); + // When inside a workspace, the already versioned $row of the default language is coming in + // For moved versioned records, the PID MIGHT be different. However, the idea of this function is + // to get the language overlay of the LIVE default record, and afterward get the versioned record + // the found (live) language record again, see the versionOL() call a few lines below. + // This means, we need to modify the $pid value for moved records, as they might be on a different + // page and use the PID of the LIVE version. + if (isset($row['_ORIG_pid']) && $schema->isWorkspaceAware() && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER) { + $pid = $row['_ORIG_pid']; + } + $overlayRows = $queryBuilder->select('*') + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->in( + $languageField, + $queryBuilder->createNamedParameter($languageUids, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->eq( + $transOrigPointerField, + $queryBuilder->createNamedParameter($recordUid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + + $olrow = false; + if ($overlayRows !== []) { + // Note: The exact order of the $languageUid traversal is important + foreach ($languageUids as $languageId) { + foreach ($overlayRows as $overlayRow) { + if ((int)$overlayRow[$languageField] === $languageId) { + // Found the requested language, stop searching + $olrow = $overlayRow; + break 2; + } + } + } + } + + $this->versionOL($table, $olrow); + // Merge record content by traversing all fields: + if (is_array($olrow)) { + if (isset($olrow['_ORIG_uid'])) { + $row['_ORIG_uid'] = $olrow['_ORIG_uid']; + } + if (isset($olrow['_ORIG_pid'])) { + $row['_ORIG_pid'] = $olrow['_ORIG_pid']; + } + foreach ($row as $fN => $fV) { + if ($fN !== 'uid' && $fN !== 'pid' && array_key_exists($fN, $olrow)) { + $row[$fN] = $olrow[$fN]; + } elseif ($fN === 'uid') { + $row['_LOCALIZED_UID'] = (int)$olrow['uid']; + // will be overridden again outside of this method if there is a multi-level chain + $row['_REQUESTED_OVERLAY_LANGUAGE'] = $languageAspect->getContentId(); + } + } + return $row; + } + // No overlay found. + // Unset, if non-translated records should be hidden. ONLY done if the source + // record really is default language and not [All] in which case it is allowed. + if (in_array($languageAspect->getOverlayType(), [LanguageAspect::OVERLAYS_ON_WITH_FLOATING, LanguageAspect::OVERLAYS_ON], true)) { + return null; + } + } elseif ($languageAspect->getContentId() !== $incomingLanguageId) { + return null; + } + return $row; + } + + /************************************************ + * + * Page related: Menu, Domain record, Root line + * + ************************************************/ + + /** + * Returns an array with page rows for subpages of a certain page ID. This is used for menus in the frontend. + * If there are mount points in overlay mode the _MP_PARAM field is set to the correct MPvar. + * + * If the $pageId being input does in itself require MPvars to define a correct + * rootline these must be handled externally to this function. + * + * @param int|int[] $pageId The page id (or array of page ids) for which to fetch subpages (PID) + * @param string $fields Fields to select, `*` is the default - If a custom list is set, make sure the list + * contains the `uid` field. It's mandatory for further processing of the result row. + * @param string $sortField The field to sort by. Default is "sorting + * @param string $additionalWhereClause Optional additional where clauses. Like "AND title like '%some text%'" for instance. + * @param bool $checkShortcuts Check if shortcuts exist, checks by default + * @return array Array with key/value pairs; keys are page-uid numbers. values are the corresponding page records (with overlaid localized fields, if any) + * @see getPageShortcut() + * @see \TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject::makeMenu() + */ + public function getMenu($pageId, $fields = '*', $sortField = 'sorting', $additionalWhereClause = '', $checkShortcuts = true, bool $disableGroupAccessCheck = false) + { + // @todo: Restricting $fields to a list like 'uid, title' here, leads to issues from methods like + // getSubpagesForPages() which access keys like 'doktype'. This is odd, select field list + // should be handled better here, probably at least containing fields that are used in the + // sub methods. In the end, it might be easier to drop argument $fields altogether and + // always select * ? + return $this->getSubpagesForPages((array)$pageId, $fields, $sortField, $additionalWhereClause, $checkShortcuts, true, $disableGroupAccessCheck); + } + + /** + * Returns an array with page-rows for pages with uid in $pageIds. + * + * This is used for menus. If there are mount points in overlay mode + * the _MP_PARAM field is set to the correct MPvar. + * + * @param int[] $pageIds Array of page ids to fetch + * @param string $fields Fields to select, `*` is the default - If a custom list is set, make sure the list + * contains the `uid` field. It's mandatory for further processing of the result row. + * @param string $sortField The field to sort by. Default is "sorting" + * @param string $additionalWhereClause Optional additional where clauses. Like "AND title like '%some text%'" for instance. + * @param bool $checkShortcuts Check if shortcuts exist, checks by default + * @return array Array with key/value pairs; keys are page-uid numbers. values are the corresponding page records (with overlaid localized fields, if any) + */ + public function getMenuForPages(array $pageIds, $fields = '*', $sortField = 'sorting', $additionalWhereClause = '', $checkShortcuts = true, bool $disableGroupAccessCheck = false) + { + return $this->getSubpagesForPages($pageIds, $fields, $sortField, $additionalWhereClause, $checkShortcuts, false, $disableGroupAccessCheck); + } + + /** + * Loads page records either by PIDs or by UIDs. + * + * By default the subpages of the given page IDs are loaded (as the method name suggests). If $parentPages is set + * to FALSE, the page records for the given page IDs are loaded directly. + * + * Concerning the rationale, please see these two other methods: + * + * @see PageRepository::getMenu() + * @see PageRepository::getMenuForPages() + * + * Version and language overlay are applied to the loaded records. + * + * If a record is a mount point in overlay mode, the overlaying page record is returned in place of the + * record. The record is enriched by the field _MP_PARAM containing the mount point mapping for the mount + * point. + * + * The query can be customized by setting fields, sorting and additional WHERE clauses. If additional WHERE + * clauses are given, the clause must start with an operator, i.e: "AND title like '%some text%'". + * + * The keys of the returned page records are the page UIDs. + * + * CAUTION: In case of an overlaid mount point, it is the original UID. + * + * @param int[] $pageIds PIDs or UIDs to load records for + * @param string $fields Fields to select, `*` is the default - If a custom list is set, make sure the list + * contains the `uid` field. It's mandatory for further processing of the result row. + * @param string $sortField the field to sort by + * @param string $additionalWhereClause optional additional WHERE clause + * @param bool $checkShortcuts whether to check if shortcuts exist + * @param bool $parentPages Switch to load pages (false) or child pages (true). + * @return array page records + * + * @see self::getPageShortcut() + * @see \TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject::makeMenu() + */ + protected function getSubpagesForPages( + array $pageIds, + string $fields = '*', + string $sortField = 'sorting', + string $additionalWhereClause = '', + bool $checkShortcuts = true, + bool $parentPages = true, + bool $disableGroupAccessCheck = false + ): array { + $relationField = $parentPages ? 'pid' : 'uid'; + + $schema = $this->tcaSchemaFactory->get('pages'); + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->context->getPropertyFromAspect('workspace', 'id'))); + + $res = $queryBuilder->select(...GeneralUtility::trimExplode(',', $fields, true)) + ->from('pages') + ->where( + $queryBuilder->expr()->in( + $relationField, + $queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->eq( + $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(), + $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) + ), + $this->getEnableFieldsConstraint(), + QueryHelper::stripLogicalOperatorPrefix($disableGroupAccessCheck ? '' : $this->getMultipleGroupsWhereClause('pages.fe_group', 'pages')), + QueryHelper::stripLogicalOperatorPrefix($additionalWhereClause) + ); + + if (!empty($sortField)) { + $orderBy = QueryHelper::parseOrderBy($sortField); + foreach ($orderBy as $order) { + $res->addOrderBy($order[0], $order[1] ?? 'ASC'); + } + } + $result = $res->executeQuery(); + + $pages = []; + while ($page = $result->fetchAssociative()) { + $originalUid = $page['uid']; + + // Versioning Preview Overlay + $this->versionOL('pages', $page, true); + // Skip if page got disabled due to version overlay (might be delete placeholder) + if (empty($page)) { + continue; + } + + // Add a mount point parameter if needed + $page = $this->addMountPointParameterToPage((array)$page, $disableGroupAccessCheck); + + // If shortcut, look up if the target exists and is currently visible + if ($checkShortcuts) { + $page = $this->checkValidShortcutOfPage($page, $additionalWhereClause, $disableGroupAccessCheck); + $page = $this->checkValidLinkOfPage($page, $disableGroupAccessCheck); + } + + // If the page still is there, we add it to the output + if (!empty($page)) { + $pages[$originalUid] = $page; + } + } + + // Finally load language overlays + return $this->getPagesOverlay($pages); + } + + /** + * Replaces the given page record with mounted page if required + * + * If the given page record is a mount point in overlay mode, the page + * record is replaced by the record of the overlaying page. The overlay + * record is enriched by setting the mount point mapping into the field + * _MP_PARAM as string for example '23-14'. + * + * In all other cases the given page record is returned as is. + * + * @todo Find a better name. The current doesn't hit the point. + * + * @param array $page The page record to handle. + * @param bool $disableGroupAccessCheck set to true to disable group access check + * @return array The given page record or it's replacement. + */ + protected function addMountPointParameterToPage(array $page, bool $disableGroupAccessCheck = false): array + { + if (empty($page)) { + return []; + } + + // $page MUST have "uid", "pid", "doktype", "mount_pid", "mount_pid_ol" fields in it + $mountPointInfo = $this->getMountPointInfo($page['uid'], $page); + + // There is a valid mount point in overlay mode. + if (is_array($mountPointInfo) && $mountPointInfo['overlay']) { + // Using "getPage" is OK since we need the check for enableFields AND for type 2 + // of mount pids we DO require a doktype < 200! + $mountPointPage = $this->getPage((int)$mountPointInfo['mount_pid'], $disableGroupAccessCheck); + + if (!empty($mountPointPage)) { + $page = $mountPointPage; + $page['_MP_PARAM'] = $mountPointInfo['MPvar']; + } else { + $page = []; + } + } + return $page; + } + + /** + * If shortcut, look up if the target exists and is currently visible + * + * @param array $page The page to check + * @param string $additionalWhereClause Optional additional where clauses. Like "AND title like '%some text%'" for instance. + * @param bool $disableGroupAccessCheck set to true to disable group access check + */ + protected function checkValidShortcutOfPage(array $page, string $additionalWhereClause, bool $disableGroupAccessCheck = false): array + { + if (empty($page)) { + return []; + } + + $dokType = (int)($page['doktype'] ?? 0); + $shortcutMode = (int)($page['shortcut_mode'] ?? 0); + + if ($dokType === self::DOKTYPE_SHORTCUT && (($shortcut = (int)($page['shortcut'] ?? 0)) || $shortcutMode)) { + if ($shortcutMode === self::SHORTCUT_MODE_NONE && $shortcut > 0) { + // No shortcut_mode set, so target is directly set in $page['shortcut'] + $searchField = 'uid'; + $searchUid = $shortcut; + } elseif ($shortcutMode === self::SHORTCUT_MODE_PARENT_PAGE) { + // Shortcut to parent page + $searchField = 'uid'; + $searchUid = $page['pid']; + } elseif ($shortcutMode === self::SHORTCUT_MODE_FIRST_SUBPAGE || $shortcutMode) { + // Check subpages if first subpage or an invalid shortcut mode + $searchField = 'pid'; + // If a shortcut mode is set and no valid page is given to select subpages + // from use the actual page. + $searchUid = $shortcut ?: $page['uid']; + } else { + return []; + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll(); + $count = $queryBuilder->count('uid') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + $searchField, + $queryBuilder->createNamedParameter($searchUid, Connection::PARAM_INT) + ), + $this->getEnableFieldsConstraint(), + QueryHelper::stripLogicalOperatorPrefix($disableGroupAccessCheck ? '' : $this->getMultipleGroupsWhereClause('pages.fe_group', 'pages')), + QueryHelper::stripLogicalOperatorPrefix($additionalWhereClause) + ) + ->executeQuery() + ->fetchOne(); + + if (!$count) { + $page = []; + } + } elseif ($dokType === self::DOKTYPE_SHORTCUT) { + // Neither shortcut target nor mode is set. Remove the page from the menu. + $page = []; + } + return $page; + } + + /** + * If shortcut, look up if the target exists and is currently visible + * + * @param array $page The page to check + */ + protected function checkValidLinkOfPage(array $page, bool $disableGroupAccessCheck): array + { + if (empty($page)) { + return []; + } + $dokType = (int)($page['doktype'] ?? 0); + if ($dokType !== self::DOKTYPE_LINK) { + return $page; + } + $link = (string)($page['link'] ?? ''); + if ($link === '') { + // No link set, remove from menu. + return []; + } + $chain = []; + try { + $resolvedPage = $this->resolveReferencedPageRecord($page, $chain, 20, $disableGroupAccessCheck); + } catch (ShortcutTargetPageNotFoundException|LinkedPageNotResolvableException|PageReferenceResolvingReachedIterationLimitException|CircularPageReferenceChainException $exception) { + // Linked page is not linkable, remove it from the menu. + return []; + } + return $page; + } + + /** + * Get page shortcut; Finds the records pointed to by $shortcutFieldValue + * + * @param string $shortcutFieldValue The value of the "shortcut" field from the pages record + * @param int $shortcutMode The shortcut mode: 1 will select first subpage, 2 a random subpage, 3 the parent page; default is the page pointed to by $SC + * @param int $thisUid The current page UID of the page which is a shortcut + * @param int $iteration Safety feature which makes sure that the function is calling itself recursively max 20 times (since this function can find shortcuts to other shortcuts to other shortcuts...) + * @param array $pageLog An array filled with previous page uids tested by the function - new page uids are evaluated against this to avoid going in circles. + * @param bool $disableGroupCheck If true, the group check is disabled when fetching the target page (needed e.g. for menu generation) + * + * @throws \RuntimeException + * @throws ShortcutTargetPageNotFoundException + * @return mixed Returns the page record of the page that the shortcut pointed to. + * @internal + */ + protected function getPageShortcut($shortcutFieldValue, $shortcutMode, $thisUid, $iteration = 20, $pageLog = [], $disableGroupCheck = false) + { + // @todo: Simplify! page['shortcut'] is maxitems 1 and not a comma separated list of values! + $shortcutId = GeneralUtility::intExplode(',', $shortcutFieldValue)[0]; + // Find $page record depending on shortcut mode: + if ($shortcutMode === self::SHORTCUT_MODE_PARENT_PAGE) { + $parent = $this->getPage(($shortcutId ?: (int)$thisUid), $disableGroupCheck); + if ($parent === [] || ($referencedPageRecord = $this->getPage((int)($parent['pid'] ?? 0), $disableGroupCheck)) === []) { + $message = 'This page (ID ' . $thisUid . ') is of type "Shortcut" and configured to redirect to its parent page. However, the parent page is not accessible.'; + throw new ShortcutTargetPageNotFoundException($message, 1301648358); + } + } elseif ($shortcutMode === self::DOKTYPE_SHORTCUT || ($shortcutMode !== self::SHORTCUT_MODE_FIRST_SUBPAGE && $shortcutId)) { + $referencedPageRecord = $this->getPage($shortcutId, $disableGroupCheck); + if ($referencedPageRecord === []) { + $message = 'This page (ID ' . $thisUid . ') is of type "Shortcut" and configured to redirect to a page, which is not accessible (ID ' . $shortcutId . ').'; + throw new ShortcutTargetPageNotFoundException($message, 1301648404); + } + } else { + $excludedDoktypes = [ + self::DOKTYPE_SPACER, + self::DOKTYPE_SYSFOLDER, + self::DOKTYPE_BE_USER_SECTION, + ]; + $pageArray = $this->getMenu($shortcutId ?: (int)$thisUid, '*', 'sorting', 'AND pages.doktype NOT IN (' . implode(', ', $excludedDoktypes) . ')', true, $disableGroupCheck); + $referencedPageRecord = reset($pageArray); + if ($referencedPageRecord === false || $referencedPageRecord === null) { + $message = 'This page (ID ' . $thisUid . ') is of type "Shortcut" and configured to redirect to a subpage. However, this page has no accessible subpages.'; + throw new ShortcutTargetPageNotFoundException($message, 1301648328); + } + } + // Check if shortcut page was a shortcut itself, if so look up recursively + return $this->resolveReferencedPageRecord($referencedPageRecord, $pageLog, $iteration, $disableGroupCheck); + } + + /** + * Check if page is a shortcut, then resolve the target page directly. + * This is a better method than "getPageShortcut()" and should be used instead, as this automatically checks for $page records + * and returns the shortcut pages directly. + * + * This method also provides a runtime cache around resolving the shortcut resolving, in order to speed up link generation + * to the same shortcut page. + * + * @throws CircularPageReferenceChainException + * @throws ShortcutTargetPageNotFoundException + * @throws PageReferenceResolvingReachedIterationLimitException + * @throws LinkedPageNotResolvableException + */ + public function resolveShortcutPage(array $page, bool $disableGroupAccessCheck = false): array + { + if ((int)($page['doktype'] ?? 0) !== self::DOKTYPE_SHORTCUT) { + return $page; + } + $shortcutMode = (int)($page['shortcut_mode'] ?? self::SHORTCUT_MODE_NONE); + $shortcutTarget = (string)($page['shortcut'] ?? ''); + + $cacheIdentifier = 'shortcuts_resolved_' . ($disableGroupAccessCheck ? '1' : '0') . '_' . $page['uid'] . '_' . $this->context->getPropertyFromAspect('language', 'id', 0) . '_' . $page['language_tag']; + // Only use the runtime cache if we do not support the random subpages functionality + $cachedResult = $this->getRuntimeCache()->get($cacheIdentifier); + if (is_array($cachedResult)) { + return $cachedResult; + } + $shortcut = $this->getPageShortcut( + $shortcutTarget, + $shortcutMode, + $page['uid'], + 20, + [], + $disableGroupAccessCheck + ); + if (!empty($shortcut)) { + $shortcutOriginalPageUid = (int)$page['uid']; + $page = $shortcut; + $page['_SHORTCUT_ORIGINAL_PAGE_UID'] = $shortcutOriginalPageUid; + } + + $this->getRuntimeCache()->set($cacheIdentifier, $page); + + return $page; + } + + /** + * If a page is a link whose destination is another page, the other pages + * record is returned. The result is cached. Circles of pages linking to + * each other are stopped after 20 iterations and an exception is thrown + * in that case. + * + * If the link destination is of any other type, the original page record + * is returned. + * + * @throws CircularPageReferenceChainException + * @throws ShortcutTargetPageNotFoundException + * @throws PageReferenceResolvingReachedIterationLimitException + * @throws LinkedPageNotResolvableException + */ + public function resolveLinkPage(array $pageRecord, bool $disableGroupAccessCheck = false): array + { + if ((int)($pageRecord['doktype'] ?? 0) !== self::DOKTYPE_LINK) { + return $pageRecord; + } + $linkParts = $this->pageTypeLinkResolver->resolveTypolinkParts($pageRecord); + if ($linkParts['type'] !== 'page') { + return $pageRecord; + } + + $cacheIdentifier = 'links_resolved_' . ($disableGroupAccessCheck ? '1' : '0') . '_' . $pageRecord['uid'] . '_' . $this->context->getPropertyFromAspect('language', 'id', 0) . '_' . $pageRecord['language_tag']; + // Only use the runtime cache if we do not support the random subpages functionality + $cachedResult = $this->getRuntimeCache()->get($cacheIdentifier); + if (is_array($cachedResult)) { + return $cachedResult; + } + $resolvedPageRecord = $this->getPageLink( + $linkParts, + $pageRecord, + 20, + [], + $disableGroupAccessCheck + ); + + if (!empty($resolvedPageRecord)) { + $shortcutOriginalPageUid = (int)$pageRecord['uid']; + $pageRecord = $resolvedPageRecord; + $pageRecord['_SHORTCUT_ORIGINAL_PAGE_UID'] = $shortcutOriginalPageUid; + } + + $this->getRuntimeCache()->set($cacheIdentifier, $pageRecord); + + return $resolvedPageRecord; + } + + /** + * @internal to be used only within {@see self::resolveLinkPage()} and {@see self::resolveReferencedPageRecord()}. + * + * @throws CircularPageReferenceChainException + * @throws ShortcutTargetPageNotFoundException + * @throws PageReferenceResolvingReachedIterationLimitException + * @throws LinkedPageNotResolvableException + */ + protected function getPageLink(array $linkParts, array $pageRecord, int $iteration = 20, array $pageLog = [], bool $disableGroupCheck = false): array + { + if (($linkParts['pageuid'] ?? '') === 'current') { + // TypoLink field allows to omit a page uid to create links to current page and only adding + // query parameters and is respected here by returning the record for the current record. + return $pageRecord; + } + $referencedPageId = (int)($linkParts['pageuid'] ?? 0); + $referencedPageRecord = $this->getPage($referencedPageId, $disableGroupCheck); + if (empty($referencedPageRecord)) { + $message = sprintf('This page (ID %d) is of type "Link" and configured to redirect to a page, which is not accessible (ID %d).', $pageRecord['uid'], $referencedPageId); + throw new LinkedPageNotResolvableException($message, 1761831322); + } + return $this->resolveReferencedPageRecord($referencedPageRecord, $pageLog, $iteration, $disableGroupCheck); + } + + /** + * Returns a MountPoint array for the specified page + * + * Does a recursive search if the mounted page should be a mount page + * itself. + * + * Note: + * + * Recursive mount points are not supported by all parts of the core. + * The usage is discouraged. They may be removed from this method. + * + * @see https://decisions.typo3.org/t/supporting-or-prohibiting-recursive-mount-points/165/3 + * + * An array will be returned if mount pages are enabled, the correct + * doktype (7) is set for page and there IS a mount_pid with a valid + * record. + * + * The optional page record must contain at least uid, pid, doktype, + * mount_pid, mount_pid_ol. If it is not supplied it will be looked up by + * the system at additional costs for the lookup. + * + * Returns FALSE if no mount point was found, "-1" if there should have been + * one, but no connection to it, otherwise an array with information + * about mount pid and modes. + * + * @param int $pageId Page id to do the lookup for. + * @param array|bool $pageRec Optional page record for the given page. + * @param array $prevMountPids Internal register to prevent lookup cycles. + * @param int $firstPageUid The first page id. + * @return mixed Mount point array or failure flags (-1, false). + * @see \TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject + */ + public function getMountPointInfo($pageId, $pageRec = false, $prevMountPids = [], $firstPageUid = 0) + { + if (!$GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids']) { + return false; + } + $cacheIdentifier = 'PageRepository_getMountPointInfo_' . $pageId; + $cache = $this->getRuntimeCache(); + if ($cache->has($cacheIdentifier)) { + return $cache->get($cacheIdentifier); + } + $result = false; + // Get pageRec if not supplied: + if (!is_array($pageRec)) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $pageRec = $queryBuilder->select('uid', 'pid', 'doktype', 'mount_pid', 'mount_pid_ol', 't3ver_state', 'l10n_parent') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + + // Only look for version overlay if page record is not supplied; This assumes + // that the input record is overlaid with preview version, if any! + $this->versionOL('pages', $pageRec); + } + // Set first Page uid: + if (!$firstPageUid) { + $firstPageUid = (int)($pageRec['l10n_parent'] ?? false) ?: $pageRec['uid'] ?? 0; + } + // Look for mount pid value plus other required circumstances: + $mount_pid = (int)($pageRec['mount_pid'] ?? 0); + $doktype = (int)($pageRec['doktype'] ?? 0); + if (is_array($pageRec) && $doktype === self::DOKTYPE_MOUNTPOINT && $mount_pid > 0 && !in_array($mount_pid, $prevMountPids, true)) { + // Get the mount point record (to verify its general existence): + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $mountRec = $queryBuilder->select('uid', 'pid', 'doktype', 'mount_pid', 'mount_pid_ol', 't3ver_state', 'l10n_parent') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($mount_pid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + + $this->versionOL('pages', $mountRec); + if (is_array($mountRec)) { + // Look for recursive mount point: + $prevMountPids[] = $mount_pid; + $recursiveMountPid = $this->getMountPointInfo($mount_pid, $mountRec, $prevMountPids, $firstPageUid); + // Return mount point information: + $result = $recursiveMountPid ?: [ + 'mount_pid' => $mount_pid, + 'overlay' => $pageRec['mount_pid_ol'], + 'MPvar' => $mount_pid . '-' . $firstPageUid, + 'mount_point_rec' => $pageRec, + 'mount_pid_rec' => $mountRec, + ]; + } else { + // Means, there SHOULD have been a mount point, but there was none! + $result = -1; + } + } + $cache->set($cacheIdentifier, $result); + return $result; + } + + /** + * Removes Page UID numbers from the input array which are not available due to QueryRestrictions + * This is also very helpful to add a custom RestrictionContainer to add custom Restrictions such as "bad doktypes" e.g. RECYCLER doktypes + * + * @param int[] $pageIds Array of Page UID numbers to check + * @param QueryRestrictionContainerInterface|null $restrictionContainer + * @return int[] Returns the array of remaining page UID numbers + */ + public function filterAccessiblePageIds(array $pageIds, ?QueryRestrictionContainerInterface $restrictionContainer = null): array + { + if ($pageIds === []) { + return []; + } + $validPageIds = []; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->setRestrictions( + $restrictionContainer ?? GeneralUtility::makeInstance(FrontendRestrictionContainer::class, $this->context) + ); + $statement = $queryBuilder->select('uid') + ->from('pages') + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY) + ) + ) + ->executeQuery(); + while ($row = $statement->fetchAssociative()) { + $validPageIds[] = (int)$row['uid']; + } + return $validPageIds; + } + /******************************** + * + * Selecting records in general + * + ********************************/ + + /** + * Checks if a record exists and is accessible. + * The row is returned if everything's OK. + * + * @param string $table The table name to search + * @param int $uid The uid to look up in $table + * @param bool $checkPage If set, it's also required that the page on which the record resides is accessible + * @return array|null Returns array (the record) if OK, otherwise null + */ + public function checkRecord(string $table, int $uid, bool $checkPage = false): ?array + { + if (!$this->tcaSchemaFactory->has($table)) { + return null; + } + if ($uid <= 0) { + return null; + } + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class, $this->context)); + $row = $queryBuilder->select('*') + ->from($table) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + if ($row) { + $this->versionOL($table, $row); + if (is_array($row)) { + if ($checkPage) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class, $this->context)); + $numRows = (int)$queryBuilder->count('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($row['pid'], Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchOne(); + if ($numRows > 0) { + return $row; + } + return null; + } + return $row; + } + } + return null; + } + + /** + * Returns record no matter what - except if record is deleted + * + * @param string $table The table name to search + * @param int $uid The uid to look up in $table + * @param array $fields Fields to select, `*` is the default - If a custom list is set, make sure the list + * contains the `uid` field. It's mandatory for further processing of the result row. + * @return array|null Returns array (the record) if found, otherwise null + * @see getPage_noCheck() + */ + public function getRawRecord(string $table, int $uid, array $fields = ['*']): ?array + { + if ($uid <= 0) { + return null; + } + if (!$this->tcaSchemaFactory->has($table)) { + return null; + } + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $row = $queryBuilder + ->select(...$fields) + ->from($table) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + if ($row) { + $this->versionOL($table, $row); + if (is_array($row)) { + return $row; + } + } + return null; + } + + /******************************** + * + * Standard clauses + * + ********************************/ + + /** + * Returns a DB query constraints (part of the WHERE clause) which will + * filter out records with start/end times or hidden/fe_groups fields set + * to values that should de-select them according to the current time, preview + * settings or user login. + * + * @param string $table Table name + * @param array $enableFieldsToIgnore Array where values (or keys) can be "disabled", "starttime", "endtime", "fe_group" (keys from "enablefields" in TCA) and if set they will make sure that part of the clause is not added. Thus disables the specific part of the clause. For previewing etc. + * @return array Constraints built up by the enableField controls + */ + public function getDefaultConstraints(string $table, array $enableFieldsToIgnore = [], ?string $tableAlias = null): array + { + if (array_is_list($enableFieldsToIgnore)) { + $enableFieldsToIgnore = array_flip($enableFieldsToIgnore); + foreach ($enableFieldsToIgnore as $key => $value) { + $enableFieldsToIgnore[$key] = true; + } + } + if (!$this->tcaSchemaFactory->has($table)) { + return []; + } + $schema = $this->tcaSchemaFactory->get($table); + $tableAlias ??= $table; + + // If set, any hidden-fields in records are ignored, falling back to the default property from the visibility aspect + if (!isset($enableFieldsToIgnore['disabled'])) { + $enableFieldsToIgnore['disabled'] = (bool)$this->context->getPropertyFromAspect('visibility', $table === 'pages' ? 'includeHiddenPages' : 'includeHiddenContent', false); + } + $showScheduledRecords = $this->context->getPropertyFromAspect('visibility', 'includeScheduledRecords', false); + if (!isset($enableFieldsToIgnore['starttime'])) { + $enableFieldsToIgnore['starttime'] = $showScheduledRecords; + } + if (!isset($enableFieldsToIgnore['endtime'])) { + $enableFieldsToIgnore['endtime'] = $showScheduledRecords; + } + + $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($table) + ->expr(); + + $constraints = []; + // Delete field check + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $constraints['deleted'] = $expressionBuilder->eq($tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(), 0); + } + + if ($schema->isWorkspaceAware()) { + // This should work exactly as WorkspaceRestriction and WorkspaceRestriction should be used instead + if ((int)$this->context->getPropertyFromAspect('workspace', 'id') === 0) { + // Filter out placeholder records (new/deleted items) + // in case we are NOT in a version preview (that means we are online!) + $constraints['workspaces'] = $expressionBuilder->and( + $expressionBuilder->lte( + $tableAlias . '.t3ver_state', + VersionState::DEFAULT_STATE->value + ), + $expressionBuilder->eq($tableAlias . '.t3ver_wsid', 0) + ); + } else { + // show only records of live and of the current workspace + // in case we are in a versioning preview + $constraints['workspaces'] = $expressionBuilder->or( + $expressionBuilder->eq($tableAlias . '.t3ver_wsid', 0), + $expressionBuilder->eq($tableAlias . '.t3ver_wsid', (int)$this->context->getPropertyFromAspect('workspace', 'id')) + ); + } + + // Filter out versioned records + if (empty($enableFieldsToIgnore['pid'])) { + // Always filter out versioned records that have an "offline" record + $constraints['pid'] = $expressionBuilder->or( + $expressionBuilder->eq($tableAlias . '.t3ver_oid', 0), + $expressionBuilder->eq($tableAlias . '.t3ver_state', VersionState::MOVE_POINTER->value) + ); + } + } + + // Enable fields + // In case of versioning-preview, enableFields are ignored (checked in versionOL()) + if ((int)$this->context->getPropertyFromAspect('workspace', 'id') === 0 || !$schema->isWorkspaceAware()) { + if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) && !$enableFieldsToIgnore['disabled']) { + $constraints['disabled'] = $expressionBuilder->eq( + $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(), + 0 + ); + } + if ($schema->hasCapability(TcaSchemaCapability::RestrictionStartTime) && !($enableFieldsToIgnore['starttime'] ?? false)) { + $constraints['starttime'] = $expressionBuilder->lte( + $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName(), + $this->context->getPropertyFromAspect('date', 'accessTime', 0) + ); + } + if ($schema->hasCapability(TcaSchemaCapability::RestrictionEndTime) && !($enableFieldsToIgnore['endtime'] ?? false)) { + $field = $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName(); + $constraints['endtime'] = $expressionBuilder->or( + $expressionBuilder->eq($field, 0), + $expressionBuilder->gt( + $field, + $this->context->getPropertyFromAspect('date', 'accessTime', 0) + ) + ); + } + if ($schema->hasCapability(TcaSchemaCapability::RestrictionUserGroup) && !($enableFieldsToIgnore['fe_group'] ?? false)) { + $field = $tableAlias . '.' . $schema->getCapability(TcaSchemaCapability::RestrictionUserGroup)->getFieldName(); + $constraints['fe_group'] = QueryHelper::stripLogicalOperatorPrefix( + $this->getMultipleGroupsWhereClause($field, $table) + ); + } + } + + // Call a PSR-14 Event for additional constraints + $event = new ModifyDefaultConstraintsForDatabaseQueryEvent($table, $tableAlias, $expressionBuilder, $constraints, $enableFieldsToIgnore, $this->context); + $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch($event); + return $event->getConstraints(); + } + + /** + * Creating where-clause for checking group access to elements in enableFields + * function + * + * @param string $field Field with group list + * @param string $table Table name + * @return string AND sql-clause + * @see getDefaultConstraints() + */ + public function getMultipleGroupsWhereClause(string $field, string $table): string + { + if (!$this->context->hasAspect('frontend.user')) { + return ''; + } + /** @var UserAspect $userAspect */ + $userAspect = $this->context->getAspect('frontend.user'); + $memberGroups = $userAspect->getGroupIds(); + $cache = $this->getRuntimeCache(); + $cacheIdentifier = 'PageRepository_groupAccessWhere_' . md5($field . '_' . $table . '_' . implode('_', $memberGroups)); + $cacheEntry = $cache->get($cacheIdentifier); + if ($cacheEntry) { + return $cacheEntry; + } + + $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($table) + ->expr(); + $orChecks = []; + // If the field is empty, then OK + $orChecks[] = $expressionBuilder->eq($field, $expressionBuilder->literal('')); + // If the field is NULL, then OK + $orChecks[] = $expressionBuilder->isNull($field); + // If the field contains zero, then OK + $orChecks[] = $expressionBuilder->eq($field, $expressionBuilder->literal('0')); + foreach ($memberGroups as $value) { + $orChecks[] = $expressionBuilder->inSet($field, $expressionBuilder->literal((string)($value ?? ''))); + } + + $accessGroupWhere = ' AND (' . $expressionBuilder->or(...$orChecks) . ')'; + $cache->set($cacheIdentifier, $accessGroupWhere); + return $accessGroupWhere; + } + + /********************** + * + * Versioning Preview + * + **********************/ + + /** + * Versioning Preview Overlay + * + * ONLY active when backend user is previewing records. MUST NEVER affect a site + * served which is not previewed by backend users!!! + * + * Generally ALWAYS used when records are selected based on uid or pid. If + * records are selected on other fields than uid or pid (eg. "email = ....") then + * usage might produce undesired results and that should be evaluated on + * individual basis. + * + * Principle: Record online! => Find offline? + * + * @param string $table Table name + * @param array|false|null $row Record array passed by reference. As minimum, the "uid", "pid" and "t3ver_state" fields must exist! The record MAY be set to FALSE in which case the calling function should act as if the record is forbidden to access! + * @param bool $unsetMovePointers If set, the $row is cleared in case it is a move-pointer. This is only for preview of moved records (to remove the record from the original location so it appears only in the new location) + * @param bool $bypassEnableFieldsCheck Unless this option is TRUE, the $row is unset if enablefields for BOTH the version AND the online record deselects it. This is because when versionOL() is called it is assumed that the online record is already selected with no regards to it's enablefields. However, after looking for a new version the online record enablefields must ALSO be evaluated of course. This is done all by this function! + * @see BackendUtility::workspaceOL() + * @param-out false|array|null $row + */ + public function versionOL(string $table, &$row, bool $unsetMovePointers = false, bool $bypassEnableFieldsCheck = false): void + { + if ((int)$this->context->getPropertyFromAspect('workspace', 'id') <= 0) { + return; + } + if (!is_array($row)) { + return; + } + if (!isset($row['uid'], $row['t3ver_oid'])) { + return; + } + // implode(',',array_keys($row)) = Using fields from original record to make + // sure no additional fields are selected. This is best for eg. getPageOverlay() + // Computed properties are excluded since those would lead to SQL errors. + $fields = array_keys($this->purgeComputedProperties($row)); + // will overlay any incoming moved record with the live record, which in turn + // will be overlaid with its workspace version again to fetch both PID fields. + $incomingRecordIsAMoveVersion = (int)$row['t3ver_oid'] > 0 && VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::MOVE_POINTER; + if ($incomingRecordIsAMoveVersion) { + // Fetch the live version again if the given $row is a move pointer, so we know the original PID + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $row = $queryBuilder->select(...$fields) + ->from($table) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter((int)$row['t3ver_oid'], Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + } + $wsAlt = $this->getWorkspaceVersionOfRecord($table, $row, $bypassEnableFieldsCheck); + if (!$wsAlt) { + return; + } + if (is_array($wsAlt)) { + $rowVersionState = VersionState::tryFrom($wsAlt['t3ver_state'] ?? 0); + if ($rowVersionState === VersionState::MOVE_POINTER) { + // For move pointers, store the actual live PID in the _ORIG_pid + // The only place where PID is actually different in a workspace + $wsAlt['_ORIG_pid'] = $row['pid']; + } + // For versions of single elements or page+content, preserve online UID + // (this will produce true "overlay" of element _content_, not any references) + // For new versions there is no online counterpart + if ($rowVersionState !== VersionState::NEW_PLACEHOLDER) { + $wsAlt['_ORIG_uid'] = $wsAlt['uid']; + } + $wsAlt['uid'] = $row['uid']; + // Changing input record to the workspace version alternative: + $row = $wsAlt; + // Unset record if it turned out to be deleted in workspace + if ($rowVersionState === VersionState::DELETE_PLACEHOLDER) { + $row = false; + } + // Check if move-pointer in workspace (unless if a move-placeholder is the + // reason why it appears!): + // You have to specifically set $unsetMovePointers in order to clear these + // because it is normally a display issue if it should be shown or not. + if ($rowVersionState === VersionState::MOVE_POINTER && !$incomingRecordIsAMoveVersion && $unsetMovePointers) { + // Unset record if it turned out to be deleted in workspace + $row = false; + } + return; + } + // No version found, then check if online version is a dummy-representation + // Notice, that unless $bypassEnableFieldsCheck is TRUE, the $row is unset if + // enablefields for BOTH the version AND the online record deselects it. See + // note for $bypassEnableFieldsCheck + if ($wsAlt <= -1 || VersionState::tryFrom($row['t3ver_state'] ?? 0)->indicatesPlaceholder()) { + // Unset record if it turned out to be "hidden" + $row = false; + } + } + + /** + * Select the version of a record for a workspace + * + * @param string $table Table name to select from + * @param array $liveRecord Record for which to find a workspace version. + * @param bool $bypassEnableFieldsCheck If TRUE, enableFields are not checked for. + * @return array|int|bool If found, return record, otherwise other value: Returns 1 if version was sought for but not found, returns -1/-2 if record (offline/online) existed but had enableFields that would disable it. Returns FALSE if not in workspace or no versioning for record. Notice, that the enablefields of the online record is also tested. + * @see BackendUtility::getWorkspaceVersionOfRecord() + * @internal this is a rather low-level method, it is recommended to use versionOL instead() + */ + public function getWorkspaceVersionOfRecord(string $table, array $liveRecord, bool $bypassEnableFieldsCheck = false, ?Context $context = null): array|int|bool + { + $context ??= $this->context; + $uid = (int)$liveRecord['uid']; + $workspace = (int)$context->getPropertyFromAspect('workspace', 'id'); + // No look up in database because versioning not enabled / or workspace not offline + if ($workspace === 0) { + return false; + } + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->isWorkspaceAware()) { + return false; + } + // Select workspace version of record, only testing for deleted. + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $fields = $this->purgeComputedProperties($liveRecord); + + $versionedRecord = $queryBuilder + ->select(...array_keys($fields)) + ->from($table) + ->where( + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter($workspace, Connection::PARAM_INT) + ), + $queryBuilder->expr()->or( + // t3ver_state=1 does not contain a t3ver_oid, and returns itself + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_state', + $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT) + ) + ), + $queryBuilder->expr()->eq( + 't3ver_oid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + /** @var RecordAccessVoter $accessVoter */ + $accessVoter = GeneralUtility::makeInstance(RecordAccessVoter::class); + // If version found, check if the versioned record has access ("enableFields") + if (is_array($versionedRecord)) { + if ($bypassEnableFieldsCheck || $accessVoter->accessGranted($table, $versionedRecord, $context)) { + // Return offline version, tested for its enableFields. + return $versionedRecord; + } + // Return -1 because offline version did not have granted access. + return -1; + } + // OK, so no workspace version was found. Then check if online version can be + // selected with full enable fields and if so, return 1: + if ($bypassEnableFieldsCheck || $accessVoter->accessGranted($table, $liveRecord, $context)) { + // Means search was done, but no version found. + return 1; + } + // Return -2 because the online record was de-selected due to its enableFields. + return -2; + } + + /** + * Perfect use case: get storage folders recursively, including the given Page IDs. + * + * Difference to "getDescendantPageIdsRecursive" is that this is used with multiple Page IDs, + * AND it includes the page IDs themselves. + * + * @return int[] + */ + public function getPageIdsRecursive(array $pageIds, int $depth): array + { + if ($pageIds === []) { + return []; + } + $pageIds = array_map(intval(...), $pageIds); + if ($depth === 0) { + return $pageIds; + } + $allPageIds = []; + foreach ($pageIds as $pageId) { + $allPageIds = array_merge($allPageIds, [$pageId], $this->getDescendantPageIdsRecursive($pageId, $depth)); + } + return array_unique($allPageIds); + } + + /** + * Generates a list of Page IDs from $startPageId. List does not include $startPageId itself. + * Only works on default language level. + * + * Pages that prevent looking for further subpages: + * - deleted pages + * - pages of the Backend User Section (doktype = 6) type + * - pages that have the extendToSubpages set, where starttime, endtime, hidden or fe_group + * would hide the pages + * + * Apart from that, pages with enable-fields excluding them, will also be removed. + * + * Mount Pages are descended, but note these ID numbers are not useful for links unless the correct MPvar is set. + * + * @param int $startPageId The id of the start page from which point in the page tree to descend. + * @param int $depth Maximum recursion depth. Use 100 or so to descend "infinitely". Stops when 0 is reached. + * @param int $begin An optional integer the level in the tree to start collecting. Zero means 'start right away', 1 = 'next level and out' + * @param array $excludePageIds Avoid collecting these pages and their possible subpages + * @param bool $bypassEnableFieldsCheck If true, then enableFields and other checks are not evaluated + * @return int[] Returns the list of Page IDs + */ + public function getDescendantPageIdsRecursive(int $startPageId, int $depth, int $begin = 0, array $excludePageIds = [], bool $bypassEnableFieldsCheck = false): array + { + if (!$startPageId) { + return []; + } + if (!$this->getRawRecord('pages', $startPageId, ['uid'])) { + // Start page does not exist + return []; + } + // Find mount point if any + $mount_info = $this->getMountPointInfo($startPageId); + $includePageId = false; + if (is_array($mount_info)) { + $startPageId = (int)$mount_info['mount_pid']; + // In overlay mode, use the mounted page uid + if ($mount_info['overlay']) { + $includePageId = true; + } + } + $descendantPageIds = $this->getSubpagesRecursive($startPageId, $depth, $begin, $excludePageIds, $bypassEnableFieldsCheck); + if ($includePageId) { + $descendantPageIds = array_merge([$startPageId], $descendantPageIds); + } + return $descendantPageIds; + } + + /** + * This is an internal (recursive) method which returns the Page IDs for a given $pageId. + * and also checks for permissions of the pages AND resolves mountpoints. + * + * @param int $pageId must be a valid page record (this is not checked) + * @return int[] + */ + protected function getSubpagesRecursive(int $pageId, int $depth, int $begin, array $excludePageIds, bool $bypassEnableFieldsCheck, array $prevId_array = []): array + { + $descendantPageIds = []; + // if $depth is 0, then we do not fetch subpages + if ($depth === 0) { + return []; + } + // Add this ID to the array of IDs + if ($begin <= 0) { + $prevId_array[] = $pageId; + } + // Select subpages + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->context->getPropertyFromAspect('workspace', 'id'))); + $queryBuilder->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())), + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')), + ) + ) + ->orderBy('sorting'); + + if ($excludePageIds !== []) { + $queryBuilder->andWhere( + $queryBuilder->expr()->notIn('uid', $queryBuilder->createNamedParameter($excludePageIds, Connection::PARAM_INT_ARRAY)) + ); + } + + $result = $queryBuilder->executeQuery(); + while ($row = $result->fetchAssociative()) { + $versionState = VersionState::tryFrom($row['t3ver_state'] ?? 0); + $this->versionOL('pages', $row, false, $bypassEnableFieldsCheck); + if ($row === false + || (int)$row['doktype'] === self::DOKTYPE_BE_USER_SECTION + || $versionState->indicatesPlaceholder() + ) { + // falsy row means Overlay prevents access to this page. + // Doing this after the overlay to make sure changes + // in the overlay are respected. + // However, we do not process pages below of and + // including of type BE user section + continue; + } + // Find mount point if any: + $next_id = (int)$row['uid']; + $mount_info = $this->getMountPointInfo($next_id, $row); + // Overlay mode: + if (is_array($mount_info) && $mount_info['overlay']) { + $next_id = (int)$mount_info['mount_pid']; + // @todo: check if we could use $mount_info[mount_pid_rec] and check against $excludePageIds? + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->context->getPropertyFromAspect('workspace', 'id'))); + $queryBuilder->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($next_id, Connection::PARAM_INT) + ) + ) + ->orderBy('sorting') + ->setMaxResults(1); + + if ($excludePageIds !== []) { + $queryBuilder->andWhere( + $queryBuilder->expr()->notIn('uid', $queryBuilder->createNamedParameter($excludePageIds, Connection::PARAM_INT_ARRAY)) + ); + } + + $row = $queryBuilder->executeQuery()->fetchAssociative(); + $this->versionOL('pages', $row); + $versionState = VersionState::tryFrom($row['t3ver_state'] ?? 0); + if ($row === false + || (int)$row['doktype'] === self::DOKTYPE_BE_USER_SECTION + || $versionState->indicatesPlaceholder() + ) { + // Doing this after the overlay to make sure + // changes in the overlay are respected. + // see above + continue; + } + } + $accessVoter = GeneralUtility::makeInstance(RecordAccessVoter::class); + // Add record: + if ($bypassEnableFieldsCheck || $accessVoter->accessGrantedForPageInRootLine($row, $this->context)) { + // Add ID to list: + if ($begin <= 0) { + if ($bypassEnableFieldsCheck || $accessVoter->accessGranted('pages', $row, $this->context)) { + $descendantPageIds[] = $next_id; + } + } + // Next level + // Normal mode: + if (is_array($mount_info) && !$mount_info['overlay']) { + $next_id = (int)$mount_info['mount_pid']; + } + // Call recursively, if the id is not in prevID_array: + if (!in_array($next_id, $prevId_array, true)) { + $descendantPageIds = array_merge( + $descendantPageIds, + $this->getSubpagesRecursive( + $next_id, + $depth - 1, + $begin - 1, + $excludePageIds, + $bypassEnableFieldsCheck, + $prevId_array + ) + ); + } + } + } + return $descendantPageIds; + } + + /** + * Checks if the page is hidden in the active workspace (and the current language), then the "preview" + * mode for frontend pages is active. + * + * @internal this is not part of the TYPO3 Core API. + */ + public function checkIfPageIsHidden(int $pageId, LanguageAspect $languageAspect): bool + { + if ($pageId === 0) { + return false; + } + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder + ->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $queryBuilder + ->select('*') + ->from('pages') + ->setMaxResults(1); + + // $pageId always points to the ID of the default language page, so we check + // the current site language to determine if we need to fetch a translation but consider fallbacks + if ($languageAspect->getId() > 0) { + $languagesToCheck = [$languageAspect->getId()]; + // Remove fallback information like "pageNotFound" + foreach ($languageAspect->getFallbackChain() as $languageToCheck) { + if (is_numeric($languageToCheck)) { + $languagesToCheck[] = $languageToCheck; + } + } + // Check for the language and all its fallbacks (except for default language) + $constraint = $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('l10n_parent', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), + $queryBuilder->expr()->in('sys_language_uid', $queryBuilder->createNamedParameter(array_filter($languagesToCheck), Connection::PARAM_INT_ARRAY)) + ); + // If the fallback language Ids also contains the default language, this needs to be considered + if (in_array(0, $languagesToCheck, true)) { + $constraint = $queryBuilder->expr()->or( + $constraint, + // Ensure to also fetch the default record + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())), + $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')), + ) + ) + ); + } + $queryBuilder->where($constraint); + // Ensure that the translated records are shown first (maxResults is set to 1) + $queryBuilder->orderBy('sys_language_uid', 'DESC'); + } else { + $queryBuilder->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)) + ); + } + $page = $queryBuilder->executeQuery()->fetchAssociative(); + if ((int)$this->context->getPropertyFromAspect('workspace', 'id') > 0) { + // Fetch overlay of page if in workspace and check if it is hidden. The visibility aspect is + // reset on a cloned context so the workspace version is evaluated against default visibility. + $context = clone $this->context; + $context->setAspect('visibility', new VisibilityAspect()); + $targetPage = $this->getWorkspaceVersionOfRecord('pages', $page, false, $context); + // Also checks if the workspace version is NOT hidden but the live version is in fact still hidden + $result = $targetPage === -1 || $targetPage === -2 || (is_array($targetPage) && $targetPage['hidden'] == 0 && $page['hidden'] == 1); + } else { + $result = is_array($page) && ($page['hidden'] || $page['starttime'] > $GLOBALS['SIM_EXEC_TIME'] || $page['endtime'] != 0 && $page['endtime'] <= $GLOBALS['SIM_EXEC_TIME']); + } + return $result; + } + + /** + * This resolves and returns the referenced pageRecord for {@see self::DOKTYPE_SHORTCUT} and + * {@see self::DOKTYPE_LINK}, + * + * For any other `$pageRecord['doktype']` the passed `$pageRecord` is returned unchanged. + * + * @throws CircularPageReferenceChainException + * @throws PageReferenceResolvingReachedIterationLimitException + * @throws ShortcutTargetPageNotFoundException + * @throws LinkedPageNotResolvableException + */ + public function resolveReferencedPageRecord(array $pageRecord, array $pageLog, int $iteration, bool $disableGroupCheck): mixed + { + $doktype = (int)($pageRecord['doktype'] ?? 0); + if (!in_array($doktype, [self::DOKTYPE_LINK, self::DOKTYPE_SHORTCUT], true)) { + return $pageRecord; + } + if (in_array($pageRecord['uid'], $pageLog, true)) { + $pageLog[] = $pageRecord['uid']; + $chain = implode(' → ', $pageLog); + $this->logger->error( + 'Circular page reference detected. Chain: {chain}', + ['chain' => $chain, 'uids' => $pageLog] + ); + throw new CircularPageReferenceChainException( + sprintf( + 'A circular reference occurred while resolving page references (shortcut/link). Chain: %s', + $chain, + ), + 1294587212, + ); + } + $pageLog[] = $pageRecord['uid']; + if ($iteration <= 0) { + $chain = implode(' → ', $pageLog); + $this->logger->error( + 'Page reference resolving depth reached before resolving final page. Chain: {chain}', + ['chain' => $chain, 'uids' => $pageLog] + ); + throw new PageReferenceResolvingReachedIterationLimitException( + sprintf( + 'Resolving page references reached max depth before resolving final page record for shortcut/link. Chain: %s', + $chain, + ), + 1763640566, + ); + } + if ($doktype === self::DOKTYPE_LINK) { + $subLinkParts = $this->pageTypeLinkResolver->resolveTypolinkParts($pageRecord); + return match ($subLinkParts['type'] ?? '') { + 'page' => $this->getPageLink($subLinkParts, $pageRecord, $iteration - 1, $pageLog, $disableGroupCheck), + // @todo Consider to add a PSR-14 event here to allow handling for custom link types if they are linkable/redirectable, + // for now return $pageRecord to allow them, like `email` or `telephone` which are at least linkable in menues. + default => $pageRecord, + }; + } + return $this->getPageShortcut((string)$pageRecord['shortcut'], $pageRecord['shortcut_mode'], $pageRecord['uid'], $iteration - 1, $pageLog, $disableGroupCheck); + } + + /** + * Purges computed properties from database rows, + * such as _ORIG_uid or _ORIG_pid for instance. + */ + protected function purgeComputedProperties(array $row): array + { + foreach (self::COMPUTED_PROPERTY_NAMES as $computedPropertyName) { + if (array_key_exists($computedPropertyName, $row)) { + unset($row[$computedPropertyName]); + } + } + return $row; + } + + protected function getRuntimeCache(): FrontendInterface + { + return GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'); + } +} diff --git a/Classes/Error/AbstractExceptionHandler.php b/Classes/Error/AbstractExceptionHandler.php new file mode 100644 index 0000000..0922d61 --- /dev/null +++ b/Classes/Error/AbstractExceptionHandler.php @@ -0,0 +1,246 @@ +echoExceptionCLI($exception); + break; + default: + $this->echoExceptionWeb($exception); + } + } + + /** + * Writes exception to different logs + * + * @param \Throwable $exception The throwable object. + * @param string $mode The context where the exception was thrown. + * Either self::CONTEXT_WEB or self::CONTEXT_CLI. + */ + protected function writeLogEntries(\Throwable $exception, string $mode): void + { + // Do not write any logs for some messages to avoid filling up tables or files with illegal requests + $ignoredCodes = array_merge(self::IGNORED_EXCEPTION_CODES, self::IGNORED_HMAC_EXCEPTION_CODES); + if (in_array($exception->getCode(), $ignoredCodes, true)) { + return; + } + + // PSR-3 logging framework. + try { + if ($this->logger) { + // 'FE' if in FrontendApplication, else 'BE' (also in CLI without request object) + // @todo: We could reconsider this construct with PHP 8.5: It might be possible to register + // the exception handler early during bootstrap. Then, later, when a request is available, + // get it, and reconfigure exception handler to its final state. This would avoid the runtime + // dependency to request including the funny PHP_SAPI fork in handleException(). + $applicationMode = ($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ? 'FE' + : 'BE'; + $requestUrl = $this->anonymizeToken(NormalizedParams::createFromServerParams($_SERVER)->getRequestUrl()); + $this->logger->critical('Core: Exception handler ({mode}: {application_mode}): {exception_class}, code #{exception_code}, file {file}, line {line}: {message}', [ + 'mode' => $mode, + 'application_mode' => $applicationMode, + 'exception_class' => get_class($exception), + 'exception_code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'message' => $exception->getMessage(), + 'request_url' => $requestUrl, + 'exception' => $this->logExceptionStackTrace ? $exception : null, + ]); + } + } catch (\Exception $exception) { + // A nested exception here was probably caused by a database failure, which means there's little + // else that can be done other than moving on and letting the system hard-fail. + } + + // Legacy logger. Remove this section eventually. + $filePathAndName = $exception->getFile(); + $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : ''; + $logTitle = 'Core: Exception handler (' . $mode . ')'; + $logMessage = 'Uncaught TYPO3 Exception: ' . $exceptionCodeNumber . $exception->getMessage() . ' | ' + . get_class($exception) . ' thrown in file ' . $filePathAndName . ' in line ' . $exception->getLine(); + if ($mode === self::CONTEXT_WEB) { + $logMessage .= '. Requested URL: ' . $this->anonymizeToken(NormalizedParams::createFromServerParams($_SERVER)->getRequestUrl()); + } + // When database credentials are wrong, the exception is probably + // caused by this. Therefore we cannot do any database operation, + // otherwise this will lead into recurring exceptions. + try { + // Write error message to sys_log table + $this->writeLog($logTitle . ': ' . $logMessage); + } catch (\Throwable $exception) { + } + } + + /** + * Writes an exception in the sys_log table + * + * @param string $logMessage Default text that follows the message. + */ + protected function writeLog(string $logMessage) + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_log'); + + if (!$connection->isConnected()) { + return; + } + $userId = 0; + $workspace = 0; + $data = []; + $backendUser = $this->getBackendUser(); + if ($backendUser !== null) { + if (isset($backendUser->user['uid'])) { + $userId = $backendUser->user['uid']; + } + $workspace = $backendUser->workspace; + if ($backUserId = $backendUser->getOriginalUserIdWhenInSwitchUserMode()) { + $data['originalUser'] = $backUserId; + } + } + + $connection->insert( + 'sys_log', + [ + 'userid' => $userId, + 'type' => SystemLogType::ERROR, + 'channel' => SystemLogType::toChannel(SystemLogType::ERROR), + 'action' => SystemLogGenericAction::UNDEFINED, + 'error' => SystemLogErrorClassification::SYSTEM_ERROR, + 'level' => SystemLogType::toLevel(SystemLogType::ERROR), + 'details' => str_replace('%', '%%', $logMessage), + 'log_data' => empty($data) ? '' : json_encode($data), + 'IP' => NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress(), + 'tstamp' => $GLOBALS['EXEC_TIME'], + 'workspace' => $workspace, + ] + ); + } + + /** + * Sends the HTTP Status 500 code, if $exception is *not* a + * TYPO3\CMS\Core\Error\Http\StatusException and headers are not sent, yet. + * + * @param \Throwable $exception The throwable object. + */ + protected function sendStatusHeaders(\Throwable $exception) + { + $headers = $exception instanceof StatusException + ? $exception->getStatusHeaders() + : [HttpUtility::HTTP_STATUS_500]; + if (!headers_sent()) { + foreach ($headers as $header) { + header($header); + } + } + } + + /** + * Derives the numeric HTTP status code from the exception. + * + * Mirrors the logic of {@see sendStatusHeaders()}: returns the status code + * from the HTTP status line of a StatusException, or 500 for any other exception. + */ + protected function getHttpStatusCodeFromException(\Throwable $exception): int + { + if (!($exception instanceof StatusException)) { + return 500; + } + foreach ($exception->getStatusHeaders() ?? [] as $header) { + if (preg_match('/^HTTP\/[\d.]+\s+(\d{3})/', $header, $matches)) { + return (int)$matches[1]; + } + } + return 500; + } + + protected function getBackendUser(): ?BackendUserAuthentication + { + return $GLOBALS['BE_USER'] ?? null; + } + + /** + * Replaces the generated token with a generic equivalent + */ + protected function anonymizeToken(string $requestedUrl): string + { + $pattern = '/(?:(?<=[tT]oken=)|(?<=[tT]oken%3D))[0-9a-fA-F]{40}/'; + return preg_replace($pattern, '--AnonymizedToken--', $requestedUrl); + } +} diff --git a/Classes/Error/DebugExceptionHandler.php b/Classes/Error/DebugExceptionHandler.php new file mode 100644 index 0000000..5e73fb9 --- /dev/null +++ b/Classes/Error/DebugExceptionHandler.php @@ -0,0 +1,668 @@ +handleException(...)); + } + + /** + * Formats and echoes the exception as XHTML. + * + * @param \Throwable $exception The throwable object. + */ + public function echoExceptionWeb(\Throwable $exception) + { + $this->sendStatusHeaders($exception); + $this->writeLogEntries($exception, self::CONTEXT_WEB); + + $content = $this->getContent($exception); + $css = $this->getStylesheet(); + $js = $this->getJavascript(); + + echo << + + + + TYPO3 Exception + + + + + + $content + + +HTML; + } + + /** + * Formats and echoes the exception for the command line + * + * @param \Throwable $exception The throwable object. + */ + public function echoExceptionCLI(\Throwable $exception) + { + $filePathAndName = $exception->getFile(); + $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : ''; + $this->writeLogEntries($exception, self::CONTEXT_CLI); + echo LF . 'Uncaught TYPO3 Exception ' . $exceptionCodeNumber . $exception->getMessage() . LF; + echo 'thrown in file ' . $filePathAndName . LF; + echo 'in line ' . $exception->getLine() . LF . LF; + die(1); + } + + /** + * Generates the HTML for the error output. + */ + protected function getContent(\Throwable $throwable): string + { + $content = ''; + + // exceptions can be chained + // for easier debugging, all exceptions are displayed to the developer + $throwables = $this->getAllThrowables($throwable); + $count = count($throwables); + foreach ($throwables as $position => $e) { + $content .= $this->getSingleThrowableContent($e, $position + 1, $count); + } + + $exceptionInfo = ''; + if ($throwable->getCode() > 0) { + $documentationLink = Typo3Information::URL_EXCEPTION . 'debug/' . $throwable->getCode(); + $exceptionInfo = << + + +INFO; + } + + $typo3Logo = $this->getTypo3LogoAsSvg(); + + try { + // This outside dependency class is always loaded before the exception handler is setup. + // So it is safe to access without affecting the output of this handler. + $projectPath = Environment::getProjectPath() . DIRECTORY_SEPARATOR; + } catch (\Throwable) { + // just in case something goes wrong. + $projectPath = ''; + } + + $projectPathEscaped = $this->escapeHtml($projectPath); + + return << +
+
+
+
$typo3Logo
+

+ Whoops, looks like something went wrong. + +

+
+
+
+ + $exceptionInfo + +
+ $content +
+ +HTML; + } + + /** + * Renders the HTML for a single throwable. + */ + protected function getSingleThrowableContent(\Throwable $throwable, int $index, int $total): string + { + $exceptionTitle = get_class($throwable); + $exceptionCode = $throwable->getCode() ? '#' . $throwable->getCode() . ' ' : ''; + $exceptionMessage = $this->escapeHtml($throwable->getMessage()); + + // The trace does not contain the step where the exception is thrown. + // To display it as well it is added manually to the trace. + $trace = $throwable->getTrace(); + array_unshift($trace, [ + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'args' => [], + ]); + + $backtraceCode = $this->getBacktraceCode($trace); + + return << +
+

+ ({$index}/{$total}) + {$exceptionCode}{$exceptionTitle} +

+

{$exceptionMessage}

+
+
+ {$backtraceCode} +
+ +HTML; + } + + /** + * Generates the stylesheet needed to display the error page. + */ + protected function getStylesheet(): string + { + return << *:first-child { + margin-top: 0; + } + + .exception-page .trace-step > *:last-child { + margin-bottom: 0; + } + + .exception-page .trace-step:nth-child(even) + { + background-color: #fafafa; + } + + .exception-page .trace-step:last-child { + border-bottom: none; + } + + .exception-page .copy-button { + cursor: pointer; + border: 0.1rem solid transparent; + background-color: transparent; + padding: 0; + margin-left: 1rem; + } + + .exception-page .copy-button:hover { + border: 0.1rem solid #b9b9b9; + } + + .exception-page #stacktrace-action-buttons { + display: inline-flex; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; + } + + .exception-page .stacktrace-action-button { + cursor: pointer; + padding: 0.5rem; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0,0,0,0); + box-sizing: border-box; + background-color: color(srgb 0.97 0.97 0.97); + border: 1px solid color(srgb 0.75 0.75 0.75); + border-radius: .75em; + color: color(srgb 0.1 0.1 0.1); + display: inline-flex; + font-weight: 400; + gap: .35em; + justify-content: center; + outline-offset: 0; + text-decoration: none; + --typo3-transition-color: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,opacity .15s ease-in-out; + transition: var(--typo3-transition-color); + user-select: none; + vertical-align: middle; + white-space: nowrap; + margin-bottom: 0; + margin-top: 0; + } + + pre.plaintextFallback { + margin: 2rem auto; + border: 1px solid black; + max-height: 250px; + font-size: 0.8em; + padding: 1rem; + } + +STYLESHEET; + } + + /** + * Returns JavaScript functionality. Loaded from a TypeScript build. + * It is loaded inline, to not need to load additional URIs or build routes to assets. + * Also, it does not use ES6 module loading to be light-weight and dependency free. + */ + protected function getJavascript(): string + { + return file_get_contents(__DIR__ . '/../../Resources/Public/JavaScript/utility/debug-exception-handler-service.js'); + } + + /** + * Renders the backtrace as HTML. + */ + protected function getBacktraceCode(array $trace): string + { + $content = ''; + + foreach ($trace as $step) { + $content .= '
'; + $args = $this->flattenArgs($step['args'] ?? []); + + if (isset($step['function'])) { + $content .= '
' . sprintf( + 'at %s%s%s(%s)', + $step['class'] ?? '', + $step['type'] ?? '', + $step['function'], + $this->formatArgs($args) + ) . '
'; + } + + if (isset($step['file']) && isset($step['line'])) { + $content .= $this->getCodeSnippet($step['file'], $step['line']); + } + + $content .= '
'; + } + + return $content; + } + + /** + * Returns a code snippet from the specified file. + * + * @param string $filePathAndName Absolute path and file name of the PHP file + * @param int $lineNumber Line number defining the center of the code snippet + * @return string The code snippet + */ + protected function getCodeSnippet(string $filePathAndName, int $lineNumber): string + { + $showLinesAround = 4; + + $content = '
'; + $content .= '
' . $this->formatPath($filePathAndName, $lineNumber) . '
'; + + if (@file_exists($filePathAndName)) { + $phpFile = @file($filePathAndName); + if (is_array($phpFile)) { + $startLine = $lineNumber > $showLinesAround ? $lineNumber - $showLinesAround : 1; + $phpFileCount = count($phpFile); + $endLine = $lineNumber < $phpFileCount - $showLinesAround ? $lineNumber + $showLinesAround + 1 : $phpFileCount + 1; + if ($endLine > $startLine) { + $content .= '
'; + $content .= '
';
+
+                    for ($line = $startLine; $line < $endLine; $line++) {
+                        $codeLine = str_replace("\t", ' ', $phpFile[$line - 1]);
+                        $spanClass = '';
+                        if ($line === $lineNumber) {
+                            $spanClass = 'highlight';
+                        }
+
+                        $content .= '' . $this->escapeHtml($codeLine) . '';
+                    }
+
+                    $content .= '
'; + $content .= '
'; + } + } + } + + $content .= '
'; + + return $content; + } + + /** + * Formats a path adding a line number. + * + * @param string $path The full path of the file. + * @param int $line The line number. + */ + protected function formatPath(string $path, int $line): string + { + // "data-lineno" is evaluated by debug-exception-handler-service.js + return sprintf( + 'in %s%s', + $line > 0 ? $line : 1, + $this->escapeHtml($path), + $line > 0 ? ' line ' . $line : '' + ); + } + + /** + * Formats the arguments of a method call. + * + * @param array $args The flattened args of method/function call + */ + protected function formatArgs(array $args): string + { + $result = []; + foreach ($args as $key => $item) { + if ($item[0] === 'object') { + $formattedValue = sprintf('object(%s)', $item[1]); + } elseif ($item[0] === 'array') { + $formattedValue = sprintf('array(%s)', is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]); + } elseif ($item[0] === 'null') { + $formattedValue = 'null'; + } elseif ($item[0] === 'boolean') { + $formattedValue = '' . strtolower(var_export($item[1], true)) . ''; + } elseif ($item[0] === 'resource') { + $formattedValue = 'resource'; + } else { + $formattedValue = str_replace("\n", '', $this->escapeHtml(var_export($item[1], true))); + } + + $result[] = is_int($key) ? $formattedValue : sprintf("'%s' => %s", $this->escapeHtml($key), $formattedValue); + } + + return implode(', ', $result); + } + + protected function flattenArgs(array $args, int $level = 0, int &$count = 0): array + { + $result = []; + foreach ($args as $key => $value) { + if (++$count > 1e4) { + return ['array', '*SKIPPED over 10000 entries*']; + } + if ($value instanceof \__PHP_Incomplete_Class) { + // is_object() returns false on PHP<=7.1 + $result[$key] = ['incomplete-object', $this->getClassNameFromIncomplete($value)]; + } elseif (is_object($value)) { + $result[$key] = ['object', get_class($value)]; + } elseif (is_array($value)) { + if ($level > 10) { + $result[$key] = ['array', '*DEEP NESTED ARRAY*']; + } else { + $result[$key] = ['array', $this->flattenArgs($value, $level + 1, $count)]; + } + } elseif ($value === null) { + $result[$key] = ['null', null]; + } elseif (is_bool($value)) { + $result[$key] = ['boolean', $value]; + } elseif (is_int($value)) { + $result[$key] = ['integer', $value]; + } elseif (is_float($value)) { + $result[$key] = ['float', $value]; + } elseif (is_resource($value)) { + $result[$key] = ['resource', get_resource_type($value)]; + } else { + $result[$key] = ['string', (string)$value]; + } + } + + return $result; + } + + protected function getClassNameFromIncomplete(\__PHP_Incomplete_Class $value): string + { + $array = new \ArrayObject($value); + + return $array['__PHP_Incomplete_Class_Name']; + } + + protected function escapeHtml(string $str): string + { + return htmlspecialchars($str, ENT_COMPAT | ENT_SUBSTITUTE); + } + + protected function getTypo3LogoAsSvg(): string + { + return << +SVG; + } + + protected function getAllThrowables(\Throwable $throwable): array + { + $all = [$throwable]; + + while ($throwable = $throwable->getPrevious()) { + $all[] = $throwable; + } + + return $all; + } +} diff --git a/Classes/Error/ErrorHandler.php b/Classes/Error/ErrorHandler.php new file mode 100644 index 0000000..cba251a --- /dev/null +++ b/Classes/Error/ErrorHandler.php @@ -0,0 +1,290 @@ + 'PHP Warning', + E_NOTICE => 'PHP Notice', + E_USER_ERROR => 'PHP User Error', + E_USER_WARNING => 'PHP User Warning', + E_USER_NOTICE => 'PHP User Notice', + E_RECOVERABLE_ERROR => 'PHP Catchable Fatal Error', + E_USER_DEPRECATED => 'TYPO3 Deprecation Notice', + E_DEPRECATED => 'PHP Runtime Deprecation Notice', + // @todo: Remove 2048 (deprecated E_STRICT) in v14, as this value is no longer used by PHP itself + // and only kept here here because possible custom PHP extensions may still use it. + // See https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant + 2048 /* deprecated E_STRICT */ => 'PHP Runtime Notice', + ]; + + /** + * Error levels which should result in an exception thrown. + */ + protected int $exceptionalErrors = 0; + + /** + * Error levels which should be handled. + */ + protected int $errorHandlerErrors = 0; + + /** + * Whether to write a flash message in case of an error + */ + protected bool $debugMode = false; + + /** + * Registers this class as default error handler + * + * @param int $errorHandlerErrors The integer representing the E_* error level which should be + */ + public function __construct($errorHandlerErrors) + { + $excludedErrors = E_COMPILE_WARNING | E_COMPILE_ERROR | E_CORE_WARNING | E_CORE_ERROR | E_PARSE | E_ERROR; + // reduces error types to those a custom error handler can process + $this->errorHandlerErrors = (int)$errorHandlerErrors & ~$excludedErrors; + } + + /** + * Defines which error levels should result in an exception thrown. + * + * @param int $exceptionalErrors The integer representing the E_* error level to handle as exceptions + */ + public function setExceptionalErrors($exceptionalErrors) + { + $exceptionalErrors = (int)$exceptionalErrors; + // We always disallow E_USER_DEPRECATED to generate exceptions as this may cause + // bad user experience specifically during upgrades. + $this->exceptionalErrors = $exceptionalErrors & ~E_USER_DEPRECATED; + } + + /** + * @param bool $debugMode + */ + public function setDebugMode($debugMode) + { + $this->debugMode = (bool)$debugMode; + } + + public function registerErrorHandler() + { + set_error_handler([$this, 'handleError']); + } + + /** + * Handles an error. + * If the error is registered as exceptionalError it will by converted into an exception, to be handled + * by the configured exceptionhandler. Additionally the error message is written to the configured logs. + * If application is backend, the error message is also added to the flashMessageQueue, in frontend the + * error message is displayed in the admin panel (as TsLog message). + * + * @param int $errorLevel The error level - one of the E_* constants + * @param string $errorMessage The error message + * @param string $errorFile Name of the file the error occurred in + * @param int $errorLine Line number where the error occurred + * @return bool + * @throws Exception with the data passed to this method if the error is registered as exceptionalError + */ + public function handleError($errorLevel, $errorMessage, $errorFile, $errorLine) + { + // Filter all errors, that should not be reported/ handled from current error reporting + $reportingLevel = $this->errorHandlerErrors & error_reporting(); + // Since symfony does this: + // @trigger_error('...', E_USER_DEPRECATED), and we DO want to log these, + // we always enforce deprecation messages to be handled, even when they are silenced + $reportingLevel |= E_USER_DEPRECATED; + $shouldHandleError = (bool)($reportingLevel & $errorLevel); + if (!$shouldHandleError) { + return self::ERROR_HANDLED; + } + + $message = self::ERROR_LEVEL_LABELS[$errorLevel] . ': ' . $errorMessage . ' in ' . $errorFile . ' line ' . $errorLine; + if ($errorLevel & $this->exceptionalErrors) { + throw new Exception($message, 1476107295); + } + + $message = $this->getFormattedLogMessage($message); + + if ($errorLevel === E_USER_DEPRECATED || $errorLevel === E_DEPRECATED) { + $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger('TYPO3.CMS.deprecations'); + $logger->notice($message); + return self::ERROR_HANDLED; + } + + switch ($errorLevel) { + case E_USER_ERROR: + case E_RECOVERABLE_ERROR: + $logLevel = LogLevel::ERROR; + break; + case E_USER_WARNING: + case E_WARNING: + $logLevel = LogLevel::WARNING; + break; + default: + $logLevel = LogLevel::NOTICE; + } + + if ($this->logger) { + $this->logger->log($logLevel, $message); + } + + try { + // Write error message to TSlog (admin panel) + $this->getTimeTracker()->setTSlogMessage($message, $logLevel); + } catch (\Throwable $e) { + // Silently catch in case an error occurs before the DI container is in place + } + // Write error message to sys_log table (ext: belog, Tools->Log) + if ($errorLevel & ($GLOBALS['TYPO3_CONF_VARS']['SYS']['belogErrorReporting'] ?? 0)) { + // Silently catch in case an error occurs before a database connection exists. + try { + $this->writeLog($message, $logLevel); + } catch (\Exception $e) { + } + } + if ($logLevel === LogLevel::ERROR) { + // Let the internal handler continue. This will stop the script + return self::PROPAGATE_ERROR; + } + if ($this->debugMode) { + $this->createAndEnqueueFlashMessage($message, $errorLevel); + } + // Don't execute PHP internal error handler + return self::ERROR_HANDLED; + } + + protected function createAndEnqueueFlashMessage(string $message, int $errorLevel): void + { + switch ($errorLevel) { + case E_USER_WARNING: + case E_WARNING: + $flashMessageSeverity = ContextualFeedbackSeverity::WARNING; + break; + default: + $flashMessageSeverity = ContextualFeedbackSeverity::NOTICE; + } + $flashMessage = new FlashMessage( + $message, + self::ERROR_LEVEL_LABELS[$errorLevel], + $flashMessageSeverity + ); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + /** + * Writes an error in the sys_log table + * + * @param string $logMessage Default text that follows the message (in english!). + * @param string $logLevel The error level, see LogLevel::* constants + */ + protected function writeLog($logMessage, string $logLevel) + { + // Avoid ConnectionPool usage prior boot completion (see #96291). + if (!GeneralUtility::getContainer()->get('boot.state')->complete) { + if ($this->logger) { + // Log via debug(), the original message has already been logged with the original serverity in handleError(). + // This log entry is targeted for users that try to debug why a log record is missing in sys_log + // while it has been logged to the logging framework. + $this->logger->debug( + 'An error could not be logged to database as it appeared during early bootstrap (TCA or ext_localconf.php loading).', + ['original_message' => $logMessage, 'original_loglevel' => $logLevel] + ); + } + return; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_log'); + if ($connection->isConnected()) { + $userId = 0; + $workspace = 0; + $data = []; + $backendUser = $this->getBackendUser(); + if (is_object($backendUser)) { + if (isset($backendUser->user['uid'])) { + $userId = $backendUser->user['uid']; + } + $workspace = $backendUser->workspace; + if ($backUserId = $backendUser->getOriginalUserIdWhenInSwitchUserMode()) { + $data['originalUser'] = $backUserId; + } + } + + $connection->insert( + 'sys_log', + [ + 'userid' => $userId, + 'type' => SystemLogType::ERROR, + 'channel' => SystemLogType::toChannel(SystemLogType::ERROR), + 'action' => SystemLogGenericAction::UNDEFINED, + 'error' => SystemLogErrorClassification::SYSTEM_ERROR, + 'level' => $logLevel, + 'details' => str_replace('%', '%%', $logMessage), + 'log_data' => empty($data) ? '' : json_encode($data), + 'IP' => NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress(), + 'tstamp' => $GLOBALS['EXEC_TIME'], + 'workspace' => $workspace, + ] + ); + } + } + + protected function getFormattedLogMessage(string $message): string + { + // String 'FE' if in FrontendApplication, else 'BE' (also in CLI without request object) + $applicationType = ($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() ? 'FE' : 'BE'; + $logPrefix = 'Core: Error handler (' . $applicationType . ')'; + return $logPrefix . ': ' . $message; + } + + protected function getTimeTracker(): TimeTracker + { + return GeneralUtility::makeInstance(TimeTracker::class); + } + + protected function getBackendUser(): ?BackendUserAuthentication + { + return $GLOBALS['BE_USER'] ?? null; + } +} diff --git a/Classes/Error/ErrorHandlerInterface.php b/Classes/Error/ErrorHandlerInterface.php new file mode 100644 index 0000000..7dd7beb --- /dev/null +++ b/Classes/Error/ErrorHandlerInterface.php @@ -0,0 +1,61 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/Http/ForbiddenException.php b/Classes/Error/Http/ForbiddenException.php new file mode 100644 index 0000000..dd5b630 --- /dev/null +++ b/Classes/Error/Http/ForbiddenException.php @@ -0,0 +1,53 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/Http/InternalServerErrorException.php b/Classes/Error/Http/InternalServerErrorException.php new file mode 100644 index 0000000..e7ead2f --- /dev/null +++ b/Classes/Error/Http/InternalServerErrorException.php @@ -0,0 +1,53 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/Http/LinkedPageNotResolvableException.php b/Classes/Error/Http/LinkedPageNotResolvableException.php new file mode 100644 index 0000000..ae6927f --- /dev/null +++ b/Classes/Error/Http/LinkedPageNotResolvableException.php @@ -0,0 +1,23 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/Http/ServiceUnavailableException.php b/Classes/Error/Http/ServiceUnavailableException.php new file mode 100644 index 0000000..18b597d --- /dev/null +++ b/Classes/Error/Http/ServiceUnavailableException.php @@ -0,0 +1,53 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/Http/ShortcutTargetPageNotFoundException.php b/Classes/Error/Http/ShortcutTargetPageNotFoundException.php new file mode 100644 index 0000000..ed29a75 --- /dev/null +++ b/Classes/Error/Http/ShortcutTargetPageNotFoundException.php @@ -0,0 +1,23 @@ +statusHeaders = $statusHeaders; + } else { + $this->statusHeaders = [$statusHeaders]; + } + $this->title = $title ?: $this->title; + parent::__construct($message, $code); + } + + /** + * Setter for the title. + * + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * Getter for the title. + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Getter for the Status Header. + * + * @return array + */ + public function getStatusHeaders() + { + return $this->statusHeaders; + } +} diff --git a/Classes/Error/Http/UnauthorizedException.php b/Classes/Error/Http/UnauthorizedException.php new file mode 100644 index 0000000..a665a1a --- /dev/null +++ b/Classes/Error/Http/UnauthorizedException.php @@ -0,0 +1,53 @@ +message = $message; + } + parent::__construct($this->statusHeaders, $this->message, $this->title, $code); + } +} diff --git a/Classes/Error/PageErrorHandler/FluidPageErrorHandler.php b/Classes/Error/PageErrorHandler/FluidPageErrorHandler.php new file mode 100644 index 0000000..f29ac99 --- /dev/null +++ b/Classes/Error/PageErrorHandler/FluidPageErrorHandler.php @@ -0,0 +1,80 @@ +configuration; + $templateRootPaths = null; + if (is_string($configuration['errorFluidTemplatesRootPath'] ?? false) && $configuration['errorFluidTemplatesRootPath'] !== '') { + $templateRootPaths = [$configuration['errorFluidTemplatesRootPath']]; + } + $layoutRootPaths = null; + if (is_string($configuration['errorFluidLayoutsRootPath'] ?? false) && $configuration['errorFluidLayoutsRootPath'] !== '') { + $layoutRootPaths = [$configuration['errorFluidLayoutsRootPath']]; + } + $partialRootPaths = null; + if (is_string($configuration['errorFluidPartialsRootPath'] ?? false) && $configuration['errorFluidPartialsRootPath'] !== '') { + $partialRootPaths = [$configuration['errorFluidPartialsRootPath']]; + } + $templatePathAndFilename = null; + if (is_string($configuration['errorFluidTemplate'] ?? false) && $configuration['errorFluidTemplate'] !== '') { + $templatePathAndFilename = GeneralUtility::getFileAbsFileName($configuration['errorFluidTemplate']); + } + if ($templatePathAndFilename === null || !is_file($templatePathAndFilename)) { + throw new \RuntimeException('FluidPageErrorHandler: Configured Fluid template file not found.', 1764510148); + } + + $viewFactoryDate = new ViewFactoryData( + templateRootPaths: $templateRootPaths, + partialRootPaths: $partialRootPaths, + layoutRootPaths: $layoutRootPaths, + templatePathAndFilename: $templatePathAndFilename, + request: $request, + ); + $viewFactory = GeneralUtility::makeInstance(ViewFactoryInterface::class); + $view = $viewFactory->create($viewFactoryDate); + $view->assignMultiple([ + 'request' => $request, + 'message' => $message, + 'reasons' => $reasons, + ]); + return new HtmlResponse($view->render(), $this->statusCode); + } +} diff --git a/Classes/Error/PageErrorHandler/InvalidPageErrorHandlerException.php b/Classes/Error/PageErrorHandler/InvalidPageErrorHandlerException.php new file mode 100644 index 0000000..9501711 --- /dev/null +++ b/Classes/Error/PageErrorHandler/InvalidPageErrorHandlerException.php @@ -0,0 +1,26 @@ +statusCode = $statusCode; + if (empty($configuration['errorContentSource'])) { + throw new \InvalidArgumentException('PageContentErrorHandler needs to have a proper link set.', 1522826413); + } + $this->errorHandlerConfiguration = $configuration; + + // @todo Convert this to DI once this class can be injected properly. + $container = GeneralUtility::getContainer(); + $this->application = $container->get(Application::class); + $this->responseFactory = $container->get(ResponseFactoryInterface::class); + $this->siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + $this->link = $container->get(LinkService::class); + $this->requestFactory = $container->get(RequestFactoryInterface::class); + $this->guzzleClientFactory = $container->get(GuzzleClientFactory::class); + } + + public function handlePageError(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface + { + try { + $urlParams = $this->link->resolve($this->errorHandlerConfiguration['errorContentSource']); + $urlParams['pageuid'] = (int)($urlParams['pageuid'] ?? 0); + $urlType = $urlParams['type'] ?? LinkService::TYPE_UNKNOWN; + $resolvedUrl = $this->resolveUrl($request, $urlParams); + + // avoid denial-of-service amplification scenario + if ($resolvedUrl === (string)$request->getUri()) { + return new HtmlResponse( + 'The error page could not be resolved, as the error page itself is not accessible', + $this->statusCode + ); + } + // External URL most likely pointing to additional hosts or pages not contained in the current instance, + // and using internal sub requests would never receive a valid page. Send an external request instead. + if ($urlType === LinkService::TYPE_URL) { + return $this->sendExternalRequest($resolvedUrl, $request); + } + // Create a sub-request and do not take any special query parameters into account + $subRequest = $request->withQueryParams([])->withUri(new Uri($resolvedUrl))->withMethod('GET'); + $subResponse = $this->sendSubRequest($subRequest, $urlParams['pageuid'], $request); + + if ($subResponse->getStatusCode() >= 300) { + throw new \RuntimeException(sprintf('Error handler could not fetch error page "%s", status code: %s', $resolvedUrl, $subResponse->getStatusCode()), 1544172839); + } + + $response = $this->responseFactory->createResponse($this->statusCode) + ->withHeader('content-type', $subResponse->getHeader('content-type')) + ->withBody($subResponse->getBody()); + + foreach (['Content-Security-Policy', 'Content-Security-Policy-Report-Only'] as $header) { + if ($subResponse->hasHeader($header)) { + $response = $response->withHeader($header, $subResponse->getHeader($header)); + } + } + return $response; + } catch (InvalidRouteArgumentsException|SiteNotFoundException $e) { + return new HtmlResponse('Invalid error handler configuration: ' . $this->errorHandlerConfiguration['errorContentSource']); + } + } + + /** + * Sends an in-process subrequest. + * + * The $pageId is used to ensure the correct site is accessed. + */ + protected function sendSubRequest(ServerRequestInterface $request, int $pageId, ServerRequestInterface $originalRequest): ResponseInterface + { + $site = $request->getAttribute('site'); + if (!$site instanceof Site) { + $site = $this->siteFinder->getSiteByPageId($pageId); + $request = $request->withAttribute('site', $site); + } + + $request = $request->withAttribute('originalRequest', $originalRequest); + + return $this->application->handle($request); + } + + /** + * Sends an external request to fetch the error page from a remote resource. + * + * A custom header is added and checked to mitigate request loops, which + * indicates additional configuration error in the error handler config. + */ + protected function sendExternalRequest(string $url, ServerRequestInterface $originalRequest): ResponseInterface + { + if ($originalRequest->hasHeader('Requested-By') + && in_array('TYPO3 Error Handler', $originalRequest->getHeader('Requested-By'), true) + ) { + // If the header is set here, it is a recursive call within the same instance where an + // outer error handler called a page that results in another error handler call. To break + // the loop, we except here. + return new HtmlResponse( + 'The error page could not be resolved, the error page itself is not accessible', + $this->statusCode + ); + } + try { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Content-Type', 'text/html') + ->withHeader('Requested-By', 'TYPO3 Error Handler'); + $response = $this->guzzleClientFactory->getClient()->send($request); + // In case global guzzle configuration has been changed to not throw an exception + // for error status codes, the response status code is checked here. + if ($response->getStatusCode() >= 300) { + return new HtmlResponse( + 'The error page could not be resolved, as the error page itself is not accessible', + $this->statusCode + ); + } + return $this->responseFactory + ->createResponse($this->statusCode) + ->withHeader('Content-Type', $response->getHeader('Content-Type')) + ->withBody($response->getBody()); + } catch (GuzzleException) { + return new HtmlResponse( + 'The error page could not be resolved, the error page itself is not accessible', + $this->statusCode + ); + } + } + + /** + * Resolve the URL (currently only page and external URL are supported) + */ + protected function resolveUrl(ServerRequestInterface $request, array $urlParams): string + { + if (!in_array($urlParams['type'], ['page', 'url'])) { + throw new \InvalidArgumentException('PageContentErrorHandler can only handle TYPO3 URLs of types "page" or "url"', 1522826609); + } + if ($urlParams['type'] === 'url') { + return $urlParams['url']; + } + + // Get the site related to the configured error page + $site = $this->siteFinder->getSiteByPageId($urlParams['pageuid']); + $requestLanguage = $request->getAttribute('language'); + // Try to get the current request language from the site that was found above + if ($requestLanguage instanceof SiteLanguage && $requestLanguage->isEnabled()) { + try { + $language = $site->getLanguageById($requestLanguage->getLanguageId()); + } catch (\InvalidArgumentException $e) { + $language = $site->getDefaultLanguage(); + } + } else { + $language = $site->getDefaultLanguage(); + } + + // Requested language or default language is disabled in current site => Fetch first "enabled" language + if (!$language->isEnabled()) { + $enabledLanguages = $site->getLanguages(); + if ($enabledLanguages === []) { + throw new \RuntimeException( + 'Site ' . $site->getIdentifier() . ' does not define any enabled language.', + 1674487171 + ); + } + $language = reset($enabledLanguages); + } + + // Build Url + $uri = $site->getRouter()->generateUri( + (int)$urlParams['pageuid'], + ['_language' => $language] + ); + + // Fallback to the current URL if the site is not having a proper scheme and host + $currentUri = $request->getUri(); + if (empty($uri->getScheme())) { + $uri = $uri->withScheme($currentUri->getScheme()); + } + if (empty($uri->getUserInfo())) { + $uri = $uri->withUserInfo($currentUri->getUserInfo()); + } + if (empty($uri->getHost())) { + $uri = $uri->withHost($currentUri->getHost()); + } + if ($uri->getPort() === null) { + $uri = $uri->withPort($currentUri->getPort()); + } + + return (string)$uri; + } +} diff --git a/Classes/Error/PageErrorHandler/PageErrorHandlerInterface.php b/Classes/Error/PageErrorHandler/PageErrorHandlerInterface.php new file mode 100644 index 0000000..e9dd1e8 --- /dev/null +++ b/Classes/Error/PageErrorHandler/PageErrorHandlerInterface.php @@ -0,0 +1,34 @@ + $reasons + */ + public function handlePageError(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface; +} diff --git a/Classes/Error/PageErrorHandler/PageErrorHandlerNotConfiguredException.php b/Classes/Error/PageErrorHandler/PageErrorHandlerNotConfiguredException.php new file mode 100644 index 0000000..ae2fda7 --- /dev/null +++ b/Classes/Error/PageErrorHandler/PageErrorHandlerNotConfiguredException.php @@ -0,0 +1,26 @@ +statusCode = $statusCode; + $this->context = GeneralUtility::makeInstance(Context::class); + $this->linkService = GeneralUtility::makeInstance(LinkService::class); + + $urlParams = $this->linkService->resolve($configuration['loginRedirectTarget'] ?? ''); + $this->loginRedirectPid = (int)($urlParams['pageuid'] ?? 0); + $this->loginRedirectParameter = $configuration['loginRedirectParameter'] ?? 'return_url'; + } + + public function handlePageError( + ServerRequestInterface $request, + string $message, + array $reasons = [] + ): ResponseInterface { + $this->checkHandlerConfiguration(); + + if ($this->shouldHandleRequest($reasons)) { + return $this->handleLoginRedirect($request); + } + + // Show general error message with a 403 HTTP statuscode + return $this->getGenericAccessDeniedResponse($message); + } + + private function getGenericAccessDeniedResponse(string $reason): ResponseInterface + { + $content = GeneralUtility::makeInstance(ErrorPageController::class)->errorAction( + 'Page Not Found', + 'The page did not exist or was inaccessible.' . ($reason ? ' Reason: ' . $reason : ''), + 0, + $this->statusCode, + ); + return new HtmlResponse($content, $this->statusCode); + } + + private function handleLoginRedirect(ServerRequestInterface $request): ResponseInterface + { + if ($this->isLoggedIn()) { + return $this->getGenericAccessDeniedResponse( + 'The requested page was not accessible with the provided credentials' + ); + } + + /** @var Site $site */ + $site = $request->getAttribute('site'); + $language = $request->getAttribute('language'); + + $loginUrl = $site->getRouter()->generateUri( + $this->loginRedirectPid, + [ + '_language' => $language, + $this->loginRedirectParameter => (string)$request->getUri(), + ] + ); + + return new RedirectResponse($loginUrl); + } + + private function shouldHandleRequest(array $reasons): bool + { + if (!isset($reasons['code'])) { + return false; + } + + $accessDeniedReasons = [ + PageAccessFailureReasons::ACCESS_DENIED_PAGE_NOT_RESOLVED, + PageAccessFailureReasons::ACCESS_DENIED_SUBSECTION_NOT_RESOLVED, + ]; + $isAccessDenied = in_array($reasons['code'], $accessDeniedReasons, true); + + return $isAccessDenied || $this->isSimulatedBackendGroup(); + } + + private function isLoggedIn(): bool + { + return $this->context->getPropertyFromAspect('frontend.user', 'isLoggedIn') || $this->isSimulatedBackendGroup(); + } + + protected function isSimulatedBackendGroup(): bool + { + // look for special "any group" + return $this->context->getPropertyFromAspect('backend.user', 'isLoggedIn') + && $this->context->getPropertyFromAspect('frontend.user', 'groupIds')[1] === -2; + } + + private function checkHandlerConfiguration(): void + { + if ($this->loginRedirectPid === 0) { + throw new \RuntimeException('No loginRedirectTarget configured for LoginRedirect errorhandler', 1700813537); + } + + if ($this->statusCode !== 403) { + throw new \RuntimeException('Invalid HTTP statuscode ' . $this->statusCode . ' for LoginRedirect errorhandler', 1700813545); + } + } +} diff --git a/Classes/Error/ProductionExceptionHandler.php b/Classes/Error/ProductionExceptionHandler.php new file mode 100644 index 0000000..cb042a8 --- /dev/null +++ b/Classes/Error/ProductionExceptionHandler.php @@ -0,0 +1,139 @@ +handleException(...)); + } + + /** + * Echoes an exception for the web. + * + * @param \Throwable $exception The throwable object. + */ + public function echoExceptionWeb(\Throwable $exception) + { + $this->sendStatusHeaders($exception); + $this->writeLogEntries($exception, self::CONTEXT_WEB); + echo GeneralUtility::makeInstance(ErrorPageController::class)->errorAction( + $this->getTitle($exception), + $this->getMessage($exception), + $this->discloseExceptionInformation($exception) ? $exception->getCode() : 0, + $this->getHttpStatusCodeFromException($exception) + ); + } + + /** + * Echoes an exception for the command line. + * + * @param \Throwable $exception The throwable object. + */ + public function echoExceptionCLI(\Throwable $exception) + { + $filePathAndName = $exception->getFile(); + $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : ''; + $this->writeLogEntries($exception, self::CONTEXT_CLI); + echo LF . 'Uncaught TYPO3 Exception ' . $exceptionCodeNumber . $exception->getMessage() . LF; + echo 'thrown in file ' . $filePathAndName . LF; + echo 'in line ' . $exception->getLine() . LF . LF; + die(1); + } + + /** + * Determines, whether Exception details should be outputted + * + * @param \Throwable $exception The throwable object. + * @return bool + */ + protected function discloseExceptionInformation(\Throwable $exception) + { + // Allow message to be shown in production mode if the exception is about + // trusted host configuration. By doing so we do not disclose + // any valuable information to an attacker but avoid confusions among TYPO3 admins + // in production context. + if ($exception->getCode() === 1396795884) { + return true; + } + // Show client error messages 40x in every case + if ($exception instanceof AbstractClientErrorException) { + return true; + } + // Only show errors if a BE user is authenticated + $backendUser = $this->getBackendUser(); + if ($backendUser === null) { + return false; + } + return ($backendUser->user['uid'] ?? 0) > 0; + } + + /** + * Returns the title for the error message + * + * @param \Throwable $exception The throwable object. + * @return string + */ + protected function getTitle(\Throwable $exception) + { + if ($this->discloseExceptionInformation($exception) && $exception instanceof StatusException && $exception->getTitle() !== '') { + return $exception->getTitle(); + } + return $this->defaultTitle; + } + + /** + * Returns the message for the error message + * + * @param \Throwable $exception The throwable object. + * @return string + */ + protected function getMessage(\Throwable $exception) + { + if ($this->discloseExceptionInformation($exception)) { + return $exception->getMessage(); + } + return $this->defaultMessage; + } +} diff --git a/Classes/EventDispatcher/EventDispatcher.php b/Classes/EventDispatcher/EventDispatcher.php new file mode 100644 index 0000000..d14d8a7 --- /dev/null +++ b/Classes/EventDispatcher/EventDispatcher.php @@ -0,0 +1,56 @@ +isPropagationStopped()) { + return $event; + } + foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) { + $listener($event); + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { + break; + } + } + return $event; + } +} diff --git a/Classes/EventDispatcher/ListenerProvider.php b/Classes/EventDispatcher/ListenerProvider.php new file mode 100644 index 0000000..f70b295 --- /dev/null +++ b/Classes/EventDispatcher/ListenerProvider.php @@ -0,0 +1,109 @@ +container = $container; + } + + /** + * Not part of the public API, used in the generated service factor for this class, + * + * @internal + */ + public function addListener(string $event, string $service, ?string $method = null, ?string $identifier = null): void + { + $this->listeners[$event][$identifier ?? $service] = [ + 'service' => $service, + 'method' => $method, + ]; + } + + /** + * Not part of the public API, only used for debugging purposes + * + * @internal + */ + public function getAllListenerDefinitions(): array + { + return $this->listeners; + } + + public function getListenersForEvent(object $event): iterable + { + $eventClasses = [get_class($event)]; + $classParents = class_parents($event); + $classInterfaces = class_implements($event); + if (!empty($classParents)) { + array_push($eventClasses, ...array_values($classParents)); + } + if (!empty($classInterfaces)) { + array_push($eventClasses, ...array_values($classInterfaces)); + } + foreach ($eventClasses as $className) { + if (isset($this->listeners[$className])) { + foreach ($this->listeners[$className] as $listener) { + yield $this->getCallable($listener['service'], $listener['method']); + } + } + } + } + + /** + * @throws \InvalidArgumentException + */ + protected function getCallable(string $service, ?string $method = null): callable + { + $target = $this->container->get($service); + if ($method !== null) { + // Dispatch to configured method name instead of __invoke() + $target = [ $target, $method ]; + } + + if (!is_callable($target)) { + throw new \InvalidArgumentException( + sprintf('Event listener "%s%s%s" is not callable"', $service, ($method !== null ? '::' : ''), $method), + 1549988537 + ); + } + + return $target; + } +} diff --git a/Classes/EventDispatcher/NoopEventDispatcher.php b/Classes/EventDispatcher/NoopEventDispatcher.php new file mode 100644 index 0000000..2fd1a6d --- /dev/null +++ b/Classes/EventDispatcher/NoopEventDispatcher.php @@ -0,0 +1,34 @@ +> + */ + protected array $expressionLanguageProviders = []; + + /** + * @var array + */ + protected array $expressionLanguageVariables = []; + + public function getExpressionLanguageProviders(): array + { + return $this->expressionLanguageProviders; + } + + public function getExpressionLanguageVariables(): array + { + return $this->expressionLanguageVariables; + } +} diff --git a/Classes/ExpressionLanguage/DefaultProvider.php b/Classes/ExpressionLanguage/DefaultProvider.php new file mode 100644 index 0000000..abe0a9c --- /dev/null +++ b/Classes/ExpressionLanguage/DefaultProvider.php @@ -0,0 +1,53 @@ +version = $typo3Version->getVersion(); + $typo3->branch = $typo3Version->getBranch(); + $typo3->devIpMask = trim($GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask'] ?? ''); + $this->expressionLanguageVariables = [ + 'applicationContext' => (string)Environment::getContext(), + 'typo3' => $typo3, + 'date' => $context->getAspect('date'), + 'features' => $features, + ]; + $this->expressionLanguageProviders[] = DefaultFunctionsProvider::class; + } +} diff --git a/Classes/ExpressionLanguage/FunctionsProvider/DefaultFunctionsProvider.php b/Classes/ExpressionLanguage/FunctionsProvider/DefaultFunctionsProvider.php new file mode 100644 index 0000000..fae5b01 --- /dev/null +++ b/Classes/ExpressionLanguage/FunctionsProvider/DefaultFunctionsProvider.php @@ -0,0 +1,146 @@ +getIpFunction(), + $this->getCompatVersionFunction(), + $this->getLikeFunction(), + $this->getEnvFunction(), + $this->getDateFunction(), + $this->getFeatureToggleFunction(), + $this->getTraverseArrayFunction(), + ]; + } + + protected function getIpFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'ip', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $str) { + if ($str === 'devIP') { + $str = $arguments['typo3']->devIpMask; + } + $request = $arguments['request'] ?? null; + if (!$request instanceof RequestWrapper) { + throw new \RuntimeException( + 'Using expression language function "ip(' . $str . ')" in a context without request.', + 1686745105 + ); + } + $normalizedParams = $request->getNormalizedParams(); + if ($normalizedParams === null) { + return false; + } + return GeneralUtility::cmpIP($normalizedParams->getRemoteAddress(), $str); + } + ); + } + + protected function getCompatVersionFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'compatVersion', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, mixed $str) { + return VersionNumberUtility::convertVersionNumberToInteger($arguments['typo3']->branch) + >= VersionNumberUtility::convertVersionNumberToInteger((string)$str); + } + ); + } + + protected function getLikeFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'like', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $haystack, $needle) { + return StringUtility::searchStringWildcard((string)$haystack, (string)$needle); + } + ); + } + + protected function getEnvFunction(): ExpressionFunction + { + return ExpressionFunction::fromPhp('getenv'); + } + + protected function getDateFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'date', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $format) { + return $arguments['date']->getDateTime()->format($format); + } + ); + } + + protected function getFeatureToggleFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'feature', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $featureName) { + return $arguments['features']->isFeatureEnabled($featureName); + } + ); + } + + protected function getTraverseArrayFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'traverse', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $array, $path) { + if (!is_array($array) || !is_string($path) || $path === '') { + return ''; + } + try { + return ArrayUtility::getValueByPath($array, $path); + } catch (MissingArrayPathException) { + return ''; + } + } + ); + } +} diff --git a/Classes/ExpressionLanguage/FunctionsProvider/Typo3ConditionFunctionsProvider.php b/Classes/ExpressionLanguage/FunctionsProvider/Typo3ConditionFunctionsProvider.php new file mode 100644 index 0000000..29dcf7a --- /dev/null +++ b/Classes/ExpressionLanguage/FunctionsProvider/Typo3ConditionFunctionsProvider.php @@ -0,0 +1,123 @@ +getSessionFunction(), + $this->getSiteFunction(), + $this->getSiteLanguageFunction(), + $this->getLocaleFunction(), + ]; + } + + protected function getSessionFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'session', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $str) { + $retVal = null; + $keyParts = explode('|', $str); + $sessionKey = array_shift($keyParts); + $frontendUser = $arguments['request']->getFrontendUser(); + if ($frontendUser) { + $retVal = $frontendUser->getSessionData($sessionKey); + foreach ($keyParts as $keyPart) { + if (is_object($retVal)) { + $retVal = $retVal->{$keyPart}; + } elseif (is_array($retVal)) { + $retVal = $retVal[$keyPart]; + } else { + break; + } + } + } + return $retVal; + } + ); + } + + protected function getSiteFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'site', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $str) { + $site = $arguments['site'] ?? null; + if ($site instanceof SiteInterface) { + $methodName = 'get' . ucfirst(trim($str)); + if (method_exists($site, $methodName)) { + return $site->$methodName(); + } + } + return null; + } + ); + } + + protected function getSiteLanguageFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'siteLanguage', + static fn() => null, // Not implemented, we only use the evaluator + static function ($arguments, $str) { + $siteLanguage = $arguments['siteLanguage'] ?? null; + if ($siteLanguage instanceof SiteLanguage) { + $methodName = 'get' . ucfirst(trim($str)); + if (method_exists($siteLanguage, $methodName)) { + return $siteLanguage->$methodName(); + } + } + return null; + } + ); + } + + protected function getLocaleFunction(): ExpressionFunction + { + return new ExpressionFunction( + 'locale', + static fn() => null, // Not implemented, we only use the evaluator + static function (array $arguments) { + $siteLanguage = $arguments['siteLanguage'] ?? null; + if ($siteLanguage instanceof SiteLanguage) { + return $siteLanguage->getLocale(); + } + return null; + } + ); + } +} diff --git a/Classes/ExpressionLanguage/ProviderConfigurationLoader.php b/Classes/ExpressionLanguage/ProviderConfigurationLoader.php new file mode 100644 index 0000000..06e89a8 --- /dev/null +++ b/Classes/ExpressionLanguage/ProviderConfigurationLoader.php @@ -0,0 +1,79 @@ +coreCache->require($this->cacheIdentifier); + if ($providers !== false) { + return $providers; + } + + return $this->createCache(); + } + + private function createCache(): array + { + $packages = $this->packageManager->getActivePackages(); + $providers = []; + foreach ($packages as $package) { + $packageConfiguration = $package->getPackagePath() . 'Configuration/ExpressionLanguage.php'; + if (file_exists($packageConfiguration)) { + $providersInPackage = require $packageConfiguration; + if (is_array($providersInPackage)) { + $providers[] = $providersInPackage; + } + } + } + $providers = count($providers) > 0 ? array_merge_recursive(...$providers) : $providers; + $this->coreCache->set($this->cacheIdentifier, 'return ' . var_export($providers, true) . ';'); + return $providers; + } + + /** + * @internal + */ + #[AsEventListener] + public function warmupCaches(CacheWarmupEvent $event): void + { + if ($event->hasGroup('system')) { + $this->createCache(); + } + } +} diff --git a/Classes/ExpressionLanguage/ProviderInterface.php b/Classes/ExpressionLanguage/ProviderInterface.php new file mode 100644 index 0000000..4059b67 --- /dev/null +++ b/Classes/ExpressionLanguage/ProviderInterface.php @@ -0,0 +1,37 @@ +> + */ + public function getExpressionLanguageProviders(): array; + + /** + * An array with key/value pairs. The key will be available as variable name + * + * @return array + */ + public function getExpressionLanguageVariables(): array; +} diff --git a/Classes/ExpressionLanguage/RequestWrapper.php b/Classes/ExpressionLanguage/RequestWrapper.php new file mode 100644 index 0000000..8ba7681 --- /dev/null +++ b/Classes/ExpressionLanguage/RequestWrapper.php @@ -0,0 +1,97 @@ +request = $request ?? new ServerRequest(); + } + + public function getQueryParams(): array + { + return $this->request->getQueryParams(); + } + + public function getParsedBody(): array + { + return (array)($this->request->getParsedBody() ?? []); + } + + public function getHeaders(): array + { + return $this->request->getHeaders(); + } + + public function getCookieParams(): array + { + return $this->request->getCookieParams(); + } + + /** + * @todo: Could be removed since 'site' variable is provided explicitly. + */ + public function getSite(): ?SiteInterface + { + return $this->request->getAttribute('site'); + } + + /** + * @todo: Could be removed since 'siteLanguage' variable is provided explicitly. + */ + public function getSiteLanguage(): ?SiteLanguage + { + return $this->request->getAttribute('language'); + } + + public function getNormalizedParams(): ?NormalizedParams + { + return $this->request->getAttribute('normalizedParams'); + } + + public function getPageArguments(): ?PageArguments + { + return ($routing = $this->request->getAttribute('routing')) instanceof PageArguments ? $routing : null; + } + + /** + * @internal Exposing the full FE user object may change + */ + public function getFrontendUser(): ?FrontendUserAuthentication + { + return $this->request->getAttribute('frontend.user'); + } +} diff --git a/Classes/ExpressionLanguage/Resolver.php b/Classes/ExpressionLanguage/Resolver.php new file mode 100644 index 0000000..01d554b --- /dev/null +++ b/Classes/ExpressionLanguage/Resolver.php @@ -0,0 +1,84 @@ +getExpressionLanguageProviders()[$context] ?? []; + // Always add default provider + array_unshift($providers, DefaultProvider::class); + $providers = array_unique($providers); + $functionProviders = []; + $generalVariables = []; + foreach ($providers as $provider) { + /** @var ProviderInterface $providerInstance */ + $providerInstance = GeneralUtility::makeInstance($provider); + $functionProviders[] = $providerInstance->getExpressionLanguageProviders(); + $generalVariables[] = $providerInstance->getExpressionLanguageVariables(); + } + $functionProviders = array_merge(...$functionProviders); + $generalVariables = array_replace_recursive(...$generalVariables); + $this->expressionLanguageVariables = array_replace_recursive($generalVariables, $variables); + foreach ($functionProviders as $functionProvider) { + /** @var ExpressionFunctionProviderInterface[] $functionProviderInstances */ + $functionProviderInstances[] = GeneralUtility::makeInstance($functionProvider); + } + $this->expressionLanguage = new ExpressionLanguage(null, $functionProviderInstances); + } + + /** + * Evaluate an expression. + */ + public function evaluate(string $expression, array $contextVariables = []): mixed + { + return $this->expressionLanguage->evaluate($expression, array_replace($this->expressionLanguageVariables, $contextVariables)); + } + + /** + * Compiles an expression to source code. + * Currently unused in core: We *may* add support for this later to speed up condition parsing? + */ + public function compile(string $condition): string + { + return $this->expressionLanguage->compile($condition, array_keys($this->expressionLanguageVariables)); + } +} diff --git a/Classes/ExpressionLanguage/RoutingConditionProvider.php b/Classes/ExpressionLanguage/RoutingConditionProvider.php new file mode 100644 index 0000000..a8d25ff --- /dev/null +++ b/Classes/ExpressionLanguage/RoutingConditionProvider.php @@ -0,0 +1,34 @@ +expressionLanguageProviders = []; + } +} diff --git a/Classes/ExpressionLanguage/TypoScriptConditionProvider.php b/Classes/ExpressionLanguage/TypoScriptConditionProvider.php new file mode 100644 index 0000000..5f03eea --- /dev/null +++ b/Classes/ExpressionLanguage/TypoScriptConditionProvider.php @@ -0,0 +1,37 @@ +expressionLanguageProviders = [ + Typo3ConditionFunctionsProvider::class, + ]; + } +} diff --git a/Classes/FormProtection/AbstractFormProtection.php b/Classes/FormProtection/AbstractFormProtection.php new file mode 100644 index 0000000..bf0785c --- /dev/null +++ b/Classes/FormProtection/AbstractFormProtection.php @@ -0,0 +1,149 @@ +sessionToken = $this->sessionToken ?? $this->retrieveSessionToken(); + return $this->sessionToken; + } + + /** + * Deletes the session token and persists the (empty) token. + * + * This function is intended to be called when a user logs on or off. + */ + public function clean() + { + $this->sessionToken = ''; + $this->persistSessionToken(); + } + + /** + * Generates a token for a form by hashing the given parameters + * with the secret session token. + * + * Calling this function two times with the same parameters will create + * the same valid token during one user session. + * + * @param string $formName + * @param string $action + * @param string $formInstanceName + * @return string the 32-character hex ID of the generated token + * @throws \InvalidArgumentException + */ + public function generateToken($formName, $action = '', $formInstanceName = '') + { + if ($formName == '') { + throw new \InvalidArgumentException('$formName must not be empty.', 1294586643); + } + $hashService = GeneralUtility::makeInstance(HashService::class); + return $hashService->hmac($formName . $action . $formInstanceName . $this->getSessionToken(), self::class, HashAlgo::SHA3_256); + } + + /** + * Checks whether the token $tokenId is valid in the form $formName with + * $formInstanceName. + * + * @param string $tokenId + * @param string $formName + * @param string $action + * @param string $formInstanceName + * @return bool + */ + public function validateToken($tokenId, $formName, $action = '', $formInstanceName = '') + { + $hashService = GeneralUtility::makeInstance(HashService::class); + $validTokenId = $hashService->hmac(((string)$formName . (string)$action) . (string)$formInstanceName . $this->getSessionToken(), self::class, HashAlgo::SHA3_256); + if (hash_equals($validTokenId, (string)$tokenId)) { + $isValid = true; + } else { + $isValid = false; + } + if (!$isValid) { + $this->createValidationErrorMessage(); + } + return $isValid; + } + + /** + * Generates the random token which is used in the hash for the form tokens. + * + * @return string + */ + protected function generateSessionToken() + { + return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(64); + } + + /** + * Creates or displays an error message telling the user that the submitted + * form token is invalid. + */ + protected function createValidationErrorMessage() + { + if ($this->validationFailedCallback !== null) { + $this->validationFailedCallback->__invoke(); + } + } + + /** + * Retrieves the session token. + * + * @return string + */ + abstract protected function retrieveSessionToken(); + + /** + * Saves the session token so that it can be used by a later incarnation + * of this class. + * + * @internal + */ + abstract public function persistSessionToken(); +} diff --git a/Classes/FormProtection/BackendFormProtection.php b/Classes/FormProtection/BackendFormProtection.php new file mode 100644 index 0000000..495b184 --- /dev/null +++ b/Classes/FormProtection/BackendFormProtection.php @@ -0,0 +1,180 @@ + + * $formToken = GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('backend') + * ->generateToken( + * 'BE user setup', 'edit' + * ); + * $this->content .= ''; + * + * + * The three parameters $formName, $action and $formInstanceName can be + * arbitrary strings, but they should make the form token as specific as + * possible. For different forms (e.g. BE user setup and editing a tt_content + * record) or different records (with different UIDs) from the same table, + * those values should be different. + * + * For editing a tt_content record, the call could look like this: + * + *
+ * $formToken = GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('backend')
+ * ->getFormProtection()->generateToken(
+ * 'tt_content', 'edit', $uid
+ * );
+ * 
+ * + * + * When processing the data that has been submitted by the form, you can check + * that the form token is valid like this: + * + *
+ * if ($dataHasBeenSubmitted && GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('backend')
+ * ->validateToken(
+ * \TYPO3\CMS\Core\Utility\GeneralUtility::_POST('formToken'),
+ * 'BE user setup', 'edit
+ * )
+ * ) {
+ * processes the data
+ * } else {
+ * no need to do anything here as the BE form protection will create a
+ * flash message for an invalid token
+ * }
+ * 
+ */ +class BackendFormProtection extends AbstractFormProtection +{ + /** + * Keeps the instance of the user which existed during creation + * of the object. + * + * @var BackendUserAuthentication + */ + protected $backendUser; + + /** + * Instance of the registry, which is used to permanently persist + * the session token so that it can be restored during re-login. + * + * @var Registry + */ + protected $registry; + + /** + * Only allow construction if we have an authorized backend session + * + * @throws \TYPO3\CMS\Core\Error\Exception + */ + public function __construct(BackendUserAuthentication $backendUser, Registry $registry, ?\Closure $validationFailedCallback = null) + { + $this->backendUser = $backendUser; + $this->registry = $registry; + $this->validationFailedCallback = $validationFailedCallback; + if (!$this->isAuthorizedBackendSession()) { + throw new Exception('A back-end form protection may only be instantiated if there is an active back-end session.', 1285067843); + } + } + + /** + * Retrieves the saved session token or generates a new one. + * + * @return string + */ + protected function retrieveSessionToken() + { + $this->sessionToken = $this->backendUser->getSessionData('formProtectionSessionToken'); + if (empty($this->sessionToken)) { + $this->sessionToken = $this->generateSessionToken(); + $this->persistSessionToken(); + } + return $this->sessionToken; + } + + /** + * Saves the tokens so that they can be used by a later incarnation of this + * class. + * + * @internal + */ + public function persistSessionToken() + { + $this->backendUser->setAndSaveSessionData('formProtectionSessionToken', $this->sessionToken); + } + + /** + * Sets the session token for the user from the registry + * and returns it additionally. + * + * @internal + * @return string + * @throws \UnexpectedValueException + */ + public function setSessionTokenFromRegistry() + { + $this->sessionToken = $this->registry->get('core', 'formProtectionSessionToken:' . $this->backendUser->user['uid']); + if (empty($this->sessionToken)) { + throw new \UnexpectedValueException('Failed to restore the session token from the registry.', 1301827270); + } + return $this->sessionToken; + } + + /** + * Stores the session token in the registry to have it + * available during re-login of the user. + * + * @internal + */ + public function storeSessionTokenInRegistry() + { + $this->registry->set('core', 'formProtectionSessionToken:' . $this->backendUser->user['uid'], $this->getSessionToken()); + } + + /** + * Removes the session token for the user from the registry. + * + * @internal + */ + public function removeSessionTokenFromRegistry() + { + $this->registry->remove('core', 'formProtectionSessionToken:' . $this->backendUser->user['uid']); + } + + /** + * Checks if a user is logged in and the session is active. + * + * @return bool + */ + protected function isAuthorizedBackendSession() + { + return !empty($this->backendUser->user['uid']); + } +} diff --git a/Classes/FormProtection/DisabledFormProtection.php b/Classes/FormProtection/DisabledFormProtection.php new file mode 100644 index 0000000..9deec38 --- /dev/null +++ b/Classes/FormProtection/DisabledFormProtection.php @@ -0,0 +1,64 @@ +getIdentifierForType($type); + if ($this->runtimeCache->has($identifier)) { + return $this->runtimeCache->get($identifier); + } + $classNameAndConstructorArguments = $this->getClassNameAndConstructorArguments($type, $GLOBALS['TYPO3_REQUEST'] ?? null); + $this->runtimeCache->set($identifier, $this->createInstance(...$classNameAndConstructorArguments)); + return $this->runtimeCache->get($identifier); + } + + /** + * Detect the right FormProtection implementation based on the request. + */ + public function createFromRequest(ServerRequestInterface $request): AbstractFormProtection + { + $type = $this->determineTypeFromRequest($request); + $identifier = $this->getIdentifierForType($type); + if ($this->runtimeCache->has($identifier)) { + return $this->runtimeCache->get($identifier); + } + $classNameAndConstructorArguments = $this->getClassNameAndConstructorArguments($type, $request); + $this->runtimeCache->set($identifier, $this->createInstance(...$classNameAndConstructorArguments)); + return $this->runtimeCache->get($identifier); + } + + /** + * Detects the type of FormProtection which should be instantiated, based on the request. + */ + protected function determineTypeFromRequest(ServerRequestInterface $request): string + { + if ($this->isInstallToolSession($request)) { + return 'installtool'; + } + if ($this->isFrontendSession($request)) { + return 'frontend'; + } + if ($this->isBackendSession()) { + return 'backend'; + } + return 'disabled'; + } + + /** + * This is the equivalent to getClassNameAndConstructorArgumentsByType() but non-static. + * It also does not handle "default" or class names, but is based on types previously resolved by + * the request. See determineTypeFromRequest() + * + * @param string $type Valid types: installtool, frontend, backend. + * @return array Array of arguments + */ + protected function getClassNameAndConstructorArguments(string $type, ?ServerRequestInterface $request): array + { + if ($type === 'installtool') { + return [ + InstallToolFormProtection::class, + ]; + } + if ($type === 'frontend') { + $user = $request?->getAttribute('frontend.user'); + if ($user && isset($user->user['uid'])) { + return [ + FrontendFormProtection::class, + $user, + ]; + } + } + if ($type === 'backend') { + $user = $GLOBALS['BE_USER'] ?? null; + $isAjaxCall = (bool)($request ? $request->getAttribute('route')?->getOption('ajax') : false); + if ($user && isset($user->user['uid'])) { + return [ + BackendFormProtection::class, + $user, + $this->container->get(Registry::class), + $this->getMessageClosure( + $this->languageServiceFactory->createFromUserPreferences($user), + $this->flashMessageService->getMessageQueueByIdentifier(), + $isAjaxCall + ), + ]; + } + } + // failed to use preferred type, disable form protection + return [ + DisabledFormProtection::class, + ]; + } + + /** + * Conveniant method to create a deterministic cache identifier. + */ + protected function getIdentifierForType(string $type): string + { + return 'formprotection-instance-' . hash('xxh3', $type); + } + + /** + * Check if we are in the install tool + */ + protected function isInstallToolSession(ServerRequestInterface $request): bool + { + return (bool)((int)$request->getAttribute('applicationType') & SystemEnvironmentBuilder::REQUESTTYPE_INSTALL); + } + + /** + * Checks if a user is logged in and the session is active. + */ + protected function isBackendSession(): bool + { + $user = $GLOBALS['BE_USER'] ?? null; + return $user instanceof BackendUserAuthentication && isset($user->user['uid']); + } + + /** + * Checks if a frontend user is logged in and the session is active. + */ + protected function isFrontendSession(ServerRequestInterface $request): bool + { + $user = $request->getAttribute('frontend.user'); + return $user instanceof FrontendUserAuthentication && isset($user->user['uid']); + } + + protected function getMessageClosure(LanguageService $languageService, FlashMessageQueue $messageQueue, bool $isAjaxCall): \Closure + { + return static function () use ($languageService, $messageQueue, $isAjaxCall) { + $flashMessage = new FlashMessage( + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.formProtection.tokenInvalid'), + '', + ContextualFeedbackSeverity::ERROR, + !$isAjaxCall + ); + $messageQueue->enqueue($flashMessage); + }; + } + + /** + * Creates an instance for the requested class $className + * and stores it internally. + * + * @param class-string $className + * @param array $constructorArguments + * @throws \InvalidArgumentException + */ + protected function createInstance(string $className, ...$constructorArguments): AbstractFormProtection + { + if (!class_exists($className)) { + throw new \InvalidArgumentException('$className must be the name of an existing class, but actually was "' . $className . '".', 1285352962); + } + $instance = GeneralUtility::makeInstance($className, ...$constructorArguments); + if (!$instance instanceof AbstractFormProtection) { + throw new \InvalidArgumentException('$className must be a subclass of ' . AbstractFormProtection::class . ', but actually was "' . $className . '".', 1285353026); + } + return $instance; + } +} diff --git a/Classes/FormProtection/FrontendFormProtection.php b/Classes/FormProtection/FrontendFormProtection.php new file mode 100644 index 0000000..5cafb9c --- /dev/null +++ b/Classes/FormProtection/FrontendFormProtection.php @@ -0,0 +1,131 @@ + + * $formToken = GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('frontend') + * ->generateToken( + * 'User setup', 'edit' + * ); + * $this->content .= ''; + * + * + * The three parameters $formName, $action and $formInstanceName can be + * arbitrary strings, but they should make the form token as specific as + * possible. For different forms (e.g. User setup and editing a news + * record) or different records (with different UIDs) from the same table, + * those values should be different. + * + * For editing a news record, the call could look like this: + * + *
+ * $formToken = GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('frontend')
+ * ->getFormProtection()->generateToken(
+ * 'news', 'edit', $uid
+ * );
+ * 
+ * + * + * When processing the data that has been submitted by the form, you can check + * that the form token is valid like this: + * + *
+ * if ($dataHasBeenSubmitted && GeneralUtility::makeInstance(FormProtectionFactory::class)->createFromType('frontend')
+ * ->validateToken(
+ * \TYPO3\CMS\Core\Utility\GeneralUtility::_POST('formToken'),
+ * 'User setup', 'edit
+ * )
+ * ) {
+ * Processes the data.
+ * } else {
+ * Create a flash message for the invalid token or just discard this request.
+ * }
+ * 
+ */ +class FrontendFormProtection extends AbstractFormProtection +{ + /** + * Keeps the instance of the user which existed during creation + * of the object. + * + * @var FrontendUserAuthentication + */ + protected $frontendUser; + + /** + * Only allow construction if we have an authorized frontend session + * + * @throws \TYPO3\CMS\Core\Error\Exception + */ + public function __construct(FrontendUserAuthentication $frontendUser, ?\Closure $validationFailedCallback = null) + { + $this->frontendUser = $frontendUser; + $this->validationFailedCallback = $validationFailedCallback; + if (!$this->isAuthorizedFrontendSession()) { + throw new Exception('A front-end form protection may only be instantiated if there is an active front-end session.', 1460975777); + } + } + + /** + * Retrieves the saved session token or generates a new one. + * + * @return string + */ + protected function retrieveSessionToken() + { + $this->sessionToken = $this->frontendUser->getSessionData('formProtectionSessionToken'); + if (empty($this->sessionToken)) { + $this->sessionToken = $this->generateSessionToken(); + $this->persistSessionToken(); + } + return $this->sessionToken; + } + + /** + * Saves the tokens so that they can be used by a later incarnation of this + * class. + * + * @internal + */ + public function persistSessionToken() + { + $this->frontendUser->setAndSaveSessionData('formProtectionSessionToken', $this->sessionToken); + } + + /** + * Checks if a user is logged in and the session is active. + * + * @return bool + */ + protected function isAuthorizedFrontendSession() + { + return !empty($this->frontendUser->user['uid']); + } +} diff --git a/Classes/FormProtection/InstallToolFormProtection.php b/Classes/FormProtection/InstallToolFormProtection.php new file mode 100644 index 0000000..f94f9b0 --- /dev/null +++ b/Classes/FormProtection/InstallToolFormProtection.php @@ -0,0 +1,84 @@ + + * $formToken = $this->formProtection->generateToken( + * 'installToolPassword', 'change' + * ); + * then puts the generated form token in a hidden field in the template + * + * + * The three parameters $formName, $action and $formInstanceName can be + * arbitrary strings, but they should make the form token as specific as + * possible. For different forms (e.g. the password change and editing a the + * configuration), those values should be different. + * + * When processing the data that has been submitted by the form, you can check + * that the form token is valid like this: + * + *
+ * if ($dataHasBeenSubmitted && $this->formProtection()->validateToken(
+ * $_POST['formToken'],
+ * 'installToolPassword',
+ * 'change'
+ * ) {
+ * processes the data
+ * } else {
+ * no need to do anything here as the install tool form protection will
+ * create an error message for an invalid token
+ * }
+ * 
+ */ +/** + * Install Tool form protection + */ +class InstallToolFormProtection extends AbstractFormProtection +{ + /** + * Retrieves or generates the session token. + */ + protected function retrieveSessionToken(): string + { + if (isset($_SESSION['installToolFormToken']) && !empty($_SESSION['installToolFormToken'])) { + $this->sessionToken = $_SESSION['installToolFormToken']; + } else { + $this->sessionToken = $this->generateSessionToken(); + $this->persistSessionToken(); + } + return $this->sessionToken; + } + + /** + * Saves the tokens so that they can be used by a later incarnation of this + * class. + */ + public function persistSessionToken() + { + $_SESSION['installToolFormToken'] = $this->sessionToken; + } +} diff --git a/Classes/Hooks/BackendUserPasswordCheck.php b/Classes/Hooks/BackendUserPasswordCheck.php new file mode 100644 index 0000000..f690601 --- /dev/null +++ b/Classes/Hooks/BackendUserPasswordCheck.php @@ -0,0 +1,69 @@ +random = GeneralUtility::makeInstance(Random::class); + } + + /** + * @param array $incomingFieldArray + * @param string $table + * @param string $id + */ + public function processDatamap_preProcessFieldArray(&$incomingFieldArray, $table, $id, DataHandler $dataHandler) + { + // Not within be_users + if ($table !== 'be_users') { + return; + } + // Existing record, nothing to change + if (MathUtility::canBeInterpretedAsInteger($id)) { + return; + } + if ($dataHandler->isImporting) { + return; + } + if (!isset($incomingFieldArray['password']) || (string)$incomingFieldArray['password'] === '') { + $incomingFieldArray['password'] = $this->random->generateRandomPassword([ + 'lowerCaseCharacters' => true, + 'upperCaseCharacters' => true, + 'digitCharacters' => true, + 'specialCharacters' => true, + ]); + } + if (!isset($incomingFieldArray['username']) || (string)$incomingFieldArray['username'] === '') { + $incomingFieldArray['username'] = 'autogenerated-' . md5($id); + } + } +} diff --git a/Classes/Hooks/CreateSiteConfiguration.php b/Classes/Hooks/CreateSiteConfiguration.php new file mode 100644 index 0000000..40d823c --- /dev/null +++ b/Classes/Hooks/CreateSiteConfiguration.php @@ -0,0 +1,157 @@ +BE_USER->workspace > 0 + || !isset($dataHandler->substNEWwithIDs[$id]) + || (int)($fieldValues['l10n_parent'] ?? 0) !== 0 + || ((int)$fieldValues['pid'] !== 0 && !($fieldValues['is_siteroot'] ?? false)) + || (isset($fieldValues['t3ver_oid']) && (int)$fieldValues['t3ver_oid'] > 0) + || !in_array((int)$fieldValues['doktype'], $this->allowedPageTypes, true) + || $dataHandler->isImporting + ) { + return; + } + + $uid = (int)$dataHandler->substNEWwithIDs[$id]; + $this->generateSiteConfigurationForRootPage($uid, $dataHandler->BE_USER); + } + + protected function generateSiteConfigurationForRootPage(int $pageId, BackendUserAuthentication $backendUser): void + { + $entryPoint = 'autogenerated-' . $pageId; + $siteIdentifier = $entryPoint . '-' . md5((string)$pageId); + + if (!$this->siteExistsByRootPageId($pageId)) { + $siteWriter = GeneralUtility::makeInstance(SiteWriter::class); + $normalizedParams = $this->getNormalizedParams(); + $basePrefix = Environment::isCli() ? $normalizedParams->getSitePath() : $normalizedParams->getSiteUrl(); + try { + $siteWriter->createNewBasicSite( + $siteIdentifier, + $pageId, + $basePrefix . $entryPoint + ); + $backendUser->writelog(Type::SITE, SiteAction::CREATE, SystemLogErrorClassification::MESSAGE, null, 'Site configuration \'%s\' was automatically created for new root page (%s).', [$siteIdentifier, $pageId], 'site'); + $this->updateSlugForPage($pageId); + } catch (SiteConfigurationWriteException $e) { + $flashMessage = new FlashMessage($e->getMessage(), '', ContextualFeedbackSeverity::WARNING, true); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + } + } + + protected function getNormalizedParams(): NormalizedParams + { + $normalizedParams = null; + $serverParams = Environment::isCli() ? ['HTTP_HOST' => 'localhost'] : $_SERVER; + if (isset($GLOBALS['TYPO3_REQUEST'])) { + $normalizedParams = $GLOBALS['TYPO3_REQUEST']->getAttribute('normalizedParams'); + $serverParams = $GLOBALS['TYPO3_REQUEST']->getServerParams(); + } + + if (!$normalizedParams instanceof NormalizedParams) { + $normalizedParams = NormalizedParams::createFromServerParams($serverParams); + } + + return $normalizedParams; + } + + /** + * Updates the slug of the given pageId by spinning up a new DataHandler instance. + */ + protected function updateSlugForPage(int $pageId): void + { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataMap = [ + 'pages' => [ + $pageId => [ + 'slug' => '', + ], + ], + ]; + $dataHandler->start($dataMap, []); + $dataHandler->process_datamap(); + } + + /** + * Checks whether a site exists by its root page. Sets up a new SiteFinder instance + * + * @param int $rootPageId the page ID (default language) + */ + protected function siteExistsByRootPageId(int $rootPageId): bool + { + try { + GeneralUtility::makeInstance(SiteFinder::class)->getSiteByRootPageId($rootPageId); + } catch (SiteNotFoundException $e) { + return false; + } + return true; + } +} diff --git a/Classes/Hooks/DestroySessionHook.php b/Classes/Hooks/DestroySessionHook.php new file mode 100644 index 0000000..36a0ad9 --- /dev/null +++ b/Classes/Hooks/DestroySessionHook.php @@ -0,0 +1,53 @@ +getSessionBackend('BE'); + $sessionManager->invalidateAllSessionsByUserId($backend, (int)$id, $GLOBALS['BE_USER']); + } + if ($table === 'fe_users') { + // Destroy any FE user sessions for the given user + $backend = $sessionManager->getSessionBackend('FE'); + $sessionManager->invalidateAllSessionsByUserId($backend, (int)$id); + } + } +} diff --git a/Classes/Hooks/PagesTsConfigGuard.php b/Classes/Hooks/PagesTsConfigGuard.php new file mode 100644 index 0000000..ea38a90 --- /dev/null +++ b/Classes/Hooks/PagesTsConfigGuard.php @@ -0,0 +1,38 @@ +BE_USER->isAdmin()) { + unset($incomingFieldArray['TSconfig']); + unset($incomingFieldArray['tsconfig_includes']); + } + } +} diff --git a/Classes/Hooks/SystemMaintainerAllowanceCheck.php b/Classes/Hooks/SystemMaintainerAllowanceCheck.php new file mode 100644 index 0000000..ec10714 --- /dev/null +++ b/Classes/Hooks/SystemMaintainerAllowanceCheck.php @@ -0,0 +1,53 @@ +BE_USER->isSystemMaintainer(); + $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true); + if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList) { + $fieldArray = []; + $dataHandler->log( + $table, + (int)$id, + SystemLogDatabaseAction::UPDATE, + null, + SystemLogErrorClassification::SECURITY_NOTICE, + 'Only system maintainers can change details of other system maintainers. The values have not been updated.' + ); + } + } +} diff --git a/Classes/Hooks/TcaDisplayConditions.php b/Classes/Hooks/TcaDisplayConditions.php new file mode 100644 index 0000000..b2d2cca --- /dev/null +++ b/Classes/Hooks/TcaDisplayConditions.php @@ -0,0 +1,60 @@ +getBackendUser(); + $isCurrentUser = (int)($parameters['record']['uid'] ?? 0) === (int)$backendUser->getUserId(); + return strtolower($parameters['conditionParameters'][0] ?? 'true') !== 'true' ? !$isCurrentUser : $isCurrentUser; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/Hooks/TcaItemsProcessorFunctions.php b/Classes/Hooks/TcaItemsProcessorFunctions.php new file mode 100644 index 0000000..6e8a2c2 --- /dev/null +++ b/Classes/Hooks/TcaItemsProcessorFunctions.php @@ -0,0 +1,466 @@ +tcaSchemaFactory->all() as $tableName => $schema) { + // Hide "admin only" tables + if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)) { + continue; + } + $icon = $this->iconFactory->mapRecordTypeToIconIdentifier($tableName, [], $this->tcaSchemaFactory->get($tableName)); + $fieldDefinition['items'][] = ['label' => $schema->getTitle(), 'value' => $tableName, 'icon' => $icon]; + } + } + + public function populateAvailablePageTypes(array &$fieldDefinition): void + { + foreach ($this->pageDoktypeRegistry->getAllDoktypes() as $pageType) { + if (!$pageType->getValue()) { + continue; + } + $icon = $this->iconFactory->mapRecordTypeToIconIdentifier('pages', ['doktype' => $pageType->getValue()], $this->tcaSchemaFactory->get('pages')); + $fieldDefinition['items'][] = ['label' => $pageType->getLabel(), 'value' => $pageType->getValue(), 'icon' => $icon]; + } + } + + public function populateAvailableUserModules(array &$fieldDefinition): void + { + $modules = $this->moduleProvider->getUserModules(); + if ($modules === []) { + return; + } + $languageService = $this->getLanguageService(); + foreach ($modules as $identifier => $module) { + // Item configuration + $label = $languageService->sL($module->getTitle()); + $parentModule = $module->getParentModule(); + while ($parentModule) { + $label = $languageService->sL($parentModule->getTitle()) . ' > ' . $label; + $parentModule = $parentModule->getParentModule(); + } + $help = null; + if ($module->getDescription()) { + $help = [ + 'title' => $languageService->sL($module->getShortDescription()), + 'description' => $languageService->sL($module->getDescription()), + ]; + } + $fieldDefinition['items'][] = [ + 'label' => $label, + 'value' => $identifier, + 'icon' => $module->getIconIdentifier(), + 'description' => $help, + ]; + } + } + + public function populateExcludeFields(array &$fieldDefinition): void + { + $languageService = $this->getLanguageService(); + foreach ($this->getGroupedExcludeFields() as $excludeFieldGroup) { + $table = $excludeFieldGroup['table'] ?? ''; + $origin = $excludeFieldGroup['origin'] ?? ''; + $schema = $this->tcaSchemaFactory->get($table); + // If the field comes from a FlexForm, the syntax is more complex + if ($origin === 'flexForm') { + // The field comes from a plugins FlexForm + // Add header if not yet set for plugin section + $sectionHeader = $excludeFieldGroup['sectionHeader'] ?? ''; + if (!isset($fieldDefinition['items'][$sectionHeader])) { + // there is no icon handling for plugins - we take the icon from the table + $icon = $this->iconFactory->mapRecordTypeToIconIdentifier($table, [], $this->tcaSchemaFactory->get($table)); + $fieldDefinition['items'][$sectionHeader] = ['label' => $sectionHeader, 'value' => '--div--', 'icon' => $icon]; + } + } elseif (!isset($fieldDefinition['items'][$table])) { + // Add header if not yet set for table + $icon = $this->iconFactory->mapRecordTypeToIconIdentifier($table, [], $this->tcaSchemaFactory->get($table)); + $fieldDefinition['items'][$table] = ['label' => $schema->getTitle(), 'value' => '--div--', 'icon' => $icon]; + } + $fullField = $excludeFieldGroup['fullField'] ?? ''; + $fieldName = $excludeFieldGroup['fieldName'] ?? ''; + $label = $origin === 'flexForm' + ? ($excludeFieldGroup['fieldLabel'] ?? '') + : $languageService->sL($schema->getField($fieldName)->getLabel()); + // Item configuration: + $fieldDefinition['items'][] = [ + 'label' => rtrim($label, ':') . ' (' . $fieldName . ')', + 'value' => $table . ':' . $fullField, + 'icon' => 'empty-empty', + ]; + } + } + + public function populateExplicitAuthValues(array &$fieldDefinition): void + { + // Traverse grouped field values: + foreach ($this->getGroupedExplicitAuthFieldValues() as $groupKey => $tableFields) { + if (empty($tableFields['items']) || !is_array($tableFields['items'])) { + continue; + } + // Add header: + $fieldDefinition['items'][] = [ + 'label' => $tableFields['tableFieldLabel'] ?? '', + 'value' => '--div--', + ]; + // Traverse options for this field: + foreach ($tableFields['items'] as $itemValue => $itemContent) { + $fieldDefinition['items'][] = [ + 'label' => $itemContent, + 'value' => $groupKey . ':' . preg_replace('/[:|,]/', '', (string)$itemValue), + 'icon' => 'status-status-permission-granted', + ]; + } + } + } + + public function populateCustomPermissionOptions(array &$fieldDefinition): void + { + $customOptions = $GLOBALS['TYPO3_CONF_VARS']['BE']['customPermOptions'] ?? []; + if (!is_array($customOptions) || $customOptions === []) { + return; + } + $languageService = $this->getLanguageService(); + foreach ($customOptions as $customOptionsKey => $customOptionsValue) { + if (empty($customOptionsValue['items']) || !is_array($customOptionsValue['items'])) { + continue; + } + // Add header: + $fieldDefinition['items'][] = [ + 'label' => $languageService->sL($customOptionsValue['header'] ?? ''), + 'value' => '--div--', + ]; + // Traverse items: + foreach ($customOptionsValue['items'] as $itemKey => $itemConfig) { + $icon = 'empty-empty'; + $helpText = ''; + if (!empty($itemConfig[1]) && $this->iconRegistry->isRegistered($itemConfig[1])) { + // Use icon identifier when registered + $icon = $itemConfig[1]; + } + if (!empty($itemConfig[2])) { + $helpText = $languageService->sL($itemConfig[2]); + } + $fieldDefinition['items'][] = [ + 'label' => $languageService->sL($itemConfig[0] ?? ''), + 'value' => $customOptionsKey . ':' . preg_replace('/[:|,]/', '', (string)$itemKey), + 'icon' => $icon, + 'description' => $helpText, + ]; + } + } + } + + /** + * Populates a list of category fields (with the defined relationships) for the given table + */ + public function populateAvailableCategoryFields(array &$fieldDefinition): void + { + $table = (string)($fieldDefinition['config']['itemsProcConfig']['table'] ?? ''); + if ($table === '') { + throw new \UnexpectedValueException('No table to search for category fields given.', 1627565458); + } + + if (!$this->tcaSchemaFactory->has($table)) { + throw new \RuntimeException('Given table ' . $table . ' does not define any valid schema to search for category fields.', 1627565459); + } + + // Only category fields with the "manyToMany" relationship are allowed by default. + // This can however be changed using the "allowedRelationships" itemsProcConfig. + $allowedRelationships = $fieldDefinition['config']['itemsProcConfig']['allowedRelationships'] ?? false; + if (!is_array($allowedRelationships) || $allowedRelationships === []) { + $allowedRelationships = ['manyToMany']; + } + + $schema = $this->tcaSchemaFactory->get($table); + + // Loop on all table columns to find category fields + foreach ($schema->getFields() as $fieldName => $fieldConfig) { + /** @var CategoryFieldType $fieldConfig */ + if (!$fieldConfig->isType(TableColumnType::CATEGORY)) { + continue; + } + if (!in_array($fieldConfig->getConfiguration()['relationship'] ?? '', $allowedRelationships, true)) { + continue; + } + $fieldDefinition['items'][] = [ + 'label' => $this->getLanguageService()->sL($fieldConfig->getLabel()), + 'value' => $fieldName, + ]; + } + } + + /** + * Returns an array with the exclude fields as defined in TCA and FlexForms + * Used for listing the exclude fields in be_groups forms. + * + * @return array Array of arrays with excludeFields (fieldName, table:fieldName) from TCA + * and FlexForms (fieldName, table:extKey;sheetName;fieldName) + */ + protected function getGroupedExcludeFields(): array + { + $languageService = $this->getLanguageService(); + $excludeFieldGroups = []; + + // Fetch translations for table names + $tableToTranslation = []; + // All TCA keys + foreach ($this->tcaSchemaFactory->all() as $table => $schema) { + $tableToTranslation[$table] = $schema->getTitle($languageService->sL(...)) ?: $table; + } + // Sort by translations + asort($tableToTranslation); + foreach ($tableToTranslation as $table => $translatedTable) { + $excludeFieldGroup = []; + $schema = $this->tcaSchemaFactory->get($table); + + // All field names configured and not restricted to admins + $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); + // Skip this table if it’s rootlevel-only and the rootlevel restriction applies + // (unless ignoreRootLevelRestriction is enabled). + + if (!$rootLevelCapability->shallIgnoreRootLevelRestriction() && $rootLevelCapability->getRootLevelType() === RootLevelCapability::TYPE_ONLY_ON_ROOTLEVEL) { + continue; + } + if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)) { + continue; + } + + foreach ($schema->getFields() as $fieldName => $fieldDefinition) { + // Only show fields that can be excluded for editors, or are hidden for non-admins + if ($fieldDefinition->supportsAccessControl() && $fieldDefinition->getDisplayConditions() !== 'HIDE_FOR_NON_ADMINS') { + // Get human-readable names of fields + $translatedField = $languageService->sL($fieldDefinition->getLabel()); + // Add entry, key 'labels' needed for sorting + $excludeFieldGroup[] = [ + 'labels' => $translatedTable . ':' . $translatedField, + 'sectionHeader' => $translatedTable, + 'table' => $table, + 'tableField' => $fieldName, + 'fieldName' => $fieldName, + 'fullField' => $fieldName, + 'fieldLabel' => $translatedField, + 'origin' => 'tca', + ]; + } + } + // All FlexForm fields + $flexFormArray = $this->getRegisteredFlexForms((string)$table); + foreach ($flexFormArray as $tableField => $flexForms) { + $flexFieldLabel = ''; + // Get all sheets + foreach ($flexForms as $extIdent => $extConf) { + if ($schema->hasSubSchema((string)$extIdent)) { + $fieldDefinition = $schema->getSubSchema((string)$extIdent)->getField($tableField); + } else { + $fieldDefinition = $schema->getField($tableField); + } + if ($fieldDefinition->getLabel() !== '') { + $flexFieldLabel = $languageService->sL($fieldDefinition->getLabel()); + } + if (empty($extConf['sheets']) || !is_array($extConf['sheets'])) { + continue; + } + // Get all fields in sheet + foreach ($extConf['sheets'] as $sheetName => $sheet) { + if (empty($sheet['ROOT']['el']) || !is_array($sheet['ROOT']['el'])) { + continue; + } + foreach ($sheet['ROOT']['el'] as $pluginFieldName => $field) { + // Use only fields that have exclude flag set + if (empty($field['exclude'])) { + continue; + } + $fieldLabel = !empty($field['label']) ? $languageService->sL($field['label']) : $pluginFieldName; + $excludeFieldGroup[] = [ + 'labels' => trim($translatedTable . ' ' . $flexFieldLabel . ' ' . $extIdent, ': ') . ':' . $fieldLabel, + 'sectionHeader' => trim($translatedTable . ' ' . $flexFieldLabel . ' ' . $extIdent, ':'), + 'table' => $table, + 'tableField' => $tableField, + 'extIdent' => $extIdent, + 'fieldName' => $pluginFieldName, + 'fullField' => $tableField . ';' . $extIdent . ';' . $sheetName . ';' . $pluginFieldName, + 'fieldLabel' => $fieldLabel, + 'origin' => 'flexForm', + ]; + } + } + } + } + // Sort fields by the translated value + if (!empty($excludeFieldGroup)) { + usort($excludeFieldGroup, static function (array $array1, array $array2) { + $array1 = reset($array1); + $array2 = reset($array2); + if (is_string($array1) && is_string($array2)) { + return strcasecmp($array1, $array2); + } + return 0; + }); + $excludeFieldGroups = array_merge($excludeFieldGroups, $excludeFieldGroup); + } + } + + return $excludeFieldGroups; + } + + /** + * Returns FlexForm data structures it finds. Used in select "special" for be_groups + * to set "exclude" flags for single flex form fields. + * + * This only finds flex forms registered in 'ds' config sections - default and record type specific. + * This does not resolve other sophisticated flex form data structure references. + * + * @todo: This approach is limited and doesn't find everything. It works for casual tt_content plugins, though: + * @todo: The data structure identifier determination depends on data row, but we don't have all rows at hand here. + * @todo: The code thus "guesses" some standard data structure identifier scenarios and tries to resolve those. + * @todo: This guessing can not be solved in a good way. A general registry of "all" possible data structures is + * @todo: probably not wanted, since that wouldn't work for truly dynamic DS calculations. Probably the only + * @todo: thing we could do here is a hook to allow extensions declaring specific data structures to + * @todo: allow backend admins to set exclude flags for certain fields in those cases. + * + * @param string $table Table to handle + * @return array Data structures + */ + protected function getRegisteredFlexForms(string $table): array + { + if (!$this->tcaSchemaFactory->has($table)) { + return []; + } + $schema = $this->tcaSchemaFactory->get($table); + $flexForms = []; + // Get all flex fields and add the default data structure + foreach ($schema->getFields() as $field => $fieldDefinition) { + if ($fieldDefinition->getType() !== TableColumnType::FLEX->value) { + continue; + } + $flexForms[$field] = []; + // Default data structure + try { + $flexForms[$field]['default'] = $this->flexFormTools->parseDataStructureByIdentifier(json_encode([ + 'type' => 'tca', + 'tableName' => $table, + 'fieldName' => $field, + 'dataStructureKey' => 'default', + ]), $schema); + } catch (InvalidIdentifierException $e) { + // Skip default on error + } + } + + // If flex fields exist and the table supports sub schemata, add specific data strcuturs for those sub schemas + if ($flexForms !== [] && $schema->supportsSubSchema()) { + foreach ($schema->getSubSchemata() as $recordType => $subSchema) { + foreach (array_keys($flexForms) as $fieldName) { + if ($subSchema->hasField($fieldName)) { + try { + $flexForms[$fieldName][$recordType] = $this->flexFormTools->parseDataStructureByIdentifier(json_encode([ + 'type' => 'tca', + 'tableName' => $table, + 'fieldName' => $fieldName, + 'dataStructureKey' => $recordType, + ]), $schema); + } catch (InvalidIdentifierException $e) { + // Skip record type specific config on error + } + } + } + } + } + + return $flexForms; + } + + /** + * Returns an array with explicit allow fields. + * Used for listing these field/value pairs in be_groups forms + * + * @return array Array with information from all of $GLOBALS['TCA'] + */ + protected function getGroupedExplicitAuthFieldValues(): array + { + $languageService = $this->getLanguageService(); + $allowOptions = []; + foreach ($this->tcaSchemaFactory->all() as $table => $schema) { + // All field names configured: + foreach ($schema->getFields() as $field => $fieldDefinition) { + $fieldConfig = $fieldDefinition->getConfiguration(); + if (($fieldConfig['type'] ?? '') !== 'select' + || ($fieldConfig['authMode'] ?? false) !== 'explicitAllow' + || empty($fieldConfig['items']) + || !is_array($fieldConfig['items']) + ) { + continue; + } + // Get Human Readable names of fields and table: + $allowOptions[$table . ':' . $field]['tableFieldLabel'] + = $schema->getTitle($languageService->sL(...)) . ': ' + . $languageService->sL($fieldDefinition->getLabel()); + + foreach ($fieldConfig['items'] as $item) { + $itemIdentifier = (string)($item['value'] ?? ''); + // Values '' and '--div--' are not controlled by this setting. + if ($itemIdentifier === '' || $itemIdentifier === '--div--') { + continue; + } + $allowOptions[$table . ':' . $field]['items'][$itemIdentifier] = $languageService->sL($item['label'] ?? ''); + } + } + } + return $allowOptions; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Hooks/UpdateFileIndexEntry.php b/Classes/Hooks/UpdateFileIndexEntry.php new file mode 100644 index 0000000..6cdbcaf --- /dev/null +++ b/Classes/Hooks/UpdateFileIndexEntry.php @@ -0,0 +1,86 @@ +BE_USER->workspace > 0 + || !isset($dataHandler->substNEWwithIDs[$id]) + || !($fieldValues['file'] ?? false) + || (int)$fieldValues['l10n_parent'] !== 0 + || (int)$fieldValues['pid'] !== 0 + || (isset($fieldValues['t3ver_oid']) && (int)$fieldValues['t3ver_oid'] > 0) + || $dataHandler->isImporting + ) { + return; + } + + $uid = (int)$dataHandler->substNEWwithIDs[$id]; + + try { + $fileObject = $this->resourceFactory->getFileObject((int)$fieldValues['file']); + GeneralUtility::makeInstance(Indexer::class, $fileObject->getStorage())->updateIndexEntry($fileObject); + } catch (FileDoesNotExistException $e) { + $dataHandler->log( + 'sys_file_metadata', + $uid, + SystemLogFileAction::EDIT, + null, + SystemLogErrorClassification::SYSTEM_ERROR, + 'The referenced file "{fileUid}" was not found.', + null, + ['fileUid' => $fieldValues['file']] + ); + } + } +} diff --git a/Classes/Html/DefaultSanitizerBuilder.php b/Classes/Html/DefaultSanitizerBuilder.php new file mode 100644 index 0000000..3ad9f8c --- /dev/null +++ b/Classes/Html/DefaultSanitizerBuilder.php @@ -0,0 +1,107 @@ +srcAttr->addValues($isOnCurrentHostAttr); + $this->hrefAttr->addValues($isOnCurrentHostAttr, $isTypo3Uri); + + // @todo `style` used in Introduction Package, inline CSS should be removed + $this->globalAttrs[] = new Behavior\Attr('style'); + } + + public function build(): Sanitizer + { + $behavior = $this->createBehavior(); + $visitor = GeneralUtility::makeInstance(CommonVisitor::class, $behavior); + return GeneralUtility::makeInstance(Sanitizer::class, $behavior, $visitor); + } + + protected function createBehavior(): Behavior + { + if (!isset($this->behavior)) { + $this->behavior = parent::createBehavior() + ->withName('default') + ->withNodes(new Behavior\NodeHandler( + new Behavior\Tag('svg'), + new Behavior\Handler\ClosureHandler( + static function (NodeInterface $node, ?\DOMNode $domNode, Context $context): ?\DOMNode { + if ($domNode === null) { + return null; + } + + $newNode = GeneralUtility::makeInstance(SvgSanitizer::class) + ->sanitizeNode($domNode); + + // purge empty svg nodes + if ($newNode->childNodes->length === 0) { + return null; + } + + $fragment = $domNode->ownerDocument->createDocumentFragment(); + $fragment->append($newNode); + return $fragment; + } + ) + )); + } + return $this->behavior; + } +} diff --git a/Classes/Html/Event/AfterTransformTextForPersistenceEvent.php b/Classes/Html/Event/AfterTransformTextForPersistenceEvent.php new file mode 100644 index 0000000..803fa36 --- /dev/null +++ b/Classes/Html/Event/AfterTransformTextForPersistenceEvent.php @@ -0,0 +1,51 @@ +htmlContent; + } + + public function setHtmlContent(string $htmlContent): void + { + $this->htmlContent = $htmlContent; + } + + public function getInitialHtmlContent(): string + { + return $this->initialHtmlContent; + } + + public function getProcessingConfiguration(): array + { + return $this->processingConfiguration; + } +} diff --git a/Classes/Html/Event/AfterTransformTextForRichTextEditorEvent.php b/Classes/Html/Event/AfterTransformTextForRichTextEditorEvent.php new file mode 100644 index 0000000..bb5996c --- /dev/null +++ b/Classes/Html/Event/AfterTransformTextForRichTextEditorEvent.php @@ -0,0 +1,51 @@ +htmlContent; + } + + public function setHtmlContent(string $htmlContent): void + { + $this->htmlContent = $htmlContent; + } + + public function getInitialHtmlContent(): string + { + return $this->initialHtmlContent; + } + + public function getProcessingConfiguration(): array + { + return $this->processingConfiguration; + } +} diff --git a/Classes/Html/Event/BeforeTransformTextForPersistenceEvent.php b/Classes/Html/Event/BeforeTransformTextForPersistenceEvent.php new file mode 100644 index 0000000..d29f257 --- /dev/null +++ b/Classes/Html/Event/BeforeTransformTextForPersistenceEvent.php @@ -0,0 +1,51 @@ +htmlContent; + } + + public function setHtmlContent(string $htmlContent): void + { + $this->htmlContent = $htmlContent; + } + + public function getInitialHtmlContent(): string + { + return $this->initialHtmlContent; + } + + public function getProcessingConfiguration(): array + { + return $this->processingConfiguration; + } +} diff --git a/Classes/Html/Event/BeforeTransformTextForRichTextEditorEvent.php b/Classes/Html/Event/BeforeTransformTextForRichTextEditorEvent.php new file mode 100644 index 0000000..89acca5 --- /dev/null +++ b/Classes/Html/Event/BeforeTransformTextForRichTextEditorEvent.php @@ -0,0 +1,51 @@ +htmlContent; + } + + public function setHtmlContent(string $htmlContent): void + { + $this->htmlContent = $htmlContent; + } + + public function getInitialHtmlContent(): string + { + return $this->initialHtmlContent; + } + + public function getProcessingConfiguration(): array + { + return $this->processingConfiguration; + } +} diff --git a/Classes/Html/Event/BrokenLinkAnalysisEvent.php b/Classes/Html/Event/BrokenLinkAnalysisEvent.php new file mode 100644 index 0000000..728cb39 --- /dev/null +++ b/Classes/Html/Event/BrokenLinkAnalysisEvent.php @@ -0,0 +1,80 @@ +linkWasChecked; + } + + /** + * Returns the link type as string + * @see LinkService types + */ + public function getLinkType(): string + { + return $this->linkType; + } + + /** + * Returns resolved LinkService data, depending on the type + */ + public function getLinkData(): array + { + return $this->linkData; + } + + public function markAsCheckedLink(): void + { + $this->linkWasChecked = true; + } + + public function markAsBrokenLink(string $reason = ''): void + { + $this->isBroken = true; + $this->reason = $reason; + } + + public function isBrokenLink(): bool + { + return $this->isBroken; + } + + public function getReason(): string + { + return $this->reason; + } +} diff --git a/Classes/Html/HtmlCropper.php b/Classes/Html/HtmlCropper.php new file mode 100644 index 0000000..462b293 --- /dev/null +++ b/Classes/Html/HtmlCropper.php @@ -0,0 +1,311 @@ + # a comment + | + ]*>.*? # a canvas tag + | + ]*>.*? # a script tag + | + ]*>.*? # a noscript tag + | + ]*>.*? # a template tag + ) + | + + ".*?" # attribute values in double-quotes + | + \'.*?\' # attribute values in single-quotes + | + [^\'">\\s]+ # plain attribute values + ) + )? + ) + | # OR a single dash (for TYPO3 link tag) + (?: + \\s+- + ) + )+\\s* + | # OR only spaces + \\s* + ) + /?> # closing the tag with \'>\' or \'/>\' + )'; + + public function __construct( + private LoggerInterface $logger, + ) {} + + /** + * Implements "cropHTML" which is a modified "substr" function allowing to limit a string length to a certain number + * of chars (from either start or end of string) and having a pre/postfix applied if the string really was cropped. + * + * @param string $content The string to perform the operation on + * @param int $numberOfChars Max number of chars of the string. Negative value means cropping from end of string. + * @param string $replacementForEllipsis The pre/postfix string to apply if cropping occurs. + * @param bool $cropToSpace If true then crop will be applied at nearest space. + * @return string The processed input value. + */ + public function crop(string $content, int $numberOfChars, string $replacementForEllipsis, bool $cropToSpace): string + { + $cropFromRight = $numberOfChars < 0; + + $sections = $this->splitContentIntoSections($content, $cropFromRight); + + // Only crop text sections (chars of tag-blocks are not counted). + $strLengthOfAllPrevTextSections = 0; + + // This is the offset of the content item which was cropped. + $croppedOffset = null; + $amountOfSections = count($sections); + + // For cropSectionToNextSpace we need a collection of all processed text sections + $processedTextSectionsForCropping = []; + + for ($offset = 0; $offset < $amountOfSections; $offset++) { + if ($this->isTextSection($offset)) { + $contentOfCurrentSection = $sections[$offset]; + $strLengthOfCurrentSection = mb_strlen( + html_entity_decode($contentOfCurrentSection, ENT_COMPAT, 'UTF-8'), + 'utf-8' + ); + + if ($strLengthOfAllPrevTextSections + $strLengthOfCurrentSection > abs($numberOfChars)) { + $croppedOffset = $offset; + $cropPosition = $this->getCropPosition( + $contentOfCurrentSection, + $numberOfChars, + $strLengthOfAllPrevTextSections, + $cropFromRight + ); + + // Main cropping. Note the +1 and -1. These are there to be able to + // check for space characters later on. + $contentOfCurrentSection = !$cropFromRight + ? mb_substr($contentOfCurrentSection, 0, $cropPosition + 1) + : mb_substr($contentOfCurrentSection, -$cropPosition - 1); + + $contentOfCurrentSection = $this->cropSectionToNextSpace( + $contentOfCurrentSection, + $processedTextSectionsForCropping, + $cropToSpace, + $cropFromRight + ); + + $sections[$offset] = $contentOfCurrentSection; + break; + } + $strLengthOfAllPrevTextSections += $strLengthOfCurrentSection; + if ($contentOfCurrentSection !== '') { + $processedTextSectionsForCropping[] = $contentOfCurrentSection; + } + } + } + + $sections = $this->closeCroppedTags($sections, $croppedOffset, $numberOfChars, $replacementForEllipsis); + + // Reverse array once again if we are cropping from the end. + if ($numberOfChars < 0) { + $sections = array_reverse($sections); + } + + return implode('', $sections); + } + + /** + * Split $content into an array(even items in the array are outside the tags, odd numbers are tag-blocks). + */ + protected function splitContentIntoSections(string $content, bool $cropFromRight): array + { + $splitPattern = sprintf( + self::TAGS_REG_EXP, + self::TAGS + ); + + $sections = preg_split( + '%' . $splitPattern . '%xs', + $content, + -1, + PREG_SPLIT_DELIM_CAPTURE + ); + if ($sections === false) { + $this->logger->debug('Unable to split "{content}" into tags.', ['content' => $content]); + $sections = []; + } + + // Reverse array if we are cropping from right. + if ($cropFromRight) { + $sections = array_reverse($sections); + } + + return $sections; + } + + protected function getCropPosition( + string $contentOfCurrentSection, + int $numberOfChars, + int $strLengthOfAllPrevTextSections, + bool $cropFromRight + ): int { + $cropPosition = abs($numberOfChars) - $strLengthOfAllPrevTextSections; + + // The snippet "&[^&\s;]{2,8};" in the RegEx below represents entities. + $entityPattern = '/&[^&\\s;]{2,8};/'; + preg_match_all($entityPattern, $contentOfCurrentSection, $matches); + $entityMatches = $matches[0]; + + // If we have found any html entities, these should be counted as 1 character. + // Strategy is to replace all found entities with an arbitrary character ($) + // and use this new string to count offsets. + if ($entityMatches !== []) { + $escapedContent = str_replace('$', ' ', $contentOfCurrentSection); + $replacedContent = preg_replace($entityPattern, '$', $escapedContent, -1); + $croppedContent = !$cropFromRight + ? mb_substr($replacedContent, 0, $cropPosition) + : mb_substr($replacedContent, $numberOfChars, $cropPosition); + + // In case of negative offsets, we need to reverse everything. + // Because the string is cropped from behind, the entities + // have to be replaced in reverse, too. + if ($cropFromRight) { + $croppedContent = strrev($croppedContent); + $entityMatches = array_reverse($entityMatches); + } + + foreach ($entityMatches as $entity) { + $croppedContent = preg_replace('/\$/', $entity, $croppedContent, 1); + } + + $cropPosition = mb_strlen($croppedContent); + } + + return $cropPosition; + } + + protected function closeCroppedTags( + array $sections, + ?int $croppedOffset, + int $numberOfChars, + string $replacementForEllipsis + ): array { + $closingTags = []; + if ($croppedOffset !== null) { + $openingTagRegEx = '#^<(\\w+)(?:\\s|>)#'; + $closingTagRegEx = '#^)#'; + for ($offset = $croppedOffset - 1; $offset >= 0; $offset = $offset - 2) { + if (str_ends_with($sections[$offset], '/>')) { + // Ignore empty element tags (e.g.
). + continue; + } + + preg_match($numberOfChars < 0 ? $closingTagRegEx : $openingTagRegEx, $sections[$offset], $matches); + $tagName = $matches[1] ?? null; + if ($tagName !== null) { + // Seek for the closing (or opening) tag. + $amountOfSections = count($sections); + for ($seekingOffset = $offset + 2; $seekingOffset < $amountOfSections; $seekingOffset = $seekingOffset + 2) { + preg_match($numberOfChars < 0 ? $openingTagRegEx : $closingTagRegEx, $sections[$seekingOffset], $matches); + $seekingTagName = $matches[1] ?? null; + if ($tagName === $seekingTagName) { + // We found a matching tag. + // Add closing tag only if it occurs after the cropped content item. + if ($seekingOffset > $croppedOffset) { + $closingTags[] = $sections[$seekingOffset]; + } + break; + } + } + } + } + // Drop the cropped items of the content array. The $closingTags will be added later on again. + array_splice($sections, $croppedOffset + 1); + } + + return array_merge($sections, [ + $croppedOffset !== null ? trim($replacementForEllipsis) : '', + ], $closingTags); + } + + protected function cropSectionToNextSpace( + string $contentOfCurrentSection, + array $processedTextSectionsForCropping, + bool $cropToSpace, + bool $cropFromRight + ): string { + // Crop to space means, we ensure to crop before (or after) a space. + // If there are no spaces, this option has no effect. + $cropToSpaceApplied = false; + if ($cropToSpace) { + $exploded = explode(' ', $contentOfCurrentSection); + if (!$cropFromRight) { + array_unshift( + $exploded, + ...$processedTextSectionsForCropping + ); + } else { + array_push( + $exploded, + ...$processedTextSectionsForCropping + ); + } + + if (count($exploded) > 1) { + if (!$cropFromRight && $exploded[count($exploded) - 1] !== ' ') { + array_pop($exploded); + $cropToSpaceApplied = true; + } elseif ($exploded[0] !== ' ') { + array_shift($exploded); + $cropToSpaceApplied = true; + } + } + $exploded = array_diff($exploded, $processedTextSectionsForCropping); + $contentOfCurrentSection = implode(' ', $exploded); + } + + // Only remove the extra character again, if crop2space did not apply anything. + if (!$cropToSpaceApplied) { + $contentOfCurrentSection = !$cropFromRight + ? mb_substr($contentOfCurrentSection, 0, -1) + : mb_substr($contentOfCurrentSection, 1); + } + + return $contentOfCurrentSection; + } + + protected function isTextSection(int $offset): bool + { + return $offset % 2 === 0; + } +} diff --git a/Classes/Html/HtmlParser.php b/Classes/Html/HtmlParser.php new file mode 100644 index 0000000..217c54e --- /dev/null +++ b/Classes/Html/HtmlParser.php @@ -0,0 +1,1048 @@ +removeFirstAndLastTag() to process the content if needed. + * + * @param string $tag List of tags, comma separated. + * @param string $content HTML-content + * @param bool $eliminateExtraEndTags If set, excessive end tags are ignored - you should probably set this in most cases. + * @return array Even numbers in the array are outside the blocks, Odd numbers are block-content. + * @see splitTags() + * @see removeFirstAndLastTag() + */ + public function splitIntoBlock($tag, $content, $eliminateExtraEndTags = false) + { + $tags = array_unique(GeneralUtility::trimExplode(',', $tag, true)); + array_walk($tags, static function (string &$tag): void { + $tag = preg_quote($tag, '/'); + }); + $regexStr = '/\\<\\/?(' . implode('|', $tags) . ')(\\s*\\>|\\s[^\\>]*\\>)/si'; + $parts = preg_split($regexStr, $content); + if (empty($parts)) { + return []; + } + $newParts = []; + $pointer = strlen($parts[0]); + $buffer = $parts[0]; + $nested = 0; + reset($parts); + // We skip the first element in foreach loop + $partsSliced = array_slice($parts, 1, null, true); + foreach ($partsSliced as $v) { + $isEndTag = substr($content, $pointer, 2) === '') + 1; + // We meet a start-tag: + if (!$isEndTag) { + // Ground level: + if (!$nested) { + // Previous buffer stored + $newParts[] = $buffer; + $buffer = ''; + } + // We are inside now! + $nested++; + // New buffer set and pointer increased + $mbuffer = substr($content, $pointer, strlen($v) + $tagLen); + $pointer += strlen($mbuffer); + $buffer .= $mbuffer; + } else { + // If we meet an endtag: + // Decrease nested-level + $nested--; + $eliminated = 0; + if ($eliminateExtraEndTags && $nested < 0) { + $nested = 0; + $eliminated = 1; + } else { + // In any case, add the endtag to current buffer and increase pointer + $buffer .= substr($content, $pointer, $tagLen); + } + $pointer += $tagLen; + // if we're back on ground level, (and not by eliminating tags... + if (!$nested && !$eliminated) { + $newParts[] = $buffer; + $buffer = ''; + } + // New buffer set and pointer increased + $mbuffer = substr($content, $pointer, strlen($v)); + $pointer += strlen($mbuffer); + $buffer .= $mbuffer; + } + } + $newParts[] = $buffer; + return $newParts; + } + + /** + * Splitting content into blocks *recursively* and processing tags/content with call back functions. + * + * @param string $tag Tag list, see splitIntoBlock() + * @param string $content Content, see splitIntoBlock() + * @param object $procObj Object where call back methods are. + * @param string $callBackContent Name of call back method for content; "function callBackContent($str,$level) + * @param string $callBackTags Name of call back method for tags; "function callBackTags($tags,$level) + * @param int $level Indent level + * @return string Processed content + * @see splitIntoBlock() + */ + public function splitIntoBlockRecursiveProc($tag, $content, &$procObj, $callBackContent, $callBackTags, $level = 0) + { + $parts = $this->splitIntoBlock($tag, $content, true); + foreach ($parts as $k => $v) { + if ($k % 2) { + $firstTagName = $this->getFirstTagName($v, true); + $tagsArray = []; + $tagsArray['tag_start'] = $this->getFirstTag($v); + $tagsArray['tag_end'] = ''; + $tagsArray['tag_name'] = strtolower($firstTagName); + $tagsArray['content'] = $this->splitIntoBlockRecursiveProc($tag, $this->removeFirstAndLastTag($v), $procObj, $callBackContent, $callBackTags, $level + 1); + if ($callBackTags) { + $tagsArray = $procObj->{$callBackTags}($tagsArray, $level); + } + $parts[$k] = $tagsArray['tag_start'] . $tagsArray['content'] . $tagsArray['tag_end']; + } else { + if ($callBackContent) { + $parts[$k] = $procObj->{$callBackContent}($parts[$k], $level); + } + } + } + return implode('', $parts); + } + + /** + * Returns an array with the $content divided by tag-blocks specified with the list of tags, $tag + * Even numbers in the array are outside the blocks, Odd numbers are block-content. + * Use ->removeFirstAndLastTag() to process the content if needed. + * + * @param string $tag List of tags + * @param string $content HTML-content + * @return array Even numbers in the array are outside the blocks, Odd numbers are block-content. + * @see splitIntoBlock() + * @see removeFirstAndLastTag() + */ + public function splitTags($tag, $content) + { + $tags = GeneralUtility::trimExplode(',', $tag, true); + array_walk($tags, static function (string &$tag): void { + $tag = preg_quote($tag, '/'); + }); + $regexStr = '/\\<(' . implode('|', $tags) . ')(\\s[^>]*)?\\/?>/si'; + $parts = preg_split($regexStr, $content); + if (empty($parts)) { + return []; + } + $pointer = strlen($parts[0]); + $newParts = []; + $newParts[] = $parts[0]; + reset($parts); + // We skip the first element in foreach loop + $partsSliced = array_slice($parts, 1, null, true); + foreach ($partsSliced as $v) { + $tagLen = strcspn(substr($content, $pointer), '>') + 1; + // Set tag: + // New buffer set and pointer increased + $tag = substr($content, $pointer, $tagLen); + $newParts[] = $tag; + $pointer += strlen($tag); + // Set content: + $newParts[] = $v; + $pointer += strlen($v); + } + return $newParts; + } + + /** + * Removes the first and last tag in the string + * Anything before the first and after the last tags respectively is also removed + * + * @param string $str String to process + * @return string + */ + public function removeFirstAndLastTag($str) + { + $parser = SimpleParser::fromString($str); + $first = $parser->getFirstNode(SimpleNode::TYPE_ELEMENT); + $last = $parser->getLastNode(SimpleNode::TYPE_ELEMENT); + if ($first === null || $first === $last) { + return ''; + } + $sequence = array_slice( + $parser->getNodes(), + $first->getIndex() + 1, + $last->getIndex() - $first->getIndex() - 1 + ); + return implode('', array_map(strval(...), $sequence)); + } + + /** + * Returns the first tag in $str + * Actually everything from the beginning of the $str is returned, so you better make sure the tag is the first thing... + * + * @param string $str HTML string with tags + * @return string + */ + public function getFirstTag($str) + { + $parser = SimpleParser::fromString($str); + $first = $parser->getFirstNode(SimpleNode::TYPE_ELEMENT); + if ($first === null) { + return ''; + } + $sequence = array_slice( + $parser->getNodes(), + 0, + $first->getIndex() + 1 + ); + return implode('', array_map(strval(...), $sequence)); + } + + /** + * Returns the NAME of the first tag in $str + * + * @param string $str HTML tag (The element name MUST be separated from the attributes by a space character! Just *whitespace* will not do) + * @param bool $preserveCase If set, then the tag is NOT converted to uppercase by case is preserved. + * @return string Tag name in upper case + * @see getFirstTag() + */ + public function getFirstTagName($str, $preserveCase = false) + { + $parser = SimpleParser::fromString($str); + $elements = $parser->getNodes(SimpleNode::TYPE_ELEMENT); + foreach ($elements as $element) { + $name = $element->getElementName(); + if ($name === null) { + continue; + } + return $preserveCase ? $name : strtoupper($name); + } + return ''; + } + + /** + * Returns an array with all attributes as keys. Attributes are only lowercase a-z + * If an attribute is empty (shorthand), then the value for the key is empty. You can check if it existed with isset() + * + * Compared to the method in GeneralUtility::get_tag_attributes this method also returns meta data about each + * attribute, e.g. if it is a shorthand attribute, and what the quotation is. Also, since all attribute keys + * are lower-cased, the meta information contains the original attribute name. + * + * @param string $tag Tag: $tag is either a whole tag (eg '') or the parameterlist (ex ' OPTION ATTRIB=VALUE>') + * @param bool $deHSC If set, the attribute values are de-htmlspecialchar'ed. Should actually always be set! + * @return array array(Tag attributes,Attribute meta-data) + */ + public function get_tag_attributes($tag, $deHSC = false) + { + [$components, $metaC] = $this->split_tag_attributes($tag); + // Attribute name is stored here + $name = ''; + $valuemode = false; + $attributes = []; + $attributesMeta = []; + if (is_array($components)) { + foreach ($components as $key => $val) { + // Only if $name is set (if there is an attribute, that waits for a value), that valuemode is enabled. This ensures that the attribute is assigned it's value + if ($val !== '=') { + if ($valuemode) { + if ($name) { + $attributes[$name] = $deHSC ? htmlspecialchars_decode($val) : $val; + $attributesMeta[$name]['dashType'] = $metaC[$key]; + $name = ''; + } + } else { + if ($namekey = preg_replace('/[^[:alnum:]_\\:\\-]/', '', $val) ?? '') { + $name = strtolower((string)$namekey); + $attributesMeta[$name] = []; + $attributesMeta[$name]['origTag'] = $namekey; + $attributes[$name] = ''; + } + } + $valuemode = false; + } else { + $valuemode = true; + } + } + return [$attributes, $attributesMeta]; + } + return [null, null]; + } + + /** + * Returns an array with the 'components' from an attribute list. + * The result is normally analyzed by get_tag_attributes + * Removes tag-name if found. + * + * The difference between this method and the one in GeneralUtility is that this method actually determines + * more information on the attribute, e.g. if the value is enclosed by a " or ' character. + * That's why this method returns two arrays, the "components" and the "meta-information" of the "components". + * + * @param string $tag The tag or attributes + * @return array + * @internal + * @see \TYPO3\CMS\Core\Utility\GeneralUtility::split_tag_attributes() + */ + public function split_tag_attributes($tag) + { + $matches = []; + if (preg_match('/(\\<[^\\s]+\\s+)?(.*?)\\s*(\\>)?$/s', $tag, $matches) !== 1) { + return [[], []]; + } + $tag_tmp = $matches[2]; + $metaValue = []; + $value = []; + $matches = []; + if (preg_match_all('/("[^"]*"|\'[^\']*\'|[^\\s"\'\\=]+|\\=)/s', $tag_tmp, $matches) > 0) { + foreach ($matches[1] as $part) { + $firstChar = $part[0]; + if ($firstChar === '"' || $firstChar === '\'') { + $metaValue[] = $firstChar; + $value[] = substr($part, 1, -1); + } else { + $metaValue[] = ''; + $value[] = $part; + } + } + } + return [$value, $metaValue]; + } + + /********************************* + * + * Clean HTML code + * + *********************************/ + /** + * Function that can clean up HTML content according to configuration given in the $tags array. + * + * Initializing the $tags array to allow a list of tags (in this case ,, and ), set it like this: $tags = array_flip(explode(',','b,a,i,u')) + * If the value of the $tags[$tagname] entry is an array, advanced processing of the tags is initialized. These are the options: + * + * ``` + * $tags[$tagname] = Array( + * 'overrideAttribs' => '' If set, this string is preset as the attributes of the tag + * 'allowedAttribs' => '0' (zero) = no attributes allowed, '[commalist of attributes]' = only allowed attributes. If blank, all attributes are allowed. + * 'fixAttrib' => Array( + * '[attribute name]' => Array ( + * 'set' => Force the attribute value to this value. + * 'unset' => Boolean: If set, the attribute is unset. + * 'default' => If no attribute exists by this name, this value is set as default value (if this value is not blank) + * 'always' => Boolean. If set, the attribute is always processed. Normally an attribute is processed only if it exists + * 'trim,intval,lower,upper' => All booleans. If any of these keys are set, the value is passed through the respective PHP-functions. + * 'range' => Array ('[low limit]','[high limit, optional]') Setting integer range. + * 'list' => Array ('[value1/default]','[value2]','[value3]') Attribute must be in this list. If not, the value is set to the first element. + * 'removeIfFalse' => Boolean/'blank'. If set, then the attribute is removed if it is 'FALSE'. If this value is set to 'blank' then the value must be a blank string (that means a 'zero' value will not be removed) + * 'removeIfEquals' => [value] If the attribute value matches the value set here, then it is removed. + * 'casesensitiveComp' => 1 If set, then the removeIfEquals and list comparisons will be case sensitive. Otherwise not. + * ) + * ), + * 'protect' => '', Boolean. If set, the tag <> is converted to < and > + * 'remap' => '', String. If set, the tagname is remapped to this tagname + * 'rmTagIfNoAttrib' => '', Boolean. If set, then the tag is removed if no attributes happened to be there. + * 'nesting' => '', Boolean/'global'. If set TRUE, then this tag must have starting and ending tags in the correct order. Any tags not in this order will be discarded. Thus '' will be converted to ''. Is the value 'global' then true nesting in relation to other tags marked for 'global' nesting control is preserved. This means that if and are set for global nesting then this string '' is converted to '' + * ) + * ``` + * + * @param string $content Is the HTML-content being processed. This is also the result being returned. + * @param array $tags Is an array where each key is a tagname in lowercase. Only tags present as keys in this array are preserved. The value of the key can be an array with a vast number of options to configure. + * @param mixed $keepAll Boolean/'protect', if set, then all tags are kept regardless of tags present as keys in $tags-array. If 'protect' then the preserved tags have their <> converted to < and > + * @param int $hSC Values -1,0,1,2: Set to zero= disabled, set to 1 then the content BETWEEN tags is htmlspecialchar()'ed, set to -1 its the opposite and set to 2 the content will be HSC'ed BUT with preservation for real entities (eg. "&" or "ê") + * @param array $addConfig Configuration array send along as $conf to the internal functions + * @return string Processed HTML content + */ + public function HTMLcleaner($content, $tags = [], $keepAll = 0, $hSC = 0, $addConfig = []): string + { + $newContent = []; + $tokArr = explode('<', $content); + $newContent[] = $this->bidir_htmlspecialchars(current($tokArr), $hSC); + // We skip the first element in foreach loop + $tokArrSliced = array_slice($tokArr, 1, null, true); + $c = 1; + $tagRegister = []; + $tagStack = []; + $inComment = false; + $inCdata = false; + $skipTag = false; + foreach ($tokArrSliced as $tok) { + if ($inComment) { + if (($eocPos = strpos($tok, '-->')) === false) { + // End of comment is not found in the token. Go further until end of comment is found in other tokens. + $newContent[$c++] = '<' . $tok; + continue; + } + // Comment ends in the middle of the token: add comment and proceed with rest of the token + $newContent[$c++] = '<' . substr($tok, 0, $eocPos + 3); + $tok = substr($tok, $eocPos + 3); + $inComment = false; + $skipTag = true; + } elseif ($inCdata) { + if (($eocPos = strpos($tok, '/*]]>*/')) === false) { + // End of comment is not found in the token. Go further until end of comment is found in other tokens. + $newContent[$c++] = '<' . $tok; + continue; + } + // Comment ends in the middle of the token: add comment and proceed with rest of the token + $newContent[$c++] = '<' . substr($tok, 0, $eocPos + 10); + $tok = substr($tok, $eocPos + 10); + $inCdata = false; + $skipTag = true; + } elseif (str_starts_with($tok, '!--')) { + if (($eocPos = strpos($tok, '-->')) === false) { + // Comment started in this token but it does end in the same token. Set a flag to skip till the end of comment + $newContent[$c++] = '<' . $tok; + $inComment = true; + continue; + } + // Start and end of comment are both in the current token. Add comment and proceed with rest of the token + $newContent[$c++] = '<' . substr($tok, 0, $eocPos + 3); + $tok = substr($tok, $eocPos + 3); + $skipTag = true; + } elseif (str_starts_with($tok, '![CDATA[*/')) { + if (($eocPos = strpos($tok, '/*]]>*/')) === false) { + // Comment started in this token but it does end in the same token. Set a flag to skip till the end of comment + $newContent[$c++] = '<' . $tok; + $inCdata = true; + continue; + } + // Start and end of comment are both in the current token. Add comment and proceed with rest of the token + $newContent[$c++] = '<' . substr($tok, 0, $eocPos + 10); + $tok = substr($tok, $eocPos + 10); + $skipTag = true; + } + $firstChar = $tok[0] ?? null; + // It is a tag... (first char is a-z0-9 or /) (fixed 19/01 2004). This also avoids triggering on and + if (!$skipTag && preg_match('/[[:alnum:]\\/]/', (string)$firstChar) === 1) { + $tagEnd = strpos($tok, '>'); + // If there is and end-bracket... tagEnd can't be 0 as the first character can't be a > + if ($tagEnd) { + $endTag = $firstChar === '/' ? 1 : 0; + $tagContent = substr($tok, $endTag, $tagEnd - $endTag); + $tagParts = preg_split('/\\s+/s', $tagContent, 2); + $tagName = strtolower(rtrim($tagParts[0], '/')); + $emptyTag = 0; + if (isset($tags[$tagName])) { + // If there is processing to do for the tag: + if (is_array($tags[$tagName])) { + if (preg_match('/^(' . self::VOID_ELEMENTS . ' )$/i', $tagName)) { + $emptyTag = 1; + } + // If NOT an endtag, do attribute processing (added dec. 2003) + if (!$endTag) { + // Override attributes + if (isset($tags[$tagName]['overrideAttribs']) && (string)$tags[$tagName]['overrideAttribs'] !== '') { + $tagParts[1] = $tags[$tagName]['overrideAttribs']; + } + // Allowed attributes (array-directives takes precedence in general) + $allowedAttribsArray = $tags[$tagName]['allowedAttribs.'] ?? null; + $allowedAttribsList = $tags[$tagName]['allowedAttribs'] ?? null; + // Note that "allowedAttribsList = 0" means: "strip every attribute", and not "do not apply attribute stripping". + // Only a 'null' (or [] for the array notation) means to allow all attributes, and not kick into this condition branch. + if (is_array($allowedAttribsArray) && $allowedAttribsArray !== [] || (string)$allowedAttribsList !== '') { + // No attribs allowed - array-directives takes precedence in general + if ($allowedAttribsArray === [0] || $allowedAttribsArray === ['0'] || $allowedAttribsList === '0') { + $tagParts[1] = ''; + } elseif (isset($tagParts[1]) && trim($tagParts[1])) { + $tagAttrib = $this->get_tag_attributes($tagParts[1]); + $newTagAttribs = []; + $allowedAttribs = is_array($allowedAttribsArray) + ? array_map('strtolower', $allowedAttribsArray) + : GeneralUtility::trimExplode(',', strtolower($allowedAttribsList), true); + + foreach ($allowedAttribs as $allowedAttrib) { + if (isset($tagAttrib[0][$allowedAttrib])) { + $newTagAttribs[$allowedAttrib] = $tagAttrib[0][$allowedAttrib]; + } + } + $tagParts[1] = $this->compileTagAttribs($newTagAttribs, $tagAttrib[1]); + } + } + // Fixed attrib values + if (isset($tags[$tagName]['fixAttrib']) && is_array($tags[$tagName]['fixAttrib'])) { + $tagAttrib = $this->get_tag_attributes($tagParts[1] ?? ''); + $tagParts[1] = ''; + foreach ($tags[$tagName]['fixAttrib'] as $attr => $params) { + if (isset($params['set']) && $params['set'] !== '') { + $tagAttrib[0][$attr] = $params['set']; + } + if (!empty($params['unset'])) { + unset($tagAttrib[0][$attr]); + } + if (!empty($params['default']) && !isset($tagAttrib[0][$attr])) { + $tagAttrib[0][$attr] = $params['default']; + } + if (($params['always'] ?? false) || isset($tagAttrib[0][$attr])) { + if ($params['trim'] ?? false) { + $tagAttrib[0][$attr] = trim($tagAttrib[0][$attr]); + } + if ($params['intval'] ?? false) { + $tagAttrib[0][$attr] = (int)$tagAttrib[0][$attr]; + } + if ($params['lower'] ?? false) { + $tagAttrib[0][$attr] = strtolower($tagAttrib[0][$attr]); + } + if ($params['upper'] ?? false) { + $tagAttrib[0][$attr] = strtoupper($tagAttrib[0][$attr]); + } + if ($params['range'] ?? false) { + if (isset($params['range'][1])) { + $tagAttrib[0][$attr] = MathUtility::forceIntegerInRange($tagAttrib[0][$attr], (int)$params['range'][0], (int)$params['range'][1]); + } else { + $tagAttrib[0][$attr] = MathUtility::forceIntegerInRange($tagAttrib[0][$attr], (int)$params['range'][0]); + } + } + if (isset($params['list']) && is_array($params['list'])) { + // For the class attribute, remove from the attribute value any class not in the list + // Classes are case sensitive + if ($attr === 'class') { + $newClasses = []; + $classes = GeneralUtility::trimExplode(' ', $tagAttrib[0][$attr] ?? '', true); + foreach ($classes as $class) { + if (in_array($class, $params['list'])) { + $newClasses[] = $class; + } + } + if (!empty($newClasses)) { + $tagAttrib[0][$attr] = implode(' ', $newClasses); + } else { + $tagAttrib[0][$attr] = $params['list'][0]; + } + } else { + $normalizedSearchWord = $tagAttrib[0][$attr] ?? ''; + $normalizedSearchList = $params['list']; + if (!($params['casesensitiveComp'] ?? false)) { + // Case-sensitive comparison is not wanted, normalize all values + $normalizedSearchWord = strtoupper((string)($tagAttrib[0][$attr] ?? '')); + $normalizedSearchList = array_map('strtoupper', $normalizedSearchList); + } + if (!in_array($normalizedSearchWord, $normalizedSearchList, true)) { + $tagAttrib[0][$attr] = $params['list'][0]; + } + } + } + if ( + (($params['removeIfFalse'] ?? false) && $params['removeIfFalse'] !== 'blank' && !$tagAttrib[0][$attr]) + || (($params['removeIfFalse'] ?? false) && $params['removeIfFalse'] === 'blank' && (string)$tagAttrib[0][$attr] === '') + ) { + unset($tagAttrib[0][$attr]); + } + if ((string)($params['removeIfEquals'] ?? '') !== '') { + $normalizedAttribute = $tagAttrib[0][$attr]; + $normalizedRemoveIfEquals = $params['removeIfEquals']; + if (!($params['casesensitiveComp'] ?? false)) { + // Case-sensitive comparison is not wanted, normalize all values + $normalizedAttribute = strtoupper($tagAttrib[0][$attr]); + $normalizedRemoveIfEquals = strtoupper($params['removeIfEquals']); + } + + if ($normalizedAttribute === $normalizedRemoveIfEquals) { + unset($tagAttrib[0][$attr]); + } + } + if ($params['prefixRelPathWith'] ?? false) { + $urlParts = parse_url($tagAttrib[0][$attr]); + if (is_array($urlParts) && empty($urlParts['scheme']) && !empty($urlParts['path']) && !str_starts_with($urlParts['path'], '/')) { + // If it is NOT an absolute URL (by http: or starting "/") + $tagAttrib[0][$attr] = $params['prefixRelPathWith'] . $tagAttrib[0][$attr]; + } + } + if ($params['userFunc'] ?? false) { + if (is_array($params['userFunc.'] ?? null)) { + $params['userFunc.']['attributeValue'] = $tagAttrib[0][$attr]; + } else { + $params['userFunc.'] = $tagAttrib[0][$attr]; + } + $tagAttrib[0][$attr] = GeneralUtility::callUserFunction($params['userFunc'], $params['userFunc.'], $this); + } + } + } + $tagParts[1] = $this->compileTagAttribs($tagAttrib[0], $tagAttrib[1]); + } + } else { + // If endTag, remove any possible attributes: + $tagParts[1] = ''; + } + // Protecting the tag by converting < and > to < and > ?? + if (!empty($tags[$tagName]['protect'])) { + $lt = '<'; + $gt = '>'; + } else { + $lt = '<'; + $gt = '>'; + } + // Remapping tag name? + if (!empty($tags[$tagName]['remap'])) { + $tagParts[0] = $tags[$tagName]['remap']; + } + // rmTagIfNoAttrib + if ($endTag || empty($tags[$tagName]['rmTagIfNoAttrib']) || trim($tagParts[1] ?? '')) { + $setTag = true; + // Remove this closing tag if $tagName was among $TSconfig['removeTags'] + if ($endTag + && isset($tags[$tagName]['allowedAttribs']) && $tags[$tagName]['allowedAttribs'] === 0 + && isset($tags[$tagName]['rmTagIfNoAttrib']) && $tags[$tagName]['rmTagIfNoAttrib'] === 1 + ) { + $setTag = false; + } + if (isset($tags[$tagName]['nesting'])) { + if (!isset($tagRegister[$tagName])) { + $tagRegister[$tagName] = []; + } + if ($endTag) { + $correctTag = true; + if ($tags[$tagName]['nesting'] === 'global') { + $lastEl = end($tagStack); + if ($tagName !== $lastEl) { + if (in_array($tagName, $tagStack, true)) { + while (!empty($tagStack) && $tagName !== $lastEl) { + $elPos = end($tagRegister[$lastEl]); + unset($newContent[$elPos]); + array_pop($tagRegister[$lastEl]); + array_pop($tagStack); + $lastEl = end($tagStack); + } + } else { + // In this case the + $correctTag = false; + } + } + } + if (empty($tagRegister[$tagName]) || !$correctTag) { + $setTag = false; + } else { + array_pop($tagRegister[$tagName]); + if ($tags[$tagName]['nesting'] === 'global') { + array_pop($tagStack); + } + } + } else { + $tagRegister[$tagName][] = $c; + if ($tags[$tagName]['nesting'] === 'global') { + $tagStack[] = $tagName; + } + } + } + if ($setTag) { + // Setting the tag + $newContent[$c++] = $lt . ($endTag ? '/' : '') . trim($tagParts[0] . ' ' . ($tagParts[1] ?? '')) . ($emptyTag ? ' /' : '') . $gt; + } + } + } else { + $newContent[$c++] = '<' . ($endTag ? '/' : '') . $tagContent . '>'; + } + } elseif ($keepAll) { + // This is if the tag was not defined in the array for processing: + if ($keepAll === 'protect') { + $lt = '<'; + $gt = '>'; + } else { + $lt = '<'; + $gt = '>'; + } + $newContent[$c++] = $lt . ($endTag ? '/' : '') . $tagContent . $gt; + } + $newContent[$c++] = $this->bidir_htmlspecialchars(substr($tok, $tagEnd + 1), $hSC); + } else { + $newContent[$c++] = $this->bidir_htmlspecialchars('<' . $tok, $hSC); + } + } else { + $newContent[$c++] = $this->bidir_htmlspecialchars(($skipTag ? '' : '<') . $tok, $hSC); + // It was not a tag anyways + $skipTag = false; + } + } + // Unsetting tags: + foreach ($tagRegister as $positions) { + foreach ($positions as $pKey) { + unset($newContent[$pKey]); + } + } + $newContent = implode('', $newContent); + $newContent = $this->stripEmptyTagsIfConfigured($newContent, $addConfig); + return $newContent; + } + + /** + * Converts htmlspecialchars forth ($dir=1) AND back ($dir=-1) + * + * @param string $value Input value + * @param int $dir Direction: forth ($dir=1, dir=2 for preserving entities) AND back ($dir=-1) + * @return string Output value + */ + public function bidir_htmlspecialchars($value, $dir) + { + switch ((int)$dir) { + case 1: + return htmlspecialchars($value); + case 2: + return htmlspecialchars($value, ENT_COMPAT, 'UTF-8', false); + case -1: + return htmlspecialchars_decode($value); + default: + return $value; + } + } + + /** + * Prefixes the relative paths of hrefs/src/action in the tags [td,table,body,img,input,form,link,script,a] + * in the $content with the $main_prefix or and alternative given by $alternatives + * + * @param string $main_prefix Prefix string + * @param string $content HTML content + * @param array $alternatives Array with alternative prefixes for certain of the tags. key=>value pairs where the keys are the tag element names in uppercase + * @param string $suffix Suffix string (put after the resource). + * @return string Processed HTML content + */ + public function prefixResourcePath($main_prefix, $content, $alternatives = [], $suffix = '') + { + $parts = $this->splitTags('embed,td,table,body,img,input,form,link,script,a,param,source', $content); + foreach ($parts as $k => $v) { + if ($k % 2) { + $params = $this->get_tag_attributes($v); + // Detect tag-ending so that it is re-applied correctly. + $tagEnd = substr($v, -2) === '/>' ? ' />' : '>'; + // The 'name' of the first tag + $firstTagName = $this->getFirstTagName($v); + $prefixedRelPath = false; + $prefix = $alternatives[strtoupper($firstTagName)] ?? $main_prefix; + switch (strtolower($firstTagName)) { + case 'td': + case 'body': + case 'table': + if (isset($params[0]['background'])) { + $params[0]['background'] = $this->prefixRelPath($prefix, $params[0]['background'], $suffix); + $prefixedRelPath = true; + } + break; + case 'img': + case 'input': + case 'script': + case 'embed': + if (isset($params[0]['src'])) { + $params[0]['src'] = $this->prefixRelPath($prefix, $params[0]['src'], $suffix); + $prefixedRelPath = true; + } + break; + case 'link': + case 'a': + if (isset($params[0]['href'])) { + $params[0]['href'] = $this->prefixRelPath($prefix, $params[0]['href'], $suffix); + $prefixedRelPath = true; + } + break; + case 'form': + if (isset($params[0]['action'])) { + $params[0]['action'] = $this->prefixRelPath($prefix, $params[0]['action'], $suffix); + $prefixedRelPath = true; + } + break; + case 'param': + if (isset($params[0]['name']) && $params[0]['name'] === 'movie' && isset($params[0]['value'])) { + $params[0]['value'] = $this->prefixRelPath($prefix, $params[0]['value'], $suffix); + $prefixedRelPath = true; + } + break; + case 'source': + if (isset($params[0]['srcset'])) { + $srcsetImagePaths = GeneralUtility::trimExplode(',', $params[0]['srcset']); + for ($i = 0; $i < count($srcsetImagePaths); $i++) { + $srcsetImagePaths[$i] = $this->prefixRelPath($prefix, $srcsetImagePaths[$i], $suffix); + } + $params[0]['srcset'] = implode(', ', $srcsetImagePaths); + $prefixedRelPath = true; + } + break; + } + if ($prefixedRelPath) { + $tagParts = preg_split('/\\s+/s', $v, 2); + $tagParts[1] = $this->compileTagAttribs($params[0], $params[1]); + $parts[$k] = '<' . trim(strtolower($firstTagName) . ' ' . $tagParts[1]) . $tagEnd; + } + } + } + $content = implode('', $parts); + // Fix '; + $assets = $this->assetCollector->getInlineStyleSheets($priority); + return $this->render($assets, $template, true, Directive::StyleSrcElem, $nonce); + } + + public function renderStyleSheets(bool $priority = false, string $endingSlash = '', ?ConsumableNonce $nonce = null): string + { + $this->eventDispatcher->dispatch( + new BeforeStylesheetsRenderingEvent($this->assetCollector, false, $priority) + ); + + $template = ''; + $assets = $this->assetCollector->getStyleSheets($priority); + foreach ($assets as &$assetData) { + $originalSource = $assetData['source']; + // Collect CSP hash from original source path before URL transformation + if (!empty($assetData['options']['csp'])) { + $integrity = $assetData['attributes']['integrity'] ?? ''; + if ($integrity !== '') { + $this->directiveHashCollection->addGenericHashValue(Directive::StyleSrcElem, $integrity); + } else { + $this->directiveHashCollection->addResourceHash(Directive::StyleSrcElem, $assetData['source']); + } + } + $assetData['source'] = $this->getAbsoluteWebPath($assetData['source']); + $assetData['attributes']['href'] = $assetData['source']; + $assetData['attributes']['rel'] = $assetData['attributes']['rel'] ?? 'stylesheet'; + if (($assetData['attributes']['integrity'] ?? '') === ResourceHashCollection::AUTO) { + $hash = $this->resourceHashCollection->fetchResourceHash($originalSource)?->export() ?? ''; + if ($hash !== '') { + $assetData['attributes']['integrity'] = $hash; + if (empty($assetData['attributes']['crossorigin']) && PathUtility::hasProtocolAndScheme($originalSource)) { + $assetData['attributes']['crossorigin'] = 'anonymous'; + } + } else { + unset($assetData['attributes']['integrity']); + } + } + } + return $this->render($assets, $template, false, Directive::StyleSrcElem, $nonce); + } + + protected function render( + array $assets, + string $template, + bool $isInline, + Directive $directive, + ?ConsumableNonce $nonce = null + ): string { + $results = []; + foreach ($assets as $assetData) { + $attributes = $assetData['attributes']; + $useCsp = !empty($assetData['options']['csp']); + if ($isInline && $useCsp) { + $this->directiveHashCollection->addInlineHash($directive, $assetData['source']); + } + if ($nonce !== null && $useCsp) { + $attributes['nonce'] = $isInline ? $nonce->consumeInline($directive) : $nonce->consumeStatic($directive); + } + $attributesString = count($attributes) ? ' ' . GeneralUtility::implodeAttributes($attributes, true) : ''; + $results[] = str_replace( + ['%attributes%', '%source%'], + [$attributesString, $assetData['source']], + $template + ); + } + return implode(LF, $results); + } + + private function getAbsoluteWebPath(string $file): string + { + $resource = $this->systemResourceFactory->createPublicResource($file); + return (string)$this->resourcePublisher->generateUri($resource, null); + } +} diff --git a/Classes/Page/ContentArea.php b/Classes/Page/ContentArea.php new file mode 100644 index 0000000..1c2dc99 --- /dev/null +++ b/Classes/Page/ContentArea.php @@ -0,0 +1,98 @@ +identifier; + } + + public function getName(): string + { + return $this->name; + } + + public function getColPos(): int + { + return $this->colPos; + } + + public function getSlideMode(): ContentSlideMode + { + return $this->slideMode; + } + + public function getAllowedContentTypes(): array + { + return $this->allowedContentTypes; + } + + public function getDisallowedContentTypes(): array + { + return $this->disallowedContentTypes; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function getRecords(): array + { + return $this->records; + } + + /** + * @internal Only to be used for AfterContentHasBeenFetchedEvent + */ + public function withRecords(array $records): self + { + $self = clone $this; + $self->records = $records; + return $self; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->records); + } + + public function count(): int + { + return count($this->records); + } +} diff --git a/Classes/Page/ContentAreaClosure.php b/Classes/Page/ContentAreaClosure.php new file mode 100644 index 0000000..7725ebc --- /dev/null +++ b/Classes/Page/ContentAreaClosure.php @@ -0,0 +1,37 @@ +instantiator)($request); + } +} diff --git a/Classes/Page/ContentAreaCollection.php b/Classes/Page/ContentAreaCollection.php new file mode 100644 index 0000000..ddb0f3f --- /dev/null +++ b/Classes/Page/ContentAreaCollection.php @@ -0,0 +1,106 @@ + + */ +final readonly class ContentAreaCollection implements ContainerInterface, \IteratorAggregate +{ + public function __construct( + /** @var ContentAreaClosure[]|ContentArea[] $contentAreas */ + private array $contentAreas, + private ?ServerRequestInterface $request = null, + ) {} + + public function withRequest(ServerRequestInterface $request): self + { + return new self($this->contentAreas, $request); + } + + public function get(string $id): ContentArea + { + if (!$this->has($id)) { + throw new ContentAreaNotFoundException('No content area found for identifier: ' . $id, 1726479567); + } + $area = $this->contentAreas[$id]; + if ($area instanceof ContentAreaClosure) { + if ($this->request === null) { + throw new \LogicException('Cannot instantiate ContentAreaClosure without a request. Call withRequest() first.', 1776158770); + } + return $area->instantiate($this->request); + } + return $area; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->contentAreas); + } + + /** + * @internal Only for AfterContentHasBeenFetchedEvent + */ + public function getGroupedRecords(ServerRequestInterface $request): array + { + $areas = []; + foreach ($this->contentAreas as $area) { + $area = $area instanceof ContentAreaClosure ? $area->instantiate($request) : $area; + $areas[$area->getIdentifier()] = [ + 'name' => $area->getName(), + 'colPos' => $area->getColPos(), + 'identifier' => $area->getIdentifier(), + 'allowedContentTypes' => $area->getAllowedContentTypes(), + 'records' => $area->getRecords(), + 'area' => $area, + ]; + } + return $areas; + } + + /** + * @internal Only for AfterContentHasBeenFetchedEvent + */ + public function withUpdatedRecords(array $groupedRecords): self + { + $areas = []; + foreach ($groupedRecords as $identifier => $data) { + $areas[$identifier] = $data['area']->withRecords($data['records']); + } + return new self($areas, $this->request); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + $result = []; + foreach (array_keys($this->contentAreas) as $key) { + $result[$key] = $this->get($key); + } + return new \ArrayIterator($result); + } +} diff --git a/Classes/Page/ContentAreaNotFoundException.php b/Classes/Page/ContentAreaNotFoundException.php new file mode 100644 index 0000000..5c2d2ea --- /dev/null +++ b/Classes/Page/ContentAreaNotFoundException.php @@ -0,0 +1,26 @@ + self::Slide, + 'collect' => self::Collect, + 'collectReverse' => self::CollectReverse, + default => self::None, + }; + } + + public function getValue(): string + { + return lcfirst($this->name); + } +} diff --git a/Classes/Page/DefaultJavaScriptAssetTrait.php b/Classes/Page/DefaultJavaScriptAssetTrait.php new file mode 100644 index 0000000..b4a93a7 --- /dev/null +++ b/Classes/Page/DefaultJavaScriptAssetTrait.php @@ -0,0 +1,58 @@ +shallRemoveDefaultFrontendJavaScript($request)) { + return; + } + $filePath = 'EXT:frontend/Resources/Public/JavaScript/default_frontend.js'; + $collector = GeneralUtility::makeInstance(AssetCollector::class); + // `config.removeDefaultJS = external` - persist JavaScript to `typo3temp/assets/` + if ($this->shallExportDefaultFrontendJavaScript($request)) { + $source = file_get_contents(GeneralUtility::getFileAbsFileName($filePath)); + $filePath = GeneralUtility::writeJavaScriptContentToTemporaryFile((string)$source); + } + $collector->addJavaScript('frontend-default', $filePath, ['async' => 'async']); + } + + protected function shallRemoveDefaultFrontendJavaScript(ServerRequestInterface $request): bool + { + $frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray(); + return ($frontendTypoScriptConfigArray['removeDefaultJS'] ?? 'external') === '1'; + } + + protected function shallExportDefaultFrontendJavaScript(ServerRequestInterface $request): bool + { + $frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray(); + return ($frontendTypoScriptConfigArray['removeDefaultJS'] ?? 'external') === 'external'; + } +} diff --git a/Classes/Page/Event/AbstractBeforeAssetRenderingEvent.php b/Classes/Page/Event/AbstractBeforeAssetRenderingEvent.php new file mode 100644 index 0000000..4d0a3fd --- /dev/null +++ b/Classes/Page/Event/AbstractBeforeAssetRenderingEvent.php @@ -0,0 +1,42 @@ +assetCollector; + } + + public function isInline(): bool + { + return $this->inline; + } + + public function isPriority(): bool + { + return $this->priority; + } +} diff --git a/Classes/Page/Event/BeforeJavaScriptsRenderingEvent.php b/Classes/Page/Event/BeforeJavaScriptsRenderingEvent.php new file mode 100644 index 0000000..b9b37ae --- /dev/null +++ b/Classes/Page/Event/BeforeJavaScriptsRenderingEvent.php @@ -0,0 +1,33 @@ +assetCollector = $assetCollector; + $this->inline = $isInline; + $this->priority = $priority; + } +} diff --git a/Classes/Page/Event/BeforeStylesheetsRenderingEvent.php b/Classes/Page/Event/BeforeStylesheetsRenderingEvent.php new file mode 100644 index 0000000..4d112df --- /dev/null +++ b/Classes/Page/Event/BeforeStylesheetsRenderingEvent.php @@ -0,0 +1,33 @@ +assetCollector = $assetCollector; + $this->inline = $isInline; + $this->priority = $priority; + } +} diff --git a/Classes/Page/Event/ResolveJavaScriptImportEvent.php b/Classes/Page/Event/ResolveJavaScriptImportEvent.php new file mode 100644 index 0000000..5197755 --- /dev/null +++ b/Classes/Page/Event/ResolveJavaScriptImportEvent.php @@ -0,0 +1,40 @@ +resolution !== null; + } +} diff --git a/Classes/Page/Event/ResolveVirtualJavaScriptImportEvent.php b/Classes/Page/Event/ResolveVirtualJavaScriptImportEvent.php new file mode 100644 index 0000000..e3da095 --- /dev/null +++ b/Classes/Page/Event/ResolveVirtualJavaScriptImportEvent.php @@ -0,0 +1,42 @@ +resolution !== null; + } +} diff --git a/Classes/Page/ImportMap.php b/Classes/Page/ImportMap.php new file mode 100644 index 0000000..a407cef --- /dev/null +++ b/Classes/Page/ImportMap.php @@ -0,0 +1,421 @@ + $packages + */ + public function __construct( + protected readonly HashService $hashService, + protected readonly array $packages, + protected readonly ?PolicyRegistry $policyRegistry = null, + protected readonly ?FrontendInterface $cache = null, + protected readonly string $cacheIdentifier = '', + protected readonly ?EventDispatcherInterface $eventDispatcher = null, + protected readonly bool $bustSuffix = true + ) {} + + /** + * HEADS UP: Do only use in authenticated mode as this discloses as installed extensions + */ + public function includeAllImports(): void + { + $this->extensionsToLoad['*'] = true; + } + + public function includeTaggedImports(string $tag): void + { + if (isset($this->extensionsToLoad['*'])) { + return; + } + + foreach ($this->getImportMaps() as $package => $config) { + $tags = $config['tags'] ?? []; + if (in_array($tag, $tags, true)) { + $this->loadDependency($package); + } + } + } + + public function includeImportsFor(string $specifier): void + { + if (!isset($this->extensionsToLoad['*'])) { + $this->resolveImport($specifier, true); + } else { + $this->dispatchResolveJavaScriptImportEvent($specifier, true); + } + } + + /** + * @return ?non-empty-string + */ + public function resolveImport( + string $specifier, + bool $loadImportConfiguration = true, + string $uriPrefix = '/' + ): ?string { + $resolution = $this->dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration); + if ($resolution !== null) { + return $resolution; + } + + foreach (array_reverse($this->getImportMaps()) as $package => $config) { + $imports = $config['imports'] ?? []; + if (isset($imports[$specifier])) { + if ($loadImportConfiguration) { + $this->loadDependency($package); + } + return $this->getResourceUri($imports[$specifier], $uriPrefix); + } + + $specifierParts = explode('/', $specifier); + $specifierPartCount = count($specifierParts); + for ($i = 1; $i < $specifierPartCount; ++$i) { + $prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/'; + if (isset($imports[$prefix])) { + if ($loadImportConfiguration) { + $this->loadDependency($package); + } + return $this->getResourceUri($imports[$prefix] . implode('/', array_slice($specifierParts, $i)), $uriPrefix); + } + } + } + + return null; + } + + public function render( + string $uriPrefix, + string|ConsumableNonce|null $nonce + ): string { + if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) { + return ''; + } + + $html = []; + + $importMap = $this->composeImportMap($uriPrefix); + $json = json_encode( + $importMap, + JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR + ); + $attributes = [ + 'type' => 'importmap', + ]; + if ($nonce !== null) { + $attributes['nonce'] = $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : $nonce; + } else { + $this->policyRegistry?->appendMutationCollection( + new MutationCollection( + new Mutation(MutationMode::Extend, Directive::ScriptSrcElem, HashValue::hash($json)) + ) + ); + } + $html[] = sprintf( + '', + GeneralUtility::implodeAttributes($attributes, true), + $json + ); + + return implode(PHP_EOL, $html) . PHP_EOL; + } + + public function warmupCaches(): void + { + $this->computeImportMaps(); + } + + protected function getImportMaps(): array + { + return $this->importMaps ?? $this->getFromCache() ?? $this->computeImportMaps(); + } + + protected function getFromCache(): ?array + { + if ($this->cache === null) { + return null; + } + if (!$this->cache->has($this->cacheIdentifier)) { + return null; + } + $importMaps = $this->cache->get($this->cacheIdentifier); + if ($importMaps === false) { + // Cache entry has been removed in the meantime + return null; + } + if (!is_array($importMaps)) { + // An invalid result is to be ignored (cache will be recreated) + return null; + } + $this->importMaps = $importMaps; + return $importMaps; + } + + protected function computeImportMaps(): array + { + $extensionVersions = []; + $importMaps = []; + foreach ($this->packages as $package) { + $configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php'; + if (!is_readable($configurationFile)) { + continue; + } + $extensionVersions[$package->getPackageKey()] = implode(':', [ + $package->getPackageKey(), + $package->getPackageMetadata()->getVersion(), + ]); + $packageConfiguration = require($configurationFile); + $importMaps[$package->getPackageKey()] = $packageConfiguration ?? []; + } + + $isDevelopment = Environment::getContext()->isDevelopment(); + if ($isDevelopment) { + $bust = (string)$GLOBALS['EXEC_TIME']; + } else { + $bust = $this->hashService->hmac( + Environment::getProjectPath() . implode('|', $extensionVersions), + self::class + ); + } + + foreach ($importMaps as $packageName => $config) { + $importMaps[$packageName]['imports'] = $this->resolvePaths( + $config['imports'] ?? [], + $this->bustSuffix ? $bust : null + ); + } + + $this->importMaps = $importMaps; + if ($this->cache !== null) { + $this->cache->set($this->cacheIdentifier, $importMaps); + } + return $importMaps; + } + + protected function resolveRecursiveImportMap( + string $prefix, + string $pathResourceIdentifier, + array $exclude, + string $bust + ): array { + // @todo resolve with resource directory object once available + $absolutePath = GeneralUtility::getFileAbsFileName($pathResourceIdentifier); + if (!$absolutePath || @!is_dir($absolutePath)) { + return []; + } + $exclude = array_map( + static fn(string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath), + $exclude + ); + + $fileIterator = new \RegexIterator( + new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($absolutePath) + ), + '#^' . preg_quote($absolutePath, '#') . '(.+\.js)$#', + \RegexIterator::GET_MATCH + ); + + $map = []; + foreach ($fileIterator as $match) { + $fileName = $match[0]; + $specifier = $prefix . ($match[1] ?? ''); + $resourceIdentifier = $pathResourceIdentifier . ($match[1] ?? ''); + + // @todo: Abstract into an iterator? + foreach ($exclude as $excludedPath) { + if (str_starts_with($fileName, $excludedPath)) { + continue 2; + } + } + $map[$specifier] = $resourceIdentifier . '?bust=' . $bust; + } + + return $map; + } + + protected function resolvePaths( + array $imports, + ?string $bust = null + ): array { + $cacheBustingSpecifiers = []; + foreach ($imports as $specifier => $address) { + if (is_string($address) && str_starts_with($address, 'VIRTUAL:')) { + $imports[$specifier] = $address; + continue; + } + if (str_ends_with($specifier, '/')) { + $resourceIdentifier = is_array($address) ? ($address['path'] ?? '') : $address; + $exclude = is_array($address) ? ($address['exclude'] ?? []) : []; + if ($bust !== null) { + // Resolve recursive importmap in order to add a bust suffix + // to each file. + $cacheBustingSpecifiers[] = $this->resolveRecursiveImportMap($specifier, $resourceIdentifier, $exclude, $bust); + } + } else { + $resourceIdentifier = $address; + if ($bust !== null) { + $resourceIdentifier .= '?bust=' . $bust; + } + } + $imports[$specifier] = $resourceIdentifier; + } + + return $imports + array_merge(...$cacheBustingSpecifiers); + } + + protected function loadDependency(string $packageName): void + { + if (isset($this->extensionsToLoad[$packageName])) { + return; + } + + $this->extensionsToLoad[$packageName] = true; + $dependencies = $this->getImportMaps()[$packageName]['dependencies'] ?? []; + foreach ($dependencies as $dependency) { + $this->loadDependency($dependency); + } + } + + protected function composeImportMap(string $uriPrefix): array + { + $importMaps = $this->getImportMaps(); + + if (!isset($this->extensionsToLoad['*'])) { + $importMaps = array_intersect_key($importMaps, $this->extensionsToLoad); + } + + $importMap = []; + foreach ($importMaps as $singleImportMap) { + ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap); + } + unset($importMap['dependencies']); + unset($importMap['tags']); + + foreach ($importMap['imports'] ?? [] as $specifier => $resourceIdentifier) { + if (str_starts_with($resourceIdentifier, 'VIRTUAL:')) { + $virtualName = substr($resourceIdentifier, 8); + $resolved = $this->dispatchResolveVirtualJavaScriptImportEvent($virtualName); + if ($resolved === null) { + unset($importMap['imports'][$specifier]); + continue; + } + } else { + $resolved = $this->getResourceUri($resourceIdentifier, $uriPrefix); + } + $importMap['imports'][$specifier] = $resolved; + } + + return $importMap; + } + + /** + * @throws CanNotResolvePublicResourceException + * @throws CanNotResolveSystemResourceException + */ + protected function getResourceUri(string $resourceIdentifier, $uriPrefix): string + { + return (string)PathUtility::getSystemResourceUri( + $resourceIdentifier, + null, + new UriGenerationOptions( + uriPrefix: $uriPrefix, + cacheBusting: false, + ) + ); + } + + /** + * @return ?non-empty-string + */ + protected function dispatchResolveJavaScriptImportEvent( + string $specifier, + bool $loadImportConfiguration = true + ): ?string { + if ($this->eventDispatcher === null) { + return null; + } + + return $this->eventDispatcher->dispatch( + new ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this) + )->resolution; + } + + /** + * @return ?non-empty-string + */ + protected function dispatchResolveVirtualJavaScriptImportEvent( + string $specifier, + ): ?string { + if ($this->eventDispatcher === null) { + return null; + } + + return $this->eventDispatcher->dispatch( + new ResolveVirtualJavaScriptImportEvent($specifier, $this) + )->resolution; + } + + /** + * @internal + */ + public function updateState(array $state): void + { + $this->extensionsToLoad = $state['extensionsToLoad'] ?? []; + } + + /** + * @internal + */ + public function getState(): array + { + return [ + 'extensionsToLoad' => $this->extensionsToLoad, + ]; + } +} diff --git a/Classes/Page/ImportMapCacheWarmer.php b/Classes/Page/ImportMapCacheWarmer.php new file mode 100644 index 0000000..a784dca --- /dev/null +++ b/Classes/Page/ImportMapCacheWarmer.php @@ -0,0 +1,39 @@ +hasGroup('system')) { + $this->importMapFactory->create(true)->warmupCaches(); + } + } +} diff --git a/Classes/Page/ImportMapFactory.php b/Classes/Page/ImportMapFactory.php new file mode 100644 index 0000000..4003fcd --- /dev/null +++ b/Classes/Page/ImportMapFactory.php @@ -0,0 +1,58 @@ +packageManager->getActivePackages() + ); + return new ImportMap( + $this->hashService, + $activePackages, + $this->policyRegistry, + $this->assetsCache, + $this->cacheIdentifier, + $this->eventDispatcher, + $bustSuffix + ); + } +} diff --git a/Classes/Page/JavaScriptItems.php b/Classes/Page/JavaScriptItems.php new file mode 100644 index 0000000..9fc9135 --- /dev/null +++ b/Classes/Page/JavaScriptItems.php @@ -0,0 +1,122 @@ + + */ + private array $globalAssignments = []; + + /** + * @var list + */ + private array $javaScriptModuleInstructions = []; + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function addGlobalAssignment(array $payload): void + { + if (empty($payload)) { + return; + } + $this->globalAssignments[] = $payload; + } + + public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void + { + $this->javaScriptModuleInstructions[] = $instruction; + } + + /** + * @return list + * @internal + */ + public function toArray(): array + { + if ($this->isEmpty()) { + return []; + } + $items = []; + foreach ($this->globalAssignments as $item) { + $items[] = [ + 'type' => 'globalAssignment', + 'payload' => $item, + ]; + } + foreach ($this->javaScriptModuleInstructions as $item) { + $items[] = [ + 'type' => 'javaScriptModuleInstruction', + 'payload' => $item, + ]; + } + return $items; + } + + public function isEmpty(): bool + { + return $this->globalAssignments === [] + && empty($this->javaScriptModuleInstructions); + } + + /** + * @internal + */ + public function updateState(array $state): void + { + $this->globalAssignments = $state['globalAssignments'] ?? []; + $this->javaScriptModuleInstructions = []; + foreach ($state['javaScriptModuleInstructions'] ?? [] as $instruction) { + $this->javaScriptModuleInstructions[] = JavaScriptModuleInstruction::fromState($instruction); + } + } + + /** + * @internal + */ + public function getState(): array + { + return [ + 'globalAssignments' => $this->globalAssignments, + 'javaScriptModuleInstructions' => array_map( + static fn(JavaScriptModuleInstruction $instruction): array => $instruction->getState(), + $this->javaScriptModuleInstructions + ), + ]; + } + + /** + * @return list + */ + public function getGlobalAssignments(): array + { + return $this->globalAssignments; + } + + /** + * @return list + */ + public function getJavaScriptModuleInstructions(): array + { + return $this->javaScriptModuleInstructions; + } +} diff --git a/Classes/Page/JavaScriptModuleInstruction.php b/Classes/Page/JavaScriptModuleInstruction.php new file mode 100644 index 0000000..165d783 --- /dev/null +++ b/Classes/Page/JavaScriptModuleInstruction.php @@ -0,0 +1,174 @@ +exportName = $exportName; + return $target; + } + + /** + * @return self + * @internal + */ + public static function fromState(array $state) + { + $target = GeneralUtility::makeInstance(static::class, $state['name'], $state['flags'] ?? 0); + $target->exportName = $state['exportName'] ?? null; + $target->items = $state['items']; + return $target; + } + + /** + * @param string $name Module name + */ + public function __construct(string $name, int $flags) + { + $this->name = $name; + $this->flags = $flags; + } + + /** + * @internal + */ + public function getState(): array + { + return [ + 'name' => $this->name, + 'exportName' => $this->exportName, + 'flags' => $this->flags, + 'items' => $this->items, + ]; + } + + public function jsonSerialize(): array + { + return $this->getState(); + } + + public function getName(): string + { + return $this->name; + } + + public function getExportName(): ?string + { + return $this->exportName; + } + + public function getFlags(): int + { + return $this->flags; + } + + public function getItems(): array + { + return $this->items; + } + + /** + * @return $this + */ + public function addFlags(int ...$flags): self + { + foreach ($flags as $flag) { + $this->flags |= $flag; + } + return $this; + } + + /** + * @param array $assignments key-value assignments + * @return static + */ + public function assign(array $assignments): self + { + $this->items[] = [ + 'type' => static::ITEM_ASSIGN, + 'assignments' => $assignments, + ]; + return $this; + } + + /** + * @param string|null $method method of JavaScript module to be invoked + * @param mixed ...$args corresponding method arguments + * @return static + */ + public function invoke(?string $method = null, ...$args): self + { + $this->items[] = [ + 'type' => static::ITEM_INVOKE, + 'method' => $method, + 'args' => $args, + ]; + return $this; + } + + /** + * @param mixed ...$args new instance arguments + * @return static + */ + public function instance(...$args): self + { + $this->items[] = [ + 'type' => static::ITEM_INSTANCE, + 'args' => $args, + ]; + return $this; + } + + public function shallLoadImportMap(): bool + { + return ($this->flags & self::FLAG_LOAD_IMPORTMAP) === self::FLAG_LOAD_IMPORTMAP; + } + + public function shallUseTopWindow(): bool + { + return ($this->flags & self::FLAG_USE_TOP_WINDOW) === self::FLAG_USE_TOP_WINDOW; + } +} diff --git a/Classes/Page/JavaScriptRenderer.php b/Classes/Page/JavaScriptRenderer.php new file mode 100644 index 0000000..f86f39d --- /dev/null +++ b/Classes/Page/JavaScriptRenderer.php @@ -0,0 +1,290 @@ +handlerResource = $handlerResource; + $this->items = new JavaScriptItems(); + $this->importMap = GeneralUtility::makeInstance(ImportMapFactory::class)->create(); + } + + public function addGlobalAssignment(array $payload): void + { + $this->items->addGlobalAssignment($payload); + } + + public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void + { + if ($instruction->shallLoadImportMap()) { + $this->importMap->includeImportsFor($instruction->getName()); + } + $this->javaScriptModuleInstructionFlags |= $instruction->getFlags(); + if ($instruction->getItems() !== []) { + $this->instructionsWithItems++; + } + $this->items->addJavaScriptModuleInstruction($instruction); + } + + public function hasImportMap(): bool + { + return ($this->javaScriptModuleInstructionFlags & JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP) === JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP; + } + + /** + * HEADS UP: Do only use in authenticated mode as this discloses as installed extensions + */ + public function includeAllImports(): void + { + $this->importMap->includeAllImports(); + } + + public function includeTaggedImports(string $tag): void + { + $this->importMap->includeTaggedImports($tag); + } + + /** + * @return list + * @internal + */ + public function toArray(): array + { + if ($this->isEmpty()) { + return []; + } + return $this->items->toArray(); + } + + /** + * @throws \InvalidArgumentException when a JavaScript module could not be resolved (no src URL in import map) + */ + public function render(string|ConsumableNonce|null $nonce, string $uriPrefix): string + { + if ($this->isEmpty()) { + return ''; + } + + $scriptTags = []; + + $modules = []; + $dynamicInstructions = []; + foreach ($this->items->getJavascriptModuleInstructions() as $instruction) { + $moduleName = $instruction->getName(); + $url = $this->importMap->resolveImport($moduleName, true, $uriPrefix); + if ($url === null) { + throw new \InvalidArgumentException( + sprintf( + 'JavaScript module "%s" could not be resolved. (Missing entry in Configuration/JavaScriptModules.php?).', + $moduleName + ), + 1728220800 + ); + } + if ( + $instruction->getItems() !== [] + || ($instruction->getFlags() & JavaScriptModuleInstruction::FLAG_USE_TOP_WINDOW) !== 0 + ) { + $dynamicInstructions[] = [ + 'type' => 'javaScriptModuleInstruction', + 'payload' => $instruction, + ]; + } else { + $modules[$moduleName] = $url; + } + } + + $globalAssignments = $this->mergeGlobalAssignments($this->items->getGlobalAssignments()); + if ($globalAssignments !== []) { + $scriptTags[] = $this->createScriptElement( + ['nonce' => $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : (string)$nonce], + sprintf('Object.assign(globalThis, %s)', $this->jsonEncode($globalAssignments)) + ); + } + $scriptTags = [ + ...$scriptTags, + ...array_map( + fn(string $url): string => $this->createScriptElement([ + 'type' => 'module', + 'async' => 'async', + 'src' => $url, + ]), + $modules + ), + ]; + + if ($dynamicInstructions !== []) { + $scriptTags[] = $this->createItemHandlerElement($dynamicInstructions, true, $nonce, $uriPrefix); + } + + return implode(PHP_EOL, $scriptTags); + } + + public function renderImportMap(string $uriPrefix, string|ConsumableNonce|null $nonce = null): string + { + if (!$this->isEmpty() && ($this->instructionsWithItems > 0 || $this->items->getGlobalAssignments() !== [])) { + $this->importMap->includeImportsFor('@typo3/core/java-script-item-handler.js'); + } + return $this->importMap->render($uriPrefix, $nonce); + } + + protected function isEmpty(): bool + { + return $this->items->isEmpty(); + } + + protected function createItemHandlerElement(array $payload, bool $async, string|ConsumableNonce|null $nonce, string $uriPrefix): string + { + // actual JSON payload is stored as comment in `script.textContent` + // and consumed by java-script-item-handler.js + return $this->createScriptElement( + [ + 'src' => PathUtility::getSystemResourceUri( + $this->handlerResource, + null, + new UriGenerationOptions( + uriPrefix: $uriPrefix, + cacheBusting: false, + ) + ), + 'nonce' => (string)$nonce, + 'async' => $async ? 'async' : '', + ], + '/* ' . $this->jsonEncode($payload) . ' */' + ); + } + + protected function createScriptElement(array $attributes, string $textContent = ''): string + { + if (empty($attributes)) { + return ''; + } + $attributesPart = GeneralUtility::implodeAttributes($attributes, true); + return sprintf('%s', $attributesPart ? ' ' : '', $attributesPart, $textContent); + } + + protected function jsonEncode($value): string + { + return (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG); + } + + protected function mergeGlobalAssignments(array $assignments): array + { + $globalAssignments = []; + foreach ($assignments as $assignment) { + // Merge `window` one level up, as we target `globalThis` which is window. + // Needed because we assign to globalThis and must not overwrite window entirely, + // but only merge to it and because we want to forbid nested assignments like + // `window.parent.foo` below. + if (isset($assignment['window'])) { + $assignment = [ + ...$assignment, + ...$assignment['window'], + ]; + unset($assignment['window']); + } + $globalAssignments = array_merge_recursive($globalAssignments, $assignment); + } + + // deny indirect global assignments (not for security reasons, but for reducing + // the chance of hard-to-debug side-effects) + unset($globalAssignments['window']); + unset($globalAssignments['parent']); + unset($globalAssignments['globalThis']); + unset($globalAssignments['document']); + + // filter potential prototype pollution side-effects + return ArrayUtility::filterRecursive( + $globalAssignments, + static fn(string $key): bool => match ($key) { + '__proto__', 'prototype', 'constructor' => false, + default => true, + }, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * @internal + */ + public function updateState(array $state): void + { + foreach ($state as $var => $value) { + switch ($var) { + case 'items': + $this->items->updateState($value); + break; + case 'importMap': + $this->importMap->updateState($value); + break; + default: + $this->{$var} = $value; + break; + } + } + } + + /** + * @internal + */ + public function getState(): array + { + $state = []; + foreach (get_object_vars($this) as $var => $value) { + switch ($var) { + case 'items': + $state[$var] = $this->items->getState(); + break; + case 'importMap': + $state[$var] = $this->importMap->getState(); + break; + default: + $state[$var] = $value; + break; + } + } + return $state; + } +} diff --git a/Classes/Page/PageLayout.php b/Classes/Page/PageLayout.php new file mode 100644 index 0000000..2f48c73 --- /dev/null +++ b/Classes/Page/PageLayout.php @@ -0,0 +1,48 @@ +identifier; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getContentAreas(): ContentAreaCollection + { + return $this->contentAreas; + } +} diff --git a/Classes/Page/PageLayoutResolver.php b/Classes/Page/PageLayoutResolver.php new file mode 100644 index 0000000..3e1ec2e --- /dev/null +++ b/Classes/Page/PageLayoutResolver.php @@ -0,0 +1,114 @@ +getLayoutIdentifierForPage($pageRecord, $rootLine); + $layout = $this->dataProviderCollection->getBackendLayout($selectedPageLayout, $pageId); + + if ($layout === null) { + return null; + } + + $contentAreas = $this->eventDispatcher->dispatch(new ResolveContentAreasEvent($layout))->getContentAreas(); + return new PageLayout($layout->getIdentifier(), $layout->getTitle(), new ContentAreaCollection($contentAreas)); + } + + /** + * Check if the current page has a value in the DB field "backend_layout" + * if empty, check the root line for "backend_layout_next_level" + * Same as TypoScript: + * field = backend_layout + * ifEmpty.data = levelfield:-2, backend_layout_next_level, slide + * ifEmpty.ifEmpty = default + */ + public function getLayoutIdentifierForPage(array $page, array $rootLine): string + { + $selectedLayout = $page['backend_layout'] ?? ''; + + // If it is set to "none" - don't use any + if ($selectedLayout === '-1') { + return 'none'; + } + + if ($selectedLayout === '' || $selectedLayout === '0') { + // If it not set check the root-line for a layout on next level and use this + // Remove first element, which is the current page + // See also \TYPO3\CMS\Backend\View\BackendLayoutView::getSelectedCombinedIdentifier() + array_shift($rootLine); + foreach ($rootLine as $rootLinePage) { + $selectedLayout = (string)($rootLinePage['backend_layout_next_level'] ?? ''); + // If layout for "next level" is set to "none" - don't use any and stop searching + if ($selectedLayout === '-1') { + $selectedLayout = 'none'; + break; + } + if ($selectedLayout !== '' && $selectedLayout !== '0') { + // Stop searching if a layout for "next level" is set + break; + } + } + } + if ($selectedLayout === '0' || $selectedLayout === '') { + $selectedLayout = 'default'; + } + return $selectedLayout; + } + + public function getLayoutIdentifierForPageWithoutPrefix(array $page, array $rootLine): string + { + $selectedLayout = $this->getLayoutIdentifierForPage($page, $rootLine); + if (str_contains($selectedLayout, '__')) { + return explode('__', $selectedLayout, 2)[1] ?? ''; + } + return $selectedLayout; + } +} diff --git a/Classes/Page/PageRenderer.php b/Classes/Page/PageRenderer.php new file mode 100644 index 0000000..7af88cf --- /dev/null +++ b/Classes/Page/PageRenderer.php @@ -0,0 +1,1905 @@ + tag (depending on the DocType) and possible translation files. + */ + protected Locale $locale; + + // Arrays containing associative arrays for the included files + /** + * @var array + */ + protected array $jsFiles = []; + protected array $jsLibs = []; + + /** + * @var array + */ + protected array $cssFiles = []; + + /** + * @var array + */ + protected array $cssLibs = []; + + protected string $title = ''; + protected string $favIcon = ''; + + // Static header blocks + protected string $xmlPrologAndDocType = ''; + protected array $inlineComments = []; + protected array $headerData = []; + protected array $footerData = []; + protected string $titleTag = '|'; + protected string $htmlTag = ''; + protected string $headTag = ''; + protected string $iconMimeType = ''; + protected string $shortcutTag = ''; + + // Static inline code blocks + /** + * @var array + */ + protected array $jsInline = []; + + /** + * @var array + */ + protected array $cssInline = []; + protected string $bodyContent = ''; + protected string $templateFile = 'PKG:typo3/cms-core:Resources/Private/Templates/PageRenderer.html'; + protected array $inlineLanguageLabels = []; + protected array $inlineLanguageLabelFiles = []; + protected array $inlineSettings = []; + + /** + * Is empty string for HTML and ' /' for XHTML rendering + */ + protected string $endingSlash = ''; + + protected JavaScriptRenderer $javaScriptRenderer; + protected ?ConsumableNonce $nonce = null; + protected DocType $docType = DocType::html5; + protected bool $applyNonceHint = false; + + public function __construct( + protected readonly Context $context, + #[Autowire(service: 'cache.assets')] + protected readonly FrontendInterface $assetsCache, + protected readonly MarkerBasedTemplateService $templateService, + protected readonly MetaTagManagerRegistry $metaTagRegistry, + protected readonly AssetRenderer $assetRenderer, + protected readonly AssetCollector $assetCollector, + protected readonly RelativeCssPathFixer $relativeCssPathFixer, + protected readonly LanguageServiceFactory $languageServiceFactory, + protected readonly ResponseFactoryInterface $responseFactory, + protected readonly StreamFactoryInterface $streamFactory, + protected readonly IconRegistry $iconRegistry, + protected readonly SystemResourcePublisherInterface $resourcePublisher, + protected readonly SystemResourceFactory $systemResourceFactory, + protected readonly ResourceHashCollection $resourceHashCollection, + protected readonly DirectiveHashCollection $directiveHashCollection, + ) { + $this->locale = new Locale(); + $this->docType = DocType::html5; + $this->xmlPrologAndDocType = DocType::html5->getDoctypeDeclaration(); + $htmlTagAttributes = ['lang' => 'en']; + $backendUserAspect = $this->context->getAspect('backend.user'); + if ($backendUserAspect->isLoggedIn()) { + // If a backend user is logged in, we assume BE context and add a default html tag + // with theme and color scheme attributes for this backend user. + // In case this is FE context, FE RequestHandler will later override with final html tag attributes again. + // This is done here for BE b/w compat reasons. Assuming BE context is done to prevent + // accessing Request in __construct() and application type is only available as Request attribute. + $themeAndColorSchemeAttributes = $this->getThemeAndColorSchemeHtmlTagAttributes($this->getBackendUser()); + $htmlTagAttributes = array_merge($htmlTagAttributes, $themeAndColorSchemeAttributes); + } + $this->htmlTag = ''; + $this->javaScriptRenderer = JavaScriptRenderer::create('EXT:core/Resources/Public/JavaScript/java-script-item-handler.js'); + $this->setMetaTag('name', 'generator', 'TYPO3 CMS'); + } + + /** + * @internal + */ + public function updateState(array $state): void + { + foreach ($state as $var => $value) { + switch ($var) { + case 'assetsCache': + case 'assetRenderer': + case 'assetCollector': + case 'context': + case 'templateService': + case 'relativeCssPathFixer': + case 'languageServiceFactory': + case 'responseFactory': + case 'streamFactory': + case 'iconRegistry': + case 'resourcePublisher': + case 'systemResourceFactory': + case 'resourceHashCollection': + case 'directiveHashCollection': + case 'nonce': + break; + case 'metaTagRegistry': + $this->metaTagRegistry->updateState($value); + break; + case 'javaScriptRenderer': + $this->javaScriptRenderer->updateState($value); + break; + default: + $this->{$var} = $value; + break; + } + } + } + + /** + * @internal + */ + public function getState(): array + { + $state = []; + foreach (get_object_vars($this) as $var => $value) { + switch ($var) { + case 'assetsCache': + case 'assetRenderer': + case 'context': + case 'templateService': + case 'relativeCssPathFixer': + case 'languageServiceFactory': + case 'responseFactory': + case 'streamFactory': + case 'iconRegistry': + case 'resourcePublisher': + case 'nonce': + case 'systemResourceFactory': + case 'resourceHashCollection': + case 'directiveHashCollection': + break; + case 'metaTagRegistry': + $state[$var] = $this->metaTagRegistry->getState(); + break; + case 'javaScriptRenderer': + $state[$var] = $this->javaScriptRenderer->getState(); + break; + default: + $state[$var] = $value; + break; + } + } + return $state; + } + + /** + * BE only, FE uses a different approach to register JS + */ + public function getJavaScriptRenderer(): JavaScriptRenderer + { + return $this->javaScriptRenderer; + } + + /** + * Content of tag in <html><head> + */ + public function setTitle(string $title): void + { + $this->title = $title; + } + + /** + * Sets xml prolog and docType. + * FE only, BE is hard coded html5. + * + * @param string $xmlPrologAndDocType Complete tags for xml prolog and docType + */ + public function setXmlPrologAndDocType(string $xmlPrologAndDocType): void + { + $this->xmlPrologAndDocType = $xmlPrologAndDocType; + } + + /** + * Sets language + */ + public function setLanguage(Locale $locale, ServerRequestInterface $request): void + { + $this->locale = $locale; + $this->setDefaultHtmlTag($request); + } + + /** + * Sets html tag + * FE only, BE is hard coded html5. + */ + public function setHtmlTag(string $htmlTag): void + { + $this->htmlTag = $htmlTag; + } + + /** + * Sets HTML head tag + * FE only. + */ + public function setHeadTag(string $headTag): void + { + $this->headTag = $headTag; + } + + /** + * Sets favicon + */ + public function setFavIcon(string $favIcon): void + { + $this->favIcon = $favIcon; + } + + /** + * Sets icon mime type + */ + public function setIconMimeType(string $iconMimeType): void + { + $this->iconMimeType = $iconMimeType; + } + + /** + * Sets template file + * FE only. + */ + public function setTemplateFile(string $file): void + { + $this->templateFile = $file; + } + + /** + * Sets Content for Body + * BE only, FE uses addBodyContent + */ + public function setBodyContent(string $content): void + { + $this->bodyContent = $content; + } + + /** + * BE only? + */ + public function setApplyNonceHint(bool $applyNonceHint): void + { + $this->applyNonceHint = $applyNonceHint; + } + + /** + * FE only + */ + public function enableMoveJsFromHeaderToFooter(): void + { + $this->moveJsFromHeaderToFooter = true; + } + + /** + * FE only, unused. + */ + public function disableMoveJsFromHeaderToFooter(): void + { + $this->moveJsFromHeaderToFooter = false; + } + + public function setNonce(?ConsumableNonce $nonce): void + { + $this->nonce = $nonce; + } + + /** + * FE only, BE is hard coded HTML5 + */ + public function setDocType(DocType $docType, ServerRequestInterface $request): void + { + $this->docType = $docType; + $this->xmlPrologAndDocType = $docType->getDoctypeDeclaration(); + $this->setDefaultHtmlTag($request); + } + + /** + * Sets a given meta tag + * + * @param string $type The type of the meta tag. Allowed values are property, name or http-equiv + * @param string $name The name of the property to add + * @param string $content The content of the meta tag + * @param array $subProperties Subproperties of the meta tag (like e.g. og:image:width) + * @param bool $replace Replace earlier set meta tag + */ + public function setMetaTag(string $type, string $name, string $content, array $subProperties = [], bool $replace = true): void + { + // Lowercase all the things + $type = strtolower($type); + $name = strtolower($name); + if (!in_array($type, ['property', 'name', 'http-equiv'], true)) { + throw new \InvalidArgumentException( + 'When setting a meta tag the only types allowed are property, name or http-equiv. "' . $type . '" given.', + 1496402460 + ); + } + $manager = $this->metaTagRegistry->getManagerForProperty($name); + $manager->addProperty($name, $content, $subProperties, $replace, $type); + } + + /** + * Adds inline HTML comment. + * FE only. + */ + public function addInlineComment(string $comment): void + { + if (!in_array($comment, $this->inlineComments)) { + $this->inlineComments[] = $comment; + } + } + + /** + * Adds header data + * + * @param string $data Free header data for HTML header + */ + public function addHeaderData(string $data): void + { + if (!in_array($data, $this->headerData)) { + $this->headerData[] = $data; + } + } + + /** + * Adds footer data + * + * @param string $data Free footer data for HTML footer before closing body tag + */ + public function addFooterData(string $data): void + { + if (!in_array($data, $this->footerData)) { + $this->footerData[] = $data; + } + } + + /** + * Adds JS Library. JS Library block is rendered on top of the JS files. + * + * @param string $name Arbitrary identifier + * @param string|StaticResourceInterface $file File name + * @param string|null $type Content Type + * @param bool $forceOnTop Flag if added library should be inserted at begin of this block + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags + * @param string $integrity Subresource Integrity (SRI) + * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags + * @param string $crossorigin CORS settings attribute + * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags + * @param array<string, string> $tagAttributes Key => value list of tag attributes + */ + public function addJsLibrary($name, $file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void + { + $resource = $this->handleAddedResource($file); + $isUriResource = $resource instanceof UriResource; + if ($type === null) { + $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; + } + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + if (!isset($this->jsLibs[strtolower($name)])) { + $this->jsLibs[strtolower($name)] = [ + 'file' => (string)$resource, + 'type' => $type, + 'section' => self::PART_HEADER, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'async' => $async, + 'integrity' => $integrity, + 'defer' => $defer, + 'crossorigin' => $crossorigin, + 'nomodule' => $nomodule, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds JS Library to Footer. JS Library block is rendered on top of the Footer JS files. + * + * @param string $name Arbitrary identifier + * @param string|StaticResourceInterface $file File name + * @param string|null $type Content Type + * @param bool $forceOnTop Flag if added library should be inserted at begin of this block + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags + * @param string $integrity Subresource Integrity (SRI) + * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags + * @param string $crossorigin CORS settings attribute + * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags + * @param array<string, string> $tagAttributes Key => value list of tag attributes + */ + public function addJsFooterLibrary($name, $file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void + { + $resource = $this->handleAddedResource($file); + $isUriResource = $resource instanceof UriResource; + if ($type === null) { + $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; + } + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + $name .= '_jsFooterLibrary'; + if (!isset($this->jsLibs[strtolower($name)])) { + $this->jsLibs[strtolower($name)] = [ + 'file' => (string)$resource, + 'type' => $type, + 'section' => self::PART_FOOTER, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'async' => $async, + 'integrity' => $integrity, + 'defer' => $defer, + 'crossorigin' => $crossorigin, + 'nomodule' => $nomodule, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds JS file + * + * @param string|StaticResourceInterface $file File name + * @param string|null $type Content Type + * @param bool $forceOnTop + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags + * @param string $integrity Subresource Integrity (SRI) + * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags + * @param string $crossorigin CORS settings attribute + * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags + * @param array<string, string> $tagAttributes Key => value list of tag attributes + */ + public function addJsFile($file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void + { + $resource = $this->handleAddedResource($file); + $resourceIdentifier = (string)$resource; + $isUriResource = $resource instanceof UriResource; + if ($type === null) { + $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; + } + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + if (!isset($this->jsFiles[$resourceIdentifier])) { + $this->jsFiles[$resourceIdentifier] = [ + 'file' => $resourceIdentifier, + 'type' => $type, + 'section' => self::PART_HEADER, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'async' => $async, + 'integrity' => $integrity, + 'defer' => $defer, + 'crossorigin' => $crossorigin, + 'nomodule' => $nomodule, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds JS file to footer + * + * @param string|StaticResourceInterface $file File name + * @param string|null $type Content Type + * @param bool $forceOnTop + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $async Flag if property 'async="async"' should be added to JavaScript tags + * @param string $integrity Subresource Integrity (SRI) + * @param bool $defer Flag if property 'defer="defer"' should be added to JavaScript tags + * @param string $crossorigin CORS settings attribute + * @param bool $nomodule Flag if property 'nomodule="nomodule"' should be added to JavaScript tags + * @param array<string, string> $tagAttributes Key => value list of tag attributes + */ + public function addJsFooterFile($file, $type = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $async = false, $integrity = '', $defer = false, $crossorigin = '', $nomodule = false, array $tagAttributes = []): void + { + $resource = $this->handleAddedResource($file); + $resourceIdentifier = (string)$resource; + $isUriResource = $resource instanceof UriResource; + if ($type === null) { + $type = $this->docType === DocType::html5 ? '' : 'text/javascript'; + } + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + if (!isset($this->jsFiles[$resourceIdentifier])) { + $this->jsFiles[$resourceIdentifier] = [ + 'file' => $resourceIdentifier, + 'type' => $type, + 'section' => self::PART_FOOTER, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'async' => $async, + 'integrity' => $integrity, + 'defer' => $defer, + 'crossorigin' => $crossorigin, + 'nomodule' => $nomodule, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds JS inline code + * FE only. + * + * @param string $name + * @param string $block + * @param bool $forceOnTop + */ + public function addJsInlineCode($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void + { + if (!isset($this->jsInline[$name]) && !empty($block)) { + $this->jsInline[$name] = [ + 'code' => $block . LF, + 'section' => self::PART_HEADER, + 'forceOnTop' => $forceOnTop, + 'csp' => $csp, + ]; + } + } + + /** + * Adds JS inline code to footer + * FE only. + * + * @param string $name + * @param string $block + * @param bool $forceOnTop + */ + public function addJsFooterInlineCode($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void + { + if (!isset($this->jsInline[$name]) && !empty($block)) { + $this->jsInline[$name] = [ + 'code' => $block . LF, + 'section' => self::PART_FOOTER, + 'forceOnTop' => $forceOnTop, + 'csp' => $csp, + ]; + } + } + + /** + * Adds CSS file + * + * @param string|StaticResourceInterface $file + * @param string $rel + * @param string $media + * @param string $title + * @param bool $forceOnTop + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $inline + * @param array<string, string> $tagAttributes Key => value list of tag attributes + * @param string $integrity Subresource Integrity (SRI) + * @param string $crossorigin CORS settings attribute + */ + public function addCssFile($file, $rel = 'stylesheet', $media = 'all', $title = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $inline = false, array $tagAttributes = [], string $integrity = '', string $crossorigin = ''): void + { + $resource = $this->handleAddedResource($file); + $isUriResource = $resource instanceof UriResource; + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + $resourceIdentifier = (string)$resource; + if (!isset($this->cssFiles[$resourceIdentifier])) { + $this->cssFiles[$resourceIdentifier] = [ + 'file' => $resourceIdentifier, + 'rel' => $rel, + 'media' => $media, + 'title' => $title, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'inline' => $inline, + 'integrity' => $integrity, + 'crossorigin' => $crossorigin, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds CSS library + * + * @param string|StaticResourceInterface $file + * @param string $rel + * @param string $media + * @param string $title + * @param bool $forceOnTop + * @param string $allWrap + * @param string $splitChar The char used to split the allWrap value, default is "|" + * @param bool $inline + * @param array<string, string> $tagAttributes Key => value list of tag attributes + * @param string $integrity Subresource Integrity (SRI) + * @param string $crossorigin CORS settings attribute + */ + public function addCssLibrary($file, $rel = 'stylesheet', $media = 'all', $title = '', mixed $_ = null, $forceOnTop = false, $allWrap = '', mixed $__ = null, $splitChar = '|', $inline = false, array $tagAttributes = [], string $integrity = '', string $crossorigin = ''): void + { + $resource = $this->handleAddedResource($file); + $isUriResource = $resource instanceof UriResource; + if ($integrity === ResourceHashCollection::AUTO) { + $integrity = $this->resourceHashCollection->fetchResourceHash($resource)?->export() ?? ''; + } + if ($crossorigin === '' && $integrity !== '' && $isUriResource) { + $crossorigin = 'anonymous'; + } + $resourceIdentifier = (string)$resource; + if (!isset($this->cssLibs[$resourceIdentifier])) { + $this->cssLibs[$resourceIdentifier] = [ + 'file' => $resourceIdentifier, + 'rel' => $rel, + 'media' => $media, + 'title' => $title, + 'forceOnTop' => $forceOnTop, + 'allWrap' => $allWrap, + 'splitChar' => $splitChar, + 'inline' => $inline, + 'integrity' => $integrity, + 'crossorigin' => $crossorigin, + 'tagAttributes' => $tagAttributes, + ]; + } + } + + /** + * Adds CSS inline code + * + * @param string $name + * @param string $block + * @param bool $forceOnTop + */ + public function addCssInlineBlock($name, $block, mixed $_ = null, $forceOnTop = false, bool $csp = false): void + { + if (!isset($this->cssInline[$name]) && !empty($block)) { + $this->cssInline[$name] = [ + 'code' => $block, + 'forceOnTop' => $forceOnTop, + 'csp' => $csp, + ]; + } + } + + /** + * Includes an ES6/ES11 compatible JavaScript module by + * resolving the specifier to an import-mapped filename. + * + * @param string $specifier Bare module identifier like @my/package/filename.js + */ + public function loadJavaScriptModule(string $specifier): void + { + $this->javaScriptRenderer->addJavaScriptModuleInstruction( + JavaScriptModuleInstruction::create($specifier) + ); + } + + /** + * Adds Javascript Inline Label. This will occur in TYPO3.lang - object + * The label can be used in scripts with TYPO3.lang.<key> + * BE only. + * + * @param string $key + * @param string $value + */ + public function addInlineLanguageLabel($key, $value): void + { + $this->inlineLanguageLabels[$key] = $value; + } + + /** + * Adds Javascript Inline Label Array. This will occur in TYPO3.lang - object + * The label can be used in scripts with TYPO3.lang.<key> + * Array will be merged with existing array. + * BE only. + */ + public function addInlineLanguageLabelArray(array $array): void + { + $this->inlineLanguageLabels = array_merge($this->inlineLanguageLabels, $array); + } + + /** + * Gets labels to be used in JavaScript fetched from a locallang file. + * + * @param string $fileRef Input is a file-reference (see GeneralUtility::getFileAbsFileName). That file is expected to be a 'locallang.xlf' file containing a valid XML TYPO3 language structure. + * @param string $selectionPrefix Prefix to select the correct labels (default: '') + * @param string $stripFromSelectionName String to be removed from the label names in the output. (default: '') + */ + public function addInlineLanguageLabelFile($fileRef, $selectionPrefix = '', $stripFromSelectionName = ''): void + { + $index = md5($fileRef . $selectionPrefix . $stripFromSelectionName); + if ($fileRef && !isset($this->inlineLanguageLabelFiles[$index])) { + $this->inlineLanguageLabelFiles[$index] = [ + 'fileRef' => $fileRef, + 'selectionPrefix' => $selectionPrefix, + 'stripFromSelectionName' => $stripFromSelectionName, + ]; + } + } + + /** + * Adds Javascript Inline Setting. This will occur in TYPO3.settings - object + * The label can be used in scripts with TYPO3.setting.<key> + * + * @param string|null $namespace + * @param string $key + * @param mixed $value + */ + public function addInlineSetting($namespace, $key, $value): void + { + if ($namespace !== null && $namespace !== '') { + if (strpos($namespace, '.')) { + $parts = explode('.', $namespace); + $a = &$this->inlineSettings; + foreach ($parts as $part) { + $a = &$a[$part]; + } + $a[$key] = $value; + } else { + $this->inlineSettings[$namespace][$key] = $value; + } + } else { + $this->inlineSettings[$key] = $value; + } + } + + /** + * Adds Javascript Inline Setting. This will occur in TYPO3.settings - object + * The label can be used in scripts with TYPO3.setting.<key> + * Array will be merged with existing array. + * + * @param string $namespace + */ + public function addInlineSettingArray($namespace, array $array): void + { + if ($namespace) { + if (strpos($namespace, '.')) { + $parts = explode('.', $namespace); + $a = &$this->inlineSettings; + foreach ($parts as $part) { + $a = &$a[$part]; + } + $a = array_merge((array)$a, $array); + } else { + $this->inlineSettings[$namespace] = array_merge((array)($this->inlineSettings[$namespace] ?? []), $array); + } + } else { + $this->inlineSettings = array_merge($this->inlineSettings, $array); + } + } + + /** + * Adds content to body content + */ + public function addBodyContent(string $content): void + { + $this->bodyContent .= $content; + } + + /** + * Render the page. + * BE only. + * + * @return string Content of rendered page + */ + public function render(ServerRequestInterface $request): string + { + $this->prepareRendering(); + [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); + $metaTags = implode(LF, $this->renderMetaTagsFromAPI($this->docType)); + $markerArray = [ + 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, + 'HTMLTAG' => $this->htmlTag, + 'HEADTAG' => $this->headTag, + 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', + 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', + 'CSS_LIBS' => $cssLibs, + 'CSS_INCLUDE' => $cssFiles, + 'CSS_INLINE' => $cssInline, + 'JS_INLINE' => $jsInline, + 'JS_INCLUDE' => $jsFiles, + 'JS_LIBS' => $jsLibs, + 'TITLE' => $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : '', + 'META' => $metaTags, + 'HEADERDATA' => $this->headerData ? implode(LF, $this->headerData) : '', + 'FOOTERDATA' => $this->footerData ? implode(LF, $this->footerData) : '', + 'JS_LIBS_FOOTER' => $jsFooterLibs, + 'JS_INCLUDE_FOOTER' => $jsFooterFiles, + 'JS_INLINE_FOOTER' => $jsFooterInline, + 'BODY' => $this->bodyContent, + // @internal + 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', + ]; + $markerArray = array_map(trim(...), $markerArray); + $template = $this->getTemplate(); + // The page renderer needs a full reset when the page was rendered + $this->reset($request); + return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); + } + + /** + * Render the page for frontend output. + * FE only. + * + * @internal Not part of the public API. Only for use in TYPO3 frontend rendering. + */ + public function renderFrontendPage(ServerRequestInterface $request): string + { + $this->prepareRendering(); + [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); + $metaTags = implode(LF, $this->renderMetaTagsFromAPI($this->docType)); + $markerArray = [ + 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, + 'HTMLTAG' => $this->htmlTag, + 'HEADTAG' => $this->headTag, + 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', + 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', + 'CSS_LIBS' => $cssLibs, + 'CSS_INCLUDE' => $cssFiles, + 'CSS_INLINE' => $cssInline, + 'JS_INLINE' => $jsInline, + 'JS_INCLUDE' => $jsFiles, + 'JS_LIBS' => $jsLibs, + 'TITLE' => $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : '', + 'META' => $metaTags, + 'HEADERDATA' => $this->headerData ? implode(LF, $this->headerData) : '', + 'FOOTERDATA' => $this->footerData ? implode(LF, $this->footerData) : '', + 'JS_LIBS_FOOTER' => $jsFooterLibs, + 'JS_INCLUDE_FOOTER' => $jsFooterFiles, + 'JS_INLINE_FOOTER' => $jsFooterInline, + 'BODY' => $this->bodyContent, + // @internal + 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', + ]; + $markerArray = array_map(trim(...), $markerArray); + $template = $this->getTemplate(); + $this->reset($request); + return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); + } + + /** + * BE only. + */ + public function renderResponse( + ServerRequestInterface $request, + int $code = 200, + string $reasonPhrase = '', + ): ResponseInterface { + $stream = $this->streamFactory->createStream($this->render($request)); + return $this->responseFactory->createResponse($code, $reasonPhrase) + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($stream); + } + + /** + * Frontend related rendering of the main page HTML scaffold with placeholders + * for dynamic sections finished by uncached element ("INT") processing later. + * The result of this method is cached as content in page cache. + * FE only. + * + * @param string $substituteHash The hash that is used for the placeholder markers + * @internal Never use in extensions. + */ + public function renderPageWithUncachedObjects(string $substituteHash): string + { + $this->prepareRendering(); + $markerArray = [ + 'XMLPROLOG_DOCTYPE' => $this->xmlPrologAndDocType, + 'HTMLTAG' => $this->htmlTag, + 'HEADTAG' => $this->headTag, + 'INLINECOMMENT' => $this->inlineComments ? LF . LF . '<!-- ' . LF . implode(LF, $this->inlineComments) . '-->' . LF . LF : '', + 'SHORTCUT' => $this->favIcon ? sprintf($this->shortcutTag, htmlspecialchars($this->favIcon), $this->iconMimeType) : '', + 'META' => '<!-- ###META' . $substituteHash . '### -->', + 'BODY' => $this->bodyContent, + 'TITLE' => '<!-- ###TITLE' . $substituteHash . '### -->', + 'CSS_LIBS' => '<!-- ###CSS_LIBS' . $substituteHash . '### -->', + 'CSS_INCLUDE' => '<!-- ###CSS_INCLUDE' . $substituteHash . '### -->', + 'CSS_INLINE' => '<!-- ###CSS_INLINE' . $substituteHash . '### -->', + 'JS_INLINE' => '<!-- ###JS_INLINE' . $substituteHash . '### -->', + 'JS_INCLUDE' => '<!-- ###JS_INCLUDE' . $substituteHash . '### -->', + 'JS_LIBS' => '<!-- ###JS_LIBS' . $substituteHash . '### -->', + 'HEADERDATA' => '<!-- ###HEADERDATA' . $substituteHash . '### -->', + 'FOOTERDATA' => '<!-- ###FOOTERDATA' . $substituteHash . '### -->', + 'JS_LIBS_FOOTER' => '<!-- ###JS_LIBS_FOOTER' . $substituteHash . '### -->', + 'JS_INCLUDE_FOOTER' => '<!-- ###JS_INCLUDE_FOOTER' . $substituteHash . '### -->', + 'JS_INLINE_FOOTER' => '<!-- ###JS_INLINE_FOOTER' . $substituteHash . '### -->', + // @internal + 'TRAILING_SLASH_FOR_SELF_CLOSING_TAG' => $this->endingSlash ? ' ' . $this->endingSlash : '', + ]; + // Reset body content to empty string so the content is not cached twice since it is + // already cached as 'content' section next to the other PageRenderer state. + $this->bodyContent = ''; + $markerArray = array_map(trim(...), $markerArray); + $template = $this->getTemplate(); + // Note in contrast to render(), this method does *not* call $this->reset() since the PageRenderer state + // is serialized and cached for uncached element processing. + return trim($this->templateService->substituteMarkerArray($template, $markerArray, '###|###')); + } + + /** + * Renders the JavaScript and CSS files that have been added during processing + * of uncached content objects (USER_INT, COA_INT) + * FE only. + * + * @param string $substituteHash The hash that is used for the variables + * @internal Never use in extensions. + */ + public function renderJavaScriptAndCssForProcessingOfUncachedContentObjects(ServerRequestInterface $request, string $cachedPageContent, string $substituteHash): string + { + $this->prepareRendering(); + // bodyContent is reset to empty string in FE both after render() and renderPageWithUncachedObjects(). + // $this->bodyContent is set to the "cached with placeholder" string here for renderJavaScriptAndCss() + // hook to consistently receive bodyContent, otherwise it wouldn't be needed to do this here. + $this->bodyContent = $cachedPageContent; + [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs] = $this->renderJavaScriptAndCss($request); + $title = $this->title ? str_replace('|', htmlspecialchars($this->title), $this->titleTag) : ''; + $markerArray = [ + '<!-- ###TITLE' . $substituteHash . '### -->' => $title, + '<!-- ###CSS_LIBS' . $substituteHash . '### -->' => $cssLibs, + '<!-- ###CSS_INCLUDE' . $substituteHash . '### -->' => $cssFiles, + '<!-- ###CSS_INLINE' . $substituteHash . '### -->' => $cssInline, + '<!-- ###JS_INLINE' . $substituteHash . '### -->' => $jsInline, + '<!-- ###JS_INCLUDE' . $substituteHash . '### -->' => $jsFiles, + '<!-- ###JS_LIBS' . $substituteHash . '### -->' => $jsLibs, + '<!-- ###META' . $substituteHash . '### -->' => implode(LF, $this->renderMetaTagsFromAPI($this->docType)), + '<!-- ###HEADERDATA' . $substituteHash . '### -->' => implode(LF, $this->headerData), + '<!-- ###FOOTERDATA' . $substituteHash . '### -->' => implode(LF, $this->footerData), + '<!-- ###JS_LIBS_FOOTER' . $substituteHash . '### -->' => $jsFooterLibs, + '<!-- ###JS_INCLUDE_FOOTER' . $substituteHash . '### -->' => $jsFooterFiles, + '<!-- ###JS_INLINE_FOOTER' . $substituteHash . '### -->' => $jsFooterInline, + ]; + foreach ($markerArray as $placeHolder => $content) { + $cachedPageContent = str_replace($placeHolder, $content, $cachedPageContent); + } + $this->reset($request); + return $cachedPageContent; + } + + /** + * Reset all vars to initial values + */ + protected function reset(ServerRequestInterface $request): void + { + $this->locale = new Locale(); + $this->setDocType(DocType::html5, $request); + $this->templateFile = 'PKG:typo3/cms-core:Resources/Private/Templates/PageRenderer.html'; + $this->bodyContent = ''; + $this->jsFiles = []; + $this->jsInline = []; + $this->jsLibs = []; + $this->cssFiles = []; + $this->cssInline = []; + $this->inlineComments = []; + $this->headerData = []; + $this->footerData = []; + $this->javaScriptRenderer = JavaScriptRenderer::create('EXT:core/Resources/Public/JavaScript/java-script-item-handler.js'); + } + + /** + * Internal method to set a basic <html> tag when in HTML5 with the proper language/locale and "dir" attributes. + */ + protected function setDefaultHtmlTag(ServerRequestInterface $request): void + { + if ($this->docType === DocType::html5) { + $attributes = [ + 'lang' => $this->locale->getName(), + ]; + if ($this->locale->isRightToLeftLanguageDirection()) { + $attributes['dir'] = 'rtl'; + } + // @todo: build an API to add HTML attributes cleanly + if ($this->getApplicationType($request) === 'BE') { + $backendUser = $this->context->getAspect('backend.user'); + if ($backendUser->isLoggedIn()) { + $attributes = array_merge($attributes, $this->getThemeAndColorSchemeHtmlTagAttributes($this->getBackendUser())); + } + } + $this->setHtmlTag('<html ' . GeneralUtility::implodeAttributes($attributes, true) . '>'); + } + } + + private function getThemeAndColorSchemeHtmlTagAttributes(BackendUserAuthentication $backendUser): array + { + $attributes = []; + $userTS = $backendUser->getTSConfig(); + $themeDisabled = $userTS['setup.']['fields.']['theme.']['disabled'] ?? '0'; + $theme = $backendUser->uc['theme'] ?? $userTS['setup.']['fields.']['theme'] ?? 'fresh'; + if ($themeDisabled === '1') { + $theme = $userTS['setup.']['fields.']['theme'] ?? 'fresh'; + } + if ($theme !== 'modern') { + $attributes['data-theme'] = $theme; + } + $colorSchemeDisabled = $userTS['setup.']['fields.']['colorScheme.']['disabled'] ?? '0'; + $colorScheme = $backendUser->uc['colorScheme'] ?? $userTS['setup.']['fields.']['colorScheme'] ?? 'auto'; + if ($colorSchemeDisabled === '1') { + $colorScheme = $userTS['setup.']['fields.']['colorScheme'] ?? 'light'; + } + if ($colorScheme !== 'auto') { + $attributes['data-color-scheme'] = $colorScheme; + } + return $attributes; + } + + /** + * Renders metaTags based on tags added via the API + */ + protected function renderMetaTagsFromAPI(DocType $docType): array + { + $metaTags = []; + $metaTagManagers = $this->metaTagRegistry->getAllManagers(); + foreach ($metaTagManagers as $managerObject) { + // @todo: Reflect $docType argument in MetaTagManagerInterface + $properties = $managerObject->renderAllProperties($docType); // @phpstan-ignore arguments.count + if (!empty($properties)) { + $metaTags[] = $properties; + } + } + return $metaTags; + } + + /** + * Remove ending slashes from static header block + * if the page is being rendered as html (not xhtml) + * and define property $this->endingSlash for further use + */ + protected function prepareRendering(): void + { + if ($this->docType->isXmlCompliant()) { + $this->endingSlash = ' /'; + } else { + $this->shortcutTag = str_replace(' />', '>', $this->shortcutTag); + $this->endingSlash = ''; + } + } + + /** + * Renders all JavaScript and CSS + * + * @return string[] + */ + protected function renderJavaScriptAndCss(ServerRequestInterface $request): array + { + $this->executePreRenderHook(); + $mainJsLibs = $this->renderMainJavaScriptLibraries($request); + $this->executeRenderPostTransformHook(); + $cssLibs = $this->renderCssLibraries($request); + $cssFiles = $this->renderCssFiles($request); + $cssInline = $this->renderCssInline(); + [$jsLibs, $jsFooterLibs] = $this->renderAdditionalJavaScriptLibraries($request); + [$jsFiles, $jsFooterFiles] = $this->renderJavaScriptFiles($request); + [$jsInline, $jsFooterInline] = $this->renderInlineJavaScript(); + $jsLibs = $mainJsLibs . $jsLibs; + if ($this->moveJsFromHeaderToFooter) { + $jsFooterLibs = $jsLibs . LF . $jsFooterLibs; + $jsLibs = ''; + $jsFooterFiles = $jsFiles . LF . $jsFooterFiles; + $jsFiles = ''; + $jsFooterInline = $jsInline . LF . $jsFooterInline; + $jsInline = ''; + } + // Use AssetRenderer to inject all JavaScripts and CSS files + $jsInline .= $this->assetRenderer->renderInlineJavaScript(true, $this->nonce); + $jsFooterInline .= $this->assetRenderer->renderInlineJavaScript(false, $this->nonce); + $jsFiles .= $this->assetRenderer->renderJavaScript(true, $this->nonce); + $jsFooterFiles .= $this->assetRenderer->renderJavaScript(false, $this->nonce); + $cssInline .= $this->assetRenderer->renderInlineStyleSheets(true, $this->nonce); + // append inline CSS to footer (as there is no cssFooterInline) + $jsFooterFiles .= $this->assetRenderer->renderInlineStyleSheets(false, $this->nonce); + $cssLibs .= $this->assetRenderer->renderStyleSheets(true, $this->endingSlash, $this->nonce); + $cssFiles .= $this->assetRenderer->renderStyleSheets(false, $this->endingSlash, $this->nonce); + + $this->executePostRenderHook($jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs); + return [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs]; + } + + /** + * Reads the template file and returns the requested part as string + */ + protected function getTemplate(): string + { + $templateResource = $this->systemResourceFactory->createResource($this->templateFile); + try { + if ($templateResource instanceof SystemResourceInterface) { + return $templateResource->getContents(); + } + } catch (SystemResourceDoesNotExistException) { + } + return ''; + } + + /** + * Helper function for render the main JavaScript libraries + * + * @return string Content with JavaScript libraries + */ + protected function renderMainJavaScriptLibraries(ServerRequestInterface $request): string + { + $out = ''; + + foreach ($this->assetCollector->getJavaScriptModules() as $module) { + $this->loadJavaScriptModule($module); + } + + // adds a nonce hint/work-around for lit-elements (which is only applied automatically in ShadowDOM) + // see https://lit.dev/docs/api/ReactiveElement/#ReactiveElement.styles) + if ($this->applyNonceHint && $this->nonce !== null) { + $this->javaScriptRenderer->addGlobalAssignment(['litNonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)]); + } + + $sitePath = $request->getAttribute('normalizedParams')->getSitePath(); + + $useNonce = $this->getApplicationType($request) === 'BE'; + $out .= $this->javaScriptRenderer->renderImportMap( + $sitePath, + $useNonce ? $this->nonce : null, + ); + + $this->loadJavaScriptLanguageStrings(); + if ($this->getApplicationType($request) === 'BE') { + $noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']); + $this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn); + $this->addGlobalCSSUrlsToInlineSettings($request); + $this->inlineSettings['cache']['iconCacheIdentifier'] = sha1($this->iconRegistry->getBackendIconsCacheIdentifier()); + } + $assignments = array_filter([ + 'settings' => $this->inlineSettings, + 'lang' => $this->parseLanguageLabelsForJavaScript(), + ]); + if ($assignments !== []) { + if ($this->getApplicationType($request) === 'BE') { + $this->javaScriptRenderer->addGlobalAssignment(['TYPO3' => $assignments]); + } else { + $out .= $this->wrapInlineScript( + sprintf( + "var TYPO3 = Object.assign(TYPO3 || {}, %s);\r\n", + // filter potential prototype pollution + sprintf( + 'Object.fromEntries(Object.entries(%s).filter((entry) => ' + . "!['__proto__', 'prototype', 'constructor'].includes(entry[0])))", + json_encode($assignments) + ) + ), + $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : [] + ); + } + } + $out .= $this->javaScriptRenderer->render($this->nonce, $sitePath); + return $out; + } + + /** + * Converts the language labels for usage in JavaScript + */ + protected function parseLanguageLabelsForJavaScript(): array + { + if (empty($this->inlineLanguageLabels)) { + return []; + } + + $labels = []; + foreach ($this->inlineLanguageLabels as $key => $translationUnit) { + if (is_array($translationUnit)) { + $translationUnit = current($translationUnit); + $labels[$key] = $translationUnit['target'] ?? $translationUnit['source']; + } else { + $labels[$key] = $translationUnit; + } + } + + return $labels; + } + + /** + * Load the language strings into JavaScript + */ + protected function loadJavaScriptLanguageStrings(): void + { + foreach ($this->inlineLanguageLabelFiles as $languageLabelFile) { + $selectionPrefix = $languageLabelFile['selectionPrefix']; + $stripFromSelectionName = $languageLabelFile['stripFromSelectionName']; + $labelsFromFile = []; + $allLabels = $this->readLLfile($languageLabelFile['fileRef']); + // Iterate through all labels from the language file + foreach ($allLabels as $label => $value) { + // If $selectionPrefix is set, only respect labels that start with $selectionPrefix + if ($selectionPrefix === '' || str_starts_with($label, $selectionPrefix)) { + // Remove substring $stripFromSelectionName from label + $label = str_replace($stripFromSelectionName, '', $label); + $labelsFromFile[$label] = $value; + } + } + $this->inlineLanguageLabels = array_merge($this->inlineLanguageLabels, $labelsFromFile); + } + $this->inlineLanguageLabelFiles = []; + } + + /** + * Make URLs to all backend ajax handlers available as inline setting. + */ + protected function addAjaxUrlsToInlineSettings(bool $publicRoutesOnly = false): void + { + $ajaxUrls = []; + // Add the ajax-based routes + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $router = GeneralUtility::makeInstance(Router::class); + foreach ($router->getRoutes() as $routeIdentifier => $route) { + if ($publicRoutesOnly && $route->getOption('access') !== 'public') { + continue; + } + if ($route->getOption('ajax')) { + $uri = (string)$uriBuilder->buildUriFromRoute($routeIdentifier); + // use the shortened value in order to use this in JavaScript + if (str_starts_with($routeIdentifier, 'ajax_')) { + $routeIdentifier = substr($routeIdentifier, 5); + } + $ajaxUrls[$routeIdentifier] = $uri; + } + } + + $this->inlineSettings['ajaxUrls'] = $ajaxUrls; + } + + protected function addGlobalCSSUrlsToInlineSettings(ServerRequestInterface $request): void + { + $this->inlineSettings['cssUrls'] = [ + 'backend' => $this->getPublicUrlForFile('EXT:backend/Resources/Public/Css/backend.css', $request), + ]; + } + + /** + * Render CSS library files + */ + protected function renderCssLibraries(ServerRequestInterface $request): string + { + $cssFiles = ''; + if (!empty($this->cssLibs)) { + foreach ($this->cssLibs as $properties) { + $tag = $this->createCssTag($properties, $properties['file'], $request); + if ($properties['forceOnTop'] ?? false) { + $cssFiles = $tag . $cssFiles; + } else { + $cssFiles .= $tag; + } + } + } + return $cssFiles; + } + + /** + * Render CSS files + */ + protected function renderCssFiles(ServerRequestInterface $request): string + { + $cssFiles = ''; + if (!empty($this->cssFiles)) { + foreach ($this->cssFiles as $properties) { + $tag = $this->createCssTag($properties, $properties['file'], $request); + if ($properties['forceOnTop'] ?? false) { + $cssFiles = $tag . $cssFiles; + } else { + $cssFiles .= $tag; + } + } + } + return $cssFiles; + } + + /** + * Adds a CSP hash for a static file to the hash collection. + * Resolves PKG:, EXT: and relative public paths via SystemResourceFactory. + * Silently skips URI resources (http/https) and unresolvable paths. + */ + private function addFileHashToCollection(Directive $directive, string $file): void + { + $resource = $this->systemResourceFactory->createResource($file); + if ($resource instanceof SystemResourceInterface) { + $this->directiveHashCollection->addResourceHash($directive, $resource); + } + } + + /** + * Create link (inline=0) or style (inline=1) tag + */ + private function createCssTag(array $properties, string $file, ServerRequestInterface $request): string + { + $includeInline = $properties['inline'] ?? false; + $resource = $includeInline ? $this->systemResourceFactory->createResource($file) : null; + if ($resource instanceof SystemResourceInterface) { + $tag = $this->createInlineCssTagFromFile($resource, $properties, $request); + } else { + // collect CSP hash - use integrity attribute if given, else hash file content + $integrity = $properties['integrity'] ?? ''; + if ($integrity !== '') { + try { + $this->directiveHashCollection->addGenericHashValue(Directive::StyleSrcElem, $integrity); + } catch (\LogicException) { + // integrity format not recognized, skip + } + } else { + $this->addFileHashToCollection(Directive::StyleSrcElem, $file); + } + $tagAttributes = []; + if ($properties['rel'] ?? false) { + $tagAttributes['rel'] = $properties['rel']; + } + $tagAttributes['href'] = $this->getPublicUrlForFile($file, $request); + if ($properties['media'] ?? false) { + $tagAttributes['media'] = $properties['media']; + } + if ($properties['title'] ?? false) { + $tagAttributes['title'] = $properties['title']; + } + if ($properties['integrity'] ?? false) { + $tagAttributes['integrity'] = $properties['integrity']; + } + if ($properties['crossorigin'] ?? false) { + $tagAttributes['crossorigin'] = $properties['crossorigin']; + } + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::StyleSrcElem); + } + $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); + $tag = '<link ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . $this->endingSlash . '>'; + } + if ($properties['allWrap'] ?? false) { + $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); + $tag = $wrapArr[0] . $tag . $wrapArr[1]; + } + $tag .= LF; + + return $tag; + } + + /** + * Render inline CSS + */ + protected function renderCssInline(): string + { + if (empty($this->cssInline)) { + return ''; + } + $cssItems = [0 => [], 1 => []]; + foreach ($this->cssInline as $name => $properties) { + $useCsp = !empty($properties['csp']); + $nonceKey = (int)$useCsp; + $cssCode = '/*' . htmlspecialchars($name) . '*/' . LF . ($properties['code'] ?? '') . LF; + if ($properties['forceOnTop'] ?? false) { + array_unshift($cssItems[$nonceKey], $cssCode); + } else { + $cssItems[$nonceKey][] = $cssCode; + } + } + $cssItems = array_filter($cssItems); + foreach ($cssItems as $useCsp => $items) { + $assembledContent = implode('', $items); + if ($useCsp) { + // Hash the full assembled content as it appears inside the <style> tag + $this->directiveHashCollection->addInlineHash(Directive::StyleSrcElem, LF . $assembledContent . LF); + } + $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::StyleSrcElem)] : []; + $cssItems[$useCsp] = $this->wrapInlineStyle($assembledContent, $attributes); + } + return implode(LF, $cssItems); + } + + /** + * Render JavaScript libraries + * + * @return string[] jsLibs and jsFooterLibs strings + */ + protected function renderAdditionalJavaScriptLibraries(ServerRequestInterface $request): array + { + $jsLibs = ''; + $jsFooterLibs = ''; + if (!empty($this->jsLibs)) { + foreach ($this->jsLibs as $properties) { + // collect CSP hash - use integrity attribute if given, else hash file content + $integrity = $properties['integrity'] ?? ''; + if ($integrity !== '') { + try { + $this->directiveHashCollection->addGenericHashValue(Directive::ScriptSrcElem, $integrity); + } catch (\LogicException) { + // integrity format not recognized, skip + } + } else { + $this->addFileHashToCollection(Directive::ScriptSrcElem, $properties['file']); + } + $tagAttributes = []; + $tagAttributes['src'] = $this->getPublicUrlForFile($properties['file'], $request); + if ($properties['type'] ?? false) { + $tagAttributes['type'] = $properties['type']; + } + if ($properties['async'] ?? false) { + $tagAttributes['async'] = 'async'; + } + if ($properties['defer'] ?? false) { + $tagAttributes['defer'] = 'defer'; + } + if ($properties['nomodule'] ?? false) { + $tagAttributes['nomodule'] = 'nomodule'; + } + if ($properties['integrity'] ?? false) { + $tagAttributes['integrity'] = $properties['integrity']; + } + if ($properties['crossorigin'] ?? false) { + $tagAttributes['crossorigin'] = $properties['crossorigin']; + } + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::ScriptSrcElem); + } + $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); + $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; + if ($properties['allWrap'] ?? false) { + $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); + $tag = $wrapArr[0] . $tag . $wrapArr[1]; + } + $tag .= LF; + if ($properties['forceOnTop'] ?? false) { + if (($properties['section'] ?? 0) === self::PART_HEADER) { + $jsLibs = $tag . $jsLibs; + } else { + $jsFooterLibs = $tag . $jsFooterLibs; + } + } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { + $jsLibs .= $tag; + } else { + $jsFooterLibs .= $tag; + } + } + } + if ($this->moveJsFromHeaderToFooter) { + $jsFooterLibs = $jsLibs . LF . $jsFooterLibs; + $jsLibs = ''; + } + return [$jsLibs, $jsFooterLibs]; + } + + /** + * Render JavaScript files + * + * @return string[] jsFiles and jsFooterFiles strings + */ + protected function renderJavaScriptFiles(ServerRequestInterface $request): array + { + $jsFiles = ''; + $jsFooterFiles = ''; + if (!empty($this->jsFiles)) { + foreach ($this->jsFiles as $properties) { + // collect CSP hash - use integrity attribute if given, else hash file content + $integrity = $properties['integrity'] ?? ''; + if ($integrity !== '') { + try { + $this->directiveHashCollection->addGenericHashValue(Directive::ScriptSrcElem, $integrity); + } catch (\LogicException) { + // integrity format not recognized, skip + } + } else { + $this->addFileHashToCollection(Directive::ScriptSrcElem, $properties['file']); + } + $tagAttributes = []; + $tagAttributes['src'] = $this->getPublicUrlForFile($properties['file'], $request); + if ($properties['type'] ?? false) { + $tagAttributes['type'] = $properties['type']; + } + if ($properties['async'] ?? false) { + $tagAttributes['async'] = 'async'; + } + if ($properties['defer'] ?? false) { + $tagAttributes['defer'] = 'defer'; + } + if ($properties['nomodule'] ?? false) { + $tagAttributes['nomodule'] = 'nomodule'; + } + if ($properties['integrity'] ?? false) { + $tagAttributes['integrity'] = $properties['integrity']; + } + if ($properties['crossorigin'] ?? false) { + $tagAttributes['crossorigin'] = $properties['crossorigin']; + } + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consumeStatic(Directive::ScriptSrcElem); + } + $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); + $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; + if ($properties['allWrap'] ?? false) { + $wrapArr = explode(($properties['splitChar'] ?? false) ?: '|', $properties['allWrap'], 2); + $tag = $wrapArr[0] . $tag . $wrapArr[1]; + } + $tag .= LF; + if ($properties['forceOnTop'] ?? false) { + if (($properties['section'] ?? 0) === self::PART_HEADER) { + $jsFiles = $tag . $jsFiles; + } else { + $jsFooterFiles = $tag . $jsFooterFiles; + } + } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { + $jsFiles .= $tag; + } else { + $jsFooterFiles .= $tag; + } + } + } + if ($this->moveJsFromHeaderToFooter) { + $jsFooterFiles = $jsFiles . $jsFooterFiles; + $jsFiles = ''; + } + return [$jsFiles, $jsFooterFiles]; + } + + /** + * Render inline JavaScript (must not apply `nonce="..."` if defined). + * + * @return string[] jsInline and jsFooterInline string + */ + protected function renderInlineJavaScript(): array + { + if (empty($this->jsInline)) { + return ['', '']; + } + $regularItems = [0 => [], 1 => []]; + $footerItems = [0 => [], 1 => []]; + foreach ($this->jsInline as $name => $properties) { + $useCsp = !empty($properties['csp']); + $nonceKey = (int)$useCsp; + $jsCode = '/*' . htmlspecialchars($name) . '*/' . LF . ($properties['code'] ?? '') . LF; + if ($properties['forceOnTop'] ?? false) { + if (($properties['section'] ?? 0) === self::PART_HEADER) { + array_unshift($regularItems[$nonceKey], $jsCode); + } else { + array_unshift($footerItems[$nonceKey], $jsCode); + } + } elseif (($properties['section'] ?? 0) === self::PART_HEADER) { + $regularItems[$nonceKey][] = $jsCode; + } else { + $footerItems[$nonceKey][] = $jsCode; + } + } + $regularItems = array_filter($regularItems); + $footerItems = array_filter($footerItems); + foreach ($regularItems as $useCsp => $items) { + $assembledContent = implode('', $items); + if ($useCsp) { + // Hash the full assembled content as it appears inside the <script> tag + $this->directiveHashCollection->addInlineHash(Directive::ScriptSrcElem, LF . $assembledContent . LF); + } + $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : []; + $regularItems[$useCsp] = $this->wrapInlineScript($assembledContent, $attributes); + } + foreach ($footerItems as $useCsp => $items) { + $assembledContent = implode('', $items); + if ($useCsp) { + // Hash the full assembled content as it appears inside the <script> tag + $this->directiveHashCollection->addInlineHash(Directive::ScriptSrcElem, LF . $assembledContent . LF); + } + $attributes = $useCsp && $this->nonce !== null ? ['nonce' => $this->nonce->consumeInline(Directive::ScriptSrcElem)] : []; + $footerItems[$useCsp] = $this->wrapInlineScript($assembledContent, $attributes); + } + $regularCode = implode(LF, $regularItems); + $footerCode = implode(LF, $footerItems); + if ($this->moveJsFromHeaderToFooter) { + $footerCode = $regularCode . $footerCode; + $regularCode = ''; + } + return [$regularCode, $footerCode]; + } + + /** + * Reads a locallang file. + * + * @param string $fileRef Reference to a relative filename to include. + * @return array Returns the $LOCAL_LANG array found in the file. If no array found, returns empty array. + */ + protected function readLLfile(string $fileRef): array + { + $languageService = $this->languageServiceFactory->create($this->locale); + return $languageService->getLabelsFromResource($fileRef); + } + + private function handleAddedResource(string|StaticResourceInterface $potentialResource): StaticResourceInterface + { + if ($potentialResource instanceof StaticResourceInterface) { + return $potentialResource; + } + return $this->systemResourceFactory->createResource($potentialResource); + } + + /** + * This function acts as a wrapper to allow relative and paths starting with EXT: to be dealt with + * in this very case to always return the "absolute web path" to be included directly before output. + * + * This is mainly added so the EXT: syntax can be resolved for PageRenderer in one central place, + * and hopefully removed in the future by one standard API call. + * + * The file is also prepared as version numbered file and prefixed as absolute webpath + * + * @param string $file the filename to process + */ + protected function getPublicUrlForFile(string $file, ServerRequestInterface $request): string + { + $resource = $this->systemResourceFactory->createPublicResource($file); + return (string)$this->resourcePublisher->generateUri($resource, $request); + } + + /** + * Execute PreRenderHook for possible manipulation + */ + protected function executePreRenderHook(): void + { + $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-preProcess'] ?? false; + if (!$hooks) { + return; + } + // @todo: $jsFooterFiles and $jsFooterInline and $jsFooterLibs can be removed once the hook is adapted / replaced + $jsFooterFiles = $jsFooterLibs = $jsFooterInline = []; + $params = [ + 'jsLibs' => &$this->jsLibs, + 'jsFooterLibs' => &$jsFooterLibs, + 'jsFiles' => &$this->jsFiles, + 'jsFooterFiles' => &$jsFooterFiles, + 'cssLibs' => &$this->cssLibs, + 'cssFiles' => &$this->cssFiles, + 'headerData' => &$this->headerData, + 'footerData' => &$this->footerData, + 'jsInline' => &$this->jsInline, + 'jsFooterInline' => &$jsFooterInline, + 'cssInline' => &$this->cssInline, + ]; + foreach ($hooks as $hook) { + GeneralUtility::callUserFunction($hook, $params, $this); + } + } + + /** + * PostTransform for possible manipulation of concatenated and compressed files + */ + protected function executeRenderPostTransformHook(): void + { + $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-postTransform'] ?? false; + if (!$hooks) { + return; + } + // @todo: $jsFooterFiles and $jsFooterInline and $jsFooterLibs can be removed once the hook is adapted / replaced + $jsFooterFiles = $jsFooterLibs = $jsFooterInline = []; + $params = [ + 'jsLibs' => &$this->jsLibs, + 'jsFooterLibs' => &$jsFooterLibs, + 'jsFiles' => &$this->jsFiles, + 'jsFooterFiles' => &$jsFooterFiles, + 'cssLibs' => &$this->cssLibs, + 'cssFiles' => &$this->cssFiles, + 'headerData' => &$this->headerData, + 'footerData' => &$this->footerData, + 'jsInline' => &$this->jsInline, + 'jsFooterInline' => &$jsFooterInline, + 'cssInline' => &$this->cssInline, + ]; + foreach ($hooks as $hook) { + GeneralUtility::callUserFunction($hook, $params, $this); + } + } + + /** + * Execute postRenderHook for possible manipulation + * + * @param string $jsLibs + * @param string $jsFiles + * @param string $jsFooterFiles + * @param string $cssLibs + * @param string $cssFiles + * @param string $jsInline + * @param string $cssInline + * @param string $jsFooterInline + * @param string $jsFooterLibs + */ + protected function executePostRenderHook(&$jsLibs, &$jsFiles, &$jsFooterFiles, &$cssLibs, &$cssFiles, &$jsInline, &$cssInline, &$jsFooterInline, &$jsFooterLibs): void + { + $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-postProcess'] ?? false; + if (!$hooks) { + return; + } + $params = [ + 'jsLibs' => &$jsLibs, + 'jsFiles' => &$jsFiles, + 'jsFooterFiles' => &$jsFooterFiles, + 'cssLibs' => &$cssLibs, + 'cssFiles' => &$cssFiles, + 'headerData' => &$this->headerData, + 'footerData' => &$this->footerData, + 'jsInline' => &$jsInline, + 'cssInline' => &$cssInline, + 'xmlPrologAndDocType' => &$this->xmlPrologAndDocType, + 'htmlTag' => &$this->htmlTag, + 'headTag' => &$this->headTag, + 'shortcutTag' => &$this->shortcutTag, + 'inlineComments' => &$this->inlineComments, + 'favIcon' => &$this->favIcon, + 'iconMimeType' => &$this->iconMimeType, + 'titleTag' => &$this->titleTag, + 'title' => &$this->title, + 'jsFooterInline' => &$jsFooterInline, + 'jsFooterLibs' => &$jsFooterLibs, + 'bodyContent' => &$this->bodyContent, + ]; + foreach ($hooks as $hook) { + GeneralUtility::callUserFunction($hook, $params, $this); + } + } + + /** + * Creates a CSS inline tag + * + * @param SystemResourceInterface $resource the resource to process + */ + protected function createInlineCssTagFromFile(SystemResourceInterface $resource, array $properties, ServerRequestInterface $request): string + { + try { + $cssInline = $resource->getContents(); + } catch (SystemResourceDoesNotExistException) { + return ''; + } + $cssInlineFix = $this->relativeCssPathFixer->fixRelativeUrlPaths($cssInline, PathUtility::dirname($resource->getResourceIdentifier()) . '/', $request); + // collect CSP hash - covers the content as it appears inside the <style> tag + $this->directiveHashCollection->addInlineHash(Directive::StyleSrcElem, LF . $cssInlineFix . LF); + $tagAttributes = []; + if ($properties['media'] ?? false) { + $tagAttributes['media'] = $properties['media']; + } + if ($properties['title'] ?? false) { + $tagAttributes['title'] = $properties['title']; + } + // use nonce if given - special case, since content is created from a static file + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consumeInline(Directive::StyleSrcElem); + } + $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); + return $this->wrapInlineStyle($cssInlineFix, $tagAttributes); + } + + protected function wrapInlineStyle(string $content, array $attributes = []): string + { + $styleTag = "<style%s>\n%s\n</style>\n"; + if ($this->docType !== DocType::html5 || $this->docType->isXmlCompliant()) { + $styleTag = "<style%s>\n/*<![CDATA[*/\n<!-- \n%s-->\n/*]]>*/\n</style>\n"; + } + + $attributesList = GeneralUtility::implodeAttributes($attributes, true); + return sprintf( + $styleTag, + $attributesList !== '' ? ' ' . $attributesList : '', + $content + ); + } + + protected function wrapInlineScript(string $content, array $attributes = []): string + { + $scriptTag = "<script%s>\n%s\n</script>\n"; + // * Whenever HTML5 is used, remove the "text/javascript" type from the wrap + // since this is not needed and may lead to validation errors in the future. + // * Whenever XHTML gets disabled, remove the "text/javascript" type from the wrap + // since this is not needed and may lead to validation errors in the future. + if ($this->docType !== DocType::html5 || $this->docType->isXmlCompliant()) { + $attributes['type'] = 'text/javascript'; + $scriptTag = "<script%s>\n/*<![CDATA[*/\n%s/*]]>*/\n</script>\n"; + } + + $attributesList = GeneralUtility::implodeAttributes($attributes, true); + return sprintf( + $scriptTag, + $attributesList !== '' ? ' ' . $attributesList : '', + $content + ); + } + + /** + * String 'FE' if in FrontendApplication, 'BE' otherwise (also in CLI without request object) + */ + protected function getApplicationType(ServerRequestInterface $request): string + { + if (ApplicationType::fromRequest($request)->isFrontend()) { + return 'FE'; + } + return 'BE'; + } + + private function getBackendUser(): BackendUserAuthentication + { + if (!$GLOBALS['BE_USER'] instanceof BackendUserAuthentication) { + throw new \RuntimeException('No backend user found.', 1765402790); + } + return $GLOBALS['BE_USER']; + } + +} diff --git a/Classes/Page/ResolveContentAreasEvent.php b/Classes/Page/ResolveContentAreasEvent.php new file mode 100644 index 0000000..bbb2d37 --- /dev/null +++ b/Classes/Page/ResolveContentAreasEvent.php @@ -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\Core\Page; + +use TYPO3\CMS\Backend\View\BackendLayout\BackendLayout; + +/** + * @internal + */ +final class ResolveContentAreasEvent +{ + /** @var ContentAreaClosure[]|ContentArea[] */ + private array $contentAreas = []; + + public function __construct(private readonly BackendLayout $layout) {} + + public function getBackendLayout(): BackendLayout + { + return $this->layout; + } + + public function setContentAreas(array $contentAreas): void + { + $this->contentAreas = $contentAreas; + } + + public function getContentAreas(): array + { + return $this->contentAreas; + } +} diff --git a/Classes/Page/ResourceHashCollection.php b/Classes/Page/ResourceHashCollection.php new file mode 100644 index 0000000..5d4daa8 --- /dev/null +++ b/Classes/Page/ResourceHashCollection.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Page; + +use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashProxy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashType; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; +use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\UriResource; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * @internal + */ +final readonly class ResourceHashCollection +{ + public const string AUTO = 'auto'; + + public function __construct( + private LoggerInterface $logger, + private SystemResourceFactory $systemResourceFactory, + #[Autowire(service: 'cache.assets')] + private FrontendInterface $assetsCache, + ) {} + + public function fetchResourceHash(string|UriInterface|StaticResourceInterface $value, HashType $type = HashType::sha256): ?HashValue + { + if (is_string($value)) { + $value = $this->resolveResourceValue($value); + } + if (empty($value)) { + return null; + } + try { + if ($value instanceof UriInterface || $value instanceof UriResource) { + return HashValue::parse( + HashProxy::urls((string)$value)->withType($type)->compile($this->assetsCache) + ); + } + if ($value instanceof SystemResourceInterface) { + return HashValue::parse( + HashProxy::resource((string)$value)->withType($type)->compile($this->assetsCache) + ); + } + return null; + } catch (\Throwable $t) { + $this->logger->error('Could not add resource hash: {exceptionMessage}', [ + 'value' => $value, + 'exceptionMessage' => $t->getMessage(), + 'exceptionCode' => $t->getCode(), + ]); + return null; + } + } + + public function resolveResourceValue(string $value): UriInterface|StaticResourceInterface|null + { + if (PathUtility::hasProtocolAndScheme($value)) { + try { + return new Uri($value); + } catch (\Exception) { + return null; + } + } + if (PathUtility::isExtensionPath($value)) { + return $this->systemResourceFactory->createResource($value); + } + return null; + } +} diff --git a/Classes/PageTitle/AbstractPageTitleProvider.php b/Classes/PageTitle/AbstractPageTitleProvider.php new file mode 100644 index 0000000..d9342b1 --- /dev/null +++ b/Classes/PageTitle/AbstractPageTitleProvider.php @@ -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\Core\PageTitle; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\SingletonInterface; + +/** + * Abstract for PageTitleProviders + */ +abstract class AbstractPageTitleProvider implements PageTitleProviderInterface, SingletonInterface +{ + protected ServerRequestInterface $request; + protected string $title = ''; + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/Classes/PageTitle/PageTitleProviderInterface.php b/Classes/PageTitle/PageTitleProviderInterface.php new file mode 100644 index 0000000..1ede5f9 --- /dev/null +++ b/Classes/PageTitle/PageTitleProviderInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PageTitle; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Interface for PageTitleProviders with the methods that are needed by the PageTitleProviderManager + */ +interface PageTitleProviderInterface +{ + public function getTitle(): string; + + public function setRequest(ServerRequestInterface $request): void; +} diff --git a/Classes/PageTitle/PageTitleProviderManager.php b/Classes/PageTitle/PageTitleProviderManager.php new file mode 100644 index 0000000..bc78bf9 --- /dev/null +++ b/Classes/PageTitle/PageTitleProviderManager.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PageTitle; + +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Service\DependencyOrderingService; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\TypoScript\TypoScriptService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This class will take care of the different providers and returns the title with the highest priority + */ +class PageTitleProviderManager implements SingletonInterface +{ + private array $pageTitleCache = []; + + public function __construct( + private readonly ContainerInterface $container, + private readonly DependencyOrderingService $dependencyOrderingService, + private readonly TypoScriptService $typoScriptService, + private readonly LoggerInterface $logger, + ) {} + + public function getTitle(ServerRequestInterface $request): string + { + $pageTitle = ''; + + $titleProviders = $this->getPageTitleProviderConfiguration($request); + $titleProviders = $this->setProviderOrder($titleProviders); + $orderedTitleProviders = $this->dependencyOrderingService->orderByDependencies($titleProviders); + + $this->logger->debug('Page title providers ordered', [ + 'orderedTitleProviders' => $orderedTitleProviders, + ]); + + foreach ($orderedTitleProviders as $configuration) { + if (is_subclass_of($configuration['provider'] ?? null, PageTitleProviderInterface::class)) { + /** @var PageTitleProviderInterface $titleProviderObject */ + $titleProviderObject = $this->container->get($configuration['provider']); + $titleProviderObject->setRequest($request); + if (($pageTitle = $titleProviderObject->getTitle()) + || ($pageTitle = $this->pageTitleCache[$configuration['provider']] ?? '') !== '' + ) { + $this->logger->debug('Page title provider {provider} used on page {title}', [ + 'title' => $pageTitle, + 'provider' => $configuration['provider'], + ]); + $this->pageTitleCache[$configuration['provider']] = $pageTitle; + break; + } + $this->logger->debug('Page title provider {provider} skipped on page {title}', [ + 'title' => $pageTitle, + 'provider' => $configuration['provider'], + 'providerUsed' => $configuration['provider'], + ]); + } + } + + return $pageTitle; + } + + /** + * @internal + */ + public function getPageTitleCache(): array + { + return $this->pageTitleCache; + } + + /** + * @internal + */ + public function setPageTitleCache(array $pageTitleCache): void + { + $this->pageTitleCache = $pageTitleCache; + } + + /** + * Get the TypoScript configuration for pageTitleProviders + */ + private function getPageTitleProviderConfiguration(ServerRequestInterface $request): array + { + $config = $this->typoScriptService->convertTypoScriptArrayToPlainArray( + $request->getAttribute('frontend.typoscript')->getConfigArray() + ); + return $config['pageTitleProviders'] ?? []; + } + + /** + * @return string[] + * @throws \UnexpectedValueException + */ + protected function setProviderOrder(array $orderInformation): array + { + foreach ($orderInformation as $provider => &$configuration) { + if (isset($configuration['before'])) { + if (is_string($configuration['before'])) { + $configuration['before'] = GeneralUtility::trimExplode(',', $configuration['before'], true); + } elseif (!is_array($configuration['before'])) { + throw new \UnexpectedValueException( + 'The specified "before" order configuration for provider "' . $provider . '" is invalid.', + 1535803185 + ); + } + } + if (isset($configuration['after'])) { + if (is_string($configuration['after'])) { + $configuration['after'] = GeneralUtility::trimExplode(',', $configuration['after'], true); + } elseif (!is_array($configuration['after'])) { + throw new \UnexpectedValueException( + 'The specified "after" order configuration for provider "' . $provider . '" is invalid.', + 1535803186 + ); + } + } + } + return $orderInformation; + } +} diff --git a/Classes/PageTitle/RecordPageTitleProvider.php b/Classes/PageTitle/RecordPageTitleProvider.php new file mode 100644 index 0000000..bb61163 --- /dev/null +++ b/Classes/PageTitle/RecordPageTitleProvider.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PageTitle; + +/** + * This class will take care of the default page title + */ +class RecordPageTitleProvider extends AbstractPageTitleProvider +{ + public function getTitle(): string + { + $pageInformation = $this->request->getAttribute('frontend.page.information'); + return (string)($pageInformation->getPageRecord()['title'] ?? ''); + } +} diff --git a/Classes/PageTitle/RecordTitleProvider.php b/Classes/PageTitle/RecordTitleProvider.php new file mode 100644 index 0000000..93d7dda --- /dev/null +++ b/Classes/PageTitle/RecordTitleProvider.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PageTitle; + +/** + * This class will take care of the seo title that can be set in the backend + */ +class RecordTitleProvider extends AbstractPageTitleProvider +{ + public function setTitle(string $title): void + { + $this->title = $title; + } +} diff --git a/Classes/Pagination/AbstractPaginator.php b/Classes/Pagination/AbstractPaginator.php new file mode 100644 index 0000000..fdfd4e1 --- /dev/null +++ b/Classes/Pagination/AbstractPaginator.php @@ -0,0 +1,185 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Pagination; + +abstract class AbstractPaginator implements PaginatorInterface +{ + /** + * @var int + */ + protected $numberOfPages = 1; + + /** + * @var int + */ + protected $keyOfFirstPaginatedItem = 0; + + /** + * @var int + */ + protected $keyOfLastPaginatedItem = 0; + + /** + * @var int + */ + private $currentPageNumber = 1; + + /** + * @var int + */ + private $itemsPerPage = 10; + + public function withItemsPerPage(int $itemsPerPage): PaginatorInterface + { + if ($itemsPerPage === $this->itemsPerPage) { + return $this; + } + + $new = clone $this; + $new->setItemsPerPage($itemsPerPage); + $new->updateInternalState(); + + return $new; + } + + public function withCurrentPageNumber(int $currentPageNumber): PaginatorInterface + { + if ($currentPageNumber === $this->currentPageNumber) { + return $this; + } + + $new = clone $this; + $new->setCurrentPageNumber($currentPageNumber); + $new->updateInternalState(); + + return $new; + } + + public function getNumberOfPages(): int + { + return $this->numberOfPages; + } + + public function getCurrentPageNumber(): int + { + return $this->currentPageNumber; + } + + public function getKeyOfFirstPaginatedItem(): int + { + return $this->keyOfFirstPaginatedItem; + } + + public function getKeyOfLastPaginatedItem(): int + { + return $this->keyOfLastPaginatedItem; + } + + /** + * Must update the paginated items, i.e. the subset of all items, limited and defined by + * the given amount of items per page and offset + */ + abstract protected function updatePaginatedItems(int $itemsPerPage, int $offset): void; + + /** + * Must return the total amount of all unpaginated items + */ + abstract protected function getTotalAmountOfItems(): int; + + /** + * Must return the amount of paginated items on the current page + */ + abstract protected function getAmountOfItemsOnCurrentPage(): int; + + /** + * States whether there are items on the current page + */ + protected function hasItemsOnCurrentPage(): bool + { + return $this->getAmountOfItemsOnCurrentPage() > 0; + } + + /** + * This method is the heart of the pagination. It updates all internal params and then calls the + * {@see updatePaginatedItems} method which must update the set of paginated items. + */ + protected function updateInternalState(): void + { + $offset = (int)($this->itemsPerPage * ($this->currentPageNumber - 1)); + $totalAmountOfItems = $this->getTotalAmountOfItems(); + + /* + * If the total amount of items is zero, then the number of pages is mathematically zero as + * well. As that looks strange in the frontend, the number of pages is forced to be at least + * one. + */ + $this->numberOfPages = max(1, (int)ceil($totalAmountOfItems / $this->itemsPerPage)); + + /* + * To prevent empty results in case the given current page number exceeds the maximum number + * of pages, we set the current page number to the last page and update the internal state + * with this value again. Such situation should in the first place be prevented by not allowing + * those values to be passed, e.g. by using the "max" attribute in the view. However there are + * valid cases. For example when a user deletes a record while the pagination is already visible + * to another user with, until then, a valid "max" value. Passing invalid values unintentionally + * should therefore just silently be resolved. + */ + if ($this->currentPageNumber > $this->numberOfPages) { + $this->currentPageNumber = $this->numberOfPages; + $this->updateInternalState(); + return; + } + + $this->updatePaginatedItems($this->itemsPerPage, $offset); + + if (!$this->hasItemsOnCurrentPage()) { + $this->keyOfFirstPaginatedItem = 0; + $this->keyOfLastPaginatedItem = 0; + return; + } + + $indexOfLastPaginatedItem = min($offset + $this->itemsPerPage, $totalAmountOfItems); + + $this->keyOfFirstPaginatedItem = $offset; + $this->keyOfLastPaginatedItem = $indexOfLastPaginatedItem - 1; + } + + protected function setItemsPerPage(int $itemsPerPage): void + { + if ($itemsPerPage < 1) { + throw new \InvalidArgumentException( + 'Argument $itemsPerPage must be greater than 0', + 1573061766 + ); + } + + $this->itemsPerPage = $itemsPerPage; + } + + protected function setCurrentPageNumber(int $currentPageNumber): void + { + if ($currentPageNumber < 1) { + throw new \InvalidArgumentException( + 'Argument $currentPageNumber must be greater than 0', + 1573047338 + ); + } + + $this->currentPageNumber = $currentPageNumber; + } +} diff --git a/Classes/Pagination/ArrayPaginator.php b/Classes/Pagination/ArrayPaginator.php new file mode 100644 index 0000000..0803112 --- /dev/null +++ b/Classes/Pagination/ArrayPaginator.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Pagination; + +final class ArrayPaginator extends AbstractPaginator +{ + /** + * @var array + */ + private $items; + + /** + * @var array + */ + private $paginatedItems = []; + + public function __construct( + array $items, + int $currentPageNumber = 1, + int $itemsPerPage = 10 + ) { + $this->items = $items; + $this->setCurrentPageNumber($currentPageNumber); + $this->setItemsPerPage($itemsPerPage); + + $this->updateInternalState(); + } + + /** + * @return iterable|array + */ + public function getPaginatedItems(): iterable + { + return $this->paginatedItems; + } + + protected function updatePaginatedItems(int $itemsPerPage, int $offset): void + { + $this->paginatedItems = array_slice($this->items, $offset, $itemsPerPage); + } + + protected function getTotalAmountOfItems(): int + { + return count($this->items); + } + + protected function getAmountOfItemsOnCurrentPage(): int + { + return count($this->paginatedItems); + } +} diff --git a/Classes/Pagination/PaginationInterface.php b/Classes/Pagination/PaginationInterface.php new file mode 100644 index 0000000..845e9b4 --- /dev/null +++ b/Classes/Pagination/PaginationInterface.php @@ -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\Core\Pagination; + +/** + * An interface that defines methods needed to implement a pagination + * + * A pagination is an object that takes a paginator and calculates variables + * to render a pagination for the paginated objects in the given paginator + */ +interface PaginationInterface +{ + public function __construct(PaginatorInterface $paginator); + + /** + * Must return the previous page number + * + * Is allowed to return null to indicate that there is no + * previous page, e.g. when being on the first page + */ + public function getPreviousPageNumber(): ?int; + + /** + * Must return the next page number + * + * Is allowed to return null to indicate that there is no + * next page, e.g. when being on the last page + */ + public function getNextPageNumber(): ?int; + + /** + * Must return the first page number, usually this will return 1 + */ + public function getFirstPageNumber(): int; + + /** + * Must return the last page number, usually this will return the total amount of pages + */ + public function getLastPageNumber(): int; + + /** + * Must return the human-readable index of the first paginated item + * + * Example: given a set of 10 total items, 5 items per page and the current page being 2, + * the start record number is 6: + * + * Page 1: Records 1-5 + * Page 2: Records 6-10 + */ + public function getStartRecordNumber(): int; + + /** + * Must return the human-readable index of the last paginated item + * + * Example: given a set of 10 total items, 5 items per page and the current page being 2, + * the end record number is 10. + * + * Page 1: Records 1-5 + * Page 2: Records 6-10 + */ + public function getEndRecordNumber(): int; + + /** + * Must return a list of all page numbers. + * + * @return int[] + */ + public function getAllPageNumbers(): array; +} diff --git a/Classes/Pagination/PaginatorInterface.php b/Classes/Pagination/PaginatorInterface.php new file mode 100644 index 0000000..983d2ec --- /dev/null +++ b/Classes/Pagination/PaginatorInterface.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Pagination; + +/** + * An interface that defines methods needed to implement a paginator, i.e. an object that handles + * a set of items and returns a sub set of items, given by a configuration. + */ +interface PaginatorInterface +{ + /** + * Sets the amount of paginated items per page + * + * Must return a new instance of the Paginator with an updated internal state + */ + public function withItemsPerPage(int $itemsPerPage): PaginatorInterface; + + /** + * Sets the current page to calculate paginated items for + * + * Must return a new instance of the Paginator with an updated internal state + */ + public function withCurrentPageNumber(int $currentPageNumber): PaginatorInterface; + + /** + * Returns an iterable, sub set of the original set of items + */ + public function getPaginatedItems(): iterable; + + /** + * Returns the total number of pages, given the total number of non paginated items and the + * items per page configuration + */ + public function getNumberOfPages(): int; + + /** + * Returns the current page number + */ + public function getCurrentPageNumber(): int; + + /** + * Returns the key of the first paginated item + * + * This is useful to display the exact range of + * items that are available via getPaginatedItems + */ + public function getKeyOfFirstPaginatedItem(): int; + + /** + * Returns the key of the last paginated item + * + * This is useful to display the exact range of + * items that are available via getPaginatedItems + */ + public function getKeyOfLastPaginatedItem(): int; +} diff --git a/Classes/Pagination/QueryBuilderPaginator.php b/Classes/Pagination/QueryBuilderPaginator.php new file mode 100644 index 0000000..d9b8edc --- /dev/null +++ b/Classes/Pagination/QueryBuilderPaginator.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Pagination; + +use TYPO3\CMS\Core\Database\Query\QueryBuilder; + +/** + * Provides a paginator implementation to be used with {@see QueryBuilder} as + * data source. + * + * **Be aware** that this comes with a couple of things to be considered: + * + * * QueryBuilder is used in a generic way by this Paginator and does not take care of proper language overlay + * handling and cannot do that in a easy way and applying overlays on the result set can lead to weired item + * count jumps on pages in case some of them are removed. For example 5 items on page 1, 6 on page two albeit + * 10 items per page has been requested. + * + * * The paginator is completely in charge handling the pagination (offset/limit) and **does** not take + * existing constraints of the passed QueryBuilder into account to match the expectation shared across + * pagination handling throughout different frameworks and other Paginator implementation of TYPO3. + */ +final class QueryBuilderPaginator extends AbstractPaginator +{ + private array $paginatedItems = []; + private ?int $totalItems = null; + + public function __construct( + private readonly QueryBuilder $queryBuilder, + int $currentPageNumber = 1, + int $itemsPerPage = 10, + ) { + $this->setCurrentPageNumber($currentPageNumber); + $this->setItemsPerPage($itemsPerPage); + + $this->updateInternalState(); + } + + public function getPaginatedItems(): iterable + { + return $this->paginatedItems; + } + + protected function updatePaginatedItems(int $itemsPerPage, int $offset): void + { + $paginatedQueryBuilder = clone $this->queryBuilder; + $this->paginatedItems = $paginatedQueryBuilder + ->setMaxResults($itemsPerPage) + ->setFirstResult($offset) + ->executeQuery() + ->fetchAllAssociative(); + } + + protected function getTotalAmountOfItems(): int + { + return $this->getTotalItems(); + } + + protected function getAmountOfItemsOnCurrentPage(): int + { + return count($this->paginatedItems); + } + + private function getTotalItems(): int + { + if ($this->totalItems === null) { + $clonedQueryBuilder = clone $this->queryBuilder; + // Remove obsolete query parts. There is no need to enforce any ordering improving + // the performance and pagination constraints (LIMIT and OFFSET) are removed because + // otherwise we would not get the total items count. + $clonedQueryBuilder + ->resetOrderBy() + ->setMaxResults(null) + ->setFirstResult(0); + + $this->totalItems = (int)$clonedQueryBuilder->getConnection()->createQueryBuilder() + // @todo Upstream doctrine/dbal with() is not adopted in the decoration pattern and the reason to use + // typo3 internal implementation for the common table expression here. Replace it when upstream + // with() support has been integrated into the decoration chain. + ->typo3_with('cte_count', $clonedQueryBuilder) + ->count('*') + ->from('cte_count') + ->setParameters($clonedQueryBuilder->getParameters(), $clonedQueryBuilder->getParameterTypes()) + ->executeQuery() + ->fetchOne(); + } + return $this->totalItems; + } +} diff --git a/Classes/Pagination/SimplePagination.php b/Classes/Pagination/SimplePagination.php new file mode 100644 index 0000000..01d5a1b --- /dev/null +++ b/Classes/Pagination/SimplePagination.php @@ -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\Core\Pagination; + +final class SimplePagination implements PaginationInterface +{ + private PaginatorInterface $paginator; + + public function __construct(PaginatorInterface $paginator) + { + $this->paginator = $paginator; + } + + public function getPreviousPageNumber(): ?int + { + $previousPage = $this->paginator->getCurrentPageNumber() - 1; + + if ($previousPage > $this->paginator->getNumberOfPages()) { + return null; + } + + return $previousPage >= $this->getFirstPageNumber() + ? $previousPage + : null + ; + } + + public function getNextPageNumber(): ?int + { + $nextPage = $this->paginator->getCurrentPageNumber() + 1; + + return $nextPage <= $this->paginator->getNumberOfPages() + ? $nextPage + : null + ; + } + + public function getFirstPageNumber(): int + { + return 1; + } + + public function getLastPageNumber(): int + { + return $this->paginator->getNumberOfPages(); + } + + public function getStartRecordNumber(): int + { + if ($this->paginator->getCurrentPageNumber() > $this->paginator->getNumberOfPages()) { + return 0; + } + + return $this->paginator->getKeyOfFirstPaginatedItem() + 1; + } + + public function getEndRecordNumber(): int + { + if ($this->paginator->getCurrentPageNumber() > $this->paginator->getNumberOfPages()) { + return 0; + } + + return $this->paginator->getKeyOfLastPaginatedItem() + 1; + } + + /** + * @return int[] + */ + public function getAllPageNumbers(): array + { + return range($this->getFirstPageNumber(), $this->getLastPageNumber()); + } + + public function getPaginator(): PaginatorInterface + { + return $this->paginator; + } +} diff --git a/Classes/Pagination/SlidingWindowPagination.php b/Classes/Pagination/SlidingWindowPagination.php new file mode 100644 index 0000000..dd3f881 --- /dev/null +++ b/Classes/Pagination/SlidingWindowPagination.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Pagination; + +final class SlidingWindowPagination implements PaginationInterface +{ + private int $displayRangeStart = 0; + private int $displayRangeEnd = 0; + private bool $hasLessPages = false; + private bool $hasMorePages = false; + private int $maximumNumberOfLinks = 0; + private PaginatorInterface $paginator; + + public function __construct(PaginatorInterface $paginator, int $maximumNumberOfLinks = 0) + { + $this->paginator = $paginator; + + if ($maximumNumberOfLinks > 0) { + $this->maximumNumberOfLinks = $maximumNumberOfLinks; + } + + $this->calculateDisplayRange(); + } + + public function getPreviousPageNumber(): ?int + { + $previousPage = $this->paginator->getCurrentPageNumber() - 1; + + if ($previousPage > $this->paginator->getNumberOfPages()) { + return null; + } + + return $previousPage >= $this->getFirstPageNumber() ? $previousPage : null; + } + + public function getNextPageNumber(): ?int + { + $nextPage = $this->paginator->getCurrentPageNumber() + 1; + + return $nextPage <= $this->paginator->getNumberOfPages() ? $nextPage : null; + } + + public function getFirstPageNumber(): int + { + return 1; + } + + public function getLastPageNumber(): int + { + return $this->paginator->getNumberOfPages(); + } + + public function getStartRecordNumber(): int + { + if ($this->paginator->getCurrentPageNumber() > $this->paginator->getNumberOfPages()) { + return 0; + } + + return $this->paginator->getKeyOfFirstPaginatedItem() + 1; + } + + public function getEndRecordNumber(): int + { + if ($this->paginator->getCurrentPageNumber() > $this->paginator->getNumberOfPages()) { + return 0; + } + + return $this->paginator->getKeyOfLastPaginatedItem() + 1; + } + + public function getAllPageNumbers(): array + { + return range($this->displayRangeStart, $this->displayRangeEnd); + } + + public function getDisplayRangeStart(): int + { + return $this->displayRangeStart; + } + + public function getDisplayRangeEnd(): int + { + return $this->displayRangeEnd; + } + + public function getHasLessPages(): bool + { + return $this->hasLessPages; + } + + public function getHasMorePages(): bool + { + return $this->hasMorePages; + } + + public function getMaximumNumberOfLinks(): int + { + return $this->maximumNumberOfLinks; + } + + public function getPaginator(): PaginatorInterface + { + return $this->paginator; + } + + private function calculateDisplayRange(): void + { + $maximumNumberOfLinks = $this->maximumNumberOfLinks; + $numberOfPages = $this->paginator->getNumberOfPages(); + + if ($maximumNumberOfLinks > $numberOfPages) { + $maximumNumberOfLinks = $numberOfPages; + } + + $currentPage = $this->paginator->getCurrentPageNumber(); + $delta = floor($maximumNumberOfLinks / 2); + + $this->displayRangeStart = (int)($currentPage - $delta); + $this->displayRangeEnd = (int)($currentPage + $delta - ($maximumNumberOfLinks % 2 === 0 ? 1 : 0)); + + if ($this->displayRangeStart < 1) { + $this->displayRangeEnd -= $this->displayRangeStart - 1; + } + + if ($this->displayRangeEnd > $numberOfPages) { + $this->displayRangeStart -= $this->displayRangeEnd - $numberOfPages; + } + + $this->displayRangeStart = (int)max($this->displayRangeStart, 1); + $this->displayRangeEnd = (int)min($this->displayRangeEnd, $numberOfPages); + $this->hasLessPages = $this->displayRangeStart > 2; + $this->hasMorePages = $this->displayRangeEnd + 1 < $this->paginator->getNumberOfPages(); + } +} diff --git a/Classes/PasswordPolicy/Event/EnrichPasswordValidationContextDataEvent.php b/Classes/PasswordPolicy/Event/EnrichPasswordValidationContextDataEvent.php new file mode 100644 index 0000000..104784e --- /dev/null +++ b/Classes/PasswordPolicy/Event/EnrichPasswordValidationContextDataEvent.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PasswordPolicy\Event; + +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; + +/** + * Event is dispatched before the `ContextData` DTO is passed to the password policy validator. + * + * Note, that the `$userData` array will include user data available from the initiating class only. + * Event listeners should therefore always consider the initiating class name when accessing data + * from `getUserData()`. + */ +final readonly class EnrichPasswordValidationContextDataEvent +{ + public function __construct( + private ContextData $contextData, + private array $userData, + private string $initiatingClass + ) {} + + public function getContextData(): ContextData + { + return $this->contextData; + } + + public function getUserData(): array + { + return $this->userData; + } + + public function getInitiatingClass(): string + { + return $this->initiatingClass; + } +} diff --git a/Classes/PasswordPolicy/Generator/PasswordGenerator.php b/Classes/PasswordPolicy/Generator/PasswordGenerator.php new file mode 100644 index 0000000..60082fb --- /dev/null +++ b/Classes/PasswordPolicy/Generator/PasswordGenerator.php @@ -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\Core\PasswordPolicy\Generator; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Exception\InvalidPasswordRulesException; + +/** + * @internal only to be used within ext:core, not part of TYPO3 Core API. + */ +#[Autoconfigure(public: true)] +final readonly class PasswordGenerator implements PasswordGeneratorInterface +{ + public function __construct(private Random $random) {} + + /** + * @throws InvalidPasswordRulesException + */ + public function generate(array $options): string + { + return $this->random->generateRandomPassword($options); + } +} diff --git a/Classes/PasswordPolicy/Generator/PasswordGeneratorInterface.php b/Classes/PasswordPolicy/Generator/PasswordGeneratorInterface.php new file mode 100644 index 0000000..a030c1d --- /dev/null +++ b/Classes/PasswordPolicy/Generator/PasswordGeneratorInterface.php @@ -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\Core\PasswordPolicy\Generator; + +use TYPO3\CMS\Core\Exception\InvalidPasswordRulesException; + +/** + * This is an interface that has to be used by all password generators. + * + * Each password generator needs to implement the generate method that returns the generated password. + * In case an invalid option/configuration is passed to the generator, an InvalidPasswordRulesException + * or LogicException needs to be thrown. + */ +interface PasswordGeneratorInterface +{ + /** + * @throws InvalidPasswordRulesException + */ + public function generate(array $options): string; +} diff --git a/Classes/PasswordPolicy/PasswordPolicy.php b/Classes/PasswordPolicy/PasswordPolicy.php new file mode 100644 index 0000000..9278524 --- /dev/null +++ b/Classes/PasswordPolicy/PasswordPolicy.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PasswordPolicy; + +use TYPO3\CMS\Core\PasswordPolicy\Validator\AbstractPasswordValidator; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Password policy class which holds information about configured password validators and password requirements + * + * @internal + */ +class PasswordPolicy +{ + /** + * @var AbstractPasswordValidator[] + */ + protected array $validators = []; + + /** + * @param array<class-string<AbstractPasswordValidator>, array<string, mixed>> $validators + */ + public function __construct(array $validators, protected PasswordPolicyAction $action) + { + foreach ($validators as $validatorClassName => $validatorSettings) { + // Exclude validator if current action is defined as excludeAction + if (in_array($action, $validatorSettings['excludeActions'] ?? [], true)) { + continue; + } + + $this->validators[] = GeneralUtility::makeInstance( + $validatorClassName, + $validatorSettings['options'] ?? [] + ); + } + } + + public function getAction(): PasswordPolicyAction + { + return $this->action; + } + + public function hasValidators(): bool + { + return !empty($this->validators); + } + + public function getValidators(): array + { + return $this->validators; + } + + /** + * Returns an array with requirements (e.g. ["Password must at least contain one char"]) for all + * configured password validators. The structure of the array is as following: + * + * ['classId.validatorId' => 'Requirement text'] + */ + public function getRequirements(): array + { + $requirements = []; + foreach ($this->validators as $validator) { + $requirements = array_merge($requirements, $validator->getRequirements()); + } + + return $requirements; + } +} diff --git a/Classes/PasswordPolicy/PasswordPolicyAction.php b/Classes/PasswordPolicy/PasswordPolicyAction.php new file mode 100644 index 0000000..c355b0c --- /dev/null +++ b/Classes/PasswordPolicy/PasswordPolicyAction.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PasswordPolicy; + +/** + * This class contains actions which are used in password policy validators + */ +enum PasswordPolicyAction: string +{ + case UPDATE_USER_PASSWORD = 'updateUserPassword'; + case UPDATE_USER_PASSWORD_SWITCH_USER_MODE = 'updateUserPasswordSwitchUserMode'; + case NEW_USER_PASSWORD = 'newUserPassword'; + case UPDATE_INSTALL_TOOL_PASSWORD = 'updateInstallToolPassword'; +} diff --git a/Classes/PasswordPolicy/PasswordPolicyValidator.php b/Classes/PasswordPolicy/PasswordPolicyValidator.php new file mode 100644 index 0000000..cade0b1 --- /dev/null +++ b/Classes/PasswordPolicy/PasswordPolicyValidator.php @@ -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\Core\PasswordPolicy; + +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; + +/** + * Validates a password using validators configured in $GLOBALS['TYPO3_CONF_VARS']['SYS']['passwordPolicies']. + * The class must be instantiated with an action (see PasswordPolicyAction) and a password policy name. + */ +class PasswordPolicyValidator +{ + protected ?PasswordPolicy $passwordPolicy = null; + protected array $validationErrors = []; + + public function __construct(PasswordPolicyAction $action, string $passwordPolicy = 'default') + { + $passwordPolicies = $GLOBALS['TYPO3_CONF_VARS']['SYS']['passwordPolicies'] ?? []; + if (isset($passwordPolicies[$passwordPolicy])) { + $this->passwordPolicy = new PasswordPolicy( + $passwordPolicies[$passwordPolicy]['validators'] ?? [], + $action, + ); + } + } + + /** + * Returns, if the given password meets all requirements defined by configured password policy validators. + * If no password policy is set or the password policy has no validators, the given password is considered + * as valid. + * + * @param string $password The password to validate + * @param ContextData|null $contextData ContextData for usage in additional checks (e.g. password must not contain users firstname). + */ + public function isValidPassword(string $password, ?ContextData $contextData = null): bool + { + if (!$this->isEnabled()) { + return true; + } + + $isValid = true; + foreach ($this->passwordPolicy->getValidators() as $validator) { + if (!$validator->validate($password, $contextData)) { + $this->validationErrors = array_merge($this->validationErrors, $validator->getErrorMessages()); + $isValid = false; + } + } + + return $isValid; + } + + public function isEnabled(): bool + { + return $this->passwordPolicy !== null && $this->passwordPolicy->hasValidators(); + } + + public function hasRequirements(): bool + { + return !empty($this->getRequirements()); + } + + public function getRequirements(): array + { + return $this->passwordPolicy ? $this->passwordPolicy->getRequirements() : []; + } + + public function getValidationErrors(): array + { + return $this->validationErrors; + } +} diff --git a/Classes/PasswordPolicy/PasswordService.php b/Classes/PasswordPolicy/PasswordService.php new file mode 100644 index 0000000..592e34a --- /dev/null +++ b/Classes/PasswordPolicy/PasswordService.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PasswordPolicy; + +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +final readonly class PasswordService +{ + /** + * @return array<string, string> + */ + public function getValidationErrorsForInstallToolUpdate(#[\SensitiveParameter] string $password): array + { + return $this->getValidationErrorsForPolicyAction( + $password, + 'installTool', + PasswordPolicyAction::UPDATE_INSTALL_TOOL_PASSWORD, + ); + } + + /** + * @param string $password The password to validate + * @param string $passwordPolicyUsageContext Refers to a section in $GLOBALS['TYPO3_CONF_VARS']['SYS']['passwordPolicies'][$passwordPolicyUsage]['validators'][...] + * @param PasswordPolicyAction $passwordPolicyAction Policy action to perform (to indicate the action like "update Install Tool password") + * @param ContextData|null $contextData Optional context data (for example, previous/current password(s)) used within validators + */ + public function getValidationErrorsForPolicyAction( + #[\SensitiveParameter] + string $password, + string $passwordPolicyUsageContext, + PasswordPolicyAction $passwordPolicyAction, + ?ContextData $contextData = null, + ): array { + $passwordPolicyValidator = GeneralUtility::makeInstance( + PasswordPolicyValidator::class, + $passwordPolicyAction, + $passwordPolicyUsageContext, + ); + $passwordPolicyValidator->isValidPassword($password, $contextData); + return $passwordPolicyValidator->getValidationErrors(); + } +} diff --git a/Classes/PasswordPolicy/Validator/AbstractPasswordValidator.php b/Classes/PasswordPolicy/Validator/AbstractPasswordValidator.php new file mode 100644 index 0000000..cc5c3e8 --- /dev/null +++ b/Classes/PasswordPolicy/Validator/AbstractPasswordValidator.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\PasswordPolicy\Validator; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Abstract password validator class, which all TYPO3 password validators must extend. + */ +abstract class AbstractPasswordValidator +{ + private array $requirements = []; + private array $errorMessages = []; + + public function __construct(protected array $options = []) + { + $this->initializeRequirements(); + } + + /** + * Function must be overwritten by extending classes in order to add requirements. + * Use `$this->addRequirement(string $identifier, string $message);` to add a requirement. + */ + public function initializeRequirements(): void {} + + /** + * Validates the given password. Function must be overwritten by extending classes. + * If validation is considered as failed, use `addErrorMessage(string $identifier, string $errorMessage)` + * to add an error message and return `false`. + * + * @param string $password The password to validate + * @param ContextData|null $contextData ContextData for usage in additional checks (e.g. password must not contain users firstname). + */ + public function validate(string $password, ?ContextData $contextData = null): bool + { + return false; + } + + /** + * Returns all requirements + */ + final public function getRequirements(): array + { + return array_map(htmlspecialchars(...), $this->requirements); + } + + /** + * Adds a requirement with the given identifier and message. + * + * @param string $identifier Unique identifier for requirement + * @param string $message Message describing the requirement (e.g. "At least one digit") + */ + final protected function addRequirement(string $identifier, string $message): void + { + $classIdentifier = $this->getClassId(); + $this->requirements[$classIdentifier . $identifier] = $message; + } + + /** + * Returns all error messages + */ + final public function getErrorMessages(): array + { + return $this->errorMessages; + } + + /** + * Adds an validation error message with the given identifier and message. + * + * @param string $identifier Unique identifier for error message + * @param string $errorMessage Message describing the error (e.g. "The password must at least contain one digit") + */ + final protected function addErrorMessage(string $identifier, string $errorMessage): void + { + $classIdentifier = $this->getClassId(); + $this->errorMessages[$classIdentifier . $identifier] = $errorMessage; + } + + private function getClassId(): string + { + $classParts = explode('\\', static::class); + return lcfirst(end($classParts)) . '.'; + } + + protected function getLanguageService(): LanguageService + { + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + if ($request instanceof ServerRequestInterface && ApplicationType::fromRequest($request)->isFrontend()) { + $languageServiceFactory = GeneralUtility::makeInstance(LanguageServiceFactory::class); + return $languageServiceFactory->createFromSiteLanguage($request->getAttribute('language') + ?? $request->getAttribute('site')->getDefaultLanguage()); + } + + if (($GLOBALS['LANG'] ?? null) instanceof LanguageService) { + return $GLOBALS['LANG']; + } + + $languageServiceFactory = GeneralUtility::makeInstance(LanguageServiceFactory::class); + return $languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER'] ?? null); + } +} diff --git a/Classes/PasswordPolicy/Validator/CorePasswordValidator.php b/Classes/PasswordPolicy/Validator/CorePasswordValidator.php new file mode 100644 index 0000000..47a55b3 --- /dev/null +++ b/Classes/PasswordPolicy/Validator/CorePasswordValidator.php @@ -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\Core\PasswordPolicy\Validator; + +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; + +/** + * Configurable TYPO3 core password validator which can validate, that a password has: + * + * - A minimum length + * - At least one upper case char + * - At least one lower case char + * - At least one digit + * - At least one special char + * + * @internal only to be used within ext:core, not part of TYPO3 Core API. + */ +class CorePasswordValidator extends AbstractPasswordValidator +{ + public function validate(string $password, ?ContextData $contextData = null): bool + { + $isValid = true; + $lang = $this->getLanguageService(); + + if (strlen($password) < $this->getMinLength()) { + $this->addErrorMessage( + 'minimumLength', + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.minimumLength'), + $this->getMinLength() + ) + ); + $isValid = false; + } + + if ($this->isCheckEnabled('upperCaseCharacterRequired') + && !$this->evaluatePasswordRequirement($password, 'upperCaseCharacterRequired') + ) { + $this->addErrorMessage( + 'upperCaseCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.upperCaseCharacterRequired') + ); + $isValid = false; + } + + if ($this->isCheckEnabled('lowerCaseCharacterRequired') + && !$this->evaluatePasswordRequirement($password, 'lowerCaseCharacterRequired') + ) { + $this->addErrorMessage( + 'lowerCaseCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.lowerCaseCharacterRequired') + ); + $isValid = false; + } + + if ($this->isCheckEnabled('digitCharacterRequired') + && !$this->evaluatePasswordRequirement($password, 'digitCharacterRequired') + ) { + $this->addErrorMessage( + 'digitCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.digitCharacterRequired') + ); + $isValid = false; + } + + if ($this->isCheckEnabled('specialCharacterRequired') + && !$this->evaluatePasswordRequirement($password, 'specialCharacterRequired') + ) { + $this->addErrorMessage( + 'specialCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.specialCharacterRequired') + ); + $isValid = false; + } + + return $isValid; + } + + public function initializeRequirements(): void + { + $lang = $this->getLanguageService(); + $this->addRequirement( + 'minimumLength', + sprintf( + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.minimumLength'), + $this->getMinLength() + ), + ); + + if ($this->isCheckEnabled('upperCaseCharacterRequired')) { + $this->addRequirement( + 'upperCaseCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.upperCaseCharacterRequired') + ); + } + + if ($this->isCheckEnabled('lowerCaseCharacterRequired')) { + $this->addRequirement( + 'lowerCaseCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.lowerCaseCharacterRequired') + ); + } + + if ($this->isCheckEnabled('digitCharacterRequired')) { + $this->addRequirement( + 'digitCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.digitCharacterRequired') + ); + } + + if ($this->isCheckEnabled('specialCharacterRequired')) { + $this->addRequirement( + 'specialCharacterRequired', + $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.specialCharacterRequired') + ); + } + } + + private function getMinLength(): int + { + return (int)($this->options['minimumLength'] ?? 8); + } + + private function isCheckEnabled(string $checkIdentifier): bool + { + return $this->options[$checkIdentifier] ?? false; + } + + /** + * Evaluates the password complexity for the given check + */ + private function evaluatePasswordRequirement(string $password, string $requirement): bool + { + $result = true; + + $patterns = [ + 'upperCaseCharacterRequired' => '/[A-Z]/', + 'lowerCaseCharacterRequired' => '/[a-z]/', + 'digitCharacterRequired' => '/[0-9]/', + 'specialCharacterRequired' => '/[^0-9a-z]/i', + ]; + + if (isset($patterns[$requirement]) && !preg_match($patterns[$requirement], $password) > 0) { + $result = false; + } + + return $result; + } +} diff --git a/Classes/PasswordPolicy/Validator/Dto/ContextData.php b/Classes/PasswordPolicy/Validator/Dto/ContextData.php new file mode 100644 index 0000000..48a901f --- /dev/null +++ b/Classes/PasswordPolicy/Validator/Dto/ContextData.php @@ -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\Core\PasswordPolicy\Validator\Dto; + +/** + * Class with context data used in password validators. Uses internally an array with key/value pairs to store data. + * Extensions authors using this class should use `setData()` and `getData()` to write or read custom data used in + * custom password validators. + * + * @internal only to be used within ext:core, not part of TYPO3 Core API. + */ +class ContextData +{ + protected array $data = []; + + public function __construct( + string $loginMode = 'BE', + string $currentPasswordHash = '', + string $newUsername = '', + string $newUserFirstName = '', + string $newUserLastName = '', + string $newUserFullName = '', + ) { + $this->data['loginMode'] = $loginMode; + $this->data['currentPasswordHash'] = $currentPasswordHash; + $this->data['newUsername'] = $newUsername; + $this->data['newUserFirstName'] = $newUserFirstName; + $this->data['newUserLastName'] = $newUserLastName; + $this->data['newUserFullName'] = $newUserFullName; + } + + public function getLoginMode(): string + { + return $this->getData('loginMode'); + } + + public function getCurrentPasswordHash(): string + { + return $this->getData('currentPasswordHash'); + } + + public function getNewUsername(): string + { + return $this->getData('newUsername'); + } + + public function getNewUserFirstName(): string + { + return $this->getData('newUserFirstName'); + } + + public function getNewUserLastName(): string + { + return $this->getData('newUserLastName'); + } + + public function getNewUserFullName(): string + { + return $this->getData('newUserFullName'); + } + + public function getData(string $key): string + { + return $this->data[$key] ?? ''; + } + + public function setData(string $key, string $value): void + { + $this->data[$key] = $value; + } +} diff --git a/Classes/PasswordPolicy/Validator/NotCurrentPasswordValidator.php b/Classes/PasswordPolicy/Validator/NotCurrentPasswordValidator.php new file mode 100644 index 0000000..f34f50f --- /dev/null +++ b/Classes/PasswordPolicy/Validator/NotCurrentPasswordValidator.php @@ -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\Core\PasswordPolicy\Validator; + +use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException; +use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; +use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This validator checks, if the given password matches the current user password + * + * @internal only to be used within ext:core, not part of TYPO3 Core API. + */ +class NotCurrentPasswordValidator extends AbstractPasswordValidator +{ + public function validate(string $password, ?ContextData $contextData = null): bool + { + if (!$contextData) { + throw new \RuntimeException('ContextData must be supplied to validator.', 1662808782); + } + + if (in_array($contextData->getLoginMode(), ['FE', 'BE'], true)) { + $isValid = !$this->isCurrentPassword($password, $contextData); + } else { + throw new \RuntimeException('Unsupported loginMode provided. Ensure, that loginMode is either "FE" or "BE".', 1649846004); + } + + return $isValid; + } + + public function initializeRequirements(): void + { + $this->addRequirement( + 'notCurrentPassword', + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:requirement.notCurrentPassword') + ); + } + + /** + * Returns if the hash of the given password equals the hash of the current password + */ + protected function isCurrentPassword(string $password, ContextData $contextData): bool + { + $result = false; + $saltFactory = GeneralUtility::makeInstance(PasswordHashFactory::class); + try { + $hashInstance = $saltFactory->get($contextData->getCurrentPasswordHash(), $contextData->getLoginMode()); + $result = $hashInstance->checkPassword( + $password, + $contextData->getCurrentPasswordHash() + ); + } catch (InvalidPasswordHashException $e) { + // Since the password will be updated, we silently ignore, if current password hash can not be checked + } + + if ($result) { + $this->addErrorMessage( + 'notCurrentPassword', + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_password_policy.xlf:error.notCurrentPassword') + ); + } + + return $result; + } +} diff --git a/Classes/RateLimiter/RateLimiterFactory.php b/Classes/RateLimiter/RateLimiterFactory.php new file mode 100644 index 0000000..0c87fea --- /dev/null +++ b/Classes/RateLimiter/RateLimiterFactory.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\RateLimiter; + +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactory as SymfonyRateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\RateLimiter\Storage\CachingFrameworkStorage; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +#[Autoconfigure(public: true, shared: false)] +readonly class RateLimiterFactory implements RateLimiterFactoryInterface +{ + public function __construct( + protected CachingFrameworkStorage $storage, + protected array $config = [], + ) {} + + public function create(?string $key = null): LimiterInterface + { + if ($this->config === []) { + throw new \LogicException( + 'Cannot call create() on a RateLimiterFactory without configuration. ' + . 'Use a pre-configured named service or call createLimiter() with an explicit config array.', + 1740000001 + ); + } + + $config = $this->applyConfigOverrides($this->config); + $factory = new SymfonyRateLimiterFactory($config, $this->storage); + return $factory->create($key); + } + + public function createLimiter(array $config, ?string $key = null): LimiterInterface + { + $config = $this->applyConfigOverrides($config); + $factory = new SymfonyRateLimiterFactory($config, $this->storage); + return $factory->create($key); + } + + public function createRequestBasedLimiter(ServerRequestInterface $request, array $configuration): LimiterInterface + { + $normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request); + $remoteIp = $normalizedParams->getRemoteAddress(); + return $this->createLimiter($configuration, $remoteIp); + } + + public function createLoginRateLimiter(ServerRequestInterface $request, string $loginType): LimiterInterface + { + $normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request); + $remoteIp = $normalizedParams->getRemoteAddress(); + $limiterId = 'login-' . strtolower($loginType); + $limit = (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimit'] ?? 5); + $interval = $GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimitInterval'] ?? '15 minutes'; + + $enabled = !$this->isIpExcluded($loginType, $remoteIp) && $limit > 0; + + if (!$enabled) { + $config = [ + 'id' => $limiterId, + 'policy' => 'no_limit', + 'limit' => $limit, + 'interval' => $interval, + ]; + $factory = new SymfonyRateLimiterFactory($config, new InMemoryStorage()); + return $factory->create($remoteIp); + } + + return $this->createLimiter( + [ + 'id' => $limiterId, + 'policy' => 'sliding_window', + 'limit' => $limit, + 'interval' => $interval, + ], + $remoteIp + ); + } + + protected function applyConfigOverrides(array $config): array + { + $overrides = $GLOBALS['TYPO3_CONF_VARS']['SYS']['rateLimiter'][$config['id']] ?? []; + if ($overrides !== []) { + $config = array_replace($config, $overrides); + } + return $config; + } + + protected function isIpExcluded(string $loginType, string $remoteAddress): bool + { + $ipMask = trim($GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimitIpExcludeList'] ?? ''); + return GeneralUtility::cmpIP($remoteAddress, $ipMask); + } +} diff --git a/Classes/RateLimiter/RateLimiterFactoryInterface.php b/Classes/RateLimiter/RateLimiterFactoryInterface.php new file mode 100644 index 0000000..56a24ad --- /dev/null +++ b/Classes/RateLimiter/RateLimiterFactoryInterface.php @@ -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\Core\RateLimiter; + +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\RateLimiter\LimiterInterface; + +/** + * TYPO3's own rate limiter factory interface extending Symfony's RateLimiterFactoryInterface + * with additional convenience methods for request-based and login rate limiting. + */ +interface RateLimiterFactoryInterface extends \Symfony\Component\RateLimiter\RateLimiterFactoryInterface +{ + /** + * Create a limiter with custom configuration. + */ + public function createLimiter(array $config, ?string $key = null): LimiterInterface; + + /** + * Create a limiter based on the request input. + */ + public function createRequestBasedLimiter(ServerRequestInterface $request, array $configuration): LimiterInterface; + + /** + * Create a limiter for user login. + */ + public function createLoginRateLimiter(ServerRequestInterface $request, string $loginType): LimiterInterface; +} diff --git a/Classes/RateLimiter/RequestRateLimitedException.php b/Classes/RateLimiter/RequestRateLimitedException.php new file mode 100644 index 0000000..b1e3c4a --- /dev/null +++ b/Classes/RateLimiter/RequestRateLimitedException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\RateLimiter; + +use TYPO3\CMS\Core\Error\Http\AbstractClientErrorException; + +/** + * Exception thrown when a rate limiter has disallowed further processing + */ +class RequestRateLimitedException extends AbstractClientErrorException {} diff --git a/Classes/RateLimiter/Storage/CachingFrameworkStorage.php b/Classes/RateLimiter/Storage/CachingFrameworkStorage.php new file mode 100644 index 0000000..aa07485 --- /dev/null +++ b/Classes/RateLimiter/Storage/CachingFrameworkStorage.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\RateLimiter\Storage; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\RateLimiter\LimiterStateInterface; +use Symfony\Component\RateLimiter\Policy\SlidingWindow; +use Symfony\Component\RateLimiter\Policy\TokenBucket; +use Symfony\Component\RateLimiter\Policy\Window; +use Symfony\Component\RateLimiter\Storage\StorageInterface; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; + +/** + * A rate limiter storage utilizing TYPO3's Caching Framework. + * + * @internal This is not part of the official TYPO3 Core API due to a limitation of the Symfony Rate Limiter API. + */ +#[Autoconfigure(public: true)] +class CachingFrameworkStorage implements StorageInterface +{ + private FrontendInterface $cacheInstance; + + public function __construct(CacheManager $cacheInstance) + { + $this->cacheInstance = $cacheInstance->getCache('ratelimiter'); + $this->cacheInstance->collectGarbage(); + } + + public function save(LimiterStateInterface $limiterState): void + { + $this->cacheInstance->set( + sha1($limiterState->getId()), + serialize($limiterState), + [], + $limiterState->getExpirationTime() + ); + } + + public function fetch(string $limiterStateId): ?LimiterStateInterface + { + $cacheItem = $this->cacheInstance->get(sha1($limiterStateId)); + if ($cacheItem) { + $value = unserialize($cacheItem, ['allowed_classes' => [Window::class, SlidingWindow::class, TokenBucket::class]]); + if ($value instanceof LimiterStateInterface) { + return $value; + } + } + + return null; + } + + public function delete(string $limiterStateId): void + { + $this->cacheInstance->remove(sha1($limiterStateId)); + } +} diff --git a/Classes/Registry.php b/Classes/Registry.php new file mode 100644 index 0000000..cbdd168 --- /dev/null +++ b/Classes/Registry.php @@ -0,0 +1,193 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core; + +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; + +/** + * A class to store and retrieve entries in a registry database table. + * + * This is a simple, persistent key-value-pair store. + * + * The intention is to have a place where we can store things (mainly settings) + * that should live for more than one request, longer than a session, and that + * shouldn't expire like it would with a cache. You can actually think of it + * being like the Windows Registry in some ways. + */ +class Registry implements SingletonInterface +{ + /** + * @var array + */ + protected $entries = []; + + /** + * @var array + */ + protected $loadedNamespaces = []; + + public function __construct( + protected readonly ConnectionPool $connectionPool, + protected readonly DenyListDeserializer $deserializer, + ) {} + /** + * Returns a persistent entry. + * + * @param string $namespace Extension key of extension + * @param string $key Key of the entry to return. + * @param mixed $defaultValue Optional default value to use if this entry has never been set. Defaults to NULL. + * @return mixed Value of the entry. + * @throws \InvalidArgumentException Throws an exception if the given namespace is not valid + */ + public function get($namespace, $key, $defaultValue = null) + { + $this->validateNamespace($namespace); + if (!$this->isNamespaceLoaded($namespace)) { + $this->loadEntriesByNamespace($namespace); + } + return $this->entries[$namespace][$key] ?? $defaultValue; + } + + /** + * Sets a persistent entry. + * + * This is the main method that can be used to store a key-value-pair. + * + * Do not store binary data into the registry, it's not build to do that, + * instead use the proper way to store binary data: The filesystem. + * + * @param string $namespace Extension key of extension + * @param string $key The key of the entry to set. + * @param mixed $value The value to set. This can be any PHP data type; This class takes care of serialization + * @throws \InvalidArgumentException Throws an exception if the given namespace is not valid + */ + public function set($namespace, $key, $value) + { + $this->validateNamespace($namespace); + if (!$this->isNamespaceLoaded($namespace)) { + $this->loadEntriesByNamespace($namespace); + } + $serializedValue = serialize($value); + $connection = $this->connectionPool->getConnectionForTable('sys_registry'); + $rowCount = $connection->count( + '*', + 'sys_registry', + ['entry_namespace' => $namespace, 'entry_key' => $key] + ); + if ((int)$rowCount < 1) { + $connection->insert( + 'sys_registry', + ['entry_namespace' => $namespace, 'entry_key' => $key, 'entry_value' => $serializedValue], + ['entry_value' => Connection::PARAM_LOB] + ); + } else { + $connection->update( + 'sys_registry', + ['entry_value' => $serializedValue], + ['entry_namespace' => $namespace, 'entry_key' => $key], + ['entry_value' => Connection::PARAM_LOB] + ); + } + $this->entries[$namespace][$key] = $value; + } + + /** + * Unset a persistent entry. + * + * @param string $namespace Extension key of extension + * @param string $key The key of the entry to unset. + * @throws \InvalidArgumentException Throws an exception if the given namespace is not valid + */ + public function remove($namespace, $key) + { + $this->validateNamespace($namespace); + $this->connectionPool + ->getConnectionForTable('sys_registry') + ->delete( + 'sys_registry', + ['entry_namespace' => $namespace, 'entry_key' => $key] + ); + unset($this->entries[$namespace][$key]); + } + + /** + * Unset all persistent entries of given namespace. + * + * @param string $namespace Extension key of extension + * @throws \InvalidArgumentException Throws an exception if given namespace is invalid + */ + public function removeAllByNamespace($namespace) + { + $this->validateNamespace($namespace); + $this->connectionPool + ->getConnectionForTable('sys_registry') + ->delete( + 'sys_registry', + ['entry_namespace' => $namespace] + ); + unset($this->entries[$namespace]); + } + + /** + * check if the given namespace is loaded + * + * @param string $namespace Extension key of extension + * @return bool True if namespace was loaded already + */ + protected function isNamespaceLoaded($namespace) + { + return isset($this->loadedNamespaces[$namespace]); + } + + /** + * Loads all entries of given namespace into the internal $entries cache. + * + * @param string $namespace Extension key of extension + * @throws \InvalidArgumentException Thrown if given namespace is invalid + */ + protected function loadEntriesByNamespace($namespace) + { + $this->validateNamespace($namespace); + $this->entries[$namespace] = []; + $result = $this->connectionPool + ->getConnectionForTable('sys_registry') + ->select( + ['entry_key', 'entry_value'], + 'sys_registry', + ['entry_namespace' => $namespace] + ); + while ($row = $result->fetchAssociative()) { + $this->entries[$namespace][$row['entry_key']] = $this->deserializer->deserialize($row['entry_value']); + } + $this->loadedNamespaces[$namespace] = true; + } + + /** + * Check namespace key + * It must be at least two characters long. The word 'core' is reserved for TYPO3 core usage. + * + * @param string $namespace Namespace + * @throws \InvalidArgumentException Thrown if given namespace is invalid + */ + protected function validateNamespace($namespace) + { + if (strlen($namespace) < 2) { + throw new \InvalidArgumentException('Given namespace must be longer than two characters.', 1249755131); + } + } +} diff --git a/Classes/Resource/AbstractFile.php b/Classes/Resource/AbstractFile.php new file mode 100644 index 0000000..be6f0c9 --- /dev/null +++ b/Classes/Resource/AbstractFile.php @@ -0,0 +1,450 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Abstract file representation in the file abstraction layer. + */ +abstract class AbstractFile implements FileInterface +{ + /** + * Various file properties + * + * Note that all properties, which only the persisted (indexed) files have are stored in this + * overall properties array only. The only properties which really exist as object properties of + * the file object are the storage, the identifier, the fileName and the indexing status. + * + * @var array<non-empty-string, mixed> + */ + protected array $properties = []; + + /** + * The storage this file is located in + */ + protected ?ResourceStorage $storage = null; + + /** + * The file name of this file + */ + protected string $name = ''; + + /** + * If set to true, this file is regarded as being deleted. + */ + protected bool $deleted = false; + + /****************** + * VARIOUS FILE PROPERTY GETTERS + ******************/ + /** + * Returns true if the given property key exists for this file. + * + * @param non-empty-string $key + */ + public function hasProperty(string $key): bool + { + return array_key_exists($key, $this->properties); + } + + /** + * Returns a property value + * + * @param non-empty-string $key + */ + public function getProperty(string $key): mixed + { + if ($this->hasProperty($key)) { + return $this->properties[$key]; + } + return null; + } + + /** + * Returns the properties of this object. + * + * @return array<non-empty-string, mixed> + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * @return non-empty-string + */ + public function getHashedIdentifier(): string + { + return $this->properties['identifier_hash']; + } + + public function getName(): string + { + // Do not check if file has been deleted because we might need the + // name for undeleting it. + return $this->name; + } + + /** + * Returns the basename (the name without extension) of this file. + */ + public function getNameWithoutExtension(): string + { + return PathUtility::pathinfo($this->getName(), PATHINFO_FILENAME); + } + + /** + * @throws \RuntimeException + * @return int<0, max> + */ + public function getSize(): int + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821480); + } + if (empty($this->properties['size'])) { + $fileInfo = $this->getStorage()->getFileInfoByIdentifier($this->getIdentifier(), ['size']); + $size = array_pop($fileInfo); + } else { + $size = $this->properties['size']; + } + return MathUtility::canBeInterpretedAsInteger($size) ? (int)$size : 0; + } + + /** + * Returns the uid of this file + */ + public function getUid(): int + { + return (int)$this->getProperty('uid'); + } + + /** + * Returns the Sha1 of this file + * + * @throws \RuntimeException + * @return non-empty-string + */ + public function getSha1(): string + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821481); + } + return $this->getStorage()->hashFile($this, 'sha1'); + } + + /** + * Returns the creation time of the file as Unix timestamp + * + * @throws \RuntimeException + */ + public function getCreationTime(): int + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821487); + } + return (int)$this->getProperty('creation_date'); + } + + /** + * Returns the date (as UNIX timestamp) the file was last modified. + * + * @throws \RuntimeException + */ + public function getModificationTime(): int + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821488); + } + return (int)$this->getProperty('modification_date'); + } + + /** + * Get the extension of this file in a lower-case variant + */ + public function getExtension(): string + { + $pathinfo = PathUtility::pathinfo($this->getName()); + return strtolower($pathinfo['extension'] ?? ''); + } + + /** + * Get the MIME type of this file + * + * @return non-empty-string mime type + */ + public function getMimeType(): string + { + if ($this->properties['mime_type'] ?? false) { + return $this->properties['mime_type']; + } + $fileInfo = $this->getStorage()->getFileInfoByIdentifier($this->getIdentifier(), ['mimetype']); + return array_pop($fileInfo); + } + + /** + * Returns the fileType of this file + * basically there are only five main "file types" + * "audio" + * "image" + * "software" + * "text" + * "video" + * "other" + * see FileType enum + */ + public function getType(): int + { + return $this->getFileType()->value; + } + + public function isType(FileType $fileType): bool + { + return $this->getFileType() === $fileType; + } + + /** + * Returns the fileType of this file + * basically there are only five main "file types" + * "audio" + * "image" + * "software" + * "text" + * "video" + * "other" + * see FileType enum + */ + public function getFileType(): FileType + { + // this basically extracts the mimetype and guess the filetype based + // on the first part of the mimetype works for 99% of all cases, and + // we don't need to make an SQL statement like EXT:media does currently + if (!($this->properties['type'] ?? false)) { + $this->properties['type'] = FileType::tryFromMimeType($this->getMimeType())->value; + } + return $this->properties['type'] instanceof FileType ? $this->properties['type'] : FileType::from((int)$this->properties['type']); + } + + /** + * Useful to find out if this file can be previewed or resized as image. + * @return bool true if File has an image-extension according to $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'] + */ + public function isImage(): bool + { + return GeneralUtility::inList(strtolower($GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'] ?? ''), $this->getExtension()) && $this->getSize() > 0; + } + + /** + * Useful to find out if this file has a file extension based on any of the registered media extensions + * @return bool true if File is a media-extension according to $GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'] + */ + public function isMediaFile(): bool + { + return GeneralUtility::inList(strtolower($GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'] ?? ''), $this->getExtension()) && $this->getSize() > 0; + } + + /** + * Useful to find out if this file can be edited. + * + * @return bool true if File is a text-based file extension according to $GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext'] + */ + public function isTextFile(): bool + { + return GeneralUtility::inList(strtolower($GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext'] ?? ''), $this->getExtension()); + } + /****************** + * CONTENTS RELATED + ******************/ + /** + * Get the contents of this file + * + * @throws \RuntimeException + */ + public function getContents(): string + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821479); + } + return $this->getStorage()->getFileContents($this); + } + + /** + * Replace the current file contents with the given string + * + * @throws \RuntimeException + * @return $this + */ + public function setContents(string $contents): self + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821478); + } + $this->getStorage()->setFileContents($this, $contents); + return $this; + } + + /**************************************** + * STORAGE AND MANAGEMENT RELATED METHODS + ****************************************/ + + /** + * @throws \RuntimeException + */ + public function getStorage(): ResourceStorage + { + if ($this->storage === null) { + throw new \RuntimeException('You\'re using fileObjects without a storage.', 1381570091); + } + return $this->storage; + } + + /** + * Checks if this file exists. This should normally always return TRUE; + * it might only return FALSE when this object has been created from an + * index record without checking for. + * + * @return bool TRUE if this file physically exists + */ + public function exists(): bool + { + if ($this->deleted) { + return false; + } + return $this->storage->hasFile($this->getIdentifier()); + } + + /** + * Sets the storage this file is located in. This is only meant for + * \TYPO3\CMS\Core\Resource-internal usage; don't use it to move files. + * + * @internal Should only be used by other parts of the File API (e.g. drivers after moving a file) + * + * @return $this + */ + public function setStorage(ResourceStorage $storage): self + { + $this->storage = $storage; + $this->properties['storage'] = $storage->getUid(); + return $this; + } + + /** + * Returns a combined identifier of this file, i.e. the storage UID and the + * folder identifier separated by a colon ":". + * + * @return string Combined storage and file identifier, e.g. StorageUID:path/and/fileName.png + */ + public function getCombinedIdentifier(): string + { + if (!empty($this->properties['storage']) && MathUtility::canBeInterpretedAsInteger($this->properties['storage'])) { + $combinedIdentifier = $this->properties['storage'] . ':' . $this->getIdentifier(); + } else { + $combinedIdentifier = $this->getStorage()->getUid() . ':' . $this->getIdentifier(); + } + return $combinedIdentifier; + } + + /** + * Deletes this file from its storage. This also means that this object becomes useless. + */ + public function delete(): bool + { + // The storage will mark this file as deleted + $wasDeleted = $this->getStorage()->deleteFile($this); + + // Unset all properties when deleting the file, as they will be stale anyway + // This needs to happen AFTER the storage deleted the file, because the storage + // emits a signal, which passes the file object to the slots, which may need + // all file properties of the deleted file. + $this->properties = []; + + return $wasDeleted; + } + + /** + * Marks this file as deleted. This should only be used inside the + * File Abstraction Layer, as it is a low-level API method. + */ + public function setDeleted(): void + { + $this->deleted = true; + } + + /** + * Returns TRUE if this file has been deleted + */ + public function isDeleted(): bool + { + return $this->deleted; + } + + /***************** + * SPECIAL METHODS + *****************/ + /** + * Returns a publicly accessible URL for this file + * + * WARNING: Access to the file may be restricted by further means, e.g. some + * web-based authentication. You have to take care of this yourself. + * + * @return string|null NULL if file is deleted, the generated URL otherwise + */ + public function getPublicUrl(): ?string + { + if ($this->deleted) { + return null; + } + return $this->getStorage()->getPublicUrl($this); + } + + /** + * Returns a path to a local version of this file to process it locally (e.g. with some system tool). + * If the file is normally located on a remote storages, this creates a local copy. + * If the file is already on the local system, this only makes a new copy if $writable is set to TRUE. + * + * @param bool $writable Set this to FALSE if you only want to do read operations on the file. + * + * @throws \RuntimeException + * @return non-empty-string + */ + public function getForLocalProcessing(bool $writable = true): string + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821486); + } + return $this->getStorage()->getFileForLocalProcessing($this, $writable); + } + + /*********************** + * INDEX RELATED METHODS + ***********************/ + /** + * Updates properties of this object. + * This method is used to reconstitute settings from the + * database into this object after being instantiated. + */ + abstract public function updateProperties(array $properties); + + public function getParentFolder(): Folder + { + return $this->getStorage()->getFolder($this->getStorage()->getFolderIdentifierFromFileIdentifier($this->getIdentifier())); + } +} diff --git a/Classes/Resource/Cache/FlushCacheTagForFile.php b/Classes/Resource/Cache/FlushCacheTagForFile.php new file mode 100644 index 0000000..8e06364 --- /dev/null +++ b/Classes/Resource/Cache/FlushCacheTagForFile.php @@ -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\Core\Resource\Cache; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Resource\Event\AfterFileContentsSetEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMovedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileReplacedEvent; + +final readonly class FlushCacheTagForFile +{ + public function __construct( + private CacheManager $cacheManager, + #[Autowire(expression: 'service("features").isFeatureEnabled("frontend.cache.autoTagging")')] + private bool $autoTagging + ) {} + + #[AsEventListener(event: AfterFileContentsSetEvent::class)] + #[AsEventListener(event: AfterFileDeletedEvent::class)] + #[AsEventListener(event: AfterFileMovedEvent::class)] + #[AsEventListener(event: AfterFileRenamedEvent::class)] + #[AsEventListener(event: AfterFileReplacedEvent::class)] + public function __invoke( + AfterFileContentsSetEvent|AfterFileDeletedEvent|AfterFileMovedEvent|AfterFileRenamedEvent|AfterFileReplacedEvent $event + ): void { + if (!$this->autoTagging) { + return; + } + $this->cacheManager->flushCachesByTag(sprintf('sys_file_%s', $event->getFile()->getProperty('uid'))); + } +} diff --git a/Classes/Resource/Cache/FlushCacheTagForFolder.php b/Classes/Resource/Cache/FlushCacheTagForFolder.php new file mode 100644 index 0000000..54beebd --- /dev/null +++ b/Classes/Resource/Cache/FlushCacheTagForFolder.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Cache; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Resource\Event\AfterFolderRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderMovedEvent; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; + +final readonly class FlushCacheTagForFolder +{ + public function __construct( + private CacheManager $cacheManager, + #[Autowire(expression: 'service("features").isFeatureEnabled("frontend.cache.autoTagging")')] + private bool $autoTagging + ) {} + + #[AsEventListener(event: AfterFolderRenamedEvent::class)] + #[AsEventListener(event: BeforeFolderMovedEvent::class)] + public function __invoke(AfterFolderRenamedEvent|BeforeFolderMovedEvent $event): void + { + if (!$this->autoTagging) { + return; + } + $files = $event->getFolder()->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, true); + $this->cacheManager->flushCachesByTags( + array_map( + static fn(File $file) => sprintf('sys_file_%s', $file->getProperty('uid')), + $files + ) + ); + } +} diff --git a/Classes/Resource/Cache/FlushCacheTagForMetaData.php b/Classes/Resource/Cache/FlushCacheTagForMetaData.php new file mode 100644 index 0000000..e8c37b0 --- /dev/null +++ b/Classes/Resource/Cache/FlushCacheTagForMetaData.php @@ -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\Core\Resource\Cache; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataCreatedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataUpdatedEvent; + +final readonly class FlushCacheTagForMetaData +{ + public function __construct( + private CacheManager $cacheManager, + #[Autowire(expression: 'service("features").isFeatureEnabled("frontend.cache.autoTagging")')] + private bool $autoTagging + ) {} + + #[AsEventListener(event: AfterFileMetaDataCreatedEvent::class)] + #[AsEventListener(event: AfterFileMetaDataDeletedEvent::class)] + #[AsEventListener(event: AfterFileMetaDataUpdatedEvent::class)] + public function __invoke( + AfterFileMetaDataCreatedEvent|AfterFileMetaDataDeletedEvent|AfterFileMetaDataUpdatedEvent $event + ): void { + if (!$this->autoTagging) { + return; + } + $cacheTags = [sprintf('sys_file_%s', $event->getFileUid())]; + if (method_exists($event, 'getMetaDataUid')) { + $cacheTags[] = sprintf('sys_file_metadata_%s', $event->getMetaDataUid()); + } + $this->cacheManager->flushCachesByTags($cacheTags); + } +} diff --git a/Classes/Resource/Capabilities.php b/Classes/Resource/Capabilities.php new file mode 100644 index 0000000..c7a235a --- /dev/null +++ b/Classes/Resource/Capabilities.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Type\BitSet; + +class Capabilities extends BitSet +{ + /** + * Capability for being browsable by (backend) users + */ + public const CAPABILITY_BROWSABLE = 1; + /** + * Capability for publicly accessible storages (= accessible from the web) + */ + public const CAPABILITY_PUBLIC = 2; + /** + * Capability for writable storages. This only signifies writability in + * general - this might also be further limited by configuration. + */ + public const CAPABILITY_WRITABLE = 4; + /** + * Whether identifiers contain hierarchy information (folder structure). + */ + public const CAPABILITY_HIERARCHICAL_IDENTIFIERS = 8; + + /** + * @param self::CAPABILITY_* $capability + * @return $this + */ + public function removeCapability(int $capability): self + { + $this->unset($capability); + return $this; + } + + /** + * @param self::CAPABILITY_* ...$capabilities + * @return $this + */ + public function addCapabilities(int ...$capabilities): self + { + foreach ($capabilities as $capability) { + $this->set($capability); + } + + return $this; + } + + /** + * @param self::CAPABILITY_* $capability + */ + public function hasCapability(int $capability): bool + { + return $this->get($capability); + } +} diff --git a/Classes/Resource/Collection/AbstractFileCollection.php b/Classes/Resource/Collection/AbstractFileCollection.php new file mode 100644 index 0000000..0d5960a --- /dev/null +++ b/Classes/Resource/Collection/AbstractFileCollection.php @@ -0,0 +1,232 @@ +<?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\Core\Resource\Collection; + +use TYPO3\CMS\Core\Collection\AbstractRecordCollection; +use TYPO3\CMS\Core\Collection\CollectionInterface; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * Abstract collection. + * @extends AbstractRecordCollection<FileInterface> + */ +abstract class AbstractFileCollection extends AbstractRecordCollection +{ + /** + * The table name collections are stored to + * + * @var string + */ + protected static $storageTableName = 'sys_file_collection'; + + /** + * The type of file collection + * (see \TYPO3\CMS\Core\Collection\RecordCollectionRepository::TYPE constants) + * + * @var string + */ + protected static $type; + + /** + * The name of the field items are handled with + * (usually either criteria, items or folder) + * + * @var string + */ + protected static $itemsCriteriaField; + + /** + * Field contents of $itemsCriteriaField. Defines which the items or search criteria for the items + * depending on the type (see self::$type above) of this file collection. + * + * @var mixed + */ + protected $itemsCriteria; + + /** + * Name of the table records of this collection are stored in + * + * @var string + */ + protected $itemTableName = 'sys_file'; + + /** + * Sets the description. + * + * @param string $description + */ + public function setDescription($description) + { + $this->description = $description; + } + + /** + * Return the key of the current element + * + * @return string + */ + public function key(): mixed + { + /** @var File $currentRecord */ + $currentRecord = $this->storage->current(); + return $currentRecord->getIdentifier(); + } + + /** + * Generates comma-separated list of entry uids for usage in DataHandler + * + * @param bool $includeTableName + * @return string + */ + protected function getItemUidList($includeTableName = false) + { + $list = []; + /** @var File $entry */ + foreach ($this->storage as $entry) { + $list[] = $this->getItemTableName() . '_' . $entry->getUid(); + } + return implode(',', $list); + } + + /** + * Returns an array of the persistable properties and contents + * which are processable by DataHandler. + * + * @return array + */ + protected function getPersistableDataArray() + { + return [ + 'title' => $this->getTitle(), + 'type' => static::$type, + 'description' => $this->getDescription(), + static::$itemsCriteriaField => $this->getItemsCriteria(), + ]; + } + + /** + * Similar to method in \TYPO3\CMS\Core\Collection\AbstractRecordCollection, + * but without 'table_name' => $this->getItemTableName() + * + * @return array + */ + public function toArray() + { + $itemArray = []; + /** @var File $item */ + foreach ($this->storage as $item) { + $itemArray[] = $item->toArray(); + } + return [ + 'uid' => $this->getIdentifier(), + 'title' => $this->getTitle(), + 'description' => $this->getDescription(), + 'items' => $itemArray, + ]; + } + + /** + * Gets the current available items. + * + * @return array + */ + public function getItems() + { + $itemArray = []; + /** @var FileInterface $item */ + foreach ($this->storage as $item) { + $itemArray[] = $item; + } + return $itemArray; + } + + /** + * Similar to method in \TYPO3\CMS\Core\Collection\AbstractRecordCollection, + * but without $this->itemTableName= $array['table_name'], + * but with $this->storageItemsFieldContent = $array[self::$storageItemsField]; + */ + public function fromArray(array $array) + { + $this->uid = $array['uid']; + $this->title = $array['title']; + $this->description = $array['description']; + $this->itemsCriteria = $array[static::$itemsCriteriaField]; + } + + /** + * Gets ths items criteria. + * + * @return mixed + */ + public function getItemsCriteria() + { + return $this->itemsCriteria; + } + + /** + * Sets the items criteria. + * + * @param mixed $itemsCriteria + */ + public function setItemsCriteria($itemsCriteria) + { + $this->itemsCriteria = $itemsCriteria; + } + + /** + * Adds a file to this collection. + */ + public function add(FileInterface $data) + { + $this->storage->push($data); + } + + /** + * Adds all files of another collection to the current one. + */ + public function addAll(CollectionInterface $other) + { + /** @var File $value */ + foreach ($other as $value) { + $this->add($value); + } + } + + /** + * Removes a file from this collection. + */ + public function remove(File $file) + { + $offset = 0; + /** @var File $value */ + foreach ($this->storage as $value) { + if ($value === $file) { + break; + } + $offset++; + } + $this->storage->offsetUnset($offset); + } + + /** + * Removes all elements of the current collection. + */ + public function removeAll() + { + $this->storage = new \SplDoublyLinkedList(); + } +} diff --git a/Classes/Resource/Collection/CategoryBasedFileCollection.php b/Classes/Resource/Collection/CategoryBasedFileCollection.php new file mode 100644 index 0000000..5e5dfcc --- /dev/null +++ b/Classes/Resource/Collection/CategoryBasedFileCollection.php @@ -0,0 +1,92 @@ +<?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\Core\Resource\Collection; + +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A collection containing a set files belonging to certain categories. + * This collection is persisted to the database with the accordant category identifiers. + */ +class CategoryBasedFileCollection extends AbstractFileCollection +{ + /** + * @var string + */ + protected static $storageTableName = 'sys_file_collection'; + + /** + * @var string + */ + protected static $type = 'categories'; + + /** + * @var string + */ + protected static $itemsCriteriaField = 'category'; + + /** + * @var string + */ + protected $itemTableName = 'sys_category'; + + /** + * Populates the content-entries of the collection + */ + public function loadContents() + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category'); + $queryBuilder->getRestrictions()->removeAll(); + $statement = $queryBuilder->select('sys_file_metadata.file') + ->from('sys_category') + ->join( + 'sys_category', + 'sys_category_record_mm', + 'sys_category_record_mm', + $queryBuilder->expr()->eq( + 'sys_category_record_mm.uid_local', + $queryBuilder->quoteIdentifier('sys_category.uid') + ) + ) + ->join( + 'sys_category_record_mm', + 'sys_file_metadata', + 'sys_file_metadata', + $queryBuilder->expr()->eq( + 'sys_category_record_mm.uid_foreign', + $queryBuilder->quoteIdentifier('sys_file_metadata.uid') + ) + ) + ->where( + $queryBuilder->expr()->eq( + 'sys_category.uid', + $queryBuilder->createNamedParameter($this->getItemsCriteria(), Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'sys_category_record_mm.tablenames', + $queryBuilder->createNamedParameter('sys_file_metadata') + ) + ) + ->executeQuery(); + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + while ($record = $statement->fetchAssociative()) { + $this->add($resourceFactory->getFileObject((int)$record['file'])); + } + } +} diff --git a/Classes/Resource/Collection/FileCollectionRegistry.php b/Classes/Resource/Collection/FileCollectionRegistry.php new file mode 100644 index 0000000..7064be4 --- /dev/null +++ b/Classes/Resource/Collection/FileCollectionRegistry.php @@ -0,0 +1,104 @@ +<?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\Core\Resource\Collection; + +use TYPO3\CMS\Core\SingletonInterface; + +/** + * Registry for FileCollection classes + */ +class FileCollectionRegistry implements SingletonInterface +{ + /** + * Registered FileCollection types + * + * @var array + */ + protected $types = []; + + /** + * Constructor + */ + public function __construct() + { + foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['registeredCollections'] as $type => $class) { + $this->registerFileCollectionClass($class, $type); + } + } + + /** + * Register a (new) FileCollection type + * + * @param string $className + * @param string $type FileCollection type max length 30 chars (db field restriction) + * @param bool $override existing FileCollection type + * @return bool TRUE if registration succeeded + * @throws \InvalidArgumentException + */ + public function registerFileCollectionClass($className, $type, $override = false) + { + if (strlen($type) > 30) { + throw new \InvalidArgumentException('FileCollection type can have a max string length of 30 bytes', 1391295611); + } + + if (!class_exists($className)) { + throw new \InvalidArgumentException('Class ' . $className . ' does not exist.', 1391295613); + } + + if (!in_array(AbstractFileCollection::class, class_parents($className) ?: [], true)) { + throw new \InvalidArgumentException('FileCollection ' . $className . ' needs to extend the AbstractFileCollection.', 1391295633); + } + + if (isset($this->types[$type])) { + // Return immediately without changing configuration + if ($this->types[$type] === $className) { + return true; + } + if (!$override) { + throw new \InvalidArgumentException('FileCollections ' . $type . ' is already registered.', 1391295643); + } + } + + $this->types[$type] = $className; + return true; + } + + /** + * Returns a class name for a given type + * + * @param string $type + * @return string The class name + * @throws \InvalidArgumentException + */ + public function getFileCollectionClass($type) + { + if (!isset($this->types[$type])) { + throw new \InvalidArgumentException('Desired FileCollection type "' . $type . '" is not in the list of available FileCollections.', 1391295644); + } + return $this->types[$type]; + } + + /** + * Checks if the given FileCollection type exists + * + * @param string $type Type of the FileCollection + * @return bool TRUE if the FileCollection exists, FALSE otherwise + */ + public function fileCollectionTypeExists($type) + { + return isset($this->types[$type]); + } +} diff --git a/Classes/Resource/Collection/FolderBasedFileCollection.php b/Classes/Resource/Collection/FolderBasedFileCollection.php new file mode 100644 index 0000000..f97eef0 --- /dev/null +++ b/Classes/Resource/Collection/FolderBasedFileCollection.php @@ -0,0 +1,115 @@ +<?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\Core\Resource\Collection; + +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\StorageRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A collection containing a set of files to be represented as a (virtual) folder. + * This collection is persisted to the database with the accordant folder reference. + */ +class FolderBasedFileCollection extends AbstractFileCollection +{ + /** + * @var string + */ + protected static $storageTableName = 'sys_file_collection'; + + /** + * @var string + */ + protected static $type = 'folder'; + + /** + * @var string + */ + protected static $itemsCriteriaField = 'folder'; + + /** + * The folder + */ + protected ?Folder $folder = null; + protected bool $recursive = false; + + /** + * Populates the content-entries of the storage + * + * Queries the underlying storage for entries of the collection + * and adds them to the collection data. + * + * If the content entries of the storage had not been loaded on creation + * ($fillItems = false) this function is to be used for loading the contents + * afterward. + */ + public function loadContents() + { + if ($this->folder instanceof Folder) { + $entries = $this->folder->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, $this->recursive); + foreach ($entries as $entry) { + $this->add($entry); + } + } + } + + /** + * Gets the items criteria. + * + * @return string + */ + public function getItemsCriteria() + { + return $this->folder->getCombinedIdentifier(); + } + + /** + * Returns an array of the persistable properties and contents + * which are processable by DataHandler. + * + * @return array + */ + protected function getPersistableDataArray() + { + return [ + 'title' => $this->getTitle(), + 'type' => self::$type, + 'description' => $this->getDescription(), + 'folder_identifier' => $this->folder->getCombinedIdentifier(), + ]; + } + + /** + * Similar to method in \TYPO3\CMS\Core\Collection\AbstractRecordCollection, + * but without $this->itemTableName= $array['table_name'], + * but with $this->storageItemsFieldContent = $array[self::$storageItemsField]; + */ + public function fromArray(array $array) + { + $this->uid = (int)$array['uid']; + $this->title = (string)$array['title']; + $this->description = (string)$array['description']; + $this->recursive = (bool)$array['recursive']; + if (str_contains($array['folder_identifier'] ?? '', ':')) { + $parts = GeneralUtility::trimExplode(':', $array['folder_identifier']); + $storageRepository = GeneralUtility::makeInstance(StorageRepository::class); + $storage = $storageRepository->findByUid((int)$parts[0]); + if ($storage) { + $this->folder = $storage->getFolder($parts[1]); + } + } + } +} diff --git a/Classes/Resource/Collection/LazyFileReferenceCollection.php b/Classes/Resource/Collection/LazyFileReferenceCollection.php new file mode 100644 index 0000000..8ebb724 --- /dev/null +++ b/Classes/Resource/Collection/LazyFileReferenceCollection.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Collection; + +use TYPO3\CMS\Core\Resource\FileReference; + +/** + * When first accessed, this class will initialize itself and find the file references + * for this record field. + * + * This class acts as a "Value holder", as it only fetches the related file references + * when needed. + * + * @internal not part of public API, as this needs to be streamlined and proven + */ +class LazyFileReferenceCollection implements \IteratorAggregate, \ArrayAccess, \Countable +{ + /** + * @var FileReference[]|\Closure + */ + private array|\Closure $items; + + public function __construct( + private readonly mixed $fieldValue, + \Closure $initialization + ) { + $this->items = $initialization; + } + + public function count(): int + { + $this->initialize(); + return count($this->items); + } + + private function initialize(): void + { + if ($this->items instanceof \Closure) { + $this->items = ($this->items)(); + } + } + + public function getIterator(): \Iterator + { + $this->initialize(); + return new \ArrayIterator($this->items); + } + + public function __toString(): string + { + return (string)$this->fieldValue; + } + + public function offsetExists(mixed $offset): bool + { + $this->initialize(); + return isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + $this->initialize(); + return $this->items[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($value instanceof FileReference === false) { + throw new \InvalidArgumentException( + 'Modifying the file reference collection is only allowed by setting a value of type FileReference.', + 1723188317 + ); + } + $this->items[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('Removing items from the file reference collection is not implemented.', 1723188318); + } +} diff --git a/Classes/Resource/Collection/LazyFolderCollection.php b/Classes/Resource/Collection/LazyFolderCollection.php new file mode 100644 index 0000000..6a56aa7 --- /dev/null +++ b/Classes/Resource/Collection/LazyFolderCollection.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Collection; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * When first accessed, this class will initialize itself and find the folders + * for this record field. + * + * This class acts as a "Value holder", as it only fetches the related folders + * when needed. + * + * @internal not part of public API, as this needs to be streamlined and proven + */ +class LazyFolderCollection implements \IteratorAggregate, \ArrayAccess, \Countable +{ + /** + * @var Folder[]|\Closure + */ + private array|\Closure $items; + + public function __construct( + private readonly mixed $fieldValue, + \Closure $initialization + ) { + $this->items = $initialization; + } + + public function count(): int + { + $this->initialize(); + return count($this->items); + } + + private function initialize(): void + { + if ($this->items instanceof \Closure) { + $this->items = ($this->items)(); + } + } + + public function getIterator(): \Iterator + { + $this->initialize(); + return new \ArrayIterator($this->items); + } + + public function __toString(): string + { + return (string)$this->fieldValue; + } + + public function offsetExists(mixed $offset): bool + { + $this->initialize(); + return isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + $this->initialize(); + return $this->items[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if ($value instanceof Folder === false) { + throw new \InvalidArgumentException( + 'Modifying the folder collection is only allowed by setting a value of type Folder.', + 1724136133 + ); + } + $this->items[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('Removing items from the folder collection is not implemented.', 1724136134); + } +} diff --git a/Classes/Resource/Collection/StaticFileCollection.php b/Classes/Resource/Collection/StaticFileCollection.php new file mode 100644 index 0000000..51f1132 --- /dev/null +++ b/Classes/Resource/Collection/StaticFileCollection.php @@ -0,0 +1,60 @@ +<?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\Core\Resource\Collection; + +use TYPO3\CMS\Core\Resource\FileRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A collection containing a static set of files. This collection is persisted + * to the database with references to all files it contains. + */ +class StaticFileCollection extends AbstractFileCollection +{ + /** + * @var string + */ + protected static $type = 'static'; + + /** + * @var string + */ + protected static $itemsCriteriaField = 'files'; + + /** + * @var string + */ + protected $itemTableName = 'sys_file_reference'; + + /** + * Populates the content-entries of the storage + * + * Queries the underlying storage for entries of the collection + * and adds them to the collection data. + * + * If the content entries of the storage had not been loaded on creation + * ($fillItems = false) this function is to be used for loading the contents + * afterwards. + */ + public function loadContents() + { + $fileRepository = GeneralUtility::makeInstance(FileRepository::class); + $fileReferences = $fileRepository->findByRelation('sys_file_collection', 'files', $this->getIdentifier()); + foreach ($fileReferences as $file) { + $this->add($file); + } + } +} diff --git a/Classes/Resource/DefaultUploadFolderResolver.php b/Classes/Resource/DefaultUploadFolderResolver.php new file mode 100644 index 0000000..e8972f2 --- /dev/null +++ b/Classes/Resource/DefaultUploadFolderResolver.php @@ -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\Core\Resource; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\EventDispatcher\EventDispatcher; +use TYPO3\CMS\Core\Resource\Event\AfterDefaultUploadFolderWasResolvedEvent; +use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; + +/** + * Finds the best matching upload folder for a specific backend user + * when uploading or selecting files, based on UserTSconfig or PageTSconfig + */ +#[Autoconfigure(public: true)] +readonly class DefaultUploadFolderResolver +{ + public function __construct( + protected ResourceFactory $resourceFactory, + protected EventDispatcher $eventDispatcher, + ) {} + + public function resolve(BackendUserAuthentication $user, ?int $pid = null, ?string $table = null, ?string $field = null): Folder|bool + { + $uploadFolder = $this->getDefaultUploadFolderForUser($user); + $uploadFolder = $this->getDefaultUploadFolderForPage($pid) ?? $uploadFolder; + + $uploadFolder = $this->eventDispatcher->dispatch( + new AfterDefaultUploadFolderWasResolvedEvent($uploadFolder, $pid, $table, $field) + )->getUploadFolder() ?? $uploadFolder; + + $uploadFolder = $uploadFolder ?? $this->getDefaultUploadFolder($user); + + return $uploadFolder instanceof Folder ? $uploadFolder : false; + } + + public function getDefaultUploadFolderForUser(BackendUserAuthentication $backendUser): ?Folder + { + $uploadFolder = $backendUser->getTSConfig()['options.']['defaultUploadFolder'] ?? ''; + + return $this->resolveFolder($uploadFolder); + } + + public function getDefaultUploadFolderForPage(?int $pid): ?Folder + { + $uploadFolder = BackendUtility::getPagesTSconfig($pid)['options.']['defaultUploadFolder'] ?? ''; + + return $this->resolveFolder($uploadFolder); + } + + protected function resolveFolder(string $uploadPath): ?Folder + { + $uploadFolder = null; + + if ($uploadPath) { + try { + $uploadFolder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($uploadPath); + } catch (FolderDoesNotExistException $e) { + } + } + + return $uploadFolder; + } + + /** + * Detects the first default folder of the first storage that the backend user has access to. + * If the default storage is not available, all other storages are then checked as well. + * + * @param BackendUserAuthentication $backendUser + * @return Folder|null + */ + protected function getDefaultUploadFolder(BackendUserAuthentication $backendUser): ?Folder + { + $uploadFolder = null; + + foreach ($backendUser->getFileStorages() as $storage) { + if ($storage->isDefault() && $storage->isWritable()) { + try { + $uploadFolder = $storage->getDefaultFolder(); + if ($uploadFolder->checkActionPermission('write')) { + break; + } + $uploadFolder = null; + } catch (Exception $folderAccessException) { + // If the folder is not accessible (no permissions / does not exist) we skip this one. + } + break; + } + } + if (!$uploadFolder instanceof Folder) { + foreach ($backendUser->getFileStorages() as $storage) { + if ($storage->isWritable()) { + try { + $uploadFolder = $storage->getDefaultFolder(); + if ($uploadFolder->checkActionPermission('write')) { + break; + } + $uploadFolder = null; + } catch (Exception $folderAccessException) { + // If the folder is not accessible (no permissions / does not exist) try the next one. + } + } + } + } + return $uploadFolder; + } +} diff --git a/Classes/Resource/Driver/AbstractDriver.php b/Classes/Resource/Driver/AbstractDriver.php new file mode 100644 index 0000000..3a7cc1b --- /dev/null +++ b/Classes/Resource/Driver/AbstractDriver.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Driver; + +use TYPO3\CMS\Core\Resource\Capabilities; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * An abstract implementation of a storage driver. + */ +abstract class AbstractDriver implements DriverInterface +{ + /******************* + * CAPABILITIES + *******************/ + /** + * The capabilities of this driver. This value should be set in the constructor of derived classes. + */ + protected Capabilities $capabilities; + + /** + * The storage uid the driver was instantiated for + */ + protected ?int $storageUid = null; + + /** + * A list of all supported hash algorithms, written all lower case and + * without any dashes etc. (e.g. sha1 instead of SHA-1) + * Be sure to set this in inherited classes! + * + * @phpstan-var list<string> + * + * @todo: Remove this from this class. Properties of abstract classes MUST NOT be api. If all drivers + * need to implement this, consider creating a new method stub in the DriverInterface or consider + * creating a new SupportedHashAlgorithmsAwareInterface that demands implementations to provide said + * information. Inside this abstract class, this property is useless, however. + */ + protected array $supportedHashAlgorithms = []; + + /** + * The configuration of this driver + */ + protected array $configuration = []; + + /** + * Creates this object. + */ + public function __construct(array $configuration = []) + { + $this->configuration = $configuration; + } + + /** + * Checks a fileName for validity. This could be overridden in concrete + * drivers if they have different file naming rules. + */ + protected function isValidFilename(string $fileName): bool + { + if (str_contains($fileName, '/')) { + return false; + } + if (!preg_match('/^[\\pL\\d[:blank:]._-]*$/u', $fileName)) { + return false; + } + return true; + } + + /** + * Sets the storage uid the driver belongs to + */ + public function setStorageUid(int $storageUid): void + { + $this->storageUid = $storageUid; + } + + /** + * Returns the capabilities of this driver. + */ + public function getCapabilities(): Capabilities + { + return $this->capabilities; + } + + /** + * Returns TRUE if this driver has the given capability. + * + * @phpstan-param Capabilities::CAPABILITY_* $capability + */ + public function hasCapability(int $capability): bool + { + return $this->getCapabilities()->hasCapability($capability); + } + + /******************* + * FILE FUNCTIONS + *******************/ + + /** + * Returns a temporary path for a given file, including the file extension. + * + * @phpstan-param non-empty-string $fileIdentifier + * @phpstan-return non-empty-string + */ + protected function getTemporaryPathForFile(string $fileIdentifier): string + { + return GeneralUtility::tempnam('fal-tempfile-', '.' . PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION)); + } + + /** + * Hashes a file identifier, taking the case sensitivity of the file system + * into account. This helps mitigating problems with case-insensitive + * databases. + * + * @phpstan-param non-empty-string $identifier + * @phpstan-return non-empty-string + */ + public function hashIdentifier(string $identifier): string + { + $identifier = $this->canonicalizeAndCheckFileIdentifier($identifier); + return sha1($identifier); + } + + /** + * Returns TRUE if this driver uses case-sensitive identifiers. NOTE: This + * is a configurable setting, but the setting does not change the way the + * underlying file system treats the identifiers; the setting should + * therefore always reflect the file system and not try to change its + * behaviour + */ + public function isCaseSensitiveFileSystem(): bool + { + if (isset($this->configuration['caseSensitive'])) { + return (bool)$this->configuration['caseSensitive']; + } + return true; + } + + /** + * Makes sure the path given as parameter is valid + * + * @phpstan-param non-empty-string $filePath The file path (most times filePath) + * @phpstan-return non-empty-string + */ + abstract protected function canonicalizeAndCheckFilePath(string $filePath): string; + + /** + * Makes sure the identifier given as parameter is valid + * + * @phpstan-param non-empty-string $fileIdentifier The file Identifier + * @phpstan-return non-empty-string + */ + abstract protected function canonicalizeAndCheckFileIdentifier(string $fileIdentifier): string; + + /** + * Makes sure the identifier given as parameter is valid + * + * @phpstan-param non-empty-string $folderIdentifier The folder identifier + * @phpstan-return non-empty-string + */ + abstract protected function canonicalizeAndCheckFolderIdentifier(string $folderIdentifier): string; +} diff --git a/Classes/Resource/Driver/AbstractHierarchicalFilesystemDriver.php b/Classes/Resource/Driver/AbstractHierarchicalFilesystemDriver.php new file mode 100644 index 0000000..5108f3a --- /dev/null +++ b/Classes/Resource/Driver/AbstractHierarchicalFilesystemDriver.php @@ -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\Core\Resource\Driver; + +use TYPO3\CMS\Core\Resource\Exception\InvalidPathException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Contains a few classes that might be useful for hierarchical drivers. + */ +abstract class AbstractHierarchicalFilesystemDriver extends AbstractDriver +{ + /** + * Wrapper for \TYPO3\CMS\Core\Utility\GeneralUtility::validPathStr() + * + * @return bool TRUE if no '/', '..' or '\' is in the $theFile + */ + protected function isPathValid(string $theFile): bool + { + return GeneralUtility::validPathStr($theFile); + } + + /** + * Makes sure the given path is valid. + * + * @phpstan-param non-empty-string $filePath The file path (including the file name!) + * @phpstan-return non-empty-string + */ + protected function canonicalizeAndCheckFilePath(string $filePath): string + { + $filePath = PathUtility::getCanonicalPath($filePath); + // $filePath must be valid + if (!$this->isPathValid($filePath)) { + throw new InvalidPathException('File ' . $filePath . ' is not valid (".." and "//" is not allowed in path).', 1320286857); + } + return $filePath; + } + + /** + * Makes sure the Path given as parameter is valid. + * + * @param string $fileIdentifier The file path (including the file name!) + */ + protected function canonicalizeAndCheckFileIdentifier(string $fileIdentifier): string + { + if ($fileIdentifier !== '') { + $fileIdentifier = $this->canonicalizeAndCheckFilePath($fileIdentifier); + $fileIdentifier = '/' . ltrim($fileIdentifier, '/'); + if (!$this->isCaseSensitiveFileSystem()) { + $fileIdentifier = mb_strtolower($fileIdentifier, 'utf-8'); + } + } + return $fileIdentifier; + } + + /** + * Makes sure the Path given as parameter is valid. + * + * @phpstan-param non-empty-string $folderIdentifier The file path (including the file name!) + * @phpstan-return non-empty-string + */ + protected function canonicalizeAndCheckFolderIdentifier(string $folderIdentifier): string + { + if ($folderIdentifier === '/') { + return '/'; + } + return rtrim($this->canonicalizeAndCheckFileIdentifier($folderIdentifier), '/') . '/'; + } + + /** + * Returns the identifier of the folder the file resides in. + * + * @phpstan-param non-empty-string $fileIdentifier + * @phpstan-return non-empty-string + */ + public function getParentFolderIdentifierOfIdentifier(string $fileIdentifier): string + { + $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier($fileIdentifier); + return rtrim(GeneralUtility::fixWindowsFilePath(PathUtility::dirname($fileIdentifier)), '/') . '/'; + } +} diff --git a/Classes/Resource/Driver/DriverInterface.php b/Classes/Resource/Driver/DriverInterface.php new file mode 100644 index 0000000..096129c --- /dev/null +++ b/Classes/Resource/Driver/DriverInterface.php @@ -0,0 +1,459 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Driver; + +use TYPO3\CMS\Core\Resource\Capabilities; + +/** + * An interface Drivers have to implement to fulfil the needs + * of the FAL API. + */ +interface DriverInterface +{ + /** + * Processes the configuration for this driver. + */ + public function processConfiguration(): void; + + /** + * Sets the storage uid the driver belongs to + */ + public function setStorageUid(int $storageUid): void; + + /** + * Initializes this object. This is called by the storage after the driver + * has been attached. + */ + public function initialize(): void; + + /** + * Returns the capabilities of this driver. + */ + public function getCapabilities(): Capabilities; + + /** + * Merges the capabilities merged by the user at the storage + * configuration into the actual capabilities of the driver + * and returns the result. + */ + public function mergeConfigurationCapabilities(Capabilities $capabilities): Capabilities; + + /** + * Returns TRUE if this driver has the given capability. + * + * @param Capabilities::CAPABILITY_* $capability + */ + public function hasCapability(int $capability): bool; + + /** + * Returns TRUE if this driver uses case-sensitive identifiers. NOTE: This + * is a configurable setting, but the setting does not change the way the + * underlying file system treats the identifiers; the setting should + * therefore always reflect the file system and not try to change its + * behaviour + */ + public function isCaseSensitiveFileSystem(): bool; + + /** + * Cleans a fileName from not allowed characters + * + * @param non-empty-string $fileName + * @return non-empty-string the sanitized filename + */ + public function sanitizeFileName(string $fileName): string; + + /** + * Hashes a file identifier, taking the case sensitivity of the file system + * into account. This helps mitigating problems with case-insensitive + * databases. + * + * @param non-empty-string $identifier + * @return non-empty-string + */ + public function hashIdentifier(string $identifier): string; + + /** + * Returns the identifier of the root level folder of the storage. + * + * @return non-empty-string + */ + public function getRootLevelFolder(): string; + + /** + * Returns the identifier of the default folder new files should be put into. + * + * @return non-empty-string + */ + public function getDefaultFolder(): string; + + /** + * Returns the identifier of the folder the file resides in + * + * @param non-empty-string $fileIdentifier + * @return non-empty-string + */ + public function getParentFolderIdentifierOfIdentifier(string $fileIdentifier): string; + + /** + * Returns the public URL to a file. + * Either fully qualified URL or relative to public web path (rawurlencoded). + * + * @param non-empty-string $identifier + * @return non-empty-string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(string $identifier): ?string; + + /** + * Creates a folder, within a parent folder. + * If no parent folder is given, a root level folder will be created + * + * @param non-empty-string $newFolderName + * @return non-empty-string the Identifier of the new folder + */ + public function createFolder(string $newFolderName, string $parentFolderIdentifier = '', bool $recursive = false): string; + + /** + * Renames a folder in this storage. + * + * @param non-empty-string $folderIdentifier + * @param non-empty-string $newName + * @return array<string, string> A map of old to new file identifiers of all affected resources + */ + public function renameFolder(string $folderIdentifier, string $newName): array; + + /** + * Removes a folder in filesystem. + * + * @param non-empty-string $folderIdentifier + */ + public function deleteFolder(string $folderIdentifier, bool $deleteRecursively = false): bool; + + /** + * Checks if a file exists. + * + * @param non-empty-string $fileIdentifier + */ + public function fileExists(string $fileIdentifier): bool; + + /** + * Checks if a folder exists. + * + * @param non-empty-string $folderIdentifier + */ + public function folderExists(string $folderIdentifier): bool; + + /** + * Checks if a folder contains files and (if supported) other folders. + * + * @param non-empty-string $folderIdentifier + * @return bool TRUE if there are no files and folders within $folder + */ + public function isFolderEmpty(string $folderIdentifier): bool; + + /** + * Adds a file from the local server hard disk to a given path in TYPO3s + * virtual file system. This assumes that the local file exists, so no + * further check is done here! After a successful operation the original + * file must not exist anymore. + * + * @param non-empty-string $localFilePath within public web path + * @param non-empty-string $targetFolderIdentifier + * @param string $newFileName optional, if not given original name is used + * @param bool $removeOriginal if set the original file will be removed + * after successful operation + * @return non-empty-string the identifier of the new file + */ + public function addFile(string $localFilePath, string $targetFolderIdentifier, string $newFileName = '', bool $removeOriginal = true): string; + + /** + * Creates a new (empty) file and returns the identifier. + * + * @param non-empty-string $fileName + * @param non-empty-string $parentFolderIdentifier + * @return non-empty-string + */ + public function createFile(string $fileName, string $parentFolderIdentifier): string; + + /** + * Copies a file *within* the current storage. + * Note that this is only about an inner storage copy action, + * where a file is just copied to another folder in the same storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $fileName + * @return non-empty-string the Identifier of the new file + */ + public function copyFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $fileName): string; + + /** + * Renames a file in this storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $newName The target path (including the file name!) + * @return non-empty-string The identifier of the file after renaming + */ + public function renameFile(string $fileIdentifier, string $newName): string; + + /** + * Replaces a file with file in local file system. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $localFilePath + */ + public function replaceFile(string $fileIdentifier, string $localFilePath): bool; + + /** + * Removes a file from the filesystem. This does not check if the file is + * still used or if it is a bad idea to delete it for some other reason + * this has to be taken care of in the upper layers (e.g. the Storage)! + * + * @param non-empty-string $fileIdentifier + */ + public function deleteFile(string $fileIdentifier): bool; + + /** + * Creates a hash for a file. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $hashAlgorithm The hash algorithm to use + */ + public function hash(string $fileIdentifier, string $hashAlgorithm): string; + + /** + * Moves a file *within* the current storage. + * Note that this is only about an inner-storage move action, + * where a file is just moved to another folder in the same storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFileName + * @return non-empty-string + */ + public function moveFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $newFileName): string; + + /** + * Folder equivalent to moveFileWithinStorage(). + * + * @param non-empty-string $sourceFolderIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFolderName + * @return array<non-empty-string, non-empty-string> All files which are affected, map of old => new file identifiers + */ + public function moveFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): array; + + /** + * Folder equivalent to copyFileWithinStorage(). + * + * @param non-empty-string $sourceFolderIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFolderName + */ + public function copyFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): bool; + + /** + * Returns the contents of a file. Beware that this requires to load the + * complete file into memory and also may require fetching the file from an + * external location. So this might be an expensive operation (both in terms + * of processing resources and money) for large files. + * + * @param non-empty-string $fileIdentifier + */ + public function getFileContents(string $fileIdentifier): string; + + /** + * Sets the contents of a file to the specified value. + * + * @param non-empty-string $fileIdentifier + * @return int<0, max> The number of bytes written to the file + */ + public function setFileContents(string $fileIdentifier, string $contents): int; + + /** + * Checks if a file inside a folder exists + * + * @param non-empty-string $fileName + * @param non-empty-string $folderIdentifier + */ + public function fileExistsInFolder(string $fileName, string $folderIdentifier): bool; + + /** + * Checks if a folder inside a folder exists. + * + * @param non-empty-string $folderName + * @param non-empty-string $folderIdentifier + */ + public function folderExistsInFolder(string $folderName, string $folderIdentifier): bool; + + /** + * Returns a path to a local copy of a file for processing it. When changing the + * file, you have to take care of replacing the current version yourself! + * + * @param non-empty-string $fileIdentifier + * @param bool $writable Set this to FALSE if you only need the file for read + * operations. This might speed up things, e.g. by using + * a cached local version. Never modify the file if you + * have set this flag! + * @return non-empty-string The path to the file on the local disk + */ + public function getFileForLocalProcessing(string $fileIdentifier, bool $writable = true): string; + + /** + * Returns the permissions of a file/folder as an array + * (keys r, w) of boolean flags + * + * @param non-empty-string $identifier + * @return array{r: bool, w: bool} + */ + public function getPermissions(string $identifier): array; + + /** + * Directly output the contents of the file to the output + * buffer. Should not take care of header files or flushing + * buffer before. Will be taken care of by the Storage. + * + * @param non-empty-string $identifier + */ + public function dumpFileContents(string $identifier): void; + + /** + * Checks if a given identifier is within a container, e.g. if + * a file or folder is within another folder. + * This can e.g. be used to check for web-mounts. + * + * Hint: this also needs to return TRUE if the given identifier + * matches the container identifier to allow access to the root + * folder of a file mount. + * + * @param non-empty-string $folderIdentifier + * @param non-empty-string $identifier identifier to be checked against $folderIdentifier + * @return bool TRUE if $content is within or matches $folderIdentifier + */ + public function isWithin(string $folderIdentifier, string $identifier): bool; + + /** + * Returns information about a file. + * + * @param non-empty-string $fileIdentifier + * @param list<string> $propertiesToExtract Array of properties which are be extracted + * If empty all will be extracted + * @return array<string, mixed> + */ + public function getFileInfoByIdentifier(string $fileIdentifier, array $propertiesToExtract = []): array; + + /** + * Returns information about a folder. + * + * @param non-empty-string $folderIdentifier + * @return array{ + * identifier: non-empty-string, + * name: string, + * mtime: int, + * ctime: int, + * storage: int, + * } + */ + public function getFolderInfoByIdentifier(string $folderIdentifier): array; + + /** + * Returns the identifier of a file inside the folder + * + * @param non-empty-string $fileName + * @param non-empty-string $folderIdentifier + * @return non-empty-string file identifier + */ + public function getFileInFolder(string $fileName, string $folderIdentifier): string; + + /** + * Returns a list of files inside the specified path + * + * @param non-empty-string $folderIdentifier + * @param int<0, max> $start + * @param int<0, max> $numberOfItems + * @param list<callable> $filenameFilterCallbacks callbacks for filtering the items + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return list<string> of FileIdentifiers + */ + public function getFilesInFolder( + string $folderIdentifier, + int $start = 0, + int $numberOfItems = 0, + bool $recursive = false, + array $filenameFilterCallbacks = [], + string $sort = '', + bool $sortRev = false + ): array; + + /** + * Returns the identifier of a folder inside the folder + * + * @param non-empty-string $folderName The name of the target folder + * @param non-empty-string $folderIdentifier + * @return non-empty-string folder identifier + */ + public function getFolderInFolder(string $folderName, string $folderIdentifier): string; + + /** + * Returns a list of folders inside the specified path + * + * @param non-empty-string $folderIdentifier + * @param int<0, max> $start + * @param int<0, max> $numberOfItems + * @param list<callable> $folderNameFilterCallbacks callbacks for filtering the items + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return array<string|int, string> folder identifiers (where key and value are identical, but int-like identifiers + * will get converted to int array keys) + */ + public function getFoldersInFolder( + string $folderIdentifier, + int $start = 0, + int $numberOfItems = 0, + bool $recursive = false, + array $folderNameFilterCallbacks = [], + string $sort = '', + bool $sortRev = false + ): array; + + /** + * Returns the number of files inside the specified path + * + * @param non-empty-string $folderIdentifier + * @param list<callable> $filenameFilterCallbacks callbacks for filtering the items + * @return int<0, max> Number of files in folder + */ + public function countFilesInFolder(string $folderIdentifier, bool $recursive = false, array $filenameFilterCallbacks = []): int; + + /** + * Returns the number of folders inside the specified path + * + * @param non-empty-string $folderIdentifier + * @param list<callable> $folderNameFilterCallbacks callbacks for filtering the items + * @return int<0, max> Number of folders in folder + */ + public function countFoldersInFolder(string $folderIdentifier, bool $recursive = false, array $folderNameFilterCallbacks = []): int; +} diff --git a/Classes/Resource/Driver/DriverRegistry.php b/Classes/Resource/Driver/DriverRegistry.php new file mode 100644 index 0000000..d90d76f --- /dev/null +++ b/Classes/Resource/Driver/DriverRegistry.php @@ -0,0 +1,140 @@ +<?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\Core\Resource\Driver; + +use TYPO3\CMS\Core\SingletonInterface; + +/** + * Registry for driver classes. + */ +class DriverRegistry implements SingletonInterface +{ + /** + * @var array + */ + protected $drivers = []; + + /** + * @var array + */ + protected $driverConfigurations = []; + + /** + * Creates this object by detecting all available drivers registered in $TYPO3_CONF_VARS. + */ + public function __construct() + { + $driverConfigurations = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['registeredDrivers']; + foreach ($driverConfigurations as $shortName => $driverConfig) { + $shortName = $shortName ?: $driverConfig['shortName'] ?? ''; + $this->registerDriverClass($driverConfig['class'] ?? '', $shortName, $driverConfig['label'] ?? '', $driverConfig['flexFormDS'] ?? ''); + } + } + + /** + * Registers a driver class with an optional short name. + * + * @param string $className + * @param string|null $shortName + * @param string $label + * @param string $flexFormDataStructurePathAndFilename + * @return bool TRUE if registering succeeded + * @throws \InvalidArgumentException + */ + public function registerDriverClass($className, $shortName = null, $label = null, $flexFormDataStructurePathAndFilename = null) + { + // todo: Default of $shortName must be empty string, not null. + $shortName = (string)$shortName; + + // check if the class is available for TYPO3 before registering the driver + if (!class_exists($className)) { + throw new \InvalidArgumentException('Class ' . $className . ' does not exist.', 1314979197); + } + + if (!in_array(DriverInterface::class, class_implements($className) ?: [], true)) { + throw new \InvalidArgumentException('Driver ' . $className . ' needs to implement the DriverInterface.', 1387619575); + } + if ($shortName === '') { + $shortName = $className; + } + if (array_key_exists($shortName, $this->drivers)) { + // Return immediately without changing configuration + if ($this->drivers[$shortName] === $className) { + return true; + } + throw new \InvalidArgumentException('Driver ' . $shortName . ' is already registered.', 1314979451); + } + $this->drivers[$shortName] = $className; + $this->driverConfigurations[$shortName] = [ + 'class' => $className, + 'shortName' => $shortName, + 'label' => $label, + 'flexFormDS' => $flexFormDataStructurePathAndFilename, + ]; + return true; + } + + /** + * Adds the TCA information so the registered drivers can be selected when creating a sys_file_storage + * in the TYPO3 Backend. + */ + public function addDriversToTCA(): void + { + $driverFieldConfig = &$GLOBALS['TCA']['sys_file_storage']['columns']['driver']['config']; + $types = &$GLOBALS['TCA']['sys_file_storage']['types']; + foreach ($this->driverConfigurations as $driver) { + $label = $driver['label'] ?: $driver['class']; + $driverId = $driver['shortName']; + $driverFieldConfig['items'][$driverId] = ['label' => $label, 'value' => $driverId]; + $types[$driverId] = $types['0']; + if ($driver['flexFormDS']) { + $types[$driverId]['columnsOverrides']['configuration']['config']['ds'] = $driver['flexFormDS']; + } + } + } + + /** + * Returns a class name for a given class name or short name. + * + * @param string $shortName + * @return string The class name + * @throws \InvalidArgumentException + */ + public function getDriverClass($shortName) + { + if (in_array($shortName, $this->drivers) && class_exists($shortName)) { + return $shortName; + } + if (!array_key_exists($shortName, $this->drivers)) { + throw new \InvalidArgumentException( + 'Desired storage "' . $shortName . '" is not in the list of available storages.', + 1314085990 + ); + } + return $this->drivers[$shortName]; + } + + /** + * Checks if the given driver exists + * + * @param string $shortName Name of the driver + * @return bool TRUE if the driver exists, FALSE otherwise + */ + public function driverExists($shortName) + { + return array_key_exists($shortName, $this->drivers); + } +} diff --git a/Classes/Resource/Driver/LocalDriver.php b/Classes/Resource/Driver/LocalDriver.php new file mode 100644 index 0000000..a344f00 --- /dev/null +++ b/Classes/Resource/Driver/LocalDriver.php @@ -0,0 +1,1371 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Driver; + +use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Core\Charset\CharsetConverter; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\Response; +use TYPO3\CMS\Core\Http\SelfEmittableLazyOpenStream; +use TYPO3\CMS\Core\Resource\Capabilities; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFileNameException; +use TYPO3\CMS\Core\Resource\Exception\FileOperationErrorException; +use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; +use TYPO3\CMS\Core\Resource\Exception\InvalidConfigurationException; +use TYPO3\CMS\Core\Resource\Exception\InvalidFileNameException; +use TYPO3\CMS\Core\Resource\Exception\InvalidPathException; +use TYPO3\CMS\Core\Resource\Exception\ResourcePermissionsUnavailableException; +use TYPO3\CMS\Core\Resource\FolderInterface; +use TYPO3\CMS\Core\Type\File\FileInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * A concrete implementation for a File Driver for the local file system. + */ +class LocalDriver extends AbstractHierarchicalFilesystemDriver implements StreamableDriverInterface +{ + /** + * @var string + */ + public const UNSAFE_FILENAME_CHARACTER_EXPRESSION = '\\x00-\\x2C\\/\\x3A-\\x3F\\x5B-\\x60\\x7B-\\xBF'; + + /** + * The absolute base path. It always contains a trailing slash. + */ + protected string $absoluteBasePath = '/'; + + /** + * The base URL that points to this driver's storage. As long is this + * is not set, it is assumed that this folder is not publicly available + */ + protected ?string $baseUri = null; + + /** + * @var array<non-empty-string, FolderInterface::ROLE_*> + */ + protected array $mappingFolderNameToRole = [ + '_recycler_' => FolderInterface::ROLE_RECYCLER, + '_temp_' => FolderInterface::ROLE_TEMPORARY, + 'user_upload' => FolderInterface::ROLE_USERUPLOAD, + ]; + + public function __construct(array $configuration = []) + { + parent::__construct($configuration); + // The capabilities default of this driver. See Capabilities::CAPABILITY_* constants for possible values + $this->capabilities = new Capabilities( + Capabilities::CAPABILITY_BROWSABLE + | Capabilities::CAPABILITY_PUBLIC + | Capabilities::CAPABILITY_WRITABLE + | Capabilities::CAPABILITY_HIERARCHICAL_IDENTIFIERS + ); + } + + /** + * Merges the capabilities from the user of the storage configuration into the actual + * capabilities of the driver and returns the result. + */ + public function mergeConfigurationCapabilities(Capabilities $capabilities): Capabilities + { + $this->capabilities->and($capabilities); + return $this->capabilities; + } + + public function processConfiguration(): void + { + try { + $this->absoluteBasePath = $this->calculateBasePath($this->configuration); + } catch (InvalidConfigurationException $e) { + // The storage is offline, but the absolute base path requires a "/" at the end. + $this->absoluteBasePath = '/'; + throw $e; + } + $this->determineBaseUrl(); + if ($this->baseUri === null) { + // remove public flag + $this->capabilities->removeCapability(Capabilities::CAPABILITY_PUBLIC); + } + } + + /** + * Initializes this object. This is called by the storage after the driver + * has been attached. + */ + public function initialize(): void {} + + /** + * Determines the base URL for this driver, from the configuration or + * the public path. + */ + protected function determineBaseUrl(): void + { + // only calculate baseURI if the storage does not enforce jumpUrl Script + if ($this->hasCapability(Capabilities::CAPABILITY_PUBLIC)) { + if (!empty($this->configuration['baseUri'])) { + $this->baseUri = rtrim($this->configuration['baseUri'], '/') . '/'; + } elseif (str_starts_with($this->absoluteBasePath, Environment::getPublicPath())) { + // use site-relative URLs + $temporaryBaseUri = rtrim(PathUtility::stripPathSitePrefix($this->absoluteBasePath), '/'); + if ($temporaryBaseUri !== '') { + $uriParts = explode('/', $temporaryBaseUri); + $uriParts = array_map(rawurlencode(...), $uriParts); + $temporaryBaseUri = implode('/', $uriParts) . '/'; + } + $this->baseUri = $temporaryBaseUri; + } + } + } + + /** + * Calculates the absolute path to this driver's storage location + */ + protected function calculateBasePath(array $configuration): string + { + if (!array_key_exists('basePath', $configuration) || empty($configuration['basePath'])) { + throw new InvalidConfigurationException( + 'Configuration must contain base path.', + 1346510477 + ); + } + + if (!empty($configuration['pathType']) && $configuration['pathType'] === 'relative') { + $relativeBasePath = $configuration['basePath']; + $absoluteBasePath = Environment::getPublicPath() . '/' . $relativeBasePath; + } else { + $absoluteBasePath = $configuration['basePath']; + } + $absoluteBasePath = $this->canonicalizeAndCheckFilePath($absoluteBasePath); + $absoluteBasePath = rtrim($absoluteBasePath, '/') . '/'; + if (!$this->isAllowedAbsolutePath($absoluteBasePath)) { + throw new InvalidConfigurationException( + 'Base path "' . $absoluteBasePath . '" is not within the allowed project root path or allowed lockRootPath.', + 1704807715 + ); + } + if (!is_dir($absoluteBasePath)) { + throw new InvalidConfigurationException( + 'Base path "' . $absoluteBasePath . '" does not exist or is no directory.', + 1299233097 + ); + } + return $absoluteBasePath; + } + + /** + * Returns a publicly accessible URL to a file or folder. + * For the local driver, this will always return a path relative to public web path. + * For non-public storages, this method returns null. + * + * @param non-empty-string $identifier + * @return string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(string $identifier): ?string + { + $publicUrl = null; + if ($this->baseUri !== null) { + $uriParts = explode('/', ltrim($identifier, '/')); + $uriParts = array_map(rawurlencode(...), $uriParts); + $identifier = implode('/', $uriParts); + $publicUrl = $this->baseUri . $identifier; + } + return $publicUrl; + } + + /** + * Returns the identifier of the root level folder of the storage. + * + * @return non-empty-string + */ + public function getRootLevelFolder(): string + { + return '/'; + } + + /** + * Returns the identifier of the default folder where new files should be put into. + * + * @return non-empty-string + */ + public function getDefaultFolder(): string + { + $identifier = '/user_upload/'; + $createFolder = !$this->folderExists($identifier); + if ($createFolder === true) { + $identifier = $this->createFolder('user_upload'); + } + return $identifier; + } + + /** + * Creates a folder, within a given parent folder. + * If no parent folder is given, a folder on the root-level will be created + * + * @param non-empty-string $newFolderName + * @return non-empty-string the identifier of the new folder + */ + public function createFolder(string $newFolderName, string $parentFolderIdentifier = '', bool $recursive = false): string + { + $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier); + $newFolderName = trim($newFolderName, '/'); + if ($recursive === false) { + $newFolderName = $this->sanitizeFileName($newFolderName); + $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier . $newFolderName . '/'); + GeneralUtility::mkdir($this->getAbsolutePath($newIdentifier)); + } else { + $parts = GeneralUtility::trimExplode('/', $newFolderName); + $parts = array_map($this->sanitizeFileName(...), $parts); + $newFolderName = implode('/', $parts); + $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier( + $parentFolderIdentifier . $newFolderName . '/' + ); + GeneralUtility::mkdir_deep($this->getAbsolutePath($newIdentifier)); + } + return $newIdentifier; + } + + /** + * Returns information about a file. + * + * @param non-empty-string $fileIdentifier In the case of the LocalDriver, this is the (relative) path to the file. + * @param list<non-empty-string> $propertiesToExtract Array of properties which should be extracted, if empty all will be extracted + * @return array<non-empty-string, mixed> + */ + public function getFileInfoByIdentifier(string $fileIdentifier, array $propertiesToExtract = []): array + { + $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); + // don't use $this->fileExists() because we need the absolute path to the file anyway, so we can directly + // use PHP's filesystem method. + if (!file_exists($absoluteFilePath) || !is_file($absoluteFilePath)) { + // @todo: This should be turned into a specific exception instead! + throw new \InvalidArgumentException('File ' . $fileIdentifier . ' does not exist.', 1314516809); + } + + $dirPath = PathUtility::dirname($fileIdentifier); + $dirPath = $this->canonicalizeAndCheckFolderIdentifier($dirPath); + return $this->extractFileInformation($absoluteFilePath, $dirPath, $propertiesToExtract); + } + + /** + * Returns information about a folder. + * + * @param string $folderIdentifier In the case of the LocalDriver, this is the (relative) path to the file. + * @return array{ + * identifier: non-empty-string, + * name: string, + * mtime: int, + * ctime: int, + * storage: int, + * } + */ + public function getFolderInfoByIdentifier(string $folderIdentifier): array + { + $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + + if (!$this->folderExists($folderIdentifier)) { + throw new FolderDoesNotExistException( + 'Folder "' . $folderIdentifier . '" does not exist.', + 1314516810 + ); + } + $absolutePath = $this->getAbsolutePath($folderIdentifier); + return [ + 'identifier' => $folderIdentifier, + 'name' => PathUtility::basename($folderIdentifier), + 'mtime' => filemtime($absolutePath), + 'ctime' => filectime($absolutePath), + 'storage' => $this->storageUid, + ]; + } + + /** + * Returns a string where any character not matching [.a-zA-Z0-9_-] is + * substituted by '_' + * Trailing dots are removed and characters are lowercased if using + * a case insensitive file system. + * + * Previously in \TYPO3\CMS\Core\Utility\File\BasicFileUtility::cleanFileName() + * + * @param string $fileName Input string, typically the body of a fileName + * @return non-empty-string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed + */ + public function sanitizeFileName(string $fileName): string + { + $fileName = \Normalizer::normalize($fileName) ?: $fileName; + // Handle UTF-8 characters + if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) { + // Allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave) + $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName)); + if (!$this->isCaseSensitiveFileSystem()) { + $cleanFileName = mb_strtolower($cleanFileName, 'utf-8'); + } + } else { + $fileName = GeneralUtility::makeInstance(CharsetConverter::class)->utf8_char_mapping($fileName); + // Replace unwanted characters with underscores + $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName)); + if (!$this->isCaseSensitiveFileSystem()) { + $cleanFileName = strtolower($cleanFileName); + } + } + // Strip trailing dots and return + $cleanFileName = rtrim($cleanFileName, '.'); + if ($cleanFileName === '') { + throw new InvalidFileNameException('File name ' . $fileName . ' is invalid.', 1320288991); + } + return $cleanFileName; + } + + /** + * Generic wrapper for extracting a list of items from a path. + * + * @param int $start The position to start the listing; if not set, start from the beginning + * @param int $numberOfItems The number of items to list; if set to zero, all items are returned + * @param array $filterMethods The filter methods used to filter the directory items + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return array<string|int, string> folder identifiers (where key and value are identical, but int-like identifiers + * will get converted to int array keys) + */ + protected function getDirectoryItemList(string $folderIdentifier, int $start, int $numberOfItems, array $filterMethods, bool $includeFiles = true, bool $includeDirs = true, bool $recursive = false, string $sort = '', bool $sortRev = false): array + { + $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + $realPath = $this->getAbsolutePath($folderIdentifier); + if (!is_dir($realPath)) { + throw new \InvalidArgumentException( + 'Cannot list items in directory ' . $folderIdentifier . ' - does not exist or is no directory', + 1314349666 + ); + } + + $items = $this->retrieveFileAndFoldersInPath($realPath, $recursive, $includeFiles, $includeDirs, $sort, $sortRev); + $iterator = new \ArrayIterator($items); + if ($iterator->count() === 0) { + return []; + } + + // $c is the counter for how many items we still have to fetch (-1 is unlimited) + $c = $numberOfItems > 0 ? $numberOfItems : -1; + $items = []; + while ($iterator->valid() && ($numberOfItems === 0 || $c > 0)) { + // $iteratorItem is the file or folder name + $iteratorItem = $iterator->current(); + // go on to the next iterator item now as we might skip this one early + $iterator->next(); + + try { + if ( + !$this->applyFilterMethodsToDirectoryItem( + $filterMethods, + $iteratorItem['name'], + $iteratorItem['identifier'], + $this->getParentFolderIdentifierOfIdentifier($iteratorItem['identifier']) + ) + ) { + continue; + } + if ($start > 0) { + $start--; + } else { + // The identifier can also be an int-like string, resulting in int array keys. + $items[$iteratorItem['identifier']] = $iteratorItem['identifier']; + // Decrement item counter to make sure we only return $numberOfItems + // we cannot do this earlier in the method (unlike moving the iterator forward) because we only add the + // item here + --$c; + } + } catch (InvalidPathException) { + } + } + return $items; + } + + /** + * Applies a set of filter methods to a file name to find out if it should be used or not. + * This is used by directory listings. + * + * @param array $filterMethods The filter methods to use + */ + protected function applyFilterMethodsToDirectoryItem(array $filterMethods, string $itemName, string $itemIdentifier, string $parentIdentifier): bool + { + foreach ($filterMethods as $filter) { + if (is_callable($filter)) { + $result = $filter($itemName, $itemIdentifier, $parentIdentifier, [], $this); + // We use -1 as the "don't include“ return value, for historic reasons, + // as call_user_func() used to return FALSE if calling the method failed. + if ($result === -1) { + return false; + } + if ($result === false) { + throw new \RuntimeException( + 'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1], + 1476046425 + ); + } + } + } + return true; + } + + /** + * Returns a file inside the specified path. + * + * @param non-empty-string $fileName + * @param non-empty-string $folderIdentifier + * @return non-empty-string File Identifier + */ + public function getFileInFolder(string $fileName, string $folderIdentifier): string + { + return $this->canonicalizeAndCheckFileIdentifier($folderIdentifier . '/' . $fileName); + } + + /** + * Returns a list of files inside the specified path. + * + * @param string $folderIdentifier + * @param int $start + * @param int $numberOfItems + * @param bool $recursive + * @param array $filenameFilterCallbacks The method callbacks to use for filtering the items + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return string[] of FileIdentifiers + */ + public function getFilesInFolder(string $folderIdentifier, int $start = 0, int $numberOfItems = 0, bool $recursive = false, array $filenameFilterCallbacks = [], string $sort = '', bool $sortRev = false): array + { + return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $filenameFilterCallbacks, true, false, $recursive, $sort, $sortRev); + } + + /** + * Returns the number of files inside the specified path. + * + * @param non-empty-string $folderIdentifier + * @param list<callable> $filenameFilterCallbacks callbacks for filtering the items + * @return int<0, max> Number of files in folder + */ + public function countFilesInFolder(string $folderIdentifier, bool $recursive = false, array $filenameFilterCallbacks = []): int + { + return count($this->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filenameFilterCallbacks)); + } + + /** + * Returns a list of folders inside the specified path. + * + * @param string $folderIdentifier + * @param int $start + * @param int $numberOfItems + * @param bool $recursive + * @param array $folderNameFilterCallbacks The method callbacks to use for filtering the items + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return array<string|int, string> folder identifiers (where key and value are identical, but int-like identifiers + * will get converted to int array keys) + */ + public function getFoldersInFolder(string $folderIdentifier, int $start = 0, int $numberOfItems = 0, bool $recursive = false, array $folderNameFilterCallbacks = [], string $sort = '', bool $sortRev = false): array + { + return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $folderNameFilterCallbacks, false, true, $recursive, $sort, $sortRev); + } + + /** + * Returns the number of folders inside the specified path. + * + * @param non-empty-string $folderIdentifier + * @param list<callable> $folderNameFilterCallbacks callbacks for filtering the items + * @return int<0, max> Number of folders in folder + */ + public function countFoldersInFolder(string $folderIdentifier, bool $recursive = false, array $folderNameFilterCallbacks = []): int + { + return count($this->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $folderNameFilterCallbacks)); + } + + /** + * Returns a list with the names of all files and folders in a path, optionally recursive. + * + * @param string $path The absolute path + * @param bool $recursive If TRUE, recursively fetches files and folders + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + */ + protected function retrieveFileAndFoldersInPath(string $path, bool $recursive = false, bool $includeFiles = true, bool $includeDirs = true, string $sort = '', bool $sortRev = false): array + { + $pathLength = strlen($this->getAbsoluteBasePath()); + $iteratorMode = \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::FOLLOW_SYMLINKS; + if ($recursive) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, $iteratorMode), + \RecursiveIteratorIterator::SELF_FIRST, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + } else { + $iterator = new \RecursiveDirectoryIterator($path, $iteratorMode); + } + + $directoryEntries = []; + while ($iterator->valid()) { + /** @var \SplFileInfo $entry */ + $entry = $iterator->current(); + $isFile = $entry->isFile(); + $isDirectory = !$isFile && $entry->isDir(); + if ( + (!$isFile && !$isDirectory) // skip non-files/non-folders + || ($isFile && !$includeFiles) // skip files if they are excluded + || ($isDirectory && !$includeDirs) // skip directories if they are excluded + || $entry->getFilename() === '' // skip empty entries + || !$entry->isReadable() // skip unreadable entries + ) { + $iterator->next(); + continue; + } + $entryIdentifier = '/' . substr($entry->getPathname(), $pathLength); + $entryName = PathUtility::basename($entryIdentifier); + if ($isDirectory) { + $entryIdentifier .= '/'; + } + $entryArray = [ + 'identifier' => $entryIdentifier, + 'name' => $entryName, + 'type' => $isDirectory ? 'dir' : 'file', + ]; + $directoryEntries[$entryIdentifier] = $entryArray; + $iterator->next(); + } + return $this->sortDirectoryEntries($directoryEntries, $sort, $sortRev); + } + + /** + * Sort the directory entries by a certain key. + * + * @param array $directoryEntries Array of directory entry arrays from retrieveFileAndFoldersInPath() + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return array Sorted entries. Content of the keys is undefined. + */ + protected function sortDirectoryEntries(array $directoryEntries, string $sort = '', bool $sortRev = false): array + { + if (count($directoryEntries) < 2) { + return $directoryEntries; + } + $entriesToSort = []; + foreach ($directoryEntries as $entryArray) { + $dir = pathinfo($entryArray['name'], PATHINFO_DIRNAME) . '/'; + $fullPath = $this->getAbsoluteBasePath() . $entryArray['identifier']; + switch ($sort) { + case 'size': + $sortingKey = '0'; + if ($entryArray['type'] === 'file') { + $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'size'); + } + // Add a character for a natural order sorting + $sortingKey .= 's'; + break; + case 'rw': + $perms = $this->getPermissions($entryArray['identifier']); + $sortingKey = ($perms['r'] ? 'R' : '') + . ($perms['w'] ? 'W' : ''); + break; + case 'fileext': + $sortingKey = pathinfo($entryArray['name'], PATHINFO_EXTENSION); + break; + case 'tstamp': + $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'mtime'); + // Add a character for a natural order sorting + $sortingKey .= 't'; + break; + case 'crdate': + $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'ctime'); + // Add a character for a natural order sorting + $sortingKey .= 'c'; + break; + case 'name': + case 'file': + default: + $sortingKey = $entryArray['name']; + } + $i = 0; + while (isset($entriesToSort[$sortingKey . $i])) { + $i++; + } + $entriesToSort[$sortingKey . $i] = $entryArray; + } + + $sortMultiplier = $sortRev ? -1 : 1; + uksort($entriesToSort, static function (string $entryA, string $entryB) use ($sortMultiplier): int { + return strnatcasecmp($entryA, $entryB) * $sortMultiplier; + }); + + return $entriesToSort; + } + + /** + * Extracts information about a file from the filesystem. + * + * @param string $filePath The absolute path to the file + * @param string $containerPath The relative path to the file's container + * @param array $propertiesToExtract array of properties which should be returned, if empty all will be extracted + */ + protected function extractFileInformation(string $filePath, string $containerPath, array $propertiesToExtract = []): array + { + if (empty($propertiesToExtract)) { + $propertiesToExtract = [ + 'size', 'atime', 'mtime', 'ctime', 'mimetype', 'name', 'extension', + 'identifier', 'identifier_hash', 'storage', 'folder_hash', + ]; + } + $fileInformation = []; + foreach ($propertiesToExtract as $property) { + $fileInformation[$property] = $this->getSpecificFileInformation($filePath, $containerPath, $property); + } + return $fileInformation; + } + + /** + * Extracts specific information of a file from the file system. + */ + public function getSpecificFileInformation(string $fileIdentifier, string $containerPath, string $property): bool|int|string|null + { + $identifier = $this->canonicalizeAndCheckFileIdentifier($containerPath . PathUtility::basename($fileIdentifier)); + + $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $fileIdentifier); + return match ($property) { + 'size' => $fileInfo->getSize(), + 'atime' => $fileInfo->getATime(), + 'mtime' => $fileInfo->getMTime(), + 'ctime' => $fileInfo->getCTime(), + 'name' => PathUtility::basename($fileIdentifier), + 'extension' => PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION), + 'mimetype' => (string)$fileInfo->getMimeType(), + 'identifier' => $identifier, + 'storage' => $this->storageUid, + 'identifier_hash' => $this->hashIdentifier($identifier), + 'folder_hash' => $this->hashIdentifier($this->getParentFolderIdentifierOfIdentifier($identifier)), + default => throw new \InvalidArgumentException( + sprintf('The information "%s" is not available.', $property), + 1476047422 + ), + }; + } + + /** + * Returns the absolute path of the folder this driver operates on. + */ + protected function getAbsoluteBasePath(): string + { + return $this->absoluteBasePath; + } + + /** + * Returns the absolute path of a file or folder. + */ + protected function getAbsolutePath(string $fileIdentifier): string + { + $relativeFilePath = ltrim($this->canonicalizeAndCheckFileIdentifier($fileIdentifier), '/'); + return $this->absoluteBasePath . $relativeFilePath; + } + + /** + * Creates a (cryptographic) hash for a file. + * + * @param string $hashAlgorithm The hash algorithm to use + */ + public function hash(string $fileIdentifier, string $hashAlgorithm): string + { + $hashContext = hash_init($hashAlgorithm); + hash_update_file($hashContext, $this->getAbsolutePath($fileIdentifier)); + return hash_final($hashContext); + } + + /** + * Adds a file from the local server's hard drive to a given path in TYPO3s storage location. + * This assumes that the local file exists, so no further check is done here. + * After a successful "add" operation, the original file must not exist anymore. + * + * @param non-empty-string $localFilePath within public web path + * @param non-empty-string $targetFolderIdentifier + * @param string $newFileName optional, if not given original name is used + * @param bool $removeOriginal if set the original file will be removed after successful operation + * @return non-empty-string the identifier of the new file + */ + public function addFile(string $localFilePath, string $targetFolderIdentifier, string $newFileName = '', bool $removeOriginal = true): string + { + $localFilePath = $this->canonicalizeAndCheckFilePath($localFilePath); + // as for the "virtual storage" for backwards-compatibility, this check always fails, as the file probably lies under public web path + // thus, it is not checked here + // @todo is check in storage + if (str_starts_with($localFilePath, $this->absoluteBasePath) && $this->storageUid > 0) { + throw new \InvalidArgumentException('Cannot add a file that is already part of this storage.', 1314778269); + } + $newFileName = $this->sanitizeFileName($newFileName !== '' ? $newFileName : PathUtility::basename($localFilePath)); + $newFileIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier) . $newFileName; + $targetPath = $this->getAbsolutePath($newFileIdentifier); + + if ($removeOriginal) { + if (is_uploaded_file($localFilePath)) { + $result = @move_uploaded_file($localFilePath, $targetPath); + } else { + $result = @rename($localFilePath, $targetPath); + } + } else { + $result = @copy($localFilePath, $targetPath); + } + if ($result === false || !file_exists($targetPath)) { + throw new \RuntimeException( + 'Adding file ' . $localFilePath . ' at ' . $newFileIdentifier . ' failed.', + 1476046453 + ); + } + clearstatcache(); + // Change the permissions of the file + GeneralUtility::fixPermissions($targetPath); + return $newFileIdentifier; + } + + /** + * Checks if a file exists on the file system. + * + * @param non-empty-string $fileIdentifier + */ + public function fileExists(string $fileIdentifier): bool + { + $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); + return is_file($absoluteFilePath); + } + + /** + * Checks if a file inside a folder exists. + * + * @param non-empty-string $fileName + * @param non-empty-string $folderIdentifier + */ + public function fileExistsInFolder(string $fileName, string $folderIdentifier): bool + { + $identifier = $folderIdentifier . '/' . $fileName; + $identifier = $this->canonicalizeAndCheckFileIdentifier($identifier); + return $this->fileExists($identifier); + } + + /** + * Checks if a folder exists. + * + * @param non-empty-string $folderIdentifier + */ + public function folderExists(string $folderIdentifier): bool + { + $absoluteFilePath = $this->getAbsolutePath($folderIdentifier); + return is_dir($absoluteFilePath); + } + + /** + * Checks if a folder inside a folder exists. + * + * @param non-empty-string $folderName + * @param non-empty-string $folderIdentifier + */ + public function folderExistsInFolder(string $folderName, string $folderIdentifier): bool + { + $identifier = $folderIdentifier . '/' . $folderName; + $identifier = $this->canonicalizeAndCheckFolderIdentifier($identifier); + return $this->folderExists($identifier); + } + + /** + * Returns the identifier for a folder within a given folder. + * + * @param non-empty-string $folderName The name of the target folder + * @param non-empty-string $folderIdentifier + * @return non-empty-string + */ + public function getFolderInFolder(string $folderName, string $folderIdentifier): string + { + return $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier . '/' . $folderName); + } + + /** + * Replaces the contents (and file-specific metadata) of a file with another file from the server's hard disk. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $localFilePath + */ + public function replaceFile(string $fileIdentifier, string $localFilePath): bool + { + $filePath = $this->getAbsolutePath($fileIdentifier); + if (is_uploaded_file($localFilePath)) { + $result = @move_uploaded_file($localFilePath, $filePath); + } else { + $result = @rename($localFilePath, $filePath); + } + GeneralUtility::fixPermissions($filePath); + if ($result === false) { + throw new \RuntimeException('Replacing file ' . $fileIdentifier . ' with ' . $localFilePath . ' failed.', 1315314711); + } + return true; + } + + /** + * Copies a file *within* the current storage. + * The responsibility of this method in the Driver is only about an intra-storage copy action, + * where a file is just copied to another folder in the same storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $fileName + * @return non-empty-string the identifier of the new file + */ + public function copyFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $fileName): string + { + $sourcePath = $this->getAbsolutePath($fileIdentifier); + $newIdentifier = $targetFolderIdentifier . '/' . $fileName; + $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier); + + $absoluteFilePath = $this->getAbsolutePath($newIdentifier); + @copy($sourcePath, $absoluteFilePath); + GeneralUtility::fixPermissions($absoluteFilePath); + return $newIdentifier; + } + + /** + * Moves a file *within* the current storage. + * The responsibility of this method in the Driver is only about an intra-storage move action, + * where a file is just moved to another folder in the same storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFileName + * @return non-empty-string + */ + public function moveFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $newFileName): string + { + $sourcePath = $this->getAbsolutePath($fileIdentifier); + $targetIdentifier = $targetFolderIdentifier . '/' . $newFileName; + $targetIdentifier = $this->canonicalizeAndCheckFileIdentifier($targetIdentifier); + $result = @rename($sourcePath, $this->getAbsolutePath($targetIdentifier)); + if ($result === false) { + throw new \RuntimeException('Moving file ' . $sourcePath . ' to ' . $targetIdentifier . ' failed.', 1315314712); + } + return $targetIdentifier; + } + + /** + * Copies a file to a temporary path and returns that path. + */ + protected function copyFileToTemporaryPath(string $fileIdentifier): string + { + $sourcePath = $this->getAbsolutePath($fileIdentifier); + $temporaryPath = $this->getTemporaryPathForFile($fileIdentifier); + $result = @copy($sourcePath, $temporaryPath); + if ($result === false) { + throw new \RuntimeException( + 'Copying file "' . $fileIdentifier . '" to temporary path "' . $temporaryPath . '" failed.', + 1320577649 + ); + } + @touch($temporaryPath, (int)filemtime($sourcePath)); + return $temporaryPath; + } + + /** + * Moves a file or folder to the given directory, renaming the source in the process if + * a file or folder of the same name already exists in the target path. + */ + protected function recycleFileOrFolder(string $filePath, string $recycleDirectory): bool + { + $destinationFile = $recycleDirectory . '/' . PathUtility::basename($filePath); + if (file_exists($destinationFile)) { + $timeStamp = \DateTimeImmutable::createFromFormat('U.u', (string)microtime(true))->format('YmdHisu'); + $destinationFile = $recycleDirectory . '/' . $timeStamp . '_' . PathUtility::basename($filePath); + } + $result = @rename($filePath, $destinationFile); + // Update the mtime for the file, so the recycler garbage collection task knows which files to delete + // Using ctime() is not possible there since this is not supported on Windows + if ($result) { + @touch($destinationFile); + } + return $result; + } + + /** + * Creates a map of old and new file/folder identifiers after renaming or + * moving a folder. The old identifier is used as the key, the new one as the value. + * @return array<non-empty-string, non-empty-string> + */ + protected function createIdentifierMap(array $filesAndFolders, string $sourceFolderIdentifier, string $targetFolderIdentifier): array + { + $identifierMap = []; + $identifierMap[$sourceFolderIdentifier] = $targetFolderIdentifier; + foreach ($filesAndFolders as $oldItem) { + $oldIdentifier = $oldItem['identifier']; + if ($oldItem['type'] === 'dir') { + $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier( + str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier']) + ); + } else { + $newIdentifier = $this->canonicalizeAndCheckFileIdentifier( + str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier']) + ); + } + if (!file_exists($this->getAbsolutePath($newIdentifier))) { + throw new FileOperationErrorException( + sprintf('File "%1$s" was not found (should have been copied/moved from "%2$s").', $newIdentifier, $oldIdentifier), + 1330119453 + ); + } + $identifierMap[$oldIdentifier] = $newIdentifier; + } + return $identifierMap; + } + + /** + * Folder equivalent to moveFileWithinStorage(). + * + * @param non-empty-string $sourceFolderIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFolderName + * @return array<non-empty-string, non-empty-string> A map of old to new file identifiers + */ + public function moveFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): array + { + $sourcePath = $this->getAbsolutePath($sourceFolderIdentifier); + $relativeTargetPath = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName); + $targetPath = $this->getAbsolutePath($relativeTargetPath); + // get all files and folders we are going to move, to have a map for updating later. + $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true); + $result = @rename($sourcePath, $targetPath); + if ($result === false) { + throw new \RuntimeException('Moving folder ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320711817); + } + // Create a mapping from old to new identifiers + return $this->createIdentifierMap($filesAndFolders, $sourceFolderIdentifier, $relativeTargetPath); + } + + /** + * Folder equivalent to copyFileWithinStorage(). + * + * @param non-empty-string $sourceFolderIdentifier + * @param non-empty-string $targetFolderIdentifier + * @param non-empty-string $newFolderName + * @return true + */ + public function copyFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): bool + { + // This target folder path already includes the topmost level, i.e. the folder this method knows as $folderToCopy. + // We can thus rely on this folder being present and just create the subfolder we want to copy to. + $newFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName); + $sourceFolderPath = $this->getAbsolutePath($sourceFolderIdentifier); + $targetFolderPath = $this->getAbsolutePath($newFolderIdentifier); + + GeneralUtility::mkdir($targetFolderPath); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sourceFolderPath), + \RecursiveIteratorIterator::SELF_FIRST, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + // Rewind the iterator as this is important for some systems e.g. Windows + $iterator->rewind(); + while ($iterator->valid()) { + /** @var \RecursiveDirectoryIterator $current */ + $current = $iterator->current(); + $fileName = $current->getFilename(); + $itemSubPath = GeneralUtility::fixWindowsFilePath((string)$iterator->getSubPathname()); + if ($current->isDir() && !($fileName === '..' || $fileName === '.')) { + GeneralUtility::mkdir($targetFolderPath . '/' . $itemSubPath); + } elseif ($current->isFile()) { + $copySourcePath = $sourceFolderPath . '/' . $itemSubPath; + $copyTargetPath = $targetFolderPath . '/' . $itemSubPath; + $result = @copy($copySourcePath, $copyTargetPath); + if ($result === false) { + // rollback + GeneralUtility::rmdir($targetFolderIdentifier, true); + throw new FileOperationErrorException( + 'Copying resource "' . $copySourcePath . '" to "' . $copyTargetPath . '" failed.', + 1330119452 + ); + } + } + $iterator->next(); + } + GeneralUtility::fixPermissions($targetFolderPath, true); + return true; + } + + /** + * Renames a file in this storage. + * + * @param non-empty-string $fileIdentifier + * @param non-empty-string $newName The target path (including the file name!) + * @return non-empty-string The identifier of the file after renaming + */ + public function renameFile(string $fileIdentifier, string $newName): string + { + // Makes sure the Path given as parameter is valid + $newName = $this->sanitizeFileName($newName); + $newIdentifier = rtrim(GeneralUtility::fixWindowsFilePath(PathUtility::dirname($fileIdentifier)), '/') . '/' . $newName; + $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier); + // The target should not exist already + if ($this->fileExists($newIdentifier)) { + throw new ExistingTargetFileNameException( + 'The target file "' . $newIdentifier . '" already exists.', + 1320291063 + ); + } + $sourcePath = $this->getAbsolutePath($fileIdentifier); + $targetPath = $this->getAbsolutePath($newIdentifier); + $result = @rename($sourcePath, $targetPath); + if ($result === false) { + throw new \RuntimeException('Renaming file ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320375115); + } + return $newIdentifier; + } + + /** + * Renames a folder in this storage. + * + * @param non-empty-string $folderIdentifier + * @param non-empty-string $newName + * @return array<string, string> A map of old to new file identifiers of all affected files and folders + * @throws \RuntimeException if renaming the folder failed + */ + public function renameFolder(string $folderIdentifier, string $newName): array + { + $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); + $newName = $this->sanitizeFileName($newName); + + $newIdentifier = PathUtility::dirname($folderIdentifier) . '/' . $newName; + $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($newIdentifier); + + $sourcePath = $this->getAbsolutePath($folderIdentifier); + $targetPath = $this->getAbsolutePath($newIdentifier); + // get all files and folders we are going to move, to have a map for updating later. + $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true); + $result = @rename($sourcePath, $targetPath); + if ($result === false) { + throw new \RuntimeException(sprintf('Renaming folder "%1$s" to "%2$s" failed."', $sourcePath, $targetPath), 1320375116); + } + try { + // Create a mapping from old to new identifiers + $identifierMap = $this->createIdentifierMap($filesAndFolders, $folderIdentifier, $newIdentifier); + } catch (\Exception $e) { + @rename($targetPath, $sourcePath); + throw new \RuntimeException( + sprintf( + 'Creating filename mapping after renaming "%1$s" to "%2$s" failed. Reverted rename operation.\\n\\nOriginal error: %3$s"', + $sourcePath, + $targetPath, + $e->getMessage() + ), + 1334160746 + ); + } + return $identifierMap; + } + + /** + * Removes a file from the filesystem. This does not check if the file is + * still used or if it is a bad idea to delete it for some other reason + * this has to be taken care of in the upper layers (e.g. the ResourceStorage). + * + * @param non-empty-string $fileIdentifier + */ + public function deleteFile(string $fileIdentifier): bool + { + $filePath = $this->getAbsolutePath($fileIdentifier); + $result = @unlink($filePath); + + if ($result === false) { + throw new \RuntimeException('Deletion of file ' . $fileIdentifier . ' failed.', 1320855304); + } + return true; + } + + /** + * Removes a folder from the hard drive. + * + * @param non-empty-string $folderIdentifier + */ + public function deleteFolder(string $folderIdentifier, bool $deleteRecursively = false): bool + { + $folderPath = $this->getAbsolutePath($folderIdentifier); + $recycleDirectory = $this->getRecycleDirectory($folderPath); + if (!empty($recycleDirectory) && $folderPath !== $recycleDirectory) { + $result = $this->recycleFileOrFolder($folderPath, $recycleDirectory); + } else { + $result = GeneralUtility::rmdir($folderPath, $deleteRecursively); + } + if ($result === false) { + throw new FileOperationErrorException( + 'Deleting folder "' . $folderIdentifier . '" failed.', + 1330119451 + ); + } + return $result; + } + + /** + * Checks if a folder contains files and (if supported) other folders. + * + * @param non-empty-string $folderIdentifier + * @return bool TRUE if there are no files and folders within $folder + */ + public function isFolderEmpty(string $folderIdentifier): bool + { + $path = $this->getAbsolutePath($folderIdentifier); + $dirHandle = opendir($path); + if ($dirHandle === false) { + return true; + } + while ($entry = readdir($dirHandle)) { + if ($entry !== '.' && $entry !== '..') { + closedir($dirHandle); + return false; + } + } + closedir($dirHandle); + return true; + } + + /** + * Returns (a local copy of) a file for further processing. This makes a copy + * first when in writable mode, so if you change the file, you have to update it yourself afterward. + * + * @param non-empty-string $fileIdentifier + * @param bool $writable Set this to FALSE if you only need the file for read operations. + * This might speed up things, e.g. by using a cached local version. + * Never modify the file if you have set this flag! + * @return non-empty-string The path to the file on the local disk + */ + public function getFileForLocalProcessing(string $fileIdentifier, bool $writable = true): string + { + if ($writable === false) { + return $this->getAbsolutePath($fileIdentifier); + } + return $this->copyFileToTemporaryPath($fileIdentifier); + } + + /** + * Returns the permissions of a file/folder as an array (keys r, w) of boolean flags. + * + * @param non-empty-string $identifier + * @return array{r: bool, w: bool} + */ + public function getPermissions(string $identifier): array + { + $path = $this->getAbsolutePath($identifier); + $permissionBits = fileperms($path); + if ($permissionBits === false) { + throw new ResourcePermissionsUnavailableException('Error while fetching permissions for ' . $path, 1319455097); + } + return [ + 'r' => is_readable($path), + 'w' => is_writable($path), + ]; + } + + /** + * Checks if a given identifier is within a container, e.g. if + * a file or folder is within another folder. It will also return + * TRUE if both canonical identifiers are equal. + * + * @param non-empty-string $folderIdentifier + * @param non-empty-string $identifier identifier to be checked against $folderIdentifier + * @return bool TRUE if $content is within or matches $folderIdentifier + */ + public function isWithin(string $folderIdentifier, string $identifier): bool + { + $folderIdentifier = $this->canonicalizeAndCheckFileIdentifier($folderIdentifier); + $entryIdentifier = $this->canonicalizeAndCheckFileIdentifier($identifier); + if ($folderIdentifier === $entryIdentifier) { + return true; + } + // File identifier canonicalization will not modify a single slash so + // we must not append another slash in that case. + if ($folderIdentifier !== '/') { + $folderIdentifier .= '/'; + } + return str_starts_with($entryIdentifier, $folderIdentifier); + } + + /** + * Creates a new (empty) file and returns the identifier. + * + * @param non-empty-string $fileName + * @param non-empty-string $parentFolderIdentifier + * @return non-empty-string + */ + public function createFile(string $fileName, string $parentFolderIdentifier): string + { + $fileName = $this->sanitizeFileName(ltrim($fileName, '/')); + $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier); + $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( + $parentFolderIdentifier . $fileName + ); + $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); + $result = @touch($absoluteFilePath); + if ($result !== true) { + throw new \RuntimeException('Creating file ' . $fileIdentifier . ' failed.', 1320569854); + } + GeneralUtility::fixPermissions($absoluteFilePath); + clearstatcache(); + return $fileIdentifier; + } + + /** + * Returns the contents of a file. Beware that this requires to load the + * complete file into memory and also may require fetching the file from an + * external location. So this might be an expensive operation (both in terms of + * processing resources and money) for large files. + * + * @param non-empty-string $fileIdentifier + * @return string The file contents if file exists, otherwise an empty string + */ + public function getFileContents(string $fileIdentifier): string + { + $filePath = $this->getAbsolutePath($fileIdentifier); + return is_readable($filePath) ? (string)file_get_contents($filePath) : ''; + } + + /** + * Sets the contents of a file to the specified value. + * + * @param non-empty-string $fileIdentifier + * @return int<0, max> The number of bytes written to the file + * @throws \RuntimeException if the operation failed + */ + public function setFileContents(string $fileIdentifier, string $contents): int + { + $filePath = $this->getAbsolutePath($fileIdentifier); + $result = file_put_contents($filePath, $contents); + + // Make sure later calls to filesize() etc. return correct values. + clearstatcache(true, $filePath); + + if ($result === false) { + throw new \RuntimeException('Setting contents of file "' . $fileIdentifier . '" failed.', 1325419305); + } + return $result; + } + + /** + * Returns the role of an item. This is currently only implemented for folder, + * but could be extended to files as well. + */ + public function getRole(string $folderIdentifier): string + { + $name = PathUtility::basename($folderIdentifier); + return $this->mappingFolderNameToRole[$name] ?? FolderInterface::ROLE_DEFAULT; + } + + /** + * Directly output the contents of the file to the output buffer. + * Should not take care of header files or flushing buffer before. Will be taken care of by the Storage. + * + * @param non-empty-string $identifier + */ + public function dumpFileContents(string $identifier): void + { + readfile($this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier))); + } + + /** + * Stream file using a PSR-7 Response object. + */ + public function streamFile(string $identifier, array $properties): ResponseInterface + { + $fileInfo = $this->getFileInfoByIdentifier($identifier, ['name', 'mimetype', 'mtime', 'size']); + $downloadName = $properties['filename_overwrite'] ?? $fileInfo['name'] ?? ''; + $mimeType = $properties['mimetype_overwrite'] ?? $fileInfo['mimetype'] ?? ''; + $contentDisposition = ($properties['as_download'] ?? false) ? 'attachment' : 'inline'; + + $filePath = $this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier)); + + return new Response( + new SelfEmittableLazyOpenStream($filePath), + 200, + [ + 'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"', + 'Content-Type' => $mimeType, + 'Content-Length' => (string)$fileInfo['size'], + 'Last-Modified' => gmdate('D, d M Y H:i:s', $fileInfo['mtime']) . ' GMT', + // Cache-Control header is needed here to solve an issue with browser IE8 and lower + // See for more information: http://support.microsoft.com/kb/323308 + 'Cache-Control' => '', + ] + ); + } + + /** + * Get the path of the nearest recycler folder of a given path. + * Return an empty string if there is no recycler folder available in the base path. + */ + protected function getRecycleDirectory(string $path): string + { + $recyclerSubdirectory = array_search(FolderInterface::ROLE_RECYCLER, $this->mappingFolderNameToRole, true); + if ($recyclerSubdirectory === false) { + return ''; + } + $rootDirectory = rtrim($this->getAbsolutePath($this->getRootLevelFolder()), '/'); + $searchDirectory = PathUtility::dirname($path); + // Check if file or folder to be deleted is inside a recycler directory + if ($this->getRole($searchDirectory) === FolderInterface::ROLE_RECYCLER) { + $searchDirectory = PathUtility::dirname($searchDirectory); + // Check if file or folder to be deleted is inside the root recycler + if ($searchDirectory == $rootDirectory) { + return ''; + } + $searchDirectory = PathUtility::dirname($searchDirectory); + } + // Search for the closest recycler directory + while ($searchDirectory) { + $recycleDirectory = $searchDirectory . '/' . $recyclerSubdirectory; + if (is_dir($recycleDirectory)) { + return $recycleDirectory; + } + if ($searchDirectory === $rootDirectory) { + return ''; + } + $searchDirectory = PathUtility::dirname($searchDirectory); + } + + return ''; + } + + /** + * Wrapper for `GeneralUtility::isAllowedAbsPath`, which implicitly invokes + * `GeneralUtility::validPathStr` (like in `parent::isPathValid`). + */ + protected function isAllowedAbsolutePath(string $path): bool + { + return GeneralUtility::isAllowedAbsPath($path); + } +} diff --git a/Classes/Resource/Driver/StreamableDriverInterface.php b/Classes/Resource/Driver/StreamableDriverInterface.php new file mode 100644 index 0000000..687de98 --- /dev/null +++ b/Classes/Resource/Driver/StreamableDriverInterface.php @@ -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\Core\Resource\Driver; + +use Psr\Http\Message\ResponseInterface; + +/** + * An interface FAL drivers have to implement to fulfil the needs + * of streaming files using PSR-7 Response objects. + * + * @internal + */ +interface StreamableDriverInterface +{ + /** + * Streams a file using a PSR-7 Response object. + */ + public function streamFile(string $identifier, array $properties): ResponseInterface; +} diff --git a/Classes/Resource/Enum/DuplicationBehavior.php b/Classes/Resource/Enum/DuplicationBehavior.php new file mode 100644 index 0000000..568e790 --- /dev/null +++ b/Classes/Resource/Enum/DuplicationBehavior.php @@ -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\Core\Resource\Enum; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Enumeration for DuplicationBehavior + */ +enum DuplicationBehavior: string +{ + /** + * If a file is uploaded and another file with + * the same name already exists, the new file + * is renamed. + */ + case RENAME = 'rename'; + + /** + * If a file is uploaded and another file with + * the same name already exists, the old file + * gets overwritten by the new file. + */ + case REPLACE = 'replace'; + + /** + * If a file is uploaded and another file with + * the same name already exists, the process is + * aborted. + */ + case CANCEL = 'cancel'; + + /** + * Return the default duplication behaviour action, set in TSconfig + */ + public static function getDefaultDuplicationBehaviour(?BackendUserAuthentication $backendUserAuthentication = null): DuplicationBehavior + { + if ($backendUserAuthentication === null) { + return self::CANCEL; + } + $defaultAction = $backendUserAuthentication->getTSConfig()['options.']['file_list.']['uploader.']['defaultAction'] ?? ''; + + if ($defaultAction === '') { + return self::CANCEL; + } + + $duplicationBehavior = self::tryFrom($defaultAction); + if ($duplicationBehavior !== null) { + return $duplicationBehavior; + } + + GeneralUtility::makeInstance(LogManager::class) + ->getLogger(__CLASS__) + ->warning('TSConfig: options.file_list.uploader.defaultAction contains an invalid value ("{value}"), fallback to default value: "{default}"', [ + 'value' => $defaultAction, + 'default' => self::CANCEL->value, + ]); + + return self::CANCEL; + } +} diff --git a/Classes/Resource/Event/AfterDefaultUploadFolderWasResolvedEvent.php b/Classes/Resource/Event/AfterDefaultUploadFolderWasResolvedEvent.php new file mode 100644 index 0000000..a7c94b0 --- /dev/null +++ b/Classes/Resource/Event/AfterDefaultUploadFolderWasResolvedEvent.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FolderInterface; + +/** + * Event that is fired after the default upload folder for a user was checked + */ +final class AfterDefaultUploadFolderWasResolvedEvent +{ + public function __construct( + private ?FolderInterface $uploadFolder, + private readonly ?int $pid, + private readonly ?string $table, + private readonly ?string $fieldName + ) {} + + public function getUploadFolder(): ?FolderInterface + { + return $this->uploadFolder; + } + + public function setUploadFolder(FolderInterface $uploadFolder): void + { + $this->uploadFolder = $uploadFolder; + } + + public function getPid(): ?int + { + return $this->pid; + } + + public function getTable(): ?string + { + return $this->table; + } + + public function getFieldName(): ?string + { + return $this->fieldName; + } +} diff --git a/Classes/Resource/Event/AfterFileAddedEvent.php b/Classes/Resource/Event/AfterFileAddedEvent.php new file mode 100644 index 0000000..c902844 --- /dev/null +++ b/Classes/Resource/Event/AfterFileAddedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired after a file was added to the Resource Storage / Driver. + * + * Use case: Using listeners for this event allows to e.g. post-check permissions or + * specific analysis of files like additional metadata analysis after adding them to TYPO3. + */ +final readonly class AfterFileAddedEvent +{ + public function __construct(private FileInterface $file, private Folder $folder) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/AfterFileAddedToIndexEvent.php b/Classes/Resource/Event/AfterFileAddedToIndexEvent.php new file mode 100644 index 0000000..68fa6ee --- /dev/null +++ b/Classes/Resource/Event/AfterFileAddedToIndexEvent.php @@ -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\Core\Resource\Event; + +/** + * This event is fired once an index was just added to the database (= indexed). + * + * Examples: Allows to additionally populate custom fields of the sys_file/sys_file_metadata database records. + */ +final readonly class AfterFileAddedToIndexEvent +{ + public function __construct(private int $fileUid, private array $record) {} + + public function getFileUid(): int + { + return $this->fileUid; + } + + public function getRecord(): array + { + return $this->record; + } +} diff --git a/Classes/Resource/Event/AfterFileCommandProcessedEvent.php b/Classes/Resource/Event/AfterFileCommandProcessedEvent.php new file mode 100644 index 0000000..e1880a9 --- /dev/null +++ b/Classes/Resource/Event/AfterFileCommandProcessedEvent.php @@ -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\Core\Resource\Event; + +/** + * Event that is triggered after a file command has been processed. Can be used + * to perform additional tasks for specific commands. For example, trigger a + * custom indexer after a file has been uploaded. + */ +final readonly class AfterFileCommandProcessedEvent +{ + public function __construct( + private array $command, + private mixed $result, + private string $conflictMode + ) {} + + /** + * A single command, e.g. + * + * ``` + * 'upload' => [ + * 'target' => '1:/some/folder/' + * 'data' => '1' + * ] + * ``` + * + * @return array<string, array<string, mixed>> + */ + public function getCommand(): array + { + return $this->command; + } + + /** + * @return mixed The result - Depending on the performed action, + * this could e.g. be a File or just a boolean. + */ + public function getResult(): mixed + { + return $this->result; + } + + /** + * @return string The current conflict mode + * @see DuplicationBehavior + */ + public function getConflictMode(): string + { + return $this->conflictMode; + } +} diff --git a/Classes/Resource/Event/AfterFileContentsSetEvent.php b/Classes/Resource/Event/AfterFileContentsSetEvent.php new file mode 100644 index 0000000..78f7de4 --- /dev/null +++ b/Classes/Resource/Event/AfterFileContentsSetEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired after the contents of a file got set / replaced. + * + * Examples: Listeners can analyze content for AI purposes within Extensions. + */ +final readonly class AfterFileContentsSetEvent +{ + public function __construct(private FileInterface $file, private string $content) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getContent(): string + { + return $this->content; + } +} diff --git a/Classes/Resource/Event/AfterFileCopiedEvent.php b/Classes/Resource/Event/AfterFileCopiedEvent.php new file mode 100644 index 0000000..0ae8e9a --- /dev/null +++ b/Classes/Resource/Event/AfterFileCopiedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired after a file was copied within a Resource Storage / Driver. + * The folder represents the "target folder". + * + * Example: Listeners can sign up for listing duplicates using this event. + */ +final readonly class AfterFileCopiedEvent +{ + public function __construct( + private FileInterface $file, + private Folder $folder, + private string $newFileIdentifier, + private ?FileInterface $newFile + ) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getNewFileIdentifier(): string + { + return $this->newFileIdentifier; + } + + public function getNewFile(): ?FileInterface + { + return $this->newFile; + } +} diff --git a/Classes/Resource/Event/AfterFileCreatedEvent.php b/Classes/Resource/Event/AfterFileCreatedEvent.php new file mode 100644 index 0000000..3bc42a2 --- /dev/null +++ b/Classes/Resource/Event/AfterFileCreatedEvent.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a file was created within a Resource Storage / Driver. + * The folder represents the "target folder". + * + * Example: This allows to modify a file or check for an appropriate signature after a file was created in TYPO3. + */ +final readonly class AfterFileCreatedEvent +{ + public function __construct(private string $fileName, private Folder $folder) {} + + public function getFileName(): string + { + return $this->fileName; + } + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/AfterFileDeletedEvent.php b/Classes/Resource/Event/AfterFileDeletedEvent.php new file mode 100644 index 0000000..7286f5d --- /dev/null +++ b/Classes/Resource/Event/AfterFileDeletedEvent.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired after a file was deleted. + * + * Example: If an extension provides additional functionality (e.g. variants), this event allows listener to also clean + * up their custom handling. This can also be used for versioning of files. + */ +final readonly class AfterFileDeletedEvent +{ + public function __construct(private FileInterface $file) {} + + public function getFile(): FileInterface + { + return $this->file; + } +} diff --git a/Classes/Resource/Event/AfterFileMarkedAsMissingEvent.php b/Classes/Resource/Event/AfterFileMarkedAsMissingEvent.php new file mode 100644 index 0000000..dfc8db9 --- /dev/null +++ b/Classes/Resource/Event/AfterFileMarkedAsMissingEvent.php @@ -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\Core\Resource\Event; + +/** + * This event is fired once a file was just marked as missing in the database (sys_file). + * + * Example: If a file is marked as missing, listeners can try to recover a file. This can happen on specific setups + * where editors also work via FTP. + */ +final readonly class AfterFileMarkedAsMissingEvent +{ + public function __construct(private int $fileUid) {} + + public function getFileUid(): int + { + return $this->fileUid; + } +} diff --git a/Classes/Resource/Event/AfterFileMetaDataCreatedEvent.php b/Classes/Resource/Event/AfterFileMetaDataCreatedEvent.php new file mode 100644 index 0000000..c2b70c6 --- /dev/null +++ b/Classes/Resource/Event/AfterFileMetaDataCreatedEvent.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +/** + * This event is fired once metadata of a file was added to the database, so it can be + * enriched with more information. + */ +final class AfterFileMetaDataCreatedEvent +{ + public function __construct( + private readonly int $fileUid, + private readonly int $metaDataUid, + private array $record + ) {} + + public function getFileUid(): int + { + return $this->fileUid; + } + + public function getMetaDataUid(): int + { + return $this->metaDataUid; + } + + public function getRecord(): array + { + return $this->record; + } + + public function setRecord(array $record): void + { + $this->record = $record; + } +} diff --git a/Classes/Resource/Event/AfterFileMetaDataDeletedEvent.php b/Classes/Resource/Event/AfterFileMetaDataDeletedEvent.php new file mode 100644 index 0000000..61a189b --- /dev/null +++ b/Classes/Resource/Event/AfterFileMetaDataDeletedEvent.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +/** + * This event is fired once all metadata of a file was removed, in order to manage custom metadata that was + * added previously + */ +final readonly class AfterFileMetaDataDeletedEvent +{ + public function __construct(private int $fileUid) {} + + public function getFileUid(): int + { + return $this->fileUid; + } +} diff --git a/Classes/Resource/Event/AfterFileMetaDataUpdatedEvent.php b/Classes/Resource/Event/AfterFileMetaDataUpdatedEvent.php new file mode 100644 index 0000000..5b585f6 --- /dev/null +++ b/Classes/Resource/Event/AfterFileMetaDataUpdatedEvent.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +/** + * This event is fired once metadata of a file was updated, in order to update custom metadata fields accordingly + */ +final readonly class AfterFileMetaDataUpdatedEvent +{ + public function __construct( + private int $fileUid, + private int $metaDataUid, + private array $record + ) {} + + public function getFileUid(): int + { + return $this->fileUid; + } + + public function getMetaDataUid(): int + { + return $this->metaDataUid; + } + + public function getRecord(): array + { + return $this->record; + } +} diff --git a/Classes/Resource/Event/AfterFileMovedEvent.php b/Classes/Resource/Event/AfterFileMovedEvent.php new file mode 100644 index 0000000..40eb976 --- /dev/null +++ b/Classes/Resource/Event/AfterFileMovedEvent.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\FolderInterface; + +/** + * This event is fired after a file was moved within a Resource Storage / Driver. + * The folder represents the "target folder". + * + * Examples: Use this to update custom third party handlers that rely on specific paths. + */ +final readonly class AfterFileMovedEvent +{ + public function __construct( + private FileInterface $file, + private Folder $folder, + private FolderInterface $originalFolder + ) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getOriginalFolder(): FolderInterface + { + return $this->originalFolder; + } +} diff --git a/Classes/Resource/Event/AfterFileProcessingEvent.php b/Classes/Resource/Event/AfterFileProcessingEvent.php new file mode 100644 index 0000000..80a0d51 --- /dev/null +++ b/Classes/Resource/Event/AfterFileProcessingEvent.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\ProcessedFile; + +/** + * This event is fired after a file object has been processed. + * + * This allows to further customize a file object's processed file. + */ +final class AfterFileProcessingEvent +{ + public function __construct( + private readonly DriverInterface $driver, + private ProcessedFile $processedFile, + private readonly FileInterface $file, + private readonly string $taskType, + private readonly array $configuration + ) {} + + public function getProcessedFile(): ProcessedFile + { + return $this->processedFile; + } + + public function setProcessedFile(ProcessedFile $processedFile): void + { + $this->processedFile = $processedFile; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getTaskType(): string + { + return $this->taskType; + } + + public function getConfiguration(): array + { + return $this->configuration; + } +} diff --git a/Classes/Resource/Event/AfterFileRemovedFromIndexEvent.php b/Classes/Resource/Event/AfterFileRemovedFromIndexEvent.php new file mode 100644 index 0000000..20b437e --- /dev/null +++ b/Classes/Resource/Event/AfterFileRemovedFromIndexEvent.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +/** + * This event is fired once a file was just removed in the database (sys_file). + * + * Example can be to further handle files and manage them separately outside of TYPO3's index. + */ +final readonly class AfterFileRemovedFromIndexEvent +{ + public function __construct(private int $fileUid) {} + + public function getFileUid(): int + { + return $this->fileUid; + } +} diff --git a/Classes/Resource/Event/AfterFileRenamedEvent.php b/Classes/Resource/Event/AfterFileRenamedEvent.php new file mode 100644 index 0000000..76dc8ff --- /dev/null +++ b/Classes/Resource/Event/AfterFileRenamedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired after a file was renamed in order to further process a file or filename + * or update custom references to a file. + */ +final readonly class AfterFileRenamedEvent +{ + public function __construct(private FileInterface $file, private ?string $targetFileName) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getTargetFileName(): ?string + { + return $this->targetFileName; + } +} diff --git a/Classes/Resource/Event/AfterFileReplacedEvent.php b/Classes/Resource/Event/AfterFileReplacedEvent.php new file mode 100644 index 0000000..3da1215 --- /dev/null +++ b/Classes/Resource/Event/AfterFileReplacedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired after a file was replaced. + * + * Example: Further process a file or create variants, or index the contents of a file for AI analysis etc. + */ +final readonly class AfterFileReplacedEvent +{ + public function __construct(private FileInterface $file, private string $localFilePath) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getLocalFilePath(): string + { + return $this->localFilePath; + } +} diff --git a/Classes/Resource/Event/AfterFileUpdatedInIndexEvent.php b/Classes/Resource/Event/AfterFileUpdatedInIndexEvent.php new file mode 100644 index 0000000..87d6f9c --- /dev/null +++ b/Classes/Resource/Event/AfterFileUpdatedInIndexEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\File; + +/** + * This event is fired once an index was just updated inside the database (= indexed). + * Custom listeners can update further index values when a file was updated. + */ +final readonly class AfterFileUpdatedInIndexEvent +{ + public function __construct( + private File $file, + private array $properties, + private array $updatedFields + ) {} + + public function getFile(): File + { + return $this->file; + } + + public function getRelevantProperties(): array + { + return $this->properties; + } + + public function getUpdatedFields(): array + { + return $this->updatedFields; + } +} diff --git a/Classes/Resource/Event/AfterFolderAddedEvent.php b/Classes/Resource/Event/AfterFolderAddedEvent.php new file mode 100644 index 0000000..981129f --- /dev/null +++ b/Classes/Resource/Event/AfterFolderAddedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired after a folder was added to the Resource Storage / Driver. + * + * This allows to customize permissions or set up editor permissions automatically via listeners. + */ +final readonly class AfterFolderAddedEvent +{ + public function __construct(private Folder $folder) {} + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/AfterFolderCopiedEvent.php b/Classes/Resource/Event/AfterFolderCopiedEvent.php new file mode 100644 index 0000000..d5e17d9 --- /dev/null +++ b/Classes/Resource/Event/AfterFolderCopiedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\FolderInterface; + +/** + * This event is fired after a folder was copied to the Resource Storage / Driver. + * + * Example: Custom listeners can analyze contents of a file or add custom permissions to a folder automatically. + */ +final readonly class AfterFolderCopiedEvent +{ + public function __construct( + private Folder $folder, + private Folder $targetParentFolder, + private ?FolderInterface $targetFolder + ) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetParentFolder(): Folder + { + return $this->targetParentFolder; + } + + public function getTargetFolder(): ?FolderInterface + { + return $this->targetFolder; + } +} diff --git a/Classes/Resource/Event/AfterFolderDeletedEvent.php b/Classes/Resource/Event/AfterFolderDeletedEvent.php new file mode 100644 index 0000000..71438c5 --- /dev/null +++ b/Classes/Resource/Event/AfterFolderDeletedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired after a folder was deleted. Custom listeners can then further clean up permissions or + * third-party processed files with this event. + */ +final readonly class AfterFolderDeletedEvent +{ + public function __construct(private Folder $folder, private bool $wasDeleted) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function isDeleted(): bool + { + return $this->wasDeleted; + } +} diff --git a/Classes/Resource/Event/AfterFolderMovedEvent.php b/Classes/Resource/Event/AfterFolderMovedEvent.php new file mode 100644 index 0000000..43bf792 --- /dev/null +++ b/Classes/Resource/Event/AfterFolderMovedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\FolderInterface; + +/** + * This event is fired after a folder was moved within the Resource Storage / Driver. + * + * Custom references can be updated via listeners of this event. + */ +final readonly class AfterFolderMovedEvent +{ + public function __construct( + private Folder $folder, + private Folder $targetParentFolder, + private ?FolderInterface $targetFolder + ) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetParentFolder(): Folder + { + return $this->targetParentFolder; + } + + public function getTargetFolder(): ?FolderInterface + { + return $this->targetFolder; + } +} diff --git a/Classes/Resource/Event/AfterFolderRenamedEvent.php b/Classes/Resource/Event/AfterFolderRenamedEvent.php new file mode 100644 index 0000000..290d414 --- /dev/null +++ b/Classes/Resource/Event/AfterFolderRenamedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired after a folder was renamed. + * + * Examples: Add custom processing of folders or adjust permissions. + */ +final readonly class AfterFolderRenamedEvent +{ + public function __construct( + private Folder $folder, + private Folder $sourceFolder + ) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getSourceFolder(): Folder + { + return $this->sourceFolder; + } +} diff --git a/Classes/Resource/Event/AfterResourceStorageInitializationEvent.php b/Classes/Resource/Event/AfterResourceStorageInitializationEvent.php new file mode 100644 index 0000000..5d46736 --- /dev/null +++ b/Classes/Resource/Event/AfterResourceStorageInitializationEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\ResourceStorage; + +/** + * This event is fired after a resource object was built/created. + * + * Custom handlers can be initialized at this moment for any kind of source as well. + */ +final class AfterResourceStorageInitializationEvent +{ + public function __construct(private ResourceStorage $storage) {} + + public function getStorage(): ResourceStorage + { + return $this->storage; + } + + public function setStorage(ResourceStorage $storage): void + { + $this->storage = $storage; + } +} diff --git a/Classes/Resource/Event/BeforeFileAddedEvent.php b/Classes/Resource/Event/BeforeFileAddedEvent.php new file mode 100644 index 0000000..05868f4 --- /dev/null +++ b/Classes/Resource/Event/BeforeFileAddedEvent.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\ResourceStorage; + +/** + * This event is fired before a file is about to be added to the Resource Storage / Driver. + * + * This allows to do custom checks to a file or restrict access to a file before the file is added. + */ +final class BeforeFileAddedEvent +{ + public function __construct( + private string $fileName, + private readonly string $sourceFilePath, + private readonly Folder $targetFolder, + private readonly ResourceStorage $storage, + private readonly DriverInterface $driver + ) {} + + public function getFileName(): string + { + return $this->fileName; + } + + public function setFileName(string $fileName): void + { + $this->fileName = $fileName; + } + + public function getSourceFilePath(): string + { + return $this->sourceFilePath; + } + + public function getTargetFolder(): Folder + { + return $this->targetFolder; + } + + public function getStorage(): ResourceStorage + { + return $this->storage; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } +} diff --git a/Classes/Resource/Event/BeforeFileContentsSetEvent.php b/Classes/Resource/Event/BeforeFileContentsSetEvent.php new file mode 100644 index 0000000..3d1779f --- /dev/null +++ b/Classes/Resource/Event/BeforeFileContentsSetEvent.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired before the contents of a file gets set / replaced. + * + * This allows to further analyze or modify the content of a file before it is written by the driver. + */ +final class BeforeFileContentsSetEvent +{ + public function __construct(private readonly FileInterface $file, private string $content) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } +} diff --git a/Classes/Resource/Event/BeforeFileCopiedEvent.php b/Classes/Resource/Event/BeforeFileCopiedEvent.php new file mode 100644 index 0000000..7004ac7 --- /dev/null +++ b/Classes/Resource/Event/BeforeFileCopiedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a file is about to be copied within a Resource Storage / Driver. + * The folder represents the "target folder". + * + * This allows to further analyze or modify the file or metadata before it is written by the driver. + */ +final readonly class BeforeFileCopiedEvent +{ + public function __construct(private FileInterface $file, private Folder $folder) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/BeforeFileCreatedEvent.php b/Classes/Resource/Event/BeforeFileCreatedEvent.php new file mode 100644 index 0000000..4c0bb7d --- /dev/null +++ b/Classes/Resource/Event/BeforeFileCreatedEvent.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a file is about to be created within a Resource Storage / Driver. + * The folder represents the "target folder". + * + * This allows to further analyze or modify the file or filename before it is written by the driver. + */ +final readonly class BeforeFileCreatedEvent +{ + public function __construct(private string $fileName, private Folder $folder) {} + + public function getFileName(): string + { + return $this->fileName; + } + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/BeforeFileDeletedEvent.php b/Classes/Resource/Event/BeforeFileDeletedEvent.php new file mode 100644 index 0000000..f880e80 --- /dev/null +++ b/Classes/Resource/Event/BeforeFileDeletedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired before a file is about to be deleted. + * + * Event listeners can clean up third-party references with this event. + */ +final readonly class BeforeFileDeletedEvent +{ + public function __construct(private FileInterface $file) {} + + public function getFile(): FileInterface + { + return $this->file; + } +} diff --git a/Classes/Resource/Event/BeforeFileMovedEvent.php b/Classes/Resource/Event/BeforeFileMovedEvent.php new file mode 100644 index 0000000..9e9696a --- /dev/null +++ b/Classes/Resource/Event/BeforeFileMovedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a file is about to be moved within a Resource Storage / Driver. + * The folder represents the "target folder". + */ +final readonly class BeforeFileMovedEvent +{ + public function __construct( + private FileInterface $file, + private Folder $folder, + private string $targetFileName + ) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetFileName(): string + { + return $this->targetFileName; + } +} diff --git a/Classes/Resource/Event/BeforeFileProcessingEvent.php b/Classes/Resource/Event/BeforeFileProcessingEvent.php new file mode 100644 index 0000000..63ecc69 --- /dev/null +++ b/Classes/Resource/Event/BeforeFileProcessingEvent.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\ProcessedFile; + +/** + * This event is fired before a file object is processed. + * + * Allows to add further information or enrich the file before the processing is kicking in. + */ +final class BeforeFileProcessingEvent +{ + public function __construct( + private readonly DriverInterface $driver, + private ProcessedFile $processedFile, + private readonly FileInterface $file, + private readonly string $taskType, + private readonly array $configuration + ) {} + + public function getProcessedFile(): ProcessedFile + { + return $this->processedFile; + } + + public function setProcessedFile(ProcessedFile $processedFile): void + { + $this->processedFile = $processedFile; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getTaskType(): string + { + return $this->taskType; + } + + public function getConfiguration(): array + { + return $this->configuration; + } +} diff --git a/Classes/Resource/Event/BeforeFileRenamedEvent.php b/Classes/Resource/Event/BeforeFileRenamedEvent.php new file mode 100644 index 0000000..d26ee49 --- /dev/null +++ b/Classes/Resource/Event/BeforeFileRenamedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired before a file is about to be renamed. Custom listeners can further rename the file + * according to specific guidelines based on the project. + */ +final readonly class BeforeFileRenamedEvent +{ + public function __construct(private FileInterface $file, private ?string $targetFileName) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getTargetFileName(): ?string + { + return $this->targetFileName; + } +} diff --git a/Classes/Resource/Event/BeforeFileReplacedEvent.php b/Classes/Resource/Event/BeforeFileReplacedEvent.php new file mode 100644 index 0000000..768faba --- /dev/null +++ b/Classes/Resource/Event/BeforeFileReplacedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * This event is fired before a file is about to be replaced. + * Custom listeners can check for file integrity or analyze the content of the file before it gets added. + */ +final readonly class BeforeFileReplacedEvent +{ + public function __construct(private FileInterface $file, private string $localFilePath) {} + + public function getFile(): FileInterface + { + return $this->file; + } + + public function getLocalFilePath(): string + { + return $this->localFilePath; + } +} diff --git a/Classes/Resource/Event/BeforeFolderAddedEvent.php b/Classes/Resource/Event/BeforeFolderAddedEvent.php new file mode 100644 index 0000000..80b5a50 --- /dev/null +++ b/Classes/Resource/Event/BeforeFolderAddedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a folder is about to be added to the Resource Storage / Driver. + * This allows to further specify folder names according to regulations for a specific project. + */ +final readonly class BeforeFolderAddedEvent +{ + public function __construct(private Folder $parentFolder, private string $folderName) {} + + public function getParentFolder(): Folder + { + return $this->parentFolder; + } + + public function getFolderName(): string + { + return $this->folderName; + } +} diff --git a/Classes/Resource/Event/BeforeFolderCopiedEvent.php b/Classes/Resource/Event/BeforeFolderCopiedEvent.php new file mode 100644 index 0000000..897a902 --- /dev/null +++ b/Classes/Resource/Event/BeforeFolderCopiedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a folder is about to be copied to the Resource Storage / Driver. + * Listeners could add deferred processing / queuing of large folders. + */ +final readonly class BeforeFolderCopiedEvent +{ + public function __construct( + private Folder $folder, + private Folder $targetParentFolder, + private string $targetFolderName + ) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetParentFolder(): Folder + { + return $this->targetParentFolder; + } + + public function getTargetFolderName(): string + { + return $this->targetFolderName; + } +} diff --git a/Classes/Resource/Event/BeforeFolderDeletedEvent.php b/Classes/Resource/Event/BeforeFolderDeletedEvent.php new file mode 100644 index 0000000..b16da69 --- /dev/null +++ b/Classes/Resource/Event/BeforeFolderDeletedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a folder is about to be deleted. + * + * Listeners can use this event to clean up further external references to a folder / files in this folder. + */ +final readonly class BeforeFolderDeletedEvent +{ + public function __construct(private Folder $folder) {} + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/Classes/Resource/Event/BeforeFolderMovedEvent.php b/Classes/Resource/Event/BeforeFolderMovedEvent.php new file mode 100644 index 0000000..c148885 --- /dev/null +++ b/Classes/Resource/Event/BeforeFolderMovedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a folder is about to be moved to the Resource Storage / Driver. + * Listeners can be used to modify a folder name before it is actually moved or to ensure consistency + * or specific rules when moving folders. + */ +final readonly class BeforeFolderMovedEvent +{ + public function __construct( + private Folder $folder, + private Folder $targetParentFolder, + private string $targetFolderName + ) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetParentFolder(): Folder + { + return $this->targetParentFolder; + } + + public function getTargetFolderName(): string + { + return $this->targetFolderName; + } +} diff --git a/Classes/Resource/Event/BeforeFolderRenamedEvent.php b/Classes/Resource/Event/BeforeFolderRenamedEvent.php new file mode 100644 index 0000000..27040e3 --- /dev/null +++ b/Classes/Resource/Event/BeforeFolderRenamedEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * This event is fired before a folder is about to be renamed. + * Listeners can be used to modify a folder name before it is actually moved or to ensure consistency + * or specific rules when renaming folders. + */ +final readonly class BeforeFolderRenamedEvent +{ + public function __construct(private Folder $folder, private string $targetName) {} + + public function getFolder(): Folder + { + return $this->folder; + } + + public function getTargetName(): string + { + return $this->targetName; + } +} diff --git a/Classes/Resource/Event/BeforeResourceStorageInitializationEvent.php b/Classes/Resource/Event/BeforeResourceStorageInitializationEvent.php new file mode 100644 index 0000000..843a641 --- /dev/null +++ b/Classes/Resource/Event/BeforeResourceStorageInitializationEvent.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +/** + * This event is fired before a resource object is actually built/created. + * + * Example: A database record can be enriched to add dynamic values to each resource (file/folder) before + * creation of a storage + */ +final class BeforeResourceStorageInitializationEvent +{ + public function __construct(private $storageUid, private $record, private ?string $fileIdentifier) {} + + public function getStorageUid(): int + { + return $this->storageUid; + } + + public function setStorageUid(int $storageUid): void + { + $this->storageUid = $storageUid; + } + + public function getRecord(): array + { + return $this->record; + } + + public function setRecord(array $record): void + { + $this->record = $record; + } + + public function getFileIdentifier(): ?string + { + return $this->fileIdentifier; + } + + public function setFileIdentifier(?string $fileIdentifier): void + { + $this->fileIdentifier = $fileIdentifier; + } +} diff --git a/Classes/Resource/Event/EnrichFileMetaDataEvent.php b/Classes/Resource/Event/EnrichFileMetaDataEvent.php new file mode 100644 index 0000000..4a0e507 --- /dev/null +++ b/Classes/Resource/Event/EnrichFileMetaDataEvent.php @@ -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\Core\Resource\Event; + +/** + * Event that is called after a record has been loaded from database + * Allows other places to do extension of metadata at runtime or + * for example translation and workspace overlay. + */ +final class EnrichFileMetaDataEvent +{ + public function __construct(private readonly int $fileUid, private readonly int $metaDataUid, private array $record) {} + + public function getFileUid(): int + { + return $this->fileUid; + } + + public function getMetaDataUid(): int + { + return $this->metaDataUid; + } + + public function getRecord(): array + { + return $this->record; + } + + public function setRecord(array $record): void + { + $this->record = $record; + } +} diff --git a/Classes/Resource/Event/GeneratePublicUrlForResourceEvent.php b/Classes/Resource/Event/GeneratePublicUrlForResourceEvent.php new file mode 100644 index 0000000..1368f0c --- /dev/null +++ b/Classes/Resource/Event/GeneratePublicUrlForResourceEvent.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\ResourceInterface; +use TYPO3\CMS\Core\Resource\ResourceStorage; + +/** + * This event is fired before TYPO3 FAL's native URL generation for a Resource is instantiated. + * + * This allows for listeners to create custom links to certain files (e.g. restrictions) for creating + * authorized deeplinks. + */ +final class GeneratePublicUrlForResourceEvent +{ + private ?string $publicUrl = null; + + public function __construct( + private readonly ResourceInterface $resource, + private readonly ResourceStorage $storage, + private readonly DriverInterface $driver + ) {} + + public function getResource(): ResourceInterface + { + return $this->resource; + } + + public function getStorage(): ResourceStorage + { + return $this->storage; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } + + public function getPublicUrl(): ?string + { + return $this->publicUrl; + } + + public function setPublicUrl(?string $publicUrl): void + { + $this->publicUrl = $publicUrl; + } +} diff --git a/Classes/Resource/Event/ModifyFileDumpEvent.php b/Classes/Resource/Event/ModifyFileDumpEvent.php new file mode 100644 index 0000000..ad6740d --- /dev/null +++ b/Classes/Resource/Event/ModifyFileDumpEvent.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Event; + +use Psr\EventDispatcher\StoppableEventInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Resource\ResourceInterface; + +/** + * Event that is triggered when a file should be dumped to the browser, allowing to perform custom + * security/access checks when accessing a file through a direct link, and returning an alternative + * Response. + * + * It is also possible to replace the file during this event, but not setting a response. + * + * As soon as a custom Response is added, the propagation is stopped. + */ +final class ModifyFileDumpEvent implements StoppableEventInterface +{ + private ?ResponseInterface $response = null; + + public function __construct(private ResourceInterface $file, private ServerRequestInterface $request) {} + + public function getFile(): ResourceInterface + { + return $this->file; + } + + public function setFile(ResourceInterface $file): void + { + $this->file = $file; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function isPropagationStopped(): bool + { + return $this->response !== null; + } +} diff --git a/Classes/Resource/Event/SanitizeFileNameEvent.php b/Classes/Resource/Event/SanitizeFileNameEvent.php new file mode 100644 index 0000000..7d81593 --- /dev/null +++ b/Classes/Resource/Event/SanitizeFileNameEvent.php @@ -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\Core\Resource\Event; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\ResourceStorage; + +/** + * This event is fired after a file name has been sanitized and before a file is added to FAL. Listeners can use this + * event to modify the file name, and name the file according to naming conventions of a specific project. + */ +final class SanitizeFileNameEvent +{ + public function __construct( + private string $fileName, + private readonly string $originalFileName, + private readonly Folder $targetFolder, + private readonly ResourceStorage $storage, + private readonly DriverInterface $driver + ) {} + + public function getFileName(): string + { + return $this->fileName; + } + + public function getOriginalFileName(): string + { + return $this->originalFileName; + } + + public function setFileName(string $fileName): void + { + $this->fileName = $fileName; + } + + public function getTargetFolder(): Folder + { + return $this->targetFolder; + } + + public function getStorage(): ResourceStorage + { + return $this->storage; + } + + public function getDriver(): DriverInterface + { + return $this->driver; + } +} diff --git a/Classes/Resource/Exception.php b/Classes/Resource/Exception.php new file mode 100644 index 0000000..406404b --- /dev/null +++ b/Classes/Resource/Exception.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +/** + * An exception when something is wrong with the file handling + */ +class Exception extends \TYPO3\CMS\Core\Exception {} diff --git a/Classes/Resource/Exception/AbstractFileOperationException.php b/Classes/Resource/Exception/AbstractFileOperationException.php new file mode 100644 index 0000000..d06773d --- /dev/null +++ b/Classes/Resource/Exception/AbstractFileOperationException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +abstract class AbstractFileOperationException extends Exception {} diff --git a/Classes/Resource/Exception/ExistingTargetFileNameException.php b/Classes/Resource/Exception/ExistingTargetFileNameException.php new file mode 100644 index 0000000..a753943 --- /dev/null +++ b/Classes/Resource/Exception/ExistingTargetFileNameException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class ExistingTargetFileNameException extends Exception {} diff --git a/Classes/Resource/Exception/ExistingTargetFolderException.php b/Classes/Resource/Exception/ExistingTargetFolderException.php new file mode 100644 index 0000000..a8053a5 --- /dev/null +++ b/Classes/Resource/Exception/ExistingTargetFolderException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class ExistingTargetFolderException extends Exception {} diff --git a/Classes/Resource/Exception/FileAlreadyProcessedException.php b/Classes/Resource/Exception/FileAlreadyProcessedException.php new file mode 100644 index 0000000..e6861e8 --- /dev/null +++ b/Classes/Resource/Exception/FileAlreadyProcessedException.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; +use TYPO3\CMS\Core\Resource\ProcessedFile; + +/** + * Exception indicating that a file is already processed + * + * @internal + */ +class FileAlreadyProcessedException extends Exception +{ + /** + * @var ProcessedFile + */ + private $processedFile; + + public function __construct(ProcessedFile $processedFile, int $code = 0) + { + $this->processedFile = $processedFile; + parent::__construct(sprintf('File "%s" has already been processed', $processedFile->getIdentifier()), $code); + } + + public function getProcessedFile(): ProcessedFile + { + return $this->processedFile; + } +} diff --git a/Classes/Resource/Exception/FileDoesNotExistException.php b/Classes/Resource/Exception/FileDoesNotExistException.php new file mode 100644 index 0000000..058a266 --- /dev/null +++ b/Classes/Resource/Exception/FileDoesNotExistException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception to throw if a file does not exist + */ +class FileDoesNotExistException extends ResourceDoesNotExistException {} diff --git a/Classes/Resource/Exception/FileOperationErrorException.php b/Classes/Resource/Exception/FileOperationErrorException.php new file mode 100644 index 0000000..1777ca8 --- /dev/null +++ b/Classes/Resource/Exception/FileOperationErrorException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class FileOperationErrorException extends AbstractFileOperationException {} diff --git a/Classes/Resource/Exception/FolderDoesNotExistException.php b/Classes/Resource/Exception/FolderDoesNotExistException.php new file mode 100644 index 0000000..7702b9f --- /dev/null +++ b/Classes/Resource/Exception/FolderDoesNotExistException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception to throw if a folder does not exist + */ +class FolderDoesNotExistException extends ResourceDoesNotExistException {} diff --git a/Classes/Resource/Exception/IllegalFileExtensionException.php b/Classes/Resource/Exception/IllegalFileExtensionException.php new file mode 100644 index 0000000..cb54901 --- /dev/null +++ b/Classes/Resource/Exception/IllegalFileExtensionException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class IllegalFileExtensionException extends Exception {} diff --git a/Classes/Resource/Exception/InsufficientFileAccessPermissionsException.php b/Classes/Resource/Exception/InsufficientFileAccessPermissionsException.php new file mode 100644 index 0000000..e034ae8 --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFileAccessPermissionsException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFileAccessPermissionsException extends Exception {} diff --git a/Classes/Resource/Exception/InsufficientFileReadPermissionsException.php b/Classes/Resource/Exception/InsufficientFileReadPermissionsException.php new file mode 100644 index 0000000..fb0dc5f --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFileReadPermissionsException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFileReadPermissionsException extends InsufficientFileAccessPermissionsException {} diff --git a/Classes/Resource/Exception/InsufficientFileWritePermissionsException.php b/Classes/Resource/Exception/InsufficientFileWritePermissionsException.php new file mode 100644 index 0000000..34c8185 --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFileWritePermissionsException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFileWritePermissionsException extends InsufficientFileAccessPermissionsException {} diff --git a/Classes/Resource/Exception/InsufficientFolderAccessPermissionsException.php b/Classes/Resource/Exception/InsufficientFolderAccessPermissionsException.php new file mode 100644 index 0000000..47a2bf0 --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFolderAccessPermissionsException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFolderAccessPermissionsException extends Exception {} diff --git a/Classes/Resource/Exception/InsufficientFolderReadPermissionsException.php b/Classes/Resource/Exception/InsufficientFolderReadPermissionsException.php new file mode 100644 index 0000000..11dcf2c --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFolderReadPermissionsException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFolderReadPermissionsException extends InsufficientFolderAccessPermissionsException {} diff --git a/Classes/Resource/Exception/InsufficientFolderWritePermissionsException.php b/Classes/Resource/Exception/InsufficientFolderWritePermissionsException.php new file mode 100644 index 0000000..4f709f6 --- /dev/null +++ b/Classes/Resource/Exception/InsufficientFolderWritePermissionsException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientFolderWritePermissionsException extends InsufficientFolderAccessPermissionsException {} diff --git a/Classes/Resource/Exception/InsufficientUserPermissionsException.php b/Classes/Resource/Exception/InsufficientUserPermissionsException.php new file mode 100644 index 0000000..5faf58f --- /dev/null +++ b/Classes/Resource/Exception/InsufficientUserPermissionsException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InsufficientUserPermissionsException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidConfigurationException.php b/Classes/Resource/Exception/InvalidConfigurationException.php new file mode 100644 index 0000000..07f4f1d --- /dev/null +++ b/Classes/Resource/Exception/InvalidConfigurationException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the configuration + */ +class InvalidConfigurationException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidFileException.php b/Classes/Resource/Exception/InvalidFileException.php new file mode 100644 index 0000000..9433c83 --- /dev/null +++ b/Classes/Resource/Exception/InvalidFileException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the File + */ +class InvalidFileException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidFileNameException.php b/Classes/Resource/Exception/InvalidFileNameException.php new file mode 100644 index 0000000..79b9ff2 --- /dev/null +++ b/Classes/Resource/Exception/InvalidFileNameException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the File name + */ +class InvalidFileNameException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidFolderException.php b/Classes/Resource/Exception/InvalidFolderException.php new file mode 100644 index 0000000..a6051eb --- /dev/null +++ b/Classes/Resource/Exception/InvalidFolderException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the Folder + */ +class InvalidFolderException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidHashException.php b/Classes/Resource/Exception/InvalidHashException.php new file mode 100644 index 0000000..1d57be7 --- /dev/null +++ b/Classes/Resource/Exception/InvalidHashException.php @@ -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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the Hash + * Is thrown for example when the driver returns an unexpected (non-string) hash value + */ +class InvalidHashException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidPathException.php b/Classes/Resource/Exception/InvalidPathException.php new file mode 100644 index 0000000..0efa7e5 --- /dev/null +++ b/Classes/Resource/Exception/InvalidPathException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the path + */ +class InvalidPathException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidTargetFolderException.php b/Classes/Resource/Exception/InvalidTargetFolderException.php new file mode 100644 index 0000000..0cf9074 --- /dev/null +++ b/Classes/Resource/Exception/InvalidTargetFolderException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the file handling + */ +class InvalidTargetFolderException extends Exception {} diff --git a/Classes/Resource/Exception/InvalidUidException.php b/Classes/Resource/Exception/InvalidUidException.php new file mode 100644 index 0000000..cdad06f --- /dev/null +++ b/Classes/Resource/Exception/InvalidUidException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * Thrown if an invalid uid is handled. + */ +class InvalidUidException extends Exception {} diff --git a/Classes/Resource/Exception/NotInMountPointException.php b/Classes/Resource/Exception/NotInMountPointException.php new file mode 100644 index 0000000..dd79918 --- /dev/null +++ b/Classes/Resource/Exception/NotInMountPointException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with the Mount Point + */ +class NotInMountPointException extends Exception {} diff --git a/Classes/Resource/Exception/OnlineMediaAlreadyExistsException.php b/Classes/Resource/Exception/OnlineMediaAlreadyExistsException.php new file mode 100644 index 0000000..e59a3b9 --- /dev/null +++ b/Classes/Resource/Exception/OnlineMediaAlreadyExistsException.php @@ -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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; +use TYPO3\CMS\Core\Resource\File; + +/** + * Exception indicating that an online media asset is already present in the target folder + */ +class OnlineMediaAlreadyExistsException extends Exception +{ + public function __construct( + private readonly File $onlineMedia, + int $code = 0 + ) { + parent::__construct( + sprintf('Online media asset "%s" does already exist in the target folder.', $onlineMedia->getName()), + $code + ); + } + + public function getOnlineMedia(): File + { + return $this->onlineMedia; + } +} diff --git a/Classes/Resource/Exception/ResourceDoesNotExistException.php b/Classes/Resource/Exception/ResourceDoesNotExistException.php new file mode 100644 index 0000000..d60403f --- /dev/null +++ b/Classes/Resource/Exception/ResourceDoesNotExistException.php @@ -0,0 +1,23 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception to throw if a resource (file/folder) does not exist + */ +class ResourceDoesNotExistException extends Exception {} diff --git a/Classes/Resource/Exception/ResourcePermissionsUnavailableException.php b/Classes/Resource/Exception/ResourcePermissionsUnavailableException.php new file mode 100644 index 0000000..45fe774 --- /dev/null +++ b/Classes/Resource/Exception/ResourcePermissionsUnavailableException.php @@ -0,0 +1,25 @@ +<?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\Core\Resource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when something is wrong with fetching the permissions for a file or a folder. + * + * Extending \RuntimeException for backwards compatibility. + */ +class ResourcePermissionsUnavailableException extends Exception {} diff --git a/Classes/Resource/Exception/UploadException.php b/Classes/Resource/Exception/UploadException.php new file mode 100644 index 0000000..7e35f4d --- /dev/null +++ b/Classes/Resource/Exception/UploadException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when the upload goes wrong + */ +class UploadException extends AbstractFileOperationException {} diff --git a/Classes/Resource/Exception/UploadSizeException.php b/Classes/Resource/Exception/UploadSizeException.php new file mode 100644 index 0000000..4164527 --- /dev/null +++ b/Classes/Resource/Exception/UploadSizeException.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Exception; + +/** + * An exception when the size of the uploaded file has exceeded + */ +class UploadSizeException extends AbstractFileOperationException {} diff --git a/Classes/Resource/File.php b/Classes/Resource/File.php new file mode 100644 index 0000000..2bebc45 --- /dev/null +++ b/Classes/Resource/File.php @@ -0,0 +1,405 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\SystemResource\Identifier\FalResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourceUriGeneratorInterface; +use TYPO3\CMS\Core\SystemResource\Type\PublicResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * File representation in the file abstraction layer. + */ +class File extends AbstractFile implements PublicResourceInterface, SystemResourceInterface +{ + /** + * Contains the names of all properties that have been update since the + * instantiation of this object + */ + protected array $updatedProperties = []; + private ?MetaDataAspect $metaDataAspect = null; + + protected string $identifier; + + /** + * Constructor for a file object. Should normally not be used directly, use + * the corresponding factory methods instead. + */ + public function __construct(array $fileData, ResourceStorage $storage, array $metaData = []) + { + $this->identifier = $fileData['identifier'] ?? ''; + $this->name = $fileData['name'] ?? ''; + $this->properties = $fileData; + $this->storage = $storage; + + if ($metaData !== []) { + $this->getMetaData()->add($metaData); + } + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /******************************* + * VARIOUS FILE PROPERTY GETTERS + *******************************/ + /** + * Returns a property value + * + * @param non-empty-string $key + */ + public function getProperty(string $key): mixed + { + if (parent::hasProperty($key)) { + return parent::getProperty($key); + } + return $this->getMetaData()[$key]; + } + + /** + * Renames this file. + * + * @param non-empty-string $newName The new file name + */ + public function rename(string $newName, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821482); + } + + return $this->getStorage()->renameFile($this, $newName, $conflictMode); + } + + /** + * Copies this file into a target folder + * @param Folder $targetFolder Folder to copy file into. + * @param string|null $targetFileName an optional destination fileName + * + * @return self The new (copied) file. + */ + public function copyTo(Folder $targetFolder, ?string $targetFileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821483); + } + + return $targetFolder->getStorage()->copyFile($this, $targetFolder, $targetFileName, $conflictMode); + } + + /** + * Moves the file into the target folder + * + * @param Folder $targetFolder Folder to move file into. + * @param string|null $targetFileName an optional destination fileName + * @param DuplicationBehavior $conflictMode + * + * @return FileInterface This file object, with updated properties. + * @throws \RuntimeException + */ + public function moveTo(Folder $targetFolder, ?string $targetFileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + if ($this->deleted) { + throw new \RuntimeException('File has been deleted.', 1329821484); + } + + return $targetFolder->getStorage()->moveFile($this, $targetFolder, $targetFileName, $conflictMode); + } + + /** + * Checks if the file has a (metadata) property which + * can be retrieved by "getProperty" + */ + public function hasProperty(string $key): bool + { + if (!parent::hasProperty($key)) { + return isset($this->getMetaData()[$key]); + } + return true; + } + + /** + * Returns the properties of this object. + */ + public function getProperties(): array + { + return array_merge( + parent::getProperties(), + array_diff_key($this->getMetaData()->get(), parent::getProperties()), + [ + 'metadata_uid' => $this->getMetaData()->get()['uid'] ?? 0, + ] + ); + } + + /****************** + * CONTENTS RELATED + ******************/ + /** + * Get the contents of this file + */ + public function getContents(): string + { + return $this->getStorage()->getFileContents($this); + } + + /** + * Gets SHA1 hash. + * + * @return non-empty-string + */ + public function getSha1(): string + { + if (empty($this->properties['sha1'])) { + $this->properties['sha1'] = parent::getSha1(); + } + return $this->properties['sha1']; + } + + /** + * Replace the current file contents with the given string + * + * @return $this + */ + public function setContents(string $contents): self + { + $this->getStorage()->setFileContents($this, $contents); + return $this; + } + + /*********************** + * INDEX RELATED METHODS + ***********************/ + /** + * Returns TRUE if this file is indexed + */ + public function isIndexed(): bool + { + return true; + } + + /** + * Updates the properties of this file, e.g. after re-indexing or moving it. + * By default, only properties that exist as a key in the $properties array + * are overwritten. If you want to explicitly unset a property, set the + * corresponding key to NULL in the array. + * + * NOTE: This method should not be called from outside the File Abstraction Layer (FAL)! + * + * @internal + */ + public function updateProperties(array $properties): void + { + // Setting identifier and name to update values; we have to do this + // here because we might need a new identifier when loading + // (and thus possibly indexing) a file. + if (isset($properties['identifier'])) { + $this->identifier = $properties['identifier']; + } + if (isset($properties['name'])) { + $this->name = $properties['name']; + } + + if (isset($properties['uid']) && $this->properties['uid'] != 0) { + unset($properties['uid']); + } + foreach ($properties as $key => $value) { + if (!isset($this->properties[$key]) || $this->properties[$key] !== $value) { + if (!in_array($key, $this->updatedProperties)) { + $this->updatedProperties[] = $key; + } + $this->properties[$key] = $value; + } + } + // If the mime_type property should be updated and it was changed also update the type. + if (array_key_exists('mime_type', $properties) && in_array('mime_type', $this->updatedProperties)) { + $this->updatedProperties[] = 'type'; + unset($this->properties['type']); + $this->getType(); + } + if (array_key_exists('storage', $properties) && in_array('storage', $this->updatedProperties)) { + $this->storage = GeneralUtility::makeInstance(StorageRepository::class)->findByUid((int)$properties['storage']); + } + } + + /** + * Returns the names of all properties that have been updated in this record + */ + public function getUpdatedProperties(): array + { + return $this->updatedProperties; + } + + /**************************************** + * STORAGE AND MANAGEMENT RELATED METHODS + ****************************************/ + /** + * Check if a file operation (= action) is allowed for this file + * + * @param string $action can be read, write, delete + */ + public function checkActionPermission(string $action): bool + { + return $this->getStorage()->checkFileActionPermission($action, $this); + } + + /***************** + * SPECIAL METHODS + *****************/ + /** + * Creates a MD5 hash checksum based on the combined identifier of the file, + * the files' mimetype and the systems' encryption key. + * used to generate a thumbnail, and this hash is checked if valid + * + * @return string the MD5 hash + */ + public function calculateChecksum(): string + { + return md5( + $this->getCombinedIdentifier() . '|' + . $this->getMimeType() . '|' + . $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] + ); + } + + /** + * Returns a modified version of the file. + * + * @param string $taskType The task type of this processing + * @param array $configuration the processing configuration, see manual for that + */ + public function process(string $taskType, array $configuration): ProcessedFile + { + return $this->getStorage()->processFile($this, $taskType, $configuration); + } + + /** + * Returns an array representation of the file. + * (This is used by the generic listing module vidi when displaying file records.) + * + * @return array<non-empty-string, mixed> Array of main data of the file. Don't rely on all data to be present here, it's just a selection of the most relevant information. + */ + public function toArray(): array + { + $array = [ + 'id' => $this->getCombinedIdentifier(), + 'name' => $this->getName(), + 'extension' => $this->getExtension(), + 'type' => $this->getType(), + 'mimetype' => $this->getMimeType(), + 'size' => $this->getSize(), + 'url' => $this->getPublicUrl(), + 'indexed' => true, + 'uid' => $this->getUid(), + 'permissions' => [ + 'read' => $this->checkActionPermission('read'), + 'write' => $this->checkActionPermission('write'), + 'delete' => $this->checkActionPermission('delete'), + ], + 'checksum' => $this->calculateChecksum(), + ]; + foreach ($this->properties as $key => $value) { + $array[$key] = $value; + } + $stat = $this->getStorage()->getFileInfo($this); + foreach ($stat as $key => $value) { + $array[$key] = $value; + } + return $array; + } + + public function isMissing(): bool + { + return (bool)$this->getProperty('missing'); + } + + public function setMissing(bool $missing): void + { + $this->updateProperties(['missing' => $missing ? 1 : 0]); + } + + /** + * Returns a publicly accessible URL for this file + * When file is marked as missing or deleted no url is returned + * + * WARNING: Access to the file may be restricted by further means, e.g. some + * web-based authentication. You have to take care of this yourself. + */ + public function getPublicUrl(): ?string + { + if ($this->isMissing() || $this->deleted) { + return null; + } + return $this->getStorage()->getPublicUrl($this); + } + + /** + * @internal Only for use in Repositories and indexer + */ + public function _getPropertyRaw(string $key): mixed + { + return parent::getProperty($key); + } + + /** + * Loads the metadata of a file in an encapsulated aspect + */ + public function getMetaData(): MetaDataAspect + { + if ($this->metaDataAspect === null) { + $this->metaDataAspect = GeneralUtility::makeInstance(MetaDataAspect::class, $this); + } + return $this->metaDataAspect; + } + + /*********************************** + * System Resources implementation * + ***********************************/ + public function getHash(): string + { + return $this->getSha1(); + } + + public function getPublicUri(SystemResourceUriGeneratorInterface $uriGenerator): UriInterface + { + return $uriGenerator->generateForFile($this); + } + + public function isPublished(): bool + { + return $this->getStorage()->isPublic(); + } + + public function getResourceIdentifier(): string + { + return (string)(new FalResourceIdentifier( + (string)$this->getStorage()->getUid(), + $this->getIdentifier(), + sprintf('File: uid: %d, identifier: %s', $this->getUid(), $this->getIdentifier()), + )); + } + + public function __toString(): string + { + return $this->getResourceIdentifier(); + } +} diff --git a/Classes/Resource/FileCollectionRepository.php b/Classes/Resource/FileCollectionRepository.php new file mode 100644 index 0000000..8054a2f --- /dev/null +++ b/Classes/Resource/FileCollectionRepository.php @@ -0,0 +1,151 @@ +<?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\Core\Resource; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Collection\AbstractRecordCollection; +use TYPO3\CMS\Core\Collection\CollectionInterface; +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\FrontendRestrictionContainer; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Resource\Collection\FileCollectionRegistry; +use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Repository for accessing file collections stored in the database + */ +readonly class FileCollectionRepository +{ + public function __construct( + private ConnectionPool $connectionPool, + private FileCollectionRegistry $fileCollectionRegistry + ) {} + + /** + * Finds a record collection by uid. + * + * @throws Exception\ResourceDoesNotExistException + */ + public function findByUid(int $uid): ?CollectionInterface + { + $object = null; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_collection'); + if ($this->isFrontendRequest()) { + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + } else { + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + } + $data = $queryBuilder->select('*') + ->from('sys_file_collection') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + if (is_array($data)) { + $object = $this->createDomainObject($data); + } + if ($object === null) { + throw new ResourceDoesNotExistException('Could not find row with uid "' . $uid . '" in table "sys_file_collection"', 1314354066); + } + return $object; + } + + /** + * Finds record collection by type. + * + * @return CollectionInterface[]|null + */ + public function findByType(string $type): ?array + { + $expressionBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_collection')->expr(); + return $this->queryMultipleRecords([ + $expressionBuilder->eq('type', $expressionBuilder->literal($type)), + ]); + } + + /** + * Finds all record collections. + * + * @return CollectionInterface[]|null + */ + public function findAll(): ?array + { + return $this->queryMultipleRecords(); + } + + /** + * Queries for multiple records for the given conditions. + * + * @param array $conditions Conditions concatenated with AND for query + * @return CollectionInterface[]|null + */ + protected function queryMultipleRecords(array $conditions = []): ?array + { + $result = null; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_collection'); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder->select('*')->from('sys_file_collection'); + if (!empty($conditions)) { + $queryBuilder->where(...$conditions); + } + $data = $queryBuilder->executeQuery()->fetchAllAssociative(); + if (!empty($data)) { + $result = $this->createMultipleDomainObjects($data); + } + return $result; + } + + /** + * Creates multiple record collection domain objects. + * + * @param array $data Array of multiple database records to be reconstituted + * @return CollectionInterface[] + */ + protected function createMultipleDomainObjects(array $data): array + { + $collections = []; + foreach ($data as $collection) { + $collections[] = $this->createDomainObject($collection); + } + return $collections; + } + + protected function isFrontendRequest(): bool + { + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + return true; + } + return false; + } + + /** + * Creates a record collection domain object. + * + * @param array $record Database record to be reconstituted + */ + protected function createDomainObject(array $record): CollectionInterface + { + /** @var AbstractRecordCollection $className */ + $className = $this->fileCollectionRegistry->getFileCollectionClass($record['type']); + return $className::create($record); + } +} diff --git a/Classes/Resource/FileInterface.php b/Classes/Resource/FileInterface.php new file mode 100644 index 0000000..3b6eb8d --- /dev/null +++ b/Classes/Resource/FileInterface.php @@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +/** + * Interface for a file object. This can be any kind of file object, + * e.g. a processed file (which is not a FAL file), or a file reference object, + * which is a decorator around a "File" object, but of course without any additional + * file on the file system. + */ +interface FileInterface extends ResourceInterface +{ + /******************************* + * VARIOUS FILE PROPERTY GETTERS + *******************************/ + /** + * Returns true if the given key exists for this file. + * + * @param non-empty-string $key + */ + public function hasProperty(string $key): bool; + + /** + * Get the value of the $key property. + * + * @param non-empty-string $key + */ + public function getProperty(string $key): mixed; + + /** + * MUST return the size of the file as unsigned int i.e. 0-max. + * + * In case of errors, e.g. when the file is deleted or not readable, + * this method MAY either throw an Exception or return 0. + * + * @return int<0, max> + */ + public function getSize(): int; + + /** + * Returns the Sha1 of this file + * + * @return non-empty-string + */ + public function getSha1(): string; + + /** + * Returns the basename (the name without extension) of this file. + */ + public function getNameWithoutExtension(): string; + + /** + * Get the file extension + */ + public function getExtension(): string; + + /** + * Get the MIME type of this file + * + * @return non-empty-string mime type + */ + public function getMimeType(): string; + + /** + * Returns the modification time of the file as Unix timestamp + */ + public function getModificationTime(): int; + + /** + * Returns the creation time of the file as Unix timestamp + */ + public function getCreationTime(): int; + + /****************** + * CONTENTS RELATED + ******************/ + /** + * Get the contents of this file + */ + public function getContents(): string; + + /** + * Replace the current file contents with the given string. + * + * @todo: Consider to remove this function from the interface, as its + * implementation in FileInUse could cause unforseen side-effects by setting + * contents on the original file instead of just on the Usage of the file. + * @todo: At the same time, it could be considered whether to make the whole + * interface a read-only FileInterface, so that all file management and + * modification functions are removed... + * @return $this + */ + public function setContents(string $contents): self; + + /**************************************** + * STORAGE AND MANAGEMENT RELATED METHODS + ****************************************/ + /** + * Deletes this file from its storage. This also means that this object becomes useless. + */ + public function delete(): bool; + + /***************** + * SPECIAL METHODS + *****************/ + /** + * Returns a publicly accessible URL for this file + * + * WARNING: Access to the file may be restricted by further means, e.g. + * some web-based authentication. You have to take care of this yourself. + * + * @return non-empty-string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(): ?string; + + /** + * Returns TRUE if this file is indexed + */ + public function isIndexed(): bool; + + /** + * Returns a path to a local version of this file to process it locally (e.g. with some system tool). + * If the file is normally located on a remote storages, this creates a local copy. + * If the file is already on the local system, this only makes a new copy if $writable is set to TRUE. + * + * @param bool $writable Set this to FALSE if you only want to do read operations on the file. + * @return non-empty-string + */ + public function getForLocalProcessing(bool $writable = true): string; + + /** + * Returns an array representation of the file. + * (This is used by the generic listing module vidi when displaying file records.) + * + * @return array<string, mixed> Array of main data of the file. Don't rely on all data to be present here, it's just a selection of the most relevant information. + */ + public function toArray(): array; +} diff --git a/Classes/Resource/FileReference.php b/Classes/Resource/FileReference.php new file mode 100644 index 0000000..4744630 --- /dev/null +++ b/Classes/Resource/FileReference.php @@ -0,0 +1,508 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\ReferenceIndex; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a specific usage of a file with possibilities to override certain + * properties of the original file just for this usage of the file. + * + * It acts as a decorator over the original file in the way that most method calls are + * directly passed along to the original file object. + * + * All file related methods are directly passed along; only meta-data functionality is adopted + * in this decorator class to prioritize possible overrides for the metadata for this specific usage + * of the file. + */ +class FileReference implements FileInterface +{ + /** + * Various properties of the FileReference. Note that these information can be different + * to the ones found in the originalFile. + */ + protected array $propertiesOfFileReference; + + /** + * Reference to the original File object underlying this FileReference. + */ + protected File $originalFile; + + /** + * Properties merged with the parent object (File) if + * the value is not defined (NULL). Thus, FileReference properties act + * as overlays for the defined File properties. + */ + protected array $mergedProperties = []; + + /** + * Constructor for a file in use object. Should normally not be used + * directly, use the corresponding factory methods instead. + * + * @throws \InvalidArgumentException + */ + public function __construct(array $fileReferenceData, ?ResourceFactory $factory = null) + { + $this->propertiesOfFileReference = $fileReferenceData; + if (!$fileReferenceData['uid_local']) { + throw new \InvalidArgumentException('Incorrect reference to original file given for FileReference.', 1300098528); + } + $this->originalFile = $this->getFileObject((int)$fileReferenceData['uid_local'], $factory); + } + + private function getFileObject(int $uidLocal, ?ResourceFactory $factory = null): File + { + if ($factory === null) { + $factory = GeneralUtility::makeInstance(ResourceFactory::class); + } + return $factory->getFileObject($uidLocal); + } + + /******************************* + * VARIOUS FILE PROPERTY GETTERS + *******************************/ + /** + * Returns true if the given key exists for this file. + * + * @param non-empty-string $key The property to be looked up + */ + public function hasProperty(string $key): bool + { + return array_key_exists($key, $this->getProperties()); + } + + /** + * Gets a property, falling back to values of the parent. + * + * @param non-empty-string $key The property to be looked up + * @throws \InvalidArgumentException + */ + public function getProperty(string $key): mixed + { + if (!$this->hasProperty($key)) { + throw new \InvalidArgumentException('Property "' . $key . '" was not found in file reference or original file.', 1314226805); + } + $properties = $this->getProperties(); + return $properties[$key]; + } + + /** + * Gets a property of the file reference. + * + * @param string $key The property to be looked up + * @throws \InvalidArgumentException + */ + public function getReferenceProperty(string $key): mixed + { + if (!array_key_exists($key, $this->propertiesOfFileReference)) { + throw new \InvalidArgumentException('Property "' . $key . '" of file reference was not found.', 1360684914); + } + return $this->propertiesOfFileReference[$key]; + } + + /** + * Gets all properties, falling back to values of the parent. + */ + public function getProperties(): array + { + if (empty($this->mergedProperties)) { + $this->mergedProperties = $this->propertiesOfFileReference; + ArrayUtility::mergeRecursiveWithOverrule( + $this->mergedProperties, + $this->originalFile->getProperties(), + true, + true, + false + ); + array_walk($this->mergedProperties, $this->restoreNonNullValuesCallback(...)); + } + return $this->mergedProperties; + } + + /** + * Callback to handle the NULL value feature + * + * @param mixed $value + * @param mixed $key + */ + protected function restoreNonNullValuesCallback(&$value, $key) + { + if (array_key_exists($key, $this->propertiesOfFileReference) && $this->propertiesOfFileReference[$key] !== null) { + $value = $this->propertiesOfFileReference[$key]; + } + } + + /** + * Gets all properties of the file reference. + */ + public function getReferenceProperties(): array + { + return $this->propertiesOfFileReference; + } + + public function getName(): string + { + return $this->originalFile->getName(); + } + + /** + * Returns the title text to this image + * + * @todo Possibly move this to the image domain object instead + */ + public function getTitle(): string + { + return (string)$this->getProperty('title'); + } + + /** + * Returns the alternative text to this image + * + * @todo Possibly move this to the image domain object instead + */ + public function getAlternative(): string + { + return (string)$this->getProperty('alternative'); + } + + /** + * Returns the description text to this file + * + * @todo Possibly move this to the image domain object instead + */ + public function getDescription(): string + { + return (string)$this->getProperty('description'); + } + + /** + * Returns the link that should be active when clicking on this image + * + * @todo Move this to the image domain object instead + */ + public function getLink(): string + { + return $this->propertiesOfFileReference['link']; + } + + /** + * Returns the uid of this File In Use + */ + public function getUid(): int + { + return (int)$this->propertiesOfFileReference['uid']; + } + + /** + * @return int<0, max> + */ + public function getSize(): int + { + return $this->originalFile->getSize(); + } + + /** + * Returns the Sha1 of this file + * + * @return non-empty-string + */ + public function getSha1(): string + { + return $this->originalFile->getSha1(); + } + + /** + * Get the file extension of this file + * + * @return string The file extension + */ + public function getExtension(): string + { + return $this->originalFile->getExtension(); + } + + /** + * Returns the basename (the name without extension) of this file. + */ + public function getNameWithoutExtension(): string + { + return $this->originalFile->getNameWithoutExtension(); + } + + /** + * Get the MIME type of this file + * + * @return non-empty-string mime type + */ + public function getMimeType(): string + { + return $this->originalFile->getMimeType(); + } + + /** + * Returns the modification time of the file as Unix timestamp + */ + public function getModificationTime(): int + { + return $this->originalFile->getModificationTime(); + } + + /** + * Returns the creation time of the file as Unix timestamp + */ + public function getCreationTime(): int + { + return $this->originalFile->getCreationTime(); + } + + /** + * Returns the fileType of this file + */ + public function getType(): int + { + return $this->originalFile->getType(); + } + + public function isType(FileType $fileType): bool + { + return $this->getFileType() === $fileType; + } + + public function getFileType(): FileType + { + return $this->originalFile->getFileType(); + } + + /** + * Check if file is marked as missing by indexer + */ + public function isMissing(): bool + { + return (bool)$this->originalFile->getProperty('missing'); + } + + /****************** + * CONTENTS RELATED + ******************/ + /** + * Get the contents of this file + */ + public function getContents(): string + { + return $this->originalFile->getContents(); + } + + /** + * Replace the current file contents with the given string + * + * @param string $contents The contents to write to the file. + * + * @return $this + */ + public function setContents(string $contents): self + { + $this->originalFile->setContents($contents); + return $this; + } + + /**************************************** + * STORAGE AND MANAGEMENT RELATED METHODS + ****************************************/ + /** + * Get the storage the original file is located in + */ + public function getStorage(): ResourceStorage + { + return $this->originalFile->getStorage(); + } + + /** + * Returns the identifier of the underlying original file + * + * @return non-empty-string + */ + public function getIdentifier(): string + { + return $this->originalFile->getIdentifier(); + } + + /** + * Returns a combined identifier of the underlying original file + * + * @return string Combined storage and file identifier, e.g. StorageUID:path/and/fileName.png + */ + public function getCombinedIdentifier(): string + { + return $this->originalFile->getCombinedIdentifier(); + } + + /** + * Deletes only this particular FileReference from the persistence layer (table: sys_file_reference) + * and leaves the original file untouched. + */ + public function delete(): bool + { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('sys_file_reference'); + $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); + if ($schema->hasCapability(TcaSchemaCapability::SoftDelete)) { + $softDeleteFieldName = $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + $affectedRows = $connectionPool->getConnectionForTable('sys_file_reference') + ->update( + 'sys_file_reference', + [ + $softDeleteFieldName => 1, + ], + [ + 'uid' => $this->getUid(), + ] + ); + } else { + $affectedRows = $connectionPool->getConnectionForTable('sys_file_reference') + ->delete( + 'sys_file_reference', + [ + 'uid' => $this->getUid(), + ] + ); + } + + if ($affectedRows === 1) { + $table = $this->propertiesOfFileReference['tablenames']; + $uidForeign = $this->propertiesOfFileReference['uid_foreign']; + $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class); + $referenceIndex->updateRefIndexTable($table, $uidForeign); + $referenceIndex->updateRefIndexTable('sys_file_reference', $this->getUid()); + } + + return $affectedRows === 1; + } + + /** + * Renames the fileName in this particular usage. + * + * @param non-empty-string $newName The new file name + * @param DuplicationBehavior $conflictMode + */ + public function rename(string $newName, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + // @todo Implement this function. This should only rename the + // FileReference (sys_file_reference) record, not the file itself. + throw new \BadMethodCallException('Function not implemented FileReference::rename().', 1333754473); + //return $this->fileRepository->renameUsageRecord($this, $newName); + } + + /***************** + * SPECIAL METHODS + *****************/ + /** + * Returns a publicly accessible URL for this file + * + * WARNING: Access to the file may be restricted by further means, e.g. + * some web-based authentication. You have to take care of this yourself. + * + * @return non-empty-string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(): ?string + { + return $this->originalFile->getPublicUrl(); + } + + /** + * Returns TRUE if this file is indexed. + * This is always true for FileReference objects, as they rely on a + * sys_file_reference record to be present, which in turn can only exist if + * the original file is indexed. + */ + public function isIndexed(): bool + { + return true; + } + + /** + * Returns a path to a local version of this file to process it locally (e.g. with some system tool). + * If the file is normally located on a remote storages, this creates a local copy. + * If the file is already on the local system, this only makes a new copy if $writable is set to TRUE. + * + * @param bool $writable Set this to FALSE if you only want to do read operations on the file. + * @return non-empty-string + */ + public function getForLocalProcessing(bool $writable = true): string + { + return $this->originalFile->getForLocalProcessing($writable); + } + + /** + * Returns an array representation of the file. + * (This is used by the generic listing module vidi when displaying file records.) + * + * @return array<non-empty-string, mixed> Array of main data of the file. Don't rely on all data to be present here, it's just a selection of the most relevant information. + */ + public function toArray(): array + { + return array_merge($this->originalFile->toArray(), $this->propertiesOfFileReference); + } + + /** + * Gets the original file being referenced. + */ + public function getOriginalFile(): File + { + return $this->originalFile; + } + + /** + * @return non-empty-string + */ + public function getHashedIdentifier(): string + { + return $this->getStorage()->hashFileIdentifier($this->getIdentifier()); + } + + public function getParentFolder(): FolderInterface + { + return $this->originalFile->getParentFolder(); + } + + /** + * Avoids exporting original file object which contains + * singleton dependencies that must not be serialized. + * + * @return string[] + */ + public function __sleep(): array + { + $keys = get_object_vars($this); + unset($keys['originalFile'], $keys['mergedProperties']); + return array_keys($keys); + } + + public function __wakeup(): void + { + $factory = GeneralUtility::makeInstance(ResourceFactory::class); + $this->originalFile = $this->getFileObject( + (int)$this->propertiesOfFileReference['uid_local'], + $factory + ); + } +} diff --git a/Classes/Resource/FileRepository.php b/Classes/Resource/FileRepository.php new file mode 100644 index 0000000..aec1ec5 --- /dev/null +++ b/Classes/Resource/FileRepository.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; +use TYPO3\CMS\Core\Database\RelationHandler; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Repository for accessing file objects. + * It also serves as the public API for the indexing part of files in general. + * + * It is however recommended to use the ResourceFactory instead of this class, + * as it is more flexible. + */ +#[Autoconfigure(public: true)] +readonly class FileRepository +{ + public function __construct( + protected ResourceFactory $factory, + protected TcaSchemaFactory $tcaSchemaFactory, + ) {} + + /** + * Finds a File matching the given uid, regardless of the storage. + */ + public function findByUid(int $uid): File + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file'); + if ($this->isFrontend()) { + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + } + $row = $queryBuilder + ->select('*') + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchAssociative(); + if (!is_array($row)) { + throw new \RuntimeException('Could not find row with UID "' . $uid . '" in table "sys_file"', 1314354065); + } + return $this->createDomainObject($row); + } + + /** + * Creates an object managed by this repository. + */ + protected function createDomainObject(array $databaseRow): File + { + return $this->factory->getFileObject((int)$databaseRow['uid'], $databaseRow); + } + + /** + * Find FileReference objects by relation to other records + * + * @param string $tableName Table name of the related record + * @param string $fieldName Field name of the related record + * @param int $uid The UID of the related record (needs to be the localized uid, as translated IRRE elements relate to them) + * @param int|null $workspaceId + * @return FileReference[] An array of file references, empty if no objects found + */ + public function findByRelation(string $tableName, string $fieldName, int $uid, ?int $workspaceId = null): array + { + $itemList = []; + $referenceUids = []; + if ($this->isFrontend()) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('sys_file_reference'); + + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + $res = $queryBuilder + ->select('uid') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq( + 'uid_foreign', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'tablenames', + $queryBuilder->createNamedParameter($tableName) + ), + $queryBuilder->expr()->eq( + 'fieldname', + $queryBuilder->createNamedParameter($fieldName) + ) + ) + ->orderBy('sorting_foreign') + ->executeQuery(); + + while ($row = $res->fetchAssociative()) { + $referenceUids[] = $row['uid']; + } + } else { + $schema = $this->tcaSchemaFactory->get($tableName); + $workspaceId ??= GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'id', 0); + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->setWorkspaceId($workspaceId); + $relationHandler->initializeForField( + $tableName, + $schema->getField($fieldName), + $uid + ); + if (!empty($relationHandler->tableArray['sys_file_reference'])) { + $relationHandler->processDeletePlaceholder(); + $referenceUids = $relationHandler->tableArray['sys_file_reference']; + } + } + if (!empty($referenceUids)) { + foreach ($referenceUids as $referenceUid) { + try { + // Just passing the reference uid, the factory is doing workspace + // overlays automatically depending on the current environment + $itemList[] = $this->factory->getFileReferenceObject($referenceUid); + } catch (ResourceDoesNotExistException) { + // No handling, just omit the invalid reference uid + } + } + $itemList = $this->reapplySorting($itemList); + } + + return $itemList; + } + + /** + * As sorting might have changed due to workspace overlays, PHP does the sorting again. + * + * @param FileReference[] $itemList + * @return FileReference[] + */ + protected function reapplySorting(array $itemList): array + { + uasort( + $itemList, + static function (FileReference $a, FileReference $b) { + $sortA = (int)$a->getReferenceProperty('sorting_foreign'); + $sortB = (int)$b->getReferenceProperty('sorting_foreign'); + + if ($sortA === $sortB) { + return 0; + } + + return ($sortA < $sortB) ? -1 : 1; + } + ); + return $itemList; + } + + /** + * This function can be mocked in unit tests to be able to test frontend behaviour. + */ + protected function isFrontend(): bool + { + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + return true; + } + return false; + } +} diff --git a/Classes/Resource/FileType.php b/Classes/Resource/FileType.php new file mode 100644 index 0000000..219062a --- /dev/null +++ b/Classes/Resource/FileType.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +enum FileType: int +{ + /** + * any other file + */ + case UNKNOWN = 0; + + /** + * Any kind of text + * @see http://www.iana.org/assignments/media-types/text + */ + case TEXT = 1; + + /** + * Any kind of image + * @see http://www.iana.org/assignments/media-types/image + */ + case IMAGE = 2; + + /** + * Any kind of audio file + * @see http://www.iana.org/assignments/media-types/audio + */ + case AUDIO = 3; + + /** + * Any kind of video + * @see http://www.iana.org/assignments/media-types/video + */ + case VIDEO = 4; + + /** + * Any kind of application + * @see http://www.iana.org/assignments/media-types/application + */ + case APPLICATION = 5; + + public static function tryFromMimeType(string $mimeType): self + { + [$fileType] = explode('/', $mimeType); + return match (strtolower($fileType)) { + 'text' => FileType::TEXT, + 'image' => FileType::IMAGE, + 'audio' => FileType::AUDIO, + 'video' => FileType::VIDEO, + 'application', 'software' => FileType::APPLICATION, + default => FileType::UNKNOWN, + }; + } +} diff --git a/Classes/Resource/Filter/FileExtensionFilter.php b/Classes/Resource/Filter/FileExtensionFilter.php new file mode 100644 index 0000000..25d6bf3 --- /dev/null +++ b/Classes/Resource/Filter/FileExtensionFilter.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Filter; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Utility methods for filtering filenames + */ +class FileExtensionFilter +{ + /** + * Allowed file extensions. If NULL, all extensions are allowed. + * + * @var string[]|null + */ + protected ?array $allowedFileExtensions = null; + + /** + * Disallowed file extensions. If NULL, no extension is disallowed (i.e. all are allowed). + * + * @var string[]|null + */ + protected ?array $disallowedFileExtensions = null; + + public function filter(array $references, string $allowedFileExtensions, string $disallowedFileExtensions): array + { + if ($allowedFileExtensions !== '') { + $this->setAllowedFileExtensions($allowedFileExtensions); + } + if ($disallowedFileExtensions !== '') { + $this->setDisallowedFileExtensions($disallowedFileExtensions); + } + $cleanReferences = []; + foreach ($references as $reference) { + if (empty($reference)) { + continue; + } + $parts = GeneralUtility::revExplode('_', (string)$reference, 2); + $fileReferenceUid = (int)$parts[count($parts) - 1]; + try { + $fileReference = GeneralUtility::makeInstance(ResourceFactory::class)->getFileReferenceObject($fileReferenceUid); + $file = $fileReference->getOriginalFile(); + if ($this->isAllowed($file->getExtension())) { + $cleanReferences[] = $reference; + } + } catch (ResourceDoesNotExistException $e) { + // do nothing + } + } + return $cleanReferences; + } + + /** + * Entry method for use as filelist filter. + * + * We use -1 as the "don't include“ return value, for historic reasons, + * as call_user_func() used to return FALSE if calling the method failed. + * + * @param string $itemName + * @param string $itemIdentifier + * @param string $parentIdentifier + * @param array $additionalInformation Additional information about the inspected item + * @param DriverInterface $driver + * @return bool|int -1 if the file should not be included in a listing + */ + public function filterFileList($itemName, $itemIdentifier, $parentIdentifier, array $additionalInformation, DriverInterface $driver) + { + $returnCode = true; + // Early return in case no file filters are set at all + if ($this->allowedFileExtensions === null && $this->disallowedFileExtensions === null) { + return $returnCode; + } + // Check that this is a file and not a folder + if ($driver->fileExists($itemIdentifier)) { + try { + $fileInfo = $driver->getFileInfoByIdentifier($itemIdentifier, ['extension']); + } catch (\InvalidArgumentException $e) { + $fileInfo = []; + } + if (!$this->isAllowed((string)($fileInfo['extension'] ?? ''))) { + $returnCode = -1; + } + } + return $returnCode; + } + + /** + * Checks whether a file is allowed according to the criteria defined in the class variables ($this->allowedFileExtensions etc.) + * + * @internal this is used internally for TYPO3 core only + */ + public function isAllowed(string $fileExtension): bool + { + $fileExtension = strtolower($fileExtension); + $result = true; + // Check allowed file extensions + if (!empty($this->allowedFileExtensions) && !in_array($fileExtension, $this->allowedFileExtensions, true)) { + $result = false; + } + // Check disallowed file extensions + if (!empty($this->disallowedFileExtensions) && in_array($fileExtension, $this->disallowedFileExtensions, true)) { + $result = false; + } + return $result; + } + + /** + * Set allowed file extensions + * + * @param mixed $allowedFileExtensions Comma-separated list or array, of allowed file extensions + */ + public function setAllowedFileExtensions(mixed $allowedFileExtensions): void + { + $this->allowedFileExtensions = $this->convertToLowercaseArray($allowedFileExtensions); + } + + public function getAllowedFileExtensions(): ?array + { + return $this->allowedFileExtensions; + } + + /** + * Set disallowed file extensions + * + * @param mixed $disallowedFileExtensions Comma-separated list or array, of allowed file extensions + */ + public function setDisallowedFileExtensions(mixed $disallowedFileExtensions): void + { + $this->disallowedFileExtensions = $this->convertToLowercaseArray($disallowedFileExtensions); + } + + public function getDisallowedFileExtensions(): ?array + { + return $this->disallowedFileExtensions; + } + + /** + * Compared the current allowed and disallowed lists and returns + * a filtered list either as allow or as disallow list. The "mode" + * is indicated by the array key, which is either "allowedFileExtensions" + * or "disallowedFileExtensions". + */ + public function getFilteredFileExtensions(): array + { + if ($this->disallowedFileExtensions === null) { + return ['allowedFileExtensions' => $this->allowedFileExtensions ?? ['*']]; + } + + if ($this->allowedFileExtensions === null) { + return ['disallowedFileExtensions' => $this->disallowedFileExtensions]; + } + + return ['allowedFileExtensions' => array_filter($this->allowedFileExtensions, function (string $fileExtension): bool { + return !in_array($fileExtension, $this->disallowedFileExtensions, true); + })]; + } + + /** + * Converts mixed (string or array) input arguments into an array, NULL if empty. + * + * All array values will be converted to lower case. + */ + protected function convertToLowercaseArray(mixed $inputArgument): ?array + { + $returnValue = null; + if (is_array($inputArgument)) { + $returnValue = $inputArgument; + } elseif ((string)$inputArgument !== '') { + $returnValue = GeneralUtility::trimExplode(',', $inputArgument); + } + + if (is_array($returnValue)) { + $returnValue = array_map(strtolower(...), $returnValue); + } + + return $returnValue; + } +} diff --git a/Classes/Resource/Filter/FileNameFilter.php b/Classes/Resource/Filter/FileNameFilter.php new file mode 100644 index 0000000..69440f9 --- /dev/null +++ b/Classes/Resource/Filter/FileNameFilter.php @@ -0,0 +1,76 @@ +<?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\Core\Resource\Filter; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; + +/** + * Utility methods for filtering filenames + */ +class FileNameFilter +{ + /** + * whether to also show the hidden files (don't show them by default) + * + * @var bool + */ + protected static $showHiddenFilesAndFolders = false; + + /** + * Filter method that checks if a file/folder name starts with a dot (e.g. .htaccess) + * + * We use -1 as the "don't include“ return value, for historic reasons, + * as call_user_func() used to return FALSE if calling the method failed. + * + * @param string $itemName + * @param string $itemIdentifier + * @param string $parentIdentifier + * @param array $additionalInformation Additional information (driver dependent) about the inspected item + * @param DriverInterface $driverInstance + * @return bool|int -1 if the file should not be included in a listing + */ + public static function filterHiddenFilesAndFolders($itemName, $itemIdentifier, $parentIdentifier, array $additionalInformation, DriverInterface $driverInstance) + { + // Only apply the filter if you want to hide the hidden files + if (self::$showHiddenFilesAndFolders === false && str_contains($itemIdentifier, '/.')) { + return -1; + } + return true; + } + + /** + * Gets the info whether the hidden files are also displayed currently + * + * @static + * @return bool + */ + public static function getShowHiddenFilesAndFolders() + { + return self::$showHiddenFilesAndFolders; + } + + /** + * set the flag to show (or hide) the hidden files + * + * @static + * @param bool $showHiddenFilesAndFolders + * @return bool + */ + public static function setShowHiddenFilesAndFolders($showHiddenFilesAndFolders) + { + return self::$showHiddenFilesAndFolders = (bool)$showHiddenFilesAndFolders; + } +} diff --git a/Classes/Resource/Filter/ImportExportFilter.php b/Classes/Resource/Filter/ImportExportFilter.php new file mode 100644 index 0000000..69f547d --- /dev/null +++ b/Classes/Resource/Filter/ImportExportFilter.php @@ -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\Core\Resource\Filter; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; + +/** + * Utility methods for filtering filenames stored in `importexport` temporary folder. + * Albeit this filter is in the scope of `ext:impexp`, it is located in `ext:core` to + * apply filters on left-over fragments, even when `ext:impexp` is not installed. + * + * @internal + */ +readonly class ImportExportFilter +{ + /** + * Filter method that checks if a directory or a file in such directory belongs to the temp directory of EXT:impexp + * and the user has "export" permissions. + */ + public static function filterImportExportFilesAndFolders(string $itemName, string $itemIdentifier, string $parentIdentifier, array $additionalInformation, DriverInterface $driverInstance) + { + // + `_temp_` is hard-coded in `ImportExport::getDefaultUploadTemporaryFolder()` + // + `importexport` is hard-coded in `ImportExport::createDefaultImportExportFolder()` + $importExportFolderSubPath = '/_temp_/importexport/'; + if (str_ends_with($parentIdentifier, $importExportFolderSubPath) || str_contains($itemIdentifier, $importExportFolderSubPath)) { + $backendUser = self::getBackendUser(); + if ($backendUser === null || !$backendUser->isExportEnabled()) { + return -1; + } + } + + return true; + } + + protected static function getBackendUser(): ?BackendUserAuthentication + { + return $GLOBALS['BE_USER'] ?? null; + } +} diff --git a/Classes/Resource/Folder.php b/Classes/Resource/Folder.php new file mode 100644 index 0000000..61d1928 --- /dev/null +++ b/Classes/Resource/Folder.php @@ -0,0 +1,514 @@ +<?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\Core\Resource; + +use Psr\Http\Message\UploadedFileInterface; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFileNameException; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFolderException; +use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderWritePermissionsException; +use TYPO3\CMS\Core\Resource\Exception\ResourcePermissionsUnavailableException; +use TYPO3\CMS\Core\Resource\Search\FileSearchDemand; +use TYPO3\CMS\Core\Resource\Search\Result\FileSearchResultInterface; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * A folder that groups files in a storage. This may be a folder on the local + * disk, a bucket in Amazon S3 or a user or a tag in Flickr. + * + * This object is not persisted in TYPO3 locally, but created on the fly by + * storage drivers for the folders they "offer". + * + * Some folders serve as a physical container for files (e.g. folders on the + * local disk, S3 buckets or Flickr users). Other folders just group files by a + * certain criterion, e.g. a tag. + * The way this is implemented depends on the storage driver. + */ +class Folder implements FolderInterface +{ + /** + * Modes for filter usage in getFiles()/getFolders() + */ + public const FILTER_MODE_NO_FILTERS = 0; + // Merge local filters into storage's filters + public const FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS = 1; + // Only use the filters provided by the storage + public const FILTER_MODE_USE_STORAGE_FILTERS = 2; + // Only use the filters provided by the current class + public const FILTER_MODE_USE_OWN_FILTERS = 3; + /** + * The storage this folder belongs to. + */ + protected ResourceStorage $storage; + + /** + * The identifier of this folder to identify it on the storage. + * On some drivers, this is the path to the folder, but drivers could also just + * provide any other unique identifier for this folder on the specific storage. + */ + protected string $identifier; + + /** + * The name of this folder + */ + protected string $name; + + /** + * The filters this folder should use for a filelist. + * + * @var callable[] + */ + protected array $fileAndFolderNameFilters = []; + + public function __construct(ResourceStorage $storage, string $identifier, string $name) + { + $this->storage = $storage; + $this->identifier = $identifier; + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + /** + * Returns the full path of this folder, from the root. + * + * @param string|null $rootId ID of the root folder, NULL to auto-detect + */ + public function getReadablePath(?string $rootId = null): string + { + if ($rootId === null) { + // Find first matching file mount and use that as root + foreach ($this->storage->getFileMounts() as $fileMount) { + if ($this->storage->isWithinFolder($fileMount['folder'], $this)) { + $rootId = $fileMount['folder']->getIdentifier(); + break; + } + } + if ($rootId === null) { + $rootId = $this->storage->getRootLevelFolder()->getIdentifier(); + } + } + $readablePath = '/'; + if ($this->identifier !== $rootId) { + try { + $readablePath = $this->getParentFolder()->getReadablePath($rootId); + } catch (InsufficientFolderAccessPermissionsException $e) { + // May have no access to parent folder (e.g. because of mount point) + $readablePath = '/'; + } + } + return $readablePath . ($this->name ? $this->name . '/' : ''); + } + + /** + * Sets a new name of the folder + * currently this does not trigger the "renaming process" + * as the name is more seen as a label + * + * @param string $name The new name + */ + public function setName(string $name): void + { + $this->name = $name; + } + + public function getStorage(): ResourceStorage + { + return $this->storage; + } + + /** + * Returns the path of this folder inside the storage. It depends on the + * type of storage whether this is a real path or just some unique identifier. + * + * @return non-empty-string + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getHashedIdentifier(): string + { + return $this->storage->hashFileIdentifier($this->identifier); + } + + /** + * Returns a combined identifier of this folder, i.e. the storage UID and + * the folder identifier separated by a colon ":". + * + * @return string Combined storage and folder identifier, e.g. StorageUID:folder/path/ + */ + public function getCombinedIdentifier(): string + { + return $this->getStorage()->getUid() . ':' . $this->getIdentifier(); + } + + /** + * Returns a publicly accessible URL for this folder + * + * WARNING: Access to the folder may be restricted by further means, e.g. some + * web-based authentication. You have to take care of this yourself. + * + * @return string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(): ?string + { + return $this->getStorage()->getPublicUrl($this); + } + + /** + * Returns a list of files in this folder, optionally filtered. There are several filter modes available, see the + * FILTER_MODE_* constants for more information. + * + * For performance reasons the returned items can also be limited to a given range + * + * @param int $start The item to start at + * @param int $numberOfItems The number of items to return + * @param int $filterMode The filter mode to use for the filelist. + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return File[] + */ + public function getFiles(int $start = 0, int $numberOfItems = 0, int $filterMode = self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, bool $recursive = false, string $sort = '', bool $sortRev = false): array + { + if ($filterMode === 0) { + $useFilters = false; + $backedUpFilters = []; + } else { + [$backedUpFilters, $useFilters] = $this->prepareFiltersInStorage($filterMode); + } + + $fileObjects = $this->storage->getFilesInFolder($this, $start, $numberOfItems, $useFilters, $recursive, $sort, $sortRev); + + $this->restoreBackedUpFiltersInStorage($backedUpFilters); + + return $fileObjects; + } + + /** + * Returns a file search result based on the given demand. + * The result also includes matches in meta-data fields that are defined in TCA. + * + * @param int $filterMode The filter mode to use for the found files + */ + public function searchFiles(FileSearchDemand $searchDemand, int $filterMode = self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS): FileSearchResultInterface + { + [$backedUpFilters, $useFilters] = $this->prepareFiltersInStorage($filterMode); + $searchResult = $this->storage->searchFiles($searchDemand, $this, $useFilters); + $this->restoreBackedUpFiltersInStorage($backedUpFilters); + + return $searchResult; + } + + /** + * Returns amount of all files within this folder, optionally filtered by + * the given pattern + * + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function getFileCount(array $filterMethods = [], bool $recursive = false): int + { + return $this->storage->countFilesInFolder($this, true, $recursive); + } + + /** + * Returns the object for a subfolder of the current folder if it exists, + * or throws a FolderDoesNotExistException. + * + * @throws FolderDoesNotExistException + */ + public function getSubfolder(string $name): Folder + { + if (!$this->storage->hasFolderInFolder($name, $this)) { + throw new FolderDoesNotExistException('Folder "' . $name . '" does not exist in "' . $this->identifier . '"', 1329836110); + } + return $this->storage->getFolderInFolder($name, $this); + } + + /** + * @param int $start The item to start at + * @param int $numberOfItems The number of items to return + * @param int $filterMode The filter mode to use for the filelist. + * @phpstan-return array<array-key, Folder> + */ + public function getSubfolders(int $start = 0, int $numberOfItems = 0, int $filterMode = self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, bool $recursive = false): array + { + [$backedUpFilters, $useFilters] = $this->prepareFiltersInStorage($filterMode); + $folderObjects = $this->storage->getFoldersInFolder($this, $start, $numberOfItems, $useFilters, $recursive); + $this->restoreBackedUpFiltersInStorage($backedUpFilters); + return $folderObjects; + } + + /** + * Adds a file from the local server disk. If the file already exists and + * overwriting is disabled, + * + * @throws ExistingTargetFileNameException + */ + public function addFile(string $localFilePath, ?string $fileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::CANCEL): File + { + $fileName = $fileName ?: PathUtility::basename($localFilePath); + + return $this->storage->addFile($localFilePath, $this, $fileName, $conflictMode); + } + + /** + * Adds an uploaded file into the Storage. + * + * @param array|UploadedFileInterface $uploadedFileData Information about the uploaded file given by $_FILES['file1'] + * or a PSR-7 UploadedFileInterface object + */ + public function addUploadedFile(array|UploadedFileInterface $uploadedFileData, DuplicationBehavior $conflictMode = DuplicationBehavior::CANCEL): FileInterface + { + return $this->storage->addUploadedFile($uploadedFileData, $this, null, $conflictMode); + } + + /** + * Renames this folder. + */ + public function rename(string $newName): self + { + return $this->storage->renameFolder($this, $newName); + } + + /** + * Deletes this folder from its storage. This also means that this object becomes useless. + */ + public function delete(bool $deleteRecursively = true): bool + { + return $this->storage->deleteFolder($this, $deleteRecursively); + } + + /** + * Creates a new blank file + * + * @param string $fileName + * @return File The new file object + */ + public function createFile(string $fileName): File + { + return $this->storage->createFile($fileName, $this); + } + + /** + * Creates a new folder + * + * @throws ExistingTargetFolderException + * @throws InsufficientFolderWritePermissionsException + */ + public function createFolder(string $folderName): Folder + { + return $this->storage->createFolder($folderName, $this); + } + + /** + * Copies folder to a target folder + * + * @param Folder $targetFolder Target folder to copy to. + * @param string|null $targetFolderName an optional destination fileName + * @param DuplicationBehavior $conflictMode + * @return Folder New (copied) folder object. + */ + public function copyTo(Folder $targetFolder, ?string $targetFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): Folder + { + return $targetFolder->getStorage()->copyFolder($this, $targetFolder, $targetFolderName, $conflictMode); + } + + /** + * Moves folder to a target folder + * + * @param Folder $targetFolder Target folder to move to. + * @param string|null $targetFolderName an optional destination fileName + * @param DuplicationBehavior $conflictMode + * @return Folder New (copied) folder object. + */ + public function moveTo(Folder $targetFolder, ?string $targetFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): Folder + { + return $targetFolder->getStorage()->moveFolder($this, $targetFolder, $targetFolderName, $conflictMode); + } + + /** + * Checks if a file exists in this folder + */ + public function hasFile(string $name): bool + { + return $this->storage->hasFileInFolder($name, $this); + } + + /** + * Fetches a file from a folder, must be a direct descendant of a folder. + */ + public function getFile(string $fileName): ?FileInterface + { + if ($this->storage->hasFileInFolder($fileName, $this)) { + return $this->storage->getFileInFolder($fileName, $this); + } + return null; + } + + /** + * Checks if a folder exists in this folder. + */ + public function hasFolder(string $name): bool + { + return $this->storage->hasFolderInFolder($name, $this); + } + + /** + * Check if a file operation (= action) is allowed on this folder + * + * @param string $action Action that can be read, write or delete + */ + public function checkActionPermission(string $action): bool + { + try { + return $this->getStorage()->checkFolderActionPermission($action, $this); + } catch (ResourcePermissionsUnavailableException $e) { + return false; + } + } + + /** + * Updates the properties of this folder, e.g. after re-indexing or moving it. + * + * NOTE: This method should not be called from outside the File Abstraction Layer (FAL)! + * + * @param array $properties + * @internal + */ + public function updateProperties(array $properties): void + { + // Setting identifier and name to update values + if (isset($properties['identifier'])) { + $this->identifier = $properties['identifier']; + } + if (isset($properties['name'])) { + $this->name = $properties['name']; + } + } + + /** + * Prepares the filters in this folder's storage according to a set filter mode. + * + * @param int $filterMode The filter mode to use; one of the FILTER_MODE_* constants + * @return array The backed up filters as an array (NULL if filters were not backed up) and whether to use filters or not (bool) + */ + protected function prepareFiltersInStorage(int $filterMode): array + { + $backedUpFilters = null; + $useFilters = true; + + switch ($filterMode) { + case self::FILTER_MODE_USE_OWN_FILTERS: + $backedUpFilters = $this->storage->getFileAndFolderNameFilters(); + $this->storage->setFileAndFolderNameFilters($this->fileAndFolderNameFilters); + + break; + + case self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS: + if (!empty($this->fileAndFolderNameFilters)) { + $backedUpFilters = $this->storage->getFileAndFolderNameFilters(); + foreach ($this->fileAndFolderNameFilters as $filter) { + $this->storage->addFileAndFolderNameFilter($filter); + } + } + + break; + + case self::FILTER_MODE_USE_STORAGE_FILTERS: + // nothing to do here + + break; + + case self::FILTER_MODE_NO_FILTERS: + $useFilters = false; + + break; + } + return [$backedUpFilters, $useFilters]; + } + + /** + * Restores the filters of a storage. + * + * @param array|null $backedUpFilters The filters to restore; might be NULL if no filters have been backed up, in + * which case this method does nothing. + * @see prepareFiltersInStorage() + */ + protected function restoreBackedUpFiltersInStorage(?array $backedUpFilters): void + { + if ($backedUpFilters !== null) { + $this->storage->setFileAndFolderNameFilters($backedUpFilters); + } + } + + /** + * Sets the filters to use when listing files. These are only used if the filter mode is one of + * FILTER_MODE_USE_OWN_FILTERS and FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS + */ + public function setFileAndFolderNameFilters(array $filters): void + { + $this->fileAndFolderNameFilters = $filters; + } + + /** + * Returns the role of this folder (if any). See FolderInterface::ROLE_* constants for possible values. + */ + public function getRole(): string + { + return $this->storage->getRole($this); + } + + /** + * Returns the parent folder. + * + * In non-hierarchical storages, that always is the root folder. + * + * The parent folder of the root folder is the root folder. + * + * @throws InsufficientFolderAccessPermissionsException + */ + public function getParentFolder(): Folder + { + return $this->getStorage()->getFolder($this->getStorage()->getFolderIdentifierFromFileIdentifier($this->getIdentifier())); + } + + /** + * Returns the modification time of the file as Unix timestamp + */ + public function getModificationTime(): int + { + return (int)$this->storage->getFolderInfo($this)['mtime']; + } + + /** + * Returns the creation time of the file as Unix timestamp + */ + public function getCreationTime(): int + { + return (int)$this->storage->getFolderInfo($this)['ctime']; + } +} diff --git a/Classes/Resource/FolderInterface.php b/Classes/Resource/FolderInterface.php new file mode 100644 index 0000000..d6daf16 --- /dev/null +++ b/Classes/Resource/FolderInterface.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Resource\Search\FileSearchDemand; +use TYPO3\CMS\Core\Resource\Search\Result\FileSearchResultInterface; + +/** + * Interface for folders + */ +interface FolderInterface extends ResourceInterface +{ + /** + * Roles for folders + */ + public const ROLE_DEFAULT = 'default'; + public const ROLE_RECYCLER = 'recycler'; + public const ROLE_PROCESSING = 'processing'; + public const ROLE_TEMPORARY = 'temporary'; + public const ROLE_USERUPLOAD = 'userupload'; + public const ROLE_MOUNT = 'mount'; + public const ROLE_READONLY_MOUNT = 'readonly-mount'; + public const ROLE_USER_MOUNT = 'user-mount'; + + /** + * @phpstan-return array<array-key, FolderInterface> + */ + public function getSubfolders(): array; + + /** + * Returns the object for a subfolder of the current folder, if it exists. + */ + public function getSubfolder(string $name): FolderInterface; + + /** + * Checks if a folder exists in this folder. + */ + public function hasFolder(string $name): bool; + + /** + * Checks if a file exists in this folder + */ + public function hasFile(string $name): bool; + + /** + * Fetches a file from a folder, must be a direct descendant of a folder. + */ + public function getFile(string $fileName): ?FileInterface; + + /** + * Renames this folder. + */ + public function rename(string $newName): self; + + /** + * Deletes this folder from its storage. This also means that this object becomes useless. + */ + public function delete(): bool; + + /** + * Returns the modification time of the folder as Unix timestamp + */ + public function getModificationTime(): int; + + /** + * Returns the creation time of the folder as Unix timestamp + */ + public function getCreationTime(): int; + + /** + * Returns a string of the path to this folder, relative to the root of the storage + */ + public function getReadablePath(?string $rootId = null): string; + + /** + * Returns a list of files in this folder, based on filters and includes pagination. + */ + public function getFiles(int $start = 0, int $numberOfItems = 0, int $filterMode = Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, bool $recursive = false, string $sort = '', bool $sortRev = false); + + /** + * Returns a list of files in this folder, based on SearchDemand. + */ + public function searchFiles(FileSearchDemand $searchDemand, int $filterMode = Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS): FileSearchResultInterface; + + /** + * Some folders have special roles in TYPO3, see the constants of this interface. + */ + public function getRole(): string; +} diff --git a/Classes/Resource/InaccessibleFolder.php b/Classes/Resource/InaccessibleFolder.php new file mode 100644 index 0000000..6019ca5 --- /dev/null +++ b/Classes/Resource/InaccessibleFolder.php @@ -0,0 +1,201 @@ +<?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\Core\Resource; + +use Psr\Http\Message\UploadedFileInterface; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderReadPermissionsException; + +/** + * A representation for an inaccessible folder. + * + * If a folder has execution rights you can list its contents + * despite the access rights on the subfolders. If a subfolder + * has no rights it has to be shown anyhow, but marked as + * inaccessible. + */ +class InaccessibleFolder extends Folder +{ + /** + * Throws an Exception, + * used to prevent duplicate code in all the methods + * + * @throws Exception\InsufficientFolderReadPermissionsException + */ + protected function throwInaccessibleException(): never + { + throw new InsufficientFolderReadPermissionsException( + 'You are trying to use a method on the inaccessible folder "' . $this->getName() . '".', + 1390290029 + ); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function setName(string $name): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function getPublicUrl(): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function getFiles(int $start = 0, int $numberOfItems = 0, int $filterMode = self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, bool $recursive = false, string $sort = '', bool $sortRev = false): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function getFileCount(array $filterMethods = [], bool $recursive = false): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function getSubfolder(string $name): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function getSubfolders(int $start = 0, int $numberOfItems = 0, int $filterMode = self::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, bool $recursive = false): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function addFile(string $localFilePath, ?string $fileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::CANCEL): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function addUploadedFile(array|UploadedFileInterface $uploadedFileData, DuplicationBehavior $conflictMode = DuplicationBehavior::CANCEL): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function rename(string $newName): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function delete(bool $deleteRecursively = true): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function createFile(string $fileName): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function createFolder(string $folderName): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function copyTo(Folder $targetFolder, ?string $targetFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function moveTo(Folder $targetFolder, ?string $targetFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function hasFile(string $name): never + { + $this->throwInaccessibleException(); + } + + /** + * @throws Exception\InsufficientFolderReadPermissionsException + */ + public function hasFolder(string $name): never + { + $this->throwInaccessibleException(); + } + + /** + * @internal + */ + public function updateProperties(array $properties): never + { + $this->throwInaccessibleException(); + } + + public function setFileAndFolderNameFilters(array $filters): never + { + $this->throwInaccessibleException(); + } + + public function getModificationTime(): int + { + return 0; + } + + public function getReadablePath(?string $rootId = null): string + { + return ''; + } + + public function getCreationTime(): int + { + return 0; + } +} diff --git a/Classes/Resource/Index/ExtractorInterface.php b/Classes/Resource/Index/ExtractorInterface.php new file mode 100644 index 0000000..b30d88d --- /dev/null +++ b/Classes/Resource/Index/ExtractorInterface.php @@ -0,0 +1,86 @@ +<?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\Core\Resource\Index; + +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; +use TYPO3\CMS\Core\Resource; +use TYPO3\CMS\Core\Resource\File; + +/** + * An Interface for MetaData extractors the FAL Indexer uses + */ +#[AutoconfigureTag('metadata.extractor')] +interface ExtractorInterface +{ + /** + * Returns an array of supported file types; + * An empty array indicates all filetypes + * + * @return array + */ + public function getFileTypeRestrictions(); + + /** + * Get all supported DriverClasses + * + * Since some extractors may only work for local files, and other extractors + * are especially made for grabbing data from remote. + * + * Returns array of string with driver names of Drivers which are supported, + * If the driver did not register a name, it's the classname. + * empty array indicates no restrictions + * + * @return array + */ + public function getDriverRestrictions(); + + /** + * Returns the data priority of the extraction Service. + * Defines the precedence of Data if several extractors + * extracted the same property. + * + * Should be between 1 and 100, 100 is more important than 1 + * + * @return int + */ + public function getPriority(); + + /** + * Returns the execution priority of the extraction Service + * Should be between 1 and 100, 100 means runs as first service, 1 runs at last service + * + * @return int + */ + public function getExecutionPriority(); + + /** + * Checks if the given file can be processed by this Extractor + * + * @return bool + */ + public function canProcess(File $file); + + /** + * The actual processing TASK + * + * Should return an array with database properties for sys_file_metadata to write + * + * @param Resource\File $file + * @param array $previousExtractedData optional, contains the array of already extracted data + * @return array + */ + public function extractMetaData(File $file, array $previousExtractedData = []); +} diff --git a/Classes/Resource/Index/ExtractorRegistry.php b/Classes/Resource/Index/ExtractorRegistry.php new file mode 100644 index 0000000..6a2ad0c --- /dev/null +++ b/Classes/Resource/Index/ExtractorRegistry.php @@ -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\Core\Resource\Index; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * Registry for MetaData extraction Services + */ +#[Autoconfigure(public: true)] +readonly class ExtractorRegistry +{ + public function __construct( + #[AutowireLocator('metadata.extractor')] + private ServiceLocator $extractors + ) {} + + /** + * Get all registered extractor instances. + * + * @return ExtractorInterface[] + */ + public function getExtractors(): array + { + // @todo Isn't there an option to get the services ordered automatically by the ServiceLocator? + $extractors = []; + foreach ($this->extractors as $extractor) { + $extractors[] = $extractor; + } + usort($extractors, [$this, 'compareExtractorPriority']); + return $extractors; + + } + + /** + * Get Extractors which work for a specific driver. + * + * @return ExtractorInterface[] + */ + public function getExtractorsWithDriverSupport(string $driverType): array + { + return array_filter( + $this->getExtractors(), + function (ExtractorInterface $extractor) use ($driverType): bool { + return empty($extractor->getDriverRestrictions()) + || in_array($driverType, $extractor->getDriverRestrictions(), true); + } + ); + } + + /** + * Compare the priority of two Extractor classes. + * Is used for sorting array of Extractor instances by priority. + * We want the result to be ordered from high to low so a higher + * priority comes before a lower. + * + * @return int -1 a > b, 0 a == b, 1 a < b + */ + private function compareExtractorPriority(ExtractorInterface $extractorA, ExtractorInterface $extractorB): int + { + return $extractorB->getExecutionPriority() - $extractorA->getExecutionPriority(); + } +} diff --git a/Classes/Resource/Index/FileIndexRepository.php b/Classes/Resource/Index/FileIndexRepository.php new file mode 100644 index 0000000..2d76bdc --- /dev/null +++ b/Classes/Resource/Index/FileIndexRepository.php @@ -0,0 +1,414 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Index; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\ReferenceIndex; +use TYPO3\CMS\Core\Resource\Event\AfterFileAddedToIndexEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMarkedAsMissingEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileRemovedFromIndexEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileUpdatedInIndexEvent; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Repository Class as an abstraction layer to sys_file + * + * Every access to table sys_file_metadata which is not handled by DataHandler + * has to use this Repository class. + * + * @internal This is meant for FAL internal use only! + */ +#[Autoconfigure(public: true)] +readonly class FileIndexRepository +{ + /** + * A list of properties which are to be persisted + */ + protected const FIELDS = [ + 'uid', 'pid', 'missing', 'type', 'storage', 'identifier', 'identifier_hash', 'extension', + 'mime_type', 'name', 'sha1', 'size', 'creation_date', 'modification_date', 'folder_hash', + ]; + + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private ConnectionPool $connectionPool, + ) {} + + /** + * Retrieves Index record for a given $fileUid + */ + public function findOneByUid(int $fileUid): array|false + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $row = $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($fileUid, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchAssociative(); + return is_array($row) ? $row : false; + } + + /** + * Retrieves Index record for a given $storageUid and $identifier + * + * @internal only for use from FileRepository + */ + public function findOneByStorageUidAndIdentifierHash(int $storageUid, string $identifierHash): array|false + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $row = $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($storageUid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('identifier_hash', $queryBuilder->createNamedParameter($identifierHash)) + ) + ->executeQuery() + ->fetchAssociative(); + return is_array($row) ? $row : false; + } + + /** + * Retrieves Index record for a given $storageUid and $identifier + * + * @internal only for use from FileRepository + */ + public function findOneByStorageAndIdentifier(ResourceStorage $storage, string $identifier): array|false + { + $identifierHash = $storage->hashFileIdentifier($identifier); + return $this->findOneByStorageUidAndIdentifierHash($storage->getUid(), $identifierHash); + } + + /** + * Retrieves Index record for a given $fileObject + * + * @internal only for use from FileRepository + */ + public function findOneByFileObject(FileInterface $fileObject): array|false + { + return $this->findOneByStorageAndIdentifier($fileObject->getStorage(), $fileObject->getIdentifier()); + } + + /** + * Returns all indexed files which match the content hash + * Used by the indexer to detect already present files + */ + public function findByContentHash(string $hash): array + { + if (!preg_match('/^[0-9a-f]{40}$/i', $hash)) { + return []; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + return $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('sha1', $queryBuilder->createNamedParameter($hash)) + ) + ->executeQuery() + ->fetchAllAssociative(); + } + + /** + * Find all records for files in a Folder + */ + public function findByFolder(Folder $folder): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $result = $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('folder_hash', $queryBuilder->createNamedParameter($folder->getHashedIdentifier())), + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($folder->getStorage()->getUid(), Connection::PARAM_INT)) + ) + ->executeQuery(); + $resultRows = []; + while ($row = $result->fetchAssociative()) { + $resultRows[$row['identifier']] = $row; + } + return $resultRows; + } + + /** + * Find all records for files in an array of Folders + * + * @param Folder[] $folders + */ + public function findByFolders(array $folders, bool $includeMissing = true, ?string $fileName = null): array + { + $storageUids = []; + $folderIdentifiers = []; + foreach ($folders as $folder) { + $storageUids[] = $folder->getStorage()->getUid(); + $folderIdentifiers[] = $folder->getHashedIdentifier(); + } + $storageUids = array_unique($storageUids); + $folderIdentifiers = array_unique($folderIdentifiers); + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->in('folder_hash', $queryBuilder->createNamedParameter($folderIdentifiers, Connection::PARAM_STR_ARRAY)), + $queryBuilder->expr()->in('storage', $queryBuilder->createNamedParameter($storageUids, Connection::PARAM_INT_ARRAY)) + ); + if (isset($fileName)) { + $nameParts = str_getcsv($fileName, ' ', '"', '\\'); + foreach ($nameParts as $part) { + $part = trim($part); + if ($part !== '') { + $queryBuilder->andWhere( + $queryBuilder->expr()->like( + 'name', + $queryBuilder->createNamedParameter( + '%' . $queryBuilder->escapeLikeWildcards($part) . '%' + ) + ) + ); + } + } + } + if (!$includeMissing) { + $queryBuilder->andWhere($queryBuilder->expr()->eq('missing', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))); + } + $result = $queryBuilder->executeQuery(); + $fileRecords = []; + while ($fileRecord = $result->fetchAssociative()) { + $fileRecords[$fileRecord['identifier']] = $fileRecord; + } + return $fileRecords; + } + + /** + * Adds a file to the index + */ + public function add(File $file): void + { + if ($this->hasIndexRecord($file)) { + $this->update($file); + if ($file->_getPropertyRaw('uid') === null) { + $file->updateProperties($this->findOneByFileObject($file)); + } + } else { + $file->updateProperties(['uid' => $this->insertRecord($file->getProperties())]); + } + } + + /** + * Add data from record (at indexing time) + */ + public function addRaw(array $data): array + { + $data['uid'] = $this->insertRecord($data); + return $data; + } + + /** + * Helper to reduce code duplication + */ + protected function insertRecord(array $data): int + { + $data = array_intersect_key($data, array_flip(self::FIELDS)); + $data['tstamp'] = time(); + $connection = $this->connectionPool->getConnectionForTable('sys_file'); + $connection->insert( + 'sys_file', + $data + ); + $data['uid'] = (int)$connection->lastInsertId(); + $this->updateRefIndex($data['uid']); + $this->eventDispatcher->dispatch(new AfterFileAddedToIndexEvent($data['uid'], $data)); + return $data['uid']; + } + + /** + * Checks if a file is indexed + */ + public function hasIndexRecord(File $file): bool + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + + if ((int)$file->_getPropertyRaw('uid') > 0) { + $constraints = [ + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($file->getUid(), Connection::PARAM_INT)), + ]; + } else { + $constraints = [ + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($file->getStorage()->getUid(), Connection::PARAM_INT)), + $queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($file->_getPropertyRaw('identifier'))), + ]; + } + $count = $queryBuilder + ->count('uid') + ->from('sys_file') + ->where(...$constraints) + ->executeQuery() + ->fetchOne(); + return (bool)$count; + } + + /** + * Updates the index record in the database + */ + public function update(File $file): void + { + $updatedProperties = array_intersect(self::FIELDS, $file->getUpdatedProperties()); + $updateRow = []; + foreach ($updatedProperties as $key) { + $updateRow[$key] = $file->getProperty($key); + } + if (!empty($updateRow)) { + if ((int)$file->_getPropertyRaw('uid') > 0) { + $constraints = ['uid' => $file->getUid()]; + } else { + $constraints = [ + 'storage' => $file->getStorage()->getUid(), + 'identifier' => $file->_getPropertyRaw('identifier'), + ]; + } + $connection = $this->connectionPool->getConnectionForTable('sys_file'); + $updateRow['tstamp'] = time(); + $connection->update( + 'sys_file', + $updateRow, + $constraints + ); + $this->updateRefIndex($file->getUid()); + $this->eventDispatcher->dispatch(new AfterFileUpdatedInIndexEvent($file, array_intersect_key($file->getProperties(), array_flip(self::FIELDS)), $updateRow)); + } + } + + /** + * Finds the files needed for second indexer step + */ + public function findInStorageWithIndexOutstanding(ResourceStorage $storage, int $limit = -1): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + if ($limit > 0) { + $queryBuilder->setMaxResults($limit); + } + return $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->gt('tstamp', $queryBuilder->quoteIdentifier('last_indexed')), + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($storage->getUid(), Connection::PARAM_INT)), + $queryBuilder->expr()->eq('missing', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->orderBy('tstamp', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + } + + /** + * Helper function for the Indexer to detect missing files + * + * @param int[] $uidList + */ + public function findInStorageAndNotInUidList(ResourceStorage $storage, array $uidList): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file'); + $queryBuilder + ->select(...self::FIELDS) + ->from('sys_file') + ->where( + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($storage->getUid(), Connection::PARAM_INT)) + ); + if (!empty($uidList)) { + $queryBuilder->andWhere( + $queryBuilder->expr()->notIn('uid', array_map(intval(...), $uidList)) + ); + } + return $queryBuilder->executeQuery()->fetchAllAssociative(); + } + + /** + * Updates the timestamp when the file indexer extracted metadata + */ + public function updateIndexingTime(int $fileUid): void + { + $this->connectionPool + ->getConnectionForTable('sys_file') + ->update( + 'sys_file', + [ + 'last_indexed' => time(), + ], + [ + 'uid' => $fileUid, + ] + ); + } + + /** + * Marks given file as missing in sys_file + */ + public function markFileAsMissing(int $fileUid): void + { + $this->connectionPool + ->getConnectionForTable('sys_file') + ->update( + 'sys_file', + [ + 'missing' => 1, + ], + [ + 'uid' => $fileUid, + ] + ); + $this->eventDispatcher->dispatch(new AfterFileMarkedAsMissingEvent($fileUid)); + } + + /** + * Remove a sys_file record from the database + */ + public function remove(int $fileUid): void + { + $this->connectionPool + ->getConnectionForTable('sys_file') + ->delete( + 'sys_file', + [ + 'uid' => $fileUid, + ] + ); + $this->updateRefIndex($fileUid); + $this->eventDispatcher->dispatch(new AfterFileRemovedFromIndexEvent($fileUid)); + } + + /** + * Update Reference Index (sys_refindex) for a file + */ + protected function updateRefIndex(int $id): void + { + $refIndexObj = GeneralUtility::makeInstance(ReferenceIndex::class); + $refIndexObj->updateRefIndexTable('sys_file', $id); + } +} diff --git a/Classes/Resource/Index/Indexer.php b/Classes/Resource/Index/Indexer.php new file mode 100644 index 0000000..0eaf59c --- /dev/null +++ b/Classes/Resource/Index/Indexer.php @@ -0,0 +1,360 @@ +<?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\Core\Resource\Index; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Resource\Exception\IllegalFileExtensionException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InvalidHashException; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileType; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Resource\Service\ExtractorService; +use TYPO3\CMS\Core\Type\File\ImageInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * The FAL Indexer + */ +class Indexer implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + protected array $filesToUpdate = []; + + /** + * @var int[] + */ + protected array $identifiedFileUids = []; + protected ResourceStorage $storage; + protected ?ExtractorService $extractorService = null; + + public function __construct(ResourceStorage $storage) + { + $this->storage = $storage; + } + + /** + * Create index entry + * + * @throws \InvalidArgumentException + */ + public function createIndexEntry(string $identifier): File + { + if ($identifier === '') { + throw new \InvalidArgumentException( + 'Invalid file identifier given. It must not empty.', + 1401732565 + ); + } + + $fileProperties = $this->gatherFileInformationArray($identifier); + $fileIndexRepository = $this->getFileIndexRepository(); + + $record = $fileIndexRepository->addRaw($fileProperties); + $fileObject = $this->getResourceFactory()->getFileObject($record['uid'], $record); + $fileIndexRepository->updateIndexingTime($fileObject->getUid()); + + $metaData = $this->extractRequiredMetaData($fileObject); + if ($this->storage->autoExtractMetadataEnabled()) { + $metaData = array_merge($metaData, $this->getExtractorService()->extractMetaData($fileObject)); + } + $fileObject->getMetaData()->add($metaData)->save(); + + return $fileObject; + } + + /** + * Update index entry + */ + public function updateIndexEntry(File $fileObject): File + { + $updatedInformation = $this->gatherFileInformationArray($fileObject->getIdentifier()); + $fileObject->updateProperties($updatedInformation); + + $fileIndexRepository = $this->getFileIndexRepository(); + $fileIndexRepository->update($fileObject); + $fileIndexRepository->updateIndexingTime($fileObject->getUid()); + + $metaData = $this->extractRequiredMetaData($fileObject); + if ($this->storage->autoExtractMetadataEnabled()) { + $metaData = array_merge($metaData, $this->getExtractorService()->extractMetaData($fileObject)); + } + $fileObject->getMetaData()->add($metaData)->save(); + + return $fileObject; + } + + public function processChangesInStorages(): void + { + // get all file-identifiers from the storage + $availableFiles = $this->storage->getFileIdentifiersInFolder($this->storage->getRootLevelFolder(false)->getIdentifier(), true, true); + $this->detectChangedFilesInStorage($availableFiles); + $this->processChangedAndNewFiles(); + + $this->detectMissingFiles(); + } + + public function runMetaDataExtraction(int $maximumFileCount = -1): void + { + $fileIndexRecords = $this->getFileIndexRepository()->findInStorageWithIndexOutstanding($this->storage, $maximumFileCount); + foreach ($fileIndexRecords as $indexRecord) { + $fileObject = $this->getResourceFactory()->getFileObject($indexRecord['uid'], $indexRecord); + // Check for existence of file before extraction + if ($fileObject->exists()) { + try { + $this->extractMetaData($fileObject); + } catch (InsufficientFileAccessPermissionsException $e) { + // We skip files that are not accessible + } catch (IllegalFileExtensionException $e) { + // We skip files that have an extension that we don't allow + } + } else { + // Mark file as missing and continue with next record + $this->getFileIndexRepository()->markFileAsMissing($indexRecord['uid']); + } + } + } + + /** + * Extract metadata for given fileObject + */ + public function extractMetaData(File $fileObject): void + { + $metaData = array_merge([ + $fileObject->getMetaData()->get(), + ], $this->getExtractorService()->extractMetaData($fileObject)); + + $fileObject->getMetaData()->add($metaData)->save(); + + $this->getFileIndexRepository()->updateIndexingTime($fileObject->getUid()); + } + + /** + * Since by now all files in filesystem have been looked at, it is safe to assume, + * that files that are indexed, but not touched in this run, are missing + */ + protected function detectMissingFiles(): void + { + $allCurrentFiles = $this->getFileIndexRepository()->findInStorageAndNotInUidList( + $this->storage, + [] + ); + + foreach ($allCurrentFiles as $record) { + // Check if the record retrieved from the database was associated + // with an existing file. + // If yes: All is good, file is in index and in database. + // If no: Database record may need to be marked as removed (extra check!) + if (in_array($record['uid'], $this->identifiedFileUids, true)) { + continue; + } + + if (!$this->storage->hasFile($record['identifier'])) { + $this->getFileIndexRepository()->markFileAsMissing($record['uid']); + } + } + } + + /** + * Check whether the extractor service supports this file according to file type restrictions. + */ + protected function isFileTypeSupportedByExtractor(File $file, ExtractorInterface $extractor): bool + { + $isSupported = true; + $fileTypeRestrictions = $extractor->getFileTypeRestrictions(); + if (!empty($fileTypeRestrictions) && !in_array($file->getType(), $fileTypeRestrictions)) { + $isSupported = false; + } + return $isSupported; + } + + /** + * Adds updated files to the processing queue + */ + protected function detectChangedFilesInStorage(array $fileIdentifierArray): void + { + foreach ($fileIdentifierArray as $fileIdentifier) { + // skip processed files + if ($this->storage->isWithinProcessingFolder($fileIdentifier)) { + continue; + } + // Get the modification time for file-identifier from the storage + $modificationTime = $this->storage->getFileInfoByIdentifier($fileIdentifier, ['mtime']); + // Look if the the modification time in FS is higher than the one in database (key needed on timestamps) + $indexRecord = $this->getFileIndexRepository()->findOneByStorageAndIdentifier($this->storage, $fileIdentifier); + + if ($indexRecord !== false) { + $this->identifiedFileUids[] = $indexRecord['uid']; + + if ((int)$indexRecord['modification_date'] !== $modificationTime['mtime'] || $indexRecord['missing']) { + $this->filesToUpdate[$fileIdentifier] = $indexRecord; + } + } else { + $this->filesToUpdate[$fileIdentifier] = null; + } + } + } + + /** + * Processes the Files which have been detected as "changed or new" + * in the storage + */ + protected function processChangedAndNewFiles(): void + { + foreach ($this->filesToUpdate as $identifier => $data) { + try { + if ($data === null) { + // search for files with same content hash in indexed storage + $fileHash = $this->storage->hashFileByIdentifier($identifier, 'sha1'); + $files = $this->getFileIndexRepository()->findByContentHash($fileHash); + $fileObject = null; + if (!empty($files)) { + foreach ($files as $fileIndexEntry) { + // check if file is missing then we assume it's moved/renamed + if (!$this->storage->hasFile($fileIndexEntry['identifier'])) { + $fileObject = $this->getResourceFactory()->getFileObject( + $fileIndexEntry['uid'], + $fileIndexEntry + ); + $fileObject->updateProperties( + [ + 'identifier' => $identifier, + ] + ); + $this->updateIndexEntry($fileObject); + $this->identifiedFileUids[] = $fileObject->getUid(); + break; + } + } + } + // create new index when no missing file with same content hash is found + if ($fileObject === null) { + $fileObject = $this->createIndexEntry($identifier); + $this->identifiedFileUids[] = $fileObject->getUid(); + } + } else { + // update existing file + $fileObject = $this->getResourceFactory()->getFileObject($data['uid'], $data); + $this->updateIndexEntry($fileObject); + } + } catch (InvalidHashException $e) { + $this->logger->error('Unable to create hash for file: {identifier}', ['identifier' => $identifier]); + } catch (\Exception $e) { + $this->logger->error('Unable to index / update file with identifier {identifier}', [ + 'identifier' => $identifier, + 'exception' => $e, + ]); + } + } + } + + /** + * Since the core desperately needs image sizes in metadata table put them there + * This should be called after every "content" update and "record" creation + */ + protected function extractRequiredMetaData(File $fileObject): array + { + $metaData = []; + + // since the core desperately needs image sizes in metadata table do this manually + // prevent doing this for remote storages, remote storages must provide the data with extractors + if ($fileObject->isImage() && $this->storage->getDriverType() === 'Local') { + $rawFileLocation = $fileObject->getForLocalProcessing(false); + $imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $rawFileLocation); + $metaData = [ + 'width' => $imageInfo->getWidth(), + 'height' => $imageInfo->getHeight(), + ]; + } + + return $metaData; + } + + /**************************** + * UTILITY + ****************************/ + /** + * Collects the information to be cached in sys_file + */ + protected function gatherFileInformationArray(string $identifier): array + { + $fileInfo = $this->storage->getFileInfoByIdentifier($identifier); + $fileInfo = $this->transformFromDriverFileInfoArrayToFileObjectFormat($fileInfo); + $fileInfo['type'] = $this->getFileType($fileInfo['mime_type'])->value; + $fileInfo['sha1'] = $this->storage->hashFileByIdentifier($identifier, 'sha1'); + $fileInfo['missing'] = 0; + + return $fileInfo; + } + + /** + * Maps the mimetype to a sys_file table type + */ + protected function getFileType(string $mimeType): FileType + { + return FileType::tryFromMimeType($mimeType); + } + + /** + * However it happened, the properties of a file object which + * are persisted to the database are named different than the + * properties the driver returns in getFileInfo. + * Therefore, a mapping must happen. + */ + protected function transformFromDriverFileInfoArrayToFileObjectFormat(array $fileInfo): array + { + $mappingInfo = [ + // 'driverKey' => 'fileProperty' Key is from the driver, value is for the property in the file + 'size' => 'size', + 'atime' => null, + 'mtime' => 'modification_date', + 'ctime' => 'creation_date', + 'mimetype' => 'mime_type', + ]; + $mappedFileInfo = []; + foreach ($fileInfo as $key => $value) { + if (array_key_exists($key, $mappingInfo)) { + if ($mappingInfo[$key] !== null) { + $mappedFileInfo[$mappingInfo[$key]] = $value; + } + } else { + $mappedFileInfo[$key] = $value; + } + } + return $mappedFileInfo; + } + + protected function getFileIndexRepository(): FileIndexRepository + { + return GeneralUtility::makeInstance(FileIndexRepository::class); + } + + protected function getResourceFactory(): ResourceFactory + { + return GeneralUtility::makeInstance(ResourceFactory::class); + } + + protected function getExtractorService(): ExtractorService + { + if ($this->extractorService === null) { + $this->extractorService = GeneralUtility::makeInstance(ExtractorService::class); + } + return $this->extractorService; + } +} diff --git a/Classes/Resource/Index/MetaDataRepository.php b/Classes/Resource/Index/MetaDataRepository.php new file mode 100644 index 0000000..5878150 --- /dev/null +++ b/Classes/Resource/Index/MetaDataRepository.php @@ -0,0 +1,219 @@ +<?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\Core\Resource\Index; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction; +use TYPO3\CMS\Core\Database\Schema\Information\ColumnInfo; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataCreatedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataUpdatedEvent; +use TYPO3\CMS\Core\Resource\Event\EnrichFileMetaDataEvent; +use TYPO3\CMS\Core\Resource\Exception\InvalidUidException; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileType; +use TYPO3\CMS\Core\Type\File\ImageInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Repository Class as an abstraction layer to sys_file_metadata + * + * Every access to table sys_file_metadata which is not handled by DataHandler + * has to use this Repository class + */ +#[Autoconfigure(public: true)] +readonly class MetaDataRepository +{ + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private ConnectionPool $connectionPool, + private Context $context, + ) {} + + /** + * Returns array of meta-data properties + */ + public function findByFile(File $file): array + { + $record = $this->findByFileUid($file->getUid()); + + // It could be possible that the meta information is freshly + // created and inserted into the database. If this is the case + // we have to take care about correct meta information for width and + // height in case of an image. + // This logic can be transferred into a custom PSR-14 event listener in the future by just using + // the AfterMetaDataCreated event. + if (!empty($record['crdate']) && (int)$record['crdate'] === $GLOBALS['EXEC_TIME']) { + if ($file->isType(FileType::IMAGE) && $file->getStorage()->getDriverType() === 'Local') { + $fileNameAndPath = $file->getForLocalProcessing(false); + + $imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $fileNameAndPath); + + $additionalMetaInformation = [ + 'width' => $imageInfo->getWidth(), + 'height' => $imageInfo->getHeight(), + ]; + + $this->update($file->getUid(), $additionalMetaInformation, $record); + } + $record = $this->findByFileUid($file->getUid()); + } + + return $record; + } + + /** + * Retrieves metadata for file + * + * @param int $uid + * @return array<string, string> $metaData + * @throws InvalidUidException + */ + public function findByFileUid(int $uid): array + { + if ($uid <= 0) { + throw new InvalidUidException('Metadata can only be retrieved for indexed files. UID: "' . $uid . '"', 1381590731); + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_metadata'); + $queryBuilder->getRestrictions() + ->add(GeneralUtility::makeInstance(RootLevelRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->context->getAspect('workspace')->getId())); + + $record = $queryBuilder + ->select('*') + ->from('sys_file_metadata') + ->where( + $queryBuilder->expr()->eq('file', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)), + $queryBuilder->expr()->in('language_tag', $queryBuilder->createNamedParameter([\Local\Multilanguage\Service\DefaultLanguageTagService::getTag(), ''], Connection::PARAM_STR_ARRAY)) + ) + // assure deterministic sorting across all databases + ->orderBy('uid', 'ASC') + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if (empty($record)) { + return []; + } + + return $this->eventDispatcher->dispatch(new EnrichFileMetaDataEvent($uid, (int)$record['uid'], $record))->getRecord(); + } + + /** + * Create empty + */ + public function createMetaDataRecord(int $fileUid, array $additionalFields = []): array + { + $emptyRecord = [ + 'file' => $fileUid, + 'pid' => 0, + 'crdate' => $GLOBALS['EXEC_TIME'], + 'tstamp' => $GLOBALS['EXEC_TIME'], + 'l10n_diffsource' => '', + ]; + $additionalFields = array_intersect_key($additionalFields, $this->getTableFields()); + $emptyRecord = array_merge($emptyRecord, $additionalFields); + + $connection = $this->connectionPool->getConnectionForTable('sys_file_metadata'); + $connection->insert( + 'sys_file_metadata', + $emptyRecord, + ['l10n_diffsource' => Connection::PARAM_LOB] + ); + + $record = $emptyRecord; + $record['uid'] = $connection->lastInsertId(); + + return $this->eventDispatcher->dispatch(new AfterFileMetaDataCreatedEvent($fileUid, (int)$record['uid'], $record))->getRecord(); + } + + /** + * Updates the metadata record in the database + * + * @param int $fileUid the file uid to update + * @param array $updateData Data to update + * @param ?array $metaDataFromDatabase Current meta data from database + * @return array The updated database record - or just $metaDataFromDatabase if no update was done + * @internal + */ + public function update(int $fileUid, array $updateData, ?array $metaDataFromDatabase = null): array + { + // backwards compatibility layer + $metaDataFromDatabase ??= $this->findByFileUid($fileUid); + + $updateRow = array_intersect_key($updateData, $this->getTableFields()); + if ($updateRow === []) { + // No valid keys to update - return current database row + return $metaDataFromDatabase; + } + if (array_key_exists('uid', $updateRow)) { + unset($updateRow['uid']); + } + $updateRow = array_diff_assoc($updateRow, $metaDataFromDatabase); + if ($updateRow === []) { + // Nothing to update - return current database row + return $metaDataFromDatabase; + } + + $updateRow['tstamp'] = time(); + $this->connectionPool->getConnectionForTable('sys_file_metadata')->update( + 'sys_file_metadata', + $updateRow, + [ + 'uid' => (int)$metaDataFromDatabase['uid'], + ] + ); + + return $this->eventDispatcher->dispatch( + new AfterFileMetaDataUpdatedEvent($fileUid, (int)$metaDataFromDatabase['uid'], array_merge($metaDataFromDatabase, $updateRow)) + )->getRecord(); + } + + /** + * Remove all metadata records for a certain file from the database + * + * @param int $fileUid + */ + public function removeByFileUid(int $fileUid): void + { + $this->connectionPool->getConnectionForTable('sys_file_metadata')->delete( + 'sys_file_metadata', + [ + 'file' => $fileUid, + ] + ); + $this->eventDispatcher->dispatch(new AfterFileMetaDataDeletedEvent($fileUid)); + } + + /** + * Gets the fields that are available in the table + * + * @return array<string, ColumnInfo> + */ + protected function getTableFields(): array + { + return $this->connectionPool + ->getConnectionForTable('sys_file_metadata') + ->getSchemaInformation() + ->listTableColumnInfos('sys_file_metadata'); + } +} diff --git a/Classes/Resource/LocalPath.php b/Classes/Resource/LocalPath.php new file mode 100644 index 0000000..9ba855f --- /dev/null +++ b/Classes/Resource/LocalPath.php @@ -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\Core\Resource; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Model representing an absolute or relative path in the local file system + * @internal + */ +class LocalPath +{ + public const TYPE_ABSOLUTE = 1; + public const TYPE_RELATIVE = 2; + + protected string $raw; + protected ?string $relative = null; + protected string $absolute; + protected int $type; + + public function __construct(string $value, int $type) + { + if ($type !== self::TYPE_ABSOLUTE && $type !== self::TYPE_RELATIVE) { + throw new \LogicException(sprintf('Unexpected type "%d"', $type), 1625826491); + } + + // @todo `../` is erased here, check again if this is a valid scenario + // value and absolute have leading and trailing slash, e.g. '/some/path/' + $value = '/' . trim(PathUtility::getCanonicalPath($value), '/'); + $value .= $value !== '/' ? '/' : ''; + $this->raw = $value; + $this->type = $type; + + $publicPath = Environment::getPublicPath(); + if ($type === self::TYPE_RELATIVE) { + $this->relative = $value; + $this->absolute = PathUtility::getCanonicalPath($publicPath . $value) . '/'; + } elseif ($type === self::TYPE_ABSOLUTE) { + $this->absolute = $value; + $this->relative = str_starts_with($value, $publicPath) + ? substr($value, strlen($publicPath)) + : null; + } + } + + /** + * @return string normalized path as provided + */ + public function getRaw(): string + { + return $this->raw; + } + + /** + * @return string|null (calculated) relative path to public path - `null` if outside public path + */ + public function getRelative(): ?string + { + return $this->relative; + } + + /** + * @return string (calculated) absolute path + */ + public function getAbsolute(): string + { + return $this->absolute; + } + + public function isAbsolute(): bool + { + return $this->type === self::TYPE_ABSOLUTE; + } + + public function isRelative(): bool + { + return $this->type === self::TYPE_RELATIVE; + } +} diff --git a/Classes/Resource/MetaDataAspect.php b/Classes/Resource/MetaDataAspect.php new file mode 100644 index 0000000..9cdd7ae --- /dev/null +++ b/Classes/Resource/MetaDataAspect.php @@ -0,0 +1,171 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Resource\Index\MetaDataRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Aspect that takes care of a file's metadata + */ +class MetaDataAspect implements \ArrayAccess, \Countable, \Iterator +{ + private array $metaData = []; + + /** + * This flag is used to treat a possible recursion between $this->get() and $this->file->getUid() + */ + private bool $loaded = false; + + private int $indexPosition = 0; + + /** + * Constructor + */ + public function __construct( + private readonly File $file + ) {} + + /** + * Adds already known metadata to the aspect + * + * @internal + * + * @return $this + */ + public function add(array $metaData): self + { + $this->loaded = true; + $this->metaData = array_merge($this->metaData, $metaData); + + return $this; + } + + /** + * Gets the metadata of a file. If not metadata is loaded yet, the database gets queried + */ + public function get(): array + { + if (!$this->loaded) { + $this->loaded = true; + $this->metaData = $this->loadFromRepository(); + } + return $this->metaData; + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->get()); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get()[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->loaded = true; + $this->metaData[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + $this->metaData[$offset] = null; + } + + public function count(): int + { + return count($this->get()); + } + + /** + * Resets the internal iterator counter + */ + public function rewind(): void + { + $this->indexPosition = 0; + } + + /** + * Gets the current value of iteration + */ + public function current(): mixed + { + $key = array_keys($this->metaData)[$this->indexPosition]; + return $this->metaData[$key]; + } + + /** + * Returns the key of the current iteration + */ + public function key(): string + { + return array_keys($this->metaData)[$this->indexPosition]; + } + + /** + * Increases the index for iteration + */ + public function next(): void + { + ++$this->indexPosition; + } + + public function valid(): bool + { + $key = array_keys($this->metaData)[$this->indexPosition] ?? ''; + return array_key_exists($key, $this->metaData); + } + + /** + * Creates new or updates existing meta data + * + * @internal + */ + public function save(): void + { + $metaDataInDatabase = $this->loadFromRepository(); + if ($metaDataInDatabase === []) { + $this->metaData = $this->getMetaDataRepository()->createMetaDataRecord($this->file->getUid(), $this->metaData); + } else { + $this->metaData = $this->getMetaDataRepository()->update($this->file->getUid(), $this->metaData, $metaDataInDatabase); + } + } + + /** + * Removes a meta data record + * + * @internal + */ + public function remove(): void + { + $this->getMetaDataRepository()->removeByFileUid($this->file->getUid()); + $this->metaData = []; + } + + protected function getMetaDataRepository(): MetaDataRepository + { + return GeneralUtility::makeInstance(MetaDataRepository::class); + } + + protected function loadFromRepository(): array + { + return $this->getMetaDataRepository()->findByFileUid($this->file->getUid()); + } +} diff --git a/Classes/Resource/MetaDataEventListener.php b/Classes/Resource/MetaDataEventListener.php new file mode 100644 index 0000000..f2d22ae --- /dev/null +++ b/Classes/Resource/MetaDataEventListener.php @@ -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\Core\Resource; + +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Resource\Event\AfterFileMetaDataUpdatedEvent; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal Marked as internal for now, methods in this class may change any time. + */ +final class MetaDataEventListener +{ + private const string TABLE_NAME = 'sys_file_metadata'; + + #[AsEventListener('synchronize-file-meta-data-translations-after-update')] + public function afterFileMetaDataUpdated(AfterFileMetaDataUpdatedEvent $event): void + { + $record = $event->getRecord(); + + if (($record['width'] ?? 0) <= 0 || ($record['height'] ?? 0) <= 0) { + return; + } + + $metaData = [ + 'width' => (int)$record['width'], + 'height' => (int)$record['height'], + ]; + + // Update translated meta data records + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE_NAME); + $connection->update( + self::TABLE_NAME, + $metaData, + [ + 'file' => $event->getFileUid(), + 'l10n_parent' => $event->getMetaDataUid(), + ] + ); + } +} diff --git a/Classes/Resource/MimeTypeCollection.php b/Classes/Resource/MimeTypeCollection.php new file mode 100644 index 0000000..dbd3330 --- /dev/null +++ b/Classes/Resource/MimeTypeCollection.php @@ -0,0 +1,1060 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +/** + * This class contains a list of all available / known mimetypes and file extensions, + * and is automatically generated by TYPO3 via Core/Build/Scripts/generateMimeTypes.php + */ +final class MimeTypeCollection +{ + private $map = [ + 'application/andrew-inset' => ['ez'], + 'application/appinstaller' => ['appinstaller'], + 'application/applixware' => ['aw'], + 'application/appx' => ['appx'], + 'application/appxbundle' => ['appxbundle'], + 'application/atom+xml' => ['atom'], + 'application/atomcat+xml' => ['atomcat'], + 'application/atomdeleted+xml' => ['atomdeleted'], + 'application/atomsvc+xml' => ['atomsvc'], + 'application/atsc-dwd+xml' => ['dwd'], + 'application/atsc-held+xml' => ['held'], + 'application/atsc-rsat+xml' => ['rsat'], + 'application/automationml-aml+xml' => ['aml'], + 'application/automationml-amlx+zip' => ['amlx'], + 'application/bdoc' => ['bdoc'], + 'application/calendar+xml' => ['xcs'], + 'application/ccxml+xml' => ['ccxml'], + 'application/cdfx+xml' => ['cdfx'], + 'application/cdmi-capability' => ['cdmia'], + 'application/cdmi-container' => ['cdmic'], + 'application/cdmi-domain' => ['cdmid'], + 'application/cdmi-object' => ['cdmio'], + 'application/cdmi-queue' => ['cdmiq'], + 'application/cpl+xml' => ['cpl'], + 'application/cu-seeme' => ['cu'], + 'application/cwl' => ['cwl'], + 'application/dash+xml' => ['mpd'], + 'application/dash-patch+xml' => ['mpp'], + 'application/davmount+xml' => ['davmount'], + 'application/dicom' => ['dcm'], + 'application/docbook+xml' => ['dbk'], + 'application/dssc+der' => ['dssc'], + 'application/dssc+xml' => ['xdssc'], + 'application/ecmascript' => ['ecma'], + 'application/emma+xml' => ['emma'], + 'application/emotionml+xml' => ['emotionml'], + 'application/epub+zip' => ['epub'], + 'application/exi' => ['exi'], + 'application/express' => ['exp'], + 'application/fdf' => ['fdf'], + 'application/fdt+xml' => ['fdt'], + 'application/font-tdpfr' => ['pfr'], + 'application/geo+json' => ['geojson'], + 'application/gml+xml' => ['gml'], + 'application/gpx+xml' => ['gpx'], + 'application/gxf' => ['gxf'], + 'application/gzip' => ['gz'], + 'application/hjson' => ['hjson'], + 'application/hyperstudio' => ['stk'], + 'application/inkml+xml' => ['ink', 'inkml'], + 'application/ipfix' => ['ipfix'], + 'application/its+xml' => ['its'], + 'application/java-archive' => ['jar', 'war', 'ear'], + 'application/java-serialized-object' => ['ser'], + 'application/java-vm' => ['class'], + 'application/javascript' => ['js'], + 'application/json' => ['json', 'map'], + 'application/json5' => ['json5'], + 'application/jsonml+json' => ['jsonml'], + 'application/ld+json' => ['jsonld'], + 'application/lgr+xml' => ['lgr'], + 'application/lost+xml' => ['lostxml'], + 'application/mac-binhex40' => ['hqx'], + 'application/mac-compactpro' => ['cpt'], + 'application/mads+xml' => ['mads'], + 'application/manifest+json' => ['webmanifest'], + 'application/marc' => ['mrc'], + 'application/marcxml+xml' => ['mrcx'], + 'application/mathematica' => ['ma', 'nb', 'mb'], + 'application/mathml+xml' => ['mathml'], + 'application/mbox' => ['mbox'], + 'application/media-policy-dataset+xml' => ['mpf'], + 'application/mediaservercontrol+xml' => ['mscml'], + 'application/metalink+xml' => ['metalink'], + 'application/metalink4+xml' => ['meta4'], + 'application/mets+xml' => ['mets'], + 'application/mmt-aei+xml' => ['maei'], + 'application/mmt-usd+xml' => ['musd'], + 'application/mods+xml' => ['mods'], + 'application/mp21' => ['m21', 'mp21'], + 'application/mp4' => ['mp4', 'mpg4', 'mp4s', 'm4p'], + 'application/msix' => ['msix'], + 'application/msixbundle' => ['msixbundle'], + 'application/msword' => ['doc', 'dot'], + 'application/mxf' => ['mxf'], + 'application/n-quads' => ['nq'], + 'application/n-triples' => ['nt'], + 'application/node' => ['cjs'], + 'application/octet-stream' => ['bin', 'dms', 'lrf', 'mar', 'so', 'dist', 'distz', 'pkg', 'bpk', 'dump', 'elc', 'deploy', 'exe', 'dll', 'deb', 'dmg', 'iso', 'img', 'msi', 'msp', 'msm', 'buffer'], + 'application/oda' => ['oda'], + 'application/oebps-package+xml' => ['opf'], + 'application/ogg' => ['ogx'], + 'application/omdoc+xml' => ['omdoc'], + 'application/onenote' => ['onetoc', 'onetoc2', 'onetmp', 'onepkg', 'one', 'onea'], + 'application/oxps' => ['oxps'], + 'application/p2p-overlay+xml' => ['relo'], + 'application/patch-ops-error+xml' => ['xer'], + 'application/pdf' => ['pdf'], + 'application/pgp-encrypted' => ['pgp'], + 'application/pgp-keys' => ['asc'], + 'application/pgp-signature' => ['sig', 'asc'], + 'application/pics-rules' => ['prf'], + 'application/pkcs10' => ['p10'], + 'application/pkcs7-mime' => ['p7m', 'p7c'], + 'application/pkcs7-signature' => ['p7s'], + 'application/pkcs8' => ['p8'], + 'application/pkix-attr-cert' => ['ac'], + 'application/pkix-cert' => ['cer'], + 'application/pkix-crl' => ['crl'], + 'application/pkix-pkipath' => ['pkipath'], + 'application/pkixcmp' => ['pki'], + 'application/pls+xml' => ['pls'], + 'application/postscript' => ['ai', 'eps', 'ps'], + 'application/provenance+xml' => ['provx'], + 'application/prs.cww' => ['cww'], + 'application/prs.xsf+xml' => ['xsf'], + 'application/pskc+xml' => ['pskcxml'], + 'application/raml+yaml' => ['raml'], + 'application/rdf+xml' => ['rdf', 'owl'], + 'application/reginfo+xml' => ['rif'], + 'application/relax-ng-compact-syntax' => ['rnc'], + 'application/resource-lists+xml' => ['rl'], + 'application/resource-lists-diff+xml' => ['rld'], + 'application/rls-services+xml' => ['rs'], + 'application/route-apd+xml' => ['rapd'], + 'application/route-s-tsid+xml' => ['sls'], + 'application/route-usd+xml' => ['rusd'], + 'application/rpki-ghostbusters' => ['gbr'], + 'application/rpki-manifest' => ['mft'], + 'application/rpki-roa' => ['roa'], + 'application/rsd+xml' => ['rsd'], + 'application/rss+xml' => ['rss'], + 'application/rtf' => ['rtf'], + 'application/sbml+xml' => ['sbml'], + 'application/scvp-cv-request' => ['scq'], + 'application/scvp-cv-response' => ['scs'], + 'application/scvp-vp-request' => ['spq'], + 'application/scvp-vp-response' => ['spp'], + 'application/sdp' => ['sdp'], + 'application/senml+xml' => ['senmlx'], + 'application/sensml+xml' => ['sensmlx'], + 'application/set-payment-initiation' => ['setpay'], + 'application/set-registration-initiation' => ['setreg'], + 'application/shf+xml' => ['shf'], + 'application/sieve' => ['siv', 'sieve'], + 'application/smil+xml' => ['smi', 'smil'], + 'application/sparql-query' => ['rq'], + 'application/sparql-results+xml' => ['srx'], + 'application/sql' => ['sql'], + 'application/srgs' => ['gram'], + 'application/srgs+xml' => ['grxml'], + 'application/sru+xml' => ['sru'], + 'application/ssdl+xml' => ['ssdl'], + 'application/ssml+xml' => ['ssml'], + 'application/swid+xml' => ['swidtag'], + 'application/tei+xml' => ['tei', 'teicorpus'], + 'application/thraud+xml' => ['tfi'], + 'application/timestamped-data' => ['tsd'], + 'application/toml' => ['toml'], + 'application/trig' => ['trig'], + 'application/ttml+xml' => ['ttml'], + 'application/ubjson' => ['ubj'], + 'application/urc-ressheet+xml' => ['rsheet'], + 'application/urc-targetdesc+xml' => ['td'], + 'application/vnd.1000minds.decision-model+xml' => ['1km'], + 'application/vnd.3gpp.pic-bw-large' => ['plb'], + 'application/vnd.3gpp.pic-bw-small' => ['psb'], + 'application/vnd.3gpp.pic-bw-var' => ['pvb'], + 'application/vnd.3gpp2.tcap' => ['tcap'], + 'application/vnd.3m.post-it-notes' => ['pwn'], + 'application/vnd.accpac.simply.aso' => ['aso'], + 'application/vnd.accpac.simply.imp' => ['imp'], + 'application/vnd.acucobol' => ['acu'], + 'application/vnd.acucorp' => ['atc', 'acutc'], + 'application/vnd.adobe.air-application-installer-package+zip' => ['air'], + 'application/vnd.adobe.formscentral.fcdt' => ['fcdt'], + 'application/vnd.adobe.fxp' => ['fxp', 'fxpl'], + 'application/vnd.adobe.xdp+xml' => ['xdp'], + 'application/vnd.adobe.xfdf' => ['xfdf'], + 'application/vnd.age' => ['age'], + 'application/vnd.ahead.space' => ['ahead'], + 'application/vnd.airzip.filesecure.azf' => ['azf'], + 'application/vnd.airzip.filesecure.azs' => ['azs'], + 'application/vnd.amazon.ebook' => ['azw'], + 'application/vnd.americandynamics.acc' => ['acc'], + 'application/vnd.amiga.ami' => ['ami'], + 'application/vnd.android.package-archive' => ['apk'], + 'application/vnd.anser-web-certificate-issue-initiation' => ['cii'], + 'application/vnd.anser-web-funds-transfer-initiation' => ['fti'], + 'application/vnd.antix.game-component' => ['atx'], + 'application/vnd.apple.installer+xml' => ['mpkg'], + 'application/vnd.apple.keynote' => ['key'], + 'application/vnd.apple.mpegurl' => ['m3u8'], + 'application/vnd.apple.numbers' => ['numbers'], + 'application/vnd.apple.pages' => ['pages'], + 'application/vnd.apple.pkpass' => ['pkpass'], + 'application/vnd.aristanetworks.swi' => ['swi'], + 'application/vnd.astraea-software.iota' => ['iota'], + 'application/vnd.audiograph' => ['aep'], + 'application/vnd.autodesk.fbx' => ['fbx'], + 'application/vnd.balsamiq.bmml+xml' => ['bmml'], + 'application/vnd.blueice.multipass' => ['mpm'], + 'application/vnd.bmi' => ['bmi'], + 'application/vnd.businessobjects' => ['rep'], + 'application/vnd.chemdraw+xml' => ['cdxml'], + 'application/vnd.chipnuts.karaoke-mmd' => ['mmd'], + 'application/vnd.cinderella' => ['cdy'], + 'application/vnd.citationstyles.style+xml' => ['csl'], + 'application/vnd.claymore' => ['cla'], + 'application/vnd.cloanto.rp9' => ['rp9'], + 'application/vnd.clonk.c4group' => ['c4g', 'c4d', 'c4f', 'c4p', 'c4u'], + 'application/vnd.cluetrust.cartomobile-config' => ['c11amc'], + 'application/vnd.cluetrust.cartomobile-config-pkg' => ['c11amz'], + 'application/vnd.commonspace' => ['csp'], + 'application/vnd.contact.cmsg' => ['cdbcmsg'], + 'application/vnd.cosmocaller' => ['cmc'], + 'application/vnd.crick.clicker' => ['clkx'], + 'application/vnd.crick.clicker.keyboard' => ['clkk'], + 'application/vnd.crick.clicker.palette' => ['clkp'], + 'application/vnd.crick.clicker.template' => ['clkt'], + 'application/vnd.crick.clicker.wordbank' => ['clkw'], + 'application/vnd.criticaltools.wbs+xml' => ['wbs'], + 'application/vnd.ctc-posml' => ['pml'], + 'application/vnd.cups-ppd' => ['ppd'], + 'application/vnd.curl.car' => ['car'], + 'application/vnd.curl.pcurl' => ['pcurl'], + 'application/vnd.dart' => ['dart'], + 'application/vnd.data-vision.rdz' => ['rdz'], + 'application/vnd.dbf' => ['dbf'], + 'application/vnd.dcmp+xml' => ['dcmp'], + 'application/vnd.dece.data' => ['uvf', 'uvvf', 'uvd', 'uvvd'], + 'application/vnd.dece.ttml+xml' => ['uvt', 'uvvt'], + 'application/vnd.dece.unspecified' => ['uvx', 'uvvx'], + 'application/vnd.dece.zip' => ['uvz', 'uvvz'], + 'application/vnd.denovo.fcselayout-link' => ['fe_launch'], + 'application/vnd.dna' => ['dna'], + 'application/vnd.dolby.mlp' => ['mlp'], + 'application/vnd.dpgraph' => ['dpg'], + 'application/vnd.dreamfactory' => ['dfac'], + 'application/vnd.ds-keypoint' => ['kpxx'], + 'application/vnd.dvb.ait' => ['ait'], + 'application/vnd.dvb.service' => ['svc'], + 'application/vnd.dynageo' => ['geo'], + 'application/vnd.ecowin.chart' => ['mag'], + 'application/vnd.enliven' => ['nml'], + 'application/vnd.epson.esf' => ['esf'], + 'application/vnd.epson.msf' => ['msf'], + 'application/vnd.epson.quickanime' => ['qam'], + 'application/vnd.epson.salt' => ['slt'], + 'application/vnd.epson.ssf' => ['ssf'], + 'application/vnd.eszigno3+xml' => ['es3', 'et3'], + 'application/vnd.ezpix-album' => ['ez2'], + 'application/vnd.ezpix-package' => ['ez3'], + 'application/vnd.fdf' => ['fdf'], + 'application/vnd.fdsn.mseed' => ['mseed'], + 'application/vnd.fdsn.seed' => ['seed', 'dataless'], + 'application/vnd.flographit' => ['gph'], + 'application/vnd.fluxtime.clip' => ['ftc'], + 'application/vnd.framemaker' => ['fm', 'frame', 'maker', 'book'], + 'application/vnd.frogans.fnc' => ['fnc'], + 'application/vnd.frogans.ltf' => ['ltf'], + 'application/vnd.fsc.weblaunch' => ['fsc'], + 'application/vnd.fujitsu.oasys' => ['oas'], + 'application/vnd.fujitsu.oasys2' => ['oa2'], + 'application/vnd.fujitsu.oasys3' => ['oa3'], + 'application/vnd.fujitsu.oasysgp' => ['fg5'], + 'application/vnd.fujitsu.oasysprs' => ['bh2'], + 'application/vnd.fujixerox.ddd' => ['ddd'], + 'application/vnd.fujixerox.docuworks' => ['xdw'], + 'application/vnd.fujixerox.docuworks.binder' => ['xbd'], + 'application/vnd.fuzzysheet' => ['fzs'], + 'application/vnd.genomatix.tuxedo' => ['txd'], + 'application/vnd.geogebra.file' => ['ggb'], + 'application/vnd.geogebra.slides' => ['ggs'], + 'application/vnd.geogebra.tool' => ['ggt'], + 'application/vnd.geometry-explorer' => ['gex', 'gre'], + 'application/vnd.geonext' => ['gxt'], + 'application/vnd.geoplan' => ['g2w'], + 'application/vnd.geospace' => ['g3w'], + 'application/vnd.gmx' => ['gmx'], + 'application/vnd.google-apps.document' => ['gdoc'], + 'application/vnd.google-apps.drawing' => ['gdraw'], + 'application/vnd.google-apps.form' => ['gform'], + 'application/vnd.google-apps.jam' => ['gjam'], + 'application/vnd.google-apps.map' => ['gmap'], + 'application/vnd.google-apps.presentation' => ['gslides'], + 'application/vnd.google-apps.script' => ['gscript'], + 'application/vnd.google-apps.site' => ['gsite'], + 'application/vnd.google-apps.spreadsheet' => ['gsheet'], + 'application/vnd.google-earth.kml+xml' => ['kml'], + 'application/vnd.google-earth.kmz' => ['kmz'], + 'application/vnd.gov.sk.xmldatacontainer+xml' => ['xdcf'], + 'application/vnd.grafeq' => ['gqf', 'gqs'], + 'application/vnd.groove-account' => ['gac'], + 'application/vnd.groove-help' => ['ghf'], + 'application/vnd.groove-identity-message' => ['gim'], + 'application/vnd.groove-injector' => ['grv'], + 'application/vnd.groove-tool-message' => ['gtm'], + 'application/vnd.groove-tool-template' => ['tpl'], + 'application/vnd.groove-vcard' => ['vcg'], + 'application/vnd.hal+xml' => ['hal'], + 'application/vnd.handheld-entertainment+xml' => ['zmm'], + 'application/vnd.hbci' => ['hbci'], + 'application/vnd.hhe.lesson-player' => ['les'], + 'application/vnd.hp-hpgl' => ['hpgl'], + 'application/vnd.hp-hpid' => ['hpid'], + 'application/vnd.hp-hps' => ['hps'], + 'application/vnd.hp-jlyt' => ['jlt'], + 'application/vnd.hp-pcl' => ['pcl'], + 'application/vnd.hp-pclxl' => ['pclxl'], + 'application/vnd.hydrostatix.sof-data' => ['sfd-hdstx'], + 'application/vnd.ibm.minipay' => ['mpy'], + 'application/vnd.ibm.modcap' => ['afp', 'listafp', 'list3820'], + 'application/vnd.ibm.rights-management' => ['irm'], + 'application/vnd.ibm.secure-container' => ['sc'], + 'application/vnd.iccprofile' => ['icc', 'icm'], + 'application/vnd.igloader' => ['igl'], + 'application/vnd.immervision-ivp' => ['ivp'], + 'application/vnd.immervision-ivu' => ['ivu'], + 'application/vnd.insors.igm' => ['igm'], + 'application/vnd.intercon.formnet' => ['xpw', 'xpx'], + 'application/vnd.intergeo' => ['i2g'], + 'application/vnd.intu.qbo' => ['qbo'], + 'application/vnd.intu.qfx' => ['qfx'], + 'application/vnd.ipunplugged.rcprofile' => ['rcprofile'], + 'application/vnd.irepository.package+xml' => ['irp'], + 'application/vnd.is-xpr' => ['xpr'], + 'application/vnd.isac.fcs' => ['fcs'], + 'application/vnd.jam' => ['jam'], + 'application/vnd.jcp.javame.midlet-rms' => ['rms'], + 'application/vnd.jisp' => ['jisp'], + 'application/vnd.joost.joda-archive' => ['joda'], + 'application/vnd.kahootz' => ['ktz', 'ktr'], + 'application/vnd.kde.karbon' => ['karbon'], + 'application/vnd.kde.kchart' => ['chrt'], + 'application/vnd.kde.kformula' => ['kfo'], + 'application/vnd.kde.kivio' => ['flw'], + 'application/vnd.kde.kontour' => ['kon'], + 'application/vnd.kde.kpresenter' => ['kpr', 'kpt'], + 'application/vnd.kde.kspread' => ['ksp'], + 'application/vnd.kde.kword' => ['kwd', 'kwt'], + 'application/vnd.kenameaapp' => ['htke'], + 'application/vnd.kidspiration' => ['kia'], + 'application/vnd.kinar' => ['kne', 'knp'], + 'application/vnd.koan' => ['skp', 'skd', 'skt', 'skm'], + 'application/vnd.kodak-descriptor' => ['sse'], + 'application/vnd.las.las+xml' => ['lasxml'], + 'application/vnd.llamagraphics.life-balance.desktop' => ['lbd'], + 'application/vnd.llamagraphics.life-balance.exchange+xml' => ['lbe'], + 'application/vnd.lotus-1-2-3' => ['123'], + 'application/vnd.lotus-approach' => ['apr'], + 'application/vnd.lotus-freelance' => ['pre'], + 'application/vnd.lotus-notes' => ['nsf'], + 'application/vnd.lotus-organizer' => ['org'], + 'application/vnd.lotus-screencam' => ['scm'], + 'application/vnd.lotus-wordpro' => ['lwp'], + 'application/vnd.macports.portpkg' => ['portpkg'], + 'application/vnd.mapbox-vector-tile' => ['mvt'], + 'application/vnd.mcd' => ['mcd'], + 'application/vnd.medcalcdata' => ['mc1'], + 'application/vnd.mediastation.cdkey' => ['cdkey'], + 'application/vnd.mfer' => ['mwf'], + 'application/vnd.mfmp' => ['mfm'], + 'application/vnd.micrografx.flo' => ['flo'], + 'application/vnd.micrografx.igx' => ['igx'], + 'application/vnd.mif' => ['mif'], + 'application/vnd.mobius.daf' => ['daf'], + 'application/vnd.mobius.dis' => ['dis'], + 'application/vnd.mobius.mbk' => ['mbk'], + 'application/vnd.mobius.mqy' => ['mqy'], + 'application/vnd.mobius.msl' => ['msl'], + 'application/vnd.mobius.plc' => ['plc'], + 'application/vnd.mobius.txf' => ['txf'], + 'application/vnd.mophun.application' => ['mpn'], + 'application/vnd.mophun.certificate' => ['mpc'], + 'application/vnd.mozilla.xul+xml' => ['xul'], + 'application/vnd.ms-artgalry' => ['cil'], + 'application/vnd.ms-cab-compressed' => ['cab'], + 'application/vnd.ms-excel' => ['xls', 'xlm', 'xla', 'xlc', 'xlt', 'xlw'], + 'application/vnd.ms-excel.addin.macroenabled.12' => ['xlam'], + 'application/vnd.ms-excel.sheet.binary.macroenabled.12' => ['xlsb'], + 'application/vnd.ms-excel.sheet.macroenabled.12' => ['xlsm'], + 'application/vnd.ms-excel.template.macroenabled.12' => ['xltm'], + 'application/vnd.ms-fontobject' => ['eot'], + 'application/vnd.ms-htmlhelp' => ['chm'], + 'application/vnd.ms-ims' => ['ims'], + 'application/vnd.ms-lrm' => ['lrm'], + 'application/vnd.ms-officetheme' => ['thmx'], + 'application/vnd.ms-outlook' => ['msg'], + 'application/vnd.ms-pki.seccat' => ['cat'], + 'application/vnd.ms-pki.stl' => ['stl'], + 'application/vnd.ms-powerpoint' => ['ppt', 'pps', 'pot'], + 'application/vnd.ms-powerpoint.addin.macroenabled.12' => ['ppam'], + 'application/vnd.ms-powerpoint.presentation.macroenabled.12' => ['pptm'], + 'application/vnd.ms-powerpoint.slide.macroenabled.12' => ['sldm'], + 'application/vnd.ms-powerpoint.slideshow.macroenabled.12' => ['ppsm'], + 'application/vnd.ms-powerpoint.template.macroenabled.12' => ['potm'], + 'application/vnd.ms-project' => ['mpp', 'mpt'], + 'application/vnd.ms-visio.viewer' => ['vdx'], + 'application/vnd.ms-word.document.macroenabled.12' => ['docm'], + 'application/vnd.ms-word.template.macroenabled.12' => ['dotm'], + 'application/vnd.ms-works' => ['wps', 'wks', 'wcm', 'wdb'], + 'application/vnd.ms-wpl' => ['wpl'], + 'application/vnd.ms-xpsdocument' => ['xps'], + 'application/vnd.mseq' => ['mseq'], + 'application/vnd.musician' => ['mus'], + 'application/vnd.muvee.style' => ['msty'], + 'application/vnd.mynfc' => ['taglet'], + 'application/vnd.nato.bindingdataobject+xml' => ['bdo'], + 'application/vnd.neurolanguage.nlu' => ['nlu'], + 'application/vnd.nitf' => ['ntf', 'nitf'], + 'application/vnd.noblenet-directory' => ['nnd'], + 'application/vnd.noblenet-sealer' => ['nns'], + 'application/vnd.noblenet-web' => ['nnw'], + 'application/vnd.nokia.n-gage.ac+xml' => ['ac'], + 'application/vnd.nokia.n-gage.data' => ['ngdat'], + 'application/vnd.nokia.n-gage.symbian.install' => ['n-gage'], + 'application/vnd.nokia.radio-preset' => ['rpst'], + 'application/vnd.nokia.radio-presets' => ['rpss'], + 'application/vnd.novadigm.edm' => ['edm'], + 'application/vnd.novadigm.edx' => ['edx'], + 'application/vnd.novadigm.ext' => ['ext'], + 'application/vnd.oasis.opendocument.chart' => ['odc'], + 'application/vnd.oasis.opendocument.chart-template' => ['otc'], + 'application/vnd.oasis.opendocument.database' => ['odb'], + 'application/vnd.oasis.opendocument.formula' => ['odf'], + 'application/vnd.oasis.opendocument.formula-template' => ['odft'], + 'application/vnd.oasis.opendocument.graphics' => ['odg'], + 'application/vnd.oasis.opendocument.graphics-template' => ['otg'], + 'application/vnd.oasis.opendocument.image' => ['odi'], + 'application/vnd.oasis.opendocument.image-template' => ['oti'], + 'application/vnd.oasis.opendocument.presentation' => ['odp'], + 'application/vnd.oasis.opendocument.presentation-template' => ['otp'], + 'application/vnd.oasis.opendocument.spreadsheet' => ['ods'], + 'application/vnd.oasis.opendocument.spreadsheet-template' => ['ots'], + 'application/vnd.oasis.opendocument.text' => ['odt'], + 'application/vnd.oasis.opendocument.text-master' => ['odm'], + 'application/vnd.oasis.opendocument.text-template' => ['ott'], + 'application/vnd.oasis.opendocument.text-web' => ['oth'], + 'application/vnd.olpc-sugar' => ['xo'], + 'application/vnd.oma.dd2+xml' => ['dd2'], + 'application/vnd.openblox.game+xml' => ['obgx'], + 'application/vnd.openofficeorg.extension' => ['oxt'], + 'application/vnd.openstreetmap.data+xml' => ['osm'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => ['pptx'], + 'application/vnd.openxmlformats-officedocument.presentationml.slide' => ['sldx'], + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow' => ['ppsx'], + 'application/vnd.openxmlformats-officedocument.presentationml.template' => ['potx'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template' => ['xltx'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['docx'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' => ['dotx'], + 'application/vnd.osgeo.mapguide.package' => ['mgp'], + 'application/vnd.osgi.dp' => ['dp'], + 'application/vnd.osgi.subsystem' => ['esa'], + 'application/vnd.palm' => ['pdb', 'pqa', 'oprc'], + 'application/vnd.pawaafile' => ['paw'], + 'application/vnd.pg.format' => ['str'], + 'application/vnd.pg.osasli' => ['ei6'], + 'application/vnd.picsel' => ['efif'], + 'application/vnd.pmi.widget' => ['wg'], + 'application/vnd.pocketlearn' => ['plf'], + 'application/vnd.powerbuilder6' => ['pbd'], + 'application/vnd.previewsystems.box' => ['box'], + 'application/vnd.procrate.brushset' => ['brushset'], + 'application/vnd.procreate.brush' => ['brush'], + 'application/vnd.procreate.dream' => ['drm'], + 'application/vnd.proteus.magazine' => ['mgz'], + 'application/vnd.publishare-delta-tree' => ['qps'], + 'application/vnd.pvi.ptid1' => ['ptid'], + 'application/vnd.pwg-xhtml-print+xml' => ['xhtm'], + 'application/vnd.quark.quarkxpress' => ['qxd', 'qxt', 'qwd', 'qwt', 'qxl', 'qxb'], + 'application/vnd.rar' => ['rar'], + 'application/vnd.realvnc.bed' => ['bed'], + 'application/vnd.recordare.musicxml' => ['mxl'], + 'application/vnd.recordare.musicxml+xml' => ['musicxml'], + 'application/vnd.rig.cryptonote' => ['cryptonote'], + 'application/vnd.rim.cod' => ['cod'], + 'application/vnd.rn-realmedia' => ['rm'], + 'application/vnd.rn-realmedia-vbr' => ['rmvb'], + 'application/vnd.route66.link66+xml' => ['link66'], + 'application/vnd.sailingtracker.track' => ['st'], + 'application/vnd.seemail' => ['see'], + 'application/vnd.sema' => ['sema'], + 'application/vnd.semd' => ['semd'], + 'application/vnd.semf' => ['semf'], + 'application/vnd.shana.informed.formdata' => ['ifm'], + 'application/vnd.shana.informed.formtemplate' => ['itp'], + 'application/vnd.shana.informed.interchange' => ['iif'], + 'application/vnd.shana.informed.package' => ['ipk'], + 'application/vnd.simtech-mindmapper' => ['twd', 'twds'], + 'application/vnd.smaf' => ['mmf'], + 'application/vnd.smart.teacher' => ['teacher'], + 'application/vnd.software602.filler.form+xml' => ['fo'], + 'application/vnd.solent.sdkm+xml' => ['sdkm', 'sdkd'], + 'application/vnd.spotfire.dxp' => ['dxp'], + 'application/vnd.spotfire.sfs' => ['sfs'], + 'application/vnd.stardivision.calc' => ['sdc'], + 'application/vnd.stardivision.draw' => ['sda'], + 'application/vnd.stardivision.impress' => ['sdd'], + 'application/vnd.stardivision.math' => ['smf'], + 'application/vnd.stardivision.writer' => ['sdw', 'vor'], + 'application/vnd.stardivision.writer-global' => ['sgl'], + 'application/vnd.stepmania.package' => ['smzip'], + 'application/vnd.stepmania.stepchart' => ['sm'], + 'application/vnd.sun.wadl+xml' => ['wadl'], + 'application/vnd.sun.xml.calc' => ['sxc'], + 'application/vnd.sun.xml.calc.template' => ['stc'], + 'application/vnd.sun.xml.draw' => ['sxd'], + 'application/vnd.sun.xml.draw.template' => ['std'], + 'application/vnd.sun.xml.impress' => ['sxi'], + 'application/vnd.sun.xml.impress.template' => ['sti'], + 'application/vnd.sun.xml.math' => ['sxm'], + 'application/vnd.sun.xml.writer' => ['sxw'], + 'application/vnd.sun.xml.writer.global' => ['sxg'], + 'application/vnd.sun.xml.writer.template' => ['stw'], + 'application/vnd.sus-calendar' => ['sus', 'susp'], + 'application/vnd.svd' => ['svd'], + 'application/vnd.symbian.install' => ['sis', 'sisx'], + 'application/vnd.syncml+xml' => ['xsm'], + 'application/vnd.syncml.dm+wbxml' => ['bdm'], + 'application/vnd.syncml.dm+xml' => ['xdm'], + 'application/vnd.syncml.dmddf+xml' => ['ddf'], + 'application/vnd.tao.intent-module-archive' => ['tao'], + 'application/vnd.tcpdump.pcap' => ['pcap', 'cap', 'dmp'], + 'application/vnd.tmobile-livetv' => ['tmo'], + 'application/vnd.trid.tpt' => ['tpt'], + 'application/vnd.triscape.mxs' => ['mxs'], + 'application/vnd.trueapp' => ['tra'], + 'application/vnd.ufdl' => ['ufd', 'ufdl'], + 'application/vnd.uiq.theme' => ['utz'], + 'application/vnd.umajin' => ['umj'], + 'application/vnd.unity' => ['unityweb'], + 'application/vnd.uoml+xml' => ['uoml', 'uo'], + 'application/vnd.vcx' => ['vcx'], + 'application/vnd.visio' => ['vsd', 'vst', 'vss', 'vsw', 'vsdx', 'vtx'], + 'application/vnd.visionary' => ['vis'], + 'application/vnd.vsf' => ['vsf'], + 'application/vnd.wap.wbxml' => ['wbxml'], + 'application/vnd.wap.wmlc' => ['wmlc'], + 'application/vnd.wap.wmlscriptc' => ['wmlsc'], + 'application/vnd.webturbo' => ['wtb'], + 'application/vnd.wolfram.player' => ['nbp'], + 'application/vnd.wordperfect' => ['wpd'], + 'application/vnd.wqd' => ['wqd'], + 'application/vnd.wt.stf' => ['stf'], + 'application/vnd.xara' => ['xar'], + 'application/vnd.xfdl' => ['xfdl'], + 'application/vnd.yamaha.hv-dic' => ['hvd'], + 'application/vnd.yamaha.hv-script' => ['hvs'], + 'application/vnd.yamaha.hv-voice' => ['hvp'], + 'application/vnd.yamaha.openscoreformat' => ['osf'], + 'application/vnd.yamaha.openscoreformat.osfpvg+xml' => ['osfpvg'], + 'application/vnd.yamaha.smaf-audio' => ['saf'], + 'application/vnd.yamaha.smaf-phrase' => ['spf'], + 'application/vnd.yellowriver-custom-menu' => ['cmp'], + 'application/vnd.zul' => ['zir', 'zirz'], + 'application/vnd.zzazz.deck+xml' => ['zaz'], + 'application/voicexml+xml' => ['vxml'], + 'application/wasm' => ['wasm'], + 'application/watcherinfo+xml' => ['wif'], + 'application/widget' => ['wgt'], + 'application/winhlp' => ['hlp'], + 'application/wsdl+xml' => ['wsdl'], + 'application/wspolicy+xml' => ['wspolicy'], + 'application/x-7z-compressed' => ['7z'], + 'application/x-abiword' => ['abw'], + 'application/x-ace-compressed' => ['ace'], + 'application/x-apple-diskimage' => ['dmg'], + 'application/x-arj' => ['arj'], + 'application/x-authorware-bin' => ['aab', 'x32', 'u32', 'vox'], + 'application/x-authorware-map' => ['aam'], + 'application/x-authorware-seg' => ['aas'], + 'application/x-bcpio' => ['bcpio'], + 'application/x-bdoc' => ['bdoc'], + 'application/x-bittorrent' => ['torrent'], + 'application/x-blender' => ['blend'], + 'application/x-blorb' => ['blb', 'blorb'], + 'application/x-bzip' => ['bz'], + 'application/x-bzip2' => ['bz2', 'boz'], + 'application/x-cbr' => ['cbr', 'cba', 'cbt', 'cbz', 'cb7'], + 'application/x-cdlink' => ['vcd'], + 'application/x-cfs-compressed' => ['cfs'], + 'application/x-chat' => ['chat'], + 'application/x-chess-pgn' => ['pgn'], + 'application/x-chrome-extension' => ['crx'], + 'application/x-cocoa' => ['cco'], + 'application/x-compressed' => ['rar'], + 'application/x-conference' => ['nsc'], + 'application/x-cpio' => ['cpio'], + 'application/x-csh' => ['csh'], + 'application/x-debian-package' => ['deb', 'udeb'], + 'application/x-dgc-compressed' => ['dgc'], + 'application/x-director' => ['dir', 'dcr', 'dxr', 'cst', 'cct', 'cxt', 'w3d', 'fgd', 'swa'], + 'application/x-doom' => ['wad'], + 'application/x-dtbncx+xml' => ['ncx'], + 'application/x-dtbook+xml' => ['dtb'], + 'application/x-dtbresource+xml' => ['res'], + 'application/x-dvi' => ['dvi'], + 'application/x-envoy' => ['evy'], + 'application/x-eva' => ['eva'], + 'application/x-font-bdf' => ['bdf'], + 'application/x-font-ghostscript' => ['gsf'], + 'application/x-font-linux-psf' => ['psf'], + 'application/x-font-pcf' => ['pcf'], + 'application/x-font-snf' => ['snf'], + 'application/x-font-type1' => ['pfa', 'pfb', 'pfm', 'afm'], + 'application/x-freearc' => ['arc'], + 'application/x-futuresplash' => ['spl'], + 'application/x-gca-compressed' => ['gca'], + 'application/x-glulx' => ['ulx'], + 'application/x-gnumeric' => ['gnumeric'], + 'application/x-gramps-xml' => ['gramps'], + 'application/x-gtar' => ['gtar'], + 'application/x-hdf' => ['hdf'], + 'application/x-httpd-php' => ['php'], + 'application/x-install-instructions' => ['install'], + 'application/x-ipynb+json' => ['ipynb'], + 'application/x-iso9660-image' => ['iso'], + 'application/x-iwork-keynote-sffkey' => ['key'], + 'application/x-iwork-numbers-sffnumbers' => ['numbers'], + 'application/x-iwork-pages-sffpages' => ['pages'], + 'application/x-java-archive-diff' => ['jardiff'], + 'application/x-java-jnlp-file' => ['jnlp'], + 'application/x-keepass2' => ['kdbx'], + 'application/x-latex' => ['latex'], + 'application/x-lua-bytecode' => ['luac'], + 'application/x-lzh-compressed' => ['lzh', 'lha'], + 'application/x-makeself' => ['run'], + 'application/x-mie' => ['mie'], + 'application/x-mobipocket-ebook' => ['prc', 'mobi'], + 'application/x-ms-application' => ['application'], + 'application/x-ms-shortcut' => ['lnk'], + 'application/x-ms-wmd' => ['wmd'], + 'application/x-ms-wmz' => ['wmz'], + 'application/x-ms-xbap' => ['xbap'], + 'application/x-msaccess' => ['mdb'], + 'application/x-msbinder' => ['obd'], + 'application/x-mscardfile' => ['crd'], + 'application/x-msclip' => ['clp'], + 'application/x-msdos-program' => ['exe'], + 'application/x-msdownload' => ['exe', 'dll', 'com', 'bat', 'msi'], + 'application/x-msmediaview' => ['mvb', 'm13', 'm14'], + 'application/x-msmetafile' => ['wmf', 'wmz', 'emf', 'emz'], + 'application/x-msmoney' => ['mny'], + 'application/x-mspublisher' => ['pub'], + 'application/x-msschedule' => ['scd'], + 'application/x-msterminal' => ['trm'], + 'application/x-mswrite' => ['wri'], + 'application/x-netcdf' => ['nc', 'cdf'], + 'application/x-ns-proxy-autoconfig' => ['pac'], + 'application/x-nzb' => ['nzb'], + 'application/x-perl' => ['pl', 'pm'], + 'application/x-pilot' => ['prc', 'pdb'], + 'application/x-pkcs12' => ['p12', 'pfx'], + 'application/x-pkcs7-certificates' => ['p7b', 'spc'], + 'application/x-pkcs7-certreqresp' => ['p7r'], + 'application/x-rar-compressed' => ['rar'], + 'application/x-redhat-package-manager' => ['rpm'], + 'application/x-research-info-systems' => ['ris'], + 'application/x-sea' => ['sea'], + 'application/x-sh' => ['sh'], + 'application/x-shar' => ['shar'], + 'application/x-shockwave-flash' => ['swf'], + 'application/x-silverlight-app' => ['xap'], + 'application/x-sql' => ['sql'], + 'application/x-stuffit' => ['sit'], + 'application/x-stuffitx' => ['sitx'], + 'application/x-subrip' => ['srt'], + 'application/x-sv4cpio' => ['sv4cpio'], + 'application/x-sv4crc' => ['sv4crc'], + 'application/x-t3vm-image' => ['t3'], + 'application/x-tads' => ['gam'], + 'application/x-tar' => ['tar'], + 'application/x-tcl' => ['tcl', 'tk'], + 'application/x-tex' => ['tex'], + 'application/x-tex-tfm' => ['tfm'], + 'application/x-texinfo' => ['texinfo', 'texi'], + 'application/x-tgif' => ['obj'], + 'application/x-ustar' => ['ustar'], + 'application/x-virtualbox-hdd' => ['hdd'], + 'application/x-virtualbox-ova' => ['ova'], + 'application/x-virtualbox-ovf' => ['ovf'], + 'application/x-virtualbox-vbox' => ['vbox'], + 'application/x-virtualbox-vbox-extpack' => ['vbox-extpack'], + 'application/x-virtualbox-vdi' => ['vdi'], + 'application/x-virtualbox-vhd' => ['vhd'], + 'application/x-virtualbox-vmdk' => ['vmdk'], + 'application/x-wais-source' => ['src'], + 'application/x-web-app-manifest+json' => ['webapp'], + 'application/x-x509-ca-cert' => ['der', 'crt', 'pem'], + 'application/x-xfig' => ['fig'], + 'application/x-xliff+xml' => ['xlf'], + 'application/x-xpinstall' => ['xpi'], + 'application/x-xz' => ['xz'], + 'application/x-zip-compressed' => ['zip'], + 'application/x-zmachine' => ['z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7', 'z8'], + 'application/xaml+xml' => ['xaml'], + 'application/xcap-att+xml' => ['xav'], + 'application/xcap-caps+xml' => ['xca'], + 'application/xcap-diff+xml' => ['xdf'], + 'application/xcap-el+xml' => ['xel'], + 'application/xcap-ns+xml' => ['xns'], + 'application/xenc+xml' => ['xenc'], + 'application/xfdf' => ['xfdf'], + 'application/xhtml+xml' => ['xhtml', 'xht'], + 'application/xliff+xml' => ['xlf'], + 'application/xml' => ['xml', 'xsl', 'xsd', 'rng'], + 'application/xml-dtd' => ['dtd'], + 'application/xop+xml' => ['xop'], + 'application/xproc+xml' => ['xpl'], + 'application/xslt+xml' => ['xsl', 'xslt'], + 'application/xspf+xml' => ['xspf'], + 'application/xv+xml' => ['mxml', 'xhvml', 'xvml', 'xvm'], + 'application/yaml' => ['yaml', 'yml'], + 'application/yang' => ['yang'], + 'application/yin+xml' => ['yin'], + 'application/zip' => ['zip'], + 'application/zip+dotlottie' => ['lottie'], + 'audio/3gpp' => ['3gpp'], + 'audio/aac' => ['adts', 'aac'], + 'audio/adpcm' => ['adp'], + 'audio/amr' => ['amr'], + 'audio/basic' => ['au', 'snd'], + 'audio/midi' => ['mid', 'midi', 'kar', 'rmi'], + 'audio/mobile-xmf' => ['mxmf'], + 'audio/mp3' => ['mp3'], + 'audio/mp4' => ['m4a', 'mp4a', 'm4b'], + 'audio/mpeg' => ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'], + 'audio/ogg' => ['oga', 'ogg', 'spx', 'opus'], + 'audio/s3m' => ['s3m'], + 'audio/silk' => ['sil'], + 'audio/vnd.dece.audio' => ['uva', 'uvva'], + 'audio/vnd.digital-winds' => ['eol'], + 'audio/vnd.dra' => ['dra'], + 'audio/vnd.dts' => ['dts'], + 'audio/vnd.dts.hd' => ['dtshd'], + 'audio/vnd.lucent.voice' => ['lvp'], + 'audio/vnd.ms-playready.media.pya' => ['pya'], + 'audio/vnd.nuera.ecelp4800' => ['ecelp4800'], + 'audio/vnd.nuera.ecelp7470' => ['ecelp7470'], + 'audio/vnd.nuera.ecelp9600' => ['ecelp9600'], + 'audio/vnd.rip' => ['rip'], + 'audio/wav' => ['wav'], + 'audio/wave' => ['wav'], + 'audio/webm' => ['weba'], + 'audio/x-aac' => ['aac'], + 'audio/x-aiff' => ['aif', 'aiff', 'aifc'], + 'audio/x-caf' => ['caf'], + 'audio/x-flac' => ['flac'], + 'audio/x-m4a' => ['m4a'], + 'audio/x-matroska' => ['mka'], + 'audio/x-mpegurl' => ['m3u'], + 'audio/x-ms-wax' => ['wax'], + 'audio/x-ms-wma' => ['wma'], + 'audio/x-pn-realaudio' => ['ram', 'ra'], + 'audio/x-pn-realaudio-plugin' => ['rmp'], + 'audio/x-realaudio' => ['ra'], + 'audio/x-wav' => ['wav'], + 'audio/xm' => ['xm'], + 'chemical/x-cdx' => ['cdx'], + 'chemical/x-cif' => ['cif'], + 'chemical/x-cmdf' => ['cmdf'], + 'chemical/x-cml' => ['cml'], + 'chemical/x-csml' => ['csml'], + 'chemical/x-xyz' => ['xyz'], + 'font/collection' => ['ttc'], + 'font/otf' => ['otf'], + 'font/ttf' => ['ttf'], + 'font/woff' => ['woff'], + 'font/woff2' => ['woff2'], + 'image/aces' => ['exr'], + 'image/apng' => ['apng'], + 'image/avci' => ['avci'], + 'image/avcs' => ['avcs'], + 'image/avif' => ['avif'], + 'image/bmp' => ['bmp', 'dib'], + 'image/cgm' => ['cgm'], + 'image/dicom-rle' => ['drle'], + 'image/dpx' => ['dpx'], + 'image/emf' => ['emf'], + 'image/fits' => ['fits'], + 'image/g3fax' => ['g3'], + 'image/gif' => ['gif'], + 'image/heic' => ['heic'], + 'image/heic-sequence' => ['heics'], + 'image/heif' => ['heif'], + 'image/heif-sequence' => ['heifs'], + 'image/hej2k' => ['hej2'], + 'image/ief' => ['ief'], + 'image/jaii' => ['jaii'], + 'image/jais' => ['jais'], + 'image/jls' => ['jls'], + 'image/jp2' => ['jp2', 'jpg2'], + 'image/jpeg' => ['jpg', 'jpeg', 'jpe'], + 'image/jph' => ['jph'], + 'image/jphc' => ['jhc'], + 'image/jpm' => ['jpm', 'jpgm'], + 'image/jpx' => ['jpx', 'jpf'], + 'image/jxl' => ['jxl'], + 'image/jxr' => ['jxr'], + 'image/jxra' => ['jxra'], + 'image/jxrs' => ['jxrs'], + 'image/jxs' => ['jxs'], + 'image/jxsc' => ['jxsc'], + 'image/jxsi' => ['jxsi'], + 'image/jxss' => ['jxss'], + 'image/ktx' => ['ktx'], + 'image/ktx2' => ['ktx2'], + 'image/pjpeg' => ['jfif'], + 'image/png' => ['png'], + 'image/prs.btif' => ['btif', 'btf'], + 'image/prs.pti' => ['pti'], + 'image/sgi' => ['sgi'], + 'image/svg+xml' => ['svg', 'svgz'], + 'image/t38' => ['t38'], + 'image/tiff' => ['tif', 'tiff'], + 'image/tiff-fx' => ['tfx'], + 'image/vnd.adobe.photoshop' => ['psd'], + 'image/vnd.airzip.accelerator.azv' => ['azv'], + 'image/vnd.dece.graphic' => ['uvi', 'uvvi', 'uvg', 'uvvg'], + 'image/vnd.djvu' => ['djvu', 'djv'], + 'image/vnd.dvb.subtitle' => ['sub'], + 'image/vnd.dwg' => ['dwg'], + 'image/vnd.dxf' => ['dxf'], + 'image/vnd.fastbidsheet' => ['fbs'], + 'image/vnd.fpx' => ['fpx'], + 'image/vnd.fst' => ['fst'], + 'image/vnd.fujixerox.edmics-mmr' => ['mmr'], + 'image/vnd.fujixerox.edmics-rlc' => ['rlc'], + 'image/vnd.microsoft.icon' => ['ico'], + 'image/vnd.ms-dds' => ['dds'], + 'image/vnd.ms-modi' => ['mdi'], + 'image/vnd.ms-photo' => ['wdp'], + 'image/vnd.net-fpx' => ['npx'], + 'image/vnd.pco.b16' => ['b16'], + 'image/vnd.tencent.tap' => ['tap'], + 'image/vnd.valve.source.texture' => ['vtf'], + 'image/vnd.wap.wbmp' => ['wbmp'], + 'image/vnd.xiff' => ['xif'], + 'image/vnd.zbrush.pcx' => ['pcx'], + 'image/webp' => ['webp'], + 'image/wmf' => ['wmf'], + 'image/x-3ds' => ['3ds'], + 'image/x-adobe-dng' => ['dng'], + 'image/x-cmu-raster' => ['ras'], + 'image/x-cmx' => ['cmx'], + 'image/x-freehand' => ['fh', 'fhc', 'fh4', 'fh5', 'fh7'], + 'image/x-icon' => ['ico'], + 'image/x-jng' => ['jng'], + 'image/x-mrsid-image' => ['sid'], + 'image/x-ms-bmp' => ['bmp'], + 'image/x-pcx' => ['pcx'], + 'image/x-pict' => ['pic', 'pct'], + 'image/x-portable-anymap' => ['pnm'], + 'image/x-portable-bitmap' => ['pbm'], + 'image/x-portable-graymap' => ['pgm'], + 'image/x-portable-pixmap' => ['ppm'], + 'image/x-rgb' => ['rgb'], + 'image/x-tga' => ['tga'], + 'image/x-xbitmap' => ['xbm'], + 'image/x-xpixmap' => ['xpm'], + 'image/x-xwindowdump' => ['xwd'], + 'message/disposition-notification' => ['disposition-notification'], + 'message/global' => ['u8msg'], + 'message/global-delivery-status' => ['u8dsn'], + 'message/global-disposition-notification' => ['u8mdn'], + 'message/global-headers' => ['u8hdr'], + 'message/rfc822' => ['eml', 'mime', 'mht', 'mhtml'], + 'message/vnd.wfa.wsc' => ['wsc'], + 'model/3mf' => ['3mf'], + 'model/gltf+json' => ['gltf'], + 'model/gltf-binary' => ['glb'], + 'model/iges' => ['igs', 'iges'], + 'model/jt' => ['jt'], + 'model/mesh' => ['msh', 'mesh', 'silo'], + 'model/mtl' => ['mtl'], + 'model/obj' => ['obj'], + 'model/prc' => ['prc'], + 'model/step' => ['step', 'stp', 'stpnc', 'p21', '210'], + 'model/step+xml' => ['stpx'], + 'model/step+zip' => ['stpz'], + 'model/step-xml+zip' => ['stpxz'], + 'model/stl' => ['stl'], + 'model/u3d' => ['u3d'], + 'model/vnd.bary' => ['bary'], + 'model/vnd.cld' => ['cld'], + 'model/vnd.collada+xml' => ['dae'], + 'model/vnd.dwf' => ['dwf'], + 'model/vnd.gdl' => ['gdl'], + 'model/vnd.gtw' => ['gtw'], + 'model/vnd.mts' => ['mts'], + 'model/vnd.opengex' => ['ogex'], + 'model/vnd.parasolid.transmit.binary' => ['x_b'], + 'model/vnd.parasolid.transmit.text' => ['x_t'], + 'model/vnd.pytha.pyox' => ['pyo', 'pyox'], + 'model/vnd.sap.vds' => ['vds'], + 'model/vnd.usda' => ['usda'], + 'model/vnd.usdz+zip' => ['usdz'], + 'model/vnd.valve.source.compiled-map' => ['bsp'], + 'model/vnd.vtu' => ['vtu'], + 'model/vrml' => ['wrl', 'vrml'], + 'model/x3d+binary' => ['x3db', 'x3dbz'], + 'model/x3d+fastinfoset' => ['x3db'], + 'model/x3d+vrml' => ['x3dv', 'x3dvz'], + 'model/x3d+xml' => ['x3d', 'x3dz'], + 'model/x3d-vrml' => ['x3dv'], + 'text/cache-manifest' => ['appcache', 'manifest'], + 'text/calendar' => ['ics', 'ifb'], + 'text/coffeescript' => ['coffee', 'litcoffee'], + 'text/css' => ['css'], + 'text/csv' => ['csv'], + 'text/html' => ['html', 'htm', 'shtml'], + 'text/jade' => ['jade'], + 'text/javascript' => ['js', 'mjs'], + 'text/jsx' => ['jsx'], + 'text/less' => ['less'], + 'text/markdown' => ['md', 'markdown'], + 'text/mathml' => ['mml'], + 'text/mdx' => ['mdx'], + 'text/n3' => ['n3'], + 'text/plain' => ['txt', 'text', 'conf', 'def', 'list', 'log', 'in', 'ini'], + 'text/prs.lines.tag' => ['dsc'], + 'text/richtext' => ['rtx'], + 'text/rtf' => ['rtf'], + 'text/sgml' => ['sgml', 'sgm'], + 'text/shex' => ['shex'], + 'text/slim' => ['slim', 'slm'], + 'text/spdx' => ['spdx'], + 'text/stylus' => ['stylus', 'styl'], + 'text/tab-separated-values' => ['tsv'], + 'text/troff' => ['t', 'tr', 'roff', 'man', 'me', 'ms'], + 'text/turtle' => ['ttl'], + 'text/uri-list' => ['uri', 'uris', 'urls'], + 'text/vcard' => ['vcard', 'vcf'], + 'text/vnd.curl' => ['curl'], + 'text/vnd.curl.dcurl' => ['dcurl'], + 'text/vnd.curl.mcurl' => ['mcurl'], + 'text/vnd.curl.scurl' => ['scurl'], + 'text/vnd.dvb.subtitle' => ['sub'], + 'text/vnd.familysearch.gedcom' => ['ged'], + 'text/vnd.fly' => ['fly'], + 'text/vnd.fmi.flexstor' => ['flx'], + 'text/vnd.graphviz' => ['gv'], + 'text/vnd.in3d.3dml' => ['3dml'], + 'text/vnd.in3d.spot' => ['spot'], + 'text/vnd.sun.j2me.app-descriptor' => ['jad'], + 'text/vnd.wap.wml' => ['wml'], + 'text/vnd.wap.wmlscript' => ['wmls'], + 'text/vtt' => ['vtt'], + 'text/wgsl' => ['wgsl'], + 'text/x-asm' => ['s', 'asm'], + 'text/x-c' => ['c', 'cc', 'cxx', 'cpp', 'h', 'hh', 'dic'], + 'text/x-component' => ['htc'], + 'text/x-fortran' => ['f', 'for', 'f77', 'f90'], + 'text/x-handlebars-template' => ['hbs'], + 'text/x-java-source' => ['java'], + 'text/x-lua' => ['lua'], + 'text/x-markdown' => ['mkd'], + 'text/x-nfo' => ['nfo'], + 'text/x-opml' => ['opml'], + 'text/x-org' => ['org'], + 'text/x-pascal' => ['p', 'pas'], + 'text/x-processing' => ['pde'], + 'text/x-sass' => ['sass'], + 'text/x-scss' => ['scss'], + 'text/x-setext' => ['etx'], + 'text/x-sfv' => ['sfv'], + 'text/x-suse-ymp' => ['ymp'], + 'text/x-uuencode' => ['uu'], + 'text/x-vcalendar' => ['vcs'], + 'text/x-vcard' => ['vcf'], + 'text/xml' => ['xml'], + 'text/yaml' => ['yaml', 'yml'], + 'video/3gpp' => ['3gp', '3gpp'], + 'video/3gpp2' => ['3g2'], + 'video/h261' => ['h261'], + 'video/h263' => ['h263'], + 'video/h264' => ['h264'], + 'video/iso.segment' => ['m4s'], + 'video/jpeg' => ['jpgv'], + 'video/jpm' => ['jpm', 'jpgm'], + 'video/mj2' => ['mj2', 'mjp2'], + 'video/mp2t' => ['ts', 'm2t', 'm2ts', 'mts'], + 'video/mp4' => ['mp4', 'mp4v', 'mpg4'], + 'video/mpeg' => ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'], + 'video/ogg' => ['ogv'], + 'video/quicktime' => ['qt', 'mov'], + 'video/vnd.dece.hd' => ['uvh', 'uvvh'], + 'video/vnd.dece.mobile' => ['uvm', 'uvvm'], + 'video/vnd.dece.pd' => ['uvp', 'uvvp'], + 'video/vnd.dece.sd' => ['uvs', 'uvvs'], + 'video/vnd.dece.video' => ['uvv', 'uvvv'], + 'video/vnd.dvb.file' => ['dvb'], + 'video/vnd.fvt' => ['fvt'], + 'video/vnd.mpegurl' => ['mxu', 'm4u'], + 'video/vnd.ms-playready.media.pyv' => ['pyv'], + 'video/vnd.uvvu.mp4' => ['uvu', 'uvvu'], + 'video/vnd.vivo' => ['viv'], + 'video/webm' => ['webm'], + 'video/x-f4v' => ['f4v'], + 'video/x-fli' => ['fli'], + 'video/x-flv' => ['flv'], + 'video/x-m4v' => ['m4v'], + 'video/x-matroska' => ['mkv', 'mk3d', 'mks'], + 'video/x-mng' => ['mng'], + 'video/x-ms-asf' => ['asf', 'asx'], + 'video/x-ms-vob' => ['vob'], + 'video/x-ms-wm' => ['wm'], + 'video/x-ms-wmv' => ['wmv'], + 'video/x-ms-wmx' => ['wmx'], + 'video/x-ms-wvx' => ['wvx'], + 'video/x-msvideo' => ['avi'], + 'video/x-sgi-movie' => ['movie'], + 'video/x-smv' => ['smv'], + 'x-conference/x-cooltalk' => ['ice'], + ]; + + /** + * @return array<string, List<string>> + */ + public function getMap(): array + { + return $this->map; + } + + /** + * @return List<string> + */ + public function getMimeTypes(): array + { + return array_keys($this->map); + } +} diff --git a/Classes/Resource/MimeTypeCompatibilityTypeGuesser.php b/Classes/Resource/MimeTypeCompatibilityTypeGuesser.php new file mode 100644 index 0000000..6864bd3 --- /dev/null +++ b/Classes/Resource/MimeTypeCompatibilityTypeGuesser.php @@ -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\Core\Resource; + +/** + * Map text/plain to concrete mime types, based on their + * supplied file extension. + * This mapping is only allowed for text/plain + * + * @internal + */ +final readonly class MimeTypeCompatibilityTypeGuesser +{ + private array $mimeTypeCompatibility; + + public function __construct() + { + $this->mimeTypeCompatibility = $this->buildMimeTypeCompatibilityList(); + } + + public function guessMimeType(array &$parameters, \SplFileInfo $fileInfo): void + { + $mimeType = $parameters['mimeType']; + $map = $this->mimeTypeCompatibility[$mimeType] ?? null; + if (!is_array($map)) { + return; + } + + $fileName = $parameters['targetFileName'] ?? $fileInfo->getFilename(); + $extension = $this->getFileExtension($fileName); + if (isset($map[$extension])) { + $parameters['mimeType'] = $map[$extension]; + } + } + + public function getMimeTypeCompatibilityList(): array + { + return $this->mimeTypeCompatibility; + } + + private function buildMimeTypeCompatibilityList(): array + { + $mimeTypeCompatibility = []; + + foreach ((new MimeTypeCollection())->getMap() as $mimeType => $extensions) { + if (str_ends_with($mimeType, '+xml')) { + foreach ($extensions as $extension) { + $mimeTypeCompatibility['text/xml'][$extension] = $mimeType; + } + } elseif (str_ends_with($mimeType, '+json')) { + foreach ($extensions as $extension) { + // Some PHP variants can detect application/json, some detect text/plain + $mimeTypeCompatibility['application/json'][$extension] = $mimeType; + $mimeTypeCompatibility['text/plain'][$extension] = $mimeType; + } + } elseif ( + str_ends_with($mimeType, '+yaml') + || (str_starts_with($mimeType, 'text/') && $mimeType !== 'text/plain') + ) { + foreach ($extensions as $extension) { + $mimeTypeCompatibility['text/plain'][$extension] = $mimeType; + } + } + } + + foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility'] ?? [] as $mimeType => $map) { + foreach ($map as $extension => $newMimeType) { + $mimeTypeCompatibility[$mimeType][$extension] = $newMimeType; + } + } + + return $mimeTypeCompatibility; + } + + private function getFileExtension(string $filename): string + { + $pos = strrpos($filename, '.'); + return $pos === false ? '' : mb_strtolower(substr($filename, $pos + 1)); + } +} diff --git a/Classes/Resource/MimeTypeDetector.php b/Classes/Resource/MimeTypeDetector.php new file mode 100644 index 0000000..09435f5 --- /dev/null +++ b/Classes/Resource/MimeTypeDetector.php @@ -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\Core\Resource; + +/** + * This class contains a list of all available / known mimetypes and file extensions, + * and is automatically generated by TYPO3 via Core/Build/Scripts/generateMimeTypes.php + */ +final class MimeTypeDetector +{ + private MimeTypeCollection $collection; + + public function __construct() + { + $this->collection = new MimeTypeCollection(); + } + + /** + * @return array<int, string> + */ + public function getMimeTypesForFileExtension(string $fileExtension): array + { + $mimeTypes = []; + $fileExtension = strtolower($fileExtension); + foreach ($this->collection->getMap() as $mimeType => $availableExtensions) { + if (in_array($fileExtension, $availableExtensions, true)) { + $mimeTypes[] = $mimeType; + } + } + return $mimeTypes; + } + + /** + * @return array<int, string> + */ + public function getFileExtensionsForMimeType(string $mimeType): array + { + return $this->collection->getMap()[strtolower($mimeType)] ?? []; + } +} diff --git a/Classes/Resource/OnlineMedia/Event/AfterVideoPreviewFetchedEvent.php b/Classes/Resource/OnlineMedia/Event/AfterVideoPreviewFetchedEvent.php new file mode 100644 index 0000000..9e9db0e --- /dev/null +++ b/Classes/Resource/OnlineMedia/Event/AfterVideoPreviewFetchedEvent.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\OnlineMedia\Event; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperInterface; + +/** + * Allows to modify a generated YouTube/Vimeo (or other Online Media) preview images + */ +final class AfterVideoPreviewFetchedEvent +{ + public function __construct( + private readonly File $file, + private readonly OnlineMediaHelperInterface $onlineMediaHelper, + private string $previewImageFilename + ) {} + + public function getFile(): File + { + return $this->file; + } + + public function getOnlineMediaId(): string + { + return $this->onlineMediaHelper->getOnlineMediaId($this->file); + } + + public function getPreviewImageFilename(): string + { + return $this->previewImageFilename; + } + + public function setPreviewImageFilename(string $previewImageFilename): void + { + $this->previewImageFilename = $previewImageFilename; + } +} diff --git a/Classes/Resource/OnlineMedia/Helpers/AbstractOEmbedHelper.php b/Classes/Resource/OnlineMedia/Helpers/AbstractOEmbedHelper.php new file mode 100644 index 0000000..3eef326 --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/AbstractOEmbedHelper.php @@ -0,0 +1,101 @@ +<?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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Resource\Exception\OnlineMediaAlreadyExistsException; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * See http://oembed.com/ for more on OEmbed specification + */ +abstract class AbstractOEmbedHelper extends AbstractOnlineMediaHelper +{ + /** + * @param string $mediaId + * @param string $format + * @return string + */ + abstract protected function getOEmbedUrl($mediaId, $format = 'json'); + + /** + * Transform mediaId to File + * + * @param string $mediaId + * @param string $fileExtension + * @return File + */ + protected function transformMediaIdToFile($mediaId, Folder $targetFolder, $fileExtension) + { + $file = $this->findExistingFileByOnlineMediaId($mediaId, $targetFolder, $fileExtension); + if ($file !== null) { + throw new OnlineMediaAlreadyExistsException($file, 1695236851); + } + // no existing file create new + $oEmbed = $this->getOEmbedData($mediaId); + if (!empty($oEmbed['title'])) { + $fileName = $oEmbed['title'] . '.' . $fileExtension; + } else { + $fileName = $mediaId . '.' . $fileExtension; + } + return $this->createNewFile($targetFolder, $fileName, $mediaId); + } + + /** + * Get OEmbed data + * + * @param string $mediaId + * @return array|null + */ + protected function getOEmbedData($mediaId) + { + $oEmbed = (string)GeneralUtility::getUrl( + $this->getOEmbedUrl($mediaId) + ); + if ($oEmbed !== '') { + $oEmbed = json_decode($oEmbed, true); + if (is_array($oEmbed)) { + return $oEmbed; + } + } + return null; + } + + /** + * Get meta data for OnlineMedia item + * Using the meta data from oEmbed + * + * @return array with metadata + */ + public function getMetaData(File $file) + { + $metadata = []; + + $oEmbed = $this->getOEmbedData($this->getOnlineMediaId($file)); + + if (is_array($oEmbed) && $oEmbed !== []) { + $metadata['width'] = (int)($oEmbed['width'] ?? 0); + $metadata['height'] = (int)($oEmbed['height'] ?? 0); + if (empty($file->getProperty('title'))) { + $metadata['title'] = strip_tags($oEmbed['title'] ?? ''); + } + $metadata['author'] = $oEmbed['author_name'] ?? ''; + } + + return $metadata; + } +} diff --git a/Classes/Resource/OnlineMedia/Helpers/AbstractOnlineMediaHelper.php b/Classes/Resource/OnlineMedia/Helpers/AbstractOnlineMediaHelper.php new file mode 100644 index 0000000..db86c3c --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/AbstractOnlineMediaHelper.php @@ -0,0 +1,149 @@ +<?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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Resource\Exception\IllegalFileExtensionException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\Index\FileIndexRepository; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Resource\ResourceInstructionTrait; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +abstract class AbstractOnlineMediaHelper implements OnlineMediaHelperInterface +{ + use ResourceInstructionTrait; + + /** + * Cached OnlineMediaIds [fileUid => id] + * + * @var array + */ + protected $onlineMediaIdCache = []; + + /** + * File extension bind to the OnlineMedia helper + * + * @var string + */ + protected $extension = ''; + + /** + * Constructor + * + * @param string $extension file extension bind to the OnlineMedia helper + */ + public function __construct($extension) + { + $this->extension = $extension; + } + + /** + * Get Online Media item id + * + * @return string + */ + public function getOnlineMediaId(File $file) + { + if (!isset($this->onlineMediaIdCache[$file->getUid()])) { + // Limiting media identifier to 2048 bytes + if ($file->getSize() > 2048) { + return ''; + } + try { + // By definition these files only contain the ID of the remote media source + $this->onlineMediaIdCache[$file->getUid()] = trim($file->getContents()); + } catch (InsufficientFileAccessPermissionsException|IllegalFileExtensionException $e) { + // User has no access to the file - online media id can not be fetched + return ''; + } + } + return $this->onlineMediaIdCache[$file->getUid()]; + } + + /** + * Search for files with same onlineMediaId by content hash in indexed storage + * + * @param string $onlineMediaId + * @param string $fileExtension + * @return File|null + */ + protected function findExistingFileByOnlineMediaId($onlineMediaId, Folder $targetFolder, $fileExtension) + { + $file = null; + $fileHash = sha1($onlineMediaId); + $files = $this->getFileIndexRepository()->findByContentHash($fileHash); + if (!empty($files)) { + foreach ($files as $fileIndexEntry) { + if ( + $fileIndexEntry['folder_hash'] === $targetFolder->getHashedIdentifier() + && (int)$fileIndexEntry['storage'] === $targetFolder->getStorage()->getUid() + && $fileIndexEntry['extension'] === $fileExtension + ) { + $file = $this->getResourceFactory()->getFileObject($fileIndexEntry['uid'], $fileIndexEntry); + break; + } + } + } + return $file; + } + + /** + * Create new OnlineMedia item container file. + * This is created inside typo3temp/ and then moved from FAL to the proper storage. + * + * @param string $fileName + * @param string $onlineMediaId + * @return File + */ + protected function createNewFile(Folder $targetFolder, $fileName, $onlineMediaId) + { + $temporaryFile = GeneralUtility::tempnam('online_media'); + GeneralUtility::writeFileToTypo3tempDir($temporaryFile, $onlineMediaId); + $this->skipResourceConsistencyCheckForCommands($targetFolder->getStorage(), $temporaryFile, $fileName); + $file = $targetFolder->addFile($temporaryFile, $fileName, DuplicationBehavior::RENAME); + return $file; + } + + /** + * Get temporary folder path to save preview images. + * In composer-mode with TYPO3 installations, this needs to be put under public/ + * In the future this should be handled via processed file objects. + * + * @return string + */ + protected function getTempFolderPath() + { + $path = Environment::getPublicPath() . '/typo3temp/assets/online_media/'; + if (!is_dir($path)) { + GeneralUtility::mkdir_deep($path); + } + return $path; + } + + protected function getFileIndexRepository(): FileIndexRepository + { + return GeneralUtility::makeInstance(FileIndexRepository::class); + } + + protected function getResourceFactory(): ResourceFactory + { + return GeneralUtility::makeInstance(ResourceFactory::class); + } +} diff --git a/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperInterface.php b/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperInterface.php new file mode 100644 index 0000000..c5263fd --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperInterface.php @@ -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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * Interface OnlineMediaInterface + */ +interface OnlineMediaHelperInterface +{ + /** + * Constructor + * + * @param string $extension file extension bind to the OnlineMedia helper + */ + public function __construct($extension); + + /** + * Try to transform given URL to a File + * + * @param string $url + * @return File|null + */ + public function transformUrlToFile($url, Folder $targetFolder); + + /** + * Get Online Media item id + * + * @return string + */ + public function getOnlineMediaId(File $file); + + /** + * Get public url + * + * Return NULL if you want to use core default behaviour + * + * @param File $file + * @return string|null + */ + public function getPublicUrl(File $file); + + /** + * Get local absolute file path to preview image + * + * Return an empty string when no preview image is available + * + * @param File $file + * @return string + */ + public function getPreviewImage(File $file); + + /** + * Get meta data for OnlineMedia item + * + * See $GLOBALS[TCA][sys_file_metadata][columns] for possible fields to fill/use + * + * @param File $file + * @return array with metadata + */ + public function getMetaData(File $file); +} diff --git a/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperRegistry.php b/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperRegistry.php new file mode 100644 index 0000000..bb9a7f2 --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/OnlineMediaHelperRegistry.php @@ -0,0 +1,83 @@ +<?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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Online Media Source Registry + */ +class OnlineMediaHelperRegistry implements SingletonInterface +{ + /** + * Checks if there is a helper for this file extension + */ + public function hasOnlineMediaHelper(string $fileExtension): bool + { + return isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['onlineMediaHelpers'][$fileExtension]); + } + + /** + * Get helper class for given File + * + * @return false|OnlineMediaHelperInterface + */ + public function getOnlineMediaHelper(File $file) + { + $registeredHelpers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['onlineMediaHelpers']; + if (isset($registeredHelpers[$file->getExtension()])) { + return GeneralUtility::makeInstance($registeredHelpers[$file->getExtension()], $file->getExtension()); + } + return false; + } + + /** + * Try to transform given URL to a File + * + * @param string $url + * @param string[] $allowedExtensions + * @return File|null + */ + public function transformUrlToFile($url, Folder $targetFolder, $allowedExtensions = []) + { + $registeredHelpers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['onlineMediaHelpers']; + foreach ($registeredHelpers as $extension => $className) { + if (!empty($allowedExtensions) && !in_array($extension, $allowedExtensions, true)) { + continue; + } + /** @var OnlineMediaHelperInterface $helper */ + $helper = GeneralUtility::makeInstance($className, $extension); + $file = $helper->transformUrlToFile($url, $targetFolder); + if ($file !== null) { + return $file; + } + } + return null; + } + + /** + * Get all file extensions that have an OnlineMediaHelper + * + * @return string[] + */ + public function getSupportedFileExtensions() + { + return array_keys($GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['onlineMediaHelpers']); + } +} diff --git a/Classes/Resource/OnlineMedia/Helpers/VimeoHelper.php b/Classes/Resource/OnlineMedia/Helpers/VimeoHelper.php new file mode 100644 index 0000000..bd4d5df --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/VimeoHelper.php @@ -0,0 +1,98 @@ +<?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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Vimeo helper class + */ +class VimeoHelper extends AbstractOEmbedHelper +{ + /** + * Get public url + * Return NULL if you want to use core default behaviour + * + * @return string|null + */ + public function getPublicUrl(File $file) + { + $videoId = $this->getOnlineMediaId($file); + return sprintf('https://vimeo.com/%s', rawurlencode($videoId)); + } + + /** + * Get local absolute file path to preview image + * + * @return string + */ + public function getPreviewImage(File $file) + { + $videoId = $this->getOnlineMediaId($file); + $temporaryFileName = $this->getTempFolderPath() . 'vimeo_' . md5($videoId) . '.jpg'; + if (!file_exists($temporaryFileName)) { + $oEmbedData = $this->getOEmbedData($videoId); + if (!empty($oEmbedData['thumbnail_url'])) { + $previewImage = GeneralUtility::getUrl($oEmbedData['thumbnail_url']); + if ($previewImage !== false) { + GeneralUtility::writeFile($temporaryFileName, $previewImage, true); + } + } + } + return $temporaryFileName; + } + + /** + * Try to transform given URL to a File + * + * @param string $url + * @return File|null + */ + public function transformUrlToFile($url, Folder $targetFolder) + { + $videoId = null; + // Try to get the Vimeo code from given url. + // Next formats are supported with and without http(s):// + // - vimeo.com/<code>/<optionalPrivateCode> # Share URL + // - vimeo.com/event/<code> + // - player.vimeo.com/video/<code>/<optionalPrivateCode> # URL form iframe embed code, can also get code from full iframe snippet + if (preg_match('/vimeo\.com\/(?:video\/|event\/)?([0-9a-z\/]+)/i', $url, $matches)) { + $videoId = $matches[1]; + } + if (empty($videoId)) { + return null; + } + return $this->transformMediaIdToFile($videoId, $targetFolder, $this->extension); + } + + /** + * Get oEmbed data url + * + * @param string $mediaId + * @param string $format + * @return string + */ + protected function getOEmbedUrl($mediaId, $format = 'json') + { + return sprintf( + 'https://vimeo.com/api/oembed.%s?width=2048&url=%s', + rawurlencode($format), + rawurlencode(sprintf('https://vimeo.com/%s', rawurlencode($mediaId))) + ); + } +} diff --git a/Classes/Resource/OnlineMedia/Helpers/YouTubeHelper.php b/Classes/Resource/OnlineMedia/Helpers/YouTubeHelper.php new file mode 100644 index 0000000..f0ff12e --- /dev/null +++ b/Classes/Resource/OnlineMedia/Helpers/YouTubeHelper.php @@ -0,0 +1,106 @@ +<?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\Core\Resource\OnlineMedia\Helpers; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Youtube helper class + */ +class YouTubeHelper extends AbstractOEmbedHelper +{ + /** + * Get public url + * + * @return string|null + */ + public function getPublicUrl(File $file) + { + $videoId = $this->getOnlineMediaId($file); + return sprintf('https://www.youtube.com/watch?v=%s', rawurlencode($videoId)); + } + + /** + * Get local absolute file path to preview image + * + * @return string + */ + public function getPreviewImage(File $file) + { + $videoId = $this->getOnlineMediaId($file); + $temporaryFileName = $this->getTempFolderPath() . 'youtube_' . md5($videoId) . '.jpg'; + + if (!file_exists($temporaryFileName)) { + $tryNames = ['maxresdefault.jpg', 'sddefault.jpg', 'hqdefault.jpg', 'mqdefault.jpg', '0.jpg']; + foreach ($tryNames as $tryName) { + $previewImage = GeneralUtility::getUrl( + sprintf('https://img.youtube.com/vi/%s/%s', $videoId, $tryName) + ); + if ($previewImage !== false) { + GeneralUtility::writeFile($temporaryFileName, $previewImage, true); + break; + } + } + } + + return $temporaryFileName; + } + + /** + * Try to transform given URL to a File + * + * @param string $url + * @return File|null + */ + public function transformUrlToFile($url, Folder $targetFolder) + { + $videoId = null; + // Try to get the YouTube code from given url. + // These formats are supported with and without http(s):// + // - youtu.be/<code> # Share URL + // - www.youtube.com/watch?v=<code> # Normal web link + // - www.youtube.com/v/<code> + // - www.youtube-nocookie.com/v/<code> # youtube-nocookie.com web link + // - www.youtube.com/embed/<code> # URL form iframe embed code, can also get code from full iframe snippet + // - www.youtube.com/shorts/<code> + // - www.youtube.com/live/<code> + if (preg_match('%(?:youtube(?:-nocookie)?\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?|shorts|live)/|.*[?&]v=)|youtu\.be/)([^"&?/ ]{11})%i', $url, $match)) { + $videoId = $match[1]; + } + if (empty($videoId)) { + return null; + } + return $this->transformMediaIdToFile($videoId, $targetFolder, $this->extension); + } + + /** + * Get oEmbed url to retrieve oEmbed data + * + * @param string $mediaId + * @param string $format + * @return string + */ + protected function getOEmbedUrl($mediaId, $format = 'json') + { + return sprintf( + 'https://www.youtube.com/oembed?url=%s&format=%s&maxwidth=2048&maxheight=2048', + rawurlencode(sprintf('https://www.youtube.com/watch?v=%s', rawurlencode($mediaId))), + rawurlencode($format) + ); + } +} diff --git a/Classes/Resource/OnlineMedia/Metadata/Extractor.php b/Classes/Resource/OnlineMedia/Metadata/Extractor.php new file mode 100644 index 0000000..0e9ef40 --- /dev/null +++ b/Classes/Resource/OnlineMedia/Metadata/Extractor.php @@ -0,0 +1,88 @@ +<?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\Core\Resource\OnlineMedia\Metadata; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Index\ExtractorInterface; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +class Extractor implements ExtractorInterface +{ + /** + * Returns an array of supported file types + * + * @return array + */ + public function getFileTypeRestrictions() + { + return []; + } + + /** + * Get all supported DriverClasses + * empty array indicates no restrictions + * + * @return array + */ + public function getDriverRestrictions() + { + return []; + } + + /** + * Returns the data priority of the extraction Service + * + * @return int + */ + public function getPriority() + { + return 10; + } + + /** + * Returns the execution priority of the extraction Service + * + * @return int + */ + public function getExecutionPriority() + { + return 10; + } + + /** + * Checks if the given file can be processed by this Extractor + * + * @return bool + */ + public function canProcess(File $file) + { + return GeneralUtility::makeInstance(OnlineMediaHelperRegistry::class)->getOnlineMediaHelper($file) !== false; + } + + /** + * The actual processing TASK + * Should return an array with database properties for sys_file_metadata to write + * + * @param array $previousExtractedData optional, contains the array of already extracted data + * @return array + */ + public function extractMetaData(File $file, array $previousExtractedData = []) + { + $helper = GeneralUtility::makeInstance(OnlineMediaHelperRegistry::class)->getOnlineMediaHelper($file); + return $helper !== false ? $helper->getMetaData($file) : []; + } +} diff --git a/Classes/Resource/OnlineMedia/Processing/PreviewProcessing.php b/Classes/Resource/OnlineMedia/Processing/PreviewProcessing.php new file mode 100644 index 0000000..45c5db7 --- /dev/null +++ b/Classes/Resource/OnlineMedia/Processing/PreviewProcessing.php @@ -0,0 +1,72 @@ +<?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\Core\Resource\OnlineMedia\Processing; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\OnlineMedia\Event\AfterVideoPreviewFetchedEvent; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Resource\Processing\LocalImageProcessor; +use TYPO3\CMS\Core\Resource\Processing\ProcessorInterface; +use TYPO3\CMS\Core\Resource\Processing\TaskInterface; + +/** + * Preview of Online Media item Processing + */ +#[Autoconfigure(public: true)] +final class PreviewProcessing extends LocalImageProcessor implements ProcessorInterface +{ + public function __construct( + private readonly OnlineMediaHelperRegistry $onlineMediaHelperRegistry, + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + public function canProcessTask(TaskInterface $task): bool + { + if ($task->getType() !== 'Image') { + return false; + } + if (!in_array($task->getName(), ['Preview', 'CropScaleMask'], true)) { + return false; + } + $sourceFile = $task->getSourceFile(); + if (!$this->onlineMediaHelperRegistry->hasOnlineMediaHelper($sourceFile->getExtension())) { + return false; + } + $previewImageFile = $this->getPreviewImageFromOnlineMedia($sourceFile); + return !empty($previewImageFile) && file_exists($previewImageFile); + } + + public function processTask(TaskInterface $task): void + { + $this->processTaskWithLocalFile( + $task, + $this->getPreviewImageFromOnlineMedia($task->getSourceFile()) + ); + } + + private function getPreviewImageFromOnlineMedia(File $file): string + { + $onlineMediaHelper = $this->onlineMediaHelperRegistry->getOnlineMediaHelper($file); + $previewImage = $onlineMediaHelper->getPreviewImage($file); + + $videoPreviewEvent = new AfterVideoPreviewFetchedEvent($file, $onlineMediaHelper, $previewImage); + $this->eventDispatcher->dispatch($videoPreviewEvent); + + return $videoPreviewEvent->getPreviewImageFilename(); + } +} diff --git a/Classes/Resource/OnlineMedia/Service/PreviewService.php b/Classes/Resource/OnlineMedia/Service/PreviewService.php new file mode 100644 index 0000000..a616c92 --- /dev/null +++ b/Classes/Resource/OnlineMedia/Service/PreviewService.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\OnlineMedia\Service; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Resource\ProcessedFileRepository; + +/** + * Service for handling the preview of online media assets + */ +readonly class PreviewService +{ + public function __construct( + protected OnlineMediaHelperRegistry $onlineMediaHelperRegistry, + protected ProcessedFileRepository $processedFileRepository + ) {} + + public function updatePreviewImage(File $file): string + { + if (!$this->onlineMediaHelperRegistry->hasOnlineMediaHelper($file->getExtension())) { + throw new \InvalidArgumentException('No online media helper exists for extension ' . $file->getExtension(), 1695130495); + } + + $onlineMediaHelper = $this->onlineMediaHelperRegistry->getOnlineMediaHelper($file); + + // Remove the current preview image to force regeneration on calling getPreviewImage() again + if (file_exists($previewImage = $onlineMediaHelper->getPreviewImage($file))) { + // Remove preview image and processed files + unlink($previewImage); + foreach ($this->processedFileRepository->findAllByOriginalFile($file) as $processedFile) { + $processedFile->delete(); + } + } + + // Force regeneration of the preview image and return the path + return $onlineMediaHelper->getPreviewImage($file); + } +} diff --git a/Classes/Resource/ProcessedFile.php b/Classes/Resource/ProcessedFile.php new file mode 100644 index 0000000..a78b76e --- /dev/null +++ b/Classes/Resource/ProcessedFile.php @@ -0,0 +1,524 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; +use TYPO3\CMS\Core\Resource\Service\ConfigurationService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Representation of a specific processed version of a file. These are created by the FileProcessingService, + * which in turn uses helper classes for doing the actual file processing. See there for a detailed description. + * + * Objects of this class may be freshly created during runtime or being fetched from the database. The latter + * indicates that the file has been processed earlier and was then cached. + * + * Each processed file—besides belonging to one file—has been created for a certain task (context) and + * configuration. All these won't change during the lifetime of a processed file; the only thing + * that can change is the original file, or rather it's contents. In that case, the processed file has to + * be processed again. Detecting this is done via comparing the current SHA1 hash of the original file against + * the one it had at the time the file was processed. + * The configuration of a processed file indicates what should be done to the original file to create the + * processed version. This may include things like cropping, scaling, rotating, flipping or using some special + * magic. + * A file may also meet the expectations set in the configuration without any processing. In that case, the + * ProcessedFile object still exists, but there is no physical file directly linked to it. Instead, it then + * redirects most method calls to the original file object. The data of these objects are also stored in the + * database, to indicate that no processing is required. With such files, the identifier and name fields in the + * database are empty to show this. + */ +class ProcessedFile extends AbstractFile +{ + /********************************************* + * FILE PROCESSING CONTEXTS + *********************************************/ + /** + * Basic processing context to get a processed image with smaller + * width/height to render a preview + */ + public const CONTEXT_IMAGEPREVIEW = 'Image.Preview'; + /** + * Standard processing context for the frontend, that was previously + * in \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::getImgResource which only takes cropping, masking and scaling + * into account + */ + public const CONTEXT_IMAGECROPSCALEMASK = 'Image.CropScaleMask'; + + /** + * Processing context, i.e. the type of processing done + */ + protected string $taskType; + + /** + * Processing configuration + */ + protected ?array $processingConfiguration; + + /** + * Reference to the original file this processed file has been created from. + */ + protected File $originalFile; + + /** + * The SHA1 hash of the original file this processed version has been created for. + * Is used for detecting changes if the original file has been changed and thus + * we have to recreate this processed file. + */ + protected ?string $originalFileSha1; + + /** + * A flag that shows if this object has been updated during its lifetime, i.e. the file has been + * replaced with a new one. + */ + protected bool $updated = false; + + /** + * If this is set, this URL is used as public URL + * This MUST be a fully qualified URL including host + */ + protected string $processingUrl; + + protected string $identifier = ''; + + /** + * Constructor for a processed file object. Should normally not be used + * directly, use the corresponding factory methods instead. + */ + public function __construct(File $originalFile, string $taskType, array $processingConfiguration, ?array $databaseRow = null) + { + $this->originalFile = $originalFile; + $this->originalFileSha1 = $this->originalFile->getSha1(); + $this->storage = $originalFile->getStorage()->getProcessingFolder()->getStorage(); + $this->taskType = $taskType; + $this->processingConfiguration = $processingConfiguration; + if (is_array($databaseRow)) { + $this->reconstituteFromDatabaseRecord($databaseRow); + } + } + + /** + * Creates a ProcessedFile object from a database record. + */ + protected function reconstituteFromDatabaseRecord(array $databaseRow): void + { + $this->taskType = $this->taskType ?: $databaseRow['task_type']; + $this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? '', ['allowed_classes' => [Area::class]]); + + $this->originalFileSha1 = $databaseRow['originalfilesha1']; + $this->identifier = (string)$databaseRow['identifier']; + $this->name = (string)$databaseRow['name']; + $this->properties = $databaseRow; + $this->processingUrl = $databaseRow['processing_url'] ?? ''; + + if (!empty($databaseRow['storage']) && (int)$this->storage->getUid() !== (int)$databaseRow['storage']) { + $this->storage = GeneralUtility::makeInstance(StorageRepository::class)->findByUid($databaseRow['storage']); + } + } + + /******************* + * CONTENTS RELATED + *******************/ + /** + * Replace the current file contents with the given string + * + * @throws \BadMethodCallException + */ + public function setContents(string $contents): self + { + throw new \BadMethodCallException('Setting contents not possible for processed file.', 1305438528); + } + + /** + * Injects a local file, which is a processing result into the object. + * + * @param string $filePath + * @throws \RuntimeException + */ + public function updateWithLocalFile(string $filePath): void + { + if (empty($this->identifier)) { + throw new \RuntimeException('Cannot update original file!', 1350582054); + } + $processingFolder = $this->originalFile->getStorage()->getProcessingFolder($this->originalFile); + $addedFile = $this->storage->updateProcessedFile($filePath, $this, $processingFolder); + + // Update some related properties + $this->identifier = $addedFile->getIdentifier(); + $this->originalFileSha1 = $this->originalFile->getSha1(); + $this->updateProperties($addedFile->getProperties()); + $this->deleted = false; + $this->updated = true; + } + + /***************************************** + * STORAGE AND MANAGEMENT RELATED METHODS + *****************************************/ + /** + * Returns TRUE if this file is indexed + */ + public function isIndexed(): false + { + // Processed files are never indexed; instead you might be looking for isPersisted() + return false; + } + + /** + * Checks whether the ProcessedFile already has an entry in sys_file_processedfile table + */ + public function isPersisted(): bool + { + return array_key_exists('uid', $this->properties) && $this->properties['uid'] > 0; + } + + /** + * Checks whether the ProcessedFile Object is newly created + */ + public function isNew(): bool + { + return !$this->isPersisted(); + } + + /** + * Checks whether the object since last reconstitution, and therefore + * needs persistence again + */ + public function isUpdated(): bool + { + return $this->updated; + } + + /** + * Sets a new file name + */ + public function setName(string $name): void + { + // Remove the existing file, but only we actually have a name or the name has changed + if (!empty($this->name) && $this->name !== $name && $this->exists()) { + $this->delete(); + } + + $this->name = $name; + // @todo this is a *weird* hack that will fail if the storage is non-hierarchical! + $this->identifier = $this->storage->getProcessingFolder($this->originalFile)->getIdentifier() . $this->name; + + $this->updated = true; + } + + /** + * Checks if this file exists. + * Since the original file may reside in a different storage + * we ask the original file if it exists in case the processed is representing it + * + * @return bool TRUE if this file physically exists + */ + public function exists(): bool + { + if ($this->usesOriginalFile()) { + return $this->originalFile->exists(); + } + + return parent::exists(); + } + + /****************** + * SPECIAL METHODS + ******************/ + + /** + * Returns TRUE if this file is already processed. + */ + public function isProcessed(): bool + { + return $this->updated || ($this->isPersisted() && !$this->needsReprocessing()); + } + + /** + * Getter for the Original, unprocessed File + */ + public function getOriginalFile(): File + { + return $this->originalFile; + } + + /** + * Get the identifier of the file + * + * If there is no processed file in the file system (as the original file did not have to be modified e.g. + * when the original image is in the boundaries of the maxW/maxH stuff), then just return the identifier of + * the original file + * + * @return non-empty-string + */ + public function getIdentifier(): string + { + return (!$this->usesOriginalFile()) ? $this->identifier : $this->getOriginalFile()->getIdentifier(); + } + + public function setIdentifier(string $identifier): void + { + $this->identifier = $identifier; + } + + /** + * Get the name of the file + * + * If there is no processed file in the file system (as the original file did not have to be modified e.g. + * when the original image is in the boundaries of the maxW/maxH stuff) + * then just return the name of the original file + * + * @return non-empty-string + */ + public function getName(): string + { + if ($this->usesOriginalFile()) { + return $this->originalFile->getName(); + } + return $this->name; + } + + /** + * Updates properties of this object. Do not use this to reconstitute an object from the database; use + * reconstituteFromDatabaseRecord() instead! + */ + public function updateProperties(array $properties): void + { + if (array_key_exists('uid', $properties) && MathUtility::canBeInterpretedAsInteger($properties['uid'])) { + $this->properties['uid'] = $properties['uid']; + } + if (isset($properties['processing_url'])) { + $this->processingUrl = $properties['processing_url']; + } + + // @todo we should have a blacklist of properties that might not be updated + $this->properties = array_merge($this->properties, $properties); + + // @todo when should this update be done? + if (!$this->isUnchanged() && $this->exists()) { + $storage = $this->storage; + if ($this->usesOriginalFile()) { + $storage = $this->originalFile->getStorage(); + } + $this->properties = array_merge($this->properties, $storage->getFileInfo($this)); + } + } + + /** + * Basic array function for the DB update + * + * @return array<non-empty-string, mixed> + */ + public function toArray(): array + { + if ($this->usesOriginalFile()) { + $properties = $this->originalFile->getProperties(); + unset($properties['uid']); + $properties['identifier'] = ''; + $properties['name'] = null; + $properties['processing_url'] = ''; + + // Use width + height set in processed file + $properties['width'] = $this->properties['width'] ?? 0; + $properties['height'] = $this->properties['height'] ?? 0; + } else { + $properties = $this->properties; + $properties['identifier'] = $this->getIdentifier(); + $properties['name'] = $this->getName(); + } + + $properties['configuration'] = (new ConfigurationService())->serialize($this->processingConfiguration); + + return array_merge($properties, [ + 'storage' => $this->getStorage()->getUid(), + 'task_type' => $this->taskType, + 'configurationsha1' => sha1($properties['configuration']), + 'original' => $this->originalFile->getUid(), + 'originalfilesha1' => $this->originalFileSha1, + ]); + } + + /** + * Returns TRUE if this file has not been changed during processing (i.e., we just deliver the original file) + */ + protected function isUnchanged(): bool + { + return !($this->properties['width'] ?? false) && $this->usesOriginalFile(); + } + + /** + * Defines that the original file should be used. + */ + public function setUsesOriginalFile(): void + { + // @todo check if some of these properties can/should be set in a generic update method + $this->identifier = $this->originalFile->getIdentifier(); + $this->updated = true; + $this->processingUrl = ''; + $this->originalFileSha1 = $this->originalFile->getSha1(); + } + + public function updateProcessingUrl(string $url): void + { + $this->updated = true; + $this->processingUrl = $url; + } + + public function usesOriginalFile(): bool + { + return empty($this->identifier) || $this->identifier === $this->originalFile->getIdentifier(); + } + + /** + * Returns TRUE if the original file of this file changed and the file should be processed again. + */ + public function isOutdated(): bool + { + return $this->needsReprocessing(); + } + + /** + * Delete processed file + */ + public function delete(bool $force = false): bool + { + if (!$force && $this->isUnchanged()) { + return false; + } + // Only delete file when original isn't used + if (!$this->usesOriginalFile()) { + return parent::delete(); + } + return true; + } + + /** + * Getter for file-properties + * + * @param non-empty-string $key + */ + public function getProperty(string $key): mixed + { + // The uid always (!) has to come from this file and never the original file (see getOriginalFile() to get this) + if ($this->isUnchanged() && $key !== 'uid') { + return $this->originalFile->getProperty($key); + } + return $this->properties[$key] ?? null; + } + + /** + * Get the MIME type of this file + * + * @throws \RuntimeException + * @return non-empty-string mime type + */ + public function getMimeType(): string + { + if ($this->usesOriginalFile()) { + return $this->getOriginalFile()->getMimeType(); + } + return parent::getMimeType(); + } + + /** + * @throws \RuntimeException + * @return int<0, max> + */ + public function getSize(): int + { + if ($this->usesOriginalFile()) { + return $this->getOriginalFile()->getSize(); + } + return parent::getSize(); + } + + /** + * Returns the uid of this file + */ + public function getUid(): int + { + return (int)($this->properties['uid'] ?? 0); + } + + /** + * Checks if the ProcessedFile needs reprocessing + */ + public function needsReprocessing(): bool + { + $fileMustBeRecreated = false; + + // if original is missing we can not reprocess the file + if ($this->originalFile->isMissing()) { + return false; + } + + // processedFile does not exist + if (!$this->usesOriginalFile() && !$this->exists()) { + $fileMustBeRecreated = true; + } + + // original file changed + if ($this->originalFile->getSha1() !== $this->originalFileSha1) { + $fileMustBeRecreated = true; + } + + if (!array_key_exists('uid', $this->properties)) { + $fileMustBeRecreated = true; + } + + // remove outdated file + if ($fileMustBeRecreated && $this->exists()) { + $this->delete(); + } + return $fileMustBeRecreated; + } + + /** + * Returns the processing information + */ + public function getProcessingConfiguration(): array + { + return $this->processingConfiguration; + } + + /** + * Getter for the task identifier. + */ + public function getTaskIdentifier(): string + { + return $this->taskType; + } + + /** + * Returns a publicly accessible URL for this file + * + * @return non-empty-string|null NULL if file is deleted, the generated URL otherwise + */ + public function getPublicUrl(): ?string + { + if (isset($this->processingUrl) && $this->processingUrl !== '') { + return $this->processingUrl; + } + if ($this->deleted) { + return null; + } + if ($this->usesOriginalFile()) { + return $this->getOriginalFile()->getPublicUrl(); + } + return $this->getStorage()->getPublicUrl($this); + } +} diff --git a/Classes/Resource/ProcessedFileRepository.php b/Classes/Resource/ProcessedFileRepository.php new file mode 100644 index 0000000..a8ae07d --- /dev/null +++ b/Classes/Resource/ProcessedFileRepository.php @@ -0,0 +1,437 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Platform\PlatformInformation; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; +use TYPO3\CMS\Core\Resource\Processing\TaskInterface; +use TYPO3\CMS\Core\Resource\Processing\TaskTypeRegistry; +use TYPO3\CMS\Core\Resource\Service\ConfigurationService; + +/** + * A repository for accessing and storing processed files. + * + * This class is mainly meant to be used internally in TYPO3 for accessing via + * FileProcessingService or custom FAL Processors. + */ +#[Autoconfigure(public: true)] +readonly class ProcessedFileRepository +{ + public function __construct( + private ResourceFactory $factory, + private TaskTypeRegistry $taskTypeRegistry, + private LoggerInterface $logger, + private ConnectionPool $connectionPool, + private Context $context, + #[Autowire(service: 'cache.runtime')] + private FrontendInterface $runtimeCache, + ) {} + + /** + * Finds a processed file matching the given UID. + */ + public function findByUid(int $uid): ProcessedFile + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_processedfile'); + $row = $queryBuilder + ->select('*') + ->from('sys_file_processedfile') + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchAssociative(); + if (!is_array($row)) { + throw new \RuntimeException('Could not find row with UID "' . $uid . '" in table "sys_file_processedfile"', 1695122090); + } + return $this->createDomainObject($row); + } + + public function findByStorageAndIdentifier(ResourceStorage $storage, string $identifier): ?ProcessedFile + { + $processedFileObject = null; + if ($storage->hasFile($identifier)) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_processedfile'); + $databaseRow = $queryBuilder + ->select('*') + ->from('sys_file_processedfile') + ->where( + $queryBuilder->expr()->eq( + 'storage', + $queryBuilder->createNamedParameter($storage->getUid(), Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 'identifier', + $queryBuilder->createNamedParameter($identifier) + ) + ) + ->executeQuery() + ->fetchAssociative(); + + if ($databaseRow) { + $processedFileObject = $this->createDomainObject($databaseRow); + } + } + return $processedFileObject; + } + + /** + * Count processed files by storage. This is used in the "Install Tool" + * to render statistics of processed files. + */ + public function countByStorage(ResourceStorage $storage): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_processedfile'); + return (int)$queryBuilder + ->count('uid') + ->from('sys_file_processedfile') + ->where( + $queryBuilder->expr()->eq( + 'storage', + $queryBuilder->createNamedParameter($storage->getUid(), Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchOne(); + } + + /** + * Adds a processed file object to the database. + */ + public function add(ProcessedFile $processedFile, TaskInterface $task): void + { + if ($processedFile->isPersisted()) { + $this->update($processedFile, $task); + } else { + $currentTimestamp = $this->context->getPropertyFromAspect('date', 'timestamp'); + $insertFields = $processedFile->toArray(); + $insertFields['crdate'] = $currentTimestamp; + $insertFields['tstamp'] = $currentTimestamp; + $insertFields['checksum'] = $task->getConfigurationChecksum(); + + $insertFields = $this->cleanUnavailableColumns($insertFields); + + $connection = $this->connectionPool->getConnectionForTable('sys_file_processedfile'); + $connection->insert( + 'sys_file_processedfile', + $insertFields, + ['configuration' => Connection::PARAM_LOB] + ); + + $uid = $connection->lastInsertId(); + $processedFile->updateProperties(['uid' => $uid]); + + $this->flushRuntimeCacheOfOriginal($processedFile); + } + } + + /** + * Updates an existing file object in the database. If the file has not been + * persisted yet, nothing changes. + */ + public function update(ProcessedFile $processedFile, TaskInterface $task): void + { + if ($processedFile->isPersisted()) { + $uid = $processedFile->getUid(); + $updateFields = $processedFile->toArray(); + $updateFields['checksum'] = $task->getConfigurationChecksum(); + $updateFields = $this->cleanUnavailableColumns($updateFields); + unset($updateFields['uid']); + $currentTimestamp = $this->context->getPropertyFromAspect('date', 'timestamp'); + $updateFields['tstamp'] = $currentTimestamp; + + $this->connectionPool->getConnectionForTable('sys_file_processedfile')->update( + 'sys_file_processedfile', + $updateFields, + [ + 'uid' => $uid, + ], + ['configuration' => Connection::PARAM_LOB] + ); + + $this->flushRuntimeCacheOfOriginal($processedFile); + } + } + + /** + * @param string $taskType The task that should be executed on the file + */ + public function findOneByOriginalFileAndTaskTypeAndConfiguration(File $file, string $taskType, array $configuration): ProcessedFile + { + // Creating a task object to only fetch cleaned configuration properties + $task = $this->prepareTaskObject($file, $taskType, $configuration); + $configuration = $task->getConfiguration(); + $configurationSha1 = sha1((new ConfigurationService())->serialize($configuration)); + + foreach ($this->getAllByOriginal($file, $taskType) as $databaseRow) { + if ($databaseRow['configurationsha1'] === $configurationSha1) { + return $this->createDomainObject($databaseRow); + } + } + + return $this->createNewProcessedFileObject($file, $taskType, $configuration); + } + + /** + * @return ProcessedFile[] + */ + public function findAllByOriginalFile(File $file): array + { + $itemList = []; + foreach ($this->getAllByOriginal($file) as $row) { + $itemList[] = $this->createDomainObject($row); + } + return $itemList; + } + + /** + * Removes all processed files and also deletes the associated physical files. + * If a storageUid is given, only db entries and files of this storage are removed. + * + * @param int|null $storageUid If not NULL, only the processed files of the given storage are removed + * @return int Number of failed deletions + */ + public function removeAll(?int $storageUid = null): int + { + $connection = $this->connectionPool->getConnectionForTable('sys_file_processedfile'); + $queryBuilder = $connection->createQueryBuilder(); + $where = [ + $queryBuilder->expr()->neq('identifier', $queryBuilder->createNamedParameter('')), + ]; + if ($storageUid !== null) { + $where[] = $queryBuilder->expr()->eq( + 'storage', + $queryBuilder->createNamedParameter($storageUid, Connection::PARAM_INT) + ); + } + $result = $queryBuilder + ->select('*') + ->from('sys_file_processedfile') + ->where(...$where) + ->executeQuery(); + + $errorCount = 0; + + while ($row = $result->fetchAssociative()) { + if ($storageUid && $storageUid !== (int)$row['storage']) { + continue; + } + try { + $file = $this->createDomainObject($row); + $file->getStorage()->setEvaluatePermissions(false); + $file->delete(true); + } catch (\Exception $e) { + $this->logger->error('Failed to delete file {identifier} in storage uid {storage}.', [ + 'identifier' => $row['identifier'], + 'storage' => $row['storage'], + 'exception' => $e, + ]); + ++$errorCount; + } + } + + if ($storageUid === null) { + // Truncate entire table if not restricted to specific storage + $connection->truncate('sys_file_processedfile'); + } else { + // else remove db rows of this storage only + $connection->delete('sys_file_processedfile', ['storage' => $storageUid], [Connection::PARAM_INT]); + } + + $this->runtimeCache->flushByTag('processed-files'); + + return $errorCount; + } + + /** + * Removes a single processed file database row. + */ + public function remove(ProcessedFile $processedFile): void + { + if (!$processedFile->isPersisted()) { + return; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_processedfile'); + $queryBuilder + ->delete('sys_file_processedfile') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($processedFile->getUid(), Connection::PARAM_INT) + ) + ) + ->executeStatement(); + + $this->flushRuntimeCacheOfOriginal($processedFile); + } + + /** + * Removes processed file database rows by uid. The original files are unknown + * on this path, so the whole runtime cache is flushed to keep it coherent. + */ + public function removeByUids(array $uids): int + { + if ($uids === []) { + return 0; + } + $connection = $this->connectionPool->getConnectionForTable('sys_file_processedfile'); + $maxBindParameters = PlatformInformation::getMaxBindParameters($connection->getDatabasePlatform()); + $deletedRecords = 0; + foreach (array_chunk($uids, $maxBindParameters) as $chunk) { + $queryBuilder = $connection->createQueryBuilder(); + $deletedRecords += $queryBuilder + ->delete('sys_file_processedfile') + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY) + ) + ) + ->executeStatement(); + } + + $this->runtimeCache->flushByTag('processed-files'); + + return $deletedRecords; + } + + /** + * @return array<int, array<string, mixed>> + */ + protected function getAllByOriginal(File $file, ?string $taskType = null): array + { + $cacheIdentifier = 'processed-file-repository-original-' . $file->getUid(); + if ($taskType !== null) { + $cacheIdentifier .= '-tasktype-' . md5($taskType); + } + + $cachedRows = $this->runtimeCache->get($cacheIdentifier); + if (is_array($cachedRows)) { + return $cachedRows; + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_file_processedfile'); + $where = [ + $queryBuilder->expr()->eq( + 'original', + $queryBuilder->createNamedParameter($file->getUid(), Connection::PARAM_INT) + ), + ]; + if ($taskType !== null) { + $where[] = $queryBuilder->expr()->eq('task_type', $queryBuilder->createNamedParameter($taskType)); + } + + $result = $queryBuilder + ->select('*') + ->from('sys_file_processedfile') + ->where(...$where) + ->executeQuery(); + + $rows = []; + while (($row = $result->fetchAssociative()) !== false) { + $rows[] = $row; + } + + $this->runtimeCache->set($cacheIdentifier, $rows, ['processed-files', $this->getRuntimeCacheTagOfOriginal($file)]); + + return $rows; + } + + protected function flushRuntimeCacheOfOriginal(array|ProcessedFile|File|int $files): void + { + if (!is_array($files)) { + $files = [$files]; + } + $tags = array_map($this->getRuntimeCacheTagOfOriginal(...), $files); + $this->runtimeCache->flushByTags($tags); + } + + protected function getRuntimeCacheTagOfOriginal(ProcessedFile|File|int $file): string + { + return 'processed-file-original-' . match (true) { + $file instanceof ProcessedFile => $file->getOriginalFile()->getUid(), + $file instanceof File => $file->getUid(), + default => $file + }; + } + + /** + * Creates a ProcessedFile object from a file object and a processing configuration. + */ + protected function createNewProcessedFileObject(File $originalFile, string $taskType, array $configuration): ProcessedFile + { + return new ProcessedFile($originalFile, $taskType, $configuration); + } + + protected function createDomainObject(array $databaseRow): ProcessedFile + { + $originalFile = $this->factory->getFileObject((int)$databaseRow['original']); + $taskType = $databaseRow['task_type']; + // Allow deserialization of Area class, since Area objects get serialized in configuration + // @todo: This should be changed to json encode and decode at some point + $configuration = unserialize( + $databaseRow['configuration'], + [ + 'allowed_classes' => [ + Area::class, + ], + ] + ); + + return new ProcessedFile($originalFile, $taskType, $configuration, $databaseRow); + } + + /** + * Removes all array keys which cannot be persisted. + */ + protected function cleanUnavailableColumns(array $data): array + { + return array_intersect_key($data, $this->connectionPool + ->getConnectionForTable('sys_file_processedfile') + ->getSchemaInformation() + ->listTableColumnInfos('sys_file_processedfile')); + } + + /** + * We need a task object, so the task can define what configuration is necessary. This way, we can then + * use a cleaned up configuration to find already processed files. + * + * Note: The Task object needs to be re-created with a real processed file, once we have one, + * as the current API Design is very tightly coupled: + * - TaskInterface has a constructor in the interface (which is bad) + * - TaskObject requires a constituted ProcessedFile object in order to "work" + * - Task objects are created by external services when needed for processing + * - ProcessedFile AND TaskObject contain both the configuration, which should be avoided as well (getting smaller now). + * + * @todo: This should be shifted into a TaskFactory or the TaskRegistry + */ + protected function prepareTaskObject(File $fileObject, string $taskType, array $configuration): TaskInterface + { + $temporaryProcessedFile = $this->createNewProcessedFileObject($fileObject, $taskType, $configuration); + $taskObject = $this->taskTypeRegistry->getTaskForType($taskType, $temporaryProcessedFile, $configuration); + $taskObject->sanitizeConfiguration(); + return $taskObject; + } +} diff --git a/Classes/Resource/Processing/AbstractTask.php b/Classes/Resource/Processing/AbstractTask.php new file mode 100644 index 0000000..4898789 --- /dev/null +++ b/Classes/Resource/Processing/AbstractTask.php @@ -0,0 +1,184 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Resource; +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\Resource\Service\ConfigurationService; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Abstract base implementation of a processing task. + */ +abstract class AbstractTask implements TaskInterface +{ + protected Resource\File $sourceFile; + protected bool $executed = false; + protected bool $successful; + + public function __construct( + protected ProcessedFile $targetFile, + protected array $configuration + ) { + $this->sourceFile = $targetFile->getOriginalFile(); + } + + /** + * Sets parameters needed in the checksum. Can be overridden to add additional parameters to the checksum. + * This should include all parameters that could possibly vary between different task instances, e.g. the + * TYPO3 image configuration in TYPO3_CONF_VARS[GFX] for graphic processing tasks. + */ + protected function getChecksumData(): array + { + return [ + $this->getSourceFile()->getUid(), + $this->getType() . '.' . $this->getName() . $this->getSourceFile()->getModificationTime(), + (new ConfigurationService())->serialize($this->configuration), + ]; + } + + /** + * Returns the checksum for this task's configuration, also taking the file and task type into account. + */ + public function getConfigurationChecksum(): string + { + return substr((string)md5(implode('|', $this->getChecksumData())), 0, 10); + } + + /** + * Returns the filename + */ + public function getTargetFilename(): string + { + return $this->targetFile->getNameWithoutExtension() + . '_' . $this->getConfigurationChecksum() + . '.' . $this->getTargetFileExtension(); + } + + /** + * Gets the file extension the processed file should + * have in the filesystem. + */ + public function getTargetFileExtension(): string + { + return $this->targetFile->getExtension(); + } + + /** + * Returns the name of this task + */ + abstract public function getName(): string; + + /** + * Returns the type of this task + */ + abstract public function getType(): string; + + public function getTargetFile(): Resource\ProcessedFile + { + return $this->targetFile; + } + + public function getSourceFile(): Resource\File + { + return $this->sourceFile; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + /** + * Returns TRUE if this task has been executed, no matter if the execution was successful. + */ + public function isExecuted(): bool + { + return $this->executed; + } + + /** + * Set this task executed. This is used by the Processors in order to transfer the state of this task to + * the file processing service. + * + * @param bool $successful Set this to FALSE if executing the task failed + */ + public function setExecuted(bool $successful): void + { + $this->executed = true; + $this->successful = $successful; + } + + /** + * Returns TRUE if this task has been successfully executed. Only call this method if the task has been processed + * at all. + * + * @throws \LogicException If the task has not been executed already + */ + public function isSuccessful(): bool + { + if (!$this->executed) { + throw new \LogicException('Task has not been executed; cannot determine success.', 1352549235); + } + return $this->successful; + } + + /** + * We only have to trigger the file processing if the file either is new, does not exist or the + * original file has changed since the last processing run (the last case has to trigger a reprocessing + * even if the original file was used until now). + */ + public function fileNeedsProcessing(): bool + { + $processedFile = $this->getTargetFile(); + if (!$processedFile->isProcessed()) { + return true; + } + + $checksum = $this->getTargetFile()->getProperty('checksum'); + $checksumCalculationOk = !$checksum || $this->getConfigurationChecksum() === $checksum; + + $fileNeedsReprocessing = $processedFile->isNew() + || (!$processedFile->usesOriginalFile() && !$processedFile->exists()) + || ($processedFile->needsReprocessing() || !$checksumCalculationOk); + + if ($fileNeedsReprocessing && $this->getTargetFile()->exists()) { + $this->getTargetFile()->delete(); + } + + return $fileNeedsReprocessing; + } + + /** + * Can be extended in the actual subclasses, but be careful on what to sanitize, as Processors might need + * information that you actually throw away. + * + * Ensure that the processing configuration which is part of the hash sum is properly cast, so + * unnecessary duplicate images are not produced, see #80942 + */ + public function sanitizeConfiguration(): void + { + foreach ($this->configuration as &$value) { + if (MathUtility::canBeInterpretedAsInteger($value)) { + $value = (int)$value; + } + } + // @todo: ideally we would do a sort() on the array to really structure this, but then the checksums would change + // @todo: and we would need to re-create all processed files again, but this would be something we should tackle at some point + } +} diff --git a/Classes/Resource/Processing/FileDeletionAspect.php b/Classes/Resource/Processing/FileDeletionAspect.php new file mode 100644 index 0000000..7aaa2d0 --- /dev/null +++ b/Classes/Resource/Processing/FileDeletionAspect.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Resource\Event\AfterFileAddedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileReplacedEvent; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\Index\FileIndexRepository; +use TYPO3\CMS\Core\Resource\Index\MetaDataRepository; +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\Resource\ProcessedFileRepository; + +/** + * Clean up database records, processed files and file references + * + * The aspect which deals with deleted files is a list of PSR-14 + * event listeners which react on file deletion. + * + * @internal this is a list of Event Listeners, and not part of TYPO3 Core API. + */ +final readonly class FileDeletionAspect +{ + public function __construct( + private ConnectionPool $connectionPool, + private MetaDataRepository $metaDataRepository, + private ProcessedFileRepository $processedFileRepository, + private FileIndexRepository $fileIndexRepository, + ) {} + + #[AsEventListener('delete-processed-files-after-add')] + public function cleanupProcessedFilesPostFileAdd(AfterFileAddedEvent $event): void + { + $this->cleanupProcessedFiles($event->getFile()); + } + + #[AsEventListener('delete-processed-files-after-replace')] + public function cleanupProcessedFilesPostFileReplace(AfterFileReplacedEvent $event): void + { + $this->cleanupProcessedFiles($event->getFile()); + } + + #[AsEventListener('delete-processed-files-after-delete')] + public function removeFromRepositoryAfterFileDeleted(AfterFileDeletedEvent $event): void + { + $this->removeFromRepository($event->getFile()); + } + + /** + * Cleanup database record for a deleted file + */ + private function removeFromRepository(FileInterface $fileObject): void + { + // remove file from repository + if ($fileObject instanceof File) { + $this->cleanupProcessedFiles($fileObject); + $this->cleanupCategoryReferences($fileObject); + $this->fileIndexRepository->remove($fileObject->getUid()); + $this->metaDataRepository->removeByFileUid($fileObject->getUid()); + + // remove all references + $this->connectionPool->getConnectionForTable('sys_file_reference')->delete( + 'sys_file_reference', + [ + 'uid_local' => $fileObject->getUid(), + ] + ); + } elseif ($fileObject instanceof ProcessedFile) { + $this->processedFileRepository->remove($fileObject); + } + } + + /** + * Remove all category references of the deleted file. + */ + private function cleanupCategoryReferences(File $fileObject): void + { + // Retrieve the file metadata uid which is different from the file uid. + $metadataProperties = $fileObject->getMetaData()->get(); + $metaDataUid = (int)($metadataProperties['_ORIG_uid'] ?? $metadataProperties['uid'] ?? 0); + + if ($metaDataUid <= 0) { + // No metadata record exists for the given file. The file might not + // have been indexed or the metadata record was deleted manually. + return; + } + + $this->connectionPool->getConnectionForTable('sys_category_record_mm')->delete( + 'sys_category_record_mm', + [ + 'uid_foreign' => $metaDataUid, + 'tablenames' => 'sys_file_metadata', + ] + ); + } + + /** + * Remove all processed files that belong to the given File object + */ + private function cleanupProcessedFiles(FileInterface $fileObject): void + { + // only delete processed files of File objects + if (!$fileObject instanceof File) { + return; + } + foreach ($this->processedFileRepository->findAllByOriginalFile($fileObject) as $processedFile) { + if ($processedFile->exists()) { + $processedFile->delete(true); + } + $this->removeFromRepository($processedFile); + } + } +} diff --git a/Classes/Resource/Processing/ImageCropScaleMaskTask.php b/Classes/Resource/Processing/ImageCropScaleMaskTask.php new file mode 100644 index 0000000..9c9daa3 --- /dev/null +++ b/Classes/Resource/Processing/ImageCropScaleMaskTask.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Imaging\GraphicalFunctions; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A task that takes care of cropping, scaling and/or masking an image. + */ +class ImageCropScaleMaskTask extends AbstractTask +{ + protected ?string $targetFileExtension; + + public function getType(): string + { + return 'Image'; + } + + public function getName(): string + { + return 'CropScaleMask'; + } + + /** + * Determines the file extension the processed file + * should have in the filesystem. + */ + public function getTargetFileExtension(): string + { + if (!isset($this->targetFileExtension)) { + $this->targetFileExtension = $this->determineTargetFileExtension(); + } + return $this->targetFileExtension; + } + + /** + * Gets the file extension the processed file should + * have in the filesystem by either using the configuration + * setting, or the extension of the original file. + */ + protected function determineTargetFileExtension(): string + { + if (!empty($this->configuration['fileExtension'])) { + return $this->configuration['fileExtension']; + } + + // @todo - See note of determineDefaultProcessingFileExtension() - find a better place for this + $imageService = GeneralUtility::makeInstance(GraphicalFunctions::class); + return $imageService->determineDefaultProcessingFileExtension($this->getSourceFile()->getExtension()); + } + + public function getTargetFileName(): string + { + return 'csm_' + . $this->getSourceFile()->getNameWithoutExtension() + . '_' . $this->getConfigurationChecksum() + . '.' . $this->getTargetFileExtension(); + } +} diff --git a/Classes/Resource/Processing/ImagePreviewTask.php b/Classes/Resource/Processing/ImagePreviewTask.php new file mode 100644 index 0000000..93a60ca --- /dev/null +++ b/Classes/Resource/Processing/ImagePreviewTask.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Imaging\GraphicalFunctions; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * A task for generating an image preview. + */ +class ImagePreviewTask extends AbstractTask +{ + protected ?string $targetFileExtension; + + public function getType(): string + { + return 'Image'; + } + + public function getName(): string + { + return 'Preview'; + } + + /** + * Returns the name the processed file should have + * in the filesystem. + */ + public function getTargetFilename(): string + { + return 'preview_' + . $this->getSourceFile()->getNameWithoutExtension() + . '_' . $this->getConfigurationChecksum() + . '.' . $this->getTargetFileExtension(); + } + + /** + * Determines the file extension the processed file + * should have in the filesystem. + */ + public function getTargetFileExtension(): string + { + if (!isset($this->targetFileExtension)) { + $this->targetFileExtension = $this->determineTargetFileExtension(); + } + return $this->targetFileExtension; + } + + /** + * Gets the file extension the processed file should + * have in the filesystem by either using the configuration + * setting, or the extension of the original file. + */ + protected function determineTargetFileExtension(): string + { + if (!empty($this->configuration['fileExtension'])) { + return $this->configuration['fileExtension']; + } + + // @todo - See note of determineDefaultProcessingFileExtension() - find a better place for this + $imageService = GeneralUtility::makeInstance(GraphicalFunctions::class); + return $imageService->determineDefaultProcessingFileExtension($this->getSourceFile()->getExtension()); + } + + /** + * Enforce default configuration for preview processing here, + * to be sure we find already processed files below, + * which we wouldn't if we would change the configuration later, as configuration is part of the lookup. + */ + public function sanitizeConfiguration(): void + { + $configuration = array_replace( + [ + 'width' => 64, + 'height' => 64, + ], + $this->configuration + ); + $configuration['width'] = MathUtility::forceIntegerInRange($configuration['width'], 1, 1000); + $configuration['height'] = MathUtility::forceIntegerInRange($configuration['height'], 1, 1000); + + $this->configuration = array_filter( + $configuration, + static function (string|int|bool|array|null $value, string $name): bool { + return !empty($value) && in_array($name, ['width', 'height'], true); + }, + ARRAY_FILTER_USE_BOTH + ); + parent::sanitizeConfiguration(); + } +} diff --git a/Classes/Resource/Processing/LocalImageProcessor.php b/Classes/Resource/Processing/LocalImageProcessor.php new file mode 100644 index 0000000..a6779fc --- /dev/null +++ b/Classes/Resource/Processing/LocalImageProcessor.php @@ -0,0 +1,436 @@ +<?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\Core\Resource\Processing; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Imaging\GraphicalFunctions; +use TYPO3\CMS\Core\Imaging\ImageProcessingInstructions; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\FileType; +use TYPO3\CMS\Core\Type\File\ImageInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\Imaging\GifBuilder; + +/** + * Processes Local Images files + */ +class LocalImageProcessor implements ProcessorInterface, LoggerAwareInterface +{ + use LoggerAwareTrait; + + /** + * Returns TRUE if this processor can process the given task. + */ + public function canProcessTask(TaskInterface $task): bool + { + return $task->getType() === 'Image' + && in_array($task->getName(), ['Preview', 'CropScaleMask'], true); + } + + /** + * Processes the given task. + * + * @throws \InvalidArgumentException + */ + public function processTask(TaskInterface $task): void + { + if ($this->checkForExistingTargetFile($task)) { + return; + } + $this->processTaskWithLocalFile($task, null); + } + + /** + * Processes an image described in a task, but optionally uses a given local image + * + * @throws \InvalidArgumentException + */ + protected function processTaskWithLocalFile(TaskInterface $task, ?string $localFile): void + { + try { + if ($task->getName() === 'CropScaleMask') { + if ($localFile === null) { + $result = $this->processCropScaleMask($task); + } else { + $result = $this->processCropScaleMaskWithLocalFile($task, $localFile); + } + } elseif ($task->getName() === 'Preview') { + if ($localFile === null) { + $result = $this->processPreview($task); + } else { + $result = $this->processPreviewWithLocalFile($task, $localFile); + } + } else { + throw new \InvalidArgumentException('Cannot find helper for task name: "' . $task->getName() . '"', 1353401352); + } + if ($result === null) { + $task->setExecuted(true); + $task->getTargetFile()->setUsesOriginalFile(); + } elseif (!empty($result['filePath']) && file_exists($result['filePath'])) { + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $result['filePath']); + if (($result['remapProcessedTargetFileExtension'] ?? null) !== null) { + // Processing changed the target filename extension to something else. + // We need to react on this, because otherwise the file contents will not + // match the file extension. + $task->getTargetFile()->setName($task->getTargetFileName() . '.' . $result['remapProcessedTargetFileExtension']); + } else { + $task->getTargetFile()->setName($task->getTargetFileName()); + } + $task->getTargetFile()->updateProperties([ + 'width' => $imageInformation->getWidth(), + 'height' => $imageInformation->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]); + $task->getTargetFile()->updateWithLocalFile($result['filePath']); + } else { + // Seems we have no valid processing result + $task->setExecuted(false); + } + } catch (\Exception $e) { + // @todo: Swallowing all exceptions including PHP warnings here is a bad idea. + // @todo: This should be restricted to more specific exceptions - if at all. + // @todo: For now, we at least log the situation. + $this->logger->error(sprintf('Processing task of image file'), ['exception' => $e]); + $task->setExecuted(false); + } + } + + /** + * Check if the target file that is to be processed already exists. + * If it exists, use the metadata from that file and mark task as done. + */ + protected function checkForExistingTargetFile(TaskInterface $task): bool + { + // the storage of the processed file, not of the original file! + $storage = $task->getTargetFile()->getStorage(); + $processingFolder = $storage->getProcessingFolder($task->getSourceFile()); + + // explicitly check for the raw filename here, as we check for files that existed before we even started + // processing, i.e. that were processed earlier + if ($processingFolder->hasFile($task->getTargetFileName())) { + // When the processed file already exists set it as processed file + $task->getTargetFile()->setName($task->getTargetFileName()); + + // If the processed file is stored on a remote server, we must fetch a local copy of the file, as we + // have no API for fetching file metadata from a remote file. + $localProcessedFile = $storage->getFileForLocalProcessing($task->getTargetFile(), false); + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $localProcessedFile); + $properties = [ + 'width' => $imageInformation->getWidth(), + 'height' => $imageInformation->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]; + $task->getTargetFile()->updateProperties($properties); + + return true; + } + return false; + } + + /** + * Helper methods to locally perform a crop/scale/mask task with the TYPO3 image processing classes. + */ + + /** + * This method actually does the processing of files locally + * + * Takes the original file (for remote storages this will be fetched from the remote server), + * does the IM magic on the local server by creating a temporary typo3temp/ file, + * copies the typo3temp/ file to the processing folder of the target storage and + * removes the typo3temp/ file. + * + * The returned array has the following structure: + * width => 100 + * height => 200 + * filePath => /some/path + * + * If filePath isn't set but width and height are the original file is used as ProcessedFile + * with the returned width and height. This is for example useful for SVG images. + */ + protected function processCropScaleMask(TaskInterface $task): ?array + { + return $this->processCropScaleMaskWithLocalFile($task, $task->getSourceFile()->getForLocalProcessing(false)); + } + + /** + * Does the heavy lifting prescribed in processTask() + * except that the processing can be performed on any given local image. + * Note that the resize() method usually does not upscale images (depends on "noScale" option), + * so the original file would be used for the processor result. + */ + protected function processCropScaleMaskWithLocalFile(TaskInterface $task, string $originalFileName): ?array + { + $result = null; + $targetFile = $task->getTargetFile(); + $targetFileExtension = $task->getTargetFileExtension(); + + $imageOperations = GeneralUtility::makeInstance(GraphicalFunctions::class); + + $configuration = $targetFile->getProcessingConfiguration(); + $configuration['additionalParameters'] ??= ''; + + // Normal situation (no masking) - just scale the image + if (!is_array($configuration['maskImages'] ?? null)) { + // the result info is an array with 0=width,1=height,2=extension,3=filename + $result = $imageOperations->resize( + $originalFileName, + $targetFileExtension, + $configuration['width'] ?? '', + $configuration['height'] ?? '', + $configuration['additionalParameters'], + $configuration, + ); + } else { + $temporaryFileName = $this->getFilenameForImageCropScaleMask($task); + $maskImage = $configuration['maskImages']['maskImage'] ?? null; + $maskBackgroundImage = $configuration['maskImages']['backgroundImage']; + if ($maskImage instanceof FileInterface && $maskBackgroundImage instanceof FileInterface) { + // This converts the original image to a temporary PNG file during all steps of the masking process + $tempFileInfo = $imageOperations->resize( + $originalFileName, + 'png', + $configuration['width'] ?? '', + $configuration['height'] ?? '', + $configuration['additionalParameters'], + $configuration + ); + if ($tempFileInfo !== null) { + // Scaling + $command = '-geometry ' . $tempFileInfo->getWidth() . 'x' . $tempFileInfo->getHeight() . '!'; + $imageOperations->mask( + $tempFileInfo->getRealPath(), + $temporaryFileName, + $maskImage->getForLocalProcessing(), + $maskBackgroundImage->getForLocalProcessing(), + $command, + $configuration + ); + $maskBottomImage = $configuration['maskImages']['maskBottomImage'] ?? null; + $maskBottomImageMask = $configuration['maskImages']['maskBottomImageMask'] ?? null; + if ($maskBottomImage instanceof FileInterface && $maskBottomImageMask instanceof FileInterface) { + // Uses the temporary PNG file from the previous step and applies another mask + $imageOperations->mask( + $temporaryFileName, + $temporaryFileName, + $maskBottomImage->getForLocalProcessing(), + $maskBottomImageMask->getForLocalProcessing(), + $command, + $configuration + ); + } + } + $result = $tempFileInfo; + } + } + + // check if the processing really generated a new file (scaled and/or cropped) + if ($result !== null) { + // The file processing yielded a different file extension than we anticipated. Most likely because + // the processing service found out a file type needed to use fallback storage. In this case, we + // append the actually received file extension to our file to be stored, which will also hint at + // a failed conversion, like some-file.avif.jpg. Otherwise use the same file extension. This is + // evaluated for persistence in @see LocalImageProcessor->processTaskWithLocalFile(). + $remapProcessedTargetFileExtension = ($targetFileExtension !== $result->getExtension()) + // Remap to correct image type extension. + ? $result->getExtension() + // No file extension remap required. + : null; + // @todo: realpath handling should be revisited, they may produce issues + // with open_basedir restrictions and/or lockRootPath. + if ($result->getRealPath() !== realpath($originalFileName)) { + $result = [ + 'width' => $result->getWidth(), + 'height' => $result->getHeight(), + 'filePath' => $result->getRealPath(), + 'remapProcessedTargetFileExtension' => $remapProcessedTargetFileExtension, + ]; + } else { + // No file was generated + $result = null; + } + } + + // If noScale option is applied, we need to reset the width and height to ensure the scaled values + // are used for the generated image tag even if the image itself is not scaled. This is needed, as + // the result is discarded due to the fact that the original image is used. + // @see https://forge.typo3.org/issues/100972 + // Note: This should only happen if no image has been generated ($result === null). + if ($result === null && ($configuration['noScale'] ?? false)) { + $configuration = $task->getConfiguration(); + $localProcessedFile = $task->getSourceFile()->getForLocalProcessing(false); + $imageDimensions = $imageOperations->getImageDimensions($localProcessedFile, true); + $imageScaleInfo = ImageProcessingInstructions::fromCropScaleValues( + $imageDimensions->getWidth(), + $imageDimensions->getHeight(), + $configuration['width'] ?? '', + $configuration['height'] ?? '', + $configuration + ); + $targetFile->updateProperties([ + 'width' => $imageScaleInfo->width, + 'height' => $imageScaleInfo->height, + ]); + } + + return $result; + } + + /** + * Returns the filename for a cropped/scaled/masked file which will be put in typo3temp for the time being. + */ + protected function getFilenameForImageCropScaleMask(TaskInterface $task): string + { + $targetFileExtension = $task->getTargetFileExtension(); + $name = $this->generateProcessedFileNameWithoutExtension($task); + return Environment::getPublicPath() . '/typo3temp/' . $name . '.' . ltrim(trim($targetFileExtension), '.'); + } + + /** + * Generate the name of the new File. Should be placed somwhere else? + */ + protected function generateProcessedFileNameWithoutExtension(TaskInterface $task): string + { + return implode('_', [ + $task->getSourceFile()->getNameWithoutExtension(), + $task->getSourceFile()->getUid(), + $task->getConfigurationChecksum(), + ]); + } + + /** + * Helper for creating local image previews using TYPO3s image processing classes. + */ + + /** + * This method actually does the processing of files locally + * + * takes the original file (on remote storages this will be fetched from the remote server) + * does the IM magic on the local server by creating a temporary typo3temp/ file + * copies the typo3temp/ file to the processing folder of the target storage + * removes the typo3temp/ file + * + * The returned array has the following structure: + * width => 100 + * height => 200 + * filePath => /some/path + * + * If filePath isn't set but width and height are the original file is used as ProcessedFile + * with the returned width and height. This is for example useful for SVG images. + */ + protected function processPreview(TaskInterface $task): ?array + { + $sourceFile = $task->getSourceFile(); + $task->sanitizeConfiguration(); + $configuration = $task->getConfiguration(); + + // Do not scale up, if the source file has dimensions and any target dimension (width/height) is larger + // This is related to $TYPO3_CONF_VARS['GFX']['processor_allowUpscaling'] = false to ensure the original + // file can be used (instead of getting processed) + if ($sourceFile->getProperty('width') > 0 && $sourceFile->getProperty('height') > 0 + && ( + $configuration['width'] > $sourceFile->getProperty('width') + || $configuration['height'] > $sourceFile->getProperty('height') + ) + ) { + return null; + } + + return $this->generatePreviewFromFile($sourceFile, $configuration, $this->getTemporaryFilePathForPreview($task)); + } + + /** + * Does the heavy lifting prescribed in processTask() + * except that the processing can be performed on any given local image + */ + protected function processPreviewWithLocalFile(TaskInterface $task, string $localFile): ?array + { + return $this->generatePreviewFromLocalFile($localFile, $task->getConfiguration(), $this->getTemporaryFilePathForPreview($task)); + } + + /** + * Returns the path to a temporary file for processing + * + * @return non-empty-string + */ + protected function getTemporaryFilePathForPreview(TaskInterface $task): string + { + return GeneralUtility::tempnam('preview_', '.' . $task->getTargetFileExtension()); + } + + /** + * Generates a preview for a file + * + * @param File $file The source file + * @param array $configuration Processing configuration + * @param string $targetFilePath Output file path + */ + protected function generatePreviewFromFile(File $file, array $configuration, string $targetFilePath): array + { + // Check file extension + if (!$file->isType(FileType::IMAGE) && !$file->isImage()) { + // Create a default image + $graphicalFunctions = GeneralUtility::makeInstance(GifBuilder::class); + $graphicalFunctions->getTemporaryImageWithText( + $targetFilePath, + 'Not imagefile!', + 'No ext!', + $file->getName() + ); + return [ + 'filePath' => $targetFilePath, + ]; + } + + return $this->generatePreviewFromLocalFile($file->getForLocalProcessing(false), $configuration, $targetFilePath); + } + + /** + * Generates a preview for a local file + * + * @param string $originalFileName Optional input file path + * @param array $configuration Processing configuration + * @param string $targetFilePath Output file path + */ + protected function generatePreviewFromLocalFile(string $originalFileName, array $configuration, string $targetFilePath): array + { + // Create the temporary file + $imageService = GeneralUtility::makeInstance(GraphicalFunctions::class); + $result = $imageService->resize($originalFileName, 'WEB', $configuration['width'] . 'm', $configuration['height'] . 'm', '', ['sample' => true]); + if ($result) { + $targetFilePath = $result->getRealPath(); + } + if (!file_exists($targetFilePath)) { + // Create an error gif + $graphicalFunctions = GeneralUtility::makeInstance(GifBuilder::class); + $graphicalFunctions->getTemporaryImageWithText( + $targetFilePath, + 'No thumb', + 'generated!' + ); + } + + return [ + 'filePath' => $targetFilePath, + ]; + } +} diff --git a/Classes/Resource/Processing/ProcessorInterface.php b/Classes/Resource/Processing/ProcessorInterface.php new file mode 100644 index 0000000..8908959 --- /dev/null +++ b/Classes/Resource/Processing/ProcessorInterface.php @@ -0,0 +1,34 @@ +<?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\Core\Resource\Processing; + +/** + * Interface for file processors. All classes capable of processing a file have to implement this interface. + */ +interface ProcessorInterface +{ + /** + * Returns TRUE if this processor can process the given task. + * + * @return bool + */ + public function canProcessTask(TaskInterface $task); + + /** + * Processes the given task and sets the processing result in the task object. + */ + public function processTask(TaskInterface $task); +} diff --git a/Classes/Resource/Processing/ProcessorRegistry.php b/Classes/Resource/Processing/ProcessorRegistry.php new file mode 100644 index 0000000..180a4b1 --- /dev/null +++ b/Classes/Resource/Processing/ProcessorRegistry.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Service\DependencyOrderingService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Registry for images processors. + */ +class ProcessorRegistry +{ + protected array $registeredProcessors = []; + + /** + * Auto register processors from configuration + */ + public function __construct(DependencyOrderingService $dependencyOrderingService) + { + $this->registeredProcessors = $dependencyOrderingService->orderByDependencies( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['processors'] ?? [] + ); + } + + /** + * Finds a matching processor that can process the given task. + * Registered processors will be tested by their priority from high to low. + */ + public function getProcessorByTask(TaskInterface $task): ProcessorInterface + { + $processor = null; + + foreach ($this->registeredProcessors as $key => $processorConfiguration) { + if (!isset($processorConfiguration['className'])) { + throw new \RuntimeException( + 'Missing key "className" for processor configuration "' . $key . '".', + 1560875741 + ); + } + + $processor = GeneralUtility::makeInstance($processorConfiguration['className']); + + if (!$processor instanceof ProcessorInterface) { + throw new \RuntimeException( + 'Processor "' . get_class($processor) . '" needs to implement interface "' . ProcessorInterface::class . '".', + 1560876288 + ); + } + + if ($processor->canProcessTask($task)) { + /* + * Stop checking for further processors to speed up image processing. + * If another processor should be used, it can be registered with higher priority. + */ + break; + } + + $processor = null; + } + + if ($processor === null) { + throw new \RuntimeException( + sprintf('No matching file processor found for task type "%s" and name "%s".', $task->getType(), $task->getName()), + 1560876294 + ); + } + + return $processor; + } +} diff --git a/Classes/Resource/Processing/SvgImageProcessor.php b/Classes/Resource/Processing/SvgImageProcessor.php new file mode 100644 index 0000000..4e7cd06 --- /dev/null +++ b/Classes/Resource/Processing/SvgImageProcessor.php @@ -0,0 +1,192 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Imaging\Exception\InvalidSvgException; +use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException; +use TYPO3\CMS\Core\Imaging\ImageDimension; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; +use TYPO3\CMS\Core\Imaging\ImageProcessingInstructions; +use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentFactory; +use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentService; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderReadPermissionsException; +use TYPO3\CMS\Core\Type\File\ImageInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Processes (scales) SVG Images files or crops them via \DOMDocument + * and creates a new locally created processed file which is then pushed + * into FAL again. + */ +#[Autoconfigure(public: true)] +readonly class SvgImageProcessor implements ProcessorInterface +{ + private const int DEFAULT_SVG_DIMENSION = 64; + + public function __construct( + private SvgDocumentFactory $svgDocumentFactory, + private SvgDocumentService $svgDocumentService, + ) {} + + public function canProcessTask(TaskInterface $task): bool + { + return $task->getType() === 'Image' + && in_array($task->getName(), ['Preview', 'CropScaleMask'], true) + && $task->getTargetFileExtension() === 'svg'; + } + + /** + * Processes the given task. + * + * @throws \InvalidArgumentException|InsufficientFolderReadPermissionsException + */ + public function processTask(TaskInterface $task): void + { + try { + $processingInstructions = ImageProcessingInstructions::fromProcessingTask($task); + $imageDimension = new ImageDimension($processingInstructions->width, $processingInstructions->height); + } catch (ZeroImageDimensionException) { + $processingInstructions = new ImageProcessingInstructions( + width: self::DEFAULT_SVG_DIMENSION, + height: self::DEFAULT_SVG_DIMENSION, + ); + // To not fail image processing, we just assume an SVG image dimension here + $imageDimension = new ImageDimension( + width: self::DEFAULT_SVG_DIMENSION, + height: self::DEFAULT_SVG_DIMENSION + ); + } + + $task->getTargetFile()->updateProperties( + [ + 'width' => $imageDimension->getWidth(), + 'height' => $imageDimension->getHeight(), + 'size' => $task->getSourceFile()->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ] + ); + + if ($this->checkForExistingTargetFile($task)) { + return; + } + + $cropArea = $processingInstructions->cropArea; + if ($cropArea === null || $cropArea->makeRelativeBasedOnFile($task->getSourceFile())->isEmpty()) { + $task->setExecuted(true); + $task->getTargetFile()->setUsesOriginalFile(); + return; + } + + $this->applyCropping($task, $cropArea, $imageDimension); + } + + /** + * Wrap the source SVG in a crop container and write it to a temporary + * processed file. The wrapper carries the viewBox crop and target + * dimensions so the result is self-contained when embedded via <img>. + */ + protected function applyCropping(TaskInterface $task, Area $cropArea, ImageDimension $imageDimension): void + { + try { + $document = $this->svgDocumentFactory->fromFile($task->getSourceFile()); + $processedSvg = $this->svgDocumentService->cropScale($document, $cropArea, $imageDimension); + } catch (InvalidSvgException) { + // Source SVG could not be parsed - fall back to the unprocessed original. + $task->setExecuted(true); + $task->getTargetFile()->setUsesOriginalFile(); + return; + } + $temporaryFilename = $this->getFilenameForSvgCropScaleMask($task); + GeneralUtility::writeFile($temporaryFilename, $this->svgDocumentService->toXml($processedSvg), true); + + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $temporaryFilename); + + $task->getTargetFile()->setName($task->getTargetFileName()); + + $task->getTargetFile()->updateProperties([ + // @todo: Use round() instead of int-cast to avoid an implicit floor()? + 'width' => (string)$imageDimension->getWidth(), + 'height' => (string)$imageDimension->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]); + $task->getTargetFile()->updateWithLocalFile($temporaryFilename); + GeneralUtility::unlink_tempfile($temporaryFilename); + } + + /** + * Check if the target file that is to be processed already exists. + * If it exists, use the metadata from that file and mark task as done. + * + * @throws InsufficientFolderReadPermissionsException + * @todo - Refactor this 80% duplicate code of LocalImageProcessor::checkForExistingTargetFile + */ + protected function checkForExistingTargetFile(TaskInterface $task): bool + { + // the storage of the processed file, not of the original file! + $storage = $task->getTargetFile()->getStorage(); + $processingFolder = $storage->getProcessingFolder($task->getSourceFile()); + + // explicitly check for the raw filename here, as we check for files that existed before we even started + // processing, i.e. that were processed earlier + if ($processingFolder->hasFile($task->getTargetFileName())) { + // When the processed file already exists set it as processed file + $task->getTargetFile()->setName($task->getTargetFileName()); + + // If the processed file is stored on a remote server, we must fetch a local copy of the file, as we + // have no API for fetching file metadata from a remote file. + $localProcessedFile = $storage->getFileForLocalProcessing($task->getTargetFile(), false); + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $localProcessedFile); + $properties = [ + 'width' => $imageInformation->getWidth(), + 'height' => $imageInformation->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]; + $task->getTargetFile()->updateProperties($properties); + + return true; + } + return false; + } + + /** + * Returns the filename for a cropped/scaled/masked file which will be put + * in typo3temp for the time being. + */ + protected function getFilenameForSvgCropScaleMask(TaskInterface $task): string + { + $targetFileExtension = $task->getTargetFileExtension(); + return GeneralUtility::tempnam($this->generateProcessedFileNameWithoutExtension($task), '.' . ltrim(trim($targetFileExtension))); + } + + /** + * Generate the name of the new File. Should be placed somwhere else? + */ + protected function generateProcessedFileNameWithoutExtension(TaskInterface $task): string + { + return implode('_', [ + $task->getSourceFile()->getNameWithoutExtension(), + $task->getSourceFile()->getUid(), + $task->getConfigurationChecksum(), + ]); + } +} diff --git a/Classes/Resource/Processing/TaskInterface.php b/Classes/Resource/Processing/TaskInterface.php new file mode 100644 index 0000000..1ebc00b --- /dev/null +++ b/Classes/Resource/Processing/TaskInterface.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Processing; + +use TYPO3\CMS\Core\Resource; + +/** + * A task is a unit of work that can be performed by a file processor. This may include multiple steps in any order, + * details depend on the configuration of the task and the tools the processor uses. + * + * Each task has a type and a name. The type describes the category of the task, like "image" and "video". If your task + * is generic or applies to multiple types of files, use "general". + * + * A task also already has to know the target file it should be executed on, so there is no "abstract" task that just + * specifies the steps to be executed without a concrete file. However, new tasks can easily be created from an + * existing task object. + */ +interface TaskInterface +{ + /** + * Returns the name of this task. + */ + public function getName(): string; + + /** + * Returns the type of this task. + */ + public function getType(): string; + + /** + * Returns the processed file this task is executed on. + */ + public function getTargetFile(): Resource\ProcessedFile; + + /** + * Returns the original file this task is based on. + */ + public function getSourceFile(): Resource\File; + + /** + * Returns the configuration for this task. + */ + public function getConfiguration(): array; + + /** + * Returns the configuration checksum of this task. + */ + public function getConfigurationChecksum(): string; + + /** + * Returns the name the processed file should have in the filesystem. + */ + public function getTargetFileName(): string; + + /** + * Gets the file extension the processed file should have in the filesystem. + */ + public function getTargetFileExtension(): string; + + /** + * Returns TRUE if the file has to be processed at all, such as e.g. the original file does. + * + * Note: This does not indicate if the concrete ProcessedFile attached to this task has to be (re)processed. + * This check is done in ProcessedFile::isOutdated(). @todo isOutdated()/needsReprocessing()? + */ + public function fileNeedsProcessing(): bool; + + /** + * Returns TRUE if this task has been executed, no matter if the execution was successful. + */ + public function isExecuted(): bool; + + /** + * Mark this task as executed. This is used by the Processors in order to transfer the state of this task to + * the file processing service. + * + * @param bool $successful Set this to FALSE if executing the task failed + */ + public function setExecuted(bool $successful): void; + + /** + * Returns TRUE if this task has been successfully executed. Only call this method if the task has been processed + * at all. + * + * @throws \LogicException If the task has not been executed already + */ + public function isSuccessful(): bool; + + /** + * For some tasks it might be important and useful to clean up the configuration, in order to find the + * ProcessedFile that uses this configuration. + * + * Ideally, a task has some information what needs to be used or not. + */ + public function sanitizeConfiguration(): void; +} diff --git a/Classes/Resource/Processing/TaskTypeRegistry.php b/Classes/Resource/Processing/TaskTypeRegistry.php new file mode 100644 index 0000000..a0a9682 --- /dev/null +++ b/Classes/Resource/Processing/TaskTypeRegistry.php @@ -0,0 +1,57 @@ +<?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\Core\Resource\Processing; + +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * The registry for task types. + */ +class TaskTypeRegistry implements SingletonInterface +{ + protected array $registeredTaskTypes = []; + + /** + * Register task types from configuration + */ + public function __construct() + { + $this->registeredTaskTypes = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['processingTaskTypes']; + } + + /** + * Returns the class that implements the given task type. + */ + protected function getClassForTaskType(string $taskType): ?string + { + return $this->registeredTaskTypes[$taskType] ?? null; + } + + /** + * @throws \RuntimeException + */ + public function getTaskForType(string $taskType, ProcessedFile $processedFile, array $processingConfiguration): TaskInterface + { + $taskClass = $this->getClassForTaskType($taskType); + if ($taskClass === null) { + throw new \RuntimeException('Unknown processing task "' . $taskType . '"', 1476049767); + } + + return GeneralUtility::makeInstance($taskClass, $processedFile, $processingConfiguration); + } +} diff --git a/Classes/Resource/RelativeCssPathFixer.php b/Classes/Resource/RelativeCssPathFixer.php new file mode 100644 index 0000000..3fae3d4 --- /dev/null +++ b/Classes/Resource/RelativeCssPathFixer.php @@ -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\Core\Resource; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; + +/** + * This fixes import paths in CSS files if their location changes, + * e.g. when inlining or compressing css + * + * @internal This class is not part of the TYPO3 API. + */ +class RelativeCssPathFixer +{ + public function __construct( + private readonly SystemResourceFactory $resourceFactory, + private readonly SystemResourcePublisherInterface $resourcePublisher, + ) {} + + /** + * Fixes the relative paths inside of url() references in CSS files + * + * @param string $contents Data to process + * @param string $newDir directory referenced from current location + * @return string Processed data + */ + public function fixRelativeUrlPaths(string $contents, string $newDir, ServerRequestInterface $request): string + { + // Replace "url()" paths + if (stripos($contents, 'url') !== false) { + $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU'; + $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')', $request); + } + // Replace "@import" paths + if (stripos($contents, '@import') !== false) { + $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i'; + $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"', $request); + } + return $contents; + } + + /** + * Finds and replaces all URLs by using a given regex + * + * @param string $contents Data to process + * @param string $regex Regex used to find URLs in content + * @param string $newDir Path to prepend to the original file + * @param string $wrap Wrap around replaced values + * @return string Processed data + */ + protected function findAndReplaceUrlPathsByRegex(string $contents, string $regex, string $newDir, string $wrap, ServerRequestInterface $request): string + { + $matches = []; + $replacements = []; + $wrapParts = explode('|', $wrap); + preg_match_all($regex, $contents, $matches); + foreach ($matches[2] as $matchCount => $match) { + // remove '," or white-spaces around + $match = trim($match, '\'" '); + // we must not rewrite paths starting with "#", containing ":" or "url(", e.g. data URIs (see RFC 2397) + if (!str_starts_with($match, '#') && !str_contains($match, ':') && !preg_match('/url\\s*\\(/i', $match)) { + $newPath = $this->resolveBackPath($newDir . $match); + $newUri = $this->resourcePublisher->generateUri($this->resourceFactory->createPublicResource($newPath), $request); + $replacements[$matches[1][$matchCount]] = $wrapParts[0] . $newUri . $wrapParts[1]; + } + } + // replace URL paths in content + if (!empty($replacements)) { + $contents = str_replace(array_keys($replacements), array_values($replacements), $contents); + } + return $contents; + } + + /** + * Resolves "../" sections in the input path string. + * For example "fileadmin/directory/../other_directory/" will be resolved to "fileadmin/other_directory/" + * + * @param string $pathStr File path in which "/../" is resolved + */ + protected function resolveBackPath(string $pathStr): string + { + if (!str_contains($pathStr, '..')) { + return $pathStr; + } + $parts = explode('/', $pathStr); + $output = []; + $c = 0; + foreach ($parts as $part) { + if ($part === '..') { + if ($c) { + array_pop($output); + --$c; + } else { + $output[] = $part; + } + } else { + ++$c; + $output[] = $part; + } + } + return implode('/', $output); + } +} diff --git a/Classes/Resource/Rendering/AudioTagRenderer.php b/Classes/Resource/Rendering/AudioTagRenderer.php new file mode 100644 index 0000000..0f90712 --- /dev/null +++ b/Classes/Resource/Rendering/AudioTagRenderer.php @@ -0,0 +1,95 @@ +<?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\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Attribute\AsFileRenderer; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\FileReference; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +#[AsFileRenderer] +class AudioTagRenderer implements FileRendererInterface +{ + /** + * Mime types that can be used in the HTML Video tag + * + * @var array + */ + protected $possibleMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/ogg']; + + /** + * Check if given File(Reference) can be rendered + * + * @param FileInterface $file File or FileReference to render + */ + public function canRender(FileInterface $file): bool + { + return in_array($file->getMimeType(), $this->possibleMimeTypes, true); + } + + /** + * Render for given File(Reference) HTML output + * + * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c + * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c + * @param array $options controls = TRUE/FALSE (default TRUE), autoplay = TRUE/FALSE (default FALSE), loop = TRUE/FALSE (default FALSE) + */ + public function render(FileInterface $file, int|string $width, int|string $height, array $options = []): string + { + // If autoplay isn't set manually check if $file is a FileReference take autoplay from there + if (!isset($options['autoplay']) && $file instanceof FileReference) { + $autoplay = $file->getProperty('autoplay'); + if ($autoplay !== null) { + $options['autoplay'] = $autoplay; + } + } + + $additionalAttributes = []; + if (isset($options['additionalAttributes']) && is_array($options['additionalAttributes'])) { + $additionalAttributes[] = GeneralUtility::implodeAttributes($options['additionalAttributes'], true, true); + } + if (isset($options['data']) && is_array($options['data'])) { + array_walk($options['data'], static function (string &$value, string $key): void { + $value = 'data-' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"'; + }); + $additionalAttributes[] = implode(' ', $options['data']); + } + if (!isset($options['controls']) || !empty($options['controls'])) { + $additionalAttributes[] = 'controls'; + } + if (!empty($options['autoplay'])) { + $additionalAttributes[] = 'autoplay'; + } + if (!empty($options['muted'])) { + $additionalAttributes[] = 'muted'; + } + if (!empty($options['loop'])) { + $additionalAttributes[] = 'loop'; + } + foreach (['class', 'dir', 'id', 'lang', 'style', 'title', 'accesskey', 'tabindex', 'onclick', 'preload', 'controlsList'] as $key) { + if (!empty($options[$key])) { + $additionalAttributes[] = $key . '="' . htmlspecialchars($options[$key]) . '"'; + } + } + + return sprintf( + '<audio%s><source src="%s" type="%s"></audio>', + empty($additionalAttributes) ? '' : ' ' . implode(' ', $additionalAttributes), + htmlspecialchars((string)$file->getPublicUrl()), + $file->getMimeType() + ); + } +} diff --git a/Classes/Resource/Rendering/FileRendererInterface.php b/Classes/Resource/Rendering/FileRendererInterface.php new file mode 100644 index 0000000..d1fcbd2 --- /dev/null +++ b/Classes/Resource/Rendering/FileRendererInterface.php @@ -0,0 +1,40 @@ +<?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\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * Interface for file renderers, which are registered as tagged services + * via the #[AsFileRenderer] attribute or the 'fal.file_renderer' service tag. + */ +interface FileRendererInterface +{ + /** + * Check if given File(Reference) can be rendered + * + * @param FileInterface $file File or FileReference to render + */ + public function canRender(FileInterface $file): bool; + + /** + * Render for given File(Reference) HTML output + * + * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c + * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c + */ + public function render(FileInterface $file, int|string $width, int|string $height, array $options = []): string; +} diff --git a/Classes/Resource/Rendering/RendererRegistry.php b/Classes/Resource/Rendering/RendererRegistry.php new file mode 100644 index 0000000..872a721 --- /dev/null +++ b/Classes/Resource/Rendering/RendererRegistry.php @@ -0,0 +1,83 @@ +<?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\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\SingletonInterface; + +/** + * Registry for file renderers, which are registered as tagged services + * via the #[AsFileRenderer] attribute or the 'fal.file_renderer' service + * tag. Renderers are ordered by their tag priority, a renderer with a + * higher priority is asked first whether it can render a file. + * + * @internal not part of TYPO3's Core API. Register file renderers via the #[AsFileRenderer] attribute instead. + */ +class RendererRegistry implements SingletonInterface +{ + /** + * Instance cache for renderer classes + * + * @var FileRendererInterface[]|null + */ + protected ?array $instances = null; + + /** + * @param iterable<FileRendererInterface> $renderers + */ + public function __construct(protected readonly iterable $renderers = []) {} + + /** + * @deprecated since TYPO3 v15.0, this method is a no-op and will be removed in TYPO3 v16.0. Register the renderer as a tagged service using the #[AsFileRenderer] attribute instead. + */ + public function registerRendererClass(string $className): void + { + trigger_error( + 'RendererRegistry->registerRendererClass() is a no-op since TYPO3 v15.0 and will be removed in TYPO3 v16.0.' + . ' Register "' . $className . '" as a tagged service using the #[AsFileRenderer] attribute instead.', + E_USER_DEPRECATED + ); + } + + /** + * Get all registered renderer instances + * + * @return FileRendererInterface[] + */ + protected function getRendererInstances(): array + { + if ($this->instances === null) { + $this->instances = []; + foreach ($this->renderers as $renderer) { + $this->instances[] = $renderer; + } + } + return $this->instances; + } + + /** + * Get matching renderer with highest priority + */ + public function getRenderer(FileInterface $file): ?FileRendererInterface + { + foreach ($this->getRendererInstances() as $fileRenderer) { + if ($fileRenderer->canRender($file)) { + return $fileRenderer; + } + } + return null; + } +} diff --git a/Classes/Resource/Rendering/VideoTagRenderer.php b/Classes/Resource/Rendering/VideoTagRenderer.php new file mode 100644 index 0000000..fbe7320 --- /dev/null +++ b/Classes/Resource/Rendering/VideoTagRenderer.php @@ -0,0 +1,127 @@ +<?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\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Attribute\AsFileRenderer; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\FileReference; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +#[AsFileRenderer] +class VideoTagRenderer implements FileRendererInterface +{ + /** + * Mime types that can be used in the HTML Video tag + * + * @var array + */ + protected $possibleMimeTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/x-m4v', 'application/ogg']; + + /** + * Special attributes which do not exist on <video> tags and therefore should be omitted. + * + * @var string[] + */ + protected array $excludeAttributes = ['api', 'no-cookie']; + + /** + * Check if given File(Reference) can be rendered + * + * @param FileInterface $file File or FileReference to render + */ + public function canRender(FileInterface $file): bool + { + return in_array($file->getMimeType(), $this->possibleMimeTypes, true); + } + + /** + * Render for given File(Reference) HTML output + * + * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c + * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c + * @param array $options controls = TRUE/FALSE (default TRUE), autoplay = TRUE/FALSE (default FALSE), loop = TRUE/FALSE (default FALSE) + */ + public function render(FileInterface $file, int|string $width, int|string $height, array $options = []): string + { + // If autoplay isn't set manually check if $file is a FileReference take autoplay from there + if (!isset($options['autoplay']) && $file instanceof FileReference) { + $autoplay = $file->getProperty('autoplay'); + if ($autoplay !== null) { + $options['autoplay'] = $autoplay; + } + } + + $attributes = []; + if (isset($options['additionalAttributes']) && is_array($options['additionalAttributes'])) { + $attributes[] = GeneralUtility::implodeAttributes($options['additionalAttributes'], true, true); + } + if (isset($options['data']) && is_array($options['data'])) { + array_walk($options['data'], static function (string &$value, string $key): void { + $value = 'data-' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"'; + }); + $attributes[] = implode(' ', $options['data']); + } + if ((int)$width > 0) { + $attributes[] = 'width="' . (int)$width . '"'; + } + if ((int)$height > 0) { + $attributes[] = 'height="' . (int)$height . '"'; + } + if (!isset($options['controls']) || !empty($options['controls'])) { + $attributes[] = 'controls'; + } + if (!empty($options['autoplay'])) { + $attributes[] = 'autoplay'; + // If autoplay is enabled, enforce muted, see https://developer.chrome.com/blog/autoplay/ + $attributes[] = 'muted'; + } + if (!empty($options['muted'])) { + $attributes[] = 'muted'; + } + if (!empty($options['loop'])) { + $attributes[] = 'loop'; + } + if (isset($options['additionalConfig']) && is_array($options['additionalConfig'])) { + foreach ($options['additionalConfig'] as $key => $value) { + if ($value && !in_array($key, $this->excludeAttributes, true)) { + if ((int)$value !== 1) { + $attributes[] = htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"'; + } else { + $attributes[] = htmlspecialchars($key); + } + // Ensure that the property is not set afterwards + $options[$key] = false; + } + } + } + + foreach (['class', 'dir', 'id', 'lang', 'style', 'title', 'accesskey', 'tabindex', 'onclick', 'controlsList', 'preload'] as $key) { + if (!empty($options[$key])) { + $attributes[] = $key . '="' . htmlspecialchars($options[$key]) . '"'; + } + } + + // Clean up duplicate attributes + $attributes = array_unique($attributes); + + return sprintf( + '<video%s><source src="%s" type="%s"></video>', + empty($attributes) ? '' : ' ' . implode(' ', $attributes), + htmlspecialchars((string)$file->getPublicUrl()), + $file->getMimeType() + ); + } +} diff --git a/Classes/Resource/Rendering/VimeoRenderer.php b/Classes/Resource/Rendering/VimeoRenderer.php new file mode 100644 index 0000000..20ecd9b --- /dev/null +++ b/Classes/Resource/Rendering/VimeoRenderer.php @@ -0,0 +1,231 @@ +<?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\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Attribute\AsFileRenderer; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\FileReference; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperInterface; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Type\DocType; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Vimeo renderer class + */ +#[AsFileRenderer] +class VimeoRenderer implements FileRendererInterface +{ + /** + * @var OnlineMediaHelperInterface|false + */ + protected $onlineMediaHelper; + + /** + * Check if given File(Reference) can be rendered + * + * @param FileInterface $file File of FileReference to render + */ + public function canRender(FileInterface $file): bool + { + return ($file->getMimeType() === 'video/vimeo' || $file->getExtension() === 'vimeo') && $this->getOnlineMediaHelper($file) !== false; + } + + /** + * Get online media helper + * + * @return false|OnlineMediaHelperInterface + */ + protected function getOnlineMediaHelper(FileInterface $file) + { + if ($this->onlineMediaHelper === null) { + $orgFile = $file; + if ($orgFile instanceof FileReference) { + $orgFile = $orgFile->getOriginalFile(); + } + if ($orgFile instanceof File) { + $this->onlineMediaHelper = GeneralUtility::makeInstance(OnlineMediaHelperRegistry::class)->getOnlineMediaHelper($orgFile); + } else { + $this->onlineMediaHelper = false; + } + } + return $this->onlineMediaHelper; + } + + /** + * Render for given File(Reference) html output + * + * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c + * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c + */ + public function render(FileInterface $file, int|string $width, int|string $height, array $options = []): string + { + $options = $this->collectOptions($options, $file); + $src = $this->createVimeoUrl($options, $file); + if ($src === '') { + return ''; + } + $attributes = $this->collectIframeAttributes($width, $height, $options); + + return sprintf( + '<iframe %s="%s"%s></iframe>', + $options['srcAttribute'] ?? 'src', + htmlspecialchars($src, ENT_QUOTES | ENT_HTML5), + empty($attributes) ? '' : ' ' . $this->implodeAttributes($attributes) + ); + } + + /** + * @return array + */ + protected function collectOptions(array $options, FileInterface $file) + { + // Check for an autoplay option at the file reference itself, if not overridden yet. + if (!isset($options['autoplay']) && $file instanceof FileReference) { + $autoplay = $file->getProperty('autoplay'); + if ($autoplay !== null) { + $options['autoplay'] = $autoplay; + } + } + + if (!isset($options['allow'])) { + $options['allow'] = 'fullscreen'; + if (!empty($options['autoplay'])) { + $options['allow'] = 'autoplay; fullscreen'; + } + } + + return $options; + } + + protected function createVimeoUrl(array $options, FileInterface $file): string + { + $videoIdRaw = $this->getVideoIdFromFile($file); + $videoIdRaw = GeneralUtility::trimExplode('/', $videoIdRaw, true); + + $videoId = $videoIdRaw[0] ?? ''; + if (empty($videoId)) { + return ''; + } + $hash = $videoIdRaw[1] ?? null; + + $urlParams = []; + if (!empty($hash)) { + $urlParams[] = 'h=' . $hash; + } + if (!empty($options['autoplay'])) { + $urlParams[] = 'autoplay=1'; + // If autoplay is enabled, enforce muted=1, see https://developer.chrome.com/blog/autoplay/ + $urlParams[] = 'muted=1'; + } + if (!empty($options['loop'])) { + $urlParams[] = 'loop=1'; + } + if (!empty($options['background'])) { + $urlParams[] = 'background=1'; + } + if (isset($options['api']) && (int)$options['api'] === 1) { + $urlParams[] = 'api=1'; + } + if (!isset($options['no-cookie']) || !empty($options['no-cookie'])) { + $urlParams[] = 'dnt=1'; + } + $urlParams[] = 'title=' . (int)!empty($options['showinfo']); + $urlParams[] = 'byline=' . (int)!empty($options['showinfo']); + $urlParams[] = 'portrait=0'; + return sprintf('https://player.vimeo.com/video/%s?%s', $videoId, implode('&', $urlParams)); + } + + /** + * @return string + */ + protected function getVideoIdFromFile(FileInterface $file) + { + if ($file instanceof FileReference) { + $orgFile = $file->getOriginalFile(); + } else { + $orgFile = $file; + } + + return $this->getOnlineMediaHelper($file)->getOnlineMediaId($orgFile); + } + + /** + * @param int|string $width + * @param int|string $height + * @return array pairs of key/value; not yet html-escaped + */ + protected function collectIframeAttributes($width, $height, array $options) + { + $attributes = []; + $attributes['allowfullscreen'] = true; + + if (isset($options['additionalAttributes']) && is_array($options['additionalAttributes'])) { + $attributes = array_merge($attributes, $options['additionalAttributes']); + } + if (isset($options['data']) && is_array($options['data'])) { + array_walk( + $options['data'], + static function (string $value, string|int $key) use (&$attributes): void { + $attributes['data-' . $key] = $value; + } + ); + } + if ((int)$width > 0) { + $attributes['width'] = (int)$width; + } + if ((int)$height > 0) { + $attributes['height'] = (int)$height; + } + if ($this->shouldIncludeFrameBorderAttribute()) { + $attributes['frameborder'] = 0; + } + foreach (['class', 'dir', 'id', 'lang', 'style', 'title', 'accesskey', 'tabindex', 'onclick', 'allow'] as $key) { + if (!empty($options[$key])) { + $attributes[$key] = $options[$key]; + } + } + return $attributes; + } + + /** + * @internal + */ + protected function implodeAttributes(array $attributes): string + { + $attributeList = []; + foreach ($attributes as $name => $value) { + $name = preg_replace('/[^\p{L}0-9_.-]/u', '', $name); + if ($value === true) { + $attributeList[] = $name; + } else { + $attributeList[] = $name . '="' . htmlspecialchars($value, ENT_QUOTES | ENT_HTML5) . '"'; + } + } + return implode(' ', $attributeList); + } + + /** + * HTML5 deprecated the "frameborder" attribute as everything should be done via styling. + * + * @todo: This renderer has a dependency to Request / TypoScript. Model this explicitly. + */ + protected function shouldIncludeFrameBorderAttribute(): bool + { + return DocType::createFromRequest($GLOBALS['TYPO3_REQUEST'] ?? null)->shouldIncludeFrameBorderAttribute(); + } +} diff --git a/Classes/Resource/Rendering/YouTubeRenderer.php b/Classes/Resource/Rendering/YouTubeRenderer.php new file mode 100644 index 0000000..0b3651e --- /dev/null +++ b/Classes/Resource/Rendering/YouTubeRenderer.php @@ -0,0 +1,234 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Rendering; + +use TYPO3\CMS\Core\Attribute\AsFileRenderer; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\FileReference; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperInterface; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Type\DocType; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * YouTube renderer class + */ +#[AsFileRenderer] +class YouTubeRenderer implements FileRendererInterface +{ + /** + * @var OnlineMediaHelperInterface|false + */ + protected $onlineMediaHelper; + + /** + * Check if given File(Reference) can be rendered + * + * @param FileInterface $file File of FileReference to render + */ + public function canRender(FileInterface $file): bool + { + return ($file->getMimeType() === 'video/youtube' || $file->getExtension() === 'youtube') && $this->getOnlineMediaHelper($file) !== false; + } + + /** + * Get online media helper + * + * @return false|OnlineMediaHelperInterface + */ + protected function getOnlineMediaHelper(FileInterface $file) + { + if ($this->onlineMediaHelper === null) { + $orgFile = $file; + if ($orgFile instanceof FileReference) { + $orgFile = $orgFile->getOriginalFile(); + } + if ($orgFile instanceof File) { + $this->onlineMediaHelper = GeneralUtility::makeInstance(OnlineMediaHelperRegistry::class)->getOnlineMediaHelper($orgFile); + } else { + $this->onlineMediaHelper = false; + } + } + return $this->onlineMediaHelper; + } + + /** + * Render for given File(Reference) html output + * + * @param int|string $width TYPO3 known format; examples: 220, 200m or 200c + * @param int|string $height TYPO3 known format; examples: 220, 200m or 200c + */ + public function render(FileInterface $file, int|string $width, int|string $height, array $options = []): string + { + $options = $this->collectOptions($options, $file); + $src = $this->createYouTubeUrl($options, $file); + if (empty($src)) { + return ''; + } + $attributes = $this->collectIframeAttributes($width, $height, $options); + + return sprintf( + '<iframe %s="%s"%s></iframe>', + $options['srcAttribute'] ?? 'src', + htmlspecialchars($src, ENT_QUOTES | ENT_HTML5), + empty($attributes) ? '' : ' ' . $this->implodeAttributes($attributes) + ); + } + + /** + * @return array + */ + protected function collectOptions(array $options, FileInterface $file) + { + // Check for an autoplay option at the file reference itself, if not overridden yet. + if (!isset($options['autoplay']) && $file instanceof FileReference) { + $autoplay = $file->getProperty('autoplay'); + if ($autoplay !== null) { + $options['autoplay'] = $autoplay; + } + } + + $showPlayerControls = 1; + $options['controls'] = (int)!empty($options['controls'] ?? $showPlayerControls); + + if (!isset($options['allow'])) { + $options['allow'] = 'fullscreen'; + if (!empty($options['autoplay'])) { + $options['allow'] = 'autoplay; fullscreen'; + } + } + return $options; + } + + protected function createYouTubeUrl(array $options, FileInterface $file): string + { + $videoId = $this->getVideoIdFromFile($file); + + if (empty($videoId)) { + return ''; + } + + $urlParams = ['autohide=1']; + $urlParams[] = 'controls=' . $options['controls']; + if (!empty($options['autoplay'])) { + $urlParams[] = 'autoplay=1'; + // If autoplay is enabled, enforce mute=1, see https://developer.chrome.com/blog/autoplay/ + $urlParams[] = 'mute=1'; + } + if (!empty($options['modestbranding'])) { + $urlParams[] = 'modestbranding=1'; + } + if (!empty($options['loop'])) { + $urlParams[] = 'loop=1&playlist=' . rawurlencode($videoId); + } + if (isset($options['relatedVideos'])) { + $urlParams[] = 'rel=' . (int)(bool)$options['relatedVideos']; + } + if (!isset($options['enablejsapi']) || !empty($options['enablejsapi'])) { + // @todo: This renderer has a dependency to Request / TypoScript. Model this explicitly. + $urlParams[] = 'enablejsapi=1&origin=' . rawurlencode( + ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('normalizedParams')?->getRequestHost() ?? '' + ); + } + + $youTubeUrl = sprintf( + 'https://www.youtube%s.com/embed/%s?%s', + !isset($options['no-cookie']) || !empty($options['no-cookie']) ? '-nocookie' : '', + rawurlencode($videoId), + implode('&', $urlParams) + ); + + return $youTubeUrl; + } + + /** + * @return string + */ + protected function getVideoIdFromFile(FileInterface $file) + { + if ($file instanceof FileReference) { + $orgFile = $file->getOriginalFile(); + } else { + $orgFile = $file; + } + + return $this->getOnlineMediaHelper($file)->getOnlineMediaId($orgFile); + } + + /** + * @param int|string $width + * @param int|string $height + * @return array pairs of key/value; not yet html-escaped + */ + protected function collectIframeAttributes($width, $height, array $options) + { + $attributes = []; + $attributes['allowfullscreen'] = true; + + if (isset($options['additionalAttributes']) && is_array($options['additionalAttributes'])) { + $attributes = array_merge($attributes, $options['additionalAttributes']); + } + if (isset($options['data']) && is_array($options['data'])) { + array_walk($options['data'], static function (string|int $value, string $key) use (&$attributes): void { + $attributes['data-' . $key] = $value; + }); + } + if ((int)$width > 0) { + $attributes['width'] = (int)$width; + } + if ((int)$height > 0) { + $attributes['height'] = (int)$height; + } + if ($this->shouldIncludeFrameBorderAttribute()) { + $attributes['frameborder'] = 0; + } + foreach (['class', 'dir', 'id', 'lang', 'style', 'title', 'accesskey', 'tabindex', 'onclick', 'poster', 'preload', 'allow'] as $key) { + if (!empty($options[$key])) { + $attributes[$key] = $options[$key]; + } + } + + return $attributes; + } + + /** + * @internal + */ + protected function implodeAttributes(array $attributes): string + { + $attributeList = []; + foreach ($attributes as $name => $value) { + $name = preg_replace('/[^\p{L}0-9_.-]/u', '', $name); + if ($value === true) { + $attributeList[] = $name; + } else { + $attributeList[] = $name . '="' . htmlspecialchars($value, ENT_QUOTES | ENT_HTML5) . '"'; + } + } + return implode(' ', $attributeList); + } + + /** + * HTML5 deprecated the "frameborder" attribute as everything should be done via styling. + * + * @todo: This renderer has a dependency to Request / TypoScript. Model this explicitly. + */ + protected function shouldIncludeFrameBorderAttribute(): bool + { + return DocType::createFromRequest($GLOBALS['TYPO3_REQUEST'] ?? null)->shouldIncludeFrameBorderAttribute(); + } +} diff --git a/Classes/Resource/ResourceFactory.php b/Classes/Resource/ResourceFactory.php new file mode 100644 index 0000000..60e5b96 --- /dev/null +++ b/Classes/Resource/ResourceFactory.php @@ -0,0 +1,443 @@ +<?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\Core\Resource; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Collection\AbstractRecordCollection; +use TYPO3\CMS\Core\Collection\CollectionInterface; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Resource\Collection\FileCollectionRegistry; +use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException; +use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; +use TYPO3\CMS\Core\Resource\Index\FileIndexRepository; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceException; +use TYPO3\CMS\Core\SystemResource\Publishing\DefaultSystemResourcePublisher; +use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Factory class for FAL objects + */ +readonly class ResourceFactory implements SingletonInterface +{ + public function __construct( + protected StorageRepository $storageRepository, + #[Autowire(service: 'cache.runtime')] + protected FrontendInterface $runtimeCache, + private FileIndexRepository $fileIndexRepository, + ) {} + + /** + * Creates an instance of the collection from given UID. The $recordData can be supplied to increase performance. + * + * @param int $uid The uid of the collection to instantiate. + * @param array $recordData The record row from database. + * + * @throws \InvalidArgumentException + */ + public function getCollectionObject(int $uid, array $recordData = []): CollectionInterface + { + $collectionObject = $this->collectionCacheGet($uid); + if ($collectionObject === null) { + // Get mount data if not already supplied as argument to this function + if (empty($recordData) || $recordData['uid'] !== $uid) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_collection'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $recordData = $queryBuilder->select('*') + ->from('sys_file_collection') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + if (empty($recordData)) { + throw new \InvalidArgumentException('No collection found for given UID: "' . $uid . '"', 1314085992); + } + } + $collectionObject = $this->createCollectionObject($recordData); + $this->collectionCacheSet($uid, $collectionObject); + } + return $collectionObject; + } + + /** + * Creates a collection object. + * + * @param array $collectionData The database row of the sys_file_collection record. + * @return CollectionInterface<File> + */ + public function createCollectionObject(array $collectionData): CollectionInterface + { + $registry = GeneralUtility::makeInstance(FileCollectionRegistry::class); + + /** @var AbstractRecordCollection $class */ + $class = $registry->getFileCollectionClass($collectionData['type']); + + return $class::create($collectionData); + } + + /** + * Creates an instance of the file given UID. The $fileData can be supplied + * to increase performance. + * + * @param int|string $uid The uid of the file to instantiate. (string is used for the time being as compat-mode) + * @param array $fileData The record row from database. + */ + public function getFileObject(int|string $uid, array $fileData = []): File + { + $uid = (int)$uid; + $fileObject = $this->fileCacheGet($uid); + if ($fileObject === null) { + // Fetches data in case $fileData is empty + if (empty($fileData)) { + $fileData = $this->fileIndexRepository->findOneByUid($uid); + if ($fileData === false) { + throw new FileDoesNotExistException('No file found for given UID: ' . $uid, 1317178604); + } + } + $fileObject = $this->createFileObject($fileData); + $this->fileCacheSet($fileObject); + } + return $fileObject; + } + + /** + * Gets a file object from an identifier [storage]:[fileId] + * + * @throws \InvalidArgumentException + */ + public function getFileObjectFromCombinedIdentifier(string $identifier): File|ProcessedFile|null + { + if ($identifier === '') { + throw new \InvalidArgumentException('Invalid file identifier given. It must be not empty.', 1401732564); + } + $parts = GeneralUtility::trimExplode(':', $identifier); + if (count($parts) === 2) { + $storageUid = (int)$parts[0]; + $fileIdentifier = $parts[1]; + } else { + // We only got a path: Go into backwards compatibility mode and + // use virtual Storage (uid=0) + $storageUid = 0; + $fileIdentifier = $parts[0]; + } + return $this->storageRepository->getStorageObject($storageUid, [], $fileIdentifier) + ->getFileByIdentifier($fileIdentifier); + } + + /** + * Bulk function, can be used for anything to get a file or folder + * + * 1. It's a UID + * 2. It's a combined identifier + * 3. It's just a path/filename (coming from the oldstyle/backwards compatibility) + * + * Files, previously laid on fileadmin/ or something, will be "mapped" to the storage the file is + * in now. Files like typo3temp/ or typo3conf/ will be moved to the first writable storage + * in its processing folder + * + * $input could be + * - "2:myfolder/myfile.jpg" (combined identifier) + * - "23" (file UID) + * - "uploads/myfile.png" (backwards-compatibility, storage "0") + * - "file:23" + */ + public function retrieveFileOrFolderObject(string|int $input): ProcessedFile|File|Folder|null + { + // Remove Environment::getPublicPath() because absolute paths under Windows systems contain ':' + // This is done in all considered sub functions anyway + $input = str_replace(Environment::getPublicPath() . '/', '', (string)$input); + + if (str_starts_with($input, 'file:')) { + $input = substr($input, 5); + return $this->retrieveFileOrFolderObject($input); + } + if (MathUtility::canBeInterpretedAsInteger($input)) { + return $this->getFileObject((int)$input); + } + if (strpos($input, ':') > 0) { + [$prefix] = explode(':', $input); + if (MathUtility::canBeInterpretedAsInteger($prefix)) { + // path or folder in a valid storageUID + return $this->getObjectFromCombinedIdentifier($input); + } + if ($prefix === 'EXT') { + try { + // @todo: We make an "URL" relative to public dir because the fallback storage root + // is the public dir and in this case file identifier === url + // this will be resolved once fallback storage is deprecated + // This should be done asap, because other implementations of SystemResourcePublisherInterface + // might not evaluate the uriPrefix options + $potentialPathRelativeToPublicDir = (string)$this->getSystemResourceUri($input, null, new UriGenerationOptions(uriPrefix: '/', cacheBusting: false)); + if (!file_exists(Environment::getPublicPath() . $potentialPathRelativeToPublicDir)) { + throw new ResourceDoesNotExistException(sprintf('File "%s" does not exist in fallback compatibility storage.', $input), 1760532790); + } + return $this->getFileObjectFromCombinedIdentifier($potentialPathRelativeToPublicDir); + } catch (SystemResourceException $e) { + throw new ResourceDoesNotExistException(sprintf('Tried to access a private resource file "%s" from fallback compatibility storage. This storage only handles public files.', $input), 1633777536, $e); + } + } + return null; + } + // this is a backwards-compatible way to access "0-storage" files or folders + // @todo: this needs to be removed once we remove support for fallback storage + // eliminate double slashes, /./ and /../ + $input = PathUtility::getCanonicalPath(ltrim($input, '/')); + if (@is_file(Environment::getPublicPath() . '/' . $input)) { + // only the local file + return $this->getFileObjectFromCombinedIdentifier($input); + } + if (@is_dir(Environment::getPublicPath() . '/' . ltrim($input, '/'))) { + // only the local path + return $this->getFolderObjectFromCombinedIdentifier(ltrim($input, '/')); + } + return null; + } + + private function getSystemResourceUri(string $resourceIdentifier, ?ServerRequestInterface $request = null, ?UriGenerationOptions $options = null): UriInterface + { + $resourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class); + $resource = $resourceFactory->createPublicResource($resourceIdentifier); + $resourcePublisher = GeneralUtility::makeInstance(DefaultSystemResourcePublisher::class); + return $resourcePublisher->generateUri($resource, $request, $options); + } + + /** + * Gets a folder object from an identifier [storage]:[fileId] + */ + public function getFolderObjectFromCombinedIdentifier(string|int $identifier): Folder + { + $parts = GeneralUtility::trimExplode(':', (string)$identifier); + if (count($parts) === 2) { + $storageUid = (int)$parts[0]; + $folderIdentifier = $parts[1]; + } else { + // We only got a path: Go into backwards compatibility mode and + // use virtual Storage (uid=0) + $storageUid = 0; + + // please note that getStorageObject() might modify $folderIdentifier when + // auto-detecting the best-matching storage to use + $folderIdentifier = $parts[0]; + // make sure to not use an absolute path, and remove Environment::getPublicPath if it is prepended + if (str_starts_with($folderIdentifier, Environment::getPublicPath() . '/')) { + $folderIdentifier = PathUtility::stripPathSitePrefix($parts[0]); + } + } + return $this->storageRepository->getStorageObject($storageUid, [], $folderIdentifier)->getFolder($folderIdentifier); + } + + /** + * Gets a file or folder object. + * + * @throws Exception\ResourceDoesNotExistException + */ + public function getObjectFromCombinedIdentifier(string $identifier): FileInterface|Folder + { + [$storageId, $objectIdentifier] = array_pad(GeneralUtility::trimExplode(':', $identifier), 2, null); + if (!MathUtility::canBeInterpretedAsInteger($storageId) && $objectIdentifier === null) { + $objectIdentifier = $storageId; + $storageId = 0; + } + if (MathUtility::canBeInterpretedAsInteger($storageId)) { + $storage = $this->storageRepository->findByUid($storageId); + if ($storage === null) { + throw new ResourceDoesNotExistException('Storage ' . $storageId . ' does not exist', 1762852423); + } + if ($storage->hasFile($objectIdentifier)) { + return $storage->getFile($objectIdentifier); + } + if ($storage->hasFolder($objectIdentifier)) { + return $storage->getFolder($objectIdentifier); + } + } + throw new ResourceDoesNotExistException('Object with identifier "' . $identifier . '" does not exist in storage', 1329647780); + } + + /** + * Creates a file object from an array of file data. Requires a database + * row to be fetched. + */ + public function createFileObject(array $fileData, ?ResourceStorage $storage = null): File + { + if (array_key_exists('storage', $fileData) && MathUtility::canBeInterpretedAsInteger($fileData['storage'])) { + $storageObject = $this->storageRepository->findByUid((int)$fileData['storage']); + } else { + $storageObject = $storage; + } + + // Ensure a storage could be fetched to create the file. + if ($storageObject === null) { + throw new \RuntimeException('A file needs to reside in a Storage', 1381570997); + } + + $fileData['storage'] = $storageObject->getUid(); + + return GeneralUtility::makeInstance(File::class, $fileData, $storageObject); + } + + /** + * Creates an instance of a FileReference object. The $fileReferenceData can + * be supplied to increase performance. + * + * @param int|string $uid The uid of the file usage (sys_file_reference) to instantiate. string is kept for backwards-compat + * @param array $fileReferenceData The record row from database. + * @param bool $raw Whether to get raw results without performing overlays + * @throws Exception\ResourceDoesNotExistException + */ + public function getFileReferenceObject(int|string $uid, array $fileReferenceData = [], bool $raw = false): FileReference + { + $uid = (int)$uid; + $fileReference = $this->fileReferenceCacheGet($uid); + if ($fileReference === null) { + // Fetches data in case $fileData is empty + if (empty($fileReferenceData)) { + $fileReferenceData = $this->getFileReferenceData($uid, $raw); + if (!is_array($fileReferenceData)) { + throw new ResourceDoesNotExistException( + 'No file reference (sys_file_reference) was found for given UID: "' . $uid . '"', + 1317178794 + ); + } + } + $fileReference = $this->createFileReferenceObject($fileReferenceData); + $this->fileReferenceCacheSet($fileReference); + } + return $fileReference; + } + + /** + * Creates a file usage object from an array of fileReference data + * from sys_file_reference table. + * Requires a database row to be already fetched and present. + */ + public function createFileReferenceObject(array $fileReferenceData): FileReference + { + return GeneralUtility::makeInstance(FileReference::class, $fileReferenceData); + } + + /** + * Gets data for the given uid of the file reference record. + * + * @param int $uid The uid of the file usage (sys_file_reference) to be fetched + * @param bool $raw Whether to get raw results without performing overlays + */ + protected function getFileReferenceData(int $uid, bool $raw = false): array|false|null + { + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + if (!$raw + && $request instanceof ServerRequestInterface + && ApplicationType::fromRequest($request)->isBackend() + ) { + $fileReferenceData = BackendUtility::getRecordWSOL('sys_file_reference', $uid); + } elseif (!$raw + && $request instanceof ServerRequestInterface + && ApplicationType::fromRequest($request)->isFrontend() + ) { + $fileReferenceData = GeneralUtility::makeInstance(PageRepository::class)->checkRecord('sys_file_reference', $uid); + } else { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $fileReferenceData = $queryBuilder->select('*') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + } + return $fileReferenceData; + } + + protected function collectionCacheIdentifier(int $uid): string + { + return sprintf('resourcefactory-collection-%s', $uid); + } + + /** + * @return CollectionInterface<File>|null + */ + protected function collectionCacheGet(int $uid): ?CollectionInterface + { + $entry = $this->runtimeCache->get($this->collectionCacheIdentifier($uid)); + if ($entry instanceof CollectionInterface) { + return $entry; + } + return null; + } + + /** + * @param CollectionInterface<File> $collection + */ + protected function collectionCacheSet(int $uid, CollectionInterface $collection): void + { + $this->runtimeCache->set($this->collectionCacheIdentifier($uid), $collection); + } + + protected function fileCacheIdentifier(int $uid): string + { + return sprintf('resourcestorage-file-%s', $uid); + } + + protected function fileCacheGet(int $uid): ?File + { + $entry = $this->runtimeCache->get($this->fileCacheIdentifier($uid)); + if ($entry instanceof File) { + return $entry; + } + return null; + } + + protected function fileCacheSet(File $file): void + { + $this->runtimeCache->set($this->fileCacheIdentifier($file->getUid()), $file); + } + + protected function fileReferenceCacheIdentifier(int $uid): string + { + return sprintf('resourcestorage-filereference-%s', $uid); + } + + protected function fileReferenceCacheGet(int $uid): ?FileReference + { + $entry = $this->runtimeCache->get($this->fileReferenceCacheIdentifier($uid)); + return ($entry instanceof FileReference) ? $entry : null; + } + + protected function fileReferenceCacheSet(FileReference $fileReference): void + { + $this->runtimeCache->set($this->fileReferenceCacheIdentifier($fileReference->getUid()), $fileReference); + } +} diff --git a/Classes/Resource/ResourceInstructionTrait.php b/Classes/Resource/ResourceInstructionTrait.php new file mode 100644 index 0000000..dd4a0fc --- /dev/null +++ b/Classes/Resource/ResourceInstructionTrait.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use Psr\Http\Message\UploadedFileInterface; +use TYPO3\CMS\Core\Resource\Service\ResourceConsistencyService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Trait for creating skip-instructions for `ResourceConsistencyService::validate()`. + */ +trait ResourceInstructionTrait +{ + /** + * Registers an instruction to skip validation in `ResourceConsistencyService` for a specific uploaded file. + */ + private function skipResourceConsistencyCheckForUploads( + ResourceStorage $storage, + array|UploadedFileInterface $uploadedFile, + ?string $targetFileName = null, + ): void { + GeneralUtility::makeInstance(ResourceConsistencyService::class)->addExceptionItem( + $storage, + $storage->getUploadedLocalFilePath($uploadedFile), + $storage->getUploadedTargetFileName($uploadedFile, $targetFileName), + ); + } + + /** + * Registers an instruction to skip validation in `ResourceConsistencyService` + * for commands (such as rename or replace) for existing files. + */ + private function skipResourceConsistencyCheckForCommands( + ResourceStorage $storage, + string|FileInterface $resource, + ?string $targetFileName = null, + ): void { + $targetFileName ??= PathUtility::basename( + $resource instanceof FileInterface ? $resource->getName() : $resource + ); + GeneralUtility::makeInstance(ResourceConsistencyService::class)->addExceptionItem( + $storage, + $resource, + $targetFileName + ); + } +} diff --git a/Classes/Resource/ResourceInterface.php b/Classes/Resource/ResourceInterface.php new file mode 100644 index 0000000..83a3654 --- /dev/null +++ b/Classes/Resource/ResourceInterface.php @@ -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\Core\Resource; + +interface ResourceInterface +{ + public function getIdentifier(): string; + + public function getName(): string; + + public function getStorage(): ResourceStorage; + + /** + * @return non-empty-string + */ + public function getHashedIdentifier(): string; + + public function getParentFolder(): FolderInterface; +} diff --git a/Classes/Resource/ResourceStorage.php b/Classes/Resource/ResourceStorage.php new file mode 100644 index 0000000..9950d4e --- /dev/null +++ b/Classes/Resource/ResourceStorage.php @@ -0,0 +1,2881 @@ +<?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\Core\Resource; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UploadedFileInterface; +use TYPO3\CMS\Core\Cache\CacheTag; +use TYPO3\CMS\Core\Cache\Event\AddCacheTagEvent; +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Crypto\HashAlgo; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Http\FalDumpFileContentsDecoratorStream; +use TYPO3\CMS\Core\Http\Response; +use TYPO3\CMS\Core\Http\UploadedFile; +use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Driver\StreamableDriverInterface; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Resource\Event\AfterFileAddedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileContentsSetEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileCopiedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileCreatedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileMovedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFileReplacedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFolderAddedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFolderCopiedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFolderDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFolderMovedEvent; +use TYPO3\CMS\Core\Resource\Event\AfterFolderRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileAddedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileContentsSetEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileCopiedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileCreatedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileMovedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileReplacedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderAddedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderCopiedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderDeletedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderMovedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFolderRenamedEvent; +use TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent; +use TYPO3\CMS\Core\Resource\Event\SanitizeFileNameEvent; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFileNameException; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFolderException; +use TYPO3\CMS\Core\Resource\Exception\FileOperationErrorException; +use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; +use TYPO3\CMS\Core\Resource\Exception\IllegalFileExtensionException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileReadPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileWritePermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderWritePermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientUserPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InvalidConfigurationException; +use TYPO3\CMS\Core\Resource\Exception\InvalidHashException; +use TYPO3\CMS\Core\Resource\Exception\InvalidTargetFolderException; +use TYPO3\CMS\Core\Resource\Exception\ResourcePermissionsUnavailableException; +use TYPO3\CMS\Core\Resource\Exception\UploadException; +use TYPO3\CMS\Core\Resource\Exception\UploadSizeException; +use TYPO3\CMS\Core\Resource\Filter\ImportExportFilter; +use TYPO3\CMS\Core\Resource\Index\FileIndexRepository; +use TYPO3\CMS\Core\Resource\Index\Indexer; +use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry; +use TYPO3\CMS\Core\Resource\Search\FileSearchDemand; +use TYPO3\CMS\Core\Resource\Search\Result\DriverFilteredSearchResult; +use TYPO3\CMS\Core\Resource\Search\Result\EmptyFileSearchResult; +use TYPO3\CMS\Core\Resource\Search\Result\FileSearchResult; +use TYPO3\CMS\Core\Resource\Search\Result\FileSearchResultInterface; +use TYPO3\CMS\Core\Resource\Security\FileNameValidator; +use TYPO3\CMS\Core\Resource\Service\FileProcessingService; +use TYPO3\CMS\Core\Resource\Service\ResourceConsistencyService; +use TYPO3\CMS\Core\Utility\Exception\NotImplementedMethodException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; +use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\CMS\Core\Validation\ResultException; + +/** + * A "mount point" inside the TYPO3 file handling. + * + * A "storage" object handles + * - abstraction to the driver + * - permissions (from the driver, and from the user, + capabilities) + * - an entry point for files, folders, and for most other operations + * + * == Driver entry point + * The driver itself, that does the actual work on the file system, + * is inside the storage but completely shadowed by + * the storage, as the storage also handles the abstraction to the + * driver + * + * The storage can be on the local system, but can also be on a remote + * system. The combination of driver + configurable capabilities (storage + * is read-only e.g.) allows for flexible uses. + * + * + * == Permission system + * As all requests have to run through the storage, the storage knows about the + * permissions of a BE/FE user, the file permissions / limitations of the driver + * and has some configurable capabilities. + * Additionally, a BE user can use "file mounts" (known from previous installations) + * to limit his/her work-zone to only a subset (identifier and its subfolders/subfolders) + * of the user itself. + * + * Check 1: "User Permissions" [is the user allowed to write a file) [is the user allowed to write a file] + * Check 2: "File Mounts" of the User (act as subsets / filters to the identifiers) [is the user allowed to do something in this folder?] + * Check 3: "Capabilities" of Storage (then: of Driver) [is the storage/driver writable?] + * Check 4: "File permissions" of the Driver [is the folder writable?] + */ +class ResourceStorage implements ResourceStorageInterface +{ + /** + * Levels numbers used to generate hashed subfolders in the processing folder + */ + public const PROCESSING_FOLDER_LEVELS = 2; + /** + * The configuration belonging to this storage (decoded from the configuration field). + */ + protected array $configuration; + + protected ?FileProcessingService $fileProcessingService = null; + + /** + * Whether to check if file or folder is in user mounts + * and the action is allowed for a user + * Default is FALSE so that resources are accessible for + * front end rendering or admins. + */ + protected bool $evaluatePermissions = false; + + /** + * User file mounts, added as an array, and used as filters + */ + protected array $fileMounts = []; + + /** + * The file permissions of the user (and their group) merged together and + * available as an array + */ + protected array $userPermissions = []; + + /** + * The capabilities of this storage as defined in the storage record. + */ + protected Capabilities $capabilities; + + protected EventDispatcherInterface $eventDispatcher; + protected ?Folder $processingFolder = null; + + /** + * All processing folders of this storage used in any storage + * + * @var Folder[]|null + */ + protected ?array $processingFolders = null; + + /** + * whether this storage is online or offline in this request + */ + protected ?bool $isOnline = null; + + protected bool $isDefault = false; + + /** + * The filters used for the files and folder names. + */ + protected array $fileAndFolderNameFilters = []; + + /** + * Constructor for a storage object. + * + * @param array $storageRecord The storage record row from the database + */ + public function __construct( + protected DriverInterface $driver, + /** + * The database record for this storage + */ + protected array $storageRecord, + ?EventDispatcherInterface $eventDispatcher = null + ) { + if (!isset($this->storageRecord['uid'])) { + throw new \InvalidArgumentException( + '$storageRecord[\'uid\'] is unexpectedly not set', + 1688920972 + ); + } + + $this->eventDispatcher = $eventDispatcher ?? GeneralUtility::makeInstance(EventDispatcherInterface::class); + if (is_array($this->storageRecord['configuration'] ?? null)) { + $this->configuration = $this->storageRecord['configuration']; + } elseif (!empty($this->storageRecord['configuration'] ?? '')) { + $this->configuration = GeneralUtility::makeInstance(FlexFormTools::class)->convertFlexFormContentToArray($this->storageRecord['configuration']); + } else { + $this->configuration = []; + } + + $capabilityBits = 0; + $capabilityBits += ($this->storageRecord['is_browsable'] ?? null ? Capabilities::CAPABILITY_BROWSABLE : 0); + $capabilityBits += ($this->storageRecord['is_public'] ?? null ? Capabilities::CAPABILITY_PUBLIC : 0); + $capabilityBits += ($this->storageRecord['is_writable'] ?? null ? Capabilities::CAPABILITY_WRITABLE : 0); + // Always let the driver decide whether to set this capability + $capabilityBits += Capabilities::CAPABILITY_HIERARCHICAL_IDENTIFIERS; + + $this->capabilities = new Capabilities($capabilityBits); + + $this->driver->setStorageUid((int)$this->storageRecord['uid']); + $this->driver->mergeConfigurationCapabilities($this->capabilities); + try { + $this->driver->processConfiguration(); + } catch (InvalidConfigurationException $e) { + // Configuration error + $this->isOnline = false; + + $message = sprintf( + 'Failed initializing storage [%d] "%s", error: %s', + $this->getUid(), + $this->getName(), + $e->getMessage() + ); + + // create a dedicated logger instance because we need a logger in the constructor + GeneralUtility::makeInstance(LogManager::class)->getLogger(static::class)->error($message); + } + $this->driver->initialize(); + $this->capabilities = $this->driver->getCapabilities(); + + $this->isDefault = (isset($this->storageRecord['is_default']) && $this->storageRecord['is_default'] == 1); + $this->resetFileAndFolderNameFiltersToDefault(); + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } + + public function getStorageRecord(): array + { + return $this->storageRecord; + } + + /** + * Sets the storage that belongs to this storage. + * + * @return $this + */ + public function setDriver(DriverInterface $driver): self + { + $this->driver = $driver; + return $this; + } + + /** + * Returns the driver object belonging to this storage. + * Note: This is a non-public method on purpose, because the outside world should never know if this is + * a local or remote storage. + */ + protected function getDriver(): DriverInterface + { + return $this->driver; + } + + /** + * Returns the name of this storage. + */ + public function getName(): string + { + return $this->storageRecord['name']; + } + + /** + * Returns the UID of this storage. + */ + public function getUid(): int + { + return (int)($this->storageRecord['uid'] ?? 0); + } + + /** + * Tells whether there are children in this storage. + */ + public function hasChildren(): bool + { + return true; + } + + /** + * Returns true if this storage is a virtual storage that provides + * access to all files in the project root. + * + * @internal + */ + public function isFallbackStorage(): bool + { + return $this->getUid() === 0; + } + + /********************************* + * Capabilities + ********************************/ + /** + * Returns the capabilities of this storage. + */ + public function getCapabilities(): Capabilities + { + return $this->capabilities; + } + + /** + * Returns TRUE if this storage has the given capability. + * + * @param Capabilities::CAPABILITY_* $capability + */ + protected function hasCapability(int $capability): bool + { + return $this->capabilities->hasCapability($capability); + } + + /** + * Returns TRUE if this storage is publicly available. This is just a + * configuration option and does not mean that it really *is* public. OTOH + * a storage that is marked as not publicly available will trigger the file + * publishing mechanisms of TYPO3. + */ + public function isPublic(): bool + { + return $this->hasCapability(Capabilities::CAPABILITY_PUBLIC); + } + + /** + * Returns TRUE if this storage is writable. This is determined by the + * driver and the storage configuration; user permissions are not taken into account. + */ + public function isWritable(): bool + { + return $this->hasCapability(Capabilities::CAPABILITY_WRITABLE); + } + + /** + * Returns TRUE if this storage is browsable by a (backend) user of TYPO3. + */ + public function isBrowsable(): bool + { + return $this->isOnline() && $this->hasCapability(Capabilities::CAPABILITY_BROWSABLE); + } + + /** + * Returns TRUE if this storage stores folder structure in file identifiers. + */ + public function hasHierarchicalIdentifiers(): bool + { + return $this->hasCapability(Capabilities::CAPABILITY_HIERARCHICAL_IDENTIFIERS); + } + + /** + * Search for files in a storage based on given restrictions + * and a possibly given folder. + * + * @param bool $useFilters Whether storage filters should be applied + */ + public function searchFiles(FileSearchDemand $searchDemand, ?Folder $folder = null, bool $useFilters = true): FileSearchResultInterface + { + $folder = $folder ?? $this->getRootLevelFolder(); + if (!$folder->checkActionPermission('read')) { + return new EmptyFileSearchResult(); + } + + return new DriverFilteredSearchResult( + new FileSearchResult( + $searchDemand->withFolder($folder) + ), + $this->driver, + $useFilters ? $this->getFileAndFolderNameFilters() : [] + ); + } + + /** + * Returns TRUE if the identifiers used by this storage are case-sensitive. + */ + public function usesCaseSensitiveIdentifiers(): bool + { + return $this->driver->isCaseSensitiveFileSystem(); + } + + /** + * Returns TRUE if this storage is browsable by a (backend) user of TYPO3. + */ + public function isOnline(): bool + { + if ($this->isOnline === null) { + if ($this->getUid() === 0) { + $this->isOnline = true; + } + // the storage is not marked as online for a longer time + if ($this->storageRecord['is_online'] == 0) { + $this->isOnline = false; + } + if ($this->isOnline !== false) { + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + // All files are ALWAYS available in the frontend + $this->isOnline = true; + } else { + // check if the storage is disabled temporary for now + $registryObject = GeneralUtility::makeInstance(Registry::class); + $offlineUntil = $registryObject->get('core', 'sys_file_storage-' . $this->getUid() . '-offline-until'); + if ($offlineUntil && $offlineUntil > time()) { + $this->isOnline = false; + } else { + $this->isOnline = true; + } + } + } + } + return $this->isOnline; + } + + /** + * Returns TRUE if auto extracting of metadata is enabled + */ + public function autoExtractMetadataEnabled(): bool + { + return !empty($this->storageRecord['auto_extract_metadata']); + } + + /** + * Blows the "fuse" and marks the storage as offline. + * + * Can only be modified by an admin. + * + * Typically, this is only done if the configuration is wrong. + */ + public function markAsPermanentlyOffline(): void + { + if ($this->getUid() > 0) { + // @todo: move this to the storage repository + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_file_storage') + ->update( + 'sys_file_storage', + ['is_online' => 0], + ['uid' => (int)$this->getUid()] + ); + } + $this->storageRecord['is_online'] = 0; + $this->isOnline = false; + } + + /** + * Marks this storage as offline for the next 5 minutes. + * + * Non-permanent: This typically happens for remote storages + * that are "flaky" and not available all the time. + */ + public function markAsTemporaryOffline(): void + { + $registryObject = GeneralUtility::makeInstance(Registry::class); + $registryObject->set('core', 'sys_file_storage-' . $this->getUid() . '-offline-until', time() + 60 * 5); + $this->storageRecord['is_online'] = 0; + $this->isOnline = false; + } + + /********************************* + * User Permissions / File Mounts + ********************************/ + /** + * Adds a file mount as a "filter" for users to only work on a subset of a + * storage object + * + * @throws Exception\FolderDoesNotExistException + */ + public function addFileMount(string $folderIdentifier, array $additionalData = []): void + { + // check for the folder before we add it as a file mount + if ($this->driver->folderExists($folderIdentifier) === false) { + // if there is an error, this is important and should be handled + // as otherwise the user would see the whole storage without any restrictions for the file mounts + throw new FolderDoesNotExistException('Folder for file mount ' . $folderIdentifier . ' does not exist.', 1334427099); + } + $data = $this->driver->getFolderInfoByIdentifier($folderIdentifier); + $folderObject = $this->createFolderObject($data['identifier'], $data['name']); + // Use the canonical identifier instead of the user provided one! + $folderIdentifier = $folderObject->getIdentifier(); + if ( + !empty($this->fileMounts[$folderIdentifier]) + && empty($this->fileMounts[$folderIdentifier]['read_only']) + && !empty($additionalData['read_only']) + ) { + // Do not overwrite a regular mount with a read only mount + return; + } + if (empty($additionalData)) { + $additionalData = [ + 'path' => $folderIdentifier, + 'title' => $folderIdentifier, + 'folder' => $folderObject, + ]; + } else { + $additionalData['folder'] = $folderObject; + if (!isset($additionalData['title'])) { + $additionalData['title'] = $folderIdentifier; + } + } + $this->fileMounts[$folderIdentifier] = $additionalData; + } + + /** + * Returns all file mounts that are registered with this storage. + */ + public function getFileMounts(): array + { + return $this->fileMounts; + } + + public function isFileMountFolder(Folder $folder): bool + { + foreach ($this->fileMounts as $mount) { + $rootLevelFolder = $mount['folder'] ?? null; + if ($rootLevelFolder instanceof Folder && $rootLevelFolder->getCombinedIdentifier() === $folder->getCombinedIdentifier()) { + return true; + } + } + + return false; + } + + /** + * Checks if the given subject is within one of the registered user + * file mounts. If not, working with the file is not permitted for the user. + * + * @param ResourceInterface $subject file or folder + * @param bool $checkWriteAccess If true, it is not only checked if the subject is within the file mount but also whether it isn't a read only file mount + */ + public function isWithinFileMountBoundaries(ResourceInterface $subject, bool $checkWriteAccess = false): bool + { + if (!$this->evaluatePermissions) { + return true; + } + $isWithinFileMount = false; + $identifier = $subject->getIdentifier(); + + // Allow access to processing folder + if ($this->isWithinProcessingFolder($identifier)) { + $isWithinFileMount = true; + } else { + // Check if the identifier of the subject is within at + // least one of the file mounts + $writableFileMountAvailable = false; + foreach ($this->fileMounts as $fileMount) { + /** @var Folder $folder */ + $folder = $fileMount['folder']; + if ($this->driver->isWithin($folder->getIdentifier(), $identifier)) { + $isWithinFileMount = true; + if (!$checkWriteAccess) { + break; + } + if (empty($fileMount['read_only'])) { + $writableFileMountAvailable = true; + break; + } + } + } + $isWithinFileMount = $checkWriteAccess ? $writableFileMountAvailable : $isWithinFileMount; + } + return $isWithinFileMount; + } + + /** + * Sets whether the permissions to access or write + * into this storage should be checked or not. + */ + public function setEvaluatePermissions(bool $evaluatePermissions): void + { + $this->evaluatePermissions = $evaluatePermissions; + } + + /** + * Gets whether the permissions to access or write + * into this storage should be checked or not. + */ + public function getEvaluatePermissions(): bool + { + return $this->evaluatePermissions; + } + + /** + * Sets the user permissions of the storage. + */ + public function setUserPermissions(array $userPermissions): void + { + $this->userPermissions = $userPermissions; + } + + /** + * Checks if the ACL settings allow for a certain action + * (is a user allowed to read a file or copy a folder). + * + * @param string $action (e.g. "read" or "write") + * @param string $type either File or Folder + */ + public function checkUserActionPermission(string $action, string $type): bool + { + if (!$this->evaluatePermissions) { + return true; + } + + $allow = false; + if (!empty($this->userPermissions[strtolower($action) . ucfirst(strtolower($type))])) { + $allow = true; + } + + return $allow; + } + + /** + * Checks if a file operation (= action) is allowed on a File/Folder/Storage (= subject). + * + * This method, by design, does not throw exceptions or do logging. + * Besides the usage from other methods in this class, it is also used by + * the Media module UI to check whether an action is allowed and whether action + * related UI elements should thus be shown (move icon, edit icon, etc.) + * + * @param string $action action, can be read, write, delete, editMeta + */ + public function checkFileActionPermission(string $action, FileInterface $file): bool + { + $isProcessedFile = $file instanceof ProcessedFile; + // Check 1: Allow editing meta data of a file if it is in mount boundaries of a writable file mount + if ($action === 'editMeta') { + return !$isProcessedFile && $this->isWithinFileMountBoundaries($file, true); + } + // Check 2: Does the user have permission to perform the action? e.g. "readFile" + if (!$isProcessedFile && $this->checkUserActionPermission($action, 'File') === false) { + return false; + } + // Check 3: No action allowed on files for denied file extensions + if (!$this->checkValidFileExtension($file)) { + return false; + } + $isReadCheck = false; + if (in_array($action, ['read', 'copy', 'move', 'replace'], true)) { + $isReadCheck = true; + } + $isWriteCheck = false; + if (in_array($action, ['add', 'write', 'move', 'rename', 'replace', 'delete'], true)) { + $isWriteCheck = true; + } + // Check 4: Does the user have the right to perform the action? + // (= is he within the file mount borders) + if (!$isProcessedFile && !$this->isWithinFileMountBoundaries($file, $isWriteCheck)) { + return false; + } + + $isMissing = false; + if (!$isProcessedFile && $file instanceof File) { + $isMissing = $file->isMissing(); + } + + if ($this->driver->fileExists($file->getIdentifier()) === false && $file instanceof File) { + $file->setMissing(true); + $isMissing = true; + } + + // Check 5: Check the capabilities of the storage (and the driver) + if ($isWriteCheck && ($isMissing || !$this->isWritable())) { + return false; + } + + // Check 6: "File permissions" of the driver (only when file isn't marked as missing) + if (!$isMissing) { + $filePermissions = $this->driver->getPermissions($file->getIdentifier()); + if ($isReadCheck && !$filePermissions['r']) { + return false; + } + if ($isWriteCheck && !$filePermissions['w']) { + return false; + } + } + return true; + } + + /** + * Checks if a folder operation (= action) is allowed on a Folder. + * + * This method, by design, does not throw exceptions or does logging. + * See the checkFileActionPermission() method above for the reasons. + */ + public function checkFolderActionPermission(string $action, ?FolderInterface $folder = null): bool + { + // Check 1: Does the user have permission to perform the action? e.g. "writeFolder" + if ($this->checkUserActionPermission($action, 'Folder') === false) { + return false; + } + + // If we do not have a folder here, we cannot do further checks + if ($folder === null) { + return true; + } + + $isReadCheck = false; + if (in_array($action, ['read', 'copy'], true)) { + $isReadCheck = true; + } + $isWriteCheck = false; + if (in_array($action, ['add', 'move', 'write', 'delete', 'rename'], true)) { + $isWriteCheck = true; + } + // Check 2: Does the user has the right to perform the action? + // (= is he within the file mount borders) + if (!$this->isWithinFileMountBoundaries($folder, $isWriteCheck)) { + return false; + } + // Check 3: Check the capabilities of the storage (and the driver) + if ($isReadCheck && !$this->isBrowsable()) { + return false; + } + if ($isWriteCheck && !$this->isWritable()) { + return false; + } + + // Check 4: "Folder permissions" of the driver + $folderPermissions = $this->driver->getPermissions($folder->getIdentifier()); + if ($isReadCheck && !$folderPermissions['r']) { + return false; + } + if ($isWriteCheck && !$folderPermissions['w']) { + return false; + } + + // Check 5: File mount check + if (!$this->isAllowedActionOnMountFolder($action, $folder)) { + return false; + } + + return true; + } + + protected function isAllowedActionOnMountFolder(string $action, FolderInterface $folder): bool + { + $deniedMountActions = ['move', 'delete', 'rename']; + + // Early return if the given folder is not a mount folder + if (!$folder instanceof Folder || !$this->isFileMountFolder($folder)) { + return true; + } + + return !in_array($action, $deniedMountActions, true); + } + + /** + * If the fileName is given, checks it against the + * TYPO3_CONF_VARS[BE][fileDenyPattern] + and if the file extension is allowed. + * + * @param string $fileName full filename + * @return bool TRUE if extension/filename is allowed + */ + protected function checkFileExtensionPermission(string $fileName): bool + { + $fileName = $this->driver->sanitizeFileName($fileName); + return GeneralUtility::makeInstance(FileNameValidator::class)->isValid($fileName); + } + + /** + * Check file extension of an existing file against the + * current file deny pattern. + */ + protected function checkValidFileExtension(FileInterface $file): bool + { + $fileNameValidator = GeneralUtility::makeInstance(FileNameValidator::class); + return $fileNameValidator->isValid($file->getName()) + && $fileNameValidator->isValid(basename($file->getIdentifier())); + } + + /** + * @throws \InvalidArgumentException + */ + protected function assertUploadedFileType(array|UploadedFileInterface $uploadedFileData): void + { + if ($uploadedFileData instanceof UploadedFileInterface && !$uploadedFileData instanceof UploadedFile) { + // This throws if $uploadedFileData is UploadedFileInterface, but is not the TYPO3 + // core implementation UploadedFile. It should be fair to throw here for now since + // getTemporaryFileName() is not part of PSR-7 UploadedFileInterface, but it + // could be eventually refactored away or streamlined? + throw new \InvalidArgumentException( + 'Uploaded file with streams are not supported yet', + 1736765655 + ); + } + } + + /** + * Assures read permission for given folder. + * + * @param FolderInterface|null $folder If a folder is given, mountpoints are checked. If not only user folder read permissions are checked. + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + protected function assureFolderReadPermission(?FolderInterface $folder = null): void + { + if (!$this->checkFolderActionPermission('read', $folder)) { + if ($folder === null) { + throw new InsufficientFolderAccessPermissionsException( + 'You are not allowed to read folders', + 1430657869 + ); + } + throw new InsufficientFolderAccessPermissionsException( + 'You are not allowed to access the given folder: "' . $folder->getName() . '"', + 1375955684 + ); + } + } + + /** + * Assures delete permission for given folder. + * + * @param FolderInterface $folder If a folder is given, mountpoints are checked. If not only user folder delete permissions are checked. + * @param bool $checkDeleteRecursively + * @throws Exception\InsufficientFolderAccessPermissionsException + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\InsufficientUserPermissionsException + */ + protected function assureFolderDeletePermission(FolderInterface $folder, bool $checkDeleteRecursively): void + { + // Check user permissions for recursive deletion if it is requested + if ($checkDeleteRecursively && !$this->checkUserActionPermission('recursivedelete', 'Folder')) { + throw new InsufficientUserPermissionsException('You are not allowed to delete folders recursively', 1377779423); + } + // Check user action permission + if (!$this->checkFolderActionPermission('delete', $folder)) { + throw new InsufficientFolderAccessPermissionsException( + 'You are not allowed to delete the given folder: "' . $folder->getName() . '"', + 1377779039 + ); + } + // Check if the user has write permissions to folders + // Would be good if we could check for actual write permissions in the containing folder + // but we cannot since we have no access to the containing folder of this file. + if (!$this->checkUserActionPermission('write', 'Folder')) { + throw new InsufficientFolderWritePermissionsException('Writing to folders is not allowed.', 1377779111); + } + } + + /** + * Assures read permission for given file. + * + * @throws Exception\InsufficientFileAccessPermissionsException + * @throws Exception\IllegalFileExtensionException + */ + protected function assureFileReadPermission(FileInterface $file): void + { + if (!$this->checkFileActionPermission('read', $file)) { + throw new InsufficientFileAccessPermissionsException( + 'You are not allowed to access that file: "' . $file->getName() . '"', + 1375955429 + ); + } + if (!$this->checkValidFileExtension($file)) { + throw new IllegalFileExtensionException( + 'You are not allowed to use that file extension. File: "' . $file->getName() . '"', + 1375955430 + ); + } + } + + /** + * Assures write permission for given file. + * + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientFileWritePermissionsException + */ + protected function assureFileWritePermissions(FileInterface $file): void + { + // Check if user is allowed to write the file and $file is writable + if (!$this->checkFileActionPermission('write', $file)) { + throw new InsufficientFileWritePermissionsException('Writing to file "' . $file->getIdentifier() . '" is not allowed.', 1330121088); + } + if (!$this->checkValidFileExtension($file)) { + throw new IllegalFileExtensionException('You are not allowed to edit a file with extension "' . $file->getExtension() . '"', 1366711933); + } + } + + /** + * Assure replace permission for given file. + * + * @throws Exception\InsufficientFileWritePermissionsException + * @throws Exception\InsufficientFolderWritePermissionsException + */ + protected function assureFileReplacePermissions(FileInterface $file): void + { + // Check if user is allowed to replace the file and $file is writable + if (!$this->checkFileActionPermission('replace', $file)) { + throw new InsufficientFileWritePermissionsException('Replacing file "' . $file->getIdentifier() . '" is not allowed.', 1436899571); + } + // Check if parentFolder is writable for the user + $parentFolder = $file->getParentFolder(); + if (!$parentFolder instanceof Folder || !$this->checkFolderActionPermission('write', $parentFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to write to the target folder "' . $file->getIdentifier() . '"', 1436899572); + } + } + + /** + * Assures delete permission for given file. + * + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientFileWritePermissionsException + * @throws Exception\InsufficientFolderWritePermissionsException + */ + protected function assureFileDeletePermissions(FileInterface $file): void + { + // Check for disallowed file extensions + if (!$this->checkValidFileExtension($file)) { + throw new IllegalFileExtensionException('You are not allowed to delete a file with extension "' . $file->getExtension() . '"', 1377778916); + } + // Check further permissions if file is not a processed file + if (!$file instanceof ProcessedFile) { + // Check if user is allowed to delete the file and $file is writable + if (!$this->checkFileActionPermission('delete', $file)) { + // Do not throw exception, if file is just missing. + // That way we make sure event "FileDeletionAspect" is still being called to remove the remaining records. + if ($file instanceof File && $file->isMissing()) { + return; + } + throw new InsufficientFileWritePermissionsException('You are not allowed to delete the file "' . $file->getIdentifier() . '"', 1319550425); + } + // Check if the user has write permissions to folders + // Would be good if we could check for actual write permissions in the containing folder + // but we cannot since we have no access to the containing folder of this file. + if (!$this->checkUserActionPermission('write', 'Folder')) { + throw new InsufficientFolderWritePermissionsException('Writing to folders is not allowed.', 1377778702); + } + } + } + + /** + * Checks if a file/user has the permission to be written to a Folder/Storage. + * If not, throws an exception. + * + * @param FolderInterface $targetFolder The target folder where the file should be written + * @param string $targetFileName The file name which should be written into the storage + * + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientUserPermissionsException + */ + protected function assureFileAddPermissions(FolderInterface $targetFolder, string $targetFileName): void + { + // Check for a valid file extension + if (!$this->checkFileExtensionPermission($targetFileName)) { + throw new IllegalFileExtensionException('Extension of file name is not allowed in "' . $targetFileName . '"!', 1322120271); + } + // Makes sure the user is allowed to upload + if (!$this->checkUserActionPermission('add', 'File')) { + throw new InsufficientUserPermissionsException('You are not allowed to add files to this storage "' . $this->getUid() . '"', 1376992145); + } + // Check if targetFolder is writable + if (!$this->checkFolderActionPermission('write', $targetFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to write to the target folder "' . $targetFolder->getIdentifier() . '"', 1322120356); + } + } + + /** + * Checks if a file has the permission to be uploaded to a Folder/Storage. + * If not, throws an exception. + * + * @param FolderInterface $targetFolder The target folder where the file should be uploaded + * @param string $targetFileName the destination file name $_FILES['file1']['name'] + * + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\UploadException + * @throws Exception\IllegalFileExtensionException + * @throws Exception\UploadSizeException + * @throws Exception\InsufficientUserPermissionsException + */ + protected function assureFileUploadPermissions(string|array|UploadedFileInterface $uploadedFileData, FolderInterface $targetFolder, string $targetFileName, int $uploadedFileSize): void + { + // the temporary file name from $_FILES['file1']['tmp_name'] + // @todo deprecate using local file path parameter here + if (is_string($uploadedFileData)) { + $localFilePath = $uploadedFileData; + // Makes sure this is an uploaded file via HTTP + if (!is_uploaded_file($localFilePath)) { + throw new UploadException('The upload has failed, no uploaded file found!', 1322110455); + } + // otherwise, resolve the local file path from the `UploadedFile`-like structure + // (no additional `is_uploaded_file` check on purpose) + } else { + $localFilePath = $this->getUploadedLocalFilePath($uploadedFileData); + } + + // Max upload size (kb) for files. + $maxUploadFileSize = GeneralUtility::getMaxUploadFileSize() * 1024; + if ($maxUploadFileSize > 0 && $uploadedFileSize >= $maxUploadFileSize) { + unlink($localFilePath); + throw new UploadSizeException('The uploaded file exceeds the size-limit of ' . $maxUploadFileSize . ' bytes', 1322110041); + } + $this->assureFileAddPermissions($targetFolder, $targetFileName); + } + + /** + * Checks for permissions to move a file. + * + * @throws \RuntimeException + * @throws Exception\InsufficientFolderAccessPermissionsException + * @throws Exception\InsufficientUserPermissionsException + * @throws Exception\IllegalFileExtensionException + */ + protected function assureFileMovePermissions(FileInterface $file, FolderInterface $targetFolder, string $targetFileName): void + { + // Check if targetFolder is within this storage + if ($this->getUid() !== $targetFolder->getStorage()->getUid()) { + throw new \RuntimeException('The target folder is not in the same storage. Target folder given: "' . $targetFolder->getIdentifier() . '"', 1422553107); + } + // Check for a valid file extension + if (!$this->checkFileExtensionPermission($targetFileName)) { + throw new IllegalFileExtensionException('Extension of file name is not allowed in "' . $targetFileName . '"!', 1378243279); + } + // Check if user is allowed to move and $file is readable and writable + if (!$file->getStorage()->checkFileActionPermission('move', $file)) { + throw new InsufficientUserPermissionsException('You are not allowed to move files to storage "' . $this->getUid() . '"', 1319219349); + } + // Check if target folder is writable + if (!$this->checkFolderActionPermission('write', $targetFolder)) { + throw new InsufficientFolderAccessPermissionsException('You are not allowed to write to the target folder "' . $targetFolder->getIdentifier() . '"', 1319219350); + } + } + + /** + * Checks for permissions to rename a file. + * + * @throws Exception\InsufficientFileWritePermissionsException + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientUserPermissionsException + */ + protected function assureFileRenamePermissions(FileInterface $file, string $targetFileName): void + { + // Check if file extension is allowed + if (!$this->checkFileExtensionPermission($targetFileName) || !$this->checkValidFileExtension($file)) { + throw new IllegalFileExtensionException('You are not allowed to rename a file with this extension. File given: "' . $file->getName() . '"', 1371466663); + } + // Check if user is allowed to rename + if (!$this->checkFileActionPermission('rename', $file)) { + throw new InsufficientUserPermissionsException('You are not allowed to rename files. File given: "' . $file->getName() . '"', 1319219351); + } + // Check if the user is allowed to write to folders + // Although it would be good to check, we cannot check here if the folder actually is writable + // because we do not know in which folder the file resides. + // So we rely on the driver to throw an exception in case the renaming failed. + if (!$this->checkFolderActionPermission('write')) { + throw new InsufficientFileWritePermissionsException('You are not allowed to write to folders', 1319219352); + } + } + + /** + * Check if a file has the permission to be copied on a File/Folder/Storage, + * if not throw an exception + * + * @throws Exception + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientFileReadPermissionsException + * @throws Exception\InsufficientUserPermissionsException + */ + protected function assureFileCopyPermissions(FileInterface $file, FolderInterface $targetFolder, string $targetFileName): void + { + // Check if targetFolder is within this storage, this should never happen + if ($this->getUid() != $targetFolder->getStorage()->getUid()) { + throw new Exception('The operation of the folder cannot be called by this storage "' . $this->getUid() . '"', 1319550405); + } + // Check if user is allowed to copy + if (!$file->getStorage()->checkFileActionPermission('copy', $file)) { + throw new InsufficientFileReadPermissionsException('You are not allowed to copy the file "' . $file->getIdentifier() . '"', 1319550426); + } + // Check if targetFolder is writable + if (!$this->checkFolderActionPermission('write', $targetFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to write to the target folder "' . $targetFolder->getIdentifier() . '"', 1319550435); + } + // Check for a valid file extension + if (!$this->checkFileExtensionPermission($targetFileName) || !$this->checkValidFileExtension($file)) { + throw new IllegalFileExtensionException('You are not allowed to copy a file of that type.', 1319553317); + } + } + + /** + * @throws ResultException + */ + protected function assureResourceConsistency(string|FileInterface $resource, string $fileName = ''): void + { + GeneralUtility::makeInstance(ResourceConsistencyService::class)->validate($this, $resource, $fileName); + } + + /** + * Check if a file has the permission to be copied on a File/Folder/Storage, + * if not throw an exception. + * + * @throws Exception + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientFileReadPermissionsException + * @throws Exception\InsufficientUserPermissionsException + * @throws \RuntimeException + */ + protected function assureFolderCopyPermissions(FolderInterface $folderToCopy, FolderInterface $targetParentFolder): void + { + // Check if targetFolder is within this storage, this should never happen + if ($this->getUid() !== $targetParentFolder->getStorage()->getUid()) { + throw new Exception('The operation of the folder cannot be called by this storage "' . $this->getUid() . '"', 1377777624); + } + if (!$folderToCopy instanceof Folder) { + throw new \RuntimeException('The folder "' . $folderToCopy->getIdentifier() . '" to copy is not of type folder.', 1384209020); + } + // Check if user is allowed to copy and the folder is readable + if (!$folderToCopy->getStorage()->checkFolderActionPermission('copy', $folderToCopy)) { + throw new InsufficientFileReadPermissionsException('You are not allowed to copy the folder "' . $folderToCopy->getIdentifier() . '"', 1377777629); + } + if (!$targetParentFolder instanceof Folder) { + throw new \RuntimeException('The target folder "' . $targetParentFolder->getIdentifier() . '" is not of type folder.', 1384209021); + } + // Check if targetFolder is writable + if (!$this->checkFolderActionPermission('write', $targetParentFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to write to the target folder "' . $targetParentFolder->getIdentifier() . '"', 1377777635); + } + } + + /** + * Check if a file has the permission to be copied on a File/Folder/Storage, + * if not throw an exception. + * + * @throws \InvalidArgumentException + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws Exception\InsufficientFileReadPermissionsException + * @throws \RuntimeException + */ + protected function assureFolderMovePermissions(FolderInterface $folderToMove, FolderInterface $targetParentFolder): void + { + // Check if targetFolder is within this storage, this should never happen + if ($this->getUid() !== $targetParentFolder->getStorage()->getUid()) { + throw new \InvalidArgumentException('Cannot move a folder into a folder that does not belong to this storage.', 1325777289); + } + if (!$folderToMove instanceof Folder) { + throw new \RuntimeException('The folder "' . $folderToMove->getIdentifier() . '" to move is not of type Folder.', 1384209022); + } + // Check if user is allowed to move and the folder is writable + // In fact we would need to check if the parent folder of the folder to move is writable also + // But as of now we cannot extract the parent folder from this folder + if (!$folderToMove->getStorage()->checkFolderActionPermission('move', $folderToMove)) { + throw new InsufficientFileReadPermissionsException('You are not allowed to copy the folder "' . $folderToMove->getIdentifier() . '"', 1377778045); + } + if (!$targetParentFolder instanceof Folder) { + throw new \RuntimeException('The target folder "' . $targetParentFolder->getIdentifier() . '" is not of type Folder.', 1384209023); + } + // Check if targetFolder is writable + if (!$this->checkFolderActionPermission('write', $targetParentFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to write to the target folder "' . $targetParentFolder->getIdentifier() . '"', 1377778049); + } + } + + /** + * Clean up a fileName from not allowed characters + * + * @param string $fileName The name of the file to be sanitized + * @param Folder|null $targetFolder The target folder where the file is located or should be added + */ + public function sanitizeFileName(string $fileName, ?Folder $targetFolder = null): string + { + $targetFolder = $targetFolder ?: $this->getDefaultFolder(); + $sanitizedFileName = $this->driver->sanitizeFileName($fileName); + + // The file name could be changed by an event listener + return $this->eventDispatcher->dispatch( + new SanitizeFileNameEvent($sanitizedFileName, $fileName, $targetFolder, $this, $this->driver) + )->getFileName(); + } + + /******************** + * FILE ACTIONS + ********************/ + /** + * Moves a file from the local filesystem to this storage. + * + * @param string $localFilePath The file on the server's hard disk to add + * @param Folder $targetFolder The target folder where the file should be added + * @param string $targetFileName The name of the file to be added, If not set, the local file name is used + * @param bool $removeOriginal if set the original file will be removed after successful operation + * + * @throws \InvalidArgumentException + * @throws Exception\ExistingTargetFileNameException + */ + public function addFile(string $localFilePath, Folder $targetFolder, string $targetFileName = '', DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME, bool $removeOriginal = true): File + { + $localFilePath = PathUtility::getCanonicalPath($localFilePath); + // File is not available locally NOR is it an uploaded file + if (!is_uploaded_file($localFilePath) && !file_exists($localFilePath)) { + throw new \InvalidArgumentException('File "' . $localFilePath . '" does not exist.', 1319552745); + } + + $targetFileName = $this->sanitizeFileName($targetFileName ?: PathUtility::basename($localFilePath), $targetFolder); + + $targetFileName = $this->eventDispatcher->dispatch( + new BeforeFileAddedEvent($targetFileName, $localFilePath, $targetFolder, $this, $this->driver) + )->getFileName(); + + $this->assureFileAddPermissions($targetFolder, $targetFileName); + $this->assureResourceConsistency($localFilePath, $targetFileName); + + $replaceExisting = false; + if ($conflictMode === DuplicationBehavior::CANCEL && $this->driver->fileExistsInFolder($targetFileName, $targetFolder->getIdentifier())) { + throw new ExistingTargetFileNameException('File "' . $targetFileName . '" already exists in folder ' . $targetFolder->getIdentifier(), 1322121068); + } + if ($conflictMode === DuplicationBehavior::RENAME) { + $targetFileName = $this->getUniqueName($targetFolder, $targetFileName); + } elseif ($conflictMode === DuplicationBehavior::REPLACE && $this->driver->fileExistsInFolder($targetFileName, $targetFolder->getIdentifier())) { + $replaceExisting = true; + } + + $fileIdentifier = $this->driver->addFile($localFilePath, $targetFolder->getIdentifier(), $targetFileName, $removeOriginal); + /** @var File $file */ + $file = $this->getFileByIdentifier($fileIdentifier); + + if ($replaceExisting) { + $this->getIndexer()->updateIndexEntry($file); + } + + $this->eventDispatcher->dispatch( + new AfterFileAddedEvent($file, $targetFolder) + ); + return $file; + } + + /** + * Updates a processed file with a new file from the local filesystem. + * + * @throws \InvalidArgumentException + * @internal do not use outside TYPO3's File Abstraction Layer code + */ + public function updateProcessedFile(string $localFilePath, ProcessedFile $processedFile, ?Folder $processingFolder = null): ProcessedFile + { + if (!file_exists($localFilePath)) { + throw new \InvalidArgumentException('File "' . $localFilePath . '" does not exist.', 1319552746); + } + if ($processingFolder === null) { + $processingFolder = $this->getProcessingFolder($processedFile->getOriginalFile()); + } + $fileIdentifier = $this->driver->addFile($localFilePath, $processingFolder->getIdentifier(), $processedFile->getName()); + // @todo check if we have to update the processed file other then the identifier + $processedFile->setIdentifier($fileIdentifier); + return $processedFile; + } + + /** + * Creates a (cryptographic) hash for a file. + */ + public function hashFile(FileInterface $fileObject, string $hashAlgorithm): string + { + return $this->hashFileByIdentifier($fileObject->getIdentifier(), $hashAlgorithm); + } + + /** + * Creates a (cryptographic) hash for a fileIdentifier. + * + * @throws InvalidHashException + */ + public function hashFileByIdentifier(string $fileIdentifier, string $hashAlgorithm): string + { + $hash = $this->driver->hash($fileIdentifier, $hashAlgorithm); + if ($hash === '') { + throw new InvalidHashException('Hash has to be non-empty string.', 1551950301); + } + return $hash; + } + + /** + * Hashes a file identifier, taking the case sensitivity of the file system + * into account. This helps to mitigate problems with case-insensitive + * databases. + */ + public function hashFileIdentifier(FileInterface|string $file): string + { + if ($file instanceof FileInterface) { + $file = $file->getIdentifier(); + } + return $this->driver->hashIdentifier($file); + } + + /** + * Returns a publicly accessible URL for a file. + * + * WARNING: Access to the file may be restricted by further means, e.g. + * some web-based authentication. You have to take care of this yourself. + * + * @param ResourceInterface $resourceObject The file or folder object + * @return string|null NULL if file is missing or deleted, the generated url otherwise + */ + public function getPublicUrl(ResourceInterface $resourceObject): ?string + { + $publicUrl = null; + if ($this->isOnline()) { + // Pre-process the public URL by an accordant event + // @todo: Both FE and BE have resolve an indirect dependency to Request by registering + // an event listener here dynamically in RequestHandler. This needs a refactoring + // and we may want to extract the entire URL generation to an own service anyway. + $event = new GeneratePublicUrlForResourceEvent($resourceObject, $this, $this->driver); + $publicUrl = $this->eventDispatcher->dispatch($event)->getPublicUrl(); + if ( + $publicUrl === null + && $resourceObject instanceof File + && ($helper = GeneralUtility::makeInstance(OnlineMediaHelperRegistry::class)->getOnlineMediaHelper($resourceObject)) !== false + ) { + $publicUrl = $helper->getPublicUrl($resourceObject); + } + + // If an event listener did not handle the URL generation, use the default way to determine public URL + if ($publicUrl === null) { + if ($this->hasCapability(Capabilities::CAPABILITY_PUBLIC)) { + $publicUrl = $this->driver->getPublicUrl($resourceObject->getIdentifier()); + } + + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + if ($publicUrl === null && $resourceObject instanceof FileInterface && $request instanceof ServerRequestInterface) { + $queryParameterArray = ['eID' => 'dumpFile', 't' => '']; + if ($resourceObject instanceof File) { + $queryParameterArray['f'] = $resourceObject->getUid(); + $queryParameterArray['t'] = 'f'; + } elseif ($resourceObject instanceof ProcessedFile) { + $queryParameterArray['p'] = $resourceObject->getUid(); + $queryParameterArray['t'] = 'p'; + } + + $hashService = GeneralUtility::makeInstance(HashService::class); + $queryParameterArray['token'] = $hashService->hmac(implode('|', $queryParameterArray), 'resourceStorageDumpFile', HashAlgo::SHA3_256); + $publicUrl = GeneralUtility::locationHeaderUrl(PathUtility::getAbsoluteWebPath(Environment::getPublicPath() . '/index.php'), $request); + $publicUrl .= '?' . http_build_query($queryParameterArray, '', '&', PHP_QUERY_RFC3986); + } + } + + if ($resourceObject instanceof AbstractFile + && GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('frontend.cache.autoTagging') + ) { + $fileResourceObject = method_exists($resourceObject, 'getOriginalFile') ? $resourceObject->getOriginalFile() : $resourceObject; + $this->eventDispatcher->dispatch( + new AddCacheTagEvent( + new CacheTag(sprintf('sys_file_%s', $fileResourceObject->getUid())) + ) + ); + $metaData = method_exists($fileResourceObject, 'getMetaData') ? $fileResourceObject->getMetaData()->get() : []; + if (array_key_exists('uid', $metaData)) { + $this->eventDispatcher->dispatch( + new AddCacheTagEvent( + new CacheTag(sprintf('sys_file_metadata_%s', $metaData['uid'])) + ) + ); + } + } + } + return $publicUrl; + } + + /** + * Passes a file to the File Processing Services and returns the resulting ProcessedFile object. + */ + public function processFile(File|FileReference $fileObject, string $context, array $configuration): ProcessedFile + { + if ($fileObject->getStorage() !== $this) { + throw new \InvalidArgumentException('Cannot process files of foreign storage', 1353401835); + } + return $this->getFileProcessingService()->processFile($fileObject, $context, $this->driver, $configuration); + } + + /** + * Copies a file from the storage for local processing. + * + * @return string Path to local file (either original or copied to some temporary local location) + */ + public function getFileForLocalProcessing(FileInterface $fileObject, bool $writable = true): string + { + return $this->driver->getFileForLocalProcessing($fileObject->getIdentifier(), $writable); + } + + /** + * Gets a file by identifier. + */ + public function getFile(string $identifier): ProcessedFile|File|null + { + $file = $this->getFileByIdentifier($identifier); + if ($file instanceof File && !$this->driver->fileExists($identifier)) { + $file->setMissing(true); + } + return $file; + } + + /** + * Gets a file object from storage by file identifier + * If the file is outside the process folder, it gets indexed and returned as file object afterward + * If the file is within processing folder, the file object will be directly returned + * + * @return File|ProcessedFile|null Returns ProcessedFile|null only if a processed file is requested, always File otherwise + */ + public function getFileByIdentifier(string $fileIdentifier): File|ProcessedFile|null + { + if (!$this->isWithinProcessingFolder($fileIdentifier)) { + $fileData = $this->getFileIndexRepository()->findOneByStorageAndIdentifier($this, $fileIdentifier); + if ($fileData === false) { + return $this->getIndexer()->createIndexEntry($fileIdentifier); + } + return $this->getResourceFactoryInstance()->getFileObject($fileData['uid'], $fileData); + } + return $this->getProcessedFileRepository()->findByStorageAndIdentifier($this, $fileIdentifier); + } + + protected function getProcessedFileRepository(): ProcessedFileRepository + { + return GeneralUtility::makeInstance(ProcessedFileRepository::class); + } + + /** + * Gets information about a file. + * + * @internal + */ + public function getFileInfo(FileInterface $fileObject): array + { + return $this->getFileInfoByIdentifier($fileObject->getIdentifier()); + } + + /** + * Gets information about a file by its identifier. + * + * @internal + */ + public function getFileInfoByIdentifier(string $identifier, array $propertiesToExtract = []): array + { + return $this->driver->getFileInfoByIdentifier($identifier, $propertiesToExtract); + } + + /** + * Unsets the file and folder name filters, thus making this storage return unfiltered filelists. + */ + public function unsetFileAndFolderNameFilters(): void + { + $this->fileAndFolderNameFilters = []; + } + + /** + * Resets the file and folder name filters to the default values defined in the TYPO3 configuration. + */ + public function resetFileAndFolderNameFiltersToDefault(): void + { + $this->fileAndFolderNameFilters = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['defaultFilterCallbacks']; + } + + /** + * Returns a filter for files generated by EXT:impexp + * + * @return array<int, ImportExportFilter|string> + * @internal + */ + public function getImportExportFilter(): array + { + $filter = GeneralUtility::makeInstance(ImportExportFilter::class); + + return [$filter, 'filterImportExportFilesAndFolders']; + } + + /** + * Returns the file and folder name filters used by this storage. + * + * @return array + */ + public function getFileAndFolderNameFilters(): array + { + return array_merge($this->fileAndFolderNameFilters, [$this->getImportExportFilter()]); + } + + /** + * @return $this + */ + public function setFileAndFolderNameFilters(array $filters): self + { + $this->fileAndFolderNameFilters = $filters; + return $this; + } + + public function addFileAndFolderNameFilter(callable $filter): void + { + $this->fileAndFolderNameFilters[] = $filter; + } + + public function getFolderIdentifierFromFileIdentifier(string $fileIdentifier): string + { + return $this->driver->getParentFolderIdentifierOfIdentifier($fileIdentifier); + } + + /** + * Get file from folder + */ + public function getFileInFolder(string $fileName, Folder $folder): File|ProcessedFile|null + { + $identifier = $this->driver->getFileInFolder($fileName, $folder->getIdentifier()); + return $this->getFileByIdentifier($identifier); + } + + /** + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return File[] + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function getFilesInFolder(Folder $folder, int $start = 0, int $maxNumberOfItems = 0, bool $useFilters = true, bool $recursive = false, string $sort = '', bool $sortRev = false): array + { + $this->assureFolderReadPermission($folder); + + $rows = $this->getFileIndexRepository()->findByFolder($folder); + + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + $fileIdentifiers = $this->driver->getFilesInFolder($folder->getIdentifier(), $start, $maxNumberOfItems, $recursive, $filters, $sort, $sortRev); + + $items = []; + foreach ($fileIdentifiers as $identifier) { + if (isset($rows[$identifier])) { + $fileObject = $this->getFileFactory()->getFileObject($rows[$identifier]['uid'], $rows[$identifier]); + } else { + $fileObject = $this->getFileByIdentifier($identifier); + } + // We never want to list anything else than regular files, not processed files etc. + if (!$fileObject instanceof File) { + continue; + } + $key = $fileObject->getName(); + while (isset($items[$key])) { + $key .= 'z'; + } + $items[$key] = $fileObject; + } + + return $items; + } + + public function getFileIdentifiersInFolder(string $folderIdentifier, bool $useFilters = true, bool $recursive = false): array + { + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + return $this->driver->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filters); + } + + /** + * @return int Number of files in folder + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function countFilesInFolder(Folder $folder, bool $useFilters = true, bool $recursive = false): int + { + $this->assureFolderReadPermission($folder); + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + return $this->driver->countFilesInFolder($folder->getIdentifier(), $recursive, $filters); + } + + public function getFolderIdentifiersInFolder(string $folderIdentifier, bool $useFilters = true, bool $recursive = false): array + { + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + return $this->driver->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $filters); + } + + /** + * Returns TRUE if the specified file exists + */ + public function hasFile(string $identifier): bool + { + // Allow if identifier is in processing folder + if (!$this->isWithinProcessingFolder($identifier)) { + $this->assureFolderReadPermission(); + } + return $this->driver->fileExists($identifier); + } + + /** + * Get all processing folders that live in this storage + * + * @return Folder[] + */ + public function getProcessingFolders(): array + { + if ($this->processingFolders === null) { + $this->processingFolders = []; + $this->processingFolders[] = $this->getProcessingFolder(); + $storageRepository = GeneralUtility::makeInstance(StorageRepository::class); + $allStorages = $storageRepository->findAll(); + foreach ($allStorages as $storage) { + // To circumvent the permission check of the folder, we use the factory to create it "manually" instead of directly using $storage->getProcessingFolder() + // See #66695 for details + [$storageUid, $processingFolderIdentifier] = array_pad(GeneralUtility::trimExplode(':', $storage->getStorageRecord()['processingfolder'] ?? ''), 2, null); + if (empty($processingFolderIdentifier) || (int)$storageUid !== $this->getUid()) { + continue; + } + $potentialProcessingFolder = $this->createFolderObject($processingFolderIdentifier, $processingFolderIdentifier); + if ($potentialProcessingFolder->getStorage() === $this && $potentialProcessingFolder->getIdentifier() !== $this->getProcessingFolder()->getIdentifier()) { + $this->processingFolders[] = $potentialProcessingFolder; + } + } + } + + return $this->processingFolders; + } + + /** + * Returns TRUE if folder that is in current storage is set as + * processing folder for one of the existing storages + */ + public function isProcessingFolder(Folder $folder): bool + { + $isProcessingFolder = false; + foreach ($this->getProcessingFolders() as $processingFolder) { + if ($folder->getCombinedIdentifier() === $processingFolder->getCombinedIdentifier()) { + $isProcessingFolder = true; + break; + } + } + return $isProcessingFolder; + } + + /** + * Checks if the queried file in the given folder exists + */ + public function hasFileInFolder(string $fileName, Folder $folder): bool + { + $this->assureFolderReadPermission($folder); + return $this->driver->fileExistsInFolder($fileName, $folder->getIdentifier()); + } + + /** + * Get contents of a file object + * + * @throws Exception\InsufficientFileReadPermissionsException + */ + public function getFileContents(FileInterface $file): string + { + $this->assureFileReadPermission($file); + return $this->driver->getFileContents($file->getIdentifier()); + } + + /** + * Returns a PSR-7 Response which can be used to stream the requested file + * + * @param bool $asDownload If set Content-Disposition attachment is sent, inline otherwise + * @param string|null $alternativeFilename the filename for the download (if $asDownload is set) + * @param string|null $overrideMimeType If set this will be used as Content-Type header instead of the automatically detected mime type. + */ + public function streamFile( + FileInterface $file, + bool $asDownload = false, + ?string $alternativeFilename = null, + ?string $overrideMimeType = null + ): ResponseInterface { + $this->assureFileReadPermission($file); + if (!$this->driver instanceof StreamableDriverInterface) { + return $this->getPseudoStream($file, $asDownload, $alternativeFilename, $overrideMimeType); + } + + $properties = [ + 'as_download' => $asDownload, + 'filename_overwrite' => $alternativeFilename, + 'mimetype_overwrite' => $overrideMimeType, + ]; + return $this->driver->streamFile($file->getIdentifier(), $properties); + } + + /** + * Wrap DriverInterface::dumpFileContents into a SelfEmittableStreamInterface + * + * @param bool $asDownload If set Content-Disposition attachment is sent, inline otherwise + * @param string|null $alternativeFilename the filename for the download (if $asDownload is set) + * @param string|null $overrideMimeType If set this will be used as Content-Type header instead of the automatically detected mime type. + */ + protected function getPseudoStream( + FileInterface $file, + bool $asDownload = false, + ?string $alternativeFilename = null, + ?string $overrideMimeType = null + ): ResponseInterface { + $downloadName = $alternativeFilename ?: $file->getName(); + $contentDisposition = $asDownload ? 'attachment' : 'inline'; + + $stream = new FalDumpFileContentsDecoratorStream($file->getIdentifier(), $this->driver, $file->getSize()); + $fileInfo = $this->driver->getFileInfoByIdentifier($file->getIdentifier(), ['mtime']); + $headers = [ + 'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"', + 'Content-Type' => $overrideMimeType ?: $file->getMimeType(), + 'Content-Length' => (string)$file->getSize(), + 'Last-Modified' => gmdate('D, d M Y H:i:s', array_pop($fileInfo)) . ' GMT', + // Cache-Control header is needed here to solve an issue with browser IE8 and lower + // See for more information: http://support.microsoft.com/kb/323308 + 'Cache-Control' => '', + ]; + + return new Response($stream, 200, $headers); + } + + /** + * Set contents of a file object. + * + * @throws \Exception|\RuntimeException + * @throws Exception\InsufficientFileWritePermissionsException + * @return int The number of bytes written to the file + */ + public function setFileContents(AbstractFile $file, string $contents): int + { + // Check if user is allowed to edit + $this->assureFileWritePermissions($file); + $this->eventDispatcher->dispatch( + new BeforeFileContentsSetEvent($file, $contents) + ); + // Call driver method to update the file and update file index entry afterwards + $result = $this->driver->setFileContents($file->getIdentifier(), $contents); + if ($file instanceof File) { + $this->getIndexer()->updateIndexEntry($file); + } + $this->eventDispatcher->dispatch( + new AfterFileContentsSetEvent($file, $contents) + ); + return $result; + } + + /** + * Creates a new file + * + * previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::func_newfile() + * + * @param string $fileName The name of the file to be created + * @param Folder $targetFolderObject The target folder where the file should be created + * + * @throws Exception\IllegalFileExtensionException + * @throws Exception\InsufficientFolderWritePermissionsException + */ + public function createFile(string $fileName, Folder $targetFolderObject): ProcessedFile|File|null + { + $this->assureFileAddPermissions($targetFolderObject, $fileName); + $this->eventDispatcher->dispatch( + new BeforeFileCreatedEvent($fileName, $targetFolderObject) + ); + $newFileIdentifier = $this->driver->createFile($fileName, $targetFolderObject->getIdentifier()); + $this->eventDispatcher->dispatch( + new AfterFileCreatedEvent($newFileIdentifier, $targetFolderObject) + ); + return $this->getFileByIdentifier($newFileIdentifier); + } + + /** + * Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::deleteFile() + * + * @throws Exception\InsufficientFileAccessPermissionsException + * @throws Exception\FileOperationErrorException + * @return bool TRUE if deletion succeeded + */ + public function deleteFile(FileInterface $fileObject): bool + { + $this->assureFileDeletePermissions($fileObject); + + $this->eventDispatcher->dispatch( + new BeforeFileDeletedEvent($fileObject) + ); + $deleted = true; + + if ($this->driver->fileExists($fileObject->getIdentifier())) { + // Disable permission check to find nearest recycler and move file without errors + $currentPermissions = $this->evaluatePermissions; + $this->evaluatePermissions = false; + + $recyclerFolder = $this->getNearestRecyclerFolder($fileObject); + if ($recyclerFolder === null) { + $result = $this->driver->deleteFile($fileObject->getIdentifier()); + } else { + $result = $this->moveFile($fileObject, $recyclerFolder); + $deleted = false; + } + + $this->evaluatePermissions = $currentPermissions; + + if (!$result) { + throw new FileOperationErrorException('Deleting the file "' . $fileObject->getIdentifier() . '\' failed.', 1329831691); + } + } + // Mark the file object as deleted + if ($deleted && $fileObject instanceof AbstractFile) { + $fileObject->setDeleted(); + } + + $this->eventDispatcher->dispatch( + new AfterFileDeletedEvent($fileObject) + ); + + return true; + } + + /** + * Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::func_copy() + * copies a source file (from any location) in to the target + * folder, the latter has to be part of this storage + * + * @param string|null $targetFileName an optional destination fileName + * + * @throws \Exception|Exception\AbstractFileOperationException + * @throws Exception\ExistingTargetFileNameException + */ + public function copyFile(FileInterface $file, Folder $targetFolder, ?string $targetFileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): File + { + if ($targetFileName === null) { + $targetFileName = $file->getName(); + } + $sanitizedTargetFileName = $this->driver->sanitizeFileName($targetFileName); + $this->assureFileCopyPermissions($file, $targetFolder, $sanitizedTargetFileName); + + $this->eventDispatcher->dispatch( + new BeforeFileCopiedEvent($file, $targetFolder) + ); + + // File exists and we should abort, let's abort + if ($conflictMode === DuplicationBehavior::CANCEL && $targetFolder->hasFile($sanitizedTargetFileName)) { + throw new ExistingTargetFileNameException('The target file already exists.', 1320291064); + } + // File exists, and we should find another name, let's find another one + if ($conflictMode === DuplicationBehavior::RENAME && $targetFolder->hasFile($sanitizedTargetFileName)) { + $sanitizedTargetFileName = $this->getUniqueName($targetFolder, $sanitizedTargetFileName); + } + $sourceStorage = $file->getStorage(); + // Call driver method to create a new file from an existing file object, + // and return the new file object + if ($sourceStorage === $this) { + $newFileObjectIdentifier = $this->driver->copyFileWithinStorage($file->getIdentifier(), $targetFolder->getIdentifier(), $sanitizedTargetFileName); + } else { + $tempPath = $file->getForLocalProcessing(); + $newFileObjectIdentifier = $this->driver->addFile($tempPath, $targetFolder->getIdentifier(), $sanitizedTargetFileName); + } + /** @var File $newFileObject */ + $newFileObject = $this->getFileByIdentifier($newFileObjectIdentifier); + + // In case we deal with a file, also copy corresponding metadata + if ($file instanceof File) { + $metaDataAspect = $newFileObject->getMetaData(); + // Add meta data of file while keeping existing properties like "file", "uid", etc. + $metaDataAspect->add(array_replace($file->getMetaData()->get(), $metaDataAspect->get())); + $metaDataAspect->save(); + } + + $this->eventDispatcher->dispatch( + new AfterFileCopiedEvent($file, $targetFolder, $newFileObjectIdentifier, $newFileObject) + ); + return $newFileObject; + } + + /** + * Moves a $file into a $targetFolder + * the target folder has to be part of this storage + * + * previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::func_move() + * + * @param string|null $targetFileName an optional destination fileName + * + * @throws Exception\ExistingTargetFileNameException + * @throws \RuntimeException + */ + public function moveFile(FileInterface $file, Folder $targetFolder, ?string $targetFileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + if ($targetFileName === null) { + $targetFileName = $file->getName(); + } + $originalFolder = $file->getParentFolder(); + $sanitizedTargetFileName = $this->driver->sanitizeFileName($targetFileName); + $this->assureFileMovePermissions($file, $targetFolder, $sanitizedTargetFileName); + if ($targetFolder->hasFile($sanitizedTargetFileName)) { + // File exists and we should abort, let's abort + if ($conflictMode === DuplicationBehavior::RENAME) { + $sanitizedTargetFileName = $this->getUniqueName($targetFolder, $sanitizedTargetFileName); + } elseif ($conflictMode === DuplicationBehavior::CANCEL) { + throw new ExistingTargetFileNameException('The target file already exists', 1329850997); + } + } + $this->eventDispatcher->dispatch( + new BeforeFileMovedEvent($file, $targetFolder, $sanitizedTargetFileName) + ); + $sourceStorage = $file->getStorage(); + // Call driver method to move the file and update the index entry + try { + if ($sourceStorage === $this) { + $newIdentifier = $this->driver->moveFileWithinStorage($file->getIdentifier(), $targetFolder->getIdentifier(), $sanitizedTargetFileName); + if (!$file instanceof AbstractFile) { + throw new \RuntimeException('The given file is not of type AbstractFile.', 1384209025); + } + $file->updateProperties(['identifier' => $newIdentifier]); + } else { + $tempPath = $file->getForLocalProcessing(); + $newIdentifier = $this->driver->addFile($tempPath, $targetFolder->getIdentifier(), $sanitizedTargetFileName); + + // Disable permission check to find nearest recycler and move file without errors + $currentPermissions = $sourceStorage->evaluatePermissions; + $sourceStorage->evaluatePermissions = false; + + $recyclerFolder = $sourceStorage->getNearestRecyclerFolder($file); + if ($recyclerFolder === null) { + $sourceStorage->driver->deleteFile($file->getIdentifier()); + } else { + $sourceStorage->moveFile($file, $recyclerFolder); + } + $sourceStorage->evaluatePermissions = $currentPermissions; + if ($file instanceof File) { + $file->updateProperties(['storage' => $this->getUid(), 'identifier' => $newIdentifier]); + } + } + if ($file instanceof File) { + $this->getIndexer()->updateIndexEntry($file); + } + } catch (\TYPO3\CMS\Core\Exception $e) { + echo $e->getMessage(); + } + $this->eventDispatcher->dispatch( + new AfterFileMovedEvent($file, $targetFolder, $originalFolder) + ); + return $file; + } + + /** + * Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::func_rename() + * + * @throws ExistingTargetFileNameException + */ + public function renameFile(FileInterface $file, string $targetFileName, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): FileInterface + { + $sanitizedTargetFileName = $this->driver->sanitizeFileName($targetFileName); + // The new name should be different from the current. + if ($file->getName() === $sanitizedTargetFileName) { + return $file; + } + if (pathinfo($sanitizedTargetFileName, PATHINFO_EXTENSION) === '') { + $sanitizedTargetFileName .= '.' . $file->getExtension(); + } + $this->assureFileRenamePermissions($file, $sanitizedTargetFileName); + $this->assureResourceConsistency($file, $sanitizedTargetFileName); + return $this->handleRenameFile($file, $sanitizedTargetFileName, $conflictMode); + } + + protected function handleRenameFile( + FileInterface $file, + string $targetFileName, + DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME, + ): FileInterface { + // The new name should be different from the current. + if ($file->getName() === $targetFileName) { + return $file; + } + $this->eventDispatcher->dispatch( + new BeforeFileRenamedEvent($file, $targetFileName) + ); + // Call driver method to rename the file and update the index entry + try { + $newIdentifier = $this->driver->renameFile($file->getIdentifier(), $targetFileName); + if ($file instanceof File) { + $file->updateProperties(['identifier' => $newIdentifier]); + $this->getIndexer()->updateIndexEntry($file); + } + } catch (ExistingTargetFileNameException $exception) { + if ($conflictMode === DuplicationBehavior::RENAME) { + $newName = $this->getUniqueName($file->getParentFolder(), $targetFileName); + $file = $this->renameFile($file, $newName); + } elseif ($conflictMode === DuplicationBehavior::CANCEL) { + throw $exception; + } elseif ($conflictMode === DuplicationBehavior::REPLACE) { + if ($file instanceof AbstractFile) { + $sourceFileIdentifier = substr($file->getCombinedIdentifier(), 0, (int)strrpos($file->getCombinedIdentifier(), '/') + 1) . $targetFileName; + $sourceFile = $this->getResourceFactoryInstance()->getFileObjectFromCombinedIdentifier($sourceFileIdentifier); + $file = $this->replaceFile($sourceFile, Environment::getPublicPath() . '/' . $file->getPublicUrl()); + } + } + } catch (\RuntimeException) { + } + $this->eventDispatcher->dispatch( + new AfterFileRenamedEvent($file, $targetFileName) + ); + return $file; + } + + /** + * Replaces a file with a local file (e.g. a freshly uploaded file) + * + * @throws \InvalidArgumentException + */ + public function replaceFile(FileInterface $file, string $localFilePath): FileInterface + { + $this->assureFileReplacePermissions($file); + $this->assureResourceConsistency($localFilePath, $file->getName()); + return $this->handleReplaceFile($file, $localFilePath); + } + + protected function handleReplaceFile(FileInterface $file, string $localFilePath): FileInterface + { + if (!file_exists($localFilePath)) { + throw new \InvalidArgumentException('File "' . $localFilePath . '" does not exist.', 1325842622); + } + $this->eventDispatcher->dispatch( + new BeforeFileReplacedEvent($file, $localFilePath) + ); + $this->driver->replaceFile($file->getIdentifier(), $localFilePath); + if ($file instanceof File) { + $this->getIndexer()->updateIndexEntry($file); + } + $this->eventDispatcher->dispatch( + new AfterFileReplacedEvent($file, $localFilePath) + ); + return $file; + } + + /** + * Adds an uploaded file into the Storage. Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::file_upload() + * + * @param array|UploadedFileInterface $uploadedFileData Information about the uploaded file given by $_FILES['file1'] + * or a PSR-7 UploadedFileInterface object + * @param Folder|null $targetFolder the target folder + * @param string|null $targetFileName the file name to be written + */ + public function addUploadedFile(array|UploadedFileInterface $uploadedFileData, ?Folder $targetFolder = null, ?string $targetFileName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::CANCEL): FileInterface + { + $this->assertUploadedFileType($uploadedFileData); + $size = $uploadedFileData instanceof UploadedFile + ? $uploadedFileData->getSize() + : $uploadedFileData['size']; + $localFilePath = $this->getUploadedLocalFilePath($uploadedFileData); + $targetFileName = $this->getUploadedTargetFileName($uploadedFileData, $targetFileName); + $targetFolder ??= $this->getDefaultFolder(); + + $this->assureFileUploadPermissions($uploadedFileData, $targetFolder, $targetFileName, $size); + // no `assureResourceConsistency` check here - this is checked in either `replaceFile` or `addFile` + + if ($this->hasFileInFolder($targetFileName, $targetFolder) && $conflictMode === DuplicationBehavior::REPLACE) { + $file = $this->getFileInFolder($targetFileName, $targetFolder); + $resultObject = $this->replaceFile($file, $localFilePath); + } else { + $resultObject = $this->addFile($localFilePath, $targetFolder, $targetFileName, $conflictMode); + } + return $resultObject; + } + + /** + * Replaces an existing file with new contents and renames the file identifier. + */ + public function replaceAndRenameUploadedFile(array|UploadedFileInterface $sourceFile, FileInterface $targetFile, ?string $targetFileName = null): FileInterface + { + $this->assertUploadedFileType($sourceFile); + $localFilePath = $this->getUploadedLocalFilePath($sourceFile); + $localFileSize = $sourceFile instanceof UploadedFile ? $sourceFile->getSize() : $sourceFile['size']; + $targetFileName = $this->getUploadedTargetFileName($sourceFile, $targetFileName); + $targetFolder = $targetFile->getParentFolder(); + + $this->assureFileUploadPermissions($sourceFile, $targetFolder, $targetFileName, $localFileSize); + $this->assureFileReplacePermissions($targetFile); + $this->assureFileRenamePermissions($targetFile, $targetFileName); + $this->assureResourceConsistency($localFilePath, $targetFileName); + + $result = $this->handleReplaceFile($targetFile, $localFilePath); + $result = $this->handleRenameFile($result, $targetFileName); + return $result; + } + + /** + * Resolves the actual local file path of a new uploaded file. + * + * @internal + */ + public function getUploadedLocalFilePath(array|UploadedFileInterface $uploadedFileData): string + { + $this->assertUploadedFileType($uploadedFileData); + return $uploadedFileData instanceof UploadedFile + ? $uploadedFileData->getTemporaryFileName() + : $uploadedFileData['tmp_name']; + } + + /** + * Resolves the actual sanitized file name to be used for persisting a new uploaded file. + * + * @internal + */ + public function getUploadedTargetFileName(array|UploadedFileInterface $uploadedFileData, ?string $targetFileName = null): string + { + $this->assertUploadedFileType($uploadedFileData); + if ($targetFileName === null) { + if ($uploadedFileData instanceof UploadedFile) { + $targetFileName = $uploadedFileData->getClientFilename(); + } else { + $targetFileName = \Normalizer::normalize($uploadedFileData['name']); + } + } + return $this->driver->sanitizeFileName($targetFileName); + } + + /******************** + * FOLDER ACTIONS + ********************/ + /** + * Returns an array with all file objects in a folder and its subfolders, with the file identifiers as keys. + * @return array<string, File> + */ + protected function getAllFileObjectsInFolder(Folder $folder): array + { + $files = []; + $folderQueue = [$folder]; + while (!empty($folderQueue)) { + $folder = array_shift($folderQueue); + foreach ($folder->getSubfolders() as $subfolder) { + $folderQueue[] = $subfolder; + } + foreach ($folder->getFiles() as $file) { + /** @var File $file */ + $files[$file->getIdentifier()] = $file; + } + } + + return $files; + } + + /** + * Moves a folder. If you want to move a folder from this storage to another + * one, call this method on the target storage, otherwise you will get an exception. + * + * @param Folder $folderToMove The folder to move. + * @param Folder $targetParentFolder The target parent folder + * + * @throws \Exception|\TYPO3\CMS\Core\Exception + * @throws \InvalidArgumentException + * @throws InvalidTargetFolderException + */ + public function moveFolder(Folder $folderToMove, Folder $targetParentFolder, ?string $newFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): Folder + { + // @todo add tests + $this->assureFolderMovePermissions($folderToMove, $targetParentFolder); + $sourceStorage = $folderToMove->getStorage(); + $sanitizedNewFolderName = $this->driver->sanitizeFileName($newFolderName ?: $folderToMove->getName()); + // @todo check if folder already exists in $targetParentFolder, handle this conflict then + $this->eventDispatcher->dispatch( + new BeforeFolderMovedEvent($folderToMove, $targetParentFolder, $sanitizedNewFolderName) + ); + // Get all file objects now so we are able to update them after moving the folder + $fileObjects = $this->getAllFileObjectsInFolder($folderToMove); + if ($sourceStorage === $this) { + if ($this->isWithinFolder($folderToMove, $targetParentFolder)) { + throw new InvalidTargetFolderException( + sprintf( + 'Cannot move folder "%s" into target folder "%s", because the target folder is already within the folder to be moved!', + $folderToMove->getName(), + $targetParentFolder->getName() + ), + 1422723050 + ); + } + $fileMappings = $this->driver->moveFolderWithinStorage($folderToMove->getIdentifier(), $targetParentFolder->getIdentifier(), $sanitizedNewFolderName); + } else { + $fileMappings = $this->moveFolderBetweenStorages($folderToMove, $targetParentFolder, $sanitizedNewFolderName); + } + // Update the identifier and storage of all file objects + foreach ($fileObjects as $oldIdentifier => $fileObject) { + $newIdentifier = $fileMappings[$oldIdentifier]; + $fileObject->updateProperties(['storage' => $this->getUid(), 'identifier' => $newIdentifier]); + $this->getIndexer()->updateIndexEntry($fileObject); + } + $returnObject = $this->getFolder($fileMappings[$folderToMove->getIdentifier()]); + + $this->eventDispatcher->dispatch( + new AfterFolderMovedEvent($folderToMove, $targetParentFolder, $returnObject) + ); + return $returnObject; + } + + /** + * Moves the given folder from a different storage to the target folder in this storage. + * + * @throws NotImplementedMethodException + */ + protected function moveFolderBetweenStorages(Folder $folderToMove, Folder $targetParentFolder, string $newFolderName) + { + throw new NotImplementedMethodException('Not yet implemented', 1476046361); + } + + /** + * Copies a folder. + * + * @param FolderInterface $folderToCopy The folder to copy + * @param FolderInterface $targetParentFolder The target folder + * @return Folder The new (copied) folder object + * @throws InvalidTargetFolderException + */ + public function copyFolder(FolderInterface $folderToCopy, FolderInterface $targetParentFolder, ?string $newFolderName = null, DuplicationBehavior $conflictMode = DuplicationBehavior::RENAME): Folder + { + $this->assureFolderCopyPermissions($folderToCopy, $targetParentFolder); + $returnObject = null; + $sanitizedNewFolderName = $this->driver->sanitizeFileName($newFolderName ?: $folderToCopy->getName()); + if ($folderToCopy instanceof Folder && $targetParentFolder instanceof Folder) { + $this->eventDispatcher->dispatch( + new BeforeFolderCopiedEvent($folderToCopy, $targetParentFolder, $sanitizedNewFolderName) + ); + } + if ($conflictMode === DuplicationBehavior::CANCEL && ($targetParentFolder->hasFolder($sanitizedNewFolderName) || $targetParentFolder->hasFile($sanitizedNewFolderName))) { + throw new InvalidTargetFolderException( + sprintf( + 'Cannot copy folder "%s" into target folder "%s", because there is already a folder or file with that name in the target folder!', + $sanitizedNewFolderName, + $targetParentFolder->getIdentifier() + ), + 1422723059 + ); + } + // Folder exists, and we should find another name, let's find another one + if ($conflictMode === DuplicationBehavior::RENAME && ($targetParentFolder->hasFolder($sanitizedNewFolderName) || $targetParentFolder->hasFile($sanitizedNewFolderName))) { + $sanitizedNewFolderName = $this->getUniqueName($targetParentFolder, $sanitizedNewFolderName); + } + $sourceStorage = $folderToCopy->getStorage(); + // call driver method to move the file + // that also updates the file object properties + if ($sourceStorage === $this) { + $this->driver->copyFolderWithinStorage($folderToCopy->getIdentifier(), $targetParentFolder->getIdentifier(), $sanitizedNewFolderName); + $returnObject = $this->getFolder($targetParentFolder->getSubfolder($sanitizedNewFolderName)->getIdentifier()); + } else { + $this->copyFolderBetweenStorages($folderToCopy, $targetParentFolder, $sanitizedNewFolderName); + } + if ($folderToCopy instanceof Folder && $targetParentFolder instanceof Folder) { + $this->eventDispatcher->dispatch( + new AfterFolderCopiedEvent($folderToCopy, $targetParentFolder, $returnObject) + ); + } + return $returnObject; + } + + /** + * Copies a folder between storages. + * + * @throws NotImplementedMethodException + */ + protected function copyFolderBetweenStorages(FolderInterface $folderToCopy, FolderInterface $targetParentFolder, string $newFolderName) + { + throw new NotImplementedMethodException('Not yet implemented.', 1476046386); + } + + /** + * Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::folder_move() + * + * @throws \Exception + * @throws \InvalidArgumentException + */ + public function renameFolder(Folder $folderObject, string $newName): Folder + { + // Renaming the folder should check if the parent folder is writable + // We cannot do this however because we cannot extract the parent folder from a folder currently + if (!$this->checkFolderActionPermission('rename', $folderObject)) { + throw new InsufficientUserPermissionsException('You are not allowed to rename the folder "' . $folderObject->getIdentifier() . '\'', 1357811441); + } + + $sanitizedNewName = $this->driver->sanitizeFileName($newName); + if ($this->driver->folderExistsInFolder($sanitizedNewName, $folderObject->getIdentifier())) { + throw new \InvalidArgumentException('The folder ' . $sanitizedNewName . ' already exists in folder ' . $folderObject->getIdentifier(), 1325418870); + } + $this->eventDispatcher->dispatch( + new BeforeFolderRenamedEvent($folderObject, $sanitizedNewName) + ); + $fileObjects = $this->getAllFileObjectsInFolder($folderObject); + $fileMappings = $this->driver->renameFolder($folderObject->getIdentifier(), $sanitizedNewName); + // Update the identifier of all file objects + foreach ($fileObjects as $oldIdentifier => $fileObject) { + $newIdentifier = $fileMappings[$oldIdentifier]; + $fileObject->updateProperties(['identifier' => $newIdentifier]); + $this->getIndexer()->updateIndexEntry($fileObject); + } + $returnObject = $this->getFolder($fileMappings[$folderObject->getIdentifier()]); + + $this->eventDispatcher->dispatch( + new AfterFolderRenamedEvent($returnObject, $folderObject) + ); + return $returnObject; + } + + /** + * Previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::folder_delete() + * + * @throws \RuntimeException + * @throws Exception\InsufficientFolderAccessPermissionsException + * @throws Exception\InsufficientUserPermissionsException + * @throws Exception\FileOperationErrorException + */ + public function deleteFolder(Folder $folderObject, bool $deleteRecursively = false): bool + { + $isEmpty = $this->driver->isFolderEmpty($folderObject->getIdentifier()); + $this->assureFolderDeletePermission($folderObject, $deleteRecursively && !$isEmpty); + if (!$isEmpty && !$deleteRecursively) { + throw new \RuntimeException('Could not delete folder "' . $folderObject->getIdentifier() . '" because it is not empty.', 1325952534); + } + + $this->eventDispatcher->dispatch( + new BeforeFolderDeletedEvent($folderObject) + ); + + // Disable permission check to find nearest recycler and move folder without errors + $currentPermissions = $this->evaluatePermissions; + $this->evaluatePermissions = false; + + $recyclerFolder = $this->getNearestRecyclerFolder($folderObject); + + if ($recyclerFolder) { + $folderObject->moveTo($recyclerFolder); + $result = false; + $this->evaluatePermissions = $currentPermissions; + } else { + $this->evaluatePermissions = $currentPermissions; + + foreach ($this->getFilesInFolder($folderObject, 0, 0, false, $deleteRecursively) as $file) { + $this->deleteFile($file); + } + + $result = $this->driver->deleteFolder($folderObject->getIdentifier(), $deleteRecursively); + } + + $this->eventDispatcher->dispatch( + new AfterFolderDeletedEvent($folderObject, $result) + ); + + return $recyclerFolder ? true : $result; + } + + /** + * Returns the folder object from the folder identifier within a given parent folder. + * + * @param string $folderName The name of the target folder + * @throws \Exception + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function getFolderInFolder(string $folderName, Folder $parentFolder, bool $returnInaccessibleFolderObject = false): Folder + { + $folderIdentifier = $this->driver->getFolderInFolder($folderName, $parentFolder->getIdentifier()); + return $this->getFolder($folderIdentifier, $returnInaccessibleFolderObject); + } + + /** + * @param string $sort Property name used to sort the items. + * Among them may be: '' (empty, no sorting), name, + * fileext, size, tstamp and rw. + * If a driver does not support the given property, it + * should fall back to "name". + * @param bool $sortRev TRUE to indicate reverse sorting (last to first) + * @return array<string|int, Folder> + */ + public function getFoldersInFolder(Folder $folder, int $start = 0, int $maxNumberOfItems = 0, bool $useFilters = true, bool $recursive = false, string $sort = '', bool $sortRev = false): array + { + if (!$this->isOnline()) { + return []; + } + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + + $folderIdentifiers = $this->driver->getFoldersInFolder($folder->getIdentifier(), $start, $maxNumberOfItems, $recursive, $filters, $sort, $sortRev); + + // Exclude processing folders + foreach ($this->getProcessingFolders() as $processingFolder) { + $processingIdentifier = $processingFolder->getIdentifier(); + if (isset($folderIdentifiers[$processingIdentifier])) { + unset($folderIdentifiers[$processingIdentifier]); + } + } + + $folders = []; + foreach ($folderIdentifiers as $folderIdentifier) { + // The folder identifier can also be an int-like string, resulting in int array keys. + $folders[$folderIdentifier] = $this->getFolder($folderIdentifier, true); + } + return $folders; + } + + /** + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function countFoldersInFolder(Folder $folder, bool $useFilters = true, bool $recursive = false): int + { + $this->assureFolderReadPermission($folder); + $filters = $useFilters ? $this->getFileAndFolderNameFilters() : []; + return $this->driver->countFoldersInFolder($folder->getIdentifier(), $recursive, $filters); + } + + /** + * Returns TRUE if the specified folder exists. + */ + public function hasFolder(string $identifier): bool + { + $this->assureFolderReadPermission(); + return $this->driver->folderExists($identifier); + } + + /** + * Checks if the given file exists in the given folder + */ + public function hasFolderInFolder(string $folderName, Folder $folder): bool + { + $this->assureFolderReadPermission($folder); + return $this->driver->folderExistsInFolder($folderName, $folder->getIdentifier()); + } + + /** + * Creates a new folder. + * + * previously in \TYPO3\CMS\Core\Utility\File\ExtendedFileUtility::func_newfolder() + * + * @param string $folderName The new folder name + * @param Folder|null $parentFolder (optional) the parent folder to create the new folder inside of. If not given, the root folder is used + * @throws Exception\ExistingTargetFolderException + * @throws Exception\InsufficientFolderAccessPermissionsException + * @throws Exception\InsufficientFolderWritePermissionsException + * @throws \Exception + */ + public function createFolder(string $folderName, ?Folder $parentFolder = null): Folder + { + if ($parentFolder === null) { + $parentFolder = $this->getRootLevelFolder(); + } elseif (!$this->driver->folderExists($parentFolder->getIdentifier())) { + throw new \InvalidArgumentException('Parent folder "' . $parentFolder->getIdentifier() . '" does not exist.', 1325689164); + } + if (!$this->checkFolderActionPermission('add', $parentFolder)) { + throw new InsufficientFolderWritePermissionsException('You are not allowed to create directories in the folder "' . $parentFolder->getIdentifier() . '"', 1323059807); + } + if ($this->driver->folderExistsInFolder($folderName, $parentFolder->getIdentifier())) { + throw new ExistingTargetFolderException('Folder "' . $folderName . '" already exists.', 1423347324); + } + + $this->eventDispatcher->dispatch( + new BeforeFolderAddedEvent($parentFolder, $folderName) + ); + + $newFolder = $this->getDriver()->createFolder($folderName, $parentFolder->getIdentifier(), true); + $newFolder = $this->getFolder($newFolder); + + $this->eventDispatcher->dispatch( + new AfterFolderAddedEvent($newFolder) + ); + + return $newFolder; + } + + /** + * Retrieves information about a folder + */ + public function getFolderInfo(Folder $folder): array + { + return $this->driver->getFolderInfoByIdentifier($folder->getIdentifier()); + } + + /** + * Returns the default folder where new files are stored if no other folder is given. + */ + public function getDefaultFolder(): Folder + { + return $this->getFolder($this->driver->getDefaultFolder()); + } + + /** + * @throws \Exception + * @throws Exception\InsufficientFolderAccessPermissionsException + */ + public function getFolder(string $identifier, bool $returnInaccessibleFolderObject = false): Folder + { + $data = $this->driver->getFolderInfoByIdentifier($identifier); + $folder = $this->createFolderObject($data['identifier'], $data['name']); + + try { + $this->assureFolderReadPermission($folder); + } catch (InsufficientFolderAccessPermissionsException $e) { + $folder = null; + if ($returnInaccessibleFolderObject) { + // if parent folder is readable return inaccessible folder object + $parentPermissions = $this->driver->getPermissions($this->driver->getParentFolderIdentifierOfIdentifier($identifier)); + if ($parentPermissions['r']) { + $folder = new InaccessibleFolder( + $this, + $data['identifier'], + $data['name'] + ); + } + } + + if ($folder === null) { + throw $e; + } + } + return $folder; + } + + /** + * Returns TRUE if the specified file is in a folder that is set a processing for a storage + */ + public function isWithinProcessingFolder(string $identifier): bool + { + $inProcessingFolder = false; + foreach ($this->getProcessingFolders() as $processingFolder) { + if ($processingFolder->getStorage()->getDriver()->isWithin($processingFolder->getIdentifier(), $identifier)) { + $inProcessingFolder = true; + break; + } + } + return $inProcessingFolder; + } + + /** + * Checks if a resource (file or folder) is within the given folder + * + * @throws \InvalidArgumentException + */ + public function isWithinFolder(Folder $folder, ResourceInterface $resource): bool + { + if ($folder->getStorage() !== $this) { + throw new \InvalidArgumentException('Given folder "' . $folder->getIdentifier() . '" is not part of this storage!', 1422709241); + } + if ($folder->getStorage() !== $resource->getStorage()) { + return false; + } + return $this->driver->isWithin($folder->getIdentifier(), $resource->getIdentifier()); + } + + /** + * Returns the folder on the root level of the storage + * or the first mount point of this storage for this user + * if $respectFileMounts is set. + * + * @todo: this is a bad method design, because the calling code can never fetch all file mounts nor traverse them. + */ + public function getRootLevelFolder(bool $respectFileMounts = true): Folder + { + if ($respectFileMounts && !empty($this->fileMounts)) { + $mount = reset($this->fileMounts); + $rootLevelFolder = $mount['folder'] ?? null; + if ($rootLevelFolder instanceof Folder) { + return $rootLevelFolder; + } + } + return $this->createFolderObject($this->driver->getRootLevelFolder(), ''); + } + + /** + * Returns the destination path/fileName of a unique fileName/foldername in that path. + * If $theFile exists in $theDest (directory) the file have numbers appended up to $this->maxNumber. + * Hereafter a unique string will be appended. + * This function is used by fx. DataHandler when files are attached to records + * and needs to be uniquely named in the uploads/* folders + * + * @param string $theFile The input fileName to check + * @param bool $dontCheckForUnique If set the fileName is returned with the path prepended without checking whether it already existed! + * + * @throws \RuntimeException + * @return string A unique fileName inside $folder, based on $theFile. + * @see \TYPO3\CMS\Core\Utility\File\BasicFileUtility::getUniqueName() + */ + protected function getUniqueName(FolderInterface $folder, string $theFile, bool $dontCheckForUnique = false): string + { + $maxNumber = 99; + // Fetches info about path, name, extension of $theFile + $origFileInfo = PathUtility::pathinfo($theFile); + // Check if the file exists and if not - return the fileName... + // The destinations file + $theDestFile = $origFileInfo['basename']; + // If the file does NOT exist we return this fileName + if ($dontCheckForUnique || (!$this->driver->fileExistsInFolder($theDestFile, $folder->getIdentifier()) && !$this->driver->folderExistsInFolder($theDestFile, $folder->getIdentifier()))) { + return $theDestFile; + } + // Well the fileName in its pure form existed. Now we try to append + // numbers / unique-strings and see if we can find an available fileName + // This removes _xx if appended to the file + $theTempFileBody = preg_replace('/_[0-9][0-9]$/', '', $origFileInfo['filename']); + $theOrigExt = ($origFileInfo['extension'] ?? '') ? '.' . $origFileInfo['extension'] : ''; + for ($a = 1; $a <= $maxNumber; $a++) { + // First we try to append numbers + $insert = '_' . sprintf('%02d', $a); + $theTestFile = $theTempFileBody . $insert . $theOrigExt; + // The destinations file + $theDestFile = $theTestFile; + // If the file does NOT exist we return this fileName + if (!$this->driver->fileExistsInFolder($theDestFile, $folder->getIdentifier()) && !$this->driver->folderExistsInFolder($theDestFile, $folder->getIdentifier())) { + return $theDestFile; + } + } + + $tries = 0; + do { + $insert = '_' . substr(md5(StringUtility::getUniqueId()), 0, 6); + $theDestFile = $theTempFileBody . $insert . $theOrigExt; + + if ($folder->hasFile($theDestFile) || $folder->hasFolder($theDestFile)) { + continue; + } + + return $theDestFile; + } while ($tries++ < 99); + + throw new \RuntimeException('Last possible name "' . $theDestFile . '" is already taken.', 1325194291); + } + + protected function getFileFactory(): ResourceFactory + { + return GeneralUtility::makeInstance(ResourceFactory::class); + } + + protected function getFileIndexRepository(): FileIndexRepository + { + return GeneralUtility::makeInstance(FileIndexRepository::class); + } + + protected function getFileProcessingService(): FileProcessingService + { + if (!$this->fileProcessingService) { + $this->fileProcessingService = GeneralUtility::makeInstance(FileProcessingService::class); + } + return $this->fileProcessingService; + } + + /** + * Gets the role of a folder. + * + * @param FolderInterface $folder Folder object to get the role from + * @return string The role the folder has + */ + public function getRole(FolderInterface $folder): string + { + $folderRole = FolderInterface::ROLE_DEFAULT; + $identifier = $folder->getIdentifier(); + if (method_exists($this->driver, 'getRole')) { + $folderRole = $this->driver->getRole($folder->getIdentifier()); + } + if (isset($this->fileMounts[$identifier])) { + $folderRole = FolderInterface::ROLE_MOUNT; + + if (!empty($this->fileMounts[$identifier]['read_only'])) { + $folderRole = FolderInterface::ROLE_READONLY_MOUNT; + } + if ($this->fileMounts[$identifier]['user_mount'] ?? false) { + $folderRole = FolderInterface::ROLE_USER_MOUNT; + } + } + if ($this->isOnline() && $folder instanceof Folder && $this->isProcessingFolder($folder)) { + $folderRole = FolderInterface::ROLE_PROCESSING; + } + + return $folderRole; + } + + /** + * Getter function to return the folder where the files can + * be processed. Does not check for access rights here. + * + * @param File|null $file Specific file you want to have the processing folder for + */ + public function getProcessingFolder(?File $file = null): Folder + { + // If a file is given, make sure to return the processing folder of the correct storage + if ($file !== null && $file->getStorage()->getUid() !== $this->getUid()) { + return $file->getStorage()->getProcessingFolder($file); + } + if (!isset($this->processingFolder)) { + $processingFolder = self::DEFAULT_ProcessingFolder; + if (!empty($this->storageRecord['processingfolder'])) { + $processingFolder = $this->storageRecord['processingfolder']; + } + try { + if (str_contains($processingFolder, ':')) { + [$storageUid, $processingFolderIdentifier] = explode(':', $processingFolder, 2); + $storage = GeneralUtility::makeInstance(StorageRepository::class)->findByUid((int)$storageUid); + if ($storage->hasFolder($processingFolderIdentifier)) { + $this->processingFolder = $storage->getFolder($processingFolderIdentifier); + } else { + $rootFolder = $storage->getRootLevelFolder(false); + $currentEvaluatePermissions = $storage->getEvaluatePermissions(); + $storage->setEvaluatePermissions(false); + $this->processingFolder = $storage->createFolder( + ltrim($processingFolderIdentifier, '/'), + $rootFolder + ); + $storage->setEvaluatePermissions($currentEvaluatePermissions); + } + } else { + if ($this->driver->folderExists($processingFolder) === false) { + $rootFolder = $this->getRootLevelFolder(false); + try { + $currentEvaluatePermissions = $this->evaluatePermissions; + $this->evaluatePermissions = false; + $this->processingFolder = $this->createFolder( + $processingFolder, + $rootFolder + ); + $this->evaluatePermissions = $currentEvaluatePermissions; + } catch (\InvalidArgumentException $e) { + $this->processingFolder = GeneralUtility::makeInstance( + InaccessibleFolder::class, + $this, + $processingFolder, + $processingFolder + ); + } + } else { + $data = $this->driver->getFolderInfoByIdentifier($processingFolder); + $this->processingFolder = $this->createFolderObject($data['identifier'], $data['name']); + } + } + } catch (InsufficientFolderWritePermissionsException|ResourcePermissionsUnavailableException $e) { + $this->processingFolder = GeneralUtility::makeInstance( + InaccessibleFolder::class, + $this, + $processingFolder, + $processingFolder + ); + } + } + + $processingFolder = $this->processingFolder; + if (!empty($file)) { + $processingFolder = $this->getNestedProcessingFolder($file, $processingFolder); + } + return $processingFolder; + } + + /** + * Getter function to return the file's corresponding hashed subfolder + * of the processed folder. + * + * @throws Exception\InsufficientFolderWritePermissionsException + */ + protected function getNestedProcessingFolder(File $file, Folder $rootProcessingFolder): Folder + { + $processingFolder = $rootProcessingFolder; + $nestedFolderNames = $this->getNamesForNestedProcessingFolder($file->getIdentifier(), self::PROCESSING_FOLDER_LEVELS); + foreach ($nestedFolderNames as $folderName) { + try { + $processingFolder = $processingFolder->getSubfolder($folderName); + } catch (FolderDoesNotExistException) { + $currentEvaluatePermissions = $processingFolder->getStorage()->getEvaluatePermissions(); + $processingFolder->getStorage()->setEvaluatePermissions(false); + + try { + $processingFolder = $processingFolder->createFolder($folderName); + } catch (ExistingTargetFolderException) { + // The folder may have been created meanwhile in a parallel process, which is fine, we take it. + $processingFolder = $processingFolder->getSubfolder($folderName); + } + + $processingFolder->getStorage()->setEvaluatePermissions($currentEvaluatePermissions); + } + } + return $processingFolder; + } + + /** + * Generates appropriate hashed sub-folder path for a given file identifier. + * + * @return string[] + */ + protected function getNamesForNestedProcessingFolder(string $fileIdentifier, int $levels): array + { + $names = []; + if ($levels === 0) { + return $names; + } + $hash = md5($fileIdentifier); + for ($i = 1; $i <= $levels; $i++) { + $names[] = substr($hash, $i, 1); + } + return $names; + } + + /** + * Gets the driver Type configured for this storage. + */ + public function getDriverType(): string + { + return $this->storageRecord['driver']; + } + + protected function getIndexer(): Indexer + { + return GeneralUtility::makeInstance(Indexer::class, $this); + } + + public function setDefault(bool $isDefault): void + { + $this->isDefault = $isDefault; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function getResourceFactoryInstance(): ResourceFactory + { + return GeneralUtility::makeInstance(ResourceFactory::class); + } + + /** + * Get the nearest Recycler folder for given file or folder + * + * Return null if: + * - There is no folder with ROLE_RECYCLER in the rootline of the given Resource + * - Resource is a ProcessedFile (we don't know the concept of recycler folders for processedFiles) + * - Resource is located in a folder with ROLE_RECYCLER + */ + protected function getNearestRecyclerFolder(ResourceInterface $resource): ?Folder + { + if ($resource instanceof ProcessedFile) { + return null; + } + // if the storage is not browsable we cannot fetch the parent folder of the file so no recycler handling is possible + if (!$this->isBrowsable()) { + return null; + } + + $recyclerFolder = null; + $folder = $resource->getParentFolder(); + + do { + // This can be removed once ->getRole() is implemented in FolderInterface + if (!$folder instanceof Folder) { + break; + } + if ($folder->getRole() === FolderInterface::ROLE_RECYCLER) { + break; + } + + foreach ($folder->getSubfolders() as $subFolder) { + // do not use a _recycler_ as the trash bin for itself + if ($subFolder->getIdentifier() === $resource->getIdentifier()) { + continue; + } + + if ($subFolder->getRole() === FolderInterface::ROLE_RECYCLER) { + $recyclerFolder = $subFolder; + break; + } + } + + $parentFolder = $folder->getParentFolder(); + $isFolderLoop = $folder->getIdentifier() === $parentFolder->getIdentifier(); + $folder = $parentFolder; + } while ($recyclerFolder === null && !$isFolderLoop); + + return $recyclerFolder; + } + + /** + * Creates a folder to directly access (a part of) a storage. + * + * @param string $identifier The path to the folder. Might also be a simple unique string, depending on the storage driver. + * @param string $name The name of the folder (e.g. the folder name) + */ + protected function createFolderObject(string $identifier, string $name): Folder + { + return GeneralUtility::makeInstance(Folder::class, $this, $identifier, $name); + } +} diff --git a/Classes/Resource/ResourceStorageInterface.php b/Classes/Resource/ResourceStorageInterface.php new file mode 100644 index 0000000..9b9c556 --- /dev/null +++ b/Classes/Resource/ResourceStorageInterface.php @@ -0,0 +1,27 @@ +<?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\Core\Resource; + +/** + * The interface for a resource storage containing all constants + */ +interface ResourceStorageInterface +{ + /** + * Name of the default processing folder + */ + public const DEFAULT_ProcessingFolder = '_processed_'; +} diff --git a/Classes/Resource/Search/FileSearchDemand.php b/Classes/Resource/Search/FileSearchDemand.php new file mode 100644 index 0000000..b6b48dc --- /dev/null +++ b/Classes/Resource/Search/FileSearchDemand.php @@ -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\Core\Resource\Search; + +use TYPO3\CMS\Core\Resource\Folder; + +/** + * Immutable value object that represents a search demand for files. + */ +class FileSearchDemand +{ + private ?string $searchTerm; + private ?Folder $folder = null; + private ?int $firstResult = null; + private ?int $maxResults = null; + private ?array $searchFields = null; + private ?array $orderings = null; + private bool $recursive = false; + + /** + * Only factory methods are allowed to be used to create this object + */ + private function __construct(?string $searchTerm = null) + { + $this->searchTerm = $searchTerm; + } + + public static function create(): self + { + return new self(); + } + + public static function createForSearchTerm(string $searchTerm): self + { + return new self($searchTerm); + } + + public function getSearchTerm(): ?string + { + return $this->searchTerm; + } + + public function hasSearchTerm(): bool + { + return $this->searchTerm !== null; + } + + public function getFolder(): ?Folder + { + return $this->folder; + } + + public function getFirstResult(): ?int + { + return $this->firstResult; + } + + public function getMaxResults(): ?int + { + return $this->maxResults; + } + + public function getSearchFields(): ?array + { + return $this->searchFields; + } + + public function getOrderings(): ?array + { + return $this->orderings; + } + + public function isRecursive(): bool + { + return $this->recursive; + } + + public function withSearchTerm(string $searchTerm): self + { + $demand = clone $this; + $demand->searchTerm = $searchTerm; + + return $demand; + } + + public function withFolder(Folder $folder): self + { + $demand = clone $this; + $demand->folder = $folder; + + return $demand; + } + + /** + * Requests the position of the first result to retrieve (the "offset"). + * Same as in QueryBuilder it is the index of the result set, with 0 being the first result. + */ + public function withStartResult(int $firstResult): self + { + $demand = clone $this; + $demand->firstResult = $firstResult; + + return $demand; + } + + public function withMaxResults(int $maxResults): self + { + $demand = clone $this; + $demand->maxResults = $maxResults; + + return $demand; + } + + public function addSearchField(string $tableName, string $field): self + { + $demand = clone $this; + $demand->searchFields[$tableName][] = $field; + + return $demand; + } + + public function addOrdering(string $tableName, string $fieldName, string $direction = 'ASC'): self + { + $demand = clone $this; + $demand->orderings[] = [$tableName, $fieldName, $direction]; + + return $demand; + } + + public function withRecursive(): self + { + $demand = clone $this; + $demand->recursive = true; + + return $demand; + } +} diff --git a/Classes/Resource/Search/FileSearchQuery.php b/Classes/Resource/Search/FileSearchQuery.php new file mode 100644 index 0000000..6b6c032 --- /dev/null +++ b/Classes/Resource/Search/FileSearchQuery.php @@ -0,0 +1,212 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search; + +use Doctrine\DBAL\Result; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\QueryHelper; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Resource\Search\QueryRestrictions\ConsistencyRestriction; +use TYPO3\CMS\Core\Resource\Search\QueryRestrictions\FolderMountsRestriction; +use TYPO3\CMS\Core\Resource\Search\QueryRestrictions\FolderRestriction; +use TYPO3\CMS\Core\Resource\Search\QueryRestrictions\SearchTermRestriction; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Represents an SQL query to search for files. + * Acts as facade to a QueryBuilder and comes with factory methods + * to preconfigure the query for a search demand. + */ +class FileSearchQuery +{ + private const string FILES_TABLE = 'sys_file'; + + private const string FILES_META_TABLE = 'sys_file_metadata'; + + private QueryBuilder $queryBuilder; + + /** + * @var QueryRestrictionInterface[] + */ + private array $additionalRestrictions = []; + + private ?Result $result = null; + + private TcaSchemaFactory $tcaSchemaFactory; + + public function __construct(?QueryBuilder $queryBuilder = null) + { + $this->queryBuilder = $queryBuilder ?? GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::FILES_TABLE); + $this->tcaSchemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + } + + /** + * Prepares a query based on a search demand to be used to fetch rows. + */ + public static function createForSearchDemand(FileSearchDemand $searchDemand, ?QueryBuilder $queryBuilder = null): self + { + $query = new self($queryBuilder); + $query->additionalRestriction( + new SearchTermRestriction($searchDemand, $query->queryBuilder) + ); + $folder = $searchDemand->getFolder(); + if ($folder !== null) { + $query->additionalRestriction( + new FolderRestriction($folder, $searchDemand->isRecursive()) + ); + } else { + $query->additionalRestriction( + new FolderMountsRestriction($GLOBALS['BE_USER']) + ); + } + + $query->queryBuilder->getConcreteQueryBuilder()->select( + 'DISTINCT ' . $query->queryBuilder->quoteIdentifier(self::FILES_TABLE . '.identifier'), + $query->queryBuilder->quoteIdentifier(self::FILES_TABLE) . '.*', + ); + + if ($searchDemand->getFirstResult() !== null) { + $query->queryBuilder->setFirstResult($searchDemand->getFirstResult()); + } + if ($searchDemand->getMaxResults() !== null) { + $query->queryBuilder->setMaxResults($searchDemand->getMaxResults()); + } + + if ($searchDemand->getOrderings() === null) { + $schema = $query->tcaSchemaFactory->get(self::FILES_TABLE); + if ($schema->hasCapability(TcaSchemaCapability::SortByField)) { + $orderBy = $schema->getCapability(TcaSchemaCapability::SortByField)->getFieldName(); + } elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) { + $orderBy = $schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue(); + } else { + $orderBy = ''; + } + foreach (QueryHelper::parseOrderBy($orderBy) as [$fieldName, $order]) { + if (is_string($fieldName) && $fieldName !== '') { + // Call add ordering only for valid field names + $searchDemand = $searchDemand->addOrdering(self::FILES_TABLE, $fieldName, $order ?? 'ASC'); + } + } + } + foreach ($searchDemand->getOrderings() as [$tableName, $fieldName, $direction]) { + if (!$query->tcaSchemaFactory->has($tableName) + || !$query->tcaSchemaFactory->get($tableName)->hasField($fieldName) + || !in_array($direction, ['ASC', 'DESC'], true)) { + // This exception is essential to avoid SQL injections based on ordering field names, which could be input controlled by an attacker. + throw new \RuntimeException(sprintf('Invalid file search ordering given table: "%s", field: "%s", direction: "%s".', $tableName, $fieldName, $direction), 1555850106); + } + // Add order by fields to select, to make postgres happy and use random names to make sure to not interfere with file fields + $query->queryBuilder->getConcreteQueryBuilder()->addSelect( + ...$query->queryBuilder->quoteIdentifiersForSelect([ + $tableName . '.' . $fieldName + . ' AS ' + . preg_replace( + '/[^a-z0-9]/', + '', + StringUtility::getUniqueId($tableName . $fieldName) + ), + ]) + ); + $query->queryBuilder->addOrderBy($tableName . '.' . $fieldName, $direction); + } + + return $query; + } + + /** + * Prepares a query based on a search demand to be used to count rows. + */ + public static function createCountForSearchDemand(FileSearchDemand $searchDemand, ?QueryBuilder $queryBuilder = null): self + { + $query = new self($queryBuilder); + $query->additionalRestriction( + new SearchTermRestriction($searchDemand, $query->queryBuilder) + ); + $folder = $searchDemand->getFolder(); + if ($folder !== null) { + $query->additionalRestriction( + new FolderRestriction($folder, $searchDemand->isRecursive()) + ); + } + + $query->queryBuilder->getConcreteQueryBuilder()->select( + 'COUNT(DISTINCT ' . $query->queryBuilder->quoteIdentifier(self::FILES_TABLE . '.identifier') . ')' + ); + + return $query; + } + + /** + * Limit the result set of identifiers, by adding further SQL restrictions. + * Note that no further restrictions can be added once result is initialized, + * by starting the iteration over the result. + * Can be accessed by subclasses to add further restrictions to the query. + * + * @throws \RuntimeException + */ + public function additionalRestriction(QueryRestrictionInterface $additionalRestriction): void + { + $this->ensureQueryNotExecuted(); + $this->additionalRestrictions[get_class($additionalRestriction)] = $additionalRestriction; + } + + /** + * @return Result + */ + public function execute() + { + if ($this->result === null) { + $this->initializeQueryBuilder(); + $this->result = $this->queryBuilder->executeQuery(); + } + + return $this->result; + } + + /** + * Create and initialize QueryBuilder for SQL based file search. + * Can be accessed by subclasses for example to add further joins to the query. + */ + private function initializeQueryBuilder(): void + { + $this->queryBuilder->from(self::FILES_TABLE); + $this->queryBuilder->join( + self::FILES_TABLE, + self::FILES_META_TABLE, + self::FILES_META_TABLE, + $this->queryBuilder->expr()->eq(self::FILES_META_TABLE . '.file', $this->queryBuilder->quoteIdentifier(self::FILES_TABLE . '.uid')) + ); + + $restrictionContainer = $this->queryBuilder->getRestrictions() + ->add(new ConsistencyRestriction($this->queryBuilder)); + foreach ($this->additionalRestrictions as $additionalRestriction) { + $restrictionContainer->add($additionalRestriction); + } + } + + private function ensureQueryNotExecuted(): void + { + if ($this->result !== null) { + throw new \RuntimeException('Cannot modify file query once it was executed. Create a new query instead.', 1555944032); + } + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/ConsistencyRestriction.php b/Classes/Resource/Search/QueryRestrictions/ConsistencyRestriction.php new file mode 100644 index 0000000..b55b8b4 --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/ConsistencyRestriction.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; + +/** + * Filters missing files from search result + */ +class ConsistencyRestriction implements QueryRestrictionInterface +{ + /** + * @var QueryBuilder + */ + private $queryBuilder; + + public function __construct(QueryBuilder $queryBuilder) + { + $this->queryBuilder = $queryBuilder; + } + + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName === 'sys_file') { + $constraints[] = $this->queryBuilder->expr()->eq($tableAlias . '.missing', $this->queryBuilder->createNamedParameter(0, Connection::PARAM_INT)); + } + } + + return $expressionBuilder->and(...$constraints); + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/FolderHashesRestriction.php b/Classes/Resource/Search/QueryRestrictions/FolderHashesRestriction.php new file mode 100644 index 0000000..f272180 --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/FolderHashesRestriction.php @@ -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\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Limits search result to files with given folder hashes + */ +class FolderHashesRestriction implements QueryRestrictionInterface +{ + /** + * @var array + */ + private $folderHashes; + + public function __construct(array $folderHashes) + { + $this->folderHashes = $folderHashes; + } + + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName !== 'sys_file') { + continue; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName); + $quotedHashes = array_map($connection->quote(...), $this->folderHashes); + $constraints[] = $expressionBuilder->in($tableAlias . '.folder_hash', $quotedHashes); + } + + return $expressionBuilder->or(...$constraints); + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/FolderIdentifierRestriction.php b/Classes/Resource/Search/QueryRestrictions/FolderIdentifierRestriction.php new file mode 100644 index 0000000..92a0f94 --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/FolderIdentifierRestriction.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Assumes identifiers carrying hierarchical information and + * filters files with identifiers starting with given identifier. + */ +class FolderIdentifierRestriction implements QueryRestrictionInterface +{ + /** + * @var string + */ + private $folderIdentifier; + + public function __construct(string $folderIdentifier) + { + $this->folderIdentifier = $folderIdentifier; + } + + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName !== 'sys_file') { + continue; + } + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName); + $folderIdentifier = $connection->createQueryBuilder()->escapeLikeWildcards($this->folderIdentifier); + $constraints[] = $expressionBuilder->like( + $tableAlias . '.identifier', + $connection->quote($folderIdentifier . '%') + ); + } + + return $expressionBuilder->or(...$constraints); + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/FolderMountsRestriction.php b/Classes/Resource/Search/QueryRestrictions/FolderMountsRestriction.php new file mode 100644 index 0000000..c045916 --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/FolderMountsRestriction.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\AbstractRestrictionContainer; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Restricts the result to available file mounts. + * No restriction is added if the user is admin. + */ +class FolderMountsRestriction extends AbstractRestrictionContainer +{ + /** + * @var BackendUserAuthentication + */ + private $backendUser; + + /** + * @var Folder[]|null + */ + private $folderMounts; + + public function __construct(BackendUserAuthentication $backendUser) + { + $this->backendUser = $backendUser; + $this->populateRestrictions(); + } + + private function populateRestrictions(): void + { + if ($this->backendUser->isAdmin()) { + return; + } + foreach ($this->getFolderMounts() as $folder) { + $this->add(new FolderRestriction($folder, true)); + } + } + + /** + * Same as parent method, but using OR composite, as files in either mounted folder should be found. + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + if (!$this->backendUser->isAdmin() && empty($this->getFolderMounts())) { + // If the user isn't an admin but has no mounted folders, add an expression leading to an empty result + return $expressionBuilder->and('1=0'); + } + $constraints = []; + foreach ($this->restrictions as $restriction) { + $constraints[] = $restriction->buildExpression($queriedTables, $expressionBuilder); + } + return $expressionBuilder->or(...$constraints); + } + + /** + * @return Folder[] + */ + private function getFolderMounts(): array + { + if ($this->folderMounts !== null) { + return $this->folderMounts; + } + $this->folderMounts = []; + $fileMounts = $this->backendUser->getFileMountRecords(); + foreach ($fileMounts as $fileMount) { + $this->folderMounts[] = GeneralUtility::makeInstance(ResourceFactory::class)->getFolderObjectFromCombinedIdentifier($fileMount['identifier'] ?? ''); + } + + return $this->folderMounts; + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/FolderRestriction.php b/Classes/Resource/Search/QueryRestrictions/FolderRestriction.php new file mode 100644 index 0000000..c1ba579 --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/FolderRestriction.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\Query\Restriction\AbstractRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Resource\Folder; + +/** + * Limits result to storage given by the folder + * and also restricts result to the given folder, respecting whether the storage + * has hierarchical identifiers or not. + */ +class FolderRestriction extends AbstractRestrictionContainer +{ + /** + * @var Folder + */ + private $folder; + + /** + * @var bool + */ + private $recursive; + + public function __construct(Folder $folder, bool $recursive) + { + $this->folder = $folder; + $this->recursive = $recursive; + $this->populateRestrictions(); + } + + private function populateRestrictions(): void + { + $storage = $this->folder->getStorage(); + $this->add(new StorageRestriction($storage)); + if (!$this->recursive) { + $this->add($this->createFolderRestriction()); + return; + } + if ($this->folder->getIdentifier() === $storage->getRootLevelFolder(false)->getIdentifier()) { + return; + } + if ($storage->hasHierarchicalIdentifiers()) { + $this->add($this->createHierarchicalFolderRestriction()); + } else { + $this->add($this->createFolderRestriction()); + } + } + + private function createHierarchicalFolderRestriction(): QueryRestrictionInterface + { + return $this->recursive ? new FolderIdentifierRestriction($this->folder->getIdentifier()) : new FolderHashesRestriction([$this->folder->getHashedIdentifier()]); + } + + private function createFolderRestriction(): QueryRestrictionInterface + { + $hashedFolderIdentifiers = []; + $hashedFolderIdentifiers[] = $this->folder->getHashedIdentifier(); + if ($this->recursive) { + foreach ($this->folder->getSubfolders(0, 0, Folder::FILTER_MODE_NO_FILTERS, true) as $subFolder) { + $hashedFolderIdentifiers[] = $subFolder->getHashedIdentifier(); + } + } + + return new FolderHashesRestriction($hashedFolderIdentifiers); + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php b/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php new file mode 100644 index 0000000..95bb1aa --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/SearchTermRestriction.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Resource\Search\FileSearchDemand; +use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Filters result by a given search term, respecting search fields defined in search demand or in TCA. + */ +readonly class SearchTermRestriction implements QueryRestrictionInterface +{ + public function __construct( + private FileSearchDemand $searchDemand, + private QueryBuilder $queryBuilder, + ) {} + + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if (!in_array($tableName, ['sys_file', 'sys_file_metadata'])) { + continue; + } + $constraints[] = $this->makeQuerySearchByTable($tableName, $tableAlias); + } + + return $expressionBuilder->or(...$constraints); + } + + /** + * Build the MySql where clause by table. + * + * @param string $tableName Record table name + */ + private function makeQuerySearchByTable(string $tableName, string $tableAlias): CompositeExpression + { + $constraints = []; + $fieldsToSearchWithin = GeneralUtility::makeInstance(SearchableSchemaFieldsCollector::class)->getFields( + $tableName, + $this->searchDemand->getSearchFields()[$tableName] ?? [] + ); + if ($fieldsToSearchWithin->count() > 0) { + $searchTerm = (string)$this->searchDemand->getSearchTerm(); + $searchTermParts = str_getcsv($searchTerm, ' ', '"', '\\'); + foreach ($searchTermParts as $searchTermPart) { + $searchTermPart = trim($searchTermPart); + if ($searchTermPart === '') { + continue; + } + $constraintsForParts = []; + $like = '%' . $this->queryBuilder->escapeLikeWildcards($searchTermPart) . '%'; + foreach ($fieldsToSearchWithin as $fieldName => $field) { + $constraintsForParts[] = $this->queryBuilder->expr()->and( + $this->queryBuilder->expr()->comparison( + sprintf( + 'LOWER(%s)', + // Ensure to cast `$fieldName` to a text value, otherwise picky databases like + // postgres would complain about trying to use `LOWER()` on incompatible field + // like integer fields, something MariaDB/MySQL is silently allowed and hidden + // away from the consumer. We avoid doing database field type checks here for + // all or specific database and adding a value conversion by default for all + // fields to be on the safe side. + // + // The lower() construct here is used to enforce "case-insensitive" search for + // all database vendors unrelated to charset/collation configurations on field + // level. + $this->queryBuilder->expr()->castText($this->queryBuilder->quoteIdentifier($tableAlias . '.' . $fieldName)) + ), + 'LIKE', + $this->queryBuilder->createNamedParameter(mb_strtolower($like)) + ) + ); + } + $constraints[] = $this->queryBuilder->expr()->or(...$constraintsForParts); + } + } + + return $this->queryBuilder->expr()->and(...$constraints); + } +} diff --git a/Classes/Resource/Search/QueryRestrictions/StorageRestriction.php b/Classes/Resource/Search/QueryRestrictions/StorageRestriction.php new file mode 100644 index 0000000..bf15acc --- /dev/null +++ b/Classes/Resource/Search/QueryRestrictions/StorageRestriction.php @@ -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\Core\Resource\Search\QueryRestrictions; + +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface; +use TYPO3\CMS\Core\Resource\ResourceStorage; + +/** + * Limits search result to a give storage + */ +class StorageRestriction implements QueryRestrictionInterface +{ + /** + * @var ResourceStorage + */ + private $storage; + + public function __construct(ResourceStorage $storage) + { + $this->storage = $storage; + } + + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($queriedTables as $tableAlias => $tableName) { + if ($tableName !== 'sys_file') { + continue; + } + $constraints[] = $expressionBuilder->eq( + $tableAlias . '.storage', + (int)$this->storage->getUid() + ); + } + + return $expressionBuilder->or(...$constraints); + } +} diff --git a/Classes/Resource/Search/Result/DriverFilteredSearchResult.php b/Classes/Resource/Search/Result/DriverFilteredSearchResult.php new file mode 100644 index 0000000..138e67f --- /dev/null +++ b/Classes/Resource/Search/Result/DriverFilteredSearchResult.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\Result; + +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Decorator for a search result with files, which filters + * the result based on given filters. + */ +class DriverFilteredSearchResult implements FileSearchResultInterface +{ + private ?array $result = null; + + public function __construct( + private readonly FileSearchResultInterface $searchResult, + private readonly DriverInterface $driver, + /** + * @var callable[] + */ + private readonly array $filters + ) {} + + /** + * @see Countable::count() + */ + public function count(): int + { + $this->initialize(); + + return count($this->result); + } + + /** + * @see Iterator::current() + */ + public function current(): File + { + $this->initialize(); + + return current($this->result); + } + + /** + * @see Iterator::key() + */ + public function key(): int + { + $this->initialize(); + + return key($this->result); + } + + /** + * @see Iterator::next() + */ + public function next(): void + { + $this->initialize(); + next($this->result); + } + + /** + * @see Iterator::rewind() + */ + public function rewind(): void + { + $this->initialize(); + reset($this->result); + } + + /** + * @see Iterator::valid() + */ + public function valid(): bool + { + $this->initialize(); + + return current($this->result) !== false; + } + + private function initialize(): void + { + if ($this->result === null) { + $this->result = $this->applyFilters(...iterator_to_array($this->searchResult)); + } + } + + /** + * Filter out identifiers by calling all attached filters + * + * @return array<int, File> + */ + private function applyFilters(File ...$files): array + { + $filteredFiles = []; + foreach ($files as $file) { + $itemIdentifier = $file->getIdentifier(); + $itemName = PathUtility::basename($itemIdentifier); + $parentIdentifier = PathUtility::dirname($itemIdentifier); + $matches = true; + foreach ($this->filters as $filter) { + if (!is_callable($filter)) { + continue; + } + $result = $filter($itemName, $itemIdentifier, $parentIdentifier, [], $this->driver); + // We use -1 as the "don't include“ return value, for historic reasons, + // as call_user_func() used to return FALSE if calling the method failed. + if ($result === -1) { + $matches = false; + } + if ($result === false) { + throw new \RuntimeException( + 'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1], + 1543617278 + ); + } + } + if ($matches) { + $filteredFiles[] = $file; + } + } + + return $filteredFiles; + } +} diff --git a/Classes/Resource/Search/Result/EmptyFileSearchResult.php b/Classes/Resource/Search/Result/EmptyFileSearchResult.php new file mode 100644 index 0000000..c7c6bbb --- /dev/null +++ b/Classes/Resource/Search/Result/EmptyFileSearchResult.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\Result; + +/** + * Represents an empty search result (no matches found) + */ +class EmptyFileSearchResult implements FileSearchResultInterface +{ + public function count(): int + { + return 0; + } + + /** + * @phpstan-return null + */ + public function current(): mixed + { + // Noop + return null; + } + + /** + * @phpstan-return null + */ + public function key(): mixed + { + // Noop + return null; + } + + public function next(): void + { + // Noop + } + + public function rewind(): void + { + // Noop + } + + /** + * @return false + */ + public function valid(): bool + { + return false; + } +} diff --git a/Classes/Resource/Search/Result/FileSearchResult.php b/Classes/Resource/Search/Result/FileSearchResult.php new file mode 100644 index 0000000..dd5bd0f --- /dev/null +++ b/Classes/Resource/Search/Result/FileSearchResult.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\Result; + +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Resource\Search\FileSearchDemand; +use TYPO3\CMS\Core\Resource\Search\FileSearchQuery; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Represents a search result for a given search query + * being an iterable and countable list of file objects. + */ +class FileSearchResult implements FileSearchResultInterface +{ + /** + * @var FileSearchDemand + */ + private $searchDemand; + + /** + * @var array + */ + private $result; + + /** + * @var int + */ + private $resultCount; + + public function __construct(FileSearchDemand $searchDemand) + { + $this->searchDemand = $searchDemand; + } + + /** + * @see Countable::count() + */ + public function count(): int + { + if ($this->resultCount !== null) { + return $this->resultCount; + } + + $this->resultCount = (int)FileSearchQuery::createCountForSearchDemand($this->searchDemand)->execute()->fetchOne(); + + return $this->resultCount; + } + + /** + * @see Iterator::current() + */ + public function current(): File + { + $this->initialize(); + return current($this->result); + } + + /** + * @see Iterator::key() + */ + public function key(): int + { + $this->initialize(); + return key($this->result); + } + + /** + * @see Iterator::next() + */ + public function next(): void + { + $this->initialize(); + next($this->result); + } + + /** + * @see Iterator::rewind() + */ + public function rewind(): void + { + $this->initialize(); + reset($this->result); + } + + /** + * @see Iterator::valid() + */ + public function valid(): bool + { + $this->initialize(); + return current($this->result) !== false; + } + + /** + * Perform the SQL query and apply filters on the resulting identifiers + */ + private function initialize(): void + { + if ($this->result !== null) { + return; + } + $this->result = FileSearchQuery::createForSearchDemand($this->searchDemand)->execute()->fetchAllAssociative(); + $this->resultCount = count($this->result); + $this->result = array_map( + static function (array $fileRow): File { + return GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileRow['uid'], $fileRow); + }, + $this->result + ); + } +} diff --git a/Classes/Resource/Search/Result/FileSearchResultInterface.php b/Classes/Resource/Search/Result/FileSearchResultInterface.php new file mode 100644 index 0000000..9c329b5 --- /dev/null +++ b/Classes/Resource/Search/Result/FileSearchResultInterface.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Search\Result; + +/** + * Representation of a result for a search for files performed by FileSearchQuery, + * which is a collection of matching files. + */ +interface FileSearchResultInterface extends \Countable, \Iterator {} diff --git a/Classes/Resource/Security/FileMetadataPermissionsAspect.php b/Classes/Resource/Security/FileMetadataPermissionsAspect.php new file mode 100644 index 0000000..0dcc25e --- /dev/null +++ b/Classes/Resource/Security/FileMetadataPermissionsAspect.php @@ -0,0 +1,148 @@ +<?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\Core\Resource\Security; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\DataHandling\DataHandlerCheckModifyAccessListHookInterface; +use TYPO3\CMS\Core\Resource\ResourceFactory; + +/** + * Dealing with file metadata data security is an assembly of hooks to + * check permissions on files belonging to file metadata records + */ +#[Autoconfigure(public: true)] +readonly class FileMetadataPermissionsAspect implements DataHandlerCheckModifyAccessListHookInterface +{ + public function __construct( + private ResourceFactory $resourceFactory, + ) {} + + /** + * This hook is called before any write operation by DataHandler + * + * @param string $table + * @param int $id + * @param array $fileMetadataRecord + * @param int|null $otherHookGrantedAccess + * @return int|null + */ + public function checkRecordUpdateAccess($table, $id, $fileMetadataRecord, $otherHookGrantedAccess, DataHandler $dataHandler) + { + $accessAllowed = $otherHookGrantedAccess; + if ($table === 'sys_file_metadata' && $accessAllowed !== 0) { + $existingFileMetadataRecord = BackendUtility::getRecord('sys_file_metadata', $id); + if ($existingFileMetadataRecord === null || (empty($existingFileMetadataRecord['file']) && !empty($fileMetadataRecord['file']))) { + $existingFileMetadataRecord = $fileMetadataRecord; + } + $accessAllowed = $this->checkFileWriteAccessForFileMetaData($existingFileMetadataRecord) ? 1 : 0; + } + + return $accessAllowed; + } + + /** + * Hook that determines whether a user has access to modify a table. + * We "abuse" it here to actually check if access is allowed to sys_file_metadata. + * + * @param bool $accessAllowed Whether the user has access to modify a table + * @param string $table The name of the table to be modified + */ + public function checkModifyAccessList(&$accessAllowed, $table, DataHandler $parent): void + { + if ($table !== 'sys_file_metadata') { + return; + } + foreach (($parent->cmdmap['sys_file_metadata'] ?? []) as $id => $command) { + $fileMetadataRecord = (array)BackendUtility::getRecord('sys_file_metadata', (int)$id); + $accessAllowed = $this->checkFileWriteAccessForFileMetaData($fileMetadataRecord); + if (!$accessAllowed) { + // If for any item in the array, access is not allowed, we deny the whole operation + break; + } + } + if (isset($parent->datamap[$table])) { + foreach ($parent->datamap[$table] as $id => $data) { + $recordAccessAllowed = false; + if (!str_contains((string)$id, 'NEW')) { + $fileMetadataRecord = BackendUtility::getRecord('sys_file_metadata', (int)$id); + if ($fileMetadataRecord !== null) { + if ($parent->isImporting && empty($fileMetadataRecord['file'])) { + // When importing the record was added with an empty file relation as first step + $recordAccessAllowed = true; + } else { + $recordAccessAllowed = $this->checkFileWriteAccessForFileMetaData($fileMetadataRecord); + } + } + } else { + // For new records record access is allowed + $recordAccessAllowed = true; + } + if (isset($data['file'])) { + if ($parent->isImporting && empty($data['file'])) { + // When importing the record will be created with an empty file relation as first step + $dataAccessAllowed = true; + } elseif (empty($data['file'])) { + $dataAccessAllowed = false; + } else { + $dataAccessAllowed = $this->checkFileWriteAccessForFileMetaData($data); + } + } else { + $dataAccessAllowed = true; + } + if (!$recordAccessAllowed || !$dataAccessAllowed) { + // If for any item in the array, access is not allowed, we deny the whole operation + $accessAllowed = false; + break; + } + } + } + } + + /** + * Deny access to the edit form. This is not mandatory, but better to show this right away that access is denied. + */ + #[AsEventListener('evaluate-file-meta-data-edit-form-access')] + public function isAllowedToShowEditForm(ModifyEditFormUserAccessEvent $event): void + { + if (!$event->doesUserHaveAccess() || $event->getTableName() !== 'sys_file_metadata' || $event->getCommand() !== 'edit') { + return; + } + $this->checkFileWriteAccessForFileMetaData( + (array)BackendUtility::getRecord('sys_file_metadata', (int)($event->getDatabaseRow()['uid'] ?? 0)) + ) ? $event->allowUserAccess() : $event->denyUserAccess(); + } + + /** + * Checks write access to the file belonging to a metadata entry + */ + protected function checkFileWriteAccessForFileMetaData(array $fileMetadataRecord): bool + { + if (empty($fileMetadataRecord['file'])) { + return false; + } + $file = $fileMetadataRecord['file']; + if (str_contains($file, 'sys_file_')) { + // The file relation could be written as sys_file_[uid], strip this off before checking access rights + $file = substr($file, strlen('sys_file_')); + } + $fileObject = $this->resourceFactory->getFileObject((int)$file); + return $fileObject->checkActionPermission('editMeta'); + } +} diff --git a/Classes/Resource/Security/FileNameValidator.php b/Classes/Resource/Security/FileNameValidator.php new file mode 100644 index 0000000..65b0378 --- /dev/null +++ b/Classes/Resource/Security/FileNameValidator.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Security; + +/** + * Ensures that any filename that an editor chooses for naming (or uses for uploading a file) is valid, meaning + * that no invalid characters (null-bytes) are added, or that the file does not contain an invalid file extension. + */ +readonly class FileNameValidator +{ + public const DEFAULT_FILE_DENY_PATTERN = '\\.(php[3-8]?|phpsh|phtml|pht|phar|shtml|cgi)(\\..*)?$|\\.pl$|^\\.htaccess$'; + + /** + * Verifies the input filename against the 'fileDenyPattern' + * + * Filenames are not allowed to contain control characters. Therefore we + * always filter on [[:cntrl:]]. + * + * @param string $fileName File path to evaluate + * @return bool Returns TRUE if the file name is OK. + */ + public function isValid(string $fileName): bool + { + $pattern = '/[[:cntrl:]]/'; + if ($fileName !== '' && $this->getCurrentFileDenyPattern() !== '') { + $pattern = '/(?:[[:cntrl:]]|' . $this->getCurrentFileDenyPattern() . ')/iu'; + } + return preg_match($pattern, $fileName) === 0; + } + + /** + * Find out if there is a custom file deny pattern configured. + */ + public function customFileDenyPatternConfigured(): bool + { + return $this->getCurrentFileDenyPattern() !== self::DEFAULT_FILE_DENY_PATTERN; + } + + /** + * Checks if the given file deny pattern does not have parts that the default pattern should + * recommend. Used in status overview. + */ + public function missingImportantPatterns(): bool + { + $defaultParts = explode('|', self::DEFAULT_FILE_DENY_PATTERN); + $givenParts = explode('|', $this->getCurrentFileDenyPattern()); + $missingParts = array_diff($defaultParts, $givenParts); + return !empty($missingParts); + } + + protected function getCurrentFileDenyPattern(): string + { + if (isset($GLOBALS['TYPO3_CONF_VARS']['BE']['fileDenyPattern'])) { + return (string)$GLOBALS['TYPO3_CONF_VARS']['BE']['fileDenyPattern']; + } + return static::DEFAULT_FILE_DENY_PATTERN; + + } +} diff --git a/Classes/Resource/Security/FilePermissionAspect.php b/Classes/Resource/Security/FilePermissionAspect.php new file mode 100644 index 0000000..f271579 --- /dev/null +++ b/Classes/Resource/Security/FilePermissionAspect.php @@ -0,0 +1,198 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Security; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\DataHandling\DataHandlerCheckModifyAccessListHookInterface; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction; +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * `DataHandler` hook handling to avoid direct access to `sys_file` related entities: + * + * + denies any write access to `sys_file` (in datamap and cmdmap, unless it is an internal process) + * + denies any write access to `sys_file` that is on legacy storage + * + denies any write access to `sys_file_reference`, referencing a file on legacy storage, + * or not part of the file-mounts of the corresponding user + * + denies any write access to `sys_file_metadata`, referencing a file on legacy storage, + * or not part of the file-mounts of the corresponding user + */ +#[Autoconfigure(public: true)] +readonly class FilePermissionAspect implements DataHandlerCheckModifyAccessListHookInterface +{ + public function __construct( + private ResourceFactory $resourceFactory, + ) {} + + /** + * Denies write access to `sys_file` in general, unless it is an internal process. + * + * @param bool &$accessAllowed + * @param string $table + */ + public function checkModifyAccessList(&$accessAllowed, $table, DataHandler $parent): void + { + $isInternalProcess = $parent->isImporting || $parent->bypassAccessCheckForRecords; + if ($table === 'sys_file' && !$isInternalProcess) { + $accessAllowed = false; + } + } + + /** + * Checks file related data being processed in `DataHandler`: + * + `sys_file` (only if `checkModifyAccessList` passed -> during internal process) + * + `sys_file_reference` + * + `sys_file_metadata` + * + * @param mixed $incomingFieldArray + * @param string $table + * @param DataHandler $dataHandler + */ + public function processDatamap_preProcessFieldArray(&$incomingFieldArray, string $table, int|string $id, DataHandler $dataHandler): void + { + if (!is_array($incomingFieldArray)) { + $incomingFieldArray = null; + return; + } + $isInternalProcess = $dataHandler->isImporting || $dataHandler->bypassAccessCheckForRecords; + $isNew = !MathUtility::canBeInterpretedAsInteger($id); + $logId = $isNew ? 0 : (int)$id; + if ($table === 'sys_file') { + $file = $this->resolveFile((int)$id); + if (!$this->isValidStorageData($incomingFieldArray) + || (!$isNew && $file !== null && $this->usesLegacyStorage($file)) + ) { + $incomingFieldArray = null; + $this->logError($table, $logId, 'Attempt to set legacy storage directly is disallowed', $dataHandler); + } + } elseif ($table === 'sys_file_reference') { + $files = $this->resolveReferencedFiles($incomingFieldArray, 'uid_local'); + foreach ($files as $file) { + if ($file === null) { + $incomingFieldArray = null; + $this->logError($table, $logId, 'Attempt to reference invalid file is disallowed', $dataHandler); + } elseif ($this->usesLegacyStorage($file)) { + $incomingFieldArray = null; + $this->logError($table, $logId, sprintf('Attempt to reference file "%d" in legacy storage is disallowed', $file->getUid()), $dataHandler); + } elseif (!$isInternalProcess && $this->usesDisallowedFileMount($file, 'read', $dataHandler->BE_USER)) { + $incomingFieldArray = null; + $this->logError($table, $logId, sprintf('Attempt to reference file "%d" without permission is disallowed', $file->getUid()), $dataHandler); + } + } + } elseif ($table === 'sys_file_metadata') { + $file = $this->resolveReferencedFile($incomingFieldArray, 'file'); + if ($file !== null && $this->usesLegacyStorage($file)) { + $incomingFieldArray = null; + $this->logError($table, $logId, sprintf('Attempt to alter metadata of file "%d" in legacy storage is disallowed', $file->getUid()), $dataHandler); + } elseif (!$isInternalProcess && $file !== null && $this->usesDisallowedFileMount($file, 'editMeta', $dataHandler->BE_USER)) { + $incomingFieldArray = null; + $this->logError($table, $logId, sprintf('Attempt to alter metadata of file "%d" without permission is disallowed', $file->getUid()), $dataHandler); + } + } + } + + protected function logError(string $table, int $id, string $message, DataHandler $dataHandler): void + { + $dataHandler->log( + $table, + $id, + SystemLogDatabaseAction::UPDATE, + null, + SystemLogErrorClassification::USER_ERROR, + $message, + null, + [$table] + ); + } + + protected function usesLegacyStorage(File $file): bool + { + return $file->getStorage()->getUid() === 0; + } + + /** + * @param non-empty-string $fileAction + * @param BackendUserAuthentication|mixed $backendUser + */ + protected function usesDisallowedFileMount(File $file, string $fileAction, mixed $backendUser): bool + { + // strict: disallow, in case it cannot be determined from BE_USER + if (!$backendUser instanceof BackendUserAuthentication) { + return true; + } + foreach ($backendUser->getFileStorages() as $storage) { + if ($storage->getUid() === $file->getStorage()->getUid()) { + return !$storage->checkFileActionPermission($fileAction, $file); + } + } + return false; + } + + /** + * @return list<?File> + */ + protected function resolveReferencedFiles(array $data, string $propertyName): array + { + $propertyItems = GeneralUtility::trimExplode(',', (string)($data[$propertyName] ?? ''), true); + return array_map( + function (string $item): ?File { + if (MathUtility::canBeInterpretedAsInteger($item)) { + return $this->resolveFile((int)$item); + } + if (preg_match('/^sys_file_(?P<fileId>\d+)$/', $item, $matches) && (int)$matches['fileId'] > 0) { + return $this->resolveFile((int)$matches['fileId']); + } + return null; + }, + $propertyItems + ); + } + + protected function resolveReferencedFile(array $data, string $propertyName): ?File + { + $propertyValue = $data[$propertyName] ?? null; + if ($propertyValue === null || !MathUtility::canBeInterpretedAsInteger($propertyValue)) { + return null; + } + return $this->resolveFile((int)$propertyValue); + } + + protected function resolveFile(int $fileId): ?File + { + try { + return $this->resourceFactory->getFileObject($fileId); + } catch (\Throwable $t) { + return null; + } + } + + protected function isValidStorageData(array $data): bool + { + $storage = $data['storage'] ?? ''; + if (!MathUtility::canBeInterpretedAsInteger($storage)) { + return false; + } + return (int)$storage > 0; + } +} diff --git a/Classes/Resource/Security/StoragePermissionsAspect.php b/Classes/Resource/Security/StoragePermissionsAspect.php new file mode 100644 index 0000000..1e63393 --- /dev/null +++ b/Classes/Resource/Security/StoragePermissionsAspect.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Security; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Resource\Event\AfterResourceStorageInitializationEvent; +use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * The aspect injects user permissions and mount points into the storage + * based on user or group configuration. + * + * We do not have AOP in TYPO3, thus the aspect which + * deals with resource security is an EventListener which reacts on storage object creation. + * + * @internal this is an Event Listener, and not part of TYPO3 Core API. + */ +final class StoragePermissionsAspect +{ + /** + * The event listener for the event where storage objects are created + */ + #[AsEventListener('backend-user-permissions')] + public function addUserPermissionsToStorage(AfterResourceStorageInitializationEvent $event): void + { + $storage = $event->getStorage(); + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() + && !$this->getBackendUser()->isAdmin() + && !$storage->isFallbackStorage() + ) { + $storage->setEvaluatePermissions(true); + $storage->setUserPermissions($this->getFilePermissionsForStorage($storage)); + $this->addFileMountsToStorage($storage); + } + } + + /** + * Adds file mounts from the user's file mount records + */ + private function addFileMountsToStorage(ResourceStorage $storage): void + { + foreach ($this->getBackendUser()->getFileMountRecords() as $fileMountRow) { + if (!str_contains($fileMountRow['identifier'] ?? '', ':')) { + // Skip record since the file mount identifier is invalid + continue; + } + [$base, $path] = GeneralUtility::trimExplode(':', $fileMountRow['identifier'], false, 2); + if ((int)$base === $storage->getUid()) { + try { + $storage->addFileMount($path, $fileMountRow); + } catch (FolderDoesNotExistException $e) { + // That file mount does not seem to be valid, fail silently + } + } + } + } + + /** + * Gets the file permissions for a storage + * by merging any storage-specific permissions for a + * storage with the default settings. + * Admin users will always get the default settings. + */ + private function getFilePermissionsForStorage(ResourceStorage $storageObject): array + { + $backendUser = $this->getBackendUser(); + $finalUserPermissions = $backendUser->getFilePermissions(); + if ($backendUser->isAdmin()) { + return $finalUserPermissions; + } + $storageFilePermissions = $backendUser->getTSConfig()['permissions.']['file.']['storage.'][$storageObject->getUid() . '.'] ?? []; + if (!empty($storageFilePermissions)) { + array_walk( + $storageFilePermissions, + static function (string $value, string $permission) use (&$finalUserPermissions): void { + $finalUserPermissions[$permission] = (bool)$value; + } + ); + } + return $finalUserPermissions; + } + + private function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/Resource/Security/SvgEventListener.php b/Classes/Resource/Security/SvgEventListener.php new file mode 100644 index 0000000..63af95e --- /dev/null +++ b/Classes/Resource/Security/SvgEventListener.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Security; + +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Resource\Event\AfterFileContentsSetEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileAddedEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileReplacedEvent; + +class SvgEventListener +{ + /** + * @var SvgSanitizer + */ + protected $sanitizer; + + /** + * @var SvgTypeCheck + */ + protected $typeCheck; + + public function __construct(SvgSanitizer $sanitizer, SvgTypeCheck $typeCheck) + { + $this->sanitizer = $sanitizer; + $this->typeCheck = $typeCheck; + } + + #[AsEventListener('svg-resource-storage-listener-before-file-added')] + public function beforeFileAdded(BeforeFileAddedEvent $event): void + { + $filePath = $event->getSourceFilePath(); + if ($this->typeCheck->forFilePath($filePath)) { + $this->sanitizer->sanitizeFile($filePath); + } + } + + #[AsEventListener('svg-resource-storage-listener-before-file-replaced')] + public function beforeFileReplaced(BeforeFileReplacedEvent $event): void + { + $filePath = $event->getLocalFilePath(); + if ($this->typeCheck->forFilePath($filePath)) { + $this->sanitizer->sanitizeFile($filePath); + } + } + + #[AsEventListener('svg-resource-storage-listener-after-file-content-set')] + public function afterFileContentsSet(AfterFileContentsSetEvent $event): void + { + $file = $event->getFile(); + if (!$this->typeCheck->forResource($file)) { + return; + } + $content = $event->getContent(); + $sanitizedContent = $this->sanitizer->sanitizeContent($content); + // cave: setting content will trigger calling this handler again + // (having custom-flags on `FileInterface` would allow to mark it as "processed") + if ($sanitizedContent !== $content) { + $file->setContents($sanitizedContent); + } + } +} diff --git a/Classes/Resource/Security/SvgHookHandler.php b/Classes/Resource/Security/SvgHookHandler.php new file mode 100644 index 0000000..6274f0f --- /dev/null +++ b/Classes/Resource/Security/SvgHookHandler.php @@ -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\Core\Resource\Security; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; + +#[Autoconfigure(public: true)] +class SvgHookHandler +{ + /** + * @var SvgSanitizer + */ + protected $sanitizer; + + /** + * @var SvgTypeCheck + */ + protected $typeCheck; + + public function __construct(SvgSanitizer $sanitizer, SvgTypeCheck $typeCheck) + { + $this->sanitizer = $sanitizer; + $this->typeCheck = $typeCheck; + } + + public function processMoveUploadedFile(array $parameters) + { + $filePath = $parameters['source'] ?? null; + if ($filePath !== null && $this->typeCheck->forFilePath($filePath)) { + $this->sanitizer->sanitizeFile($filePath); + } + } +} diff --git a/Classes/Resource/Security/SvgSanitizer.php b/Classes/Resource/Security/SvgSanitizer.php new file mode 100644 index 0000000..9578b3b --- /dev/null +++ b/Classes/Resource/Security/SvgSanitizer.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Security; + +use enshrined\svgSanitize\data\AllowedTags; +use enshrined\svgSanitize\data\TagInterface; +use enshrined\svgSanitize\data\XPath; +use enshrined\svgSanitize\ElementReference\Resolver; +use enshrined\svgSanitize\Sanitizer; + +readonly class SvgSanitizer +{ + public function sanitizeFile(string $sourcePath, ?string $targetPath = null): void + { + if ($targetPath === null) { + $targetPath = $sourcePath; + } + $svg = file_get_contents($sourcePath); + if (!is_string($svg)) { + return; + } + $sanitizedSvg = $this->sanitizeContent($svg); + if ($sanitizedSvg !== $svg) { + file_put_contents($targetPath, $sanitizedSvg); + } + } + + public function sanitizeContent(string $svg, bool $minify = false, bool $removeLinks = false): string + { + $sanitizer = new Sanitizer(); + $sanitizer->removeRemoteReferences(true); + $sanitizer->minify($minify); + if ($removeLinks) { + $sanitizer->setAllowedTags(new class implements TagInterface { + public static function getTags(): array + { + return array_values(array_diff(AllowedTags::getTags(), ['a'])); + } + }); + } + return $sanitizer->sanitize($svg) ?: ''; + } + + public function sanitizeNode( + \DOMNode $node, + ): \DOMNode { + $svgSanitizer = new class extends Sanitizer { + public function sanitizeDocument(\DOMDocument $document): void + { + $this->xmlDocument = $document; + $this->setUpBefore(); + // Pre-process all identified elements + $xPath = new XPath($this->xmlDocument); + $this->elementReferenceResolver = new Resolver($xPath, $this->useNestingLimit); + $this->elementReferenceResolver->collect(); + $elementsToRemove = $this->elementReferenceResolver->getElementsToRemove(); + // Start the cleaning process + $this->startClean($this->xmlDocument->childNodes, $elementsToRemove); + $this->resetAfter(); + } + }; + + $svg = new \DOMDocument(); + $svg->appendChild($svg->importNode($node, true)); + + $svgSanitizer->removeRemoteReferences(true); + $svgSanitizer->sanitizeDocument($svg); + return $node->ownerDocument->importNode($svg->documentElement, true); + } +} diff --git a/Classes/Resource/Security/SvgTypeCheck.php b/Classes/Resource/Security/SvgTypeCheck.php new file mode 100644 index 0000000..2685da6 --- /dev/null +++ b/Classes/Resource/Security/SvgTypeCheck.php @@ -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\Core\Resource\Security; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\MimeTypeDetector; +use TYPO3\CMS\Core\Type\File\FileInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +#[Autoconfigure(public: true)] +class SvgTypeCheck +{ + protected const MIME_TYPES = ['image/svg', 'image/svg+xml', 'application/svg', 'application/svg+xml']; + + /** + * @var MimeTypeDetector + */ + protected $mimeTypeDetector; + + /** + * @var string[] + */ + protected $fileExtensions; + + public function __construct(MimeTypeDetector $mimeTypeDetector) + { + $this->mimeTypeDetector = $mimeTypeDetector; + $this->fileExtensions = $this->resolveFileExtensions(); + } + + public function forFilePath(string $filePath): bool + { + $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $filePath); + $fileExtension = $fileInfo->getExtension(); + $mimeType = $fileInfo->getMimeType(); + return in_array($fileExtension, $this->fileExtensions, true) + || in_array($mimeType, self::MIME_TYPES, true); + } + + public function forResource(FileInterface $file): bool + { + $fileExtension = $file->getExtension(); + $mimeType = $file->getMimeType(); + return in_array($fileExtension, $this->fileExtensions, true) + || in_array($mimeType, self::MIME_TYPES, true); + } + + /** + * @return string[] + */ + protected function resolveFileExtensions(): array + { + $fileExtensions = array_map( + function (string $mimeType): array { + return $this->mimeTypeDetector->getFileExtensionsForMimeType($mimeType); + }, + self::MIME_TYPES + ); + $fileExtensions = array_filter($fileExtensions); + return count($fileExtensions) > 0 ? array_unique(array_merge(...$fileExtensions)) : []; + } +} diff --git a/Classes/Resource/Service/ConfigurationService.php b/Classes/Resource/Service/ConfigurationService.php new file mode 100644 index 0000000..232f054 --- /dev/null +++ b/Classes/Resource/Service/ConfigurationService.php @@ -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\Core\Resource\Service; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * Resources can contain configurations: For example to define image dimensions or + * image masking. Resource configurations form part of the object identity and are + * used to create an object signature that itself is used to cache objects but NOT + * to reconstruct them. To prevent objects to be reconstructed they MUST NOT be + * serialized. This is why an object signature is obtained by serializing its array + * rather than serializing it directly. But attention...as well the objects array + * MUST NOT contain resource objects which could be the case when a configuration + * defines image masking. Here this service comes into play: it serializes any + * object configuration, as well those containing resources. + */ +class ConfigurationService +{ + public function serialize(array $configuration): string + { + return serialize($this->makeSerializable($configuration)); + } + + /** + * Recursively substitute file objects with their array representation. + */ + protected function makeSerializable(array $configuration): array + { + return array_map(function (mixed $value): mixed { + if (is_array($value)) { + return $this->makeSerializable($value); + } + if ($value instanceof FileInterface) { + return $value->toArray(); + } + return $value; + }, $configuration); + } +} diff --git a/Classes/Resource/Service/ExtractorService.php b/Classes/Resource/Service/ExtractorService.php new file mode 100644 index 0000000..c1aebba --- /dev/null +++ b/Classes/Resource/Service/ExtractorService.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource\Service; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileType; +use TYPO3\CMS\Core\Resource\Index\ExtractorInterface; +use TYPO3\CMS\Core\Resource\Index\ExtractorRegistry; + +/** + * Service class to extract metadata + */ +#[Autoconfigure(public: true)] +readonly class ExtractorService +{ + public function __construct( + private ExtractorRegistry $extractorRegistry, + ) {} + + public function extractMetaData(File $fileObject): array + { + $newMetaData = $extractedMetaData = []; + // Loop through available extractors and fetch metadata for the given file. + $extractionServices = $this->extractorRegistry->getExtractorsWithDriverSupport($fileObject->getStorage()->getDriverType()); + foreach ($extractionServices as $extractorService) { + if ($this->isFileTypeSupportedByExtractor($fileObject, $extractorService) + && $extractorService->canProcess($fileObject) + ) { + $metaDataFromExtractor = $extractorService->extractMetaData($fileObject, $extractedMetaData); + if (!empty($metaDataFromExtractor)) { + $extractedMetaData[] = $metaDataFromExtractor; + $newMetaData[$extractorService->getPriority()][] = $metaDataFromExtractor; + } + } + } + // Sort metadata by priority so that merging happens in order of precedence. + ksort($newMetaData); + // Merge the collected metadata. + $metaData = [[]]; + foreach ($newMetaData as $dataFromExtractors) { + foreach ($dataFromExtractors as $data) { + $metaData[] = $data; + } + } + return array_filter(array_merge(...$metaData)); + } + + /** + * Check whether the extractor service supports this file according to file type restrictions. + */ + private function isFileTypeSupportedByExtractor(File $file, ExtractorInterface $extractor): bool + { + $supportedFileTypes = $extractor->getFileTypeRestrictions(); + if ($supportedFileTypes === []) { + return true; + } + foreach ($supportedFileTypes as $supportedFileType) { + if (is_int($supportedFileType)) { + $supportedFileType = FileType::tryFrom($supportedFileType); + } + if ($supportedFileType->value === $file->getType()) { + return true; + } + } + return false; + } +} diff --git a/Classes/Resource/Service/FileProcessingService.php b/Classes/Resource/Service/FileProcessingService.php new file mode 100644 index 0000000..0f2c019 --- /dev/null +++ b/Classes/Resource/Service/FileProcessingService.php @@ -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\Core\Resource\Service; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Event\AfterFileProcessingEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeFileProcessingEvent; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileReference; +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\Resource\ProcessedFileRepository; +use TYPO3\CMS\Core\Resource\Processing\ProcessorInterface; +use TYPO3\CMS\Core\Resource\Processing\ProcessorRegistry; +use TYPO3\CMS\Core\Resource\Processing\TaskInterface; +use TYPO3\CMS\Core\Resource\Processing\TaskTypeRegistry; + +/** + * This is a general service for creating Processed Files a.k.a. processing a File object with a given configuration. + * + * This is how it works: + * -> File->process(string $taskType, array $configuration) + * -> ResourceStorage->processFile(File $file, $taskType, array $configuration) + * -> FileProcessingService->processFile(File $file, $taskType, array $configuration) + * + * This class then transforms the information of a Task through a Processor into a ProcessedFile object. + * For this, the DB is checked if there is a ProcessedFile which has been processed or does not need + * to be processed. If processing is required, a valid Processor is searched for to process the + * Task object (which is created from the TaskTypeRegistry when needed for processing). + */ +#[Autoconfigure(public: true)] +readonly class FileProcessingService +{ + public function __construct( + protected EventDispatcherInterface $eventDispatcher, + protected ProcessedFileRepository $processedFileRepository, + protected ProcessorRegistry $processorRegistry, + protected TaskTypeRegistry $taskTypeRegistry, + ) {} + + public function processFile(File|FileReference $fileObject, string $taskType, DriverInterface $driver, array $configuration): ProcessedFile + { + // Processing always works on the original file + $originalFile = $fileObject instanceof FileReference ? $fileObject->getOriginalFile() : $fileObject; + + // Find an entry in the DB or create a new ProcessedFile which can then be added (see ->add below) + $processedFile = $this->processedFileRepository->findOneByOriginalFileAndTaskTypeAndConfiguration($originalFile, $taskType, $configuration); + + // Make sure to work with the sanitized configuration from now on! + $configuration = $processedFile->getProcessingConfiguration(); + + // Pre-process the file + $event = $this->eventDispatcher->dispatch( + new BeforeFileProcessingEvent($driver, $processedFile, $fileObject, $taskType, $configuration) + ); + $processedFile = $event->getProcessedFile(); + $task = $this->taskTypeRegistry->getTaskForType($taskType, $processedFile, $configuration); + + // Only handle the file if it is not processed yet + // (maybe modified or already processed by an event) + // or (in case of preview images) already in the DB/in the processing folder + if ($task->fileNeedsProcessing()) { + $this->getProcessorByTask($task)->processTask($task); + if ($task->isExecuted() && $task->isSuccessful() && $processedFile->isProcessed()) { + $this->processedFileRepository->add($processedFile, $task); + } + } + + // Post-process (enrich) the file + $event = $this->eventDispatcher->dispatch( + new AfterFileProcessingEvent($driver, $processedFile, $fileObject, $taskType, $configuration) + ); + + return $event->getProcessedFile(); + } + + protected function getProcessorByTask(TaskInterface $task): ProcessorInterface + { + return $this->processorRegistry->getProcessorByTask($task); + } +} diff --git a/Classes/Resource/Service/ImageProcessingService.php b/Classes/Resource/Service/ImageProcessingService.php new file mode 100644 index 0000000..84bb399 --- /dev/null +++ b/Classes/Resource/Service/ImageProcessingService.php @@ -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\Core\Resource\Service; + +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\FileProcessingAspect; +use TYPO3\CMS\Core\Locking\ResourceMutex; +use TYPO3\CMS\Core\Resource\Exception\FileAlreadyProcessedException; +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\Resource\ProcessedFileRepository; + +/** + * Disables deferred processing and actually processes a preprocessed processed file + */ +readonly class ImageProcessingService +{ + public function __construct( + private ProcessedFileRepository $processedFileRepository, + private Context $context, + private ResourceMutex $locker, + ) {} + + public function process(int $processedFileId): ProcessedFile + { + /** @var ProcessedFile $processedFile */ + $processedFile = $this->processedFileRepository->findByUid($processedFileId); + try { + $this->validateProcessedFile($processedFile); + $hadToWaitForLock = $this->locker->acquireLock(self::class, (string)$processedFileId); + + if ($hadToWaitForLock) { + // Fetch the processed file again, as it might have been processed by + // another process while waiting for the lock + /** @var ProcessedFile $processedFile */ + $processedFile = $this->processedFileRepository->findByUid($processedFileId); + $this->validateProcessedFile($processedFile); + } + + $this->context->setAspect('fileProcessing', new FileProcessingAspect(false)); + $processedFile = $processedFile->getOriginalFile()->process( + $processedFile->getTaskIdentifier(), + $processedFile->getProcessingConfiguration() + ); + + $this->validateProcessedFile($processedFile); + } catch (FileAlreadyProcessedException $e) { + $processedFile = $e->getProcessedFile(); + } finally { + $this->locker->releaseLock(self::class); + } + + return $processedFile; + } + + /** + * Check whether a processed file was already processed + * + * @throws FileAlreadyProcessedException + */ + private function validateProcessedFile(ProcessedFile $processedFile): void + { + if ($processedFile->isProcessed()) { + throw new FileAlreadyProcessedException($processedFile, 1599395651); + } + } +} diff --git a/Classes/Resource/Service/ResourceConsistencyService.php b/Classes/Resource/Service/ResourceConsistencyService.php new file mode 100644 index 0000000..d48d668 --- /dev/null +++ b/Classes/Resource/Service/ResourceConsistencyService.php @@ -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\Core\Resource\Service; + +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Localization\LabelBag; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\MimeTypeDetector; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Type\File\FileInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Validation\ResultException; +use TYPO3\CMS\Core\Validation\ResultMessage; + +/** + * This service is invoked by ResourceStorage when modifying files, validating the following: + * + only explicitly allowed file-extensions are allowed: + * see `TYPO3_CONF_VARS` settings for `textfile_ext`, `mediafile_ext` and `miscfile_ext` + * + only files having valid file-extension to mime-type items are allowed: + * e.g. denies using `image.exe` with `image/png` + * + * @phpstan-type ExceptionItem array{storage: ResourceStorage, resource: string|FileInterface, targetFileName: string} + * @phpstan-type ExceptionItemCollection array<string, ExceptionItem> + * @internal + */ +final class ResourceConsistencyService +{ + /** + * Exception items, which shall not be validated. + * These are usually set by internal components (e.g. `ext:impexp`). + * + * @var ExceptionItemCollection + */ + private array $exceptionItems = []; + + public function __construct( + private readonly Random $random, + private readonly Features $features, + private readonly MimeTypeDetector $mimeTypeDetector, + ) {} + + public function addExceptionItem(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): void + { + $identifier = $this->random->generateRandomHexString(40); + $this->exceptionItems[$identifier] = $this->createExceptionItem($storage, $resource, $targetFileName); + } + + public function removeException(string $identifier): void + { + unset($this->exceptionItems[$identifier]); + } + + /** + * @param FileInterface|string $resource holding the contents + * @param string $targetFileName (optional) target file name to be used as the identifier + * @throws ResultException + */ + public function validate(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName = ''): void + { + if (!$this->shallValidate($storage, $resource, $targetFileName)) { + return; + } + if ($targetFileName !== '') { + $fileExtension = pathinfo($targetFileName, PATHINFO_EXTENSION); + } + if ($resource instanceof FileInterface) { + $mimeType = $resource->getMimeType(); + $fileSize = $resource->getSize(); + $fileExtension ??= $resource->getExtension(); + } else { + $fileInfo = new FileInfo($resource); + $mimeType = (string)$fileInfo->getMimeType($targetFileName); + $fileSize = $fileInfo->isReadable() ? $fileInfo->getSize() : 0; + $fileExtension ??= $fileInfo->getExtension(); + } + $isEmptyFile = $fileSize === 0; + $messages = []; + // skip mime-type checks for empty files + if (!$isEmptyFile && !$this->areFileExtensionAndMimeTypeConsistent($fileExtension, $mimeType)) { + $expectedTypes = $this->mimeTypeDetector->getMimeTypesForFileExtension($fileExtension); + if ($expectedTypes === []) { + $listOfExpectedTypes = 'N/A'; + } else { + $listOfExpectedTypes = implode(', ', $expectedTypes); + } + $arguments = [$mimeType, $fileExtension, $listOfExpectedTypes]; + $messages[] = new ResultMessage( + sprintf('Mime-type "%s" not allowed for file extension "%s" (expected: %s).', ...$arguments), + new LabelBag( + 'LLL:EXT:core/Resources/Private/Language/fileMessages.xlf:FileUtility.MimeTypeNotAllowedForFileExtensionWithExpectation', + ...$arguments + ) + ); + } + if (!$this->isFileExtensionAllowed($fileExtension)) { + $arguments = [$fileExtension]; + $messages[] = new ResultMessage( + sprintf('File extension "%s" is not in the list of allowed values.', ...$arguments), + new LabelBag( + 'LLL:EXT:core/Resources/Private/Language/fileMessages.xlf:FileUtility.FileExtensionIsNotAllowed', + ...$arguments + ) + ); + } + if ($messages !== []) { + throw new ResultException('Resource consistency check failed', 1747230949, ...$messages); + } + } + + private function areFileExtensionAndMimeTypeConsistent(string $fileExtension, string $mimeType): bool + { + if (!$this->features->isFeatureEnabled('security.system.enforceFileExtensionMimeTypeConsistency')) { + return true; + } + $fileExtension = mb_strtolower($fileExtension); + $assumedMimesTypeOfFileExtension = $this->mimeTypeDetector->getMimeTypesForFileExtension($fileExtension); + // pass, in case no assumed mime-type was found (e.g., for individual file extension) + return $assumedMimesTypeOfFileExtension === [] + || ($mimeType !== '' && in_array($mimeType, $assumedMimesTypeOfFileExtension, true)); + } + + private function isFileExtensionAllowed(string $fileExtension): bool + { + if (!$this->features->isFeatureEnabled('security.system.enforceAllowedFileExtensions')) { + return true; + } + $fileExtension = mb_strtolower($fileExtension); + return in_array($fileExtension, $this->getAllowedFileExtensions(), true); + } + + private function getAllowedFileExtensions(): array + { + $allowedFileExtensions = GeneralUtility::trimExplode( + ',', + $GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext'] . ',' + . $GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'] . ',' + . $GLOBALS['TYPO3_CONF_VARS']['SYS']['miscfile_ext'], + true + ); + return array_map(mb_strtolower(...), $allowedFileExtensions); + } + + private function shallValidate(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): bool + { + $needle = $this->createExceptionItem($storage, $resource, $targetFileName); + $exceptionItems = array_filter( + $this->exceptionItems, + fn(array $exception): bool => $this->exceptionItemsMatch($exception, $needle), + ); + if ($exceptionItems === []) { + return true; + } + foreach (array_keys($exceptionItems) as $identifier) { + $this->removeException($identifier); + } + return false; + } + + /** + * @return ExceptionItem + */ + private function createExceptionItem(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): array + { + return [ + 'storage' => $storage, + 'resource' => $resource, + 'targetFileName' => $targetFileName, + ]; + } + + private function exceptionItemsMatch(array $left, array $right): bool + { + foreach ($right as $key => $value) { + if ($value !== ($left[$key] ?? null)) { + return false; + } + } + return true; + } +} diff --git a/Classes/Resource/StorageRepository.php b/Classes/Resource/StorageRepository.php new file mode 100644 index 0000000..db0c54b --- /dev/null +++ b/Classes/Resource/StorageRepository.php @@ -0,0 +1,478 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Resource\Driver\DriverInterface; +use TYPO3\CMS\Core\Resource\Driver\DriverRegistry; +use TYPO3\CMS\Core\Resource\Event\AfterResourceStorageInitializationEvent; +use TYPO3\CMS\Core\Resource\Event\BeforeResourceStorageInitializationEvent; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Repository for accessing the file storages + */ +#[Autoconfigure(public: true)] +class StorageRepository +{ + /** + * @var array<positive-int, array<mixed>>|null + */ + protected ?array $storageRowCache = null; + + /** + * @var array<int<0, max>, LocalPath>|null + */ + protected ?array $localDriverStorageCache = null; + + /** + * @var array<int<0, max>, ResourceStorage> + */ + protected array $storageInstances = []; + + public function __construct( + protected readonly EventDispatcherInterface $eventDispatcher, + protected readonly ConnectionPool $connectionPool, + protected readonly DriverRegistry $driverRegistry, + protected readonly FlexFormTools $flexFormTools, + protected readonly LoggerInterface $logger, + ) {} + + /** + * Returns the Default Storage + * + * The Default Storage is considered to be the replacement for the fileadmin/ construct. + * It is automatically created with the setting fileadminDir from install tool. + * getDefaultStorage->getDefaultFolder() will get you fileadmin/user_upload/ in a standard + * TYPO3 installation. + */ + public function getDefaultStorage(): ?ResourceStorage + { + $allStorages = $this->findAll(); + foreach ($allStorages as $storage) { + if ($storage->isDefault()) { + return $storage; + } + } + return null; + } + + public function findByUid(int $uid): ?ResourceStorage + { + $this->initializeLocalCache(); + if (isset($this->storageRowCache[$uid]) || $uid === 0) { + return $this->getStorageObject($uid, $this->storageRowCache[$uid] ?? []); + } + return null; + } + + /** + * Gets a storage object from a combined identifier + * + * @param non-empty-string $identifier An identifier of the form [storage uid]:[object identifier] + */ + public function findByCombinedIdentifier(string $identifier): ?ResourceStorage + { + $parts = GeneralUtility::trimExplode(':', $identifier); + return count($parts) === 2 ? $this->findByUid((int)$parts[0]) : null; + } + + protected function fetchRecordDataByUid(int $uid): array + { + $this->initializeLocalCache(); + if (!isset($this->storageRowCache[$uid])) { + throw new \InvalidArgumentException(sprintf('No storage found with uid "%d".', $uid), 1599235454); + } + + return $this->storageRowCache[$uid]; + } + + /** + * Initializes the Storage + */ + protected function initializeLocalCache(): void + { + if ($this->storageRowCache === null) { + $result = $this->connectionPool->getQueryBuilderForTable('sys_file_storage') + ->select('*') + ->from('sys_file_storage') + ->orderBy('name') + ->executeQuery(); + + $this->storageRowCache = []; + while ($row = $result->fetchAssociative()) { + if (!empty($row['uid'])) { + $this->storageRowCache[(int)$row['uid']] = $row; + } + } + + // if no storage is created before or the user has not access to a storage + // $this->storageRowCache would have the value array() + // so check if there is any record. If no record is found, create the fileadmin/ storage + // selecting just one row is enough + + if ($this->storageRowCache === []) { + $storageObjectsCount = $this->connectionPool->getConnectionForTable('sys_file_storage') + ->count('uid', 'sys_file_storage', []); + if ($storageObjectsCount === 0) { + if ($this->createLocalStorage( + rtrim($GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'] ?? 'fileadmin', '/'), + $GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'], + 'relative', + 'This is the local fileadmin/ directory. This storage mount has been created automatically by TYPO3.', + true + ) > 0) { + // clear Cache to force reloading of storages + $this->flush(); + // call self for initialize Cache + $this->initializeLocalCache(); + } + } + } + } + } + + /** + * Flush the internal storage caches to force reloading of storages with the next fetch. + * + * @internal + */ + public function flush(): void + { + $this->storageRowCache = null; + $this->storageInstances = []; + $this->localDriverStorageCache = null; + } + + /** + * Finds storages by type, i.e. the driver used + * + * @param non-empty-string $storageType + * @return list<ResourceStorage> + */ + public function findByStorageType(string $storageType): array + { + $this->initializeLocalCache(); + + $storageObjects = []; + foreach ($this->storageRowCache as $storageRow) { + if ($storageRow['driver'] !== $storageType) { + continue; + } + if ($this->driverRegistry->driverExists($storageRow['driver'])) { + $storageObjects[] = $this->getStorageObject($storageRow['uid'], $storageRow); + } else { + $this->logger->warning('Could not instantiate storage "{name}" because of missing driver.', ['name' => $storageRow['name']]); + } + } + return $storageObjects; + } + + /** + * Returns a list of mountpoints that are available in the VFS. + * In case no storage exists this automatically created a storage for fileadmin/ + * + * @return list<ResourceStorage> + */ + public function findAll(): array + { + $this->initializeLocalCache(); + + $storageObjects = []; + foreach ($this->storageRowCache as $storageRow) { + if ($this->driverRegistry->driverExists($storageRow['driver'])) { + $storageObjects[] = $this->getStorageObject($storageRow['uid'], $storageRow); + } else { + $this->logger->warning('Could not instantiate storage "{name}" because of missing driver.', ['name' => $storageRow['name']]); + } + } + return $storageObjects; + } + + /** + * Create the initial local storage base e.g. for the fileadmin/ directory. + * + * @param non-empty-string $name + * @param non-empty-string $basePath + * @param non-empty-string $pathType + * @return int<0, max> + */ + public function createLocalStorage(string $name, string $basePath, string $pathType, string $description = '', bool $default = false): int + { + $caseSensitive = $this->testCaseSensitivity($pathType === 'relative' ? Environment::getPublicPath() . '/' . $basePath : $basePath); + // create the FlexForm for the driver configuration + $flexFormData = [ + 'data' => [ + 'sDEF' => [ + 'lDEF' => [ + 'basePath' => ['vDEF' => rtrim($basePath, '/') . '/'], + 'pathType' => ['vDEF' => $pathType], + 'caseSensitive' => ['vDEF' => $caseSensitive], + ], + ], + ], + ]; + $flexFormXml = $this->flexFormTools->flexArray2Xml($flexFormData); + // create the record + $field_values = [ + 'pid' => 0, + 'tstamp' => $GLOBALS['EXEC_TIME'], + 'crdate' => $GLOBALS['EXEC_TIME'], + 'name' => $name, + 'description' => $description, + 'driver' => 'Local', + 'configuration' => $flexFormXml, + 'is_online' => 1, + 'auto_extract_metadata' => 1, + 'is_browsable' => 1, + 'is_public' => 1, + 'is_writable' => 1, + 'is_default' => $default ? 1 : 0, + ]; + $dbConnection = $this->connectionPool->getConnectionForTable('sys_file_storage'); + $dbConnection->insert('sys_file_storage', $field_values); + // Flush local resourceStorage cache so the storage can be accessed during the same request right away + $this->flush(); + return (int)$dbConnection->lastInsertId(); + } + + /** + * Test if the local filesystem is case sensitive + * + * @param non-empty-string $absolutePath + */ + protected function testCaseSensitivity(string $absolutePath): bool + { + $caseSensitive = true; + $path = rtrim($absolutePath, '/') . '/aAbB'; + $testFileExists = @file_exists($path); + + // create test file + if (!$testFileExists) { + // @todo: This misses a test for directory existence, touch does not create + // dirs. StorageRepositoryTest stumbles here. It should at least be + // sanitized to not touch() a file in a non-existing directory. + touch($path); + } + + // do the actual sensitivity check + if (@file_exists(strtoupper($path)) && @file_exists(strtolower($path))) { + $caseSensitive = false; + } + + // clean filesystem + if (!$testFileExists) { + unlink($path); + } + + return $caseSensitive; + } + + /** + * Creates an instance of the storage from given UID. The $recordData can + * be supplied to increase performance. + * + * @param int<0, max>|string $uid The uid of the storage to instantiate. + * @param array $recordData<string, mixed> The record row from database. + * @param non-empty-string|null $fileIdentifier Identifier for a file. Used for auto-detection of a storage, but only if $uid === 0 (Local default storage) is used + * @param-out string $fileIdentifier + */ + public function getStorageObject(int|string $uid, array $recordData = [], ?string &$fileIdentifier = null): ResourceStorage + { + $uid = (int)$uid; + if ($uid === 0 && $fileIdentifier !== null) { + $uid = $this->findBestMatchingStorageByLocalPath($fileIdentifier); + } + if (!isset($this->storageInstances[$uid])) { + $storageConfiguration = null; + $event = $this->eventDispatcher->dispatch(new BeforeResourceStorageInitializationEvent($uid, $recordData, $fileIdentifier)); + $recordData = $event->getRecord(); + $uid = $event->getStorageUid(); + $fileIdentifier = $event->getFileIdentifier(); + // If the built-in storage with UID=0 is requested: + if ($uid === 0) { + $recordData = [ + 'uid' => 0, + 'pid' => 0, + 'name' => 'Fallback Storage', + 'description' => 'Internal storage, mounting the main TYPO3_site directory.', + 'driver' => 'Local', + 'processingfolder' => 'typo3temp/assets/_processed_/', + // legacy code + 'configuration' => '', + 'is_online' => true, + 'is_browsable' => true, + 'is_public' => true, + 'is_writable' => true, + 'is_default' => false, + ]; + $storageConfiguration = [ + 'basePath' => Environment::getPublicPath(), + 'pathType' => 'absolute', + ]; + } elseif ($recordData === [] || (int)$recordData['uid'] !== $uid) { + $recordData = $this->fetchRecordDataByUid($uid); + } + $storageObject = $this->createStorageObject($recordData, $storageConfiguration); + $storageObject = $this->eventDispatcher + ->dispatch(new AfterResourceStorageInitializationEvent($storageObject)) + ->getStorage(); + $this->storageInstances[$uid] = $storageObject; + } + return $this->storageInstances[$uid]; + } + + /** + * Checks whether a file resides within a real storage in local file system. + * If no match is found, uid 0 is returned which is a fallback storage pointing to fileadmin in public web path. + * + * The file identifier is adapted accordingly to match the new storage's base path. + * @internal absolutely do not call this method publicly, not even in TYPO3 core. It must only be used for legacy resource resolving + * @param non-empty-string $localPath + * @param-out string $localPath + * @return int<0, max> + */ + public function findBestMatchingStorageByLocalPath(string &$localPath): int + { + if ($this->localDriverStorageCache === null) { + $this->initializeLocalStorageCache(); + } + // normalize path information (`//`, `../`) + $localPath = PathUtility::getCanonicalPath($localPath); + if (!str_starts_with($localPath, '/')) { + $localPath = '/' . $localPath; + } + $bestMatchStorageUid = 0; + $bestMatchLength = 0; + foreach ($this->localDriverStorageCache as $storageUid => $basePath) { + // try to match (resolved) relative base-path + if ($basePath->getRelative() !== null + && null !== $commonPrefix = PathUtility::getCommonPrefix([$basePath->getRelative(), $localPath]) + ) { + $matchLength = strlen($commonPrefix); + $basePathLength = strlen($basePath->getRelative()); + if ($matchLength >= $basePathLength && $matchLength > $bestMatchLength) { + $bestMatchStorageUid = $storageUid; + $bestMatchLength = $matchLength; + } + } + // try to match (resolved) absolute base-path + if (null !== $commonPrefix = PathUtility::getCommonPrefix([$basePath->getAbsolute(), $localPath])) { + $matchLength = strlen($commonPrefix); + $basePathLength = strlen($basePath->getAbsolute()); + if ($matchLength >= $basePathLength && $matchLength > $bestMatchLength) { + $bestMatchStorageUid = $storageUid; + $bestMatchLength = $matchLength; + } + } + } + if ($bestMatchLength > 0) { + // $commonPrefix always has trailing slash, which needs to be excluded + // (commonPrefix: /some/path/, localPath: /some/path/file.png --> /file.png; keep leading slash) + $localPath = substr($localPath, $bestMatchLength - 1); + } + return $bestMatchStorageUid; + } + + /** + * Creates an array mapping all uids to the basePath of storages using the "local" driver. + */ + protected function initializeLocalStorageCache(): void + { + $this->localDriverStorageCache = [ + // implicit legacy storage in project's public path + 0 => new LocalPath(Environment::getPublicPath(), LocalPath::TYPE_ABSOLUTE), + ]; + $storageObjects = $this->findByStorageType('Local'); + foreach ($storageObjects as $localStorage) { + $configuration = $localStorage->getConfiguration(); + if (!isset($configuration['basePath']) || !isset($configuration['pathType'])) { + continue; + } + if ($configuration['pathType'] === 'relative') { + $pathType = LocalPath::TYPE_RELATIVE; + } elseif ($configuration['pathType'] === 'absolute') { + $pathType = LocalPath::TYPE_ABSOLUTE; + } else { + continue; + } + $this->localDriverStorageCache[$localStorage->getUid()] = GeneralUtility::makeInstance( + LocalPath::class, + $configuration['basePath'], + $pathType + ); + } + } + + /** + * Creates a storage object from a storage database row. + * + * @param array|null $storageConfiguration Storage configuration (if given, this won't be extracted from the FlexForm value but the supplied array used instead) + */ + protected function createStorageObject(array $storageRecord, ?array $storageConfiguration = null): ResourceStorage + { + if (!$storageConfiguration && !empty($storageRecord['configuration'])) { + $storageConfiguration = $this->convertFlexFormDataToConfigurationArray($storageRecord['configuration']); + } + $driverType = $storageRecord['driver']; + $driverObject = $this->getDriverObject($driverType, (array)$storageConfiguration); + $storageRecord['configuration'] = $storageConfiguration; + return GeneralUtility::makeInstance(ResourceStorage::class, $driverObject, $storageRecord, $this->eventDispatcher); + } + + /** + * Converts a flexform data string to a flat array with key value pairs + * + * @return array Array with key => value pairs of the field data in the FlexForm + */ + protected function convertFlexFormDataToConfigurationArray(string $flexFormData): array + { + if ($flexFormData) { + return $this->flexFormTools->convertFlexFormContentToArray($flexFormData); + } + return []; + } + + /** + * Creates a driver object for a specified storage object. + * + * @param non-empty-string $driverIdentificationString The driver class (or identifier) to use. + * @param array $driverConfiguration The configuration of the storage + */ + protected function getDriverObject(string $driverIdentificationString, array $driverConfiguration): DriverInterface + { + $driverClass = $this->driverRegistry->getDriverClass($driverIdentificationString); + /** @var DriverInterface $driverObject */ + $driverObject = GeneralUtility::makeInstance($driverClass, $driverConfiguration); + return $driverObject; + } + + /** + * @internal + */ + public function createFromRecord(array $storageRecord): ResourceStorage + { + return $this->createStorageObject($storageRecord); + } +} diff --git a/Classes/Resource/SynchronizeFolderRelations.php b/Classes/Resource/SynchronizeFolderRelations.php new file mode 100644 index 0000000..f51e62a --- /dev/null +++ b/Classes/Resource/SynchronizeFolderRelations.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use TYPO3\CMS\Core\Attribute\AsEventListener; +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\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Resource\Event\AfterFolderRenamedEvent; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Event listeners to synchronize folder relations after some + * action like renaming or moving of a folder, took place. + * + * @internal + */ +readonly class SynchronizeFolderRelations +{ + public function __construct( + protected ConnectionPool $connectionPool, + protected FlashMessageService $flashMessageService, + protected TcaSchemaFactory $tcaSchemaFactory + ) {} + + /** + * Synchronize file collection relations after a folder was renamed + * + * + * @throws \TYPO3\CMS\Core\Exception + */ + #[AsEventListener('synchronize-file-collections-after-folder-renamed')] + public function synchronizeFileCollectionsAfterRename(AfterFolderRenamedEvent $event): void + { + $sourceIdentifier = $event->getSourceFolder()->getCombinedIdentifier(); + $targetIdentifier = $event->getFolder()->getCombinedIdentifier(); + + $synchronized = 0; + $queryBuilder = $this->getPreparedQueryBuilder('sys_file_collection'); + $statement = $queryBuilder + ->select('uid', 'folder_identifier') + ->from('sys_file_collection') + ->where( + $queryBuilder->expr()->like('folder_identifier', $queryBuilder->quote($sourceIdentifier . '%')), + $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('folder')) + ) + ->executeQuery(); + + while ($row = $statement->fetchAssociative()) { + $folder = preg_replace(sprintf('/^%s/', preg_quote($sourceIdentifier, '/')), $targetIdentifier, $row['folder_identifier']) ?? ''; + if ($folder !== '') { + $queryBuilder = $this->getPreparedQueryBuilder('sys_file_collection'); + $synchronized += (int)$queryBuilder + ->update('sys_file_collection') + ->set('folder_identifier', $folder) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter((int)$row['uid'], Connection::PARAM_INT))) + ->executeStatement(); + } + } + + if ($synchronized) { + $this->addFlashMessage((int)$synchronized, 'sys_file_collection', 'afterFolderRenamed'); + } + } + + /** + * Synchronize file mount relations after a folder was renamed + * + * + * @throws \TYPO3\CMS\Core\Exception + */ + #[AsEventListener('synchronize-filemounts-after-folder-renamed')] + public function synchronizeFilemountsAfterRename(AfterFolderRenamedEvent $event): void + { + $storageId = $event->getSourceFolder()->getStorage()->getUid(); + $sourceIdentifier = $event->getSourceFolder()->getIdentifier(); + $targetIdentifier = $event->getFolder()->getIdentifier(); + + $synchronized = 0; + $queryBuilder = $this->getPreparedQueryBuilder('sys_filemounts'); + $statement = $queryBuilder + ->select('uid', 'identifier') + ->from('sys_filemounts') + ->where( + $queryBuilder->expr()->like('identifier', $queryBuilder->quote($storageId . ':' . $sourceIdentifier . '%')) + ) + ->executeQuery(); + + while ($row = $statement->fetchAssociative()) { + [$base, $path] = GeneralUtility::trimExplode(':', $row['identifier'], false, 2); + $path = preg_replace(sprintf('/^%s/', preg_quote($sourceIdentifier, '/')), $targetIdentifier, $path) ?? ''; + if ($path !== '') { + $queryBuilder = $this->getPreparedQueryBuilder('sys_filemounts'); + $synchronized += (int)$queryBuilder + ->update('sys_filemounts') + ->set('identifier', $base . ':' . $path) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter((int)$row['uid'], Connection::PARAM_INT))) + ->executeStatement(); + } + } + + if ($synchronized) { + $this->addFlashMessage((int)$synchronized, 'sys_filemounts', 'afterFolderRenamed'); + } + } + + /** + * Add a flash message for a successfully performed synchronization + * + * @param int $updatedRelationsCount The amount of relations synchronized + * @param string $table The relation table the synchronization was performed on + * @param string $event The event after which the synchronization was performed + * + * @throws \TYPO3\CMS\Core\Exception + */ + protected function addFlashMessage(int $updatedRelationsCount, string $table, string $event): void + { + $languageService = $this->getLanguageServcie(); + $message = sprintf( + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:synchronizeFolderRelations.' . $event), + $updatedRelationsCount, + $this->tcaSchemaFactory->get($table)->getTitle($languageService->sL(...)), + ); + + $this->flashMessageService + ->getMessageQueueByIdentifier() + ->enqueue(new FlashMessage($message, '', ContextualFeedbackSeverity::OK, true)); + } + + protected function getPreparedQueryBuilder(string $table): QueryBuilder + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + return $queryBuilder; + } + + protected function getLanguageServcie(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Resource/TextExtraction/PlainTextExtractor.php b/Classes/Resource/TextExtraction/PlainTextExtractor.php new file mode 100644 index 0000000..ee3e93a --- /dev/null +++ b/Classes/Resource/TextExtraction/PlainTextExtractor.php @@ -0,0 +1,64 @@ +<?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\Core\Resource\TextExtraction; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * A simple text extractor to extract text from plain text files. + */ +class PlainTextExtractor implements TextExtractorInterface +{ + /** + * Checks if the given file can be read by this extractor + * + * @return bool + */ + public function canExtractText(FileInterface $file) + { + $canExtract = false; + + if ($file->getMimeType() === 'text/plain') { + $canExtract = true; + } + + return $canExtract; + } + + /** + * The actual text extraction. + * + * @return string + */ + public function extractText(FileInterface $file) + { + $localTempFile = $file->getForLocalProcessing(false); + + // extract text + $content = (string)file_get_contents($localTempFile); + + // In case of remote storage, the temporary copy of the + // original file in typo3temp must be removed + // Simply compare the filenames, because the filename is so unique that + // it is nearly impossible to have a file with this name in a storage + if (PathUtility::basename($localTempFile) !== $file->getName()) { + unlink($localTempFile); + } + + return $content; + } +} diff --git a/Classes/Resource/TextExtraction/TextExtractorInterface.php b/Classes/Resource/TextExtraction/TextExtractorInterface.php new file mode 100644 index 0000000..4c398bd --- /dev/null +++ b/Classes/Resource/TextExtraction/TextExtractorInterface.php @@ -0,0 +1,41 @@ +<?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\Core\Resource\TextExtraction; + +use TYPO3\CMS\Core\Resource\FileInterface; + +/** + * An interface for text extractors + */ +interface TextExtractorInterface +{ + /** + * Checks if the given file can be read by this extractor + * + * @return bool + */ + public function canExtractText(FileInterface $file); + + /** + * The actual text extraction. + * + * Should return a string of the file's content + * + * @param FileInterface $file + * @return string + */ + public function extractText(FileInterface $file); +} diff --git a/Classes/Resource/TextExtraction/TextExtractorRegistry.php b/Classes/Resource/TextExtraction/TextExtractorRegistry.php new file mode 100644 index 0000000..2fd2214 --- /dev/null +++ b/Classes/Resource/TextExtraction/TextExtractorRegistry.php @@ -0,0 +1,101 @@ +<?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\Core\Resource\TextExtraction; + +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +class TextExtractorRegistry implements SingletonInterface +{ + /** + * Registered text extractor class names + * + * @var array + */ + protected $textExtractorClasses = []; + + /** + * Instance cache for text extractor classes + * + * @var TextExtractorInterface[] + */ + protected $instances = []; + + /** + * Allows to register a text extractor class + * + * @param string $className + * @throws \InvalidArgumentException + */ + public function registerTextExtractor($className) + { + if (!class_exists($className)) { + throw new \InvalidArgumentException('The class "' . $className . '" you are trying to register is not available', 1422906893); + } + + if (!in_array(TextExtractorInterface::class, class_implements($className) ?: [], true)) { + throw new \InvalidArgumentException($className . ' must implement interface' . TextExtractorInterface::class, 1422771427); + } + + $this->textExtractorClasses[] = $className; + } + + /** + * Get all registered text extractor instances + * + * @return TextExtractorInterface[] + */ + public function getTextExtractorInstances() + { + if (empty($this->instances) && !empty($this->textExtractorClasses)) { + foreach ($this->textExtractorClasses as $className) { + $object = $this->createTextExtractorInstance($className); + $this->instances[] = $object; + } + } + + return $this->instances; + } + + /** + * Create an instance of a certain text extractor class + * + * @param string $className + * @return TextExtractorInterface + */ + protected function createTextExtractorInstance($className) + { + return GeneralUtility::makeInstance($className); + } + + /** + * Checks whether any registered text extractor can deal with a given file + * and returns it. + * + * @return TextExtractorInterface|null + */ + public function getTextExtractor(FileInterface $file) + { + foreach ($this->getTextExtractorInstances() as $textExtractor) { + if ($textExtractor->canExtractText($file)) { + return $textExtractor; + } + } + + return null; + } +} diff --git a/Classes/Resource/Utility/ListUtility.php b/Classes/Resource/Utility/ListUtility.php new file mode 100644 index 0000000..f78e59b --- /dev/null +++ b/Classes/Resource/Utility/ListUtility.php @@ -0,0 +1,64 @@ +<?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\Core\Resource\Utility; + +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\FolderInterface; + +/** + * Utility function for working with resource-lists + */ +readonly class ListUtility +{ + /** + * Resolve special folders (by their role) into localised string + * + * @param Folder[] $folders + * @return array<string|int, Folder> Folders using the Folder name (with or without role) as key + */ + public static function resolveSpecialFolderNames(array $folders) + { + $resolvedFolders = []; + foreach ($folders as $folder) { + $name = self::resolveSpecialFolderName($folder); + $resolvedFolders[$name] = $folder; + } + + return $resolvedFolders; + } + + /** + * @internal + */ + public static function resolveSpecialFolderName(Folder $folder): string + { + /** @var LanguageService $lang */ + $lang = $GLOBALS['LANG']; + + $name = $folder->getName(); + $role = $folder->getRole(); + if ($role !== FolderInterface::ROLE_DEFAULT) { + $tempName = htmlspecialchars($lang->translate('role_folder_' . $role, 'core.resources.folder') ?? $role); + if (!empty($tempName) && ($tempName !== $name)) { + // Set new name and append original name + $name = $tempName . ' (' . $name . ')'; + } + } + + return $name; + } +} diff --git a/Classes/Routing/Aspect/AspectFactory.php b/Classes/Routing/Aspect/AspectFactory.php new file mode 100644 index 0000000..796e498 --- /dev/null +++ b/Classes/Routing/Aspect/AspectFactory.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Site\SiteAwareInterface; +use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Factory for creating aspects + */ +class AspectFactory +{ + /** + * Create aspects from the given settings. + * + * @return AspectInterface[] + */ + public function createAspects(array $aspects, SiteLanguage $language, Site $site): array + { + $aspects = array_map( + function ($settings) use ($language, $site) { + $type = (string)($settings['type'] ?? ''); + $aspect = $this->create($type, $settings); + return $this->enrich($aspect, $language, $site); + }, + $aspects + ); + uasort($aspects, [$this, 'sortAspects']); + return $aspects; + } + + /** + * Creates an aspect + * + * @throws \InvalidArgumentException + * @throws \OutOfRangeException + */ + protected function create(string $type, array $settings): AspectInterface + { + if (empty($type)) { + throw new \InvalidArgumentException('Aspect type cannot be empty', 1538079481); + } + if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects'][$type])) { + throw new \OutOfRangeException(sprintf('No aspect found for %s', $type), 1538079482); + } + unset($settings['type']); + $className = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects'][$type]; + return GeneralUtility::makeInstance($className, $settings); + } + + /** + * Checks for the language aware trait, and adds the site language. + */ + protected function enrich(AspectInterface $aspect, SiteLanguage $language, Site $site): AspectInterface + { + if ($aspect instanceof SiteLanguageAwareInterface) { + $aspect->setSiteLanguage($language); + } + if ($aspect instanceof SiteAwareInterface) { + $aspect->setSite($site); + } + return $aspect; + } + + /** + * Sorts aspects with putting persisted aspects to the end, thus + * non-persisted aspects can be executed earlier without invoking database. + */ + protected function sortAspects(AspectInterface $first, AspectInterface $second): int + { + // when first is persisted, move it to the end (>0) + $first = $first instanceof PersistedMappableAspectInterface ? 1 : 0; + // when second is persisted, move it to the beginning (<0) + $second = $second instanceof PersistedMappableAspectInterface ? -1 : 0; + // 0 + 0 = 0 - both are non-persisted + // 1 - 1 = 0 - both are persisted + // 1 + 0 = 1 - only first is persisted + // 0 - 1 = -1 - only second is persisted + return $first + $second; + } +} diff --git a/Classes/Routing/Aspect/AspectInterface.php b/Classes/Routing/Aspect/AspectInterface.php new file mode 100644 index 0000000..e70b63e --- /dev/null +++ b/Classes/Routing/Aspect/AspectInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Base interface for all aspects + */ +interface AspectInterface {} diff --git a/Classes/Routing/Aspect/AspectTrait.php b/Classes/Routing/Aspect/AspectTrait.php new file mode 100644 index 0000000..9d0c4c6 --- /dev/null +++ b/Classes/Routing/Aspect/AspectTrait.php @@ -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\Core\Routing\Aspect; + +use TYPO3\CMS\Core\DataHandling\TableColumnType; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +trait AspectTrait +{ + protected function isSlugUniqueInSite(string $tableName, string $fieldName): bool + { + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($tableName); + if (!$schema->hasField($fieldName)) { + return false; + } + $fieldType = $schema->getField($fieldName); + return + $fieldType->isType(TableColumnType::SLUG) + && GeneralUtility::inList($fieldType->getConfiguration()['eval'] ?? '', 'uniqueInSite'); + } + + protected function hasSlugUniqueInSite(string $tableName, string ...$fieldNames): bool + { + foreach ($fieldNames as $fieldName) { + if ($this->isSlugUniqueInSite($tableName, $fieldName)) { + return true; + } + } + return false; + } +} diff --git a/Classes/Routing/Aspect/LocaleModifier.php b/Classes/Routing/Aspect/LocaleModifier.php new file mode 100644 index 0000000..926af7f --- /dev/null +++ b/Classes/Routing/Aspect/LocaleModifier.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; +use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait; + +/** + * Locale modifier to be used to modify routePath directly. + * + * Example: + * routeEnhancers: + * Blog: + * type: Extbase + * extension: BlogExample + * plugin: Pi1 + * routes: + * - { routePath: '/{list_label}/{paging_widget}', _controller: 'BlogExample::list', _arguments: {'paging_widget': '@widget_0/currentPage'}} + * defaultController: 'BlogExample::list' + * requirements: + * paging_widget: '\d+' + * aspects: + * list_label: + * type: LocaleModifier + * default: 'list' + * localeMap: + * - locale: 'en_US.*|en_GB.*' + * value: 'overview' + * - locale: 'fr_FR' + * value: 'liste' + * - locale: 'de_.*' + * value: 'übersicht' + */ +class LocaleModifier implements ModifiableAspectInterface, SiteLanguageAwareInterface +{ + use SiteLanguageAwareTrait; + + /** + * @var array + */ + protected $settings; + + /** + * @var array + */ + protected $localeMap; + + /** + * @var string|null + */ + protected $default; + + /** + * @throws \InvalidArgumentException + */ + public function __construct(array $settings) + { + $localeMap = $settings['localeMap'] ?? null; + $default = $settings['default'] ?? null; + + if (!is_array($localeMap)) { + throw new \InvalidArgumentException('localeMap must be array', 1537277153); + } + if (!is_string($default ?? '')) { + throw new \InvalidArgumentException('default must be string', 1537277154); + } + + $this->settings = $settings; + $this->localeMap = $localeMap; + $this->default = $default; + } + + /** + * {@inheritdoc} + */ + public function modify(): ?string + { + $locale = (string)$this->siteLanguage->getLocale(); + foreach ($this->localeMap as $item) { + $pattern = '#^' . str_replace('_', '-', $item['locale']) . '#i'; + if (preg_match($pattern, $locale)) { + return (string)$item['value']; + } + } + return $this->default; + } +} diff --git a/Classes/Routing/Aspect/MappableAspectInterface.php b/Classes/Routing/Aspect/MappableAspectInterface.php new file mode 100644 index 0000000..75defc5 --- /dev/null +++ b/Classes/Routing/Aspect/MappableAspectInterface.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Aspects that have a mapping table (either static, or in the database). + */ +interface MappableAspectInterface extends AspectInterface +{ + public function generate(string $value): ?string; + + public function resolve(string $value): ?string; +} diff --git a/Classes/Routing/Aspect/MappableProcessor.php b/Classes/Routing/Aspect/MappableProcessor.php new file mode 100644 index 0000000..81f602d --- /dev/null +++ b/Classes/Routing/Aspect/MappableProcessor.php @@ -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\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Routing\Route; + +/** + * Helper class for resolving all aspects that are mappable. + */ +class MappableProcessor +{ + public function resolve(Route $route, array &$attributes): bool + { + $mappers = $this->fetchMappers($route, $attributes); + if (empty($mappers)) { + return true; + } + + $values = []; + foreach ($mappers as $variableName => $mapper) { + $value = $mapper->resolve( + (string)($attributes[$variableName] ?? '') + ); + if ($value === null) { + if (!$mapper instanceof UnresolvedValueInterface || !$mapper->hasFallbackValue()) { + return false; + } + $value = $mapper->getFallbackValue(); + } + $values[$variableName] = $value; + } + + $attributes = array_merge($attributes, $values); + return true; + } + + public function generate(Route $route, array &$attributes): bool + { + $mappers = $this->fetchMappers($route, $attributes); + if (empty($mappers)) { + return true; + } + + $values = []; + foreach ($mappers as $variableName => $mapper) { + $value = $mapper->generate( + (string)($attributes[$variableName] ?? '') + ); + if ($value === null) { + return false; + } + $values[$variableName] = $value; + } + + $attributes = array_merge($attributes, $values); + return true; + } + + /** + * @return MappableAspectInterface[] + */ + protected function fetchMappers(Route $route, array $attributes, string $type = MappableAspectInterface::class): array + { + if (empty($attributes)) { + return []; + } + /** @var MappableAspectInterface[] $result */ + $result = $route->filterAspects([$type], array_keys($attributes)); + return $result; + } +} diff --git a/Classes/Routing/Aspect/ModifiableAspectInterface.php b/Classes/Routing/Aspect/ModifiableAspectInterface.php new file mode 100644 index 0000000..74b64dd --- /dev/null +++ b/Classes/Routing/Aspect/ModifiableAspectInterface.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Interface that describes modifiers that provide static modifications + * to route paths based on a given context (current locale, context, ...). + */ +interface ModifiableAspectInterface extends AspectInterface +{ + public function modify(): ?string; +} diff --git a/Classes/Routing/Aspect/PersistedAliasMapper.php b/Classes/Routing/Aspect/PersistedAliasMapper.php new file mode 100644 index 0000000..d6d7542 --- /dev/null +++ b/Classes/Routing/Aspect/PersistedAliasMapper.php @@ -0,0 +1,296 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\LanguageAspectFactory; +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\FrontendGroupRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Site\SiteAwareInterface; +use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Classic usage when using a "URL segment" (e.g. slug) field within a database table. + * + * Example: + * routeEnhancers: + * EventsPlugin: + * type: Extbase + * extension: Events2 + * plugin: Pi1 + * routes: + * - { routePath: '/events/{event}', _controller: 'Event::detail', _arguments: {'event': 'event_name'}} + * defaultController: 'Events2::list' + * aspects: + * event: + * type: PersistedAliasMapper + * tableName: 'tx_events2_domain_model_event' + * routeFieldName: 'path_segment' + * routeValuePrefix: '/' + */ +class PersistedAliasMapper implements PersistedMappableAspectInterface, StaticMappableAspectInterface, SiteLanguageAwareInterface, SiteAwareInterface, UnresolvedValueInterface +{ + use AspectTrait; + use SiteLanguageAccessorTrait; + use SiteAccessorTrait; + use UnresolvedValueTrait; + + /** + * @var array + */ + protected $settings; + + /** + * @var string + */ + protected $tableName; + + /** + * @var string + */ + protected $routeFieldName; + + /** + * @var string + */ + protected $routeValuePrefix; + + /** + * @var string[] + */ + protected $persistenceFieldNames; + + /** + * @var string|null + */ + protected $languageFieldName; + + /** + * @var string|null + */ + protected $languageParentFieldName; + + /** + * @var bool + */ + protected $slugUniqueInSite; + + /** + * @throws \InvalidArgumentException + */ + public function __construct(array $settings) + { + $tableName = $settings['tableName'] ?? null; + $routeFieldName = $settings['routeFieldName'] ?? null; + $routeValuePrefix = $settings['routeValuePrefix'] ?? ''; + + if (!is_string($tableName)) { + throw new \InvalidArgumentException( + 'tableName must be string', + 1537277133 + ); + } + if (!is_string($routeFieldName)) { + throw new \InvalidArgumentException( + 'routeFieldName name must be string', + 1537277134 + ); + } + if (!is_string($routeValuePrefix) || strlen($routeValuePrefix) > 1) { + throw new \InvalidArgumentException( + '$routeValuePrefix must be string with one character', + 1537277136 + ); + } + + $this->settings = $settings; + $this->tableName = $tableName; + $this->routeFieldName = $routeFieldName; + $this->routeValuePrefix = $routeValuePrefix; + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName); + if ($schema->isLanguageAware()) { + $this->languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + $this->languageParentFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + } else { + $this->languageFieldName = null; + $this->languageParentFieldName = null; + } + $this->persistenceFieldNames = $this->buildPersistenceFieldNames(); + $this->slugUniqueInSite = $this->isSlugUniqueInSite($this->tableName, $this->routeFieldName); + } + + /** + * {@inheritdoc} + */ + public function generate(string $value): ?string + { + $result = $this->findByIdentifier($value); + $result = $this->resolveOverlay($result); + if (!isset($result[$this->routeFieldName])) { + return null; + } + return $this->purgeRouteValuePrefix( + (string)$result[$this->routeFieldName] + ); + } + + /** + * {@inheritdoc} + */ + public function resolve(string $value): ?string + { + $value = $this->routeValuePrefix . $this->purgeRouteValuePrefix($value); + $result = $this->findByRouteFieldValue($value); + $translatedValue = $this->languageParentFieldName ? ($result[$this->languageParentFieldName] ?? null) : null; + if ($translatedValue) { + return (string)$translatedValue; + } + if (isset($result['uid'])) { + return (string)$result['uid']; + } + return null; + } + + /** + * @return string[] + */ + protected function buildPersistenceFieldNames(): array + { + return array_filter([ + 'uid', + 'pid', + $this->routeFieldName, + $this->languageFieldName, + $this->languageParentFieldName, + ]); + } + + /** + * @return string + */ + protected function purgeRouteValuePrefix(?string $value): ?string + { + if (empty($this->routeValuePrefix) || $value === null) { + return $value; + } + return ltrim($value, $this->routeValuePrefix); + } + + protected function findByIdentifier(string $value): ?array + { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + + $queryBuilder = $this->createQueryBuilder(); + $result = $queryBuilder + ->select(...$this->persistenceFieldNames) + ->where($queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($value, Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchAssociative(); + return $result !== false ? $result : null; + } + + protected function findByRouteFieldValue(string $value): ?array + { + $languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null; + + $queryBuilder = $this->createQueryBuilder(); + $constraints = [ + $queryBuilder->expr()->eq( + $this->routeFieldName, + $queryBuilder->createNamedParameter($value) + ), + ]; + + $languageIds = null; + if ($languageAware) { + $languageIds = $this->resolveAllRelevantLanguageIds(); + $constraints[] = $queryBuilder->expr()->in( + $this->languageFieldName, + $queryBuilder->createNamedParameter($languageIds, Connection::PARAM_INT_ARRAY) + ); + } + + $results = $queryBuilder + ->select(...$this->persistenceFieldNames) + ->where(...$constraints) + ->executeQuery() + ->fetchAllAssociative(); + // limit results to be contained in rootPageId of current Site + // (which is defining the route configuration currently being processed) + if ($this->slugUniqueInSite) { + $results = array_values($this->filterContainedInSite($results)); + } + // return first result record in case table is not language aware + if (!$languageAware) { + return $results[0] ?? null; + } + // post-process language fallbacks + return $this->resolveLanguageFallback($results, $this->languageFieldName, $languageIds); + } + + protected function createQueryBuilder(): QueryBuilder + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->tableName) + ->from($this->tableName); + $queryBuilder->setRestrictions( + GeneralUtility::makeInstance(FrontendRestrictionContainer::class, GeneralUtility::makeInstance(Context::class)) + ); + // Frontend Groups are not available at this time + // So this must be excluded to allow access restricted records + $queryBuilder->getRestrictions()->removeByType(FrontendGroupRestriction::class); + return $queryBuilder; + } + + protected function resolveOverlay(?array $record): ?array + { + $languageId = $this->siteLanguage->getLanguageId(); + if ($record === null || $languageId === 0) { + return $record; + } + + $pageRepository = $this->createPageRepository(); + return $pageRepository->getLanguageOverlay($this->tableName, $record) ?: null; + } + + protected function createPageRepository(): PageRepository + { + $context = clone GeneralUtility::makeInstance(Context::class); + $context->setAspect( + 'language', + LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage) + ); + return GeneralUtility::makeInstance( + PageRepository::class, + $context + ); + } +} diff --git a/Classes/Routing/Aspect/PersistedMappableAspectInterface.php b/Classes/Routing/Aspect/PersistedMappableAspectInterface.php new file mode 100644 index 0000000..8fe3088 --- /dev/null +++ b/Classes/Routing/Aspect/PersistedMappableAspectInterface.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Used for anything that invokes (more expensive) persistence invocations. + * Basically used to improve performance by deferring their execution. + */ +interface PersistedMappableAspectInterface extends MappableAspectInterface {} diff --git a/Classes/Routing/Aspect/PersistedPatternMapper.php b/Classes/Routing/Aspect/PersistedPatternMapper.php new file mode 100644 index 0000000..68e67eb --- /dev/null +++ b/Classes/Routing/Aspect/PersistedPatternMapper.php @@ -0,0 +1,328 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\LanguageAspectFactory; +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\FrontendGroupRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Site\SiteAwareInterface; +use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Very useful for building a path segment from a combined value of the database. + * Please note: title is not prepared for slugs and used raw. + * + * Example: + * routeEnhancers: + * EventsPlugin: + * type: Extbase + * extension: Events2 + * plugin: Pi1 + * routes: + * - { routePath: '/events/{event}', _controller: 'Event::detail', _arguments: {'event': 'event_name'}} + * defaultController: 'Events2::list' + * aspects: + * event: + * type: PersistedPatternMapper + * tableName: 'tx_events2_domain_model_event' + * routeFieldPattern: '^(?P<title>.+)-(?P<uid>\d+)$' + * routeFieldResult: '{title}-{uid}' + * + * @internal might change its options in the future, be aware that there might be modifications. + */ +class PersistedPatternMapper implements PersistedMappableAspectInterface, StaticMappableAspectInterface, SiteLanguageAwareInterface, SiteAwareInterface, UnresolvedValueInterface +{ + use AspectTrait; + use SiteLanguageAccessorTrait; + use SiteAccessorTrait; + use UnresolvedValueTrait; + + protected const PATTERN_RESULT = '#\{(?P<fieldName>[^}]+)\}#'; + + /** + * @var array + */ + protected $settings; + + /** + * @var string + */ + protected $tableName; + + /** + * @var string + */ + protected $routeFieldPattern; + + /** + * @var string + */ + protected $routeFieldResult; + + /** + * @var string[] + */ + protected $routeFieldResultNames; + + /** + * @var string|null + */ + protected $languageFieldName; + + /** + * @var string|null + */ + protected $languageParentFieldName; + + /** + * @var bool + */ + protected $slugUniqueInSite; + + /** + * @throws \InvalidArgumentException + */ + public function __construct(array $settings) + { + $tableName = $settings['tableName'] ?? null; + $routeFieldPattern = $settings['routeFieldPattern'] ?? null; + $routeFieldResult = $settings['routeFieldResult'] ?? null; + + if (!is_string($tableName)) { + throw new \InvalidArgumentException('tableName must be string', 1537277173); + } + if (!is_string($routeFieldPattern)) { + throw new \InvalidArgumentException('routeFieldPattern must be string', 1537277174); + } + if (!is_string($routeFieldResult)) { + throw new \InvalidArgumentException('routeFieldResult must be string', 1537277175); + } + if (!preg_match_all(static::PATTERN_RESULT, $routeFieldResult, $routeFieldResultNames)) { + throw new \InvalidArgumentException( + 'routeFieldResult must contain substitutable field names', + 1537962752 + ); + } + + $this->settings = $settings; + $this->tableName = $tableName; + $this->routeFieldPattern = $routeFieldPattern; + $this->routeFieldResult = $routeFieldResult; + $this->routeFieldResultNames = $routeFieldResultNames['fieldName'] ?? []; + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName); + if ($schema->isLanguageAware()) { + $this->languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + $this->languageParentFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(); + } else { + $this->languageFieldName = null; + $this->languageParentFieldName = null; + } + $this->slugUniqueInSite = $this->hasSlugUniqueInSite($this->tableName, ...$this->routeFieldResultNames); + } + + /** + * {@inheritdoc} + */ + public function generate(string $value): ?string + { + $result = $this->findByIdentifier($value); + $result = $this->resolveOverlay($result); + return $this->createRouteResult($result); + } + + /** + * {@inheritdoc} + */ + public function resolve(string $value): ?string + { + if (!preg_match('#' . $this->routeFieldPattern . '#', $value, $matches)) { + return null; + } + $values = $this->filterNamesKeys($matches); + $result = $this->findByRouteFieldValues($values); + if (($result[$this->languageParentFieldName] ?? null) > 0) { + return (string)$result[$this->languageParentFieldName]; + } + if (isset($result['uid'])) { + return (string)$result['uid']; + } + return null; + } + + /** + * @throws \InvalidArgumentException + */ + protected function createRouteResult(?array $result): ?string + { + if ($result === null) { + return $result; + } + $substitutes = []; + foreach ($this->routeFieldResultNames as $fieldName) { + if (!isset($result[$fieldName])) { + return null; + } + $routeFieldName = '{' . $fieldName . '}'; + $substitutes[$routeFieldName] = $result[$fieldName]; + } + return str_replace( + array_keys($substitutes), + array_values($substitutes), + $this->routeFieldResult + ); + } + + protected function filterNamesKeys(array $array): array + { + return array_filter( + $array, + static function ($key) { + return !is_numeric($key); + }, + ARRAY_FILTER_USE_KEY + ); + } + + protected function findByIdentifier(string $value): ?array + { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + + $queryBuilder = $this->createQueryBuilder(); + $result = $queryBuilder + ->select('*') + ->where($queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($value, Connection::PARAM_INT) + )) + ->executeQuery() + ->fetchAssociative(); + return $result !== false ? $result : null; + } + + protected function findByRouteFieldValues(array $values): ?array + { + $languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null; + + $queryBuilder = $this->createQueryBuilder(); + $results = $queryBuilder + ->select('*') + ->where(...$this->createRouteFieldConstraints($queryBuilder, $values)) + ->executeQuery() + ->fetchAllAssociative(); + // limit results to be contained in rootPageId of current Site + // (which is defining the route configuration currently being processed) + if ($this->slugUniqueInSite) { + $results = array_values($this->filterContainedInSite($results)); + } + // return first result record in case table is not language aware + if (!$languageAware) { + return $results[0] ?? null; + } + // post-process language fallbacks + $languageIds = $this->resolveAllRelevantLanguageIds(); + return $this->resolveLanguageFallback($results, $this->languageFieldName, $languageIds); + } + + protected function createQueryBuilder(): QueryBuilder + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->tableName) + ->from($this->tableName); + $queryBuilder->setRestrictions( + GeneralUtility::makeInstance(FrontendRestrictionContainer::class, GeneralUtility::makeInstance(Context::class)) + ); + // Frontend Groups are not available at this time + // So this must be excluded to allow access restricted records + $queryBuilder->getRestrictions()->removeByType(FrontendGroupRestriction::class); + return $queryBuilder; + } + + protected function createRouteFieldConstraints(QueryBuilder $queryBuilder, array $values): array + { + $languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null; + $languageExpansion = $languageAware && isset($values['uid']); + + $constraints = []; + foreach ($values as $fieldName => $fieldValue) { + if ($languageExpansion && $fieldName === 'uid') { + continue; + } + $constraints[] = $queryBuilder->expr()->eq( + $fieldName, + $queryBuilder->createNamedParameter( + $fieldValue + ) + ); + } + // either match uid or language parent field value (for any language) + if ($languageExpansion) { + $idParameter = $queryBuilder->createNamedParameter( + $values['uid'], + Connection::PARAM_INT + ); + $constraints[] = $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('uid', $idParameter), + $queryBuilder->expr()->eq($this->languageParentFieldName, $idParameter) + ); + // otherwise - basically uid is not in pattern - restrict to languages and apply fallbacks + } elseif ($languageAware) { + $languageIds = $this->resolveAllRelevantLanguageIds(); + $constraints[] = $queryBuilder->expr()->in( + $this->languageFieldName, + $queryBuilder->createNamedParameter($languageIds, Connection::PARAM_INT_ARRAY) + ); + } + + return $constraints; + } + + protected function resolveOverlay(?array $record): ?array + { + $languageId = $this->siteLanguage->getLanguageId(); + if ($record === null || $languageId === 0) { + return $record; + } + + $pageRepository = $this->createPageRepository(); + return $pageRepository->getLanguageOverlay($this->tableName, $record) ?: null; + } + + protected function createPageRepository(): PageRepository + { + $context = clone GeneralUtility::makeInstance(Context::class); + $context->setAspect( + 'language', + LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage) + ); + return GeneralUtility::makeInstance( + PageRepository::class, + $context + ); + } +} diff --git a/Classes/Routing/Aspect/SiteAccessorTrait.php b/Classes/Routing/Aspect/SiteAccessorTrait.php new file mode 100644 index 0000000..0136616 --- /dev/null +++ b/Classes/Routing/Aspect/SiteAccessorTrait.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Routing\SiteMatcher; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Helper trait to use a site within a class. + * + * @internal this is not public API yet as this might change, and could be changed within TYPO3 Core at any time. + */ +trait SiteAccessorTrait +{ + /** + * @var Site + */ + protected $site; + + /** + * @var SiteMatcher|null + */ + protected $siteMatcher; + + public function setSite(Site $site): void + { + $this->site = $site; + } + + public function getSite(): Site + { + return $this->site; + } + + /** + * Filters records that are contained in current site + * (resolved from current SiteLanguage). + * + * Results keep original indexes and probably needs to + * be passed through `array_values` for e.g. using the + * first result by `$results[0]`. + * + * @param array $results + */ + protected function filterContainedInSite(array $results): array + { + if (empty($results)) { + return $results; + } + return array_filter( + $results, + function (array $result) { + // default FrontendRestrictionContainer retrieves only live records + // (no specific workspace & move-placeholder resolving required here) + $pageId = (int)$result['pid']; + return $this->isPageIdContainedInSite($pageId); + } + ); + } + + /** + * Determines whether page is contained in current site + * (resolved from current SiteLanguage). + */ + protected function isPageIdContainedInSite(int $pageId): bool + { + try { + $expectedSite = $this->getSiteMatcher()->matchByPageId($pageId); + return $expectedSite->getRootPageId() === $this->site->getRootPageId(); + } catch (SiteNotFoundException $exception) { + // Same as in \TYPO3\CMS\Core\DataHandling\SlugHelper::isUniqueInSite + // where it is assumed that a record, that is not in site context, + // but still configured uniqueInSite is unique. We therefore must assume + // the resolved record to be rightfully part of the current site. + return true; + } + } + + protected function getSiteMatcher(): SiteMatcher + { + if (!isset($this->siteMatcher)) { + $this->siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class); + } + return $this->siteMatcher; + } +} diff --git a/Classes/Routing/Aspect/SiteLanguageAccessorTrait.php b/Classes/Routing/Aspect/SiteLanguageAccessorTrait.php new file mode 100644 index 0000000..58ab0c9 --- /dev/null +++ b/Classes/Routing/Aspect/SiteLanguageAccessorTrait.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Context\LanguageAspect; +use TYPO3\CMS\Core\Context\LanguageAspectFactory; +use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait; +use TYPO3\CMS\Core\Utility\MathUtility; + +trait SiteLanguageAccessorTrait +{ + use SiteLanguageAwareTrait; + + /** + * @var LanguageAspect + */ + protected $languageAspect; + + /** + * Resolves one record out of given language fallbacks. + */ + protected function resolveLanguageFallback(array $results, ?string $languageFieldName, ?array $languageIds): ?array + { + if ($results === []) { + return null; + } + if ($languageFieldName === null || $languageIds === null) { + return $results[0]; + } + usort( + $results, + // orders records by there occurrence in $languageFallbackIds + static function (array $a, array $b) use ($languageFieldName, $languageIds): int { + $languageA = (int)$a[$languageFieldName]; + $languageB = (int)$b[$languageFieldName]; + return array_search($languageA, $languageIds, true) + - array_search($languageB, $languageIds, true); + } + ); + return $results[0]; + } + + /** + * Resolves all language ids that are relevant to retrieve the most specific variant of a record. + * The order of these ids defines the processing order concerning language fallback - most specific + * language comes first in this array. + * + * + "all language (-1)", most specific if present since there cannot be any localizations + * + "current language" most specific for the current given request context + * + "language fallbacks" falling back to language alternatives (might include "default language") + * + * @return int[] + */ + protected function resolveAllRelevantLanguageIds() + { + $languageIds = [-1, $this->siteLanguage->getLanguageId()]; + foreach ($this->getLanguageAspect()->getFallbackChain() as $item) { + if (in_array($item, $languageIds, true) || !MathUtility::canBeInterpretedAsInteger($item)) { + continue; + } + $languageIds[] = (int)$item; + } + return $languageIds; + } + + /** + * Provides LanguageAspect which contains the logic how fallbacks + * for a given context/overlay-mode shall be handled. + * + * @see LanguageAspectFactory::createFromSiteLanguage + */ + protected function getLanguageAspect(): LanguageAspect + { + if ($this->languageAspect === null) { + $this->languageAspect = LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage); + } + return $this->languageAspect; + } +} diff --git a/Classes/Routing/Aspect/StaticMappableAspectInterface.php b/Classes/Routing/Aspect/StaticMappableAspectInterface.php new file mode 100644 index 0000000..eea9ac1 --- /dev/null +++ b/Classes/Routing/Aspect/StaticMappableAspectInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Used for anything that has a fixed list of values mapped against route arguments. + */ +interface StaticMappableAspectInterface extends MappableAspectInterface {} diff --git a/Classes/Routing/Aspect/StaticRangeMapper.php b/Classes/Routing/Aspect/StaticRangeMapper.php new file mode 100644 index 0000000..4ff155e --- /dev/null +++ b/Classes/Routing/Aspect/StaticRangeMapper.php @@ -0,0 +1,166 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Very useful for e.g. pagination or static range like "2011 ... 2030" for years. + * + * Example: + * routeEnhancers: + * MyBlogPlugin: + * type: Extbase + * extension: BlogExample + * plugin: Pi1 + * routes: + * - { routePath: '/list/{paging_widget}', _controller: 'BlogExample::list', _arguments: {'paging_widget': '@widget_0/currentPage'}} + * - { routePath: '/glossary/{section}', _controller: 'BlogExample::glossary'} + * defaultController: 'BlogExample::list' + * requirements: + * paging_widget: '\d+' + * aspects: + * paging_widget: + * type: StaticRangeMapper + * start: '1' + * end: '100' + * section: + * type: StaticRangeMapper + * start: 'a' + * end: 'z' + */ +class StaticRangeMapper implements StaticMappableAspectInterface, \Countable +{ + /** + * @var array + */ + protected $settings; + + /** + * @var string + */ + protected $start; + + /** + * @var string + */ + protected $end; + + /** + * @var string[] + */ + protected $range; + + /** + * @throws \InvalidArgumentException + */ + public function __construct(array $settings) + { + $start = $settings['start'] ?? null; + $end = $settings['end'] ?? null; + + if (!is_string($start)) { + throw new \InvalidArgumentException('start must be string', 1537277163); + } + if (!is_string($end)) { + throw new \InvalidArgumentException('end must be string', 1537277164); + } + + $this->settings = $settings; + $this->start = $start; + $this->end = $end; + $this->range = $this->applyNumericPrefix($this->buildRange()); + } + + /** + * {@inheritdoc} + */ + public function count(): int + { + return count($this->range); + } + + /** + * {@inheritdoc} + */ + public function generate(string $value): ?string + { + return $this->respondWhenInRange($value); + } + + /** + * {@inheritdoc} + */ + public function resolve(string $value): ?string + { + return $this->respondWhenInRange($value); + } + + protected function respondWhenInRange(string $value): ?string + { + if (in_array($value, $this->range, true)) { + return $value; + } + return null; + } + + /** + * Builds range based on given settings and ensures each item is string. + * The amount of items is limited to 1000 in order to avoid brute-force + * scenarios and the risk of cache-flooding. + * + * In case that is not enough, creating a custom and more specific mapper + * is encouraged. Using high values that are not distinct exposes the site + * to the risk of cache-flooding. + * + * @return string[] + * @throws \LengthException + */ + protected function buildRange(): array + { + $range = array_map('strval', range($this->start, $this->end)); + if (count($range) > 1000) { + throw new \LengthException( + 'Range is larger than 1000 items', + 1537696771 + ); + } + return $range; + } + + /** + * @return string[] + */ + protected function applyNumericPrefix(array $range): array + { + if (!preg_match('#^\d+$#', $this->start) + || !preg_match('#^\d+$#', $this->end) + || $this->start === '0' || $this->end === '0' + || $this->start[0] !== '0' && $this->end[0] !== '0' + ) { + return $range; + } + + $length = strlen(max($this->start, $this->end)); + $range = array_map( + static function ($value) use ($length) { + return str_pad($value, $length, '0', STR_PAD_LEFT); + }, + $range + ); + return $range; + } +} diff --git a/Classes/Routing/Aspect/StaticValueMapper.php b/Classes/Routing/Aspect/StaticValueMapper.php new file mode 100644 index 0000000..f14d040 --- /dev/null +++ b/Classes/Routing/Aspect/StaticValueMapper.php @@ -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\Core\Routing\Aspect; + +use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; +use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait; + +/** + * Mapper for having a static list of mapping them to value properties. + * + * routeEnhancers: + * MyBlogExample: + * type: Extbase + * extension: BlogExample + * plugin: Pi1 + * routes: + * - { routePath: '/archive/{year}', _controller: 'Blog::archive' } + * defaultController: 'Blog::list' + * aspects: + * year: + * type: StaticValueMapper + * map: + * 2k17: '2017' + * 2k18: '2018' + * next: '2019' + * # (optional) + * localeMap: + * - locale: 'en_US.*|en_GB.*' + * map: + * twenty-seventeen: '2017' + * twenty-eighteen: '2018' + * next: '2019' + * - locale: 'fr_FR' + * map: + * vingt-dix-sept: '2017' + * vingt-dix-huit: '2018' + * prochain: '2019' + */ +class StaticValueMapper implements StaticMappableAspectInterface, SiteLanguageAwareInterface, UnresolvedValueInterface, \Countable +{ + use SiteLanguageAwareTrait; + use UnresolvedValueTrait; + + /** + * @var array + */ + protected $settings; + + /** + * @var array + */ + protected $map; + + /** + * @var array + */ + protected $localeMap; + + /** + * @throws \InvalidArgumentException + */ + public function __construct(array $settings) + { + $map = $settings['map'] ?? null; + $localeMap = $settings['localeMap'] ?? []; + + if (!is_array($map)) { + throw new \InvalidArgumentException('map must be array', 1537277143); + } + if (!is_array($localeMap)) { + throw new \InvalidArgumentException('localeMap must be array', 1537277144); + } + + $this->settings = $settings; + $this->map = array_map('strval', $map); + $this->localeMap = $localeMap; + } + + /** + * {@inheritdoc} + */ + public function count(): int + { + return count($this->retrieveLocaleMap() ?? $this->map); + } + + /** + * {@inheritdoc} + */ + public function generate(string $value): ?string + { + $map = $this->retrieveLocaleMap() ?? $this->map; + $index = array_search($value, $map, true); + return $index !== false ? (string)$index : null; + } + + /** + * {@inheritdoc} + */ + public function resolve(string $value): ?string + { + $map = $this->retrieveLocaleMap() ?? $this->map; + return isset($map[$value]) ? (string)$map[$value] : null; + } + + /** + * Fetches the map of with the matching locale. + */ + protected function retrieveLocaleMap(): ?array + { + $locale = (string)$this->siteLanguage->getLocale(); + foreach ($this->localeMap as $item) { + $pattern = '#^' . str_replace('_', '-', $item['locale']) . '#i'; + if (preg_match($pattern, $locale)) { + return array_map('strval', $item['map']); + } + } + return null; + } +} diff --git a/Classes/Routing/Aspect/UnresolvedValueInterface.php b/Classes/Routing/Aspect/UnresolvedValueInterface.php new file mode 100644 index 0000000..f4ce7cd --- /dev/null +++ b/Classes/Routing/Aspect/UnresolvedValueInterface.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Aspect; + +/** + * Provides fallback values for unresolved values during processing mappers. + */ +interface UnresolvedValueInterface +{ + public function hasFallbackValue(): bool; + public function getFallbackValue(): ?string; +} diff --git a/Classes/Routing/Aspect/UnresolvedValueTrait.php b/Classes/Routing/Aspect/UnresolvedValueTrait.php new file mode 100644 index 0000000..05bc8a0 --- /dev/null +++ b/Classes/Routing/Aspect/UnresolvedValueTrait.php @@ -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\Core\Routing\Aspect; + +/** + * Provides fallback values for unresolved values during processing mappers. + */ +trait UnresolvedValueTrait +{ + /** + * @var array{fallbackValue?: ?scalar} + */ + protected $settings; + + public function hasFallbackValue(): bool + { + return array_key_exists('fallbackValue', $this->settings); + } + + public function getFallbackValue(): ?string + { + if (!$this->hasFallbackValue()) { + throw new \LogicException('Property fallbackValue must be defined', 1668084601); + } + /** @var mixed $fallbackValue */ + $fallbackValue = $this->settings['fallbackValue']; + if (is_string($fallbackValue) || is_null($fallbackValue)) { + return $fallbackValue; + } + return (string)$fallbackValue; + } +} diff --git a/Classes/Routing/BackendEntryPointResolver.php b/Classes/Routing/BackendEntryPointResolver.php new file mode 100644 index 0000000..966c239 --- /dev/null +++ b/Classes/Routing/BackendEntryPointResolver.php @@ -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\Core\Routing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Http\Uri; + +/** + * This class helps to resolve the virtual path to the main entry point of the TYPO3 Backend. + */ +#[Autoconfigure(public: true)] +class BackendEntryPointResolver +{ + protected string $entryPoint = '/typo3'; + + /** + * Returns a prefix such as /typo3/ or /mysubdir/typo3/ to the TYPO3 Backend with trailing slash. + */ + public function getPathFromRequest(ServerRequestInterface $request): string + { + $entryPoint = $this->getEntryPoint($request); + if (str_contains($entryPoint, '//')) { + $entryPointParts = parse_url($entryPoint); + /* Remove trailing slash unless, the string is '/' itself */ + $entryPoint = rtrim('/' . trim($entryPointParts['path'] ?? '', '/'), '/'); + } + return $entryPoint . '/'; + } + + /** + * Returns a full URL to the main URL of the TYPO3 Backend. + */ + public function getUriFromRequest(ServerRequestInterface $request, string $additionalPathPart = ''): UriInterface + { + $entryPoint = $this->getEntryPointConfiguration(); + if (str_starts_with($entryPoint, 'https://') || str_starts_with($entryPoint, 'http://')) { + // fqdn, early return as all required information are available. + return new Uri($entryPoint . '/' . ltrim($additionalPathPart, '/')); + } + if ($request->getAttribute('normalizedParams') instanceof NormalizedParams) { + $normalizedParams = $request->getAttribute('normalizedParams'); + } else { + $normalizedParams = NormalizedParams::createFromRequest($request); + } + if (str_starts_with($entryPoint, '//')) { + // Browser supports uri starting with `//` and uses the current request scheme for the link. Do avoid issue + // for example checking the url at some point we prefix it with the current request protocol. + return new Uri(($normalizedParams->isHttps() ? 'https:' : 'http:') . $entryPoint . '/' . ltrim($additionalPathPart, '/')); + } + return new Uri($normalizedParams->getSiteUrl() . $entryPoint . '/' . ltrim($additionalPathPart, '/')); + } + + public function isBackendRoute(ServerRequestInterface $request): bool + { + return $this->getBackendRoutePath($request) !== null; + } + + public function getBackendRoutePath(ServerRequestInterface $request): ?string + { + $uri = $request->getUri(); + $path = $uri->getPath(); + $entryPoint = $this->getEntryPoint($request); + + if (str_contains($entryPoint, '//')) { + $entryPointParts = parse_url($entryPoint); + if ($uri->getHost() !== $entryPointParts['host']) { + return null; + } + /* Remove trailing slash unless, the string is '/' itself */ + $entryPoint = rtrim('/' . trim($entryPointParts['path'] ?? '', '/'), '/'); + } + + if ($path === $entryPoint) { + return ''; + } + if (str_starts_with($path, $entryPoint . '/')) { + return substr($path, strlen($entryPoint)); + } + return null; + } + + /** + * Returns a prefix such as /typo3 or /mysubdir/typo3 to the TYPO3 Backend *without* trailing slash. + */ + protected function getEntryPoint(ServerRequestInterface $request): string + { + $entryPoint = $this->getEntryPointConfiguration(); + if (str_contains($entryPoint, '//')) { + return $entryPoint; + } + if ($request->getAttribute('normalizedParams') instanceof NormalizedParams) { + $normalizedParams = $request->getAttribute('normalizedParams'); + } else { + $normalizedParams = NormalizedParams::createFromRequest($request); + } + return $normalizedParams->getSitePath() . $entryPoint; + } + + protected function getEntryPointConfiguration(): string + { + $entryPoint = $GLOBALS['TYPO3_CONF_VARS']['BE']['entryPoint'] ?? $this->entryPoint; + if (str_starts_with($entryPoint, 'https://') + || str_starts_with($entryPoint, 'http://') + || str_starts_with($entryPoint, '//') + ) { + $uri = new Uri(rtrim($entryPoint, '/')); + $uri = $uri->withPath($this->removeMultipleSlashes($uri->getPath())); + return (string)$uri; + } + return $this->removeMultipleSlashes(trim($entryPoint, '/')); + } + + private function removeMultipleSlashes(string $value): string + { + return preg_replace('/(\/+)/', '/', $value); + } +} diff --git a/Classes/Routing/BestUrlMatcher.php b/Classes/Routing/BestUrlMatcher.php new file mode 100644 index 0000000..fd6c317 --- /dev/null +++ b/Classes/Routing/BestUrlMatcher.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Symfony\Component\Routing\Matcher\RedirectableUrlMatcherInterface; +use Symfony\Component\Routing\Matcher\UrlMatcher; +use Symfony\Component\Routing\RouteCollection as SymfonyRouteCollection; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class BestUrlMatcher extends UrlMatcher +{ + protected function matchCollection(string $pathinfo, SymfonyRouteCollection $routes): array + { + $matchedRoutes = $this->preMatchCollection($pathinfo, $routes); + $matches = count($matchedRoutes); + if ($matches === 0) { + return []; + } + if ($matches === 1) { + return $matchedRoutes[0]->getRouteResult(); + } + usort($matchedRoutes, [$this, 'sortMatchedRoutes']); + return array_shift($matchedRoutes)->getRouteResult(); + } + + /** + * Tries to match a URL with a set of routes. + * Basically all code has been duplicated from `UrlMatcher::matchCollection`, the difference is + * it does not just return the first match, but return all possible matches for further reduction. + * + * @param string $pathinfo The path info to be parsed + * @return list<MatchedRoute> + */ + protected function preMatchCollection(string $pathinfo, SymfonyRouteCollection $routes): array + { + $matchedRoutes = []; + + // HEAD and GET are equivalent as per RFC + $method = $this->context->getMethod(); + if ($method === 'HEAD') { + $method = 'GET'; + } + $supportsTrailingSlash = $method === 'GET' && $this instanceof RedirectableUrlMatcherInterface; + $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/'; + + foreach ($routes as $name => $route) { + $compiledRoute = $route->compile(); + $staticPrefix = rtrim($compiledRoute->getStaticPrefix(), '/'); + $requiredMethods = $route->getMethods(); + + // check the static prefix of the URL first. Only use the more expensive preg_match when it matches + if ($staticPrefix !== '' && !str_starts_with($trimmedPathinfo, $staticPrefix)) { + continue; + } + $regex = $compiledRoute->getRegex(); + + $pos = strrpos($regex, '$'); + $hasTrailingSlash = $regex[$pos - 1] === '/'; + $regex = substr_replace($regex, '/?$', $pos - $hasTrailingSlash, 1 + $hasTrailingSlash); + + if (!preg_match($regex, $pathinfo, $matches)) { + continue; + } + + $hasTrailingVar = $trimmedPathinfo !== $pathinfo && preg_match('#\{[\w\x80-\xFF]+\}/?$#', $route->getPath()); + + if ($hasTrailingVar && ($hasTrailingSlash || (null === $m = $matches[count($compiledRoute->getPathVariables())] ?? null) || '/' !== ($m[-1] ?? '/')) && preg_match($regex, $trimmedPathinfo, $m)) { + if ($hasTrailingSlash) { + $matches = $m; + } else { + $hasTrailingVar = false; + } + } + + $hostMatches = []; + if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) { + continue; + } + + $attributes = $this->getAttributes($route, $name, array_replace($matches, $hostMatches)); + + $status = $this->handleRouteRequirements($pathinfo, $name, $route, $attributes); + + if ($status[0] === self::REQUIREMENT_MISMATCH) { + continue; + } + + if ($pathinfo !== '/' && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) { + if ($supportsTrailingSlash && (!$requiredMethods || in_array('GET', $requiredMethods))) { + return $this->allow = $this->allowSchemes = []; + } + continue; + } + + if ($route->getSchemes() && !$route->hasScheme($this->context->getScheme())) { + $this->allowSchemes = array_merge($this->allowSchemes, $route->getSchemes()); + continue; + } + + if ($requiredMethods && !in_array($method, $requiredMethods)) { + $this->allow = array_merge($this->allow, $requiredMethods); + continue; + } + + $matchedRoute = GeneralUtility::makeInstance( + MatchedRoute::class, + $route, + array_replace($attributes, $status[1] ?? []) + ); + $matchedRoutes[] = $matchedRoute->withPathMatches($matches)->withHostMatches($hostMatches); + } + + return $matchedRoutes; + } + + /** + * Sorts the best matching route result to the beginning + */ + protected function sortMatchedRoutes(MatchedRoute $a, MatchedRoute $b): int + { + if ($a->getFallbackScore() !== $b->getFallbackScore()) { + // sort fallbacks to the end + return $a->getFallbackScore() <=> $b->getFallbackScore(); + } + if ($b->getHostMatchScore() !== $a->getHostMatchScore()) { + // sort more specific host matches to the beginning + return $b->getHostMatchScore() <=> $a->getHostMatchScore(); + } + // index `1` refers to the array index containing the corresponding `tail` match + // @todo not sure, whether `tail` can be defined generic, it's hard coded in `SiteMatcher` + if ($b->getPathMatchScore(1) !== $a->getPathMatchScore(1)) { + return $b->getPathMatchScore(1) <=> $a->getPathMatchScore(1); + } + // fallback for behavior prior to issue #93240, using reverse sorted site identifier + // (side note: site identifier did not contain any URL relevant information) + return $b->getSiteIdentifier() <=> $a->getSiteIdentifier(); + } +} diff --git a/Classes/Routing/Enhancer/AbstractEnhancer.php b/Classes/Routing/Enhancer/AbstractEnhancer.php new file mode 100644 index 0000000..f91bb30 --- /dev/null +++ b/Classes/Routing/Enhancer/AbstractEnhancer.php @@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\Aspect\AspectInterface; +use TYPO3\CMS\Core\Routing\Aspect\ModifiableAspectInterface; +use TYPO3\CMS\Core\Routing\Route; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Abstract Enhancer, useful for custom enhancers + */ +abstract class AbstractEnhancer implements EnhancerInterface +{ + /** + * @var AspectInterface[] + */ + protected array $aspects = []; + + /** + * @var VariableProcessor|null + */ + protected $variableProcessor; + + /** + * @param AspectInterface[] $aspects + * @param string|null $namespace + */ + protected function applyRouteAspects(Route $route, array $aspects, ?string $namespace = null) + { + if (empty($aspects)) { + return; + } + $aspects = $this->getVariableProcessor() + ->deflateKeys($aspects, $namespace, $route->getArguments()); + $route->setAspects($aspects); + } + + /** + * @param string|null $namespace + */ + protected function applyRequirements(Route $route, array $requirements, ?string $namespace = null) + { + $requirements = $this->getVariableProcessor() + ->deflateKeys($requirements, $namespace, $route->getArguments()); + // only keep requirements that are actually part of the current route path + $requirements = $this->filterValuesByPathVariables($route, $requirements); + // Symfony's behavior on applying pattern for parameters just concerns values + // to be passed either to URL or to internal parameters - they are always the + // same, without any transformation. + // + // TYPO3 extends ("enhances") this behavior by making a difference between values + // for generation (resulting in a URL) and matching (resulting in query parameters) + // having the following implications and meaning: + // + // + since requirements in classic Symfony focus on parameters in URLs + // and aspects define a mapping between URL part (e.g. 'some-example-news') + // and the corresponding internal argument (e.g. 'tx_news_pi1[news]=123') + // + thus, the requirement definition cannot be used for resolving and generating + // a route at the same time (it would have to be e.g. `[\w_._]+` AND `\d+`) + // + // Symfony's default regular expression pattern `[^/]+` (see + // `RouteCompiler::compilePattern()`) has to be overridden with `.+` to + // allow URI parameters like `some-example-news/january` as well. + // + // Existing `requirements` for TYPO3 route enhancers are not modified, only those + // that are not defined and would use Symfony's default pattern. + $requirements = $this->defineValuesByAspect($route, $requirements, '.+'); + $route->setRequirements($requirements); + } + + /** + * Applies variables that are considered static (not having `&cHash=...` applied), + * without having the demand to define a custom `StaticMappableAspectInterface` + * to fake the behavior. + * + * However: + * + in case there's an aspect defined for a variable, it will be skipped (aspects take precedence) + * + in case not requirement is defined for a variable, it will be skipped (avoiding weak definitions) + * + * @param array<non-empty-string, bool> $staticVariables option values + */ + protected function applyStaticVariables(Route $route, array $staticVariables, ?string $namespace = null): void + { + // skip definitions that are not explicitly set to `true` + $staticVariables = array_filter($staticVariables, static fn($definition) => $definition === true); + $staticVariables = $this->getVariableProcessor() + ->deflateKeys($staticVariables, $namespace, $route->getArguments()); + // only keep static variables that are actually part of the current route path + $staticVariables = $this->filterValuesByPathVariables($route, $staticVariables); + // skip definitions that already have an aspect defined (aspects take precedence) + $staticVariables = array_diff_key($staticVariables, $route->getAspects()); + // skip definitions that not have any requirement defined (avoiding weak definitions) + $staticVariables = array_intersect_key($staticVariables, $route->getRequirements()); + $route->setOption('_static', $staticVariables); + } + + /** + * Only keeps values that actually have been used as variables in route path. + * + * + routePath: '/list/{page}' ('page' used as variable in route path) + * + values: ['entity' => 'entity...', 'page' => 'page...', 'other' => 'other...'] + * + result: ['page' => 'page...'] + * + * @param Route $route + * @param array $values + */ + protected function filterValuesByPathVariables(Route $route, array $values): array + { + return array_intersect_key( + $values, + array_flip($route->compile()->getPathVariables()) + ); + } + + /** + * Overrides items having an aspect definition with a given + * $overrideValue in target $targetValue array. + */ + protected function overrideValuesByAspect(Route $route, array $values, string $targetValue): array + { + foreach (array_keys($route->getAspects()) as $variableName) { + $values[$variableName] = $targetValue; + } + return $values; + } + + /** + * Define items having an aspect definition in case they are not defined + * with a given $targetValue in target $targetValue array. + */ + protected function defineValuesByAspect(Route $route, array $values, string $targetValue): array + { + foreach (array_keys($route->getAspects()) as $variableName) { + if (isset($values[$variableName])) { + continue; + } + $values[$variableName] = $targetValue; + } + return $values; + } + + /** + * Modify the route path to add the variable names with the aspects, e.g. + * + * + `/{locale_modifier}/{product_title}` -> `/products/{product_title}` + * + `/{!locale_modifier}/{product_title}` -> `/products/{product_title}` + * + * @param string $routePath + */ + protected function modifyRoutePath(string $routePath): string + { + $substitutes = []; + foreach ($this->aspects as $variableName => $aspect) { + if (!$aspect instanceof ModifiableAspectInterface) { + continue; + } + $value = $aspect->modify(); + if ($value !== null) { + $substitutes['{' . $variableName . '}'] = $value; + $substitutes['{!' . $variableName . '}'] = $value; + } + } + return str_replace( + array_keys($substitutes), + array_values($substitutes), + $routePath + ); + } + + /** + * Retrieves type from processed route and modifies remaining query parameters. + * + * @param array $remainingQueryParameters reference to remaining query parameters + */ + protected function resolveType(Route $route, array &$remainingQueryParameters): string + { + $type = $remainingQueryParameters['type'] ?? 0; + $decoratedParameters = $route->getOption('_decoratedParameters'); + if (isset($decoratedParameters['type'])) { + $type = $decoratedParameters['type']; + unset($decoratedParameters['type']); + $remainingQueryParameters = array_replace_recursive( + $remainingQueryParameters, + $decoratedParameters + ); + } + return (string)$type; + } + + protected function getVariableProcessor(): VariableProcessor + { + if (isset($this->variableProcessor)) { + return $this->variableProcessor; + } + return $this->variableProcessor = GeneralUtility::makeInstance(VariableProcessor::class); + } + + /** + * {@inheritdoc} + */ + public function setAspects(array $aspects): void + { + $this->aspects = $aspects; + } + + /** + * {@inheritdoc} + */ + public function getAspects(): array + { + return $this->aspects; + } +} diff --git a/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php b/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php new file mode 100644 index 0000000..3d333ea --- /dev/null +++ b/Classes/Routing/Enhancer/DecoratingEnhancerInterface.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\RouteCollection; + +/** + * Decorates a route (or routes within a collection) with additional parameters. + */ +interface DecoratingEnhancerInterface extends EnhancerInterface +{ + /** + * Gets pattern that can be used to redecorate (undecorate) + * a potential previously decorated route path. + * + * Example: + * + route path: 'first/second.html' + * + redecoration pattern: '(?:\.html|\.json)$' + * -> 'first/second' might be the redecorated route path after + * applying the redecoration pattern to preg_match/preg_replace + * + * @return string regular expression pattern + */ + public function getRoutePathRedecorationPattern(): string; + + /** + * Decorates route collection to be processed during URL resolving. + * Executed before invoking routing enhancers. + * + * @param string $routePath URL path + */ + public function decorateForMatching(RouteCollection $collection, string $routePath): void; + + /** + * Decorates route collection during URL URL generation. + * Executed before invoking routing enhancers. + * + * @param array $parameters query parameters + */ + public function decorateForGeneration(RouteCollection $collection, array $parameters): void; +} diff --git a/Classes/Routing/Enhancer/EnhancerFactory.php b/Classes/Routing/Enhancer/EnhancerFactory.php new file mode 100644 index 0000000..e43c0e6 --- /dev/null +++ b/Classes/Routing/Enhancer/EnhancerFactory.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Creates enhancers + */ +class EnhancerFactory +{ + /** + * @var array of all class names that need to be EnhancerInterfaces when instantiated. + */ + protected $availableEnhancers; + + /** + * EnhancerFactory constructor. + */ + public function __construct() + { + $this->availableEnhancers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'] ?? []; + } + + /** + * @throws \InvalidArgumentException + * @throws \OutOfRangeException + */ + public function create(string $type, array $settings): EnhancerInterface + { + if (empty($type)) { + throw new \InvalidArgumentException( + 'Enhancer type cannot be empty', + 1537298284 + ); + } + if (!isset($this->availableEnhancers[$type])) { + throw new \OutOfRangeException( + sprintf('No enhancer found for %s', $type), + 1537277222 + ); + } + unset($settings['type']); + $className = $this->availableEnhancers[$type]; + /** @var EnhancerInterface $enhancer */ + $enhancer = GeneralUtility::makeInstance($className, $settings); + return $enhancer; + } +} diff --git a/Classes/Routing/Enhancer/EnhancerInterface.php b/Classes/Routing/Enhancer/EnhancerInterface.php new file mode 100644 index 0000000..9c423c3 --- /dev/null +++ b/Classes/Routing/Enhancer/EnhancerInterface.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\Aspect\AspectInterface; + +/** + * Base interface for enhancers, which can be decorators for adding parameters, + * or routing enhancers which adds variants to a page. + */ +interface EnhancerInterface +{ + /** + * @param AspectInterface[] $aspects + */ + public function setAspects(array $aspects): void; + + /** + * @return AspectInterface[] + */ + public function getAspects(): array; +} diff --git a/Classes/Routing/Enhancer/InflatableEnhancerInterface.php b/Classes/Routing/Enhancer/InflatableEnhancerInterface.php new file mode 100644 index 0000000..0fbc404 --- /dev/null +++ b/Classes/Routing/Enhancer/InflatableEnhancerInterface.php @@ -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\Core\Routing\Enhancer; + +/** + * Interface asserting that enhancer is capable of inflating parameters. + */ +interface InflatableEnhancerInterface +{ + public function inflateParameters(array $parameters, array $internals = []): array; +} diff --git a/Classes/Routing/Enhancer/PageTypeDecorator.php b/Classes/Routing/Enhancer/PageTypeDecorator.php new file mode 100644 index 0000000..8dda3de --- /dev/null +++ b/Classes/Routing/Enhancer/PageTypeDecorator.php @@ -0,0 +1,237 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\Route; +use TYPO3\CMS\Core\Routing\RouteCollection; + +/** + * Resolves a static list (like page.typeNum) against a file pattern. Usually added on the very last part + * of the URL. + * It is important that the PageType Enhancer is executed at the very end in your configuration, as it modifies + * EXISTING route variants. + * + * routeEnhancers: + * PageTypeSuffix: + * type: PageType + * default: '' + * index: 'index' + * map: + * '.html': 1 + * 'menu.json': 13 + */ +class PageTypeDecorator extends AbstractEnhancer implements DecoratingEnhancerInterface +{ + protected const ROUTE_PATH_DELIMITERS = ['.', '-', '_', '/']; + + /** + * @var array + */ + protected $configuration; + + /** + * @var string + */ + protected $default; + + /** + * @var string + */ + protected $index; + + /** + * @var array + */ + protected $map; + + public function __construct(array $configuration) + { + $default = $configuration['default'] ?? ''; + $index = $configuration['index'] ?? 'index'; + $map = $configuration['map'] ?? null; + + if (!is_string($default)) { + throw new \InvalidArgumentException('default must be string', 1538327508); + } + if (!is_string($index)) { + throw new \InvalidArgumentException('index must be string', 1538327509); + } + if (!is_array($map)) { + throw new \InvalidArgumentException('map must be array', 1538327510); + } + + $this->configuration = $configuration; + $this->default = $default; + $this->index = $index; + $this->map = array_map('strval', $map); + } + + public function getRoutePathRedecorationPattern(): string + { + return $this->buildRegularExpressionPattern(false); + } + + /** + * {@inheritdoc} + */ + public function decorateForMatching(RouteCollection $collection, string $routePath): void + { + $decoratedRoutePath = null; + $decoratedParameters = null; + + $pattern = $this->buildRegularExpressionPattern(); + if (preg_match('#(?P<decoration>(?:' . $pattern . '))#', $routePath, $matches, PREG_UNMATCHED_AS_NULL)) { + if (!isset($matches['decoration'])) { + throw new \UnexpectedValueException( + 'Unexpected null value at end of URL', + 1538335671 + ); + } + + $routePathValue = $matches['decoration']; + $parameterValue = $matches['indexItems'] ?? $matches['slashedItems'] ?? $matches['regularItems']; + $routePathValuePattern = $this->quoteForRegularExpressionPattern($routePathValue) . '$'; + $decoratedRoutePath = preg_replace('#' . $routePathValuePattern . '#', '', $routePath); + + $mappedType = $this->map[$parameterValue] ?? null; + if ($mappedType !== null) { + $decoratedParameters = ['type' => $mappedType]; + } elseif ($this->default === $routePathValue) { + $decoratedParameters = ['type' => 0]; + } + } + + foreach ($collection->all() as $route) { + if ($decoratedRoutePath !== null) { + $route->setOption( + '_decoratedRoutePath', + '/' . trim($decoratedRoutePath, '/') + ); + } + if ($decoratedParameters !== null) { + $route->setOption( + '_decoratedParameters', + $decoratedParameters + ); + } + } + } + + /** + * {@inheritdoc} + */ + public function decorateForGeneration(RouteCollection $collection, array $parameters): void + { + $type = isset($parameters['type']) ? (string)$parameters['type'] : null; + $value = $this->resolveValue($type); + // If the type is > 0 but the value could not be resolved, + // the type is appended as GET argument, which can be resolved already anyway. + // This happens when the PageTypeDecorator is used, but hasn't been configured for all available types. + if (!empty($type) && ($value === '' || $value === $this->default)) { + return; + } + + $considerIndex = $value !== '' && in_array($value[0], static::ROUTE_PATH_DELIMITERS); + if ($value !== '' && !in_array($value[0], static::ROUTE_PATH_DELIMITERS)) { + $value = '/' . $value; + } + + /** + * @var Route $existingRoute + */ + foreach ($collection->all() as $existingRoute) { + $existingRoutePath = rtrim($existingRoute->getPath(), '/'); + if ($considerIndex && $existingRoutePath === '') { + $existingRoutePath = $this->index; + } + $existingRoute->setPath($existingRoutePath . $value); + $deflatedParameters = $existingRoute->getOption('deflatedParameters') ?? $parameters; + if (isset($deflatedParameters['type'])) { + unset($deflatedParameters['type']); + $existingRoute->setOption( + 'deflatedParameters', + $deflatedParameters + ); + } + } + } + + /** + * Checks if the value exists inside the map. + */ + protected function resolveValue(?string $type): string + { + $index = array_search($type, $this->map, true); + if ($index !== false) { + return $index; + } + return $this->default; + } + + /** + * Builds a regexp out of the map. + */ + protected function buildRegularExpressionPattern(bool $useNames = true): string + { + $items = array_keys($this->map); + if ($this->default !== '' && !in_array($this->default, $items, true)) { + $items[] = $this->default; + } + $slashedItems = array_filter($items, [$this, 'needsSlashPrefix']); + $regularItems = array_diff($items, $slashedItems); + + $slashedItems = array_map([$this, 'quoteForRegularExpressionPattern'], $slashedItems); + $regularItems = array_map([$this, 'quoteForRegularExpressionPattern'], $regularItems); + + $patterns = []; + if (!empty($slashedItems)) { + $name = $useNames ? '?P<slashedItems>' : ''; + $patterns[] = '(?:^|/)(' . $name . implode('|', $slashedItems) . ')'; + } + if (!empty($regularItems) && !empty($this->index)) { + $name = $useNames ? '?P<indexItems>' : ''; + $indexPattern = $this->quoteForRegularExpressionPattern($this->index); + $patterns[] = '^' . $indexPattern . '(' . $name . '(?:' . implode('|', $regularItems) . '))'; + } + if (!empty($regularItems)) { + $name = $useNames ? '?P<regularItems>' : ''; + $patterns[] = '(' . $name . implode('|', $regularItems) . ')'; + } + return '(?:' . implode('|', $patterns) . ')$'; + } + + /** + * Helper method for regexps. + */ + protected function quoteForRegularExpressionPattern(string $value): string + { + return preg_quote($value, '#'); + } + + /** + * Checks if a slash should be prefixed. + */ + protected function needsSlashPrefix(string $value): bool + { + return !in_array( + $value[0] ?? '', + static::ROUTE_PATH_DELIMITERS, + true + ); + } +} diff --git a/Classes/Routing/Enhancer/PluginEnhancer.php b/Classes/Routing/Enhancer/PluginEnhancer.php new file mode 100644 index 0000000..a188cee --- /dev/null +++ b/Classes/Routing/Enhancer/PluginEnhancer.php @@ -0,0 +1,174 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface; +use TYPO3\CMS\Core\Routing\PageArguments; +use TYPO3\CMS\Core\Routing\Route; +use TYPO3\CMS\Core\Routing\RouteCollection; +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * Used for plugins like EXT:felogin. + * + * This is usually used for arguments that are built with a `tx_myplugin_pi1` as namespace in GET / POST parameter. + * + * routeEnhancers: + * ForgotPassword: + * type: Plugin + * routePath: '/forgot-pw/{user_id}/{hash}/' + * namespace: 'tx_felogin_pi1' + * _arguments: + * user_id: uid + * requirements: + * user_id: '[a-z]+' + * hash: '[a-z]{0-6}' + */ +class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface +{ + /** + * @var array + */ + protected $configuration; + + /** + * @var string + */ + protected $namespace; + + public function __construct(array $configuration) + { + $this->configuration = $configuration; + $this->namespace = $this->configuration['namespace'] ?? ''; + } + + /** + * {@inheritdoc} + */ + public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments + { + $variableProcessor = $this->getVariableProcessor(); + // determine those parameters that have been processed + $parameters = array_intersect_key( + $results, + array_flip($route->compile()->getPathVariables()) + ); + // strip of those that where not processed (internals like _route, etc.) + $internals = array_diff_key($results, $parameters); + $matchedVariableNames = array_keys($parameters); + + $staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames); + $dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []); + + // all route arguments + $routeArguments = $this->inflateParameters($parameters, $internals); + // dynamic arguments, that don't have a static mapper + $dynamicArguments = $variableProcessor + ->inflateNamespaceParameters($dynamicCandidates, $this->namespace); + // route arguments, that don't appear in dynamic arguments + $staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments); + + $page = $route->getOption('_page'); + $pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']); + $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId); + // See PageSlugCandidateProvider where this is added. + if ($page['MPvar'] ?? '') { + $routeArguments['MP'] = $page['MPvar']; + } + $type = $this->resolveType($route, $remainingQueryParameters); + return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters); + } + + /** + * {@inheritdoc} + */ + public function enhanceForMatching(RouteCollection $collection): void + { + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + $variant = $this->getVariant($defaultPageRoute, $this->configuration); + $collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant); + } + + /** + * Builds a variant of a route based on the given configuration. + */ + protected function getVariant(Route $defaultPageRoute, array $configuration): Route + { + $arguments = $configuration['_arguments'] ?? []; + unset($configuration['_arguments']); + + $variableProcessor = $this->getVariableProcessor(); + $routePath = $this->modifyRoutePath($configuration['routePath']); + $routePath = $variableProcessor->deflateRoutePath($routePath, $this->namespace, $arguments); + $variant = clone $defaultPageRoute; + $variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/')); + $variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]); + $defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments); + // only keep `defaults` that are actually used in `routePath` + $variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults)); + $this->applyRouteAspects($variant, $this->aspects, $this->namespace); + $this->applyRequirements($variant, $this->configuration['requirements'] ?? [], $this->namespace); + $this->applyStaticVariables($variant, $this->configuration['static'] ?? [], $this->namespace); + return $variant; + } + + /** + * {@inheritdoc} + */ + public function enhanceForGeneration(RouteCollection $collection, array $parameters): void + { + // No parameter for this namespace given, so this route does not fit the requirements + if (!is_array($parameters[$this->namespace] ?? null)) { + return; + } + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + $variant = $this->getVariant($defaultPageRoute, $this->configuration); + $compiledRoute = $variant->compile(); + // contains all given parameters, even if not used as variables in route + $deflatedParameters = $this->deflateParameters($variant, $parameters); + $variables = array_flip($compiledRoute->getPathVariables()); + $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters); + // all params must be given, otherwise we exclude this variant + if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) { + return; + } + $variant->addOptions(['deflatedParameters' => $deflatedParameters]); + $collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant); + } + + protected function deflateParameters(Route $route, array $parameters): array + { + return $this->getVariableProcessor()->deflateNamespaceParameters( + $parameters, + $this->namespace, + $route->getArguments() + ); + } + + /** + * @param array $parameters Actual parameter payload to be used + * @param array $internals Internal instructions (_route, _controller, ...) + */ + public function inflateParameters(array $parameters, array $internals = []): array + { + return $this->getVariableProcessor() + ->inflateNamespaceParameters($parameters, $this->namespace); + } +} diff --git a/Classes/Routing/Enhancer/ResultingInterface.php b/Classes/Routing/Enhancer/ResultingInterface.php new file mode 100644 index 0000000..b53ddb0 --- /dev/null +++ b/Classes/Routing/Enhancer/ResultingInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\PageArguments; +use TYPO3\CMS\Core\Routing\Route; + +/** + * Extend the Resulting Interface to explain that this route builds the page arguments itself, instead of having + * the PageRouter having to deal with that. + */ +interface ResultingInterface +{ + public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments; +} diff --git a/Classes/Routing/Enhancer/RoutingEnhancerInterface.php b/Classes/Routing/Enhancer/RoutingEnhancerInterface.php new file mode 100644 index 0000000..80b820a --- /dev/null +++ b/Classes/Routing/Enhancer/RoutingEnhancerInterface.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\RouteCollection; + +/** + * Interface for enhancers + */ +interface RoutingEnhancerInterface extends EnhancerInterface +{ + /** + * Extends route collection with all routes. Used during URL resolving. + */ + public function enhanceForMatching(RouteCollection $collection): void; + + /** + * Extends route collection with routes that are relevant for given + * parameters. Used during URL generation. + */ + public function enhanceForGeneration(RouteCollection $collection, array $parameters): void; +} diff --git a/Classes/Routing/Enhancer/SimpleEnhancer.php b/Classes/Routing/Enhancer/SimpleEnhancer.php new file mode 100644 index 0000000..821b4e1 --- /dev/null +++ b/Classes/Routing/Enhancer/SimpleEnhancer.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface; +use TYPO3\CMS\Core\Routing\PageArguments; +use TYPO3\CMS\Core\Routing\Route; +use TYPO3\CMS\Core\Routing\RouteCollection; +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * This is usually used for simple GET arguments that have no namespace (e.g. not plugins). + * + * routeEnhancers + * Categories: + * type: Simple + * routePath: '/cmd/{category_id}/{scope_id}' + * _arguments: + * category_id: 'category/id' + * scope_id: 'scope/id' + */ +class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface +{ + /** + * @var array + */ + protected $configuration; + + public function __construct(array $configuration) + { + $this->configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments + { + // determine those parameters that have been processed + $parameters = array_intersect_key( + $results, + array_flip($route->compile()->getPathVariables()) + ); + // strip of those that where not processed (internals like _route, etc.) + $internals = array_diff_key($results, $parameters); + $matchedVariableNames = array_keys($parameters); + + $staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames); + $dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []); + + // all route arguments + $routeArguments = $this->inflateParameters($parameters, $internals); + // dynamic arguments, that don't have a static mapper + $dynamicArguments = $this->inflateParameters($dynamicCandidates); + // route arguments, that don't appear in dynamic arguments + $staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments); + + $page = $route->getOption('_page'); + $pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']); + $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId); + // See PageSlugCandidateProvider where this is added. + if ($page['MPvar'] ?? '') { + $routeArguments['MP'] = $page['MPvar']; + } + $type = $this->resolveType($route, $remainingQueryParameters); + return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters); + } + + /** + * {@inheritdoc} + */ + public function enhanceForMatching(RouteCollection $collection): void + { + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + $variant = $this->getVariant($defaultPageRoute, $this->configuration); + $collection->add('enhancer_' . spl_object_hash($variant), $variant); + } + + /** + * Builds a variant of a route based on the given configuration. + */ + protected function getVariant(Route $defaultPageRoute, array $configuration): Route + { + $arguments = $configuration['_arguments'] ?? []; + unset($configuration['_arguments']); + + $variableProcessor = $this->getVariableProcessor(); + $routePath = $this->modifyRoutePath($configuration['routePath']); + $routePath = $variableProcessor->deflateRoutePath($routePath, null, $arguments); + $variant = clone $defaultPageRoute; + $variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/')); + $variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]); + $defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], null, $arguments); + // only keep `defaults` that are actually used in `routePath` + $variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults)); + $this->applyRouteAspects($variant, $this->aspects); + $this->applyRequirements($variant, $this->configuration['requirements'] ?? []); + $this->applyStaticVariables($variant, $this->configuration['static'] ?? []); + return $variant; + } + + /** + * {@inheritdoc} + */ + public function enhanceForGeneration(RouteCollection $collection, array $parameters): void + { + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + $variant = $this->getVariant($defaultPageRoute, $this->configuration); + $compiledRoute = $variant->compile(); + // contains all given parameters, even if not used as variables in route + $deflatedParameters = $this->getVariableProcessor()->deflateParameters($parameters, $variant->getArguments()); + $variables = array_flip($compiledRoute->getPathVariables()); + $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters); + // all params must be given, otherwise we exclude this variant + if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) { + return; + } + $variant->addOptions(['deflatedParameters' => $deflatedParameters]); + $collection->add('enhancer_' . spl_object_hash($variant), $variant); + } + + public function inflateParameters(array $parameters, array $internals = []): array + { + return $this->getVariableProcessor()->inflateParameters($parameters, $internals); + } +} diff --git a/Classes/Routing/Enhancer/VariableProcessor.php b/Classes/Routing/Enhancer/VariableProcessor.php new file mode 100644 index 0000000..4744d68 --- /dev/null +++ b/Classes/Routing/Enhancer/VariableProcessor.php @@ -0,0 +1,349 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; + +/** + * Helper for processing various variables within a Route Enhancer + */ +#[Autoconfigure(public: true, shared: false)] +class VariableProcessor +{ + protected const LEVEL_DELIMITER = '___'; + protected const ARGUMENT_SEPARATOR = '/'; + protected const VARIABLE_PATTERN = '#\{(?P<modifier>!)?(?P<name>[^}]+)\}#'; + protected array $hashes = []; + protected array $nestedValues = []; + + public function __construct(private readonly VariableProcessorCache $cache) {} + + protected function addHash(string $value): string + { + if (!$this->requiresHashing($value)) { + return $value; + } + // generate hash (fetch from cache, if available) + $hash = $this->generateHash($value); + // store hash locally (indicator, that this value was processed) + $this->hashes[$hash] = $value; + return $hash; + } + + /** + * Determines whether a parameter value requires hashing. + * This is the case if the value has 31+ chars (Symfony has a limitation of 32 chars), + * or if the value contains any non-word characters besides `[A-Za-z0-9_]`, such as `@`. + */ + protected function requiresHashing(string $value): bool + { + if (!isset($this->cache->requiresHashing[$value])) { + $this->cache->requiresHashing[$value] = strlen($value) >= 31 || preg_match('#[^\w]#', $value) > 0; + } + return $this->cache->requiresHashing[$value]; + } + + protected function generateHash(string $value): string + { + if (!isset($this->cache->hashes[$value])) { + // remove one char, which might be used as enforced route prefix `{!value}` + $hash = substr(md5($value), 0, -1); + // Symfony Route Compiler requires the first literal to be non-integer + if ($hash[0] === (string)(int)$hash[0]) { + $hash[0] = str_replace( + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ['o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x'], + $hash[0] + ); + } + $this->cache->hashes[$value] = $hash; + } + return $this->cache->hashes[$value]; + } + + /** + * @throws \OutOfRangeException + */ + protected function resolveHash(string $hash): string + { + if (strlen($hash) < 31) { + return $hash; + } + if (!isset($this->hashes[$hash])) { + throw new \OutOfRangeException( + 'Hash not resolvable', + 1537633463 + ); + } + return $this->hashes[$hash]; + } + + protected function addNestedValue(string $value): string + { + if (!str_contains($value, static::ARGUMENT_SEPARATOR)) { + return $value; + } + $nestedValue = str_replace( + static::ARGUMENT_SEPARATOR, + static::LEVEL_DELIMITER, + $value + ); + $this->nestedValues[$nestedValue] = $value; + return $nestedValue; + } + + protected function resolveNestedValue(string $value): string + { + if (!str_contains($value, static::LEVEL_DELIMITER)) { + return $value; + } + return $this->nestedValues[$value] ?? $value; + } + + public function deflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string + { + if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) { + return $routePath; + } + + $replace = []; + $search = $matches[0]; + $deflatedNames = $this->deflateValues($matches['name'], $namespace, $arguments); + foreach ($deflatedNames as $index => $deflatedName) { + $modifier = $matches['modifier'][$index] ?? ''; + $replace[] = '{' . $modifier . $deflatedName . '}'; + } + return str_replace($search, $replace, $routePath); + } + + public function inflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string + { + if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) { + return $routePath; + } + + $replace = []; + $search = $matches[0]; + $inflatedNames = $this->inflateValues($matches['name'], $namespace, $arguments); + foreach ($inflatedNames as $index => $inflatedName) { + $modifier = $matches['modifier'][$index] ?? ''; + $replace[] = '{' . $modifier . $inflatedName . '}'; + } + return str_replace($search, $replace, $routePath); + } + + /** + * Deflates (flattens) route/request parameters for a given namespace. + */ + public function deflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array + { + if (empty($namespace) || empty($parameters[$namespace])) { + return $parameters; + } + // prefix items of namespace parameters and apply argument mapping + $namespaceParameters = $this->deflateKeys($parameters[$namespace], $namespace, $arguments, false); + // deflate those array items + $namespaceParameters = $this->deflateArray($namespaceParameters); + unset($parameters[$namespace]); + // merge with remaining array items + return array_merge($parameters, $namespaceParameters); + } + + /** + * Inflates (unflattens) route/request parameters. + */ + public function inflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array + { + if (empty($namespace) || empty($parameters)) { + return $parameters; + } + + $parameters = $this->inflateArray($parameters, $namespace, $arguments); + // apply argument mapping on items of inflated namespace parameters + if (!empty($parameters[$namespace]) && !empty($arguments)) { + $parameters[$namespace] = $this->inflateKeys($parameters[$namespace], null, $arguments, false); + } + return $parameters; + } + + /** + * Deflates (flattens) route/request parameters for a given namespace. + */ + public function deflateParameters(array $parameters, array $arguments = []): array + { + $parameters = $this->deflateKeys($parameters, null, $arguments, false); + return $this->deflateArray($parameters); + } + + /** + * Inflates (unflattens) route/request parameters. + */ + public function inflateParameters(array $parameters, array $arguments = []): array + { + $parameters = $this->inflateArray($parameters, null, $arguments); + return $this->inflateKeys($parameters, null, $arguments, false); + } + + /** + * Deflates keys names on the first level, now recursion into sub-arrays. + * Can be used to adjust key names of route requirements, mappers, etc. + */ + public function deflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array + { + if (empty($items) || empty($arguments) && empty($namespace)) { + return $items; + } + $keys = $this->deflateValues(array_keys($items), $namespace, $arguments, $hash); + return array_combine( + $keys, + array_values($items) + ); + } + + /** + * Inflates keys names on the first level, now recursion into sub-arrays. + * Can be used to adjust key names of route requirements, mappers, etc. + */ + public function inflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array + { + if (empty($items) || empty($arguments) && empty($namespace)) { + return $items; + } + $keys = $this->inflateValues(array_keys($items), $namespace, $arguments, $hash); + return array_combine( + $keys, + array_values($items) + ); + } + + /** + * Deflates plain values. + */ + protected function deflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array + { + if (empty($values) || empty($arguments) && empty($namespace)) { + return $values; + } + $namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : ''; + $arguments = array_map('strval', $arguments); + return array_map( + function (string $value) use ($arguments, $namespacePrefix, $hash) { + $value = $arguments[$value] ?? $value; + $value = $this->addNestedValue($value); + $value = $namespacePrefix . $value; + if (!$hash) { + return $value; + } + return $this->addHash($value); + }, + $values + ); + } + + /** + * Inflates plain values. + */ + protected function inflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array + { + if (empty($values) || empty($arguments) && empty($namespace)) { + return $values; + } + $arguments = array_map('strval', $arguments); + $namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : ''; + return array_map( + function (string $value) use ($arguments, $namespacePrefix, $hash) { + if ($hash) { + $value = $this->resolveHash($value); + } + if (!empty($namespacePrefix) && str_starts_with($value, $namespacePrefix)) { + $value = substr($value, strlen($namespacePrefix)); + } + $value = $this->resolveNestedValue($value); + $index = array_search($value, $arguments, true); + return $index !== false ? $index : $value; + }, + $values + ); + } + + /** + * Deflates (flattens) array having nested structures. + */ + protected function deflateArray(array $array, string $prefix = ''): array + { + $delimiter = static::LEVEL_DELIMITER; + if ($prefix !== '' && !str_ends_with($prefix, $delimiter)) { + $prefix .= static::LEVEL_DELIMITER; + } + + $result = []; + foreach ($array as $key => $value) { + if (is_array($value)) { + $result = array_replace( + $result, + $this->deflateArray( + $value, + $prefix . $key . static::LEVEL_DELIMITER + ) + ); + } else { + $deflatedKey = $this->addHash($prefix . $key); + $result[$deflatedKey] = $value; + } + } + return $result; + } + + /** + * Inflates (unflattens) an array into nested structures. + * + * @param string $namespace + */ + protected function inflateArray(array $array, ?string $namespace, array $arguments): array + { + $result = []; + foreach ($array as $key => $value) { + $inflatedKey = $this->resolveHash((string)$key); + // inflate nested values `namespace__any__nested` -> `namespace__any/nested` + $inflatedKey = $this->inflateNestedValue($inflatedKey, $namespace, $arguments); + $steps = explode(static::LEVEL_DELIMITER, $inflatedKey); + $pointer = &$result; + foreach ($steps as $step) { + $pointer = &$pointer[$step]; + } + $pointer = $value; + unset($pointer); + } + return $result; + } + + protected function inflateNestedValue(string $value, ?string $namespace, array $arguments): string + { + $namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : ''; + if (!empty($namespace) && !str_starts_with($value, $namespacePrefix)) { + return $value; + } + $arguments = array_map('strval', $arguments); + $possibleNestedValueKey = substr($value, strlen($namespacePrefix)); + $possibleNestedValue = $this->nestedValues[$possibleNestedValueKey] ?? null; + if ($possibleNestedValue === null || !in_array($possibleNestedValue, $arguments, true)) { + return $value; + } + return $namespacePrefix . $possibleNestedValue; + } +} diff --git a/Classes/Routing/Enhancer/VariableProcessorCache.php b/Classes/Routing/Enhancer/VariableProcessorCache.php new file mode 100644 index 0000000..883cde5 --- /dev/null +++ b/Classes/Routing/Enhancer/VariableProcessorCache.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Enhancer; + +/** + * Shared cache among multiple `VariableProcessor` instances + * + * @internal + */ +class VariableProcessorCache +{ + /** + * @var array<string, bool> + */ + public array $requiresHashing = []; + + /** + * @var array<string, string> + */ + public array $hashes = []; +} diff --git a/Classes/Routing/Event/AfterPageUriGeneratedEvent.php b/Classes/Routing/Event/AfterPageUriGeneratedEvent.php new file mode 100644 index 0000000..590d152 --- /dev/null +++ b/Classes/Routing/Event/AfterPageUriGeneratedEvent.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing\Event; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Domain\Page; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + +final class AfterPageUriGeneratedEvent +{ + public function __construct( + private UriInterface $uri, + private readonly array|string|int|Page $route, + private readonly array $parameters, + private readonly string $fragment, + private readonly string $type, + private readonly SiteLanguage $language, + private readonly Site $site, + ) {} + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function setUri(UriInterface $uri): void + { + $this->uri = $uri; + } + + public function getRoute(): array|string|int|Page + { + return $this->route; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function getType(): string + { + return $this->type; + } + + public function getLanguage(): SiteLanguage + { + return $this->language; + } + + public function getSite(): Site + { + return $this->site; + } +} diff --git a/Classes/Routing/InvalidRouteArgumentsException.php b/Classes/Routing/InvalidRouteArgumentsException.php new file mode 100644 index 0000000..8490bc7 --- /dev/null +++ b/Classes/Routing/InvalidRouteArgumentsException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use TYPO3\CMS\Core\Exception; + +/** + * Exception thrown when a route does not exist or does not match the Route Arguments + */ +class InvalidRouteArgumentsException extends Exception {} diff --git a/Classes/Routing/MatchedRoute.php b/Classes/Routing/MatchedRoute.php new file mode 100644 index 0000000..8417e00 --- /dev/null +++ b/Classes/Routing/MatchedRoute.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Symfony\Component\Routing\Route as SymfonyRoute; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; + +/** + * @internal + */ +class MatchedRoute +{ + protected array $hostMatches = []; + protected array $pathMatches = []; + + public function __construct(protected SymfonyRoute $route, protected array $routeResult) {} + + public function withPathMatches(array $pathMatches): self + { + $target = clone $this; + $target->pathMatches = $pathMatches; + return $target; + } + + public function withHostMatches(array $hostMatches): self + { + $target = clone $this; + $target->hostMatches = $hostMatches; + return $target; + } + + public function getRoute(): SymfonyRoute + { + return $this->route; + } + + public function getRouteResult(): array + { + return $this->routeResult; + } + + public function getFallbackScore(): int + { + return $this->route->getOption('fallback') === true ? 1 : 0; + } + + public function getHostMatchScore(): int + { + return empty($this->hostMatches[0]) ? 0 : 1; + } + + public function getPathMatchScore(int $index): int + { + $completeMatch = $this->pathMatches[0]; + $tailMatch = $this->pathMatches[$index] ?? ''; + // no tail, it's a complete match + if ($tailMatch === '') { + return strlen($completeMatch); + } + // otherwise, find length of complete match that does not contain tail + // example: complete: `/french/other`, tail: `/other` -> `strlen` of `/french` + return strrpos($completeMatch, $tailMatch); + } + + public function getSiteIdentifier(): string + { + $site = $this->route->getDefault('site'); + return $site instanceof SiteInterface ? $site->getIdentifier() : ''; + } +} diff --git a/Classes/Routing/PageArguments.php b/Classes/Routing/PageArguments.php new file mode 100644 index 0000000..d16ac94 --- /dev/null +++ b/Classes/Routing/PageArguments.php @@ -0,0 +1,248 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * Contains all resolved parameters when a page is resolved from a page path segment plus all fragments. + */ +class PageArguments implements RouteResultInterface +{ + protected int $pageId; + protected string $pageType; + protected bool $dirty = false; + + /** + * All (merged) arguments of this URI (routeArguments + dynamicArguments) + * + * @var array<string, string|array> + */ + protected array $arguments; + + /** + * Route arguments mapped by static mappers + * "static" means the provided values in a URI maps to a finite number of values + * (routeArguments - "arguments mapped by non static mapper") + * + * @var array<string, string|array> + */ + protected array $staticArguments; + + /** + * Route arguments, that have an infinite number of possible values + * AND query string arguments. These arguments require a cHash. + * + * @var array<string, string|array> + */ + protected array $dynamicArguments; + + /** + * Arguments defined in and mapped by a route enhancer + * + * @var array<string, string|array> + */ + protected array $routeArguments; + + /** + * Query arguments in the generated URI + * + * @var array<string, string|array> + */ + protected array $queryArguments = []; + + public function __construct(int $pageId, string $pageType, array $routeArguments, array $staticArguments = [], array $remainingArguments = []) + { + $this->pageId = $pageId; + $this->pageType = $pageType; + $this->routeArguments = $this->sort($routeArguments); + $this->staticArguments = $this->sort($staticArguments); + $this->arguments = $this->routeArguments; + $this->updateDynamicArguments(); + if (!empty($remainingArguments)) { + $this->updateQueryArguments($remainingArguments); + } + } + + public function areDirty(): bool + { + return $this->dirty; + } + + /** + * @return array<string, string|array> + */ + public function getRouteArguments(): array + { + return $this->routeArguments; + } + + public function getPageId(): int + { + return $this->pageId; + } + + public function getPageType(): string + { + return $this->pageType; + } + + /** + * @return string|array<string, string|array>|null + */ + public function get(string $name): mixed + { + return $this->arguments[$name] ?? null; + } + + /** + * @return array<string, string|array> + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * @return array<string, string|array> + */ + public function getStaticArguments(): array + { + return $this->staticArguments; + } + + /** + * @return array<string, string|array> + */ + public function getDynamicArguments(): array + { + return $this->dynamicArguments; + } + + /** + * @return array<string, string|array> + */ + public function getQueryArguments(): array + { + return $this->queryArguments; + } + + /** + * @param array<string, string|array> $queryArguments + */ + protected function updateQueryArguments(array $queryArguments) + { + $queryArguments = $this->sort($queryArguments); + if ($this->queryArguments === $queryArguments) { + return; + } + // in case query arguments would override route arguments, + // the state is considered as dirty (since it's not distinct) + // thus, route arguments take precedence over query arguments + $additionalQueryArguments = $this->diff($queryArguments, $this->routeArguments); + $dirty = $additionalQueryArguments !== $queryArguments; + $this->dirty = $this->dirty || $dirty; + $this->queryArguments = $queryArguments; + $this->arguments = array_replace_recursive($this->arguments, $additionalQueryArguments); + $this->updateDynamicArguments(); + } + + /** + * Updates dynamic arguments based on definitions for static arguments. + */ + protected function updateDynamicArguments(): void + { + $this->dynamicArguments = $this->diff( + $this->arguments, + $this->staticArguments + ); + } + + /** + * Cleans empty array recursively. + * + * @param array<string, string|array> $array + */ + protected function clean(array $array): array + { + foreach ($array as $key => &$item) { + if (!is_array($item)) { + continue; + } + if (!empty($item)) { + $item = $this->clean($item); + } + if (empty($item)) { + unset($array[$key]); + } + } + return $array; + } + + /** + * Sorts array keys recursively. + * + * @param array<string, string|array> $array + */ + protected function sort(array $array): array + { + $array = $this->clean($array); + ArrayUtility::naturalKeySortRecursive($array); + return $array; + } + + /** + * Removes keys that are defined in $second from $first recursively. + * + * @param array<string, string|array> $first + * @param array<string, string|array> $second + */ + protected function diff(array $first, array $second): array + { + return ArrayUtility::arrayDiffKeyRecursive($first, $second); + } + + public function offsetExists(mixed $offset): bool + { + return $offset === 'pageId' || $offset === 'pageType' || isset($this->arguments[$offset]); + } + + /** + * @return int|string|array<string, string|array>|null + */ + public function offsetGet(mixed $offset): mixed + { + if ($offset === 'pageId') { + return $this->getPageId(); + } + if ($offset === 'pageType') { + return $this->getPageType(); + } + return $this->arguments[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \InvalidArgumentException('PageArguments cannot be modified.', 1538152266); + } + + public function offsetUnset(mixed $offset): void + { + throw new \InvalidArgumentException('PageArguments cannot be modified.', 1538152269); + } +} diff --git a/Classes/Routing/PageRouter.php b/Classes/Routing/PageRouter.php new file mode 100644 index 0000000..6486530 --- /dev/null +++ b/Classes/Routing/PageRouter.php @@ -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\Core\Routing; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\LanguageAspectFactory; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Domain\Page; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\ExpressionLanguage\Resolver; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Routing\Aspect\AspectFactory; +use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor; +use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface; +use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface; +use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory; +use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface; +use TYPO3\CMS\Core\Routing\Enhancer\InflatableEnhancerInterface; +use TYPO3\CMS\Core\Routing\Enhancer\ResultingInterface; +use TYPO3\CMS\Core\Routing\Enhancer\RoutingEnhancerInterface; +use TYPO3\CMS\Core\Routing\Event\AfterPageUriGeneratedEvent; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Frontend\Page\CacheHashCalculator; +use TYPO3\CMS\Frontend\Page\CacheHashConfiguration; + +/** + * Page Router - responsible for a page based on a request, by looking up the slug of the page path. + * Is also used for generating URLs for pages. + * + * Resolving is done via the "Route Candidate" pattern. + * + * Example: + * - /about-us/team/management/ + * + * will look for all pages that have + * - /about-us + * - /about-us/ + * - /about-us/team + * - /about-us/team/ + * - /about-us/team/management + * - /about-us/team/management/ + * + * And create route candidates for that. + * + * Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints, + * as the SiteMatcher has done that already. + * + * The concept of the PageRouter is to *resolve*, and to *generate* URIs. On top, it is a facade to hide the + * dependency to symfony and to not expose its logic. + */ +class PageRouter implements RouterInterface +{ + protected Site $site; + protected EnhancerFactory $enhancerFactory; + protected AspectFactory $aspectFactory; + protected CacheHashCalculator $cacheHashCalculator; + protected Context $context; + protected RequestContextFactory $requestContextFactory; + protected EventDispatcherInterface $eventDispatcher; + + /** + * A page router is always bound to a specific site. + */ + public function __construct(Site $site, ?Context $context = null) + { + $this->site = $site; + $this->context = $context ?? GeneralUtility::makeInstance(Context::class); + $this->enhancerFactory = GeneralUtility::makeInstance(EnhancerFactory::class); + $this->aspectFactory = GeneralUtility::makeInstance(AspectFactory::class, $this->context); + $this->cacheHashCalculator = GeneralUtility::makeInstance( + CacheHashCalculator::class, + GeneralUtility::makeInstance(CacheHashConfiguration::class), + GeneralUtility::makeInstance(HashService::class) + ); + $this->requestContextFactory = GeneralUtility::makeInstance(RequestContextFactory::class); + $this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + } + + /** + * Finds a RouteResult based on the given request. + */ + public function matchRequest(ServerRequestInterface $request, ?RouteResultInterface $previousResult = null): RouteResultInterface|PageArguments + { + if (!$previousResult instanceof SiteRouteResult) { + throw new RouteNotFoundException('No previous result given. Cannot find a page for an empty route part', 1555303496); + } + + $candidateProvider = $this->getSlugCandidateProvider($this->context); + + // Legacy URIs (?id=12345) takes precedence, no matter if a route is given + $requestId = ($request->getQueryParams()['id'] ?? null); + $type = '0'; + if (isset($request->getQueryParams()['type']) && is_scalar($request->getQueryParams()['type'])) { + $type = (string)$request->getQueryParams()['type']; + } + if ($requestId !== null) { + if (MathUtility::canBeInterpretedAsInteger($requestId) + && (int)$requestId > 0 + && !empty($pageId = $candidateProvider->getRealPageIdForPageIdAsPossibleCandidate((int)$requestId)) + ) { + return new PageArguments((int)$pageId, $type, [], [], $request->getQueryParams()); + } + throw new RouteNotFoundException('The requested page does not exist.', 1557839801); + } + + $urlPath = $previousResult->getTail(); + $language = $previousResult->getLanguage(); + // Keep possible existing "/" at the end (no trim, just ltrim), even though the page slug might not + // contain a "/" at the end. This way we find page candidates where pages MIGHT have a trailing slash + // and pages with slugs that do not have a trailing slash + // $pageCandidates will contain more records than expected, which is important here, as the ->match() method + // will handle this then. + // The prepended slash will ensure that the root page of the site tree will also be fetched + $prefixedUrlPath = '/' . ltrim($urlPath, '/'); + + $pageCandidates = $candidateProvider->getCandidatesForPath($prefixedUrlPath, $language); + + // Stop if there are no candidates + if (empty($pageCandidates)) { + throw new RouteNotFoundException('No page candidates found for path "' . $prefixedUrlPath . '"', 1538389999); + } + + /** @var RouteCollection<string, Route> $fullCollection */ + $fullCollection = new RouteCollection(); + foreach ($pageCandidates as $page) { + $pageIdForDefaultLanguage = (int)($page['l10n_parent'] ?: $page['uid']); + $pagePath = $page['slug']; + $pageCollection = new RouteCollection(); + $defaultRouteForPage = new Route( + $pagePath, + [], + [], + ['utf8' => true, '_page' => $page] + ); + $pageCollection->add('default', $defaultRouteForPage); + $enhancers = $this->getEnhancersForPage($pageIdForDefaultLanguage, $language, $page); + foreach ($enhancers as $enhancer) { + if ($enhancer instanceof DecoratingEnhancerInterface) { + $enhancer->decorateForMatching($pageCollection, $urlPath); + } + } + foreach ($enhancers as $enhancer) { + if ($enhancer instanceof RoutingEnhancerInterface) { + $enhancer->enhanceForMatching($pageCollection); + } + } + + $collectionPrefix = 'page_' . $page['uid']; + // Pages with a MountPoint Parameter means that they have a different context, and should be treated + // as a separate instance + if (isset($page['MPvar'])) { + $collectionPrefix .= '_MP_' . str_replace(',', '', $page['MPvar']); + } + $pageCollection->addNamePrefix($collectionPrefix . '_'); + $fullCollection->addCollection($pageCollection); + // set default route flag after all routes have been processed + $defaultRouteForPage->setOption('_isDefault', true); + } + + $matcher = new PageUriMatcher($fullCollection); + try { + $result = $matcher->match($prefixedUrlPath); + /** @var Route $matchedRoute */ + $matchedRoute = $fullCollection->get($result['_route']); + // Only use route if page language variant matches current language, otherwise handle it as route not found. + if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) { + return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams()); + } + } catch (ResourceNotFoundException $e) { + if (str_ends_with($prefixedUrlPath, '/')) { + // Second try, look for /my-page even though the request was called via /my-page/ and the slash + // was not part of the slug, but let's then check again + try { + $result = $matcher->match(rtrim($prefixedUrlPath, '/')); + /** @var Route $matchedRoute */ + $matchedRoute = $fullCollection->get($result['_route']); + // Only use route if page language variant matches current language, otherwise + // handle it as route not found. + if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) { + return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams()); + } + } catch (ResourceNotFoundException $e) { + // Do nothing + } + } else { + // Second try, look for /my-page/ even though the request was called via /my-page and the slash + // was part of the slug, but let's then check again + try { + $result = $matcher->match($prefixedUrlPath . '/'); + /** @var Route $matchedRoute */ + $matchedRoute = $fullCollection->get($result['_route']); + // Only use route if page language variant matches current language, otherwise + // handle it as route not found. + if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) { + return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams()); + } + } catch (ResourceNotFoundException $e) { + // Do nothing + } + } + } + throw new RouteNotFoundException('No route found for path "' . $urlPath . '"', 1538389998); + } + + /** + * API for generating a page uri where the $route parameter is typically an array (a page record) or the page ID + * + * @param array|string|int|Page $route + * @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language" + * @param string $fragment additional #my-fragment part + * @param string $type see the RouterInterface for possible types + * @throws InvalidRouteArgumentsException + */ + public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface + { + // sanitize superfluous page-id from additional parameters + // (even if `$parameters['id']` is different to `$pageId`, it will be removed) + unset($parameters['id']); + // Resolve language + $language = null; + $languageOption = $parameters['_language'] ?? null; + unset($parameters['_language']); + if ($languageOption instanceof SiteLanguage) { + $language = $languageOption; + } elseif ($languageOption !== null) { + $language = $this->site->getLanguageById((int)$languageOption); + } + if ($language === null) { + $language = $this->site->getDefaultLanguage(); + } + + $pageId = 0; + if ($route instanceof Page) { + $pageId = $route->getPageId(); + } elseif (is_array($route)) { + $pageId = (int)$route['uid']; + } elseif (is_scalar($route)) { + $pageId = (int)$route; + } + + $context = clone $this->context; + $context->setAspect('language', LanguageAspectFactory::createFromSiteLanguage($language)); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context); + + if ($route instanceof Page) { + $page = $route->toArray(true); + } elseif (is_array($route) + // Check 3rd party input $route for basic requirements + && isset($route['uid'], $route['language_tag'], $route['l10n_parent'], $route['slug']) + && (int)$route['language_tag'] === $language->getLanguageId() + && ((int)$route['l10n_parent'] === 0 || isset($route['_LOCALIZED_UID'])) + ) { + $page = $route; + } else { + $page = $pageRepository->getPage($pageId, true); + } + $pagePath = $page['slug'] ?? ''; + + if ($parameters['MP'] ?? '') { + $mountPointPairs = explode(',', $parameters['MP']); + $pagePath = $this->resolveMountPointParameterIntoPageSlug( + $pageId, + $pagePath, + $mountPointPairs, + $pageRepository + ); + // If the MountPoint page has a different site, the link needs to be generated + // with the base of the MountPoint page, this is especially relevant for cross-domain linking + // Because the language contains the full base, it is retrieved in this case. + try { + [, $mountPointPage] = explode('-', (string)reset($mountPointPairs)); + $site = GeneralUtility::makeInstance(SiteMatcher::class) + ->matchByPageId((int)$mountPointPage); + $language = $site->getLanguageById($language->getLanguageId()); + } catch (SiteNotFoundException $e) { + // No alternative site found, use the existing one + } + // Store the MP parameter in the page record, so it could be used for any enhancers + $page['MPvar'] = $parameters['MP']; + unset($parameters['MP']); + } + + $originalParameters = $parameters; + $collection = new RouteCollection(); + $defaultRouteForPage = new Route( + '/' . ltrim($pagePath, '/'), + [], + [], + ['utf8' => true, '_page' => $page] + ); + $collection->add('default', $defaultRouteForPage); + + // cHash is never considered because cHash is built by this very method. + unset($originalParameters['cHash']); + $enhancers = $this->getEnhancersForPage($pageId, $language, $page); + foreach ($enhancers as $enhancer) { + if ($enhancer instanceof RoutingEnhancerInterface) { + $enhancer->enhanceForGeneration($collection, $originalParameters); + } + } + foreach ($enhancers as $enhancer) { + if ($enhancer instanceof DecoratingEnhancerInterface) { + $enhancer->decorateForGeneration($collection, $originalParameters); + } + } + + $mappableProcessor = new MappableProcessor(); + $requestContext = $this->requestContextFactory->fromSiteLanguage($language); + $generator = new UrlGenerator($collection, $requestContext); + $generator->injectMappableProcessor($mappableProcessor); + // set default route flag after all routes have been processed + $defaultRouteForPage->setOption('_isDefault', true); + $allRoutes = GeneralUtility::makeInstance(RouteSorter::class) + ->withRoutes($collection->all()) + ->withOriginalParameters($originalParameters) + ->sortRoutesForGeneration() + ->getRoutes(); + $matchedRoute = null; + $pageRouteResult = null; + $uri = null; + // map our reference type to symfony's custom paths + $referenceType = $type === static::ABSOLUTE_PATH ? UrlGenerator::ABSOLUTE_PATH : UrlGenerator::ABSOLUTE_URL; + /** + * @var string $routeName + * @var Route $routeCandidate + */ + foreach ($allRoutes as $routeName => $routeCandidate) { + try { + $parameters = $originalParameters; + if ($routeCandidate->hasOption('deflatedParameters')) { + $parameters = $routeCandidate->getOption('deflatedParameters'); + } + // skip the route, in case any aspect of it could not be mapped to a value + if ($mappableProcessor->generate($routeCandidate, $parameters) === false) { + continue; + } + // ABSOLUTE_URL is used as default fallback + $urlAsString = $generator->generate($routeName, $parameters, $referenceType); + $uri = new Uri($urlAsString); + /** @var Route $matchedRoute */ + $matchedRoute = $collection->get($routeName); + // fetch potential applied defaults for later cHash generation + // (even if not applied in route, it will be exposed during resolving) + $appliedDefaults = $matchedRoute->getOption('_appliedDefaults') ?? []; + parse_str($uri->getQuery(), $remainingQueryParameters); + $enhancer = $routeCandidate->getEnhancer(); + if ($enhancer instanceof InflatableEnhancerInterface) { + $remainingQueryParameters = $enhancer->inflateParameters($remainingQueryParameters); + } + $pageRouteResult = $this->buildPageArguments($routeCandidate, array_merge($appliedDefaults, $parameters), $remainingQueryParameters); + break; + } catch (MissingMandatoryParametersException $e) { + // no match + } + } + + if (!$uri instanceof UriInterface) { + throw new InvalidRouteArgumentsException('Uri could not be built for page "' . $pageId . '"', 1538390230); + } + + if ($pageRouteResult && $pageRouteResult->areDirty()) { + // for generating URLs this should(!) never happen + // if it does happen, generator logic has flaws + throw new InvalidRouteArgumentsException('Route arguments are dirty', 1537613247); + } + + if ($matchedRoute && $pageRouteResult && !empty($pageRouteResult->getDynamicArguments())) { + $cacheHash = $this->generateCacheHash($pageId, $pageRouteResult); + + $queryArguments = $pageRouteResult->getQueryArguments(); + if (!empty($cacheHash)) { + $queryArguments['cHash'] = $cacheHash; + } + $uri = $uri->withQuery(http_build_query($queryArguments, '', '&', PHP_QUERY_RFC3986)); + } + if ($fragment) { + $uri = $uri->withFragment($fragment); + } + + $event = new AfterPageUriGeneratedEvent($uri, $route, $originalParameters, $fragment, $type, $language, $this->site); + $this->eventDispatcher->dispatch($event); + return $event->getUri(); + } + + /** + * When a MP parameter is given, the mount point parameter is resolved, and the slug of the new page + * is added while the same parts of the original pagePath is removed (before). + * This way, the subpage to a mounted page has now a different "base" (= prefixed with the slug of the + * mount point). + * + * This is done recursively when multiple mount point parameter pairs + * + * @param string $pagePath the original path of the page + * @param array $mountPointPairs an array with MP pairs (like ['13-3', '4-2'] for recursive mount points) + */ + protected function resolveMountPointParameterIntoPageSlug( + int $pageId, + string $pagePath, + array $mountPointPairs, + PageRepository $pageRepository + ): string { + // Handle recursive mount points + $prefixesToRemove = []; + $slugPrefixesToAdd = []; + foreach ($mountPointPairs as $mountPointPair) { + [$mountRoot, $mountedPage] = GeneralUtility::intExplode('-', (string)$mountPointPair); + $mountPageInformation = $pageRepository->getMountPointInfo($mountedPage); + if ($mountPageInformation) { + if ($pageId === $mountedPage) { + continue; + } + // Get slugs in the translated page + $mountedPage = $pageRepository->getPage($mountedPage); + $mountRoot = $pageRepository->getPage($mountRoot); + $slugPrefix = $mountedPage['slug'] ?? ''; + if ($slugPrefix === '/') { + $slugPrefix = ''; + } + $prefixToRemove = $mountRoot['slug'] ?? ''; + if ($prefixToRemove === '/') { + $prefixToRemove = ''; + } + $prefixesToRemove[] = $prefixToRemove; + $slugPrefixesToAdd[] = $slugPrefix; + } + } + $slugPrefixesToAdd = array_reverse($slugPrefixesToAdd); + $prefixesToRemove = array_reverse($prefixesToRemove); + foreach ($prefixesToRemove as $prefixToRemove) { + // Slug prefixes are taken from the beginning of the array, where as the parts to be removed + // Are taken from the end. + $replacement = array_shift($slugPrefixesToAdd); + if ($prefixToRemove !== '' && str_starts_with($pagePath, $prefixToRemove)) { + $pagePath = substr($pagePath, strlen($prefixToRemove)); + } + $pagePath = $replacement . ($pagePath !== '/' ? '/' . ltrim($pagePath, '/') : ''); + } + return $pagePath; + } + + /** + * Fetch possible enhancers + aspects based on the current page configuration and the site configuration put + * into "routeEnhancers" + * + * @return EnhancerInterface[] + */ + protected function getEnhancersForPage(int $pageId, SiteLanguage $language, array $page = []): array + { + $enhancers = []; + $resolver = null; + foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) { + if (is_array($enhancerConfiguration['limitToPages'] ?? null) + && !$this->matchesPageLimitation($enhancerConfiguration['limitToPages'], $pageId, $page, $language, $resolver) + ) { + continue; + } + $enhancerType = $enhancerConfiguration['type'] ?? ''; + $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration); + if (!empty($enhancerConfiguration['aspects'] ?? null)) { + $aspects = $this->aspectFactory->createAspects( + $enhancerConfiguration['aspects'], + $language, + $this->site + ); + $enhancer->setAspects($aspects); + } + $enhancers[] = $enhancer; + } + return $enhancers; + } + + /** + * Checks whether the current page matches any of the limitToPages conditions. + * Each entry in the array is OR-combined: + * - Integer values are matched against the page ID (existing behavior) + * - String values are evaluated as Symfony ExpressionLanguage expressions + * with access to the `page`, `site` and `siteLanguage` variables + */ + protected function matchesPageLimitation(array $limitToPages, int $pageId, array $page, SiteLanguage $language, ?Resolver &$resolver): bool + { + foreach ($limitToPages as $limitation) { + if (is_int($limitation)) { + if ($limitation === $pageId) { + return true; + } + continue; + } + if (is_string($limitation) && $limitation !== '') { + if ($page === []) { + continue; + } + $resolver ??= GeneralUtility::makeInstance( + Resolver::class, + 'routing', + [ + 'page' => $page, + 'site' => $this->site, + 'siteLanguage' => $language, + ] + ); + try { + if ($resolver->evaluate($limitation)) { + return true; + } + } catch (\Exception) { + continue; + } + } + } + return false; + } + + protected function generateCacheHash(int $pageId, PageArguments $arguments): string + { + return $this->cacheHashCalculator->calculateCacheHash( + $this->getCacheHashParameters($pageId, $arguments) + ); + } + + protected function getCacheHashParameters(int $pageId, PageArguments $arguments): array + { + $hashParameters = $arguments->getDynamicArguments(); + $hashParameters['id'] = $pageId; + $uri = http_build_query($hashParameters, '', '&', PHP_QUERY_RFC3986); + return $this->cacheHashCalculator->getRelevantParameters($uri); + } + + /** + * Builds route arguments. The important part here is to distinguish between + * static and dynamic arguments. Per default all arguments are dynamic until + * aspects can be used to really consider them as static (= 1:1 mapping between + * route value and resulting arguments). + * + * Besides that, internal arguments (_route, _controller, _custom, ..) have + * to be separated since those values are not meant to be used for later + * processing. Not separating those values might result in invalid cHash. + * + * This method is used during resolving and generation of URLs. + * + * @param Route $route + * @param array $results + * @param array $remainingQueryParameters + */ + protected function buildPageArguments(Route $route, array $results, array $remainingQueryParameters = []): PageArguments + { + // only use parameters that actually have been processed + // (thus stripping internals like _route, _controller, ...) + $routeArguments = $this->filterProcessedParameters($route, $results); + // assert amount of "static" mappers is not too "dynamic" + $this->assertMaximumStaticMappableAmount($route, array_keys($routeArguments)); + // delegate result handling to enhancer + $enhancer = $route->getEnhancer(); + if ($enhancer instanceof ResultingInterface) { + // forward complete(!) results, not just filtered parameters + return $enhancer->buildResult($route, $results, $remainingQueryParameters); + } + $page = $route->getOption('_page'); + if ((int)($page['l10n_parent'] ?? 0) > 0) { + $pageId = (int)$page['l10n_parent']; + } elseif ((int)($page['t3ver_oid'] ?? 0) > 0) { + $pageId = (int)$page['t3ver_oid']; + } else { + $pageId = (int)($page['uid'] ?? 0); + } + $type = $this->resolveType($route, $remainingQueryParameters); + // See PageSlugCandidateProvider where this is added. + if ($page['MPvar'] ?? '') { + $routeArguments['MP'] = $page['MPvar']; + } + return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters); + } + + /** + * Retrieves type from processed route and modifies remaining query parameters. + * + * @param array $remainingQueryParameters reference to remaining query parameters + */ + protected function resolveType(Route $route, array &$remainingQueryParameters): string + { + $type = $remainingQueryParameters['type'] ?? 0; + $decoratedParameters = $route->getOption('_decoratedParameters'); + if (isset($decoratedParameters['type'])) { + $type = $decoratedParameters['type']; + unset($decoratedParameters['type']); + $remainingQueryParameters = array_replace_recursive( + $remainingQueryParameters, + $decoratedParameters + ); + } + if (is_scalar($type)) { + return (string)$type; + } + return '0'; + } + + /** + * Asserts that possible amount of items in all static and countable mappers + * (such as StaticRangeMapper) is limited to 10000 in order to avoid + * brute-force scenarios and the risk of cache-flooding. + * + * @throws \OverflowException + * @todo with having `static` route variables, this restriction should be configurable & optional + */ + protected function assertMaximumStaticMappableAmount(Route $route, array $variableNames = []) + { + // empty when only values of route defaults where used + if ($variableNames === []) { + return; + } + $mappers = $route->filterAspects( + [StaticMappableAspectInterface::class, \Countable::class], + $variableNames + ); + if ($mappers === []) { + return; + } + + $multipliers = array_map(count(...), $mappers); + $product = array_product($multipliers); + if ($product > 10000) { + throw new \OverflowException( + 'Possible range of all mappers is larger than 10000 items', + 1537696772 + ); + } + } + + /** + * Determine parameters that have been processed. + */ + protected function filterProcessedParameters(Route $route, array $results): array + { + return array_intersect_key( + $results, + array_flip($route->compile()->getPathVariables()) + ); + } + + protected function getSlugCandidateProvider(Context $context): PageSlugCandidateProvider + { + return GeneralUtility::makeInstance( + PageSlugCandidateProvider::class, + $context, + $this->site, + $this->enhancerFactory + ); + } + + /** + * Request may have been made with default page slug, also we are dealing with a site language variant. To avoid + * duplicate content, we need to revalidate that the eventually matched language route is really the available + * page language variant for the current lange. We do this at this late point to minimize the needed database + * queries instead of checking it for all build page candidates. + * + * This is safe, as we can simply drop the route and having a correct page not found action delivered. + */ + protected function isRouteReallyValidForLanguage(Route $route, SiteLanguage $siteLanguage): bool + { + $page = $route->getOption('_page'); + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages'); + if (!$schema->isLanguageAware()) { + return true; + } + $languageIdField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(); + $languageId = (int)($page[$languageIdField] ?? 0); + if ($siteLanguage->getLanguageId() === 0 || $siteLanguage->getLanguageId() === $languageId) { + // default language site request or if page record is same language then siteLanguage, page record + // is valid to use as page resolving candidate and need no further overlay checks. + return true; + } + $pageIdInDefaultLanguage = (int)($languageId > 0 ? $page['l10n_parent'] : $page['uid']); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context); + $localizedPage = $pageRepository->getPageOverlay($pageIdInDefaultLanguage, $siteLanguage->getLanguageId()); + if (!$localizedPage) { + // no page language overlay found, which means that either language page is not published and no logged + // in backend user OR there is no language overlay for that page at all. Thus using page record to build + // as page resolving candidate is valid. + return true; + } + // we found a valid page overlay, which means that current record is not the valid page for the current + // siteLanguage. To avoid resolving page with multiple slugs for a siteLanguage path, we flag this invalid. + return false; + } +} diff --git a/Classes/Routing/PageSlugCandidateProvider.php b/Classes/Routing/PageSlugCandidateProvider.php new file mode 100644 index 0000000..14d14d4 --- /dev/null +++ b/Classes/Routing/PageSlugCandidateProvider.php @@ -0,0 +1,456 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\LanguageAspect; +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\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface; +use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; + +/** + * Provides possible pages (from the database) that _could_ match a certain URL path, + * but also works for fetching the best "slug" value for multi-lingual pages with a specific language requested. + * + * @internal as this API might change and a possible interface is given at some point. + */ +class PageSlugCandidateProvider +{ + protected Site $site; + protected Context $context; + protected EnhancerFactory $enhancerFactory; + + public function __construct(Context $context, Site $site, ?EnhancerFactory $enhancerFactory) + { + $this->context = $context; + $this->site = $site; + $this->enhancerFactory = $enhancerFactory ?? GeneralUtility::makeInstance(EnhancerFactory::class); + } + + /** + * Fetches an array of possible URLs that match the current site + language (incl. fallbacks) + * + * @return array<int,array<string,mixed>> + */ + public function getCandidatesForPath(string $urlPath, SiteLanguage $language): array + { + $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/'); + $pageCandidates = []; + $languages = [$language->getLanguageId()]; + if (!empty($language->getFallbackLanguageIds())) { + $languages = array_merge($languages, $language->getFallbackLanguageIds()); + } + // Iterate all defined languages in their configured order to get matching page candidates somewhere in the language fallback chain + foreach ($languages as $languageId) { + $pageCandidatesFromSlugsAndLanguage = $this->getPagesFromDatabaseForCandidates($slugCandidates, $languageId); + // Determine whether fetched page candidates qualify for the request. The incoming URL is checked against all + // pages found for the current URL and language. + foreach ($pageCandidatesFromSlugsAndLanguage as $candidate) { + $slugCandidate = '/' . trim($candidate['slug'], '/'); + if ($slugCandidate === '/' || str_starts_with($urlPath, $slugCandidate)) { + // The slug is a subpart of the requested URL, so it's a possible candidate + if ($urlPath === $slugCandidate) { + // The requested URL matches exactly the found slug. We can't find a better match, + // so use that page candidate and stop any further querying. + $pageCandidates = [$candidate]; + break 2; + } + + $pageCandidates[] = $candidate; + } + } + } + return $pageCandidates; + } + + /** + * Fetches the page without any language or other hidden/enable fields, but only takes + * "deleted" and "workspace" into account, as all other things will be evaluated later. + * + * This is only needed for resolving the ACTUAL Page Id when index.php?id=13 was given + * + * Should be rebuilt to return the actual Page ID considering the online ID of the page. + * + * @param int $pageId + */ + public function getRealPageIdForPageIdAsPossibleCandidate(int $pageId): ?int + { + $workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id'); + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder + ->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId)); + + $statement = $queryBuilder + ->select('uid', 'l10n_parent') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + + $page = $statement->fetchAssociative(); + if (empty($page)) { + return null; + } + return (int)($page['l10n_parent'] ?: $page['uid']); + } + + /** + * Gets all patterns that can be used to redecorate (undecorate) a + * potential previously decorated route path. + * + * @return string regular expression pattern capable of redecorating + */ + protected function getRoutePathRedecorationPattern(): string + { + $decoratingEnhancers = $this->getDecoratingEnhancers(); + if (empty($decoratingEnhancers)) { + return ''; + } + $redecorationPatterns = array_map( + static function (DecoratingEnhancerInterface $decorationEnhancers) { + $pattern = $decorationEnhancers->getRoutePathRedecorationPattern(); + return '(?:' . $pattern . ')'; + }, + $decoratingEnhancers + ); + return '(?P<decoration>' . implode('|', $redecorationPatterns) . ')'; + } + + /** + * Resolves decorating enhancers without having aspects assigned. These + * instances are used to pre-process URL path and MUST NOT be used for + * actually resolving or generating URL parameters. + * + * @return DecoratingEnhancerInterface[] + */ + protected function getDecoratingEnhancers(): array + { + $enhancers = []; + foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) { + $enhancerType = $enhancerConfiguration['type'] ?? ''; + $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration); + if ($enhancer instanceof DecoratingEnhancerInterface) { + $enhancers[] = $enhancer; + } + } + return $enhancers; + } + + /** + * Check for records in the database which matches one of the slug candidates. + * + * @param array $excludeUids when called recursively this is the mountpoint parameter of the original prefix + * @return array[]|array + * @throws SiteNotFoundException + */ + protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId, array $excludeUids = []): array + { + $workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id'); + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder + ->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId, true)); + + $statement = $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'sys_language_uid', + $queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->in( + 'slug', + $queryBuilder->createNamedParameter( + $slugCandidates, + Connection::PARAM_STR_ARRAY + ) + ) + ) + // Exact match will be first, that's important + ->orderBy('slug', 'desc') + // versioned records should be rendered before the live records + ->addOrderBy('t3ver_wsid', 'desc') + // Sort pages that are not MountPoint pages before mount points + ->addOrderBy('mount_pid_ol', 'asc') + ->addOrderBy('mount_pid', 'asc') + ->executeQuery(); + + $pages = []; + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context); + $isRecursiveCall = !empty($excludeUids); + + while ($row = $statement->fetchAssociative()) { + $mountPageInformation = null; + $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : ($row['t3ver_oid'] ?: $row['uid'])); + // When this page was added before via recursion, this page should be skipped + if (in_array($pageIdInDefaultLanguage, $excludeUids, true)) { + continue; + } + + try { + $isOnSameSite = $siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId(); + } catch (SiteNotFoundException $e) { + // Page is not in a site, so it's not considered + $isOnSameSite = false; + } + + // If a MountPoint is found on the current site, and it hasn't been added yet by some other iteration + // (see below "findPageCandidatesOfMountPoint"), then let's resolve the MountPoint information now + if (!$isOnSameSite && $isRecursiveCall) { + // Not in the same site, and called recursive, should be skipped + continue; + } + $mountPageInformation = $pageRepository->getMountPointInfo($pageIdInDefaultLanguage, $row); + + // Mount Point Pages which are not on the same site (when not called on the first level) should be skipped + // As they just clutter up the queries. + if (!$isOnSameSite && !$isRecursiveCall && $mountPageInformation) { + continue; + } + + $mountedPage = null; + if (is_array($mountPageInformation)) { + // Add the MPvar to the row, so it can be used later-on in the PageRouter / PageArguments + $row['MPvar'] = $mountPageInformation['MPvar']; + $mountedPage = $pageRepository->getPage_noCheck((int)$mountPageInformation['mount_pid_rec']['uid']); + // Ensure to fetch the slug in the translated page + $mountedPage = $pageRepository->getLanguageOverlay('pages', $mountedPage, new LanguageAspect($languageId, $languageId)); + // Mount wasn't connected properly, so it is skipped + if (!$mountedPage) { + continue; + } + // If the page is a MountPoint which should be overlaid with the contents of the mounted page, + // it must never be accessible directly, but only in the MountPoint context. Therefore we change + // the current ID and slug. + // This needs to happen before the regular case, as the $pageToAdd contains the MPvar information + if ((int)$row['doktype'] === PageRepository::DOKTYPE_MOUNTPOINT && $row['mount_pid_ol']) { + // If the mounted page was already added from above, this should not be added again (to include + // the mount point parameter). + if (in_array((int)$mountedPage['uid'], $excludeUids, true)) { + continue; + } + $pageToAdd = $mountedPage; + // Make sure target page "/about-us" is replaced by "/global-site/about-us" so router works + $pageToAdd['MPvar'] = $mountPageInformation['MPvar']; + $pageToAdd['slug'] = $row['slug']; + $pages[] = $pageToAdd; + $excludeUids[] = (int)$pageToAdd['uid']; + $excludeUids[] = $pageIdInDefaultLanguage; + } + } + + // This is the regular "non-MountPoint page" case (must happen after the if condition so MountPoint + // pages that have been replaced by the Mounted Page will not be added again. + if ($isOnSameSite && !in_array($pageIdInDefaultLanguage, $excludeUids, true)) { + $pages[] = $row; + $excludeUids[] = $pageIdInDefaultLanguage; + } + + // Add possible sub-pages prepended with the MountPoint page slug + if (is_array($mountPageInformation)) { + /** @var array $mountedPage */ + $siteOfMountedPage = $siteFinder->getSiteByPageId((int)$mountedPage['uid']); + $morePageCandidates = $this->findPageCandidatesOfMountPoint( + $row, + $mountedPage, + $siteOfMountedPage, + $languageId, + $slugCandidates + ); + foreach ($morePageCandidates as $candidate) { + // When called previously this MountPoint page should be skipped + if (in_array((int)$candidate['uid'], $excludeUids, true)) { + continue; + } + $pages[] = $candidate; + } + } + } + return $pages; + } + + /** + * Check if the page candidate is a mount point, if so, we need to + * re-start the slug candidates procedure with the mount point as a prefix (= context of the subpage). + * + * Before doing the slugCandidates are adapted to remove the slug of the mount point (actively moving the pointer + * of the path to strip away the existing prefix), then checking for more pages. + * + * Once possible candidates are found, the slug prefix needs to be re-added so the PageRouter finds the page, + * with an additional 'MPvar' attribute. + * However, all page candidates needs to be checked if they are connected in the proper mount page. + * + * @param array $mountPointPage the page with doktype=7 + * @param array $mountedPage the target page where the mountpoint is pointing to + * @param Site $siteOfMountedPage the site of the target page, which could be different from the current page + * @param int $languageId the current language id + * @param array $slugCandidates the existing slug candidates that were looked for previously + * @return array more candidates + */ + protected function findPageCandidatesOfMountPoint( + array $mountPointPage, + array $mountedPage, + Site $siteOfMountedPage, + int $languageId, + array $slugCandidates + ): array { + $pages = []; + $slugOfMountPoint = $mountPointPage['slug'] ?? ''; + $commonSlugPrefixOfMountedPage = rtrim($mountedPage['slug'] ?? '', '/'); + $narrowedDownSlugPrefixes = []; + foreach ($slugCandidates as $slugCandidate) { + // Remove the mount point prefix (that we just found) from the slug candidates + if (str_starts_with($slugCandidate, $slugOfMountPoint)) { + // Find pages without the common prefix + $narrowedDownSlugPrefix = '/' . trim(substr($slugCandidate, strlen($slugOfMountPoint)), '/'); + $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix; + $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/'; + // Find pages with the prefix of the mounted page as well + if ($commonSlugPrefixOfMountedPage) { + $narrowedDownSlugPrefix = $commonSlugPrefixOfMountedPage . $narrowedDownSlugPrefix; + $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix; + $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/'; + } + } + } + $trimmedSlugPrefixes = []; + $narrowedDownSlugPrefixes = array_unique($narrowedDownSlugPrefixes); + foreach ($narrowedDownSlugPrefixes as $narrowedDownSlugPrefix) { + $narrowedDownSlugPrefix = trim($narrowedDownSlugPrefix, '/'); + $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix; + if (!empty($narrowedDownSlugPrefix)) { + $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix . '/'; + } + } + $trimmedSlugPrefixes = array_unique($trimmedSlugPrefixes); + rsort($trimmedSlugPrefixes); + + $slugProviderForMountPage = GeneralUtility::makeInstance(static::class, $this->context, $siteOfMountedPage, $this->enhancerFactory); + // Find the right pages for which have been matched + $excludedPageIds = [(int)$mountPointPage['uid']]; + $pageCandidates = $slugProviderForMountPage->getPagesFromDatabaseForCandidates( + $trimmedSlugPrefixes, + $languageId, + $excludedPageIds + ); + // The rootline is built with page IDs of the default language, so the mount point page ID + // of the default language must be used for the connection check below + $mountPointPageIdInDefaultLanguage = (int)($languageId > 0 ? $mountPointPage['l10n_parent'] : ($mountPointPage['t3ver_oid'] ?: $mountPointPage['uid'])); + // Depending on the "mount_pid_ol" parameter, the mountedPage or the mounted page is in the rootline + $pageWhichMustBeInRootLine = (int)($mountPointPage['mount_pid_ol'] ? $mountedPage['uid'] : $mountPointPageIdInDefaultLanguage); + foreach ($pageCandidates as $pageCandidate) { + if (!$pageCandidate['mount_pid_ol']) { + $pageCandidate['MPvar'] = !empty($pageCandidate['MPvar']) + ? $mountPointPage['MPvar'] . ',' . $pageCandidate['MPvar'] + : $mountPointPage['MPvar']; + } + // In order to avoid the possibility that any random page like /about-us which is not connected to the mount + // point is not possible to be called via /my-mount-point/about-us, let's check the + $pageCandidateIsConnectedInMountPoint = false; + $rootLine = GeneralUtility::makeInstance( + RootlineUtility::class, + $pageCandidate['uid'], + (string)($pageCandidate['MPvar'] ?? $pageCandidate['mount_pid_ol']), + $this->context + )->get(); + foreach ($rootLine as $pageInRootLine) { + if ((int)$pageInRootLine['uid'] === $pageWhichMustBeInRootLine) { + $pageCandidateIsConnectedInMountPoint = true; + break; + } + } + if ($pageCandidateIsConnectedInMountPoint === false) { + continue; + } + // Rewrite the slug of the subpage to match the PageRouter matching again + // This is done by first removing the "common" prefix possibly provided by the Mounted Page + // But more importantly adding the $slugOfMountPoint of the MountPoint Page + $slugOfSubpage = $pageCandidate['slug']; + if ($commonSlugPrefixOfMountedPage && str_starts_with($slugOfSubpage, $commonSlugPrefixOfMountedPage)) { + $slugOfSubpage = substr($slugOfSubpage, strlen($commonSlugPrefixOfMountedPage)); + } + $pageCandidate['slug'] = $slugOfMountPoint . (($slugOfSubpage && $slugOfSubpage !== '/') ? '/' . trim($slugOfSubpage, '/') : ''); + $pages[] = $pageCandidate; + } + return $pages; + } + + /** + * Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json + * to return. + * + * /home/about-us/offices/ + * /home/about-us/offices.json + * /home/about-us/offices + * /home/about-us/ + * /home/about-us + * /home/ + * /home + * / + * + * @param string $routePath + * @return string[] + */ + protected function getCandidateSlugsFromRoutePath(string $routePath): array + { + $redecorationPattern = $this->getRoutePathRedecorationPattern(); + if (!empty($redecorationPattern) && preg_match('#' . $redecorationPattern . '#', $routePath, $matches)) { + $decoration = $matches['decoration']; + $decorationPattern = preg_quote($decoration, '#'); + $routePath = preg_replace('#' . $decorationPattern . '$#', '', $routePath) ?? ''; + } + + $candidatePathParts = []; + $pathParts = GeneralUtility::trimExplode('/', $routePath, true); + if (empty($pathParts)) { + return ['/']; + } + + while (!empty($pathParts)) { + $prefix = '/' . implode('/', $pathParts); + $candidatePathParts[] = $prefix . '/'; + $candidatePathParts[] = $prefix; + array_pop($pathParts); + } + $candidatePathParts[] = '/'; + return $candidatePathParts; + } +} diff --git a/Classes/Routing/PageUriMatcher.php b/Classes/Routing/PageUriMatcher.php new file mode 100644 index 0000000..865b450 --- /dev/null +++ b/Classes/Routing/PageUriMatcher.php @@ -0,0 +1,160 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor; + +/** + * Internal class, which is similar to Symfony's Urlmatcher but without validating + * - conditions / expression language + * - host matches + * - method checks + * because this method only works in conjunction with PageRouter. + * + * @internal + */ +class PageUriMatcher +{ + /** + * @var RouteCollection<string, Route> + */ + protected $routes; + + /** + * @var MappableProcessor + */ + protected $mappableProcessor; + + /** + * @param RouteCollection<string, Route> $routes + */ + public function __construct(RouteCollection $routes) + { + $this->routes = $routes; + $this->mappableProcessor = new MappableProcessor(); + } + + /** + * Matches a path segment against the route collection + * + * @return array + * @throws ResourceNotFoundException + */ + public function match(string $urlPath) + { + if ($ret = $this->matchCollection(rawurldecode($urlPath), $this->routes)) { + return $ret; + } + throw new ResourceNotFoundException( + sprintf('No routes found for "%s".', $urlPath), + 1538156220 + ); + } + + /** + * Tries to match a URL with a set of routes. + * + * @param string $urlPath The path info to be parsed + * @param RouteCollection<string,Route> $routes The set of routes + * @return array An array of parameters + */ + protected function matchCollection(string $urlPath, RouteCollection $routes): ?array + { + foreach ($routes as $name => $route) { + $urlPath = $this->getDecoratedRoutePath($route) ?? $urlPath; + $compiledRoute = $route->compile(); + + // check the static prefix of the URL first. Only use the more expensive preg_match when it matches + if ($compiledRoute->getStaticPrefix() !== '' && !str_starts_with($urlPath, $compiledRoute->getStaticPrefix())) { + continue; + } + + if (!preg_match($compiledRoute->getRegex(), $urlPath, $matches)) { + continue; + } + + // custom handling of Mappable instances + if (!$this->mappableProcessor->resolve($route, $matches)) { + continue; + } + + return $this->getAttributes($route, $name, $matches); + } + return null; + } + + /** + * Resolves an optional route specific decorated route path that has been + * assigned by DecoratingEnhancerInterface instances. + */ + protected function getDecoratedRoutePath(Route $route): ?string + { + if (!$route->hasOption('_decoratedRoutePath')) { + return null; + } + $urlPath = $route->getOption('_decoratedRoutePath'); + return rawurldecode($urlPath); + } + + /** + * Returns an array of values to use as request attributes. + * + * As this method requires the Route object, it is not available + * in matchers that do not have access to the matched Route instance + * (like the PHP and Apache matcher dumpers). + * + * @param Route $route The route we are matching against + * @param string $name The name of the route + * @param array $attributes An array of attributes from the matcher + * @return array An array of parameters + */ + protected function getAttributes(Route $route, string $name, array $attributes): array + { + $defaults = $route->getDefaults(); + if (isset($defaults['_canonical_route'])) { + $name = $defaults['_canonical_route']; + unset($defaults['_canonical_route']); + } + $attributes['_route'] = $name; + // store applied default values in route options + $relevantDefaults = array_intersect_key($defaults, array_flip($route->compile()->getPathVariables())); + // option '_appliedDefaults' contains internal(!) values (default values are not mapped when resolving) + // (keys used are deflated and need to be inflated later using VariableProcessor) + $route->setOption('_appliedDefaults', array_diff_key($relevantDefaults, $attributes)); + // side note: $defaults can contain e.g. '_controller' + return $this->mergeDefaults($attributes, $defaults); + } + + /** + * Get merged default parameters. + * + * @param array $params The parameters + * @param array $defaults The defaults + * @return array Merged default parameters + */ + protected function mergeDefaults(array $params, array $defaults): array + { + foreach ($params as $key => $value) { + if (!is_int($key) && $value !== null) { + $defaults[$key] = $value; + } + } + return $defaults; + } +} diff --git a/Classes/Routing/RequestContextFactory.php b/Classes/Routing/RequestContextFactory.php new file mode 100644 index 0000000..d410f5f --- /dev/null +++ b/Classes/Routing/RequestContextFactory.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\Routing\RequestContext; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + +/** + * @internal this is not part of the TYPO3 Core public API, as it serves as an internal + * bridge between symfony/routing component and PSR-7 requests + */ +#[Autoconfigure(public: true)] +readonly class RequestContextFactory +{ + public function __construct( + protected BackendEntryPointResolver $backendEntryPointResolver + ) {} + + public function fromBackendRequest(ServerRequestInterface $request): RequestContext + { + $scheme = $request->getUri()->getScheme(); + return new RequestContext( + $this->backendEntryPointResolver->getPathFromRequest($request), + $request->getMethod(), + $request->getUri()->getHost() ? (string)idn_to_ascii($request->getUri()->getHost()) : '', + $request->getUri()->getScheme(), + $scheme === 'http' ? $request->getUri()->getPort() ?? 80 : 80, + $scheme === 'https' ? $request->getUri()->getPort() ?? 443 : 443, + ); + } + + public function fromUri(UriInterface $uri, string $method = 'GET'): RequestContext + { + return new RequestContext( + '', + $method, + (string)idn_to_ascii($uri->getHost()), + $uri->getScheme(), + // Ports are only necessary for URL generation in Symfony which is not used by TYPO3 + 80, + 443, + $uri->getPath() + ); + } + + public function fromSiteLanguage(SiteLanguage $language): RequestContext + { + $scheme = $language->getBase()->getScheme(); + return new RequestContext( + // page segment (slug & enhanced part) is supposed to start with '/' + rtrim($language->getBase()->getPath(), '/'), + 'GET', + $language->getBase()->getHost(), + $scheme ?: 'https', + $scheme === 'http' ? $language->getBase()->getPort() ?? 80 : 80, + $scheme === 'https' ? $language->getBase()->getPort() ?? 443 : 443 + ); + } +} diff --git a/Classes/Routing/Route.php b/Classes/Routing/Route.php new file mode 100644 index 0000000..5bc60c6 --- /dev/null +++ b/Classes/Routing/Route.php @@ -0,0 +1,187 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Symfony\Component\Routing\CompiledRoute; +use Symfony\Component\Routing\Route as SymfonyRoute; +use TYPO3\CMS\Core\Routing\Aspect\AspectInterface; +use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface; + +/** + * TYPO3's route is built on top of Symfony's route with some special handling + * of "Aspects" built on top of a route + * + * @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change if we change the under-the-hood implementation. + */ +class Route extends SymfonyRoute +{ + /** + * @var CompiledRoute|null + */ + protected $compiled; + + /** + * @var array<string, AspectInterface> + */ + protected $aspects = []; + + public function __construct( + string $path, + array $defaults = [], + array $requirements = [], + array $options = [], + ?string $host = '', + $schemes = [], + $methods = [], + ?string $condition = '', + array $aspects = [] + ) { + parent::__construct($path, $defaults, $requirements, $options, $host, $schemes, $methods, $condition); + $this->setAspects($aspects); + } + + /** + * @todo '_arguments' are added implicitly, make it explicit in enhancers + */ + public function getArguments(): array + { + return $this->getOption('_arguments') ?? []; + } + + public function getEnhancer(): ?EnhancerInterface + { + return $this->getOption('_enhancer') ?? null; + } + + /** + * @return array<string, AspectInterface> + */ + public function getAspects(): array + { + return $this->aspects; + } + + /** + * Sets the aspects and removes existing ones. + * + * This method implements a fluent interface. + * + * @param array<string, AspectInterface> $aspects + * @return $this + */ + public function setAspects(array $aspects): self + { + $this->aspects = []; + return $this->addAspects($aspects); + } + + /** + * Adds aspects to the existing maps. + * + * This method implements a fluent interface. + * + * @param array<string, AspectInterface> $aspects + * @return $this + */ + public function addAspects(array $aspects): self + { + foreach ($aspects as $key => $aspect) { + if (isset($this->aspects[$key])) { + throw new \OverflowException( + sprintf('Cannot override aspect %s', $key), + 1538326791 + ); + } + $this->aspects[$key] = $aspect; + } + $this->compiled = null; + return $this; + } + + /** + * Returns the aspect for the given key. + * + * @param string $key The key + * @return AspectInterface|null The regex or null when not given + */ + public function getAspect(string $key): ?AspectInterface + { + return $this->aspects[$key] ?? null; + } + + /** + * Checks if an aspect is set for the given key. + * + * @param string $key A variable name + * @return bool true if an aspect is specified, false otherwise + */ + public function hasAspect(string $key): bool + { + return array_key_exists($key, $this->aspects); + } + + /** + * Sets an aspect for the given key. + * + * @param string $key The key + * @return $this + */ + public function setAspect(string $key, AspectInterface $aspect): self + { + $this->aspects[$key] = $aspect; + $this->compiled = null; + return $this; + } + + /** + * @param string[] $classNames All (logical AND) class names that must match + * (including interfaces, abstract classes and traits) + * @param string[] $variableNames Variable names to be filtered + * @return AspectInterface[] + */ + public function filterAspects(array $classNames, array $variableNames = []): array + { + $aspects = $this->aspects; + if (empty($classNames) && empty($variableNames)) { + return $aspects; + } + if (!empty($variableNames)) { + $aspects = array_filter( + $this->aspects, + static function (string $variableName) use ($variableNames) { + return in_array($variableName, $variableNames, true); + }, + ARRAY_FILTER_USE_KEY + ); + } + return array_filter( + $aspects, + static function (AspectInterface $aspect) use ($classNames) { + $uses = class_uses($aspect) ?: []; + foreach ($classNames as $className) { + if (!is_a($aspect, $className) + && !in_array($className, $uses, true) + ) { + return false; + } + } + return true; + } + ); + } +} diff --git a/Classes/Routing/RouteCollection.php b/Classes/Routing/RouteCollection.php new file mode 100644 index 0000000..8e5ab60 --- /dev/null +++ b/Classes/Routing/RouteCollection.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Symfony\Component\Routing\Route as SymfonyRoute; +use Symfony\Component\Routing\RouteCollection as SymfonyRouteCollection; +use TYPO3\CMS\Backend\Routing\Route as Typo3Route; + +/** + * Extensible container based on Symfony's Route Collection + * + * @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change + */ +class RouteCollection extends SymfonyRouteCollection +{ + public function add(string $name, Typo3Route|SymfonyRoute $route, int $priority = 0): void + { + if ($route instanceof Typo3Route) { + $symfonyRoute = new SymfonyRoute($route->getPath(), [], [], $route->getOptions()); + $symfonyRoute->setMethods($route->getMethods()); + parent::add($name, $symfonyRoute, $priority); + } else { + parent::add($name, $route, $priority); + } + } +} diff --git a/Classes/Routing/RouteNotFoundException.php b/Classes/Routing/RouteNotFoundException.php new file mode 100644 index 0000000..0f2f03d --- /dev/null +++ b/Classes/Routing/RouteNotFoundException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use TYPO3\CMS\Core\Exception; + +/** + * Exception thrown when a route does not exist + */ +class RouteNotFoundException extends Exception {} diff --git a/Classes/Routing/RouteResultInterface.php b/Classes/Routing/RouteResultInterface.php new file mode 100644 index 0000000..decad1a --- /dev/null +++ b/Classes/Routing/RouteResultInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +/** + * An object that is usually returned by a Router to contain all results. + */ +interface RouteResultInterface extends \ArrayAccess {} diff --git a/Classes/Routing/RouteSorter.php b/Classes/Routing/RouteSorter.php new file mode 100644 index 0000000..6ec1c29 --- /dev/null +++ b/Classes/Routing/RouteSorter.php @@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +/** + * Pre-processing of given routes based on their actual disposal concerning given parameters. + * @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change + */ +class RouteSorter +{ + protected const EARLIER = -1; + protected const LATER = 1; + + /** + * @var Route[] + */ + protected array $routes = []; + + /** + * @var array<string, string> + */ + protected array $originalParameters = []; + + /** + * @return Route[] + */ + public function getRoutes(): array + { + return $this->routes; + } + + public function withRoutes(array $routes): self + { + $target = clone $this; + $target->routes = $routes; + return $target; + } + + public function withOriginalParameters(array $originalParameters): self + { + $target = clone $this; + $target->originalParameters = $originalParameters; + return $target; + } + + public function sortRoutesForGeneration(): self + { + uasort($this->routes, [$this, 'compareForGeneration']); + return $this; + } + + protected function compareForGeneration(Route $self, Route $other): int + { + // default routes (e.g `/my-page`) -> process later + return $this->compareDefaultRoutes($self, $other, self::LATER) + // no variables (e.g. `/my-page/list`) -> process later + ?? $this->compareStaticRoutes($self, $other, self::LATER) + // all variables complete -> process earlier + ?? $this->compareAllVariablesPresence($self, $other, self::EARLIER) + // mandatory variables complete -> process earlier + ?? $this->compareMandatoryVariablesPresence($self, $other, self::EARLIER) + // more missing variable defaults -> process later + ?? $this->compareMissingDefaultsAmount($self, $other, self::LATER) + // more variable defaults -> process later + ?? $this->compareDefaultsAmount($self, $other, self::LATER) + // hm, dunno -> keep position + ?? 0; + } + + protected function compareDefaultRoutes(Route $self, Route $other, int $action = self::LATER): ?int + { + $selfIsDefaultRoute = (bool)$self->getOption('_isDefault'); + $otherIsDefaultRoute = (bool)$other->getOption('_isDefault'); + // both are default routes, keep order + if ($selfIsDefaultRoute && $otherIsDefaultRoute) { + return 0; + } + // $self is default route, sort $self after $other + if ($selfIsDefaultRoute) { + return $action; + } + // $other is default route, sort $self before $other + if ($otherIsDefaultRoute) { + return -$action; + } + return null; + } + + protected function compareStaticRoutes(Route $self, Route $other, int $action = self::LATER): ?int + { + $selfVariableNames = $self->compile()->getPathVariables(); + $otherVariableNames = $other->compile()->getPathVariables(); + if ($selfVariableNames === [] && $otherVariableNames === []) { + return 0; + } + if ($selfVariableNames === []) { + return $action; + } + if ($otherVariableNames === []) { + return -$action; + } + return null; + } + + protected function compareAllVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int + { + $selfVariables = $this->getAllRouteVariables($self); + $otherVariables = $this->getAllRouteVariables($other); + $missingSelfVariables = array_diff_key( + $selfVariables, + $this->getRouteParameters($self) + ); + $missingOtherVariables = array_diff_key( + $otherVariables, + $this->getRouteParameters($other) + ); + if ($missingSelfVariables === [] && $missingOtherVariables === []) { + $difference = count($selfVariables) - count($otherVariables); + return $difference * $action; + } + if ($missingSelfVariables === [] && $missingOtherVariables !== []) { + return $action; + } + if ($missingOtherVariables === []) { + return -$action; + } + return null; + } + + protected function compareMandatoryVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int + { + $missingSelfVariables = array_diff_key( + $this->getMandatoryRouteVariables($self), + $this->getRouteParameters($self) + ); + $missingOtherVariables = array_diff_key( + $this->getMandatoryRouteVariables($other), + $this->getRouteParameters($other) + ); + if ($missingSelfVariables === [] && $missingOtherVariables !== []) { + return $action; + } + if ($missingSelfVariables !== [] && $missingOtherVariables === []) { + return -$action; + } + return null; + } + + protected function compareMissingDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int + { + $missingSelfDefaults = array_diff_key( + $this->getActualRouteDefaults($self), + $this->getRouteParameters($self) + ); + $missingOtherDefaults = array_diff_key( + $this->getActualRouteDefaults($other), + $this->getRouteParameters($other) + ); + $difference = count($missingSelfDefaults) - count($missingOtherDefaults); + // return `null` in case of equality (`0`) + return $difference === 0 ? null : $difference * $action; + } + + protected function compareDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int + { + $selfDefaults = $this->getActualRouteDefaults($self); + $otherDefaults = $this->getActualRouteDefaults($other); + $difference = count($selfDefaults) - count($otherDefaults); + // return `null` in case of equality (`0`) + return $difference === 0 ? null : $difference * $action; + } + + /** + * Filters route variable defaults that are actually used in route path. + * + * @return array<string, string> + */ + protected function getActualRouteDefaults(Route $route): array + { + return array_intersect_key( + $route->getDefaults(), + array_flip($route->compile()->getPathVariables()) + ); + } + + /** + * @return array<string, int> + */ + protected function getAllRouteVariables(Route $route): array + { + return array_flip($route->compile()->getPathVariables()); + } + + /** + * @return array<string, int> + */ + protected function getMandatoryRouteVariables(Route $route): array + { + return array_diff_key( + $this->getAllRouteVariables($route), + $route->getDefaults() + ); + } + + /** + * @return array<string, string> + */ + protected function getRouteParameters(Route $route): array + { + // $originalParameters is used as fallback + // (custom enhancers should have processed and deflated parameters) + return $route->getOption('deflatedParameters') ?? $this->originalParameters; + } +} diff --git a/Classes/Routing/RouterInterface.php b/Classes/Routing/RouterInterface.php new file mode 100644 index 0000000..bc9a49a --- /dev/null +++ b/Classes/Routing/RouterInterface.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Domain\RecordInterface; + +/** + * Base Router to be used all over the TYPO3 Core. Its base lies around PSR-7 requests + URIs, and special "RouteResult" + * objects. + */ +interface RouterInterface +{ + /** + * Generates an absolute URL + */ + public const ABSOLUTE_URL = 'url'; + + /** + * Generates an absolute path + */ + public const ABSOLUTE_PATH = 'absolute'; + + /** + * @param RouteResultInterface|null $previousResult + * @throws RouteNotFoundException + */ + public function matchRequest(ServerRequestInterface $request, ?RouteResultInterface $previousResult = null): RouteResultInterface; + + /** + * Builds a URI based on the $route and the given parameters. + * + * @param string|array|int|\ArrayAccess|RecordInterface $route either the route name, or for pages it is usually the array of a page record, or the page ID + * @param array $parameters query parameters, specially reserved parameters are usually prefixed with "_" + * @param string $fragment the section/fragment www.example.com/page/#fragment, WITHOUT the hash + * @param string $type see the constants above. + * @throws InvalidRouteArgumentsException + */ + public function generateUri($route, array $parameters = [], string $fragment = '', string $type = self::ABSOLUTE_URL): UriInterface; +} diff --git a/Classes/Routing/SiteMatcher.php b/Classes/Routing/SiteMatcher.php new file mode 100644 index 0000000..8c3a207 --- /dev/null +++ b/Classes/Routing/SiteMatcher.php @@ -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\Core\Routing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\Routing\Exception\NoConfigurationException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Site\Entity\NullSite; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; + +/** + * Returns a site based on a given request. + * + * The main usage is the ->matchRequest() functionality, which receives a request object and boots up + * Symfony Routing to find the proper route with its defaults / attributes. + * + * On top, this is also commonly used throughout TYPO3 to fetch a site by a given pageId. + * ->matchPageId(). + * + * The concept of the SiteMatcher is to *resolve*, and not build URIs. On top, it is a facade to hide the + * dependency to symfony and to not expose its logic. + * + * @internal Please note that the site matcher will be probably cease to exist and adapted to the SiteFinder concept when Pseudo-Site handling will be removed. + */ +readonly class SiteMatcher implements SingletonInterface +{ + public function __construct( + protected Features $features, + protected SiteFinder $finder, + protected RequestContextFactory $requestContextFactory + ) {} + + /** + * Only used when a page is moved but the pseudo site caches has this information hard-coded, so the caches + * need to be flushed. + * + * @internal + * @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException + */ + public function refresh() + { + /** Ensure root line caches are flushed */ + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $cacheManager->getCache('runtime')->flushByTag(RootlineUtility::RUNTIME_CACHE_TAG); + $cacheManager->getCache('rootline')->flush(); + } + + /** + * First, it is checked, if a "id" GET/POST parameter is found. + * If it is, we check for a valid site mounted there. + * + * If it isn't the quest continues by validating the whole request URL and validating against + * all available site records (and their language prefixes). + * + * @param ServerRequestInterface $request + */ + public function matchRequest(ServerRequestInterface $request): RouteResultInterface + { + // Remove script file name (index.php) from request uri + $uri = $this->canonicalizeUri($request->getUri(), $request); + $pageId = $this->resolvePageIdQueryParam($request); + $languageId = $this->resolveLanguageIdQueryParam($request); + + $routeResult = $this->matchSiteByUri($uri, $request); + + // Allow insecure pageId based site resolution if explicitly enabled and only if both, ?id= and ?L= are defined + // (pageId based site resolution without L parameter has always been prohibited, so we do not support that) + if ( + $this->features->isFeatureEnabled('security.frontend.allowInsecureSiteResolutionByQueryParameters') + && $pageId !== null && $languageId !== null + ) { + return $this->matchSiteByQueryParams($pageId, $languageId, $routeResult, $uri); + } + + // Allow the default language to be resolved in case all languages use a prefix + // and therefore did not match based on path if an explicit pageId is given, + // (example "https://www.example.com/?id=.." was entered, but all languages have "https://www.example.com/lang-key/") + // @todo remove this fallback, in order for SiteBaseRedirectResolver to produce a redirect instead (requires functionals to be adapted) + if ($pageId !== null && $routeResult->getLanguage() === null) { + $routeResult = $routeResult->withLanguage($routeResult->getSite()->getDefaultLanguage()); + } + + // adjust the language aspect if it was given by query param `&L` (and ?id is given) + // @todo remove, this is added for backwards (and functional tests) compatibility reasons + if ($languageId !== null && $pageId !== null) { + try { + // override/set language by `&L=` query param + $routeResult = $routeResult->withLanguage($routeResult->getSite()->getLanguageById($languageId)); + } catch (\InvalidArgumentException) { + // ignore; language id not available + } + } + + return $routeResult; + } + + /** + * If a given page ID is handed in, a Site/NullSite is returned. + * + * @param int $pageId uid of a page in default language + * @param array|null $rootLine an alternative root line, if already at and. + */ + public function matchByPageId(int $pageId, ?array $rootLine = null): SiteInterface + { + try { + return $this->finder->getSiteByPageId($pageId, $rootLine); + } catch (SiteNotFoundException) { + return new NullSite(); + } + } + + /** + * Returns a Symfony RouteCollection containing all routes to all sites. + */ + protected function getRouteCollectionForAllSites(): RouteCollection + { + $collection = new RouteCollection(); + foreach ($this->finder->getAllSites() as $site) { + // Add the site as entrypoint + // @todo Find a way to test only this basic route against chinese characters, as site languages kicking + // always in. Do the rawurldecode() here to to be consistent with language preparations. + + $uri = $site->getBase(); + $route = new Route( + (rawurldecode($uri->getPath()) ?: '/') . '{tail}', + ['site' => $site, 'language' => null, 'tail' => ''], + array_filter(['tail' => '.*', 'port' => (string)$uri->getPort()]), + ['utf8' => true, 'fallback' => true], + // @todo Verify if host should here covered with idn_to_ascii() to be consistent with preparation for languages. + $uri->getHost() ?: '', + $uri->getScheme() === '' ? [] : [$uri->getScheme()] + ); + $identifier = 'site_' . $site->getIdentifier(); + $collection->add($identifier, $route); + + // Add all languages + foreach ($site->getAllLanguages() as $siteLanguage) { + $uri = $siteLanguage->getBase(); + $route = new Route( + (rawurldecode($uri->getPath()) ?: '/') . '{tail}', + ['site' => $site, 'language' => $siteLanguage, 'tail' => ''], + array_filter(['tail' => '.*', 'port' => (string)$uri->getPort()]), + ['utf8' => true], + $uri->getHost() ? (string)idn_to_ascii($uri->getHost()) : '', + $uri->getScheme() === '' ? [] : [$uri->getScheme()] + ); + $identifier = 'site_' . $site->getIdentifier() . '_' . $siteLanguage->getLanguageId(); + $collection->add($identifier, $route); + } + } + return $collection; + } + + /** + * @return ?positive-int + */ + protected function resolvePageIdQueryParam(ServerRequestInterface $request): ?int + { + $pageId = $request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? null; + if ($pageId === null) { + return null; + } + if (!MathUtility::canBeInterpretedAsInteger($pageId)) { + return null; + } + return (int)$pageId <= 0 ? null : (int)$pageId; + } + + /** + * @return ?positive-int + */ + protected function resolveLanguageIdQueryParam(ServerRequestInterface $request): ?int + { + $languageId = $request->getQueryParams()['L'] ?? $request->getParsedBody()['L'] ?? null; + if ($languageId === null) { + return null; + } + if (!MathUtility::canBeInterpretedAsInteger($languageId)) { + return null; + } + return (int)$languageId < 0 ? null : (int)$languageId; + } + + /** + * Remove script file name (index.php) from request uri + */ + protected function canonicalizeUri(UriInterface $uri, ServerRequestInterface $request): UriInterface + { + if ($uri->getPath() === '') { + return $uri; + } + + $normalizedParams = $request->getAttribute('normalizedParams'); + if (!$normalizedParams instanceof NormalizedParams) { + return $uri; + } + + $urlPath = ltrim($uri->getPath(), '/'); + $scriptName = ltrim($normalizedParams->getScriptName(), '/'); + $scriptPath = ltrim($normalizedParams->getSitePath(), '/'); + if ($scriptName !== '' && str_starts_with($urlPath, $scriptName)) { + $urlPath = '/' . $scriptPath . substr($urlPath, mb_strlen($scriptName)); + $uri = $uri->withPath($urlPath); + } + + return $uri; + } + + protected function matchSiteByUri(UriInterface $uri, ServerRequestInterface $request): SiteRouteResult + { + $collection = $this->getRouteCollectionForAllSites(); + $requestContext = $this->requestContextFactory->fromUri($uri, $request->getMethod()); + $matcher = new BestUrlMatcher($collection, $requestContext); + try { + /** @var array{site: SiteInterface, language: ?SiteLanguage, tail: string} $match */ + $match = $matcher->match($uri->getPath()); + return new SiteRouteResult( + $uri, + $match['site'], + $match['language'], + $match['tail'] + ); + } catch (NoConfigurationException|ResourceNotFoundException) { + return new SiteRouteResult($uri, new NullSite(), null, ''); + } + } + + protected function matchSiteByQueryParams( + int $pageId, + int $languageId, + SiteRouteResult $fallback, + UriInterface $uri, + ): SiteRouteResult { + try { + $site = $this->finder->getSiteByPageId($pageId); + } catch (SiteNotFoundException) { + return $fallback; + } + + try { + // override/set language by `&L=` query param + $language = $site->getLanguageById($languageId); + } catch (\InvalidArgumentException) { + return $fallback; + } + + return new SiteRouteResult($uri, $site, $language); + } +} diff --git a/Classes/Routing/SiteRouteResult.php b/Classes/Routing/SiteRouteResult.php new file mode 100644 index 0000000..b405ae1 --- /dev/null +++ b/Classes/Routing/SiteRouteResult.php @@ -0,0 +1,169 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + +/** + * Class, usually available within request attribute "routing" + * containing all the findings of the Routers. + * When doing page-based routing the SiteRouteResult will get replaced with the PageArguments object. + */ +class SiteRouteResult implements RouteResultInterface +{ + /** + * @var array + */ + protected $validProperties = ['uri', 'site', 'language', 'tail']; + + /** + * Incoming URI which was processed. + * @var UriInterface + */ + protected $uri; + + /** + * @var SiteInterface + */ + protected $site; + + /** + * @var SiteLanguage|null + */ + protected $language; + + /** + * data bag with additional attributes + * @var array + */ + protected $data; + + /** + * The leftover string of the path from the uri + * @var string + */ + protected $tail; + + public function __construct(UriInterface $uri, SiteInterface $site, ?SiteLanguage $language = null, string $tail = '', array $data = []) + { + $this->uri = $uri; + $this->site = $site; + $this->language = $language; + $this->tail = $tail; + $this->data = $data; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function getSite(): SiteInterface + { + return $this->site; + } + + public function getLanguage(): ?SiteLanguage + { + return $this->language; + } + + public function getTail(): string + { + return $this->tail; + } + + public function offsetExists($offset): bool + { + return in_array($offset, $this->validProperties, true) || isset($this->data[$offset]); + } + + /** + * @internal + */ + public function withLanguage(SiteLanguage $language): self + { + $clone = clone $this; + $clone->language = $language; + + return $clone; + } + + /** + * @param mixed $offset + */ + public function offsetGet($offset): mixed + { + switch ($offset) { + case 'uri': + return $this->uri; + case 'site': + return $this->site; + case 'language': + return $this->language; + case 'tail': + return $this->tail; + default: + return $this->data[$offset]; + } + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + switch ($offset) { + case 'uri': + throw new \InvalidArgumentException('You can never replace the URI in a route result', 1535462423); + case 'site': + throw new \InvalidArgumentException('You can never replace the Site object in a route result', 1535462454); + case 'language': + throw new \InvalidArgumentException('You can never replace the Language object in a route result', 1535462452); + case 'tail': + $this->tail = $value; + break; + default: + $this->data[$offset] = $value; + } + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + switch ($offset) { + case 'uri': + throw new \InvalidArgumentException('You can never replace the URI in a route result', 1535462429); + case 'site': + throw new \InvalidArgumentException('You can never replace the Site object in a route result', 1535462458); + case 'language': + $this->language = null; + break; + case 'tail': + $this->tail = ''; + break; + default: + unset($this->data[$offset]); + } + } +} diff --git a/Classes/Routing/SiteUrlResolver.php b/Classes/Routing/SiteUrlResolver.php new file mode 100644 index 0000000..ee4771b --- /dev/null +++ b/Classes/Routing/SiteUrlResolver.php @@ -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\Core\Routing; + +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Site\Entity\Site; + +/** + * Utilizes the SiteMatcher to resolve a URL and return its UID. + * + * @internal + */ +final readonly class SiteUrlResolver +{ + public function __construct( + private SiteMatcher $siteMatcher, + ) {} + + /** + * Searches the page UID by the full URI + */ + public function resolvePageUidBySiteUrl(string $fullUri): ?int + { + $request = new ServerRequest($fullUri); + /** @var SiteRouteResult $siteMatch */ + $siteMatch = $this->siteMatcher->matchRequest($request); + $site = $siteMatch->getSite(); + $siteId = $site->getIdentifier(); + $languageId = $siteMatch->getLanguage()?->getLanguageId(); + if ($siteId === '' || $languageId === null || !($site instanceof Site)) { + // Not a valid site. + return null; + } + + try { + $route = $site->getRouter()->matchRequest($request, $siteMatch); + } catch (RouteNotFoundException) { + return null; + } + + if ($route instanceof PageArguments && !$route->areDirty()) { + return $route->getPageId(); + } + + return null; + } + + /** + * Searches the page UID by the full URI and returns the page UID including its language. + * + * @return ?array{uid: int, languageUid: int, languageName: string} + */ + public function resolvePageUidAndLanguageBySiteUrl(string $fullUri): ?array + { + $request = new ServerRequest($fullUri); + /** @var SiteRouteResult $siteMatch */ + $siteMatch = $this->siteMatcher->matchRequest($request); + $site = $siteMatch->getSite(); + $siteId = $site->getIdentifier(); + $languageId = $siteMatch->getLanguage()?->getLanguageId(); + if ($siteId === '' || $languageId === null || !($site instanceof Site)) { + // Not a valid site. + return null; + } + + try { + $route = $site->getRouter()->matchRequest($request, $siteMatch); + } catch (RouteNotFoundException) { + return null; + } + + if ($route instanceof PageArguments && !$route->areDirty()) { + return [ + 'uid' => $route->getPageId(), + 'languageUid' => $languageId, + 'languageName' => $site->getLanguageById($languageId)->getTitle(), + ]; + } + + return null; + } +} diff --git a/Classes/Routing/UnableToLinkToPageException.php b/Classes/Routing/UnableToLinkToPageException.php new file mode 100644 index 0000000..1ce551e --- /dev/null +++ b/Classes/Routing/UnableToLinkToPageException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Routing; + +use TYPO3\CMS\Core\Exception; + +/** + * Exception thrown when a link to a page (or page in a specific translation) cannot be built. + */ +class UnableToLinkToPageException extends Exception {} diff --git a/Classes/Routing/UrlGenerator.php b/Classes/Routing/UrlGenerator.php new file mode 100644 index 0000000..e3cd530 --- /dev/null +++ b/Classes/Routing/UrlGenerator.php @@ -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\Core\Routing; + +use Symfony\Component\Routing\Generator\UrlGenerator as SymfonyUrlGenerator; +use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor; + +/** + * @internal + */ +class UrlGenerator extends SymfonyUrlGenerator +{ + /** + * @var MappableProcessor|null + */ + protected $mappableProcessor; + + public function injectMappableProcessor(MappableProcessor $mappableProcessor): void + { + $this->mappableProcessor = $mappableProcessor; + } + + /** + * Processes aspect mapping on default values and delegates route generation to parent class. + * + * {@inheritdoc} + */ + protected function doGenerate(array $variables, array $defaults, array $requirements, array $tokens, array $parameters, string $name, int $referenceType, array $hostTokens, array $requiredSchemes = []): string + { + /** @var Route $route */ + $route = $this->routes->get($name); + // _appliedDefaults contains internal(!) values (mapped default values are not generated yet) + // (keys used are deflated and need to be inflated later using VariableProcessor) + $relevantDefaults = array_intersect_key($defaults, array_flip($route->compile()->getPathVariables())); + $route->setOption('_appliedDefaults', array_diff_key($relevantDefaults, $parameters)); + // map default values for URL generation (e.g. '1' becomes 'one' if defined in aspect) + $mappableProcessor = $this->mappableProcessor ?? new MappableProcessor(); + $mappableProcessor->generate($route, $defaults); + + return parent::doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $name, $referenceType, $hostTokens, $requiredSchemes); + } +} diff --git a/Classes/Schema/ActiveRelation.php b/Classes/Schema/ActiveRelation.php new file mode 100644 index 0000000..71c16a9 --- /dev/null +++ b/Classes/Schema/ActiveRelation.php @@ -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\Core\Schema; + +/** + * A relation to another field / schema. + * + * An example: + * - A field "authors" in table "books" has an active relation to the field "written_books" in table "tx_myextension_author" + * - A field "assets" in table "tt_content" has an active relation TO "sys_file_reference.uid". + */ +final readonly class ActiveRelation +{ + public function __construct( + private string $toTable, + private ?string $toField + ) {} + + public function toTable(): string + { + return $this->toTable; + } + + public function toField(): ?string + { + return $this->toField; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } + +} diff --git a/Classes/Schema/Capability/FieldCapability.php b/Classes/Schema/Capability/FieldCapability.php new file mode 100644 index 0000000..40cde7a --- /dev/null +++ b/Classes/Schema/Capability/FieldCapability.php @@ -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\Core\Schema\Capability; + +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; + +/** + * Can be used for any kind of field which HAS a definition in the "columns" section of TCA. + * + * Examples: + * - editLock + * - descriptionField + * - any kind of enableFields + */ +final readonly class FieldCapability implements SchemaCapabilityInterface +{ + public function __construct( + private FieldTypeInterface $field + ) {} + + public function getFieldName(): string + { + return $this->field->getName(); + } + + public function getField(): FieldTypeInterface + { + return $this->field; + } + + public function __toString(): string + { + return $this->getFieldName(); + } +} diff --git a/Classes/Schema/Capability/LabelCapability.php b/Classes/Schema/Capability/LabelCapability.php new file mode 100644 index 0000000..35291aa --- /dev/null +++ b/Classes/Schema/Capability/LabelCapability.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Capability; + +/** + * Contains all information of compiling the label information of a schema. + */ +final readonly class LabelCapability implements SchemaCapabilityInterface +{ + public function __construct( + private ?string $primaryFieldName, + /** @var string[] */ + private array $additionalFieldNames, + private bool $alwaysRenderAdditionalFields, + private array $configuration, + ) {} + + public function getPrimaryFieldName(): ?string + { + return $this->primaryFieldName; + } + + public function hasPrimaryField(): bool + { + return $this->primaryFieldName !== null; + } + + /** + * @return string[] + */ + public function getAdditionalFieldNames(): array + { + return $this->additionalFieldNames; + } + + public function getAllLabelFieldNames(): array + { + return array_unique(array_filter(array_merge([$this->primaryFieldName], $this->additionalFieldNames))); + } + + public function alwaysRenderAdditionalFields(): bool + { + return $this->alwaysRenderAdditionalFields; + } + + public function getConfiguration(): array + { + return $this->configuration; + } +} diff --git a/Classes/Schema/Capability/LanguageAwareSchemaCapability.php b/Classes/Schema/Capability/LanguageAwareSchemaCapability.php new file mode 100644 index 0000000..5b48b26 --- /dev/null +++ b/Classes/Schema/Capability/LanguageAwareSchemaCapability.php @@ -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\Core\Schema\Capability; + +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; +use TYPO3\CMS\Core\Schema\Field\LanguageFieldType; +use TYPO3\CMS\Core\Schema\Field\LanguageTagFieldType; + +/** + * Contains all information if a schema is language-aware, meaning + * it has a "languageField", a "translationOrigPointerField", maybe a "translationSourceField" + * and maybe a "diffSourceField". + */ +final readonly class LanguageAwareSchemaCapability implements SchemaCapabilityInterface +{ + public function __construct( + private LanguageFieldType $languageField, + private FieldTypeInterface $originPointerField, + private ?FieldTypeInterface $translationSourceField, + private ?FieldTypeInterface $diffSourceField + ) {} + + /** + * languageField->getName() typically resolves to 'sys_language_uid' + */ + public function getLanguageField(): LanguageFieldType + { + return $this->languageField; + } + + public function getLanguageTagField(): LanguageTagFieldType + { + return new LanguageTagFieldType('language_tag'); + } + + /** + * translationOriginPointerField->getName() typically resolves to 'l10n_parent' or 'l18n_parent' + */ + public function getTranslationOriginPointerField(): FieldTypeInterface + { + return $this->originPointerField; + } + + public function hasTranslationSourceField(): bool + { + return $this->translationSourceField !== null; + } + + public function getTranslationSourceField(): ?FieldTypeInterface + { + return $this->translationSourceField; + } + + /** + * diffSourceField->getName() typically resolves to 'l10n_diffsource' or 'l18n_diffsource' + */ + public function getDiffSourceField(): ?FieldTypeInterface + { + return $this->diffSourceField; + } + + public function hasDiffSourceField(): bool + { + return $this->diffSourceField !== null; + } +} diff --git a/Classes/Schema/Capability/RootLevelCapability.php b/Classes/Schema/Capability/RootLevelCapability.php new file mode 100644 index 0000000..f5cfff7 --- /dev/null +++ b/Classes/Schema/Capability/RootLevelCapability.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Capability; + +/** + * Capability to understand the flag within + * - security.ignoreRootLevelRestriction + */ +final readonly class RootLevelCapability implements SchemaCapabilityInterface +{ + public const int TYPE_ONLY_ON_PAGES = 0; // must be on a page (not pid=0) + public const int TYPE_ONLY_ON_ROOTLEVEL = 1; // only allowed on pid=0 + public const int TYPE_BOTH = -1; // does not matter + + public function __construct( + private int $rootLevelType, + private bool $ignoreRootLevelRestriction + ) {} + + public function getRootLevelType(): int + { + return $this->rootLevelType; + } + + public function shallIgnoreRootLevelRestriction(): bool + { + return $this->ignoreRootLevelRestriction; + } + + public function canExistOnRootLevel(): bool + { + return $this->rootLevelType === self::TYPE_BOTH || $this->rootLevelType === self::TYPE_ONLY_ON_ROOTLEVEL; + } + + public function canExistOnPages(): bool + { + return $this->rootLevelType === self::TYPE_BOTH || $this->rootLevelType === self::TYPE_ONLY_ON_PAGES; + } + + /** + * Allows non-admin users to access records that on the root-level (page-id 0), thus bypassing this usual restriction. + */ + public function canAccessRecordsOnRootLevel(): bool + { + return !$this->rootLevelType || $this->ignoreRootLevelRestriction; + } +} diff --git a/Classes/Schema/Capability/ScalarCapability.php b/Classes/Schema/Capability/ScalarCapability.php new file mode 100644 index 0000000..39e22da --- /dev/null +++ b/Classes/Schema/Capability/ScalarCapability.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Capability; + +/** + * Primitive capability that just contains a fixed value. + * Examples: + * - default_sortby + * - versioningWS + * - adminOnly + * - readOnly + * - hideAtCopy + * - hideTable + * - prependAtCopy + */ +final readonly class ScalarCapability implements SchemaCapabilityInterface +{ + public function __construct( + private bool|string|int|array|float|null $value = null + ) {} + + public function getValue(): bool|string|int|array|float|null + { + return $this->value; + } +} diff --git a/Classes/Schema/Capability/SchemaCapabilityInterface.php b/Classes/Schema/Capability/SchemaCapabilityInterface.php new file mode 100644 index 0000000..3ba0eac --- /dev/null +++ b/Classes/Schema/Capability/SchemaCapabilityInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Capability; + +/** + * A semantic interface for any kind of capability. + */ +interface SchemaCapabilityInterface {} diff --git a/Classes/Schema/Capability/SystemInternalFieldCapability.php b/Classes/Schema/Capability/SystemInternalFieldCapability.php new file mode 100644 index 0000000..6fb4ac8 --- /dev/null +++ b/Classes/Schema/Capability/SystemInternalFieldCapability.php @@ -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\Core\Schema\Capability; + +/** + * Can be used for any kind of field which does NOT have + * a definition in the "columns" section of TCA. + * + * -> sortBy + * -> crdate + * -> tstamp + * -> delete + */ +final readonly class SystemInternalFieldCapability implements SchemaCapabilityInterface +{ + public function __construct( + private string $fieldName + ) {} + + public function getFieldName(): string + { + return $this->fieldName; + } + + public function __toString(): string + { + return $this->getFieldName(); + } +} diff --git a/Classes/Schema/Capability/TcaSchemaCapability.php b/Classes/Schema/Capability/TcaSchemaCapability.php new file mode 100644 index 0000000..f9c3ad9 --- /dev/null +++ b/Classes/Schema/Capability/TcaSchemaCapability.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Capability; + +/** + * Contains all capabilities that can be defined in TCA + * and are understandable by the Schema API. + */ +enum TcaSchemaCapability +{ + // TCA[ctrl][delete] + case SoftDelete; + + // TCA[ctrl][crdate] + case CreatedAt; + + // TCA[ctrl][tstamp] + case UpdatedAt; + + // TCA[ctrl][sortby] + case SortByField; + + // TCA[ctrl][default_sortby] + case DefaultSorting; + + // TCA[ctrl][origUid] + case AncestorReferenceField; + + // TCA[ctrl][editlock] + case EditLock; + + // TCA[ctrl][descriptionColumn] + case InternalDescription; + + // TCA[ctrl][language] + case Language; + + // TCA[ctrl][workspace] + case Workspace; + + // TCA[ctrl][label],TCA[ctrl][label_alt],TCA[ctrl][label_alt_force]... + case Label; + + // TCA[ctrl][adminOnly] + case AccessAdminOnly; + + // TCA[ctrl][readOnly] + case AccessReadOnly; + + // TCA[ctrl][hideAtCopy] + case HideRecordsAtCopy; + + // TCA[ctrl][hideTable] + case HideInUi; + + // TCA[ctrl][prependAtCopy] + case PrependLabelTextAtCopy; + + // TCA[ctrl][enablecolumns][disabled] + case RestrictionDisabledField; + + // TCA[ctrl][enablecolumns][starttime] + case RestrictionStartTime; + + // TCA[ctrl][enablecolumns][endtime] + case RestrictionEndTime; + + // TCA[ctrl][enablecolumns][fe_group] + case RestrictionUserGroup; + + // TCA[ctrl][extbase][enableHistoryTracking] + case ExtbaseHistoryTracking; + + case RestrictionRootLevel; + + // TCA[ctrl][ignoreWebMountRestriction] inverted + case RestrictionWebMount; + private const SYSTEM_CAPABILITIES = [ + self::CreatedAt, + self::UpdatedAt, + self::RestrictionStartTime, + self::RestrictionEndTime, + self::SoftDelete, + self::EditLock, + self::RestrictionDisabledField, + self::InternalDescription, + self::SortByField, + self::RestrictionUserGroup, + ]; + + public static function getSystemCapabilities(): array + { + return self::SYSTEM_CAPABILITIES; + } +} diff --git a/Classes/Schema/Exception/FieldTypeNotAvailableException.php b/Classes/Schema/Exception/FieldTypeNotAvailableException.php new file mode 100644 index 0000000..1556495 --- /dev/null +++ b/Classes/Schema/Exception/FieldTypeNotAvailableException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Exception; + +use TYPO3\CMS\Core\Exception; + +class FieldTypeNotAvailableException extends Exception {} diff --git a/Classes/Schema/Exception/InvalidSchemaTypeException.php b/Classes/Schema/Exception/InvalidSchemaTypeException.php new file mode 100644 index 0000000..22c8b18 --- /dev/null +++ b/Classes/Schema/Exception/InvalidSchemaTypeException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Exception; + +use TYPO3\CMS\Core\Exception; + +class InvalidSchemaTypeException extends Exception {} diff --git a/Classes/Schema/Exception/UndefinedFieldException.php b/Classes/Schema/Exception/UndefinedFieldException.php new file mode 100644 index 0000000..7cb1827 --- /dev/null +++ b/Classes/Schema/Exception/UndefinedFieldException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Exception; + +use TYPO3\CMS\Core\Exception; + +class UndefinedFieldException extends Exception {} diff --git a/Classes/Schema/Exception/UndefinedSchemaException.php b/Classes/Schema/Exception/UndefinedSchemaException.php new file mode 100644 index 0000000..fe49295 --- /dev/null +++ b/Classes/Schema/Exception/UndefinedSchemaException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Exception; + +use TYPO3\CMS\Core\Exception; + +class UndefinedSchemaException extends Exception {} diff --git a/Classes/Schema/Field/AbstractFieldType.php b/Classes/Schema/Field/AbstractFieldType.php new file mode 100644 index 0000000..c4c6556 --- /dev/null +++ b/Classes/Schema/Field/AbstractFieldType.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\DataHandling\TableColumnType; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A single field definition containing the basic information for a field + */ +abstract readonly class AbstractFieldType implements FieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + ) {} + + public static function __set_state(array $state): self + { + /** @phpstan-ignore-next-line Usage is safe because state is exported by PHP var_export() from the static instance */ + return new static(...$state); + } + + abstract public function getType(): string; + + public function getName(): string + { + return $this->name; + } + + public function getLabel(): string + { + return (string)($this->configuration['label'] ?? ''); + } + + public function getDescription(): string + { + return (string)($this->configuration['description'] ?? ''); + } + + public function supportsAccessControl(): bool + { + return (bool)($this->configuration['exclude'] ?? false); + } + + public function isRequired(): bool + { + return (bool)($this->configuration['required'] ?? false); + } + + public function isNullable(): bool + { + return (bool)($this->configuration['nullable'] ?? false); + } + + abstract public function isSearchable(): bool; + + public function getDefaultValue(): mixed + { + return $this->configuration['default'] ?? null; + } + + public function hasDefaultValue(): bool + { + return array_key_exists('default', $this->configuration); + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function getTranslationBehaviour(): FieldTranslationBehaviour + { + return FieldTranslationBehaviour::tryFromFieldConfiguration($this->configuration); + } + + public function getDisplayConditions(): array|string + { + return $this->configuration['displayCond'] ?? []; + } + + public function isType(TableColumnType ...$tableColumnTypes): bool + { + return in_array(TableColumnType::tryFrom($this->getType()), $tableColumnTypes, true); + } + + public function getSoftReferenceKeys(): array|false + { + if (!isset($this->configuration['softref'])) { + return false; + } + + return GeneralUtility::trimExplode(',', $this->configuration['softref'], true); + } +} diff --git a/Classes/Schema/Field/CategoryFieldType.php b/Classes/Schema/Field/CategoryFieldType.php new file mode 100644 index 0000000..01d0801 --- /dev/null +++ b/Classes/Schema/Field/CategoryFieldType.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\RelationshipType; + +final readonly class CategoryFieldType extends AbstractFieldType implements RelationalFieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + private array $relations + ) {} + + public function getType(): string + { + return 'category'; + } + + public function isSearchable(): false + { + return false; + } + + public function getTreeConfiguration(): array + { + return $this->configuration['treeConfig'] ?? []; + } + + public function getRelations(): array + { + return $this->relations; + } + + public function getRelationshipType(): RelationshipType + { + return RelationshipType::fromTcaConfiguration($this->configuration); + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/CheckboxFieldType.php b/Classes/Schema/Field/CheckboxFieldType.php new file mode 100644 index 0000000..f6c0bca --- /dev/null +++ b/Classes/Schema/Field/CheckboxFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class CheckboxFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'check'; + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/ColorFieldType.php b/Classes/Schema/Field/ColorFieldType.php new file mode 100644 index 0000000..b26f272 --- /dev/null +++ b/Classes/Schema/Field/ColorFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class ColorFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'color'; + } + + public function supportsOpacity(): bool + { + return (bool)($this->configuration['opacity'] ?? false); + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/CountryFieldType.php b/Classes/Schema/Field/CountryFieldType.php new file mode 100644 index 0000000..a4b0739 --- /dev/null +++ b/Classes/Schema/Field/CountryFieldType.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class CountryFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'country'; + } + + public function isSearchable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/DateTimeFieldType.php b/Classes/Schema/Field/DateTimeFieldType.php new file mode 100644 index 0000000..7c26c22 --- /dev/null +++ b/Classes/Schema/Field/DateTimeFieldType.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Database\Query\QueryHelper; + +final readonly class DateTimeFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'datetime'; + } + + /** + * native datetime fields are nullable by default, and + * are only not-nullable if `nullable` is explicitly set to false. + */ + public function isNullable(): bool + { + if ($this->getPersistenceType() !== null) { + return $this->configuration['nullable'] ?? true; + } + return parent::isNullable(); + } + + public function getFormat(): string + { + $format = $this->configuration['format'] ?? null; + $persistenceType = $this->getPersistenceType(); + // A native time field must not be formatted as date + if (($format === 'datetime' || $format === 'date') && $persistenceType === 'time') { + return 'timesec'; + } + // A native date field must not be formatted as time + if (($format === 'time' || $format === 'timesec') && $persistenceType === 'date') { + return 'date'; + } + if (in_array($format, ['datetime', 'date', 'time', 'timesec', 'datetimesec'], true)) { + return $format; + } + if ($persistenceType !== null) { + return $persistenceType === 'time' ? 'timesec' : $persistenceType; + } + return 'datetime'; + } + + public function isSearchable(): bool + { + return $this->getPersistenceType() === null && ($this->configuration['searchable'] ?? true); + } + + public function getPersistenceType(): ?string + { + return in_array($this->configuration['dbType'] ?? null, QueryHelper::getDateTimeTypes(), true) ? $this->configuration['dbType'] : null; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/EmailFieldType.php b/Classes/Schema/Field/EmailFieldType.php new file mode 100644 index 0000000..4db5aa4 --- /dev/null +++ b/Classes/Schema/Field/EmailFieldType.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class EmailFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'email'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } +} diff --git a/Classes/Schema/Field/FieldCollection.php b/Classes/Schema/Field/FieldCollection.php new file mode 100644 index 0000000..82e3617 --- /dev/null +++ b/Classes/Schema/Field/FieldCollection.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class FieldCollection implements \ArrayAccess, \IteratorAggregate, \Countable +{ + public function __construct( + /** + * @var array<string, FieldTypeInterface> $fieldDefinitions + */ + private array $fieldDefinitions = [] + ) {} + + public static function __set_state(array $state): self + { + return new self(...$state); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->fieldDefinitions[$offset]); + } + + public function offsetGet(mixed $offset): ?FieldTypeInterface + { + return $this->fieldDefinitions[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \InvalidArgumentException('Fields cannot be set.', 1712539281); + } + + public function offsetUnset(mixed $offset): void + { + throw new \InvalidArgumentException('Fields cannot be unset.', 1712539280); + } + + public function getNames(): array + { + return array_keys($this->fieldDefinitions); + } + + /** + * @return \Traversable|FieldTypeInterface[] + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->fieldDefinitions); + } + + public function count(): int + { + return count($this->fieldDefinitions); + } +} diff --git a/Classes/Schema/Field/FieldTranslationBehaviour.php b/Classes/Schema/Field/FieldTranslationBehaviour.php new file mode 100644 index 0000000..9895eac --- /dev/null +++ b/Classes/Schema/Field/FieldTranslationBehaviour.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +/** + * Defines possible behaviour scenarios based on TCA settings + * - 'l10n_mode' = exclude + * - 'l10n_mode' = prefixLangTitle + * - none = field is translatable + */ +enum FieldTranslationBehaviour +{ + /** + * A field can be translated -> any custom value can be set. + */ + case Translatable; + + /** + * A field can be translated. A prefix is prepended on initial localization like this: + * `[Translate to <language name>:]` + */ + case PrefixLanguageTitle; + + /** + * A field is excluded from the translation editing - means, it always has the same value + * as the default translation + */ + case Excluded; + + public static function tryFromFieldConfiguration(array $fieldConfiguration): self + { + $l10nMode = $fieldConfiguration['l10n_mode'] ?? null; + if ($l10nMode === 'exclude') { + return self::Excluded; + } + if ($l10nMode === 'prefixLangTitle') { + return self::PrefixLanguageTitle; + } + return self::Translatable; + } +} diff --git a/Classes/Schema/Field/FieldTypeInterface.php b/Classes/Schema/Field/FieldTypeInterface.php new file mode 100644 index 0000000..5ee5a8a --- /dev/null +++ b/Classes/Schema/Field/FieldTypeInterface.php @@ -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\Core\Schema\Field; + +use TYPO3\CMS\Core\DataHandling\TableColumnType; + +/** + * Interface for a Schema Field. + */ +interface FieldTypeInterface +{ + public function getType(): string; + public function isType(TableColumnType ...$columnType): bool; + public function getName(): string; + public function getLabel(): string; + public function supportsAccessControl(): bool; + public function isRequired(): bool; + public function isNullable(): bool; + public function isSearchable(): bool; + public function getDisplayConditions(): array|string; + public function getDefaultValue(): mixed; + public function hasDefaultValue(): bool; + public function getTranslationBehaviour(): FieldTranslationBehaviour; + public function getConfiguration(): array; + public function getSoftReferenceKeys(): array|false; + public static function __set_state(array $state): FieldTypeInterface; +} diff --git a/Classes/Schema/Field/FileFieldType.php b/Classes/Schema/Field/FileFieldType.php new file mode 100644 index 0000000..11f6b8e --- /dev/null +++ b/Classes/Schema/Field/FileFieldType.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\RelationshipType; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This is a field to a "file" (which is very similar to "inline") but with a hard-coded + * selection to sys_file_reference. + */ +final readonly class FileFieldType extends AbstractFieldType implements RelationalFieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + private array $relations, + ) {} + + public function getType(): string + { + return 'file'; + } + + public function getAllowedFileExtensions(): array + { + return is_array($this->configuration['allowed'] ?? null) + ? $this->configuration['allowed'] + : GeneralUtility::trimExplode(',', $this->configuration['allowed'] ?? '', true); + } + + public function getDisallowedFileExtensions(): array + { + return is_array($this->configuration['disallowed'] ?? null) + ? $this->configuration['disallowed'] + : GeneralUtility::trimExplode(',', $this->configuration['disallowed'] ?? '', true); + } + + public function getRelations(): array + { + return $this->relations; + } + + public function isSearchable(): false + { + return false; + } + + public function getRelationshipType(): RelationshipType + { + return RelationshipType::fromTcaConfiguration($this->configuration); + } + + public function isNullable(): false + { + return false; + } + + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/FlexFormFieldType.php b/Classes/Schema/Field/FlexFormFieldType.php new file mode 100644 index 0000000..7b7b469 --- /dev/null +++ b/Classes/Schema/Field/FlexFormFieldType.php @@ -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\Core\Schema\Field; + +final readonly class FlexFormFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'flex'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function getDataStructure(): string + { + if (!isset($this->configuration['ds'])) { + return ''; + } + + return is_string($this->configuration['ds']) ? $this->configuration['ds'] : ''; + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/FolderFieldType.php b/Classes/Schema/Field/FolderFieldType.php new file mode 100644 index 0000000..5db7468 --- /dev/null +++ b/Classes/Schema/Field/FolderFieldType.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class FolderFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'folder'; + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/GroupFieldType.php b/Classes/Schema/Field/GroupFieldType.php new file mode 100644 index 0000000..b6b5360 --- /dev/null +++ b/Classes/Schema/Field/GroupFieldType.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\RelationshipType; + +final readonly class GroupFieldType extends AbstractFieldType implements RelationalFieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + private array $relations, + ) {} + + public function getType(): string + { + return 'group'; + } + + public function isNullable(): false + { + return false; + } + + public function getRelations(): array + { + return $this->relations; + } + + public function getRelationshipType(): RelationshipType + { + return RelationshipType::fromTcaConfiguration($this->configuration); + } + + public function isSearchable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/ImageManipulationFieldType.php b/Classes/Schema/Field/ImageManipulationFieldType.php new file mode 100644 index 0000000..72f00a7 --- /dev/null +++ b/Classes/Schema/Field/ImageManipulationFieldType.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class ImageManipulationFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'imageManipulation'; + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/InlineFieldType.php b/Classes/Schema/Field/InlineFieldType.php new file mode 100644 index 0000000..db859bf --- /dev/null +++ b/Classes/Schema/Field/InlineFieldType.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\RelationshipType; + +/** + * This is an "inline" reference field - the "parent" field to a child table / field. + */ +final readonly class InlineFieldType extends AbstractFieldType implements RelationalFieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + private array $relations + ) {} + + public function getType(): string + { + return 'inline'; + } + + public function isSearchable(): false + { + return false; + } + + public function getRelations(): array + { + return $this->relations; + } + + public function getRelationshipType(): RelationshipType + { + return RelationshipType::fromTcaConfiguration($this->configuration); + } + + public function isMovingChildrenEnabled(): bool + { + return (bool)($this->configuration['behaviour']['disableMovingChildrenWithParent'] ?? false) === false; + } + + public function isNullable(): false + { + return false; + } + + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/InputFieldType.php b/Classes/Schema/Field/InputFieldType.php new file mode 100644 index 0000000..0bff96d --- /dev/null +++ b/Classes/Schema/Field/InputFieldType.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class InputFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'input'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } +} diff --git a/Classes/Schema/Field/JsonFieldType.php b/Classes/Schema/Field/JsonFieldType.php new file mode 100644 index 0000000..55d69e0 --- /dev/null +++ b/Classes/Schema/Field/JsonFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class JsonFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'json'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/LanguageFieldType.php b/Classes/Schema/Field/LanguageFieldType.php new file mode 100644 index 0000000..25327da --- /dev/null +++ b/Classes/Schema/Field/LanguageFieldType.php @@ -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\Core\Schema\Field; + +final readonly class LanguageFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'language'; + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/LanguageTagFieldType.php b/Classes/Schema/Field/LanguageTagFieldType.php new file mode 100644 index 0000000..11bb425 --- /dev/null +++ b/Classes/Schema/Field/LanguageTagFieldType.php @@ -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\Core\Schema\Field; + +final readonly class LanguageTagFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'language_tag'; + } + + public function isSearchable(): true + { + return true; + } + + public function isNullable(): true + { + return true; + } + + public function hasDefaultValue(): true + { + return true; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/LinkFieldType.php b/Classes/Schema/Field/LinkFieldType.php new file mode 100644 index 0000000..7c01d11 --- /dev/null +++ b/Classes/Schema/Field/LinkFieldType.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class LinkFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'link'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function getAllowedLinkTypes(): array + { + return $this->configuration['allowedTypes'] ?? ['*']; + } +} diff --git a/Classes/Schema/Field/NoneFieldType.php b/Classes/Schema/Field/NoneFieldType.php new file mode 100644 index 0000000..dbc581d --- /dev/null +++ b/Classes/Schema/Field/NoneFieldType.php @@ -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\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\FieldFormat; + +final readonly class NoneFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'none'; + } + + public function isSearchable(): false + { + return false; + } + + public function getFormat(): FieldFormat + { + return FieldFormat::fromTcaConfiguration($this->configuration); + } + + public function hasDefaultValue(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/NumberFieldType.php b/Classes/Schema/Field/NumberFieldType.php new file mode 100644 index 0000000..64a4a66 --- /dev/null +++ b/Classes/Schema/Field/NumberFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class NumberFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'number'; + } + + public function isSearchable(): bool + { + return $this->getFormat() === 'integer'; + } + + public function getFormat(): string + { + return $this->configuration['format'] ?? ''; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/PassthroughFieldType.php b/Classes/Schema/Field/PassthroughFieldType.php new file mode 100644 index 0000000..542ffee --- /dev/null +++ b/Classes/Schema/Field/PassthroughFieldType.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class PassthroughFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'passthrough'; + } + + public function isSearchable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/PasswordFieldType.php b/Classes/Schema/Field/PasswordFieldType.php new file mode 100644 index 0000000..5f0d23c --- /dev/null +++ b/Classes/Schema/Field/PasswordFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class PasswordFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'password'; + } + + public function isSearchable(): false + { + return false; + } + + public function isHashed(): bool + { + return $this->configuration['hashed'] ?? true; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/RadioFieldType.php b/Classes/Schema/Field/RadioFieldType.php new file mode 100644 index 0000000..e12fe7d --- /dev/null +++ b/Classes/Schema/Field/RadioFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class RadioFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'radio'; + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/RelationalFieldTypeInterface.php b/Classes/Schema/Field/RelationalFieldTypeInterface.php new file mode 100644 index 0000000..d795d21 --- /dev/null +++ b/Classes/Schema/Field/RelationalFieldTypeInterface.php @@ -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\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\ActiveRelation; +use TYPO3\CMS\Core\Schema\RelationshipType; + +/** + * Interface for a schema field that has a relation to somewhere else. + */ +interface RelationalFieldTypeInterface +{ + /** + * @return ActiveRelation[] + */ + public function getRelations(): array; + + public function getRelationshipType(): RelationshipType; +} diff --git a/Classes/Schema/Field/SelectRelationFieldType.php b/Classes/Schema/Field/SelectRelationFieldType.php new file mode 100644 index 0000000..68f3b52 --- /dev/null +++ b/Classes/Schema/Field/SelectRelationFieldType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\RelationshipType; + +/** + * This is a select type with a relation to some other schema. + */ +final readonly class SelectRelationFieldType extends AbstractFieldType implements RelationalFieldTypeInterface +{ + public function __construct( + protected string $name, + protected array $configuration, + private array $relations, + ) {} + + public function getType(): string + { + return 'select'; + } + + public function getRelations(): array + { + return $this->relations; + } + + public function getRelationshipType(): RelationshipType + { + return RelationshipType::fromTcaConfiguration($this->configuration); + } + + public function isSearchable(): false + { + return false; + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/SlugFieldType.php b/Classes/Schema/Field/SlugFieldType.php new file mode 100644 index 0000000..cfac5c4 --- /dev/null +++ b/Classes/Schema/Field/SlugFieldType.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class SlugFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'slug'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function getGeneratorOption(string $optionName): array|string|bool|null + { + return $this->configuration['generatorOptions'][$optionName] ?? null; + } + + public function getGeneratorOptions(): array + { + return is_array($this->configuration['generatorOptions']) ? $this->configuration['generatorOptions'] : []; + } + + public function hasDefaultValue(): true + { + return true; + } + public function getDefaultValue(): string + { + return ''; + } + + public function isNullable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/StaticSelectFieldType.php b/Classes/Schema/Field/StaticSelectFieldType.php new file mode 100644 index 0000000..0b6b20c --- /dev/null +++ b/Classes/Schema/Field/StaticSelectFieldType.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +use TYPO3\CMS\Core\Schema\Struct\SelectItem; + +/** + * This is a select type without any MM or foreign table logic. + */ +final readonly class StaticSelectFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'select'; + } + + public function isSearchable(): false + { + return false; + } + + /** + * @return SelectItem[] + */ + public function getItems(): array + { + return is_array($this->configuration['items'] ?? false) ? array_map( + static fn($item): SelectItem => SelectItem::fromTcaItemArray($item), + $this->configuration['items'] + ) : []; + } + + public function isNullable(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/SystemInternalFieldType.php b/Classes/Schema/Field/SystemInternalFieldType.php new file mode 100644 index 0000000..2a28002 --- /dev/null +++ b/Classes/Schema/Field/SystemInternalFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +/** + * This is used for system-internal fields that haven't been defined in the "columns" + * but need a representation in some areas such as "label_alt". + * @internal This is an experimental implementation. + */ +final readonly class SystemInternalFieldType extends AbstractFieldType +{ + public function getType(): string + { + return ''; + } + + public function isSearchable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/TextFieldType.php b/Classes/Schema/Field/TextFieldType.php new file mode 100644 index 0000000..13334d1 --- /dev/null +++ b/Classes/Schema/Field/TextFieldType.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class TextFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'text'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function isRichText(): bool + { + return $this->configuration['enableRichtext'] ?? false; + } +} diff --git a/Classes/Schema/Field/UserFieldType.php b/Classes/Schema/Field/UserFieldType.php new file mode 100644 index 0000000..1660843 --- /dev/null +++ b/Classes/Schema/Field/UserFieldType.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class UserFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'user'; + } + + public function getRenderType(): string + { + return $this->configuration['renderType'] ?? ''; + } + + public function isSearchable(): false + { + return false; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/Field/UuidFieldType.php b/Classes/Schema/Field/UuidFieldType.php new file mode 100644 index 0000000..e4dd7a3 --- /dev/null +++ b/Classes/Schema/Field/UuidFieldType.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Field; + +final readonly class UuidFieldType extends AbstractFieldType +{ + public function getType(): string + { + return 'uuid'; + } + + public function isSearchable(): bool + { + return (bool)($this->configuration['searchable'] ?? true); + } + + public function getVersion(): int + { + return in_array($this->configuration['version'] ?? 0, [4, 6, 7], true) ? $this->configuration['version'] : 4; + } + + public function isNullable(): false + { + return false; + } + + public function getDefaultValue(): string + { + return ''; + } + + public function hasDefaultValue(): true + { + return true; + } + + public function getSoftReferenceKeys(): false + { + return false; + } +} diff --git a/Classes/Schema/FieldFormat.php b/Classes/Schema/FieldFormat.php new file mode 100644 index 0000000..a8159e8 --- /dev/null +++ b/Classes/Schema/FieldFormat.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +enum FieldFormat: string +{ + case Date = 'date'; + + case Datetime = 'datetime'; + + case Time = 'time'; + + case Timesec = 'timesec'; + + case Datetimesec = 'datetimesec'; + + case Year = 'year'; + + case Int = 'int'; + + case Float = 'float'; + + case Number = 'number'; + + case Md5 = 'md5'; + + case Filesize = 'filesize'; + + case User = 'user'; + + case Undefined = ''; + + private const FORMAT_CONFIGURATION = [ + self::Date->value => [ + 'strftime', + 'option', + 'appendAge', + ], + self::Int->value => [ + 'base', + ], + self::Float->value => [ + 'precision', + ], + self::Number->value => [ + 'option', + ], + self::Filesize->value => [ + 'appendByteSize', + ], + self::User->value => [ + 'userFunc', + ], + ]; + + public static function fromTcaConfiguration(array $configuration): self + { + if (isset($configuration['config'])) { + $configuration = $configuration['config']; + } + if (isset($configuration['format'])) { + return match ($configuration['format']) { + 'date' => self::Date, + 'datetime' => self::Datetime, + 'time' => self::Time, + 'timesec' => self::Timesec, + 'datetimesec' => self::Datetimesec, + 'year' => self::Year, + 'int' => self::Int, + 'float' => self::Float, + 'number' => self::Number, + 'md5' => self::Md5, + 'filesize' => self::Filesize, + 'user' => self::User, + default => throw new \UnexpectedValueException('Invalid format: ' . $configuration['format'], 1724744407), + }; + } + + return self::Undefined; + } + + public function getFormatConfiguration(array $configuration): array + { + if (isset($configuration['config'])) { + $configuration = $configuration['config']; + } + + if (!isset(self::FORMAT_CONFIGURATION[$this->value]) + || !is_array($configuration['format.'] ?? false) + || $configuration['format.'] === [] + ) { + return []; + } + + return array_filter($configuration['format.'], fn(string $option): bool => in_array($option, self::FORMAT_CONFIGURATION[$this->value], true), ARRAY_FILTER_USE_KEY); + } +} diff --git a/Classes/Schema/FieldTypeFactory.php b/Classes/Schema/FieldTypeFactory.php new file mode 100644 index 0000000..7f8e8ea --- /dev/null +++ b/Classes/Schema/FieldTypeFactory.php @@ -0,0 +1,174 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Schema\Exception\FieldTypeNotAvailableException; +use TYPO3\CMS\Core\Schema\Field\CategoryFieldType; +use TYPO3\CMS\Core\Schema\Field\CheckboxFieldType; +use TYPO3\CMS\Core\Schema\Field\ColorFieldType; +use TYPO3\CMS\Core\Schema\Field\CountryFieldType; +use TYPO3\CMS\Core\Schema\Field\DateTimeFieldType; +use TYPO3\CMS\Core\Schema\Field\EmailFieldType; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; +use TYPO3\CMS\Core\Schema\Field\FileFieldType; +use TYPO3\CMS\Core\Schema\Field\FlexFormFieldType; +use TYPO3\CMS\Core\Schema\Field\FolderFieldType; +use TYPO3\CMS\Core\Schema\Field\GroupFieldType; +use TYPO3\CMS\Core\Schema\Field\ImageManipulationFieldType; +use TYPO3\CMS\Core\Schema\Field\InlineFieldType; +use TYPO3\CMS\Core\Schema\Field\InputFieldType; +use TYPO3\CMS\Core\Schema\Field\JsonFieldType; +use TYPO3\CMS\Core\Schema\Field\LanguageFieldType; +use TYPO3\CMS\Core\Schema\Field\LinkFieldType; +use TYPO3\CMS\Core\Schema\Field\NoneFieldType; +use TYPO3\CMS\Core\Schema\Field\NumberFieldType; +use TYPO3\CMS\Core\Schema\Field\PassthroughFieldType; +use TYPO3\CMS\Core\Schema\Field\PasswordFieldType; +use TYPO3\CMS\Core\Schema\Field\RadioFieldType; +use TYPO3\CMS\Core\Schema\Field\RelationalFieldTypeInterface; +use TYPO3\CMS\Core\Schema\Field\SelectRelationFieldType; +use TYPO3\CMS\Core\Schema\Field\SlugFieldType; +use TYPO3\CMS\Core\Schema\Field\StaticSelectFieldType; +use TYPO3\CMS\Core\Schema\Field\TextFieldType; +use TYPO3\CMS\Core\Schema\Field\UserFieldType; +use TYPO3\CMS\Core\Schema\Field\UuidFieldType; + +/** + * Create field objects based on the TCA of the "columns" area. + * + * A field type is a class that represents a field in a schema. + * + * Currently, the FieldTypes are hard-coded in this class, but in the future, this might be moved + * into a more flexible registry. + * + * Also, the class currently encapsulates the building of the FlexFormSchema (which in turn also has + * fields), however, since this has some tight coupling this resides here for the time being, + * but should be extracted later-on. + * + * Some interesting points: + * - the special type "select" is separated into two different classes - one with relations, and one without. + */ +class FieldTypeFactory +{ + /** + * @var array<string, class-string<FieldTypeInterface>> + */ + protected array $availableFieldTypes = [ + 'category' => CategoryFieldType::class, + 'check' => CheckboxFieldType::class, + 'color' => ColorFieldType::class, + 'country' => CountryFieldType::class, + 'datetime' => DateTimeFieldType::class, + 'email' => EmailFieldType::class, + 'file' => FileFieldType::class, + 'flex' => FlexFormFieldType::class, + 'folder' => FolderFieldType::class, + 'group' => GroupFieldType::class, + 'imageManipulation' => ImageManipulationFieldType::class, + 'inline' => InlineFieldType::class, + 'input' => InputFieldType::class, + 'json' => JsonFieldType::class, + 'language' => LanguageFieldType::class, + 'link' => LinkFieldType::class, + 'none' => NoneFieldType::class, + 'number' => NumberFieldType::class, + 'passthrough' => PassthroughFieldType::class, + 'password' => PasswordFieldType::class, + 'radio' => RadioFieldType::class, + 'slug' => SlugFieldType::class, + 'text' => TextFieldType::class, + 'user' => UserFieldType::class, + 'uuid' => UuidFieldType::class, + ]; + + public function createFieldType(string $fieldName, array $configuration, string $schemaName, RelationMap $relationMap, ?string $parentSchemaName = null, ?string $parentFieldName = null): FieldTypeInterface + { + $fieldType = $configuration['config']['type'] ?? ''; + switch ($fieldType) { + case 'flex': + // Build all schemata first + return $this->createFlexFormField($parentSchemaName ?? $schemaName, $fieldName, $configuration, $relationMap, $parentSchemaName ? $schemaName : null); + case 'select': + // In case type "select" is used without any relationship information, it's a static list + if (RelationshipType::fromTcaConfiguration($configuration) === RelationshipType::Undefined) { + return $this->createFromTca(StaticSelectFieldType::class, $fieldName, $configuration); + } + return $this->createFromTca(SelectRelationFieldType::class, $fieldName, $configuration, $relationMap->getActiveRelations($parentSchemaName ?? $schemaName, $parentFieldName ?? $fieldName)); + default: + if ($this->hasFieldType($fieldType)) { + $fieldTypeClass = $this->availableFieldTypes[$fieldType]; + if (is_a($fieldTypeClass, RelationalFieldTypeInterface::class, true)) { + return $this->createFromTca($fieldTypeClass, $fieldName, $configuration, $relationMap->getActiveRelations($parentSchemaName ?? $schemaName, $parentFieldName ?? $fieldName)); + } + return $this->createFromTca($fieldTypeClass, $fieldName, $configuration); + + } + throw new FieldTypeNotAvailableException('Field type "' . $fieldType . '" for field "' . $fieldName . '" not found for schema "' . $schemaName . '".', 1661532580); + } + } + + protected function hasFieldType(string $fieldType): bool + { + return array_key_exists($fieldType, $this->availableFieldTypes); + } + + /** + * Basic factory to create the field type from the TCA configuration via new(). + */ + protected function createFromTca(string $targetClass, string $fieldName, array $fieldConfiguration, ?array $relations = null): FieldTypeInterface + { + // We deliberately reduce the "config" subarray to make life easier in the future + $fieldConfiguration = $this->streamlineFieldConfiguration($fieldConfiguration); + $arguments = [ + $fieldName, + $fieldConfiguration, + ]; + if ($relations !== null) { + $arguments[] = $relations; + } + + return new $targetClass(...$arguments); + } + + /** + * First, parse the data structures (and if we only have a subschema, we use that one, ofc) + */ + protected function createFlexFormField(string $mainSchemaName, string $fieldName, array $tcaConfig, RelationMap $relationMap, ?string $subSchemaName = null): FlexFormFieldType + { + $tcaConfig = $this->streamlineFieldConfiguration($tcaConfig); + // This is the place to get all schema / data structures but should be called somewhere else, probably + // in user-land code + // @todo: this should go away, or FlexFormSchemaFactory should be removed altogether + // $flexSchemas = GeneralUtility::makeInstance(FlexFormSchemaFactory::class)->createSchemataForFlexField($tcaConfig, $mainSchemaName, $fieldName, $relationMap); + return new FlexFormFieldType( + $fieldName, + $tcaConfig, + ); + } + + /** + * Removes the "config" subkey from TCA, to make it easier to work with the configuration array, + * also makes caching smaller. + */ + protected function streamlineFieldConfiguration(array $fieldConfiguration): array + { + $configSubArrayInfo = $fieldConfiguration['config'] ?? null; + unset($fieldConfiguration['config']); + return array_replace_recursive($configSubArrayInfo ?? [], $fieldConfiguration); + } +} diff --git a/Classes/Schema/FlexFormSchema.php b/Classes/Schema/FlexFormSchema.php new file mode 100644 index 0000000..b81b6a0 --- /dev/null +++ b/Classes/Schema/FlexFormSchema.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; +use TYPO3\CMS\Core\Schema\Struct\FlexSheet; + +final readonly class FlexFormSchema implements SchemaInterface +{ + public function __construct( + private string $structIdentifier, + /** @var FlexSheet[] */ + private array $sheets + ) {} + + public function getSheets(): array + { + return $this->sheets; + } + + public function getFields(?callable $filterFunction = null): FieldCollection + { + $allFields = []; + foreach ($this->sheets as $sheet) { + $allFields = array_merge($allFields, iterator_to_array($sheet->getFields())); + } + if ($filterFunction === null) { + return new FieldCollection($allFields); + } + + return new FieldCollection(array_filter(iterator_to_array($allFields), $filterFunction)); + } + + public function getName(): string + { + return $this->structIdentifier; + } + + public function getField(string $fieldName, ?string $sheetName = null): ?FieldTypeInterface + { + if ($sheetName !== null) { + return $this->getFieldFromSheet($sheetName, $fieldName); + } + + foreach ($this->sheets as $name => $sheet) { + if ($field = $this->getFieldFromSheet($name, $fieldName)) { + return $field; + } + } + + return null; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } + + /** + * This method attempts to find a field within a given sheet. + * + * If the field is not set directly on the sheet, each section + * of the sheet will be checked for a matching field. + */ + private function getFieldFromSheet(string $sheetName, string $fieldName): ?FieldTypeInterface + { + if (!isset($this->sheets[$sheetName])) { + return null; + } + + $sheet = $this->sheets[$sheetName]; + + if ($sheet->hasField($sheetName . '/' . $fieldName)) { + return $sheet->getField($sheetName . '/' . $fieldName); + } + + return $this->getFieldFromSections($sheetName, $fieldName); + } + + /** + * This method searches for a field name within all sections of a sheet. + * + * Any slashes in the field name, section name, or container name + * are replaced with dots to support field names such as: + * - settings.mysettings.67fb88e136a4a575936... + * - my_settings.67fb88e136a4a575936... + */ + private function getFieldFromSections(string $sheetName, string $fieldName): ?FieldTypeInterface + { + $sheet = $this->sheets[$sheetName]; + $fieldPath = $sheetName . '.' . $fieldName; + + foreach ($sheet->getSections() as $sectionName => $section) { + $sectionPath = str_replace('/', '.', $sectionName); + + // If the field is not inside the current section, continue to the next + if (!str_starts_with($fieldPath, $sectionPath)) { + continue; + } + + // Remove the section path from the field name + $relativeField = substr($fieldPath, strlen($sectionPath) + 1); + + if (($pos = strpos($relativeField, '.')) !== false) { + // Get the container name from the field + $containerField = substr($relativeField, $pos + 1); + + foreach ($section as $containerName => $container) { + // If the field is not inside the current container, continue to the next + if (!str_starts_with($sectionName . '/' . $containerField, $containerName)) { + continue; + } + + // Get the field name + $finalFieldName = substr($sectionName . '/' . $containerField, strlen($containerName) + 1); + + /** @var \TYPO3\CMS\Core\Schema\Struct\FlexSectionContainer $container */ + if ($container->hasField($finalFieldName)) { + return $container->getField($finalFieldName); + } + } + } + } + + return null; + } +} diff --git a/Classes/Schema/FlexFormSchemaFactory.php b/Classes/Schema/FlexFormSchemaFactory.php new file mode 100644 index 0000000..5129a40 --- /dev/null +++ b/Classes/Schema/FlexFormSchemaFactory.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException; +use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaException; +use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools; +use TYPO3\CMS\Core\Domain\RawRecord; +use TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException; +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FlexFormFieldType; +use TYPO3\CMS\Core\Schema\Struct\FlexSectionContainer; +use TYPO3\CMS\Core\Schema\Struct\FlexSheet; + +/** + * Parses all possibles schemas of all sheets of a field. + */ +#[Autoconfigure(public: true, shared: true)] +final readonly class FlexFormSchemaFactory +{ + public function __construct( + private FlexFormTools $flexFormTools, + private FieldTypeFactory $fieldTypeFactory, + private TcaSchemaFactory $tcaSchemaFactory, + ) {} + + /** + * Currently this mixes Schema and Record Information, and could be handled in a cleaner way. + * This method signature will most likely change. + */ + public function getSchemaForRecord(RawRecord $record, FlexFormFieldType $field, RelationMap $relationMap): ?FlexFormSchema + { + try { + $schema = $this->tcaSchemaFactory->get($record->getMainType()); + $dataStructureIdentifier = $this->flexFormTools->getDataStructureIdentifier( + ['config' => $field->getConfiguration()], + $record->getMainType(), + $field->getName(), + $record->toArray(), + $schema + ); + $resolvedDataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema); + } catch (InvalidTcaException|InvalidIdentifierException|UndefinedSchemaException) { + return null; + } + + $sheets = []; + foreach ($resolvedDataStructure['sheets'] ?? [] as $sheetIdentifier => $sheetData) { + $fields = []; + $sections = []; + foreach ($sheetData['ROOT']['el'] ?? [] as $flexFieldName => $flexFieldConfig) { + $fieldIdentifier = $sheetIdentifier . '/' . $flexFieldName; + if (($flexFieldConfig['type'] ?? '') === 'array' && ($flexFieldConfig['section'] ?? false)) { + // We are inside a section, now loop over the section containers + $sectionContainers = []; + foreach ($flexFieldConfig['el'] ?? [] as $sectionContainerIdentifier => $sectionContainerDetails) { + // Sections can only have section containers + if (($sectionContainerDetails['type'] ?? '') !== 'array') { + continue; + } + $sectionFieldIdentifier = $fieldIdentifier . '/' . $sectionContainerIdentifier; + $fieldsInSectionContainer = []; + $sectionContainerTitle = $sectionContainerDetails['title'] ?? ''; + // Collect all elements within this section container + foreach ($sectionContainerDetails['el'] ?? [] as $fieldNameInSectionContainer => $sectionContainerConfig) { + $fieldsInSectionContainer[$fieldNameInSectionContainer] = $this->fieldTypeFactory->createFieldType( + $fieldNameInSectionContainer, + $sectionContainerConfig ?? [], + $record->getMainType(), + $relationMap, + null, + $field->getName() + ); + } + $sectionContainers[$sectionFieldIdentifier] = new FlexSectionContainer( + $sectionFieldIdentifier, + $sectionContainerTitle, + '', + new FieldCollection($fieldsInSectionContainer) + ); + } + $sections[$fieldIdentifier] = $sectionContainers; + } else { + $fields[$fieldIdentifier] = $this->fieldTypeFactory->createFieldType( + $fieldIdentifier, + $flexFieldConfig ?? [], + $record->getMainType(), + $relationMap, + null, + $field->getName() + ); + } + } + $fields = new FieldCollection($fields); + $sheets[$sheetIdentifier] = new FlexSheet( + $sheetIdentifier, + $sheetData['ROOT']['sheetTitle'] ?? '', + $sheetData['ROOT']['sheetDescription'] ?? '', + $fields, + $sections + ); + } + return new FlexFormSchema($dataStructureIdentifier, $sheets); + } +} diff --git a/Classes/Schema/PassiveRelation.php b/Classes/Schema/PassiveRelation.php new file mode 100644 index 0000000..6d590c7 --- /dev/null +++ b/Classes/Schema/PassiveRelation.php @@ -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\Core\Schema; + +/** + * A relation from another field / schema. + * + * Examples: + * - A table "tx_myextension_author" has a passive relation FROM the table "tx_books" and its field "authors". + * - A TCA table of type inline has a passthrough field in the child table, and that's a PASSIVE relation FROM the + * parent table. + */ +final readonly class PassiveRelation +{ + public function __construct( + private string $fromTable, + private ?string $fromField, + private ?string $flexPointer, + ) {} + + public function fromTable(): string + { + return $this->fromTable; + } + + public function fromField(): ?string + { + return $this->fromField; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/RelationMap.php b/Classes/Schema/RelationMap.php new file mode 100644 index 0000000..ab43d4d --- /dev/null +++ b/Classes/Schema/RelationMap.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A class to collect actual relations. Contains all information... + * -> what is the target of a relation of a field + * -> what is pointing to a specific schema + * + * @internal not part of TYPO3 API as it should not be exposed, although this is really cool and powerful. + */ +final class RelationMap +{ + public function __construct( + private array $relations = [] + ) {} + + public function add(string $fromTable, string $fromFieldName, array $fieldConfig, ?string $flexPointer = null): void + { + $fieldType = $fieldConfig['type'] ?? null; + if ($fieldType === 'group') { + $toTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed'] ?? $fieldConfig['foreign_table'] ?? ''); + foreach ($toTables as $toTable) { + if (isset($fieldConfig['MM'])) { + $this->addMMRelation( + $fromTable, + $fromFieldName, + $toTable, + $fieldConfig['MM'], + $fieldConfig['MM_opposite_field'] ?? null, + $flexPointer + ); + } else { + $this->addActiveRelationToTable($fromTable, $fromFieldName, $toTable, $flexPointer); + } + } + } elseif (in_array($fieldType, ['select', 'inline', 'file', 'category'], true)) { + if (isset($fieldConfig['MM'])) { + $this->addMMRelation( + $fromTable, + $fromFieldName, + $fieldConfig['foreign_table'], + $fieldConfig['MM'], + $fieldConfig['MM_opposite_field'] ?? null, + $flexPointer + ); + } elseif (isset($fieldConfig['foreign_table'], $fieldConfig['foreign_field'])) { + $this->addActiveRelationWithField( + $fromTable, + $fromFieldName, + $fieldConfig['foreign_table'], + $fieldConfig['foreign_field'], + $flexPointer + ); + } elseif (isset($fieldConfig['foreign_table'])) { + $this->addActiveRelationToTable( + $fromTable, + $fromFieldName, + $fieldConfig['foreign_table'], + $flexPointer + ); + } + // @todo: I guess we also need to do the foreign_table_field option + } + } + + private function addMMRelation(string $fromTable, string $fromField, string $toTable, string $mm, ?string $mmOppositeField = null, ?string $flexPointer = null): void + { + $this->relations[$fromTable][$fromField][] = [ + 'target' => $toTable, + 'mm' => $mm, + 'mmOppositeField' => $mmOppositeField, + 'flexPointer' => $flexPointer, + ]; + } + + private function addActiveRelationWithField(string $fromTable, string $fromField, string $toTable, string $toField, ?string $flexPointer = null): void + { + $this->relations[$fromTable][$fromField][] = [ + 'target' => $toTable, + 'targetField' => $toField, + 'flexPointer' => $flexPointer, + ]; + } + + private function addActiveRelationToTable(string $fromTable, string $fromField, string $toTable, ?string $flexPointer = null): void + { + $this->relations[$fromTable][$fromField][] = [ + 'target' => $toTable, + 'flexPointer' => $flexPointer, + ]; + } + + /** + * @return ActiveRelation[] + */ + public function getActiveRelations(string $tableName, string $fieldName): array + { + return array_map([$this, 'makeActiveRelation'], $this->relations[$tableName][$fieldName] ?? []); + } + + private function makeActiveRelation(array $relation): ActiveRelation + { + return new ActiveRelation($relation['mm'] ?? $relation['target'], $relation['mmOppositeField'] ?? $relation['targetField'] ?? null); + } + + /** + * Passive relations can never be pointed to a field within a FlexSchema + */ + public function getPassiveRelations(string $tableName, ?string $fieldName = null): array + { + $relations = []; + foreach ($this->relations as $fromTable => $fields) { + foreach ($fields as $fromField => $relation) { + foreach ($relation as $rel) { + // target table does not match + if (!in_array($rel['target'], [$tableName, '*'], true)) { + continue; + } + // restriction to field is set, if this is set, this must match the targetField + // otherwise we include all relations to the target table (regardless if it is attached to a field or not) + // because we want to get the passive relations for the table. + if ($fieldName !== null) { + if (!isset($rel['targetField']) || $rel['targetField'] !== $fieldName) { + continue; + } + } + $relations[] = new PassiveRelation($fromTable, $fromField, $rel['flexPointer'] ?? null); + } + } + } + return $relations; + } +} diff --git a/Classes/Schema/RelationMapBuilder.php b/Classes/Schema/RelationMapBuilder.php new file mode 100644 index 0000000..ba2ea53 --- /dev/null +++ b/Classes/Schema/RelationMapBuilder.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidDataStructureException; +use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException; +use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaSchemaException; +use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools; + +/** + * Low-level API to parse TCA to find field types which should be processed, as they contain + * a relation. + * + * This also parses ALL flexforms available, that's why it juggles through all FlexForm + * fields and parses the FlexForms as well. + * + * Everything is stored in a simple "RelationMap" object with an internal array structure. + * + * @internal not part of TYPO3 API as it should not be exposed, although this is really cool and powerful. + */ +final readonly class RelationMapBuilder +{ + public function __construct( + private FlexFormTools $flexFormTools + ) {} + + public function buildFromStructure(array $tca): RelationMap + { + $relationMap = new RelationMap(); + foreach ($tca as $table => $tableConfig) { + // What fields can have a relational connection to other tables? + foreach ($tableConfig['columns'] ?? [] as $fieldName => $fieldConfig) { + $fieldConfig = $fieldConfig['config'] ?? null; + if (!in_array($fieldConfig['type'] ?? '', ['select', 'group', 'inline', 'file', 'category', 'flex'], true)) { + continue; + } + + if ($fieldConfig['type'] === 'flex') { + $this->addRelationsForFlexFieldToRelationMap($table, $tableConfig, $fieldName, $relationMap); + } else { + $relationMap->add($table, $fieldName, $fieldConfig); + } + } + } + return $relationMap; + } + + /** + * Adds relations for a flex field to the relation map. + * Note: Inside a section, it is not possible to add a field with a relation (type 'inline', 'file', 'folder', 'group', 'category'). + * See TcaFlexProcess class for details. + */ + private function addRelationsForFlexFieldToRelationMap(string $tableName, array $tableConfig, string $fieldName, RelationMap $relationMap): void + { + foreach (array_merge(['default'], array_keys($tableConfig['types'] ?? [])) as $recordType) { + try { + $dataStructure = $this->flexFormTools->parseDataStructureByIdentifier(json_encode([ + 'type' => 'tca', + 'tableName' => $tableName, + 'fieldName' => $fieldName, + 'dataStructureKey' => $recordType, + ]), $tableConfig); + } catch (InvalidTcaSchemaException|InvalidIdentifierException|InvalidDataStructureException) { + // Skip default on error + continue; + } + + if (!is_array($dataStructure['sheets'] ?? null)) { + continue; + } + foreach ($dataStructure['sheets'] as $sheetIdentifier => $sheet) { + foreach ($sheet['ROOT']['el'] as $flexFieldName => $flexFieldConfig) { + $fieldIdentifier = $sheetIdentifier . '/' . $flexFieldName; + $relationMap->add($tableName, $fieldName, $flexFieldConfig['config'] ?? [], $fieldIdentifier); + } + } + } + } +} diff --git a/Classes/Schema/RelationshipType.php b/Classes/Schema/RelationshipType.php new file mode 100644 index 0000000..485afae --- /dev/null +++ b/Classes/Schema/RelationshipType.php @@ -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\Core\Schema; + +enum RelationshipType: string +{ + // A direct relation, e.g. sys_file.metadata => sys_file_metadata + case OneToOne = '1:1'; + + // A record with active relations, e.g. inline elements blog_article.comments => comment. The reference + // to the left side is stored in a pointer field in the right side. Typically used when 'foreign_field' is set. + case OneToMany = '1:n'; + + // One item is selected on the active site, e.g. be_users.avatar => file, while file can be selected by any user + case ManyToOne = 'n:1'; + + // Regular MM intermediate table is used to store data + case ManyToMany = 'mm'; + + // An item list (separated by comma) is stored (like select type is doing) + case List = 'list'; + + // Type can not be defined + case Undefined = ''; + + public static function fromTcaConfiguration(array $configuration): self + { + if (isset($configuration['config'])) { + $configuration = $configuration['config']; + } + if (isset($configuration['MM'])) { + return self::ManyToMany; + } + if (isset($configuration['relationship'])) { + return match ($configuration['relationship']) { + 'oneToOne' => self::OneToOne, + 'oneToMany' => self::OneToMany, + 'manyToOne' => self::ManyToOne, + default => throw new \UnexpectedValueException('Invalid relationship type: ' . $configuration['relationship'], 1724661829), + }; + } + if (isset($configuration['foreign_field'])) { + return self::OneToMany; + } + if (isset($configuration['foreign_table'])) { + // ManyToOne (as with `renderType=selectSingle`) is + // handled by `relationship` configuration above. + // See `TcaPreparation::configureSelectSingle()`. + return self::List; + } + if (($configuration['type'] ?? '') === 'group') { + return self::List; + } + return self::Undefined; + } + + public function hasOne(): bool + { + return in_array($this, [self::OneToOne, self::ManyToOne], true); + } + + public function hasMany(): bool + { + return in_array($this, [self::ManyToMany, self::OneToMany, self::List], true); + } + + public function isSingularRelationship(): bool + { + return in_array($this, [self::OneToOne, self::ManyToOne, self::OneToMany, self::List], true); + } +} diff --git a/Classes/Schema/SchemaCollection.php b/Classes/Schema/SchemaCollection.php new file mode 100644 index 0000000..20f5393 --- /dev/null +++ b/Classes/Schema/SchemaCollection.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException; + +final readonly class SchemaCollection implements \ArrayAccess, \IteratorAggregate, \Countable +{ + public function __construct( + /** + * @var array<string, SchemaInterface> + */ + private array $items + ) {} + + public function offsetExists(mixed $offset): bool + { + return isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->items[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \InvalidArgumentException('A schema cannot be set.', 1712539286); + } + + public function offsetUnset(mixed $offset): void + { + throw new \InvalidArgumentException('A schema cannot be unset.', 1712539285); + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return string[] + */ + public function getNames(): array + { + return array_values(array_map(fn($item): string => $item->getName(), $this->items)); + } + + /** + * Get a schema from the loaded TCA. Ensure to check for a schema with ->has() before + * calling ->get(). + */ + public function get(string $schemaName): TcaSchema + { + if (!$this->has($schemaName)) { + throw new UndefinedSchemaException('No TCA schema exists for the name "' . $schemaName . '".', 1661540376); + } + if (str_contains($schemaName, '.')) { + [$mainSchema, $subSchema] = explode('.', $schemaName, 2); + return $this->get($mainSchema)->getSubSchema($subSchema); + } + if (!$this->items[$schemaName] instanceof TcaSchema) { + throw new \RuntimeException('The schema "' . $schemaName . '" is not of type TcaSchema.', 1773758542); + } + return $this->items[$schemaName]; + } + + /** + * Checks if a schema exists, does not build the schema if not needed, thus it's very slim + * and only creates a schema if a sub-schema is requested. + */ + public function has(string $schemaName): bool + { + if (str_contains($schemaName, '.')) { + [$mainSchema, $subSchema] = explode('.', $schemaName, 2); + if (!$this->has($mainSchema)) { + return false; + } + return $this->get($mainSchema)->hasSubSchema($subSchema); + } + return isset($this->items[$schemaName]); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/SchemaInterface.php b/Classes/Schema/SchemaInterface.php new file mode 100644 index 0000000..bbbc6a2 --- /dev/null +++ b/Classes/Schema/SchemaInterface.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Schema\Field\FieldCollection; + +/** + * A generic interface for any kind of schema + * @internal this will be made public once FormEngine is using Schema API. + */ +interface SchemaInterface +{ + public function getName(): string; + //public function getFields(?callable $filterFunction = null): FieldCollection; + //public function hasField(string $fieldName): bool; + public static function __set_state(array $state): SchemaInterface; + +} diff --git a/Classes/Schema/SchemaLabelResolver.php b/Classes/Schema/SchemaLabelResolver.php new file mode 100644 index 0000000..68f845c --- /dev/null +++ b/Classes/Schema/SchemaLabelResolver.php @@ -0,0 +1,188 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\DataHandling\ItemProcessingService; +use TYPO3\CMS\Core\DataHandling\ItemsProcessorContext; +use TYPO3\CMS\Core\Schema\Struct\SelectItemCollection; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Resolves labels for TCA field items based on schema configuration + * and optional Page TSconfig overrides. + * + * Returns raw (untranslated) labels — callers are responsible for + * running language translation (e.g. sL()) when needed. + */ +#[Autoconfigure(public: true)] +readonly class SchemaLabelResolver +{ + public function __construct( + private TcaSchemaFactory $tcaSchemaFactory, + private ItemProcessingService $itemProcessingService, + ) {} + + /** + * Resolve the label for a single field item value. + * + * @param string $table Table name + * @param string $field Field name + * @param string $value The item value to look up + * @param array $row Record row, needed for itemsProcFunc/itemsProcessors context + * @param array $columnTsConfig Optional TCEFORM.<table>.<field> TSconfig array for addItems/altLabels overrides + * @param array $fieldConfiguration Optional explicit field configuration (used for volatile configs like FlexForms) + * @return string The raw (untranslated) label, or empty string if not found + */ + public function getLabelForFieldValue( + string $table, + string $field, + string $value, + array $row = [], + array $columnTsConfig = [], + array $fieldConfiguration = [], + ): string { + if ($columnTsConfig !== []) { + $tsConfigLabel = $this->resolveFromTsConfig($value, $columnTsConfig); + if ($tsConfigLabel !== null) { + return $tsConfigLabel; + } + } + + $fieldConfiguration = $this->resolveFieldConfiguration($table, $field, $fieldConfiguration); + if ($fieldConfiguration === []) { + return ''; + } + + $items = $this->resolveItems($table, $field, $row, $fieldConfiguration); + + foreach ($items as $itemConfiguration) { + if ((string)$itemConfiguration['value'] === $value) { + return $itemConfiguration['label']; + } + } + + return ''; + } + + /** + * Resolve labels for a comma-separated list of field item values. + * + * @param string $table Table name + * @param string $field Field name + * @param string $valueList Comma-separated list of item values + * @param array $row Record row, needed for itemsProcFunc/itemsProcessors context + * @param array $columnTsConfig Optional TCEFORM.<table>.<field> TSconfig array for addItems/altLabels overrides + * @param array $fieldConfiguration Optional explicit field configuration (used for volatile configs like FlexForms) + * @return array<string> Array of raw (untranslated) labels for each matched value + */ + public function getLabelsForFieldValues( + string $table, + string $field, + string $valueList, + array $row = [], + array $columnTsConfig = [], + array $fieldConfiguration = [], + ): array { + $fieldConfiguration = $this->resolveFieldConfiguration($table, $field, $fieldConfiguration); + if ($valueList === '' || $fieldConfiguration === []) { + return []; + } + + $items = $this->resolveItems($table, $field, $row, $fieldConfiguration); + $keys = GeneralUtility::trimExplode(',', $valueList); + $labels = []; + + foreach ($keys as $key) { + $label = null; + if ($columnTsConfig !== []) { + $label = $this->resolveFromTsConfig($key, $columnTsConfig); + } + if ($label === null) { + foreach ($items as $itemConfiguration) { + if ($key === (string)$itemConfiguration['value']) { + $label = $itemConfiguration['label']; + break; + } + } + } + if ($label !== null) { + $labels[] = $label; + } + } + + return $labels; + } + + private function resolveFromTsConfig(string $value, array $columnTsConfig): ?string + { + if ($value === '' && isset($columnTsConfig['altLabels'])) { + return $columnTsConfig['altLabels']; + } + if (isset($columnTsConfig['addItems.'][$value])) { + return $columnTsConfig['addItems.'][$value]; + } + if (isset($columnTsConfig['altLabels.'][$value])) { + return $columnTsConfig['altLabels.'][$value]; + } + return null; + } + + private function resolveFieldConfiguration(string $table, string $field, array $fieldConfiguration): array + { + if ($fieldConfiguration !== []) { + return $fieldConfiguration; + } + if (!$this->tcaSchemaFactory->has($table)) { + return []; + } + $schema = $this->tcaSchemaFactory->get($table); + if (!$schema->hasField($field)) { + return []; + } + return $schema->getField($field)->getConfiguration(); + } + + private function resolveItems(string $table, string $field, array $row, array $fieldConfiguration): array + { + if (isset($fieldConfiguration['items']) && !is_array($fieldConfiguration['items'])) { + return []; + } + + $items = $fieldConfiguration['items'] ?? []; + + if ( + ($fieldConfiguration['itemsProcFunc'] ?? '') !== '' + || ($fieldConfiguration['itemsProcessors'] ?? []) !== [] + ) { + $itemsCollection = SelectItemCollection::createFromArray($items, $fieldConfiguration['type']); + $context = new ItemsProcessorContext( + table: $table, + field: $field, + row: $row, + fieldConfiguration: $fieldConfiguration, + processorParameters: [], + realPid: (int)($row['pid'] ?? 0), + site: $this->itemProcessingService->resolveSite((int)($row['pid'] ?? 0)) + ); + $items = $this->itemProcessingService->processItems($itemsCollection, $context)->toArray(); + } + + return $items; + } +} diff --git a/Classes/Schema/SchemaTypeInformation.php b/Classes/Schema/SchemaTypeInformation.php new file mode 100644 index 0000000..05de115 --- /dev/null +++ b/Classes/Schema/SchemaTypeInformation.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +/** + * It is possible to use a DB field in TCA for referencing the actual type of record, by dividing the schema + * in subschema by a type. For example, "pages" has a "type" field which references the "doktype" field + * of the "pages" table. This is defined in TCA[ctrl][type] property. + * + * However, it is also possible to use a field of a foreign table to define the type of record - + * for example - the "sys_file_reference" table has type "uid_foreign:title". The uid_foreign DB field + * of "sys_file_reference" references the "uid" field of the "sys_file" table (as defined in the "uid_foreign" field + * of "sys_file_reference", and the "title" field is then pointing to the related references' schema + */ +final readonly class SchemaTypeInformation +{ + public function __construct( + private string $schemaName, + private string $fieldName, + private ?string $foreignFieldName = null, + private ?string $foreignSchemaName = null + ) {} + + public function isPointerToForeignFieldInForeignSchema(): bool + { + return $this->foreignFieldName !== null && $this->foreignSchemaName !== null; + } + + public function getSchemaName(): string + { + return $this->schemaName; + } + + public function getFieldName(): string + { + return $this->fieldName; + } + + public function getForeignSchemaName(): ?string + { + return $this->foreignSchemaName; + } + + public function getForeignFieldName(): ?string + { + return $this->foreignFieldName; + } +} diff --git a/Classes/Schema/SearchableSchemaFieldsCollector.php b/Classes/Schema/SearchableSchemaFieldsCollector.php new file mode 100644 index 0000000..5b8cd5b --- /dev/null +++ b/Classes/Schema/SearchableSchemaFieldsCollector.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; + +/** + * Class that accesses the TCA[table][searchFields] via TcaSchema factory + */ +#[Autoconfigure(public: true)] +readonly class SearchableSchemaFieldsCollector +{ + public function __construct(private TcaSchemaFactory $schemaFactory) {} + + public function getFields(string $schemaName, array $searchFields = []): FieldCollection + { + if (!$this->schemaFactory->has($schemaName)) { + return new FieldCollection(); + } + $schema = $this->schemaFactory->get($schemaName); + return $searchFields === [] + // No searchFields defined, return all searchable fields + ? $schema->getFields(static fn(FieldTypeInterface $field): bool => $field->isSearchable()) + // Return given searchFields by filtering whether they are actually searchable + : $schema->getFields(static fn(FieldTypeInterface $field): bool => in_array($field->getName(), $searchFields, true) && $field->isSearchable()); + } + + /** + * @return string[] + */ + public function getFieldNames(string $schemaName, array $searchFields = []): array + { + return array_map(static fn(FieldTypeInterface $field) => $field->getName(), iterator_to_array($this->getFields($schemaName, $searchFields))); + } + + /** + * @return string[] + */ + public function getUniqueFieldList(string $schemaName, array $existingFieldList, bool $includeSpecialFields): array + { + // Add special fields + if ($includeSpecialFields) { + $existingFieldList[] = 'uid'; + $existingFieldList[] = 'pid'; + } + // @todo should existing fields also be validated? + return array_unique(array_merge($existingFieldList, $this->getFieldNames($schemaName))); + } + + /** + * Returns table subschema divisor field name and a list of fields not included in all subSchemas along with + * the list of subSchemas they are included. + * + * @param string $tableName + * @return array{0: string, 1: array<string, list<string>>} + * @internal only to be used in TYPO3 Core + */ + public function getSchemaFieldSubSchemaTypes(string $tableName): array + { + $result = [ + 0 => '', + 1 => [], + ]; + if (!$this->schemaFactory->has($tableName)) { + return $result; + } + $schema = $this->schemaFactory->get($tableName); + if (!$schema->supportsSubSchema() || $schema->getSubSchemaTypeInformation()->isPointerToForeignFieldInForeignSchema()) { + // In case sub schema is a foreign table type, we have to return here since calling code + // might not do any joins and therefore cannot resolve the foreign table field properly. + return $result; + } + $result[0] = $schema->getSubSchemaTypeInformation()->getFieldName(); + foreach ($schema->getSubSchemata() as $recordType => $subSchemata) { + foreach ($subSchemata->getFields() as $fieldInSubschema => $fieldConfig) { + $result[1][$fieldInSubschema] ??= []; + $result[1][$fieldInSubschema][] = $recordType; + } + } + // Remove all fields which are contained in all sub-schemas, determined by + // comparing each field types count with table types count. + $subSchemaCount = count($schema->getSubSchemata()); + $result[1] = array_filter($result[1], static fn($value) => count($value) < $subSchemaCount); + return $result; + } +} diff --git a/Classes/Schema/Struct/FlexSectionContainer.php b/Classes/Schema/Struct/FlexSectionContainer.php new file mode 100644 index 0000000..4798b47 --- /dev/null +++ b/Classes/Schema/Struct/FlexSectionContainer.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Struct; + +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; + +/** + * FlexForms Sheets can contain a "repeatable set of fields", which we call "Section Container". + * The section container only contains fields, which is a very simple format. + * + * @internal This is an experimental implementation and might change until TYPO3 v13 LTS + */ +final readonly class FlexSectionContainer +{ + public function __construct( + // @todo: incomplete or obsolete implementation, these properties are never read. + private string $sheetIdentifier, + private string $title, + private string $description, + private FieldCollection $fields + ) {} + + public function getFields(): FieldCollection + { + return $this->fields; + } + + public function hasField(string $fieldName): bool + { + return isset($this->fields[$fieldName]); + } + + public function getField(string $fieldName): FieldTypeInterface + { + return $this->fields[$fieldName]; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/Struct/FlexSheet.php b/Classes/Schema/Struct/FlexSheet.php new file mode 100644 index 0000000..018ab22 --- /dev/null +++ b/Classes/Schema/Struct/FlexSheet.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Struct; + +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; + +/** + * FlexForms are always separated in sheets, "sDEF" being the default sheet + * if no sheets are defined. + * Each sheet contains fields OR Section Containers (defined by <section>1</section>) which then could also + * contain fields. + */ +final readonly class FlexSheet +{ + public function __construct( + // @todo: incomplete or obsolete implementation, these properties are never read. + private string $sheetIdentifier, + private string $title, + private string $description, + private FieldCollection $fields, + private array $sections, + ) {} + + public function getFields(): FieldCollection + { + return $this->fields; + } + + public function hasField(string $fieldName): bool + { + return isset($this->fields[$fieldName]); + } + + public function getField(string $fieldName): FieldTypeInterface + { + return $this->fields[$fieldName]; + } + + public function getSections(): array + { + return $this->sections; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/Struct/SelectItem.php b/Classes/Schema/Struct/SelectItem.php new file mode 100644 index 0000000..f876b24 --- /dev/null +++ b/Classes/Schema/Struct/SelectItem.php @@ -0,0 +1,283 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Struct; + +final class SelectItem implements \ArrayAccess +{ + private const array LEGACY_INDEXED_KEYS_MAPPING_TABLE = [ + 0 => 'label', + 1 => 'value', + 2 => 'icon', + 3 => 'group', + 4 => 'description', + ]; + private array $container = []; + + public function __construct( + private string $type, + private string $label, + private int|string|null $value, + private ?string $icon = null, + private ?string $group = null, + private string|array|null $description = null, + private bool $invertStateDisplay = false, + private ?string $iconIdentifierChecked = null, + private ?string $iconIdentifierUnchecked = null, + private ?string $labelChecked = null, + private ?string $labelUnchecked = null, + private ?string $iconOverlay = null, + ) {} + + public static function fromTcaItemArray(array $item, string $type = 'select'): SelectItem + { + return new self( + type: $type, + label: (string)($item['label'] ?? $item[0]), + value: $item['value'] ?? $item[1] ?? null, + icon: $item['icon'] ?? $item[2] ?? null, + group: $item['group'] ?? $item[3] ?? null, + description: $item['description'] ?? $item[4] ?? null, + invertStateDisplay: (bool)($item['invertStateDisplay'] ?? false), + iconIdentifierChecked: $item['iconIdentifierChecked'] ?? null, + iconIdentifierUnchecked: $item['iconIdentifierUnchecked'] ?? null, + labelChecked: $item['labelChecked'] ?? null, + labelUnchecked: $item['labelUnchecked'] ?? null, + iconOverlay: $item['iconOverlay'] ?? null, + ); + } + + public function toArray(): array + { + if ($this->type === 'radio') { + return [ + 'label' => $this->label, + 'value' => $this->value, + ]; + } + + if ($this->type === 'check') { + return [ + 'label' => $this->label, + 'invertStateDisplay' => $this->invertStateDisplay, + 'iconIdentifierChecked' => $this->iconIdentifierChecked, + 'iconIdentifierUnchecked' => $this->iconIdentifierUnchecked, + 'labelChecked' => $this->labelChecked, + 'labelUnchecked' => $this->labelUnchecked, + ]; + } + + // Default type=select + return [ + 'label' => $this->label, + 'value' => $this->value, + 'icon' => $this->icon, + 'iconOverlay' => $this->iconOverlay, + 'group' => $this->group, + 'description' => $this->description, + ]; + } + + public function getLabel(): string + { + return $this->label; + } + + public function withLabel(string $label): SelectItem + { + $clone = clone $this; + $clone->label = $label; + return $clone; + } + + public function getValue(): int|string|null + { + return $this->value; + } + + public function withValue(int|string|null $value): SelectItem + { + $clone = clone $this; + $clone->value = $value; + return $clone; + } + + public function getIcon(): ?string + { + return $this->icon; + } + + public function hasIcon(): bool + { + return $this->icon !== null; + } + + public function withIcon(?string $icon): SelectItem + { + $clone = clone $this; + $clone->icon = $icon; + return $clone; + } + + public function getGroup(): ?string + { + return $this->group; + } + + public function hasGroup(): bool + { + return $this->group !== null; + } + + public function withGroup(?string $group): SelectItem + { + $clone = clone $this; + $clone->group = $group; + return $clone; + } + + public function getDescription(): string|array|null + { + return $this->description; + } + + public function hasDescription(): bool + { + return $this->description !== null; + } + + public function withDescription(string|array|null $description): SelectItem + { + $clone = clone $this; + $clone->description = $description; + return $clone; + } + + public function invertStateDisplay(): bool + { + return $this->invertStateDisplay; + } + + public function getIconIdentifierChecked(): ?string + { + return $this->iconIdentifierChecked; + } + + public function hasIconIdentifierChecked(): bool + { + return $this->iconIdentifierChecked !== null; + } + + public function getIconIdentifierUnchecked(): ?string + { + return $this->iconIdentifierUnchecked; + } + + public function hasIconIdentifierUnchecked(): bool + { + return $this->iconIdentifierUnchecked !== null; + } + + public function getLabelChecked(): ?string + { + return $this->labelChecked; + } + + public function hasLabelChecked(): bool + { + return $this->labelChecked !== null; + } + + public function getLabelUnchecked(): ?string + { + return $this->labelUnchecked; + } + + public function hasLabelUnchecked(): bool + { + return $this->labelUnchecked !== null; + } + + public function getIconOverlay(): ?string + { + return $this->iconOverlay; + } + + public function hasIconOverlay(): bool + { + return $this->iconOverlay !== null; + } + + public function withIconOverlay(?string $iconOverlay): SelectItem + { + $clone = clone $this; + $clone->iconOverlay = $iconOverlay; + return $clone; + } + + public function isDivider(): bool + { + return $this->value === '--div--'; + } + + public function offsetExists(mixed $offset): bool + { + if (array_key_exists($offset, self::LEGACY_INDEXED_KEYS_MAPPING_TABLE)) { + $offset = self::LEGACY_INDEXED_KEYS_MAPPING_TABLE[$offset]; + } + if (property_exists($this, $offset)) { + return isset($this->toArray()[$offset]); + } + return isset($this->container[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + if (array_key_exists($offset, self::LEGACY_INDEXED_KEYS_MAPPING_TABLE)) { + $offset = self::LEGACY_INDEXED_KEYS_MAPPING_TABLE[$offset]; + } + if (property_exists($this, $offset)) { + return $this->toArray()[$offset]; + } + return $this->container[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (array_key_exists($offset, self::LEGACY_INDEXED_KEYS_MAPPING_TABLE)) { + $offset = self::LEGACY_INDEXED_KEYS_MAPPING_TABLE[$offset]; + } + if (property_exists($this, $offset)) { + $this->{$offset} = $value; + } else { + $this->container[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void + { + if (array_key_exists($offset, self::LEGACY_INDEXED_KEYS_MAPPING_TABLE)) { + $offset = self::LEGACY_INDEXED_KEYS_MAPPING_TABLE[$offset]; + } + + if (property_exists($this, $offset)) { + $this->{$offset} = null; + } else { + unset($this->container[$offset]); + } + } +} diff --git a/Classes/Schema/Struct/SelectItemCollection.php b/Classes/Schema/Struct/SelectItemCollection.php new file mode 100644 index 0000000..c197e6c --- /dev/null +++ b/Classes/Schema/Struct/SelectItemCollection.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema\Struct; + +use TYPO3\CMS\Core\Collection\CollectionInterface; +use TYPO3\CMS\Core\Collection\EditableCollectionInterface; + +final class SelectItemCollection implements CollectionInterface, EditableCollectionInterface +{ + private \SplDoublyLinkedList $storage; + + public function __construct() + { + $this->storage = new \SplDoublyLinkedList(); + } + + /** + * Utility method to transform an arbitrary array to a proper SelectItem collection + * + * @param array $itemList List of SelectItem elements or legacy item arrays + * @param string $type The field type, e.g. "select" + */ + public static function createFromArray(array $itemList, string $type): self + { + $collection = new self(); + foreach ($itemList as $item) { + if ($item instanceof SelectItem) { + $collection->add($item); + continue; + } + if (is_array($item)) { + $collection->add( + SelectItem::fromTcaItemArray($item, $type) + ); + continue; + } + throw new \InvalidArgumentException( + 'Values of $itemList must be of type ' . SelectItem::class . ' or array.', + 1762417317 + ); + } + return $collection; + } + + public function current(): SelectItem + { + return $this->storage->current(); + } + + public function next(): void + { + $this->storage->next(); + } + + public function key(): int + { + return $this->storage->key(); + } + + public function valid(): bool + { + return $this->storage->valid(); + } + + public function rewind(): void + { + $this->storage->rewind(); + } + + public function count(): int + { + return $this->storage->count(); + } + + /** + * @param mixed $data + * @todo replace this with a strict "SelectItem" type in TYPO3 v15.0 + */ + public function add($data): void + { + if ($data instanceof SelectItem) { + $this->storage->push($data); + } + } + + /** + * @param SelectItemCollection $other + */ + public function addAll(CollectionInterface $other): void + { + foreach ($other as $item) { + if ($item instanceof SelectItem) { + $this->storage->push($item); + } + } + } + + /** + * @param mixed $data + * @todo replace this with a strict "SelectItem" type in TYPO3 v15.0 + */ + public function remove($data): void + { + if (!($data instanceof SelectItem)) { + return; + } + + foreach ($this->storage as $key => $value) { + if ($value === $data) { + $this->storage->offsetUnset($key); + break; + } + } + } + + public function removeAll(): void + { + $this->storage = new \SplDoublyLinkedList(); + } + + /** + * @return SelectItem[] + */ + public function toArray(): array + { + $items = []; + foreach ($this->storage as $item) { + $items[] = $item; + } + return $items; + } +} diff --git a/Classes/Schema/Struct/WizardStep.php b/Classes/Schema/Struct/WizardStep.php new file mode 100644 index 0000000..e363efa --- /dev/null +++ b/Classes/Schema/Struct/WizardStep.php @@ -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\Core\Schema\Struct; + +use TYPO3\CMS\Core\Schema\Field\FieldCollection; + +final readonly class WizardStep +{ + public function __construct( + private string $identifier, + private string $title, + private FieldCollection $fields, + ) {} + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getFields(): FieldCollection + { + return $this->fields; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/TcaSchema.php b/Classes/Schema/TcaSchema.php new file mode 100644 index 0000000..3cfd994 --- /dev/null +++ b/Classes/Schema/TcaSchema.php @@ -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\Core\Schema; + +use TYPO3\CMS\Core\DataHandling\TableColumnType; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\Exception\InvalidSchemaTypeException; +use TYPO3\CMS\Core\Schema\Exception\UndefinedFieldException; +use TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException; +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; +use TYPO3\CMS\Core\Schema\Field\LanguageFieldType; +use TYPO3\CMS\Core\Schema\Field\RelationalFieldTypeInterface; +use TYPO3\CMS\Core\Schema\Struct\WizardStep; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Main implementation class for TCA-based schema. + */ +readonly class TcaSchema implements SchemaInterface +{ + public function __construct( + protected string $name, + protected FieldCollection $fields, + protected array $schemaConfiguration, + protected ?SchemaCollection $subSchemata = null, + /** @var PassiveRelation[] */ + protected array $passiveRelations = [], + /** @var list<WizardStep> $wizardSteps */ + protected array $wizardSteps = [], + ) {} + + public function getName(): string + { + return $this->name; + } + + public function getFields(?callable $filterFunction = null): FieldCollection + { + if ($filterFunction === null) { + return $this->fields; + } + + return new FieldCollection(array_filter(iterator_to_array($this->fields), $filterFunction)); + } + + public function hasField(string $fieldName): bool + { + return isset($this->fields[$fieldName]); + } + + public function getField(string $fieldName): FieldTypeInterface + { + if (!$this->hasField($fieldName)) { + throw new UndefinedFieldException('The field "' . $fieldName . '" is not defined for the TCA schema "' . $this->name . '".', 1661615151); + } + return $this->fields[$fieldName]; + } + + /** + * @return FieldTypeInterface[] + * @internal not part of TYPO3 Core API. + */ + public function getFieldsOfType(TableColumnType $type): iterable + { + foreach ($this->fields as $field) { + if (TableColumnType::tryFrom($field->getType()) !== $type) { + continue; + } + yield $field; + } + } + + public function getTitle(?callable $fn = null): string + { + // If a title is defined in the schema configuration, use it. + if (isset($this->schemaConfiguration['title']) && $fn) { + return $fn($this->schemaConfiguration['title']); + } + return $this->schemaConfiguration['title'] ?? ''; + } + + public function getRawConfiguration(): array + { + return $this->schemaConfiguration; + } + + public function isLanguageAware(): bool + { + return isset($this->schemaConfiguration['languageField']) && isset($this->schemaConfiguration['transOrigPointerField']); + } + + public function isWorkspaceAware(): bool + { + return (bool)($this->schemaConfiguration['versioningWS'] ?? false); + } + + public function hasCapability(TcaSchemaCapability $capability): bool + { + return match ($capability) { + TcaSchemaCapability::SoftDelete => !empty($this->schemaConfiguration['delete'] ?? null), + TcaSchemaCapability::CreatedAt => (bool)($this->schemaConfiguration['crdate'] ?? null), + TcaSchemaCapability::UpdatedAt => (bool)($this->schemaConfiguration['tstamp'] ?? null), + TcaSchemaCapability::SortByField => !empty($this->schemaConfiguration['sortby'] ?? null), + TcaSchemaCapability::DefaultSorting => (bool)($this->schemaConfiguration['default_sortby'] ?? null), + TcaSchemaCapability::AncestorReferenceField => (bool)($this->schemaConfiguration['origUid'] ?? null), + + TcaSchemaCapability::EditLock => isset($this->schemaConfiguration['editlock']) && isset($this->fields[$this->schemaConfiguration['editlock']]), + TcaSchemaCapability::InternalDescription => isset($this->schemaConfiguration['descriptionColumn']) && isset($this->fields[$this->schemaConfiguration['descriptionColumn']]), + + TcaSchemaCapability::Language => $this->isLanguageAware(), + TcaSchemaCapability::Workspace => $this->isWorkspaceAware(), + + TcaSchemaCapability::Label => (bool)($this->schemaConfiguration['label'] ?? ''), + + TcaSchemaCapability::AccessAdminOnly => (bool)($this->schemaConfiguration['adminOnly'] ?? false), + TcaSchemaCapability::AccessReadOnly => (bool)($this->schemaConfiguration['readOnly'] ?? false), + TcaSchemaCapability::HideRecordsAtCopy => (bool)($this->schemaConfiguration['hideAtCopy'] ?? false), + TcaSchemaCapability::HideInUi => (bool)($this->schemaConfiguration['hideTable'] ?? false), + TcaSchemaCapability::PrependLabelTextAtCopy => (bool)((string)($this->schemaConfiguration['prependAtCopy'] ?? '')), + TcaSchemaCapability::RestrictionDisabledField => isset($this->schemaConfiguration['enablecolumns']['disabled']), + TcaSchemaCapability::RestrictionStartTime => isset($this->schemaConfiguration['enablecolumns']['starttime']), + TcaSchemaCapability::RestrictionEndTime => isset($this->schemaConfiguration['enablecolumns']['endtime']), + TcaSchemaCapability::RestrictionUserGroup => isset($this->schemaConfiguration['enablecolumns']['fe_group']), + // This is an implicit restriction with a custom configuration + TcaSchemaCapability::RestrictionRootLevel => true, + TcaSchemaCapability::RestrictionWebMount => !empty($this->schemaConfiguration['security']['ignoreWebMountRestriction'] ?? false), + + TcaSchemaCapability::ExtbaseHistoryTracking => (bool)($this->schemaConfiguration['extbase']['enableHistoryTracking'] ?? true), + }; + } + + /** + * @return ($capability is TcaSchemaCapability::Language ? Capability\LanguageAwareSchemaCapability + * : ($capability is TcaSchemaCapability::RestrictionRootLevel ? Capability\RootLevelCapability + * : ($capability is TcaSchemaCapability::EditLock ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::InternalDescription ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::RestrictionDisabledField ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::RestrictionStartTime ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::RestrictionEndTime ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::RestrictionUserGroup ? Capability\FieldCapability + * : ($capability is TcaSchemaCapability::AccessReadOnly ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::AccessAdminOnly ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::HideRecordsAtCopy ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::HideInUi ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::PrependLabelTextAtCopy ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::DefaultSorting ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::Label ? Capability\LabelCapability + * : ($capability is TcaSchemaCapability::ExtbaseHistoryTracking ? Capability\ScalarCapability + * : ($capability is TcaSchemaCapability::AncestorReferenceField ? Capability\SystemInternalFieldCapability + * : Capability\SystemInternalFieldCapability))))))))))))))))) + */ + public function getCapability(TcaSchemaCapability $capability): Capability\SchemaCapabilityInterface + { + return match ($capability) { + TcaSchemaCapability::SoftDelete => new Capability\SystemInternalFieldCapability((string)($this->schemaConfiguration['delete'] ?? '')), + TcaSchemaCapability::CreatedAt => new Capability\SystemInternalFieldCapability((string)($this->schemaConfiguration['crdate'] ?? '')), + TcaSchemaCapability::UpdatedAt => new Capability\SystemInternalFieldCapability((string)($this->schemaConfiguration['tstamp'] ?? '')), + TcaSchemaCapability::SortByField => new Capability\SystemInternalFieldCapability((string)($this->schemaConfiguration['sortby'] ?? '')), + TcaSchemaCapability::DefaultSorting => new Capability\ScalarCapability((string)($this->schemaConfiguration['default_sortby'] ?? '')), + TcaSchemaCapability::AncestorReferenceField => new Capability\SystemInternalFieldCapability((string)($this->schemaConfiguration['origUid'] ?? '')), + + TcaSchemaCapability::EditLock => new Capability\FieldCapability($this->fields[$this->schemaConfiguration['editlock']]), + TcaSchemaCapability::InternalDescription => new Capability\FieldCapability($this->fields[$this->schemaConfiguration['descriptionColumn']]), + + TcaSchemaCapability::Language => $this->buildLanguageCapability(), + TcaSchemaCapability::Workspace => new Capability\ScalarCapability((bool)($this->schemaConfiguration['versioningWS'] ?? false)), + + TcaSchemaCapability::Label => $this->buildLabelCapability(), + + TcaSchemaCapability::AccessAdminOnly => new Capability\ScalarCapability((bool)($this->schemaConfiguration['adminOnly'] ?? false)), + TcaSchemaCapability::AccessReadOnly => new Capability\ScalarCapability((bool)($this->schemaConfiguration['readOnly'] ?? false)), + TcaSchemaCapability::HideRecordsAtCopy => new Capability\ScalarCapability((bool)($this->schemaConfiguration['hideAtCopy'] ?? false)), + TcaSchemaCapability::HideInUi => new Capability\ScalarCapability((bool)($this->schemaConfiguration['hideTable'] ?? false)), + TcaSchemaCapability::PrependLabelTextAtCopy => new Capability\ScalarCapability((string)($this->schemaConfiguration['prependAtCopy'] ?? '')), + TcaSchemaCapability::RestrictionDisabledField => new Capability\FieldCapability($this->getField($this->schemaConfiguration['enablecolumns']['disabled'])), + TcaSchemaCapability::RestrictionStartTime => new Capability\FieldCapability($this->getField($this->schemaConfiguration['enablecolumns']['starttime'])), + TcaSchemaCapability::RestrictionEndTime => new Capability\FieldCapability($this->getField($this->schemaConfiguration['enablecolumns']['endtime'])), + TcaSchemaCapability::RestrictionUserGroup => new Capability\FieldCapability($this->getField($this->schemaConfiguration['enablecolumns']['fe_group'])), + TcaSchemaCapability::RestrictionRootLevel => new Capability\RootLevelCapability((int)($this->schemaConfiguration['rootLevel'] ?? 0), (bool)($this->schemaConfiguration['security']['ignoreRootLevelRestriction'] ?? false)), + TcaSchemaCapability::RestrictionWebMount => new Capability\ScalarCapability((bool)($this->schemaConfiguration['security']['ignoreWebMountRestriction'] ?? false)), + + TcaSchemaCapability::ExtbaseHistoryTracking => new Capability\ScalarCapability((bool)($this->schemaConfiguration['extbase']['enableHistoryTracking'] ?? true)), + }; + } + + protected function buildLanguageCapability(): Capability\LanguageAwareSchemaCapability + { + /** @var LanguageFieldType $languageField */ + $languageField = $this->fields[$this->schemaConfiguration['languageField']]; + return new Capability\LanguageAwareSchemaCapability( + $languageField, + $this->fields[$this->schemaConfiguration['transOrigPointerField']], + (isset($this->schemaConfiguration['translationSource']) ? ($this->fields[$this->schemaConfiguration['translationSource']] ?? null) : null), + (isset($this->schemaConfiguration['transOrigDiffSourceField']) ? ($this->fields[$this->schemaConfiguration['transOrigDiffSourceField']] ?? null) : null), + ); + } + + protected function buildLabelCapability(): Capability\LabelCapability + { + $labelConfiguration = []; + if (isset($this->schemaConfiguration['label_userFunc'])) { + $labelConfiguration['generator'] = $this->schemaConfiguration['label_userFunc']; + $labelConfiguration['generatorOptions'] = $this->schemaConfiguration['label_userFunc_options'] ?? []; + } + if (isset($this->schemaConfiguration['formattedLabel_userFunc'])) { + $labelConfiguration['formatter'] = $this->schemaConfiguration['formattedLabel_userFunc']; + $labelConfiguration['formatterOptions'] = $this->schemaConfiguration['formattedLabel_userFunc_options'] ?? []; + } + return new Capability\LabelCapability( + $this->schemaConfiguration['label'] ?? null, + array_unique(GeneralUtility::trimExplode(',', $this->schemaConfiguration['label_alt'] ?? '', true)), + (bool)($this->schemaConfiguration['label_alt_force'] ?? false), + $labelConfiguration + ); + } + + public function hasSubSchema(string $subSchema): bool + { + return isset($this->subSchemata[$subSchema]); + } + + public function getSubSchema(string $subSchema): TcaSchema + { + if (!$this->hasSubSchema($subSchema)) { + throw new UndefinedSchemaException('The sub schema "' . $subSchema . '" is not defined for the TCA schema "' . $this->name . '".', 1661617062); + } + + return $this->subSchemata[$subSchema]; + } + + public function getSubSchemata(): SchemaCollection + { + return $this->subSchemata ?? new SchemaCollection([]); + } + + public function supportsSubSchema(): bool + { + return isset($this->schemaConfiguration['type']); + } + + public function getSubSchemaTypeInformation(): SchemaTypeInformation + { + $typeInformation = $this->schemaConfiguration['type'] ?? null; + if ($typeInformation === null) { + throw new InvalidSchemaTypeException('The schema "' . $this->name . '" has no type information.', 1749241443); + } + if (str_contains($typeInformation, ':')) { + [$localField, $foreignField] = explode(':', $typeInformation, 2); + if (!$this->fields->offsetExists($localField) || $this->fields[$localField] instanceof RelationalFieldTypeInterface === false) { + throw new InvalidSchemaTypeException('The schema "' . $this->name . '" defines a foreign field type "' . $typeInformation . '" but there is either no such local field "' . $localField . '" or the field is no relational field.', 1749241444); + } + $activeRelation = $this->fields[$localField]->getRelations()[0] ?? null; + if ($activeRelation instanceof ActiveRelation === false || $activeRelation->toTable() === '') { + throw new InvalidSchemaTypeException('The schema "' . $this->name . '" defines a foreign field type "' . $typeInformation . '" but the local field "' . $localField . '" does not provide a valid realtion.', 1749241445); + } + return new SchemaTypeInformation( + $this->getName(), + $localField, + $foreignField, + $activeRelation->toTable() + ); + } + if (!$this->fields->offsetExists($typeInformation)) { + throw new InvalidSchemaTypeException('The schema "' . $this->name . '" defines a field type "' . $typeInformation . '" but there is no such field.', 1749241446); + } + return new SchemaTypeInformation( + $this->getName(), + $typeInformation, + ); + } + + /** + * @return PassiveRelation[] + */ + public function getPassiveRelations(): array + { + return $this->passiveRelations; + } + + /** + * @return ActiveRelation[] + */ + public function getActiveRelations(): array + { + $relations = []; + foreach ($this->fields as $field) { + if ($field instanceof RelationalFieldTypeInterface) { + $relations = array_merge($relations, $field->getRelations()); + } + } + return $relations; + } + + public function getWizardSteps(): array + { + return $this->wizardSteps; + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Schema/TcaSchemaBuilder.php b/Classes/Schema/TcaSchemaBuilder.php new file mode 100644 index 0000000..338a6ef --- /dev/null +++ b/Classes/Schema/TcaSchemaBuilder.php @@ -0,0 +1,239 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use TYPO3\CMS\Core\Schema\Exception\FieldTypeNotAvailableException; +use TYPO3\CMS\Core\Schema\Exception\UndefinedFieldException; +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; +use TYPO3\CMS\Core\Schema\Struct\WizardStep; +use TYPO3\CMS\Core\Service\DependencyOrderingService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This class builds a TCA schema for a given TCA + * This is done the following way: + * + * As the relations need to be fully resolved first (done in RelationMapBuilder), + * the TcaSchemaFactory does two-step processing: + * 1a. Traverse TCA (and, if type=flex parts are registered), and find relations of all TCA parts pointing to each other + * 1b. Store this in a RelationMap object as a multi-level array. + * --- + * 2. Loop through all TCA tables one by one + * 2a. Build field objects for the TCA table. + * 2b. Detect "sub schemata" (if [ctrl][type] is set), build the field objects only relevant for the sub-schema + * 2c. Build the sub-schema + * 2d. Build the main schema + * + * @internal Not part of TYPO3's API. + */ +final readonly class TcaSchemaBuilder +{ + public function __construct( + private RelationMapBuilder $relationMapBuilder, + private FieldTypeFactory $fieldTypeFactory, + ) {} + + public function buildFromStructure(array $fullTca): SchemaCollection + { + $schemata = []; + ksort($fullTca); + $relationMap = $this->relationMapBuilder->buildFromStructure($fullTca); + foreach (array_keys($fullTca) as $table) { + $schemata[$table] = $this->build($table, $fullTca, $relationMap); + } + return new SchemaCollection($schemata); + } + + /** + * Builds a schema from a TCA table, if a sub-schema is requested, it will build the main schema and + * all sub-schematas first. + * + * First builds all fields, then the schema and attach the fields, so all parts can never be + * modified (except for adding sub-schema - this might be removed at some point hopefully). + * + * Then, resolves the sub-schema and the relevant fields for there with columnsOverrides taken into + * account. + * + * As it is crucial to understand, parts such as FlexForms (incl. Sheet, SectionContainers and their Fields) + * NEED to be resolved first, because they need to be attached. + */ + private function build(string $schemaName, array $fullTca, RelationMap $relationMap): TcaSchema + { + if (str_contains($schemaName, '.')) { + // @todo: This 'if' is dead code, isn't it? + [$mainSchema, $subSchema] = explode('.', $schemaName, 2); + $mainSchema = $this->build($mainSchema, $fullTca, $relationMap); + return $mainSchema->getSubSchema($subSchema); + } + + // Collect all fields + $allFields = []; + $schemaDefinition = $fullTca[$schemaName]; + foreach ($schemaDefinition['columns'] ?? [] as $fieldName => $fieldConfiguration) { + try { + $field = $this->fieldTypeFactory->createFieldType( + $fieldName, + $fieldConfiguration, + $schemaName, + $relationMap + ); + } catch (FieldTypeNotAvailableException) { + continue; + } + + $allFields[$fieldName] = $field; + } + + $schemaConfiguration = $schemaDefinition['ctrl'] ?? []; + // Store "palettes" information into the ctrl section + if (is_array($schemaDefinition['palettes'] ?? null)) { + $schemaConfiguration['palettes'] = $schemaDefinition['palettes']; + } + + // Resolve all sub schemas and collect their fields while keeping the system fields + $subSchemata = []; + if (isset($schemaDefinition['ctrl']['type'])) { + foreach ($schemaDefinition['types'] ?? [] as $subSchemaName => $subSchemaDefinition) { + $subSchemaName = (string)$subSchemaName; + $subSchemaFields = []; + $subSchemaFieldInformation = $this->findRelevantFieldsForSubSchema($schemaDefinition, $subSchemaName); + foreach ($subSchemaFieldInformation as $fieldName => $subSchemaFieldConfiguration) { + try { + $field = $this->fieldTypeFactory->createFieldType( + $fieldName, + $subSchemaFieldConfiguration, + $subSchemaName, + // Interesting side-note: The relations stay the same as it is not possible to modify + // this for a subtype. + $relationMap, + $schemaName + ); + } catch (FieldTypeNotAvailableException) { + continue; + } + + $subSchemaFields[$fieldName] = $field; + } + + $subSchemaFieldCollection = new FieldCollection($subSchemaFields); + $subSchemata[$subSchemaName] = new TcaSchema( + $schemaName . '.' . $subSchemaName, + $subSchemaFieldCollection, + // Merge parts from the "types" section into the ctrl section of the main schema + array_replace_recursive($schemaConfiguration, $subSchemaDefinition), + null, + [], + $this->getOrderedWizardSteps($subSchemaDefinition, $subSchemaFieldCollection, $subSchemaName) + ); + } + } elseif (($schemaDefinition['types'] ?? []) !== []) { + // Merge parts from the "types" section into the ctrl section of the main schema + $schemaConfiguration = array_replace_recursive( + $schemaConfiguration, + array_first($schemaDefinition['types']) + ); + } + return new TcaSchema( + $schemaName, + new FieldCollection($allFields), + $schemaConfiguration, + $subSchemata !== [] ? new SchemaCollection($subSchemata) : null, + $relationMap->getPassiveRelations($schemaName) + ); + } + + private function findRelevantFieldsForSubSchema(array $tcaForTable, string $subSchemaName): array + { + $fields = []; + if (!isset($tcaForTable['types'][$subSchemaName])) { + throw new \InvalidArgumentException('Subschema "' . $subSchemaName . '" not found.', 1715269835); + } + $subSchemaConfig = $tcaForTable['types'][$subSchemaName]; + $showItemArray = GeneralUtility::trimExplode(',', $subSchemaConfig['showitem'] ?? '', true); + foreach ($showItemArray as $aShowItemFieldString) { + [$fieldName, $fieldLabel, $paletteName] = GeneralUtility::trimExplode(';', $aShowItemFieldString . ';;;'); + if ($fieldName === '--div--') { + // tabs are not of interest here + continue; + } + if ($fieldName === '--palette--' && !empty($paletteName)) { + // showitem references to a palette field. unpack the palette and process + // label overrides that may be in there. + if (!isset($tcaForTable['palettes'][$paletteName]['showitem'])) { + // No palette with this name found? Skip it. + continue; + } + $palettesArray = GeneralUtility::trimExplode( + ',', + $tcaForTable['palettes'][$paletteName]['showitem'] + ); + foreach ($palettesArray as $aPalettesString) { + [$fieldName, $fieldLabel] = GeneralUtility::trimExplode(';', $aPalettesString . ';;'); + if (isset($tcaForTable['columns'][$fieldName])) { + $fields[$fieldName] = $this->getFinalFieldConfiguration($fieldName, $tcaForTable, $subSchemaConfig, $fieldLabel); + } + } + } elseif (isset($tcaForTable['columns'][$fieldName])) { + $fields[$fieldName] = $this->getFinalFieldConfiguration($fieldName, $tcaForTable, $subSchemaConfig, $fieldLabel); + } + } + return $fields; + } + + /** + * Handle label and possible columnsOverrides + */ + private function getFinalFieldConfiguration(string $fieldName, array $schemaConfiguration, array $subSchemaConfiguration, ?string $fieldLabel): array + { + $fieldConfiguration = $schemaConfiguration['columns'][$fieldName] ?? []; + if (isset($subSchemaConfiguration['columnsOverrides'][$fieldName])) { + $fieldConfiguration = array_replace_recursive($fieldConfiguration, $subSchemaConfiguration['columnsOverrides'][$fieldName]); + } + if (!empty($fieldLabel)) { + $fieldConfiguration['label'] = $fieldLabel; + } + return $fieldConfiguration; + } + + /** + * @throws UndefinedFieldException + */ + private function getOrderedWizardSteps(array $schemaDefinition, FieldCollection $fieldCollection, string $subSchemaName): array + { + if (!isset($schemaDefinition['wizardSteps'])) { + return []; + } + + $wizardSteps = []; + $dependencyOrderingService = GeneralUtility::makeInstance(DependencyOrderingService::class); + $orderedWizardSteps = $dependencyOrderingService->orderByDependencies($schemaDefinition['wizardSteps']); + + foreach ($orderedWizardSteps as $stepIdentifier => $wizardStep) { + $fields = $wizardStep['fields'] ?? throw new \UnexpectedValueException('Wizard step fields are missing', 1774356281); + $undefinedFields = array_diff($fields, $fieldCollection->getNames()); + if ($undefinedFields !== []) { + throw new UndefinedFieldException(sprintf('Wizard step fields: "%s" are not configured in TCA schema: "%s"', implode(',', $undefinedFields), $subSchemaName), 1774355993); + } + + $wizardFieldCollection = array_filter(iterator_to_array($fieldCollection), fn(FieldTypeInterface $field) => in_array($field->getName(), $fields)); + $wizardSteps[$stepIdentifier] = new WizardStep($stepIdentifier, $wizardStep['title'] ?? '', new FieldCollection($wizardFieldCollection)); + } + return $wizardSteps; + } +} diff --git a/Classes/Schema/TcaSchemaFactory.php b/Classes/Schema/TcaSchemaFactory.php new file mode 100644 index 0000000..13b2838 --- /dev/null +++ b/Classes/Schema/TcaSchemaFactory.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Schema; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Schema\Exception\UndefinedSchemaException; + +/** + * This factory returns an object representation of $GLOBALS['TCA']. It is injectable and built during bootstrap. + * + * A TcaSchema contains: + * - a list of all fields as defined in [columns] + * - a list of "capabilities" (parts defined in the [ctrl] section) + * - a list of sub-schemata (if there is a [ctrl][type] definition, then sub-schemata are instances of TcaSchema itself again) + * - a list of possible relations of other schemata pointing to this schema ("Passive Relations") + */ +#[Autoconfigure(public: true, shared: true)] +class TcaSchemaFactory +{ + protected SchemaCollection $schemata; + + public function __construct( + protected readonly TcaSchemaBuilder $schemaBuilder, + #[Autowire(expression: 'service("package-dependent-cache-identifier").withPrefix("TcaSchema").toString()')] + protected readonly string $cacheIdentifier, + #[Autowire(service: 'cache.core')] + protected readonly PhpFrontend $cache, + ) { + $this->schemata = new SchemaCollection([]); + } + + /** + * Get a schema from the loaded TCA. Ensure to check for a schema with ->has() before + * calling ->get(). + * @throws UndefinedSchemaException + */ + public function get(string $schemaName): TcaSchema + { + return $this->schemata->get($schemaName); + } + + /** + * Checks if a schema exists, does not build the schema if not needed, thus it's very slim + * and only creates a schema if a sub-schema is requested. + */ + public function has(string $schemaName): bool + { + return $this->schemata->has($schemaName); + } + + /** + * Returns all main schemata + * + * @return SchemaCollection<string, TcaSchema> + */ + public function all(): SchemaCollection + { + return $this->schemata; + } + + /** + * Only used for functional tests, which override TCA on the fly for specific test cases. + * Modifying TCA other than in Configuration/TCA/Overrides must be avoided in production code. + * + * @internal only used for TYPO3 Core internally, never use it in public! + */ + public function rebuild(array $fullTca): void + { + $this->schemata = $this->schemaBuilder->buildFromStructure($fullTca); + } + + /** + * Load TCA and populate all schema - throws away existing schema if $force is set. + * + * @internal only used for TYPO3 Core internally, never use it in public! + */ + public function load(array $tca, bool $force = false): void + { + if (!$force && $this->schemata->count() > 0) { + return; + } + if (!$force && $this->cache->has($this->cacheIdentifier)) { + $this->schemata = $this->cache->require($this->cacheIdentifier); + return; + } + $this->rebuild($tca); + $this->cache->set($this->cacheIdentifier, 'return ' . var_export($this->schemata, true) . ';'); + } + + #[AsEventListener('typo3-core/tca-schema')] + public function warmupCaches(CacheWarmupEvent $event): void + { + if ($event->hasGroup('system')) { + $this->schemata = new SchemaCollection([]); + $this->load($GLOBALS['TCA'], true); + } + } +} diff --git a/Classes/Schema/VisibleSchemaFieldsCollector.php b/Classes/Schema/VisibleSchemaFieldsCollector.php new file mode 100644 index 0000000..31d113c --- /dev/null +++ b/Classes/Schema/VisibleSchemaFieldsCollector.php @@ -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\Core\Schema; + +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Domain\RecordFactory; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\Field\FieldCollection; +use TYPO3\CMS\Core\Schema\Field\FieldTypeInterface; + +/** + * Class that provides record type dependant fields, visible for the current user, taking language context into account + */ +readonly class VisibleSchemaFieldsCollector +{ + public function __construct( + private TcaSchemaFactory $schemaFactory, + private RecordFactory $recordFactory, + ) {} + + public function getFields(string $schemaName, array $row, array $exlcudeFieldNames = []): FieldCollection + { + if (!$this->schemaFactory->has($schemaName)) { + return new FieldCollection(); + } + + $backendUser = $this->getBackendUser(); + $schema = $this->schemaFactory->get($schemaName); + $fields = $schema->getFields(); + $record = $this->recordFactory->createRawRecord($schemaName, $row); + + if ($schema->hasSubSchema($record->getRecordType() ?? '')) { + $fields = $schema->getSubSchema($record->getRecordType())->getFields(); + } + + // FieldCollection is immutable - to remove fields we transform it to an array + $fields = iterator_to_array($fields); + + foreach ($exlcudeFieldNames as $fieldName) { + unset($fields[$fieldName]); + } + + $isOverlay = false; + if ($schema->hasCapability(TcaSchemaCapability::Language)) { + $isOverlay = (int)($record->toArray()[$schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()] ?? 0) > 0; + } + + foreach ($fields as $field) { + if (($field->supportsAccessControl() && !$backendUser->check('non_exclude_fields', $schemaName . ':' . $field->getName())) + || ($isOverlay && empty($field->getConfiguration()['l10n_display']) && ($field->getConfiguration()['l10n_mode'] ?? '') === 'exclude') + ) { + unset($fields[$field->getName()]); + } + } + + return new FieldCollection($fields); + } + + /** + * @return string[] + */ + public function getFieldNames(string $schemaName, array $row, array $excludeFieldNames = []): array + { + return array_map(static fn(FieldTypeInterface $field): string => $field->getName(), iterator_to_array($this->getFields($schemaName, $row, $excludeFieldNames))); + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/Security/AllowedCallableAssertion.php b/Classes/Security/AllowedCallableAssertion.php new file mode 100644 index 0000000..abd4852 --- /dev/null +++ b/Classes/Security/AllowedCallableAssertion.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Attribute\AsAllowedCallable; + +#[Autoconfigure(public: true)] +final readonly class AllowedCallableAssertion +{ + /** + * @param list<array{class-string, string}> $items + */ + public function __construct(private array $items = []) {} + + /** + * @param string|array $callable + */ + public function assertCallable(string|array $callable): void + { + // fall-back to using reflection + $isTrusted = $this->isTrusted($callable); + if ($isTrusted === null) { + throw new AllowedCallableException( + sprintf('Unexpected callable reference: %s', $this->stringifyCallable($callable)), + 1758626231 + ); + } + if ($isTrusted === false) { + throw new AllowedCallableException( + sprintf( + 'Attribute %s required for callback reference: %s', + AsAllowedCallable::class, + $this->stringifyCallable($callable) + ), + 1758626232 + ); + } + } + + public function isTrusted(string|array $callable): ?bool + { + if (is_string($callable)) { + $callable = [$callable]; + } + if (count($callable) === 1) { + if ((is_string($callable[0]) && function_exists($callable[0])) || $callable[0] instanceof \Closure) { + return $this->hasMatchingAttributes( + (new \ReflectionFunction($callable[0]))->getAttributes(AsAllowedCallable::class) + ); + } + return null; + } + if (count($callable) === 2 + && is_string($callable[1]) + && ( + (is_string($callable[0]) && class_exists($callable[0])) + || is_object($callable[0]) + ) + ) { + // lookup autoconfigured attributes + $mapKey = [is_object($callable[0]) ? get_class($callable[0]) : $callable[0], $callable[1]]; + if (in_array($mapKey, $this->items, true)) { + return true; + } + // fall-back to using reflection + return $this->hasMatchingAttributes( + (new \ReflectionMethod(...$callable))->getAttributes(AsAllowedCallable::class), + ); + } + return null; + } + + private function hasMatchingAttributes(array $attributes): bool + { + return $attributes !== []; + } + + private function stringifyCallable(string|array $callable): string + { + if (is_array($callable)) { + $callable = array_map( + static fn(mixed $value): mixed => is_object($value) ? get_class($value) : $value, + $callable + ); + } + return json_encode($callable, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/Classes/Security/AllowedCallableException.php b/Classes/Security/AllowedCallableException.php new file mode 100644 index 0000000..c909b11 --- /dev/null +++ b/Classes/Security/AllowedCallableException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Exception; + +final class AllowedCallableException extends Exception {} diff --git a/Classes/Security/BlockSerializationTrait.php b/Classes/Security/BlockSerializationTrait.php new file mode 100644 index 0000000..e4b56c7 --- /dev/null +++ b/Classes/Security/BlockSerializationTrait.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * Blocks object being using `unserialize()` invocations. + * + * Initially this trait blocked `serialize()` as well, which caused + * a couple of side-effects in user-land code and is not problematic + * from a security point of view. + */ +trait BlockSerializationTrait +{ + /** + * Deny object deserialization. + */ + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize ' . __CLASS__, 1588784142); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Configuration/Behavior.php b/Classes/Security/ContentSecurityPolicy/Configuration/Behavior.php new file mode 100644 index 0000000..059d0c7 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Configuration/Behavior.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration; + +/** + * @internal + */ +final class Behavior +{ + /** + * @param bool|null $useNonce Whether to use nonce values + * @param bool|null $useHash Whether to use hash values + */ + public function __construct( + /** + * Controls nonce usage. null = system decides per context, true = always use nonce, false = never use nonce. + */ + public ?bool $useNonce = null, + + /** + * Whether to collect CSP hash values for assets. Always true by default because hashes enable + * response caching (unlike nonces which are per-request). Even when nonces are used, hashes are + * still collected so that cached responses include the correct CSP directives. + */ + public ?bool $useHash = null, + ) {} + + /** + * Creates a Behavior instance from a `csp.yaml` `behavior:` section. + * + * Example: + * ```yaml + * behavior: + * useNonce: false + * useHash: true + * ``` + */ + public static function fromArray(array $data): self + { + $useNonce = isset($data['useNonce']) ? (bool)$data['useNonce'] : null; + $useHash = isset($data['useHash']) ? (bool)$data['useHash'] : null; + return new self($useNonce, $useHash); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Configuration/CspConfigurationFactory.php b/Classes/Security/ContentSecurityPolicy/Configuration/CspConfigurationFactory.php new file mode 100644 index 0000000..d9b1491 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Configuration/CspConfigurationFactory.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration; + +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; +use TYPO3\CMS\Core\Type\Map; + +/** + * Transforms a `csp.yaml` site configuration into a configuration model. + * + * @internal + */ +readonly class CspConfigurationFactory +{ + public function __construct(private Features $features) {} + + /** + * @return list<Disposition> + */ + public function resolveFallbackDispositions(): array + { + $dispositions = []; + if ($this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) { + $dispositions[] = Disposition::enforce; + } + if ($this->features->isFeatureEnabled('security.frontend.reportContentSecurityPolicy')) { + $dispositions[] = Disposition::report; + } + return $dispositions; + } + + /** + * Builds a Behavior instance from the top-level `behavior:` section of the `csp.yaml` configuration. + */ + public function buildBehavior(array $siteConfiguration): Behavior + { + $behaviorData = $siteConfiguration['behavior'] ?? []; + return is_array($behaviorData) ? Behavior::fromArray($behaviorData) : new Behavior(); + } + + /** + * @return Map<Disposition, DispositionConfiguration> + */ + public function buildDispositionMap(array $siteConfiguration): Map + { + $activeAssignment = (bool)($siteConfiguration['active'] ?? true); + // @todo future TYPO3 v14 should explicitly require `active: true` to get rid of the feature fallbacks + if ($activeAssignment === false) { + return new Map(); + } + + $dispositions = new Map(); + // assign site-specific dispositions + foreach (Disposition::cases() as $disposition) { + $assignment = $siteConfiguration[$disposition->value] ?? null; + if ($this->isActive($assignment)) { + $dispositions[$disposition] = $this->buildDispositionConfiguration( + $assignment, + $siteConfiguration + ); + } + } + // in case there is no site-specific configuration, use the fallbacks as defined by top-level features + if (count($dispositions) === 0) { + foreach ($this->resolveFallbackDispositions() as $fallbackDisposition) { + // skip fallbacks in case the disposition was disabled explicitly (e.g. `enforce: false`) + if (($siteConfiguration[$fallbackDisposition->value] ?? null) !== false) { + $dispositions[$fallbackDisposition] = $this->buildDispositionConfiguration( + true, + $siteConfiguration + ); + } + } + } + return $dispositions; + } + + private function isActive(mixed $assignment): bool + { + return $assignment === true || is_array($assignment); + } + + private function buildDispositionConfiguration( + true|array $assignment, + array $siteConfiguration = [] + ): DispositionConfiguration { + if ($assignment === true) { + // take from top-level configuration + // (`includeResolutions` and `packages` are ignored on purpose) + $inheritDefault = $siteConfiguration['inheritDefault'] ?? true; + $includeResolutions = true; + $reportingUrl = null; + $mutations = $siteConfiguration['mutations'] ?? []; + $packages = []; + } else { + $inheritDefault = $assignment['inheritDefault'] ?? true; + $includeResolutions = $assignment['includeResolutions'] ?? true; + $reportingUrl = $assignment['reportingUrl'] ?? null; + $mutations = $assignment['mutations'] ?? []; + $packages = $assignment['packages'] ?? []; + } + return new DispositionConfiguration( + (bool)$inheritDefault, + (bool)$includeResolutions, + $reportingUrl, + is_array($mutations) ? $mutations : [], + is_array($packages) ? $packages : [], + ); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php b/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php new file mode 100644 index 0000000..206199a --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration; + +/** + * Represents a `csp.yaml` site configuration section for the disposition modes `enforce` and `report. + * + * @internal + */ +readonly class DispositionConfiguration +{ + public bool|string|null $reportingUrl; + + public function __construct( + public bool $inheritDefault, + public bool $includeResolutions, + mixed $reportingUrl, + public array $mutations = [], + /** @var array<string, bool> $packages */ + public array $packages = [], + ) { + $this->reportingUrl = self::normalizeReportingUrl($reportingUrl); + } + + public static function normalizeReportingUrl(mixed $reportingUrl): bool|string|null + { + if ($reportingUrl === null || is_bool($reportingUrl)) { + return $reportingUrl; + } + if (!is_scalar($reportingUrl)) { + return null; + } + if ($reportingUrl === 0 || $reportingUrl === '0') { + return false; + } + if ($reportingUrl === 1 || $reportingUrl === '1') { + return true; + } + return (string)$reportingUrl; + } + + public function resolveEffectivePackages(string ...$packageNames): array + { + if ($this->packages === []) { + return $packageNames; + } + + $effectivePackageNames = []; + if (($this->packages['*'] ?? null) === true) { + $effectivePackageNames = $packageNames; + } + + $dropPackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === false); + $effectivePackageNames = array_diff($effectivePackageNames, $dropPackageNames); + + $includePackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === true); + $effectivePackageNames = [...$effectivePackageNames, ...array_diff($includePackageNames, $effectivePackageNames)]; + + return $effectivePackageNames; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/ConsumableNonce.php b/Classes/Security/ContentSecurityPolicy/ConsumableNonce.php new file mode 100644 index 0000000..d2f9be2 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/ConsumableNonce.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Type\Map; +use TYPO3\CMS\Core\Utility\StringUtility; + +final class ConsumableNonce implements \Countable, \Stringable +{ + private const int MIN_BYTES = 40; + + /** + * @internal use the more specific methods `consumeInline()` or `consumeStatic()` instead + */ + public readonly string $value; + + /** + * @var Map<mixed, int> + */ + private Map $inlineCount; + + /** + * @var Map<mixed, int> + */ + private Map $staticCount; + + public function __construct(?string $value = null) + { + if ($value === null || strlen($value) < self::MIN_BYTES) { + $value = random_bytes(self::MIN_BYTES); + $value = StringUtility::base64urlEncode($value); + } + $this->value = $value; + $this->inlineCount = new Map(); + $this->staticCount = new Map(); + } + + public function __toString(): string + { + return $this->consumeInline(); + } + + public function count(): int + { + return $this->countInline() + $this->countStatic(); + } + + public function countInline(mixed $aspect = null): int + { + if ($aspect === null) { + return array_sum($this->inlineCount->values()); + } + return $this->inlineCount[$aspect] ?? 0; + } + + public function countStatic(mixed $aspect = null): int + { + if ($aspect === null) { + return array_sum($this->staticCount->values()); + } + return $this->staticCount[$aspect] ?? 0; + } + + /** + * @internal consider using the more specific methods `consumeInline()` or `consumeStatic()` instead + */ + public function consume(): string + { + return $this->consumeInline(); + } + + public function consumeInline(mixed $aspect = 'default'): string + { + // `\TYPO3\CMS\Core\Type\Map::offsetGet would` have to be `&offsetGet` for increments to work + $this->inlineCount[$aspect] = ($this->inlineCount[$aspect] ?? 0) + 1; + return $this->value; + } + + public function consumeStatic(mixed $aspect = 'default'): string + { + // `\TYPO3\CMS\Core\Type\Map::offsetGet would` have to be `&offsetGet` for increments to work + $this->staticCount[$aspect] = ($this->staticCount[$aspect] ?? 0) + 1; + return $this->value; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/CoveringInterface.php b/Classes/Security/ContentSecurityPolicy/CoveringInterface.php new file mode 100644 index 0000000..daa459e --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/CoveringInterface.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Interface to determine whether a value is covered by some other value in the scope of CSP, + * e.g. URI `*.example.com` would "cover" URI `https://specific.example.com/path/file.js` + * @internal + */ +interface CoveringInterface +{ + public function covers(CoveringInterface $other): bool; +} diff --git a/Classes/Security/ContentSecurityPolicy/Directive.php b/Classes/Security/ContentSecurityPolicy/Directive.php new file mode 100644 index 0000000..7a0c4c4 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Directive.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy directives + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives + */ +enum Directive: string +{ + case DefaultSrc = 'default-src'; + case BaseUri = 'base-uri'; + case ChildSrc = 'child-src'; + case ConnectSrc = 'connect-src'; + case FontSrc = 'font-src'; + case FormAction = 'form-action'; + case FrameAncestors = 'frame-ancestors'; + case FrameSrc = 'frame-src'; + case ImgSrc = 'img-src'; + case ManifestSrc = 'manifest-src'; + case MediaSrc = 'media-src'; + case ObjectSrc = 'object-src'; + // @deprecated (used for Safari, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types) + case PluginTypes = 'plugin-types'; + case ReportTo = 'report-to'; + // @deprecated (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri) + // but `report-uri` is still used for being compatible other older browsers + case ReportUri = 'report-uri'; + case RequireTrustedTypesFor = 'require-trusted-types-for'; + case Sandbox = 'sandbox'; + case ScriptSrc = 'script-src'; + case ScriptSrcAttr = 'script-src-attr'; + case ScriptSrcElem = 'script-src-elem'; + case StyleSrc = 'style-src'; + case StyleSrcAttr = 'style-src-attr'; + case StyleSrcElem = 'style-src-elem'; + case TrustedTypes = 'trusted-types'; + case UpgradeInsecureRequests = 'upgrade-insecure-requests'; + case WorkerSrc = 'worker-src'; + + private const STAND_ALONE = [ + self::Sandbox, + self::TrustedTypes, + self::UpgradeInsecureRequests, + ]; + + /** + * @return list<self> + */ + public function getAncestors(): array + { + return self::ancestorMap()[$this] ?? []; + } + + /** + * @return list<self> + * @internal + */ + public function getFamily(): array + { + $family = [$this]; + foreach (self::ancestorMap() as $child => $ancestors) { + if (in_array($this, $ancestors, true)) { + $family[] = $child; + } + } + return $family; + } + + /** + * Determines whether a mutation for the current directive would be reasonable. + * For instance, changing the `default-src` or `report-uri` would not qualify. + */ + public function isMutationReasonable(): bool + { + return in_array($this, self::reasonableMutationItems(), true); + } + + /** + * Determines whether the current directive can be used without any values, + * like for instance `sandbox`, `trusted-types` or `upgrade-insecure-requests`. + */ + public function isStandAlone(): bool + { + return in_array($this, self::STAND_ALONE, true); + } + + /** + * @return \WeakMap<self, list<self>> + */ + private static function ancestorMap(): \WeakMap + { + /** @var \WeakMap<self, list<self>> $map temporary, internal \WeakMap */ + $map = new \WeakMap(); + $map[self::ChildSrc] = [self::DefaultSrc]; + $map[self::ConnectSrc] = [self::DefaultSrc]; + $map[self::FontSrc] = [self::DefaultSrc]; + $map[self::FrameSrc] = [self::ChildSrc, self::DefaultSrc]; + $map[self::ImgSrc] = [self::DefaultSrc]; + $map[self::ManifestSrc] = [self::DefaultSrc]; + $map[self::MediaSrc] = [self::DefaultSrc]; + $map[self::ObjectSrc] = [self::DefaultSrc]; + $map[self::ScriptSrc] = [self::DefaultSrc]; + $map[self::ScriptSrcAttr] = [self::ScriptSrc, self::DefaultSrc]; + $map[self::ScriptSrcElem] = [self::ScriptSrc, self::DefaultSrc]; + $map[self::StyleSrc] = [self::DefaultSrc]; + $map[self::StyleSrcAttr] = [self::StyleSrc, self::DefaultSrc]; + $map[self::StyleSrcElem] = [self::StyleSrc, self::DefaultSrc]; + $map[self::WorkerSrc] = [self::ChildSrc, self::ScriptSrc, self::DefaultSrc]; + return $map; + } + + /** + * @return list<self> + */ + private static function reasonableMutationItems(): array + { + return [ + self::ConnectSrc, + self::FontSrc, + self::FrameSrc, + self::ImgSrc, + self::MediaSrc, + self::ScriptSrcElem, + self::StyleSrcElem, + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/DirectiveHashCollection.php b/Classes/Security/ContentSecurityPolicy/DirectiveHashCollection.php new file mode 100644 index 0000000..d7d9d3a --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/DirectiveHashCollection.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Page\ResourceHashCollection; +use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\UriResource; + +/** + * Per-request registry collecting CSP hash values for inline and static assets. + * Hash values are preferred over nonce values as they allow response caching. + * + * @internal + */ +#[Autoconfigure(public: true)] +final class DirectiveHashCollection implements \JsonSerializable +{ + /** + * @var array{ + * inline: array<string, list<HashValue>>, + * resource: array<string, list<HashValue>>, + * uri: array<string, list<HashValue>>, + * generic: array<string, list<HashValue>> + * } + */ + private array $hashValues = [ + 'inline' => [], + 'resource' => [], + 'uri' => [], + 'generic' => [], + ]; + + public function __construct(private readonly ResourceHashCollection $resourceHashCollection) {} + + /** + * Computes a SHA-256 hash of the given inline content and stores it for the directive. + */ + public function addInlineHash(Directive $directive, string $content): void + { + $this->addHashValue($directive, HashValue::hash($content), 'inline'); + } + + /** + * Lazily computes a hash from a local file identified by an EXT: path or absolute path. + * No-op if the file cannot be read. + */ + public function addResourceHash(Directive $directive, string|UriInterface|StaticResourceInterface $resource): void + { + if (is_string($resource)) { + $resource = $this->resourceHashCollection->resolveResourceValue($resource); + if ($resource === null) { + return; + } + } + $hashValue = $this->resourceHashCollection->fetchResourceHash($resource); + if ($hashValue === null) { + return; + } + if ($resource instanceof UriInterface || $resource instanceof UriResource) { + $this->addHashValue($directive, $hashValue, 'uri'); + + } else { + $this->addHashValue($directive, $hashValue, 'resource'); + } + } + + /** + * Stores an already-computed HashValue (e.g. parsed from an `integrity` attribute). + */ + public function addGenericHashValue(Directive $directive, HashValue|string $hashValue): void + { + if (is_string($hashValue)) { + $hashValue = $this->convertHashValue($hashValue); + } + if ($hashValue !== null) { + $this->addHashValue($directive, $hashValue, 'generic'); + } + } + + /** + * Converts all stored hashes into MutationCollection instances that can be applied to the CSP. + * Hash values from all types are merged per directive. + * + * @return list<MutationCollection> + */ + public function asMutationCollections(): array + { + $byDirective = []; + foreach ($this->hashValues as $typeHashValues) { + foreach ($typeHashValues as $directiveName => $hashValues) { + $byDirective[$directiveName] = array_merge($byDirective[$directiveName] ?? [], $hashValues); + } + } + $collections = []; + foreach ($byDirective as $directiveName => $hashValues) { + $directive = Directive::from($directiveName); + // filter out duplicates + $stringHashValues = array_unique(array_map(strval(...), $hashValues)); + $hashValues = array_map(HashValue::fromString(...), $stringHashValues); + // convert to CSP mutation + $mutations = array_map( + static fn(HashValue $hash): Mutation => new Mutation(MutationMode::Extend, $directive, $hash), + $hashValues + ); + $collections[] = new MutationCollection(...$mutations); + } + return $collections; + } + + public function isEmpty(): bool + { + foreach ($this->hashValues as $typeHashValues) { + if ($typeHashValues !== []) { + return false; + } + } + return true; + } + + public function countInlineHashValues(?string $aspect = null): int + { + if ($aspect === null) { + return array_sum( + array_map(count(...), $this->hashValues['inline']) + ); + } + return count($this->hashValues['inline'][$aspect] ?? []); + } + + public function jsonSerialize(): array + { + $serialized = []; + foreach ($this->hashValues as $type => $typeHashValues) { + foreach ($typeHashValues as $directiveName => $hashValues) { + $serialized[$type][$directiveName] = array_map( + static fn(HashValue $hashValue): string => $hashValue->export(), + $hashValues + ); + } + } + return $serialized; + } + + /** + * Restores hash values from a previously serialized (cached) state. + */ + public function updateFromJson(array $data): void + { + foreach ($data as $type => $typeHashValues) { + foreach ($typeHashValues as $directiveName => $hashItems) { + $directive = Directive::from($directiveName); + foreach ($hashItems as $item) { + $this->addHashValue($directive, HashValue::fromString($item), $type); + } + } + } + } + + private function addHashValue(Directive $directive, HashValue $hashValue, string $type): void + { + $this->hashValues[$type][$directive->value] ??= []; + $this->hashValues[$type][$directive->value][] = $hashValue; + } + + private function convertHashValue(string $hashValue): ?HashValue + { + try { + return HashValue::fromString($hashValue); + } catch (\LogicException) { + // hash format not recognized, skip + return null; + } + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Disposition.php b/Classes/Security/ContentSecurityPolicy/Disposition.php new file mode 100644 index 0000000..9f0f535 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Disposition.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy disposition + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP#disposition + */ +enum Disposition: string +{ + case enforce = 'enforce'; + case report = 'report'; + + public function getHttpHeaderName(): string + { + return match ($this) { + self::enforce => 'Content-Security-Policy', + self::report => 'Content-Security-Policy-Report-Only', + }; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Event/BeforePersistingReportEvent.php b/Classes/Security/ContentSecurityPolicy/Event/BeforePersistingReportEvent.php new file mode 100644 index 0000000..acea529 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Event/BeforePersistingReportEvent.php @@ -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\Core\Security\ContentSecurityPolicy\Event; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report; + +/** + * Event that is dispatched before persisting a new + * `\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report`. + */ +final class BeforePersistingReportEvent +{ + /** + * @var Report|null Alternative report, or `null` to skip persistence + */ + public ?Report $report; + + /** + * @param Report $originalReport The original report created by for the CSP violation + * @param ServerRequestInterface $request The HTTP POST request submitting the CSP violation + */ + public function __construct( + public readonly Report $originalReport, + public readonly ServerRequestInterface $request, + ) { + $this->report = $originalReport; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Event/InvestigateMutationsEvent.php b/Classes/Security/ContentSecurityPolicy/Event/InvestigateMutationsEvent.php new file mode 100644 index 0000000..f11e52f --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Event/InvestigateMutationsEvent.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report; + +/** + * Event that is dispatched when reports are handled in the + * CSP backend module to find potential mutations as a resolution. + */ +final class InvestigateMutationsEvent +{ + private bool $stopPropagation = false; + + /** + * @var list<MutationSuggestion> + */ + private array $mutationSuggestions = []; + + public function __construct( + public readonly Policy $policy, + public readonly Report $report, + ) {} + + public function isPropagationStopped(): bool + { + return $this->stopPropagation; + } + + public function stopPropagation(): void + { + $this->stopPropagation = true; + } + + /** + * @return list<MutationSuggestion> + */ + public function getMutationSuggestions(): array + { + return $this->mutationSuggestions; + } + + /** + * Overrides all mutation suggestions (use carefully). + */ + public function setMutationSuggestions(MutationSuggestion ...$mutationSuggestions): void + { + $this->mutationSuggestions = $mutationSuggestions; + } + + public function appendMutationSuggestions(MutationSuggestion ...$mutationSuggestions): void + { + if ($mutationSuggestions === []) { + return; + } + $this->mutationSuggestions += $mutationSuggestions; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php b/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php new file mode 100644 index 0000000..056ebd0 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php @@ -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\Core\Security\ContentSecurityPolicy\Event; + +use Psr\EventDispatcher\StoppableEventInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +final class PolicyMutatedEvent implements StoppableEventInterface +{ + private bool $stopPropagation = false; + private Policy $currentPolicy; + /** + * @var list<MutationCollection> + */ + private array $mutationCollections; + + public function __construct( + public readonly Scope $scope, + public readonly ?ServerRequestInterface $request, + public readonly Policy $defaultPolicy, + Policy $currentPolicy, + MutationCollection ...$mutationCollections + ) { + $this->currentPolicy = $currentPolicy; + $this->mutationCollections = $mutationCollections; + } + + public function isPropagationStopped(): bool + { + return $this->stopPropagation; + } + + public function stopPropagation(): void + { + $this->stopPropagation = true; + } + + public function getCurrentPolicy(): Policy + { + return $this->currentPolicy; + } + + public function setCurrentPolicy(Policy $currentPolicy): void + { + $this->currentPolicy = $currentPolicy; + } + + /** + * @return list<MutationCollection> + */ + public function getMutationCollections(): array + { + return $this->mutationCollections; + } + + public function setMutationCollections(MutationCollection ...$mutationCollections): void + { + $this->mutationCollections = $mutationCollections; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Event/PolicyPreparedEvent.php b/Classes/Security/ContentSecurityPolicy/Event/PolicyPreparedEvent.php new file mode 100644 index 0000000..3146f3a --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Event/PolicyPreparedEvent.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag; + +final readonly class PolicyPreparedEvent +{ + public function __construct( + public PolicyBag $policyBag, + public ServerRequestInterface $request, + public string|ResponseInterface|null $response, + ) {} +} diff --git a/Classes/Security/ContentSecurityPolicy/HashProxy.php b/Classes/Security/ContentSecurityPolicy/HashProxy.php new file mode 100644 index 0000000..2812fff --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/HashProxy.php @@ -0,0 +1,264 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use GuzzleHttp\Promise; +use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; +use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of Content-Security-Policy hash source, acting as proxy on + * files and URLs, to be resolved later when resource contents are actually compiled. + */ +final class HashProxy implements \JsonSerializable, SourceValueInterface +{ + // one week + private const int CACHE_LIFETIME = 604800; + private HashType $type = HashType::sha256; + private ?string $glob = null; + /** + * @var list<string>|null system resource identifiers + */ + private ?array $resources = null; + /** + * @var list<string>|null + */ + private ?array $urls = null; + + /** + * @param string $glob e.g. 'EXT:core/Tests/Unit/Security/ContentSecurityPolicy/Fixtures/*.js' + */ + public static function glob(string $glob): self + { + $pattern = GeneralUtility::getFileAbsFileName($glob); + $files = array_filter(glob($pattern), 'is_file'); + if ($files === []) { + throw new \LogicException('Glob pattern did not resolve any files', 1678615628); + } + $target = new self(); + $target->glob = $glob; + return $target; + } + + /** + * @param string ...$resources system resource identifiers + * @see SystemResourceFactory + */ + public static function resource(string ...$resources): self + { + $target = new self(); + $target->resources = $resources; + return $target; + } + + public static function urls(string ...$urls): self + { + if ($urls === []) { + throw new \LogicException('No URL provided', 1678617132); + } + foreach ($urls as $url) { + if (!self::isValidUrl($url)) { + throw new \LogicException( + sprintf('Value "%s" is not a valid file-like URL', $url), + 1678616641 + ); + } + } + $target = new self(); + $target->urls = $urls; + return $target; + } + + public static function knows(string $value): bool + { + return str_starts_with($value, "'hash-proxy-") && $value[-1] === "'"; + } + + public static function parse(string $value): self + { + if (!self::knows($value)) { + throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1678619052); + } + // extract from `'hash-proxy-[...]'` + $value = substr($value, 12, -1); + $properties = json_decode($value, true, 4, JSON_THROW_ON_ERROR); + if (!empty($properties['glob'])) { + $target = self::glob($properties['glob']); + } elseif (!empty($properties['resources'])) { + $target = self::resource(...$properties['resources']); + } elseif (!empty($properties['urls'])) { + $target = self::urls(...$properties['urls']); + } else { + throw new \LogicException('Cannot parse payload', 1678619395); + } + return $target->withType(HashType::from($properties['type'] ?? '')); + } + + public function withType(HashType $type): self + { + if ($this->type === $type) { + return $this; + } + $target = clone $this; + $target->type = $type; + return $target; + } + + public function isEmpty(): bool + { + return $this->glob === null && $this->resources === null && $this->urls === null; + } + + public function compile(?FrontendInterface $cache = null): ?string + { + if ($this->isEmpty()) { + return null; + } + $hashes = array_map( + fn(string $hash): string => sprintf("'%s-%s'", $this->type->value, $hash), + $this->compileHashValues($cache) + ); + return implode(' ', array_unique($hashes)); + } + + public function serialize(): ?string + { + if ($this->isEmpty()) { + return null; + } + return sprintf("'hash-proxy-%s'", json_encode($this, JSON_UNESCAPED_SLASHES)); + } + + public function jsonSerialize(): mixed + { + return array_filter([ + 'type' => $this->type, + 'glob' => $this->glob, + 'resources' => $this->resources, + 'urls' => $this->urls, + ]); + } + + /** + * Checks whether the given value is a valid URI and has a file-like part + */ + private static function isValidUrl(string $value): bool + { + try { + $uri = new Uri($value); + } catch (\InvalidArgumentException) { + return false; + } + return basename($uri->getPath()) !== ''; + } + + private function compileHashValues(?FrontendInterface $cache): array + { + if ($this->glob !== null) { + $pattern = GeneralUtility::getFileAbsFileName($this->glob); + $files = array_filter(glob($pattern), 'is_file'); + return array_map( + fn(string $file): string => base64_encode( + hash_file($this->type->value, $file, true) + ), + $files + ); + } + if ($this->resources !== null) { + $systemResourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class); + $resources = array_map( + static fn(string $resource): StaticResourceInterface => $systemResourceFactory->createResource($resource), + $this->resources + ); + $resources = array_filter( + $resources, + static fn(StaticResourceInterface $resource): bool => $resource instanceof SystemResourceInterface + ); + return array_map( + fn(SystemResourceInterface $resource): string => base64_encode( + hash($this->type->value, $resource->getContents(), true) + ), + $resources + ); + } + if ($this->urls !== null) { + $hashes = []; + $urls = $this->urls; + // try to resolve hashes from cache + if ($cache !== null) { + $urls = []; + $identifiers = []; + foreach ($this->urls as $url) { + $identifiers[$url] = 'CspHashProxyUrl_' . hash('xxh128', (json_encode([$this->type, $url]))); + $cachedHash = $cache->get($identifiers[$url]); + if ($cachedHash === false) { + // fetch content of URL & generate hash + $urls[] = $url; + } elseif ($cachedHash !== null) { + // only use cached hash of URL that did not fail previously + $hashes[] = $cachedHash; + } + } + } + // process content of remaining URLs + $contents = $this->fetchUrlContents($urls); + foreach ($contents as $url => $content) { + $contentHash = $content !== null ? base64_encode(hash($this->type->value, $content, true)) : null; + if ($contentHash !== null) { + $hashes[] = $contentHash; + } + if ($cache !== null && isset($identifiers[$url])) { + $cache->set($identifiers[$url], $contentHash, ['CspHashProxyUrl'], self::CACHE_LIFETIME); + } + } + return $hashes; + } + return []; + } + + /** + * @param list<string> $urls + * @return array<string, ?string> URL (key) and their response body contents of fulfilled requests (value) + */ + private function fetchUrlContents(array $urls): array + { + $client = GeneralUtility::makeInstance(GuzzleClientFactory::class)->getClient(); + $promises = []; + + foreach ($urls as $url) { + $promises[$url] = $client->requestAsync('GET', $url); + } + + $resolvedPromises = Promise\Utils::settle($promises)->wait(); + return array_map( + static function (array $response): ?string { + if ($response['state'] === 'fulfilled' && $response['value'] instanceof ResponseInterface) { + return (string)$response['value']->getBody(); + } + return null; + }, + $resolvedPromises + ); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/HashType.php b/Classes/Security/ContentSecurityPolicy/HashType.php new file mode 100644 index 0000000..969dcc8 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/HashType.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy hash algorithm type + * see https://www.w3.org/TR/CSP3/#grammardef-hash-algorithm + */ +enum HashType: string +{ + case sha256 = 'sha256'; + case sha384 = 'sha384'; + case sha512 = 'sha512'; + + /** + * @return list<string> + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * @return int length in bytes + */ + public function length(): int + { + return self::lengthMap()[$this]; + } + + private static function lengthMap(): \WeakMap + { + $map = new \WeakMap(); + $map[self::sha256] = 32; + $map[self::sha384] = 48; + $map[self::sha512] = 64; + return $map; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/HashValue.php b/Classes/Security/ContentSecurityPolicy/HashValue.php new file mode 100644 index 0000000..91dd358 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/HashValue.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; + +/** + * Representation of Content-Security-Policy hash source value + * see https://www.w3.org/TR/CSP3/#grammardef-hash-source + */ +final class HashValue implements \Stringable, SourceValueInterface +{ + public readonly string $value; + + public static function hash(string $payload, HashType $type = HashType::sha256): self + { + $value = hash($type->value, $payload, true); + return self::create($value, $type); + } + + public static function create(string $value, HashType $type = HashType::sha256): self + { + return new self($value, $type); + } + + /** + * @param string $value hash value (binary, hex or base64 encoded) + * @param HashType $type + */ + public function __construct(string $value, public readonly HashType $type = HashType::sha256) + { + $length = strlen($value); + if ($length === $this->type->length()) { + $value = base64_encode($value); + } elseif ($length === $this->type->length() * 2 && ctype_xdigit($value)) { + $value = base64_encode(hex2bin($value)); + } elseif (strlen(base64_decode($value) ?: '') !== $this->type->length()) { + throw new \LogicException('Invalid base64 encoded value', 1678620881); + } + $this->value = $value; + } + + public function __toString(): string + { + return sprintf("'%s-%s'", $this->type->value, $this->value); + } + + /** + * Unquoted hash value, to be used like `integrity="sha256-..."` + */ + public function export(): string + { + return $this->type->value . '-' . $this->value; + } + public static function knows(string $value): bool + { + return preg_match(self::createParsingPattern(), $value) === 1; + } + + public static function parse(string $value): self + { + if (preg_match(self::createParsingPattern(), $value, $matches) !== 1) { + throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1678621397); + } + return new self($matches['value'], HashType::from($matches['type'])); + } + + /** + * Parses the unquoted SRI format used in HTML `integrity` attributes (e.g. `sha256-abc123==`), + * as well as the quoted CSP format (e.g. `'sha256-abc123=='`). + */ + public static function fromString(string $value): self + { + $value = trim($value, "'"); + $pattern = sprintf('/^(?P<type>%s)-(?P<value>.+)$/', implode('|', HashType::values())); + if (preg_match($pattern, $value, $matches) !== 1) { + throw new \LogicException(sprintf('Parsing "%s" is not known', $value), 1773012077); + } + return new self($matches['value'], HashType::from($matches['type'])); + } + + private static function createParsingPattern(): string + { + $types = array_map(static fn(HashType $type): string => $type->value, HashType::cases()); + return sprintf("/^'(?P<type>%s)-(?P<value>.+)'$/", implode('|', $types)); + } + + public function compile(?FrontendInterface $cache = null): string + { + return (string)$this; + } + + public function serialize(): string + { + return (string)$this; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Middleware/PolicyBag.php b/Classes/Security/ContentSecurityPolicy/Middleware/PolicyBag.php new file mode 100644 index 0000000..6ee7de4 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Middleware/PolicyBag.php @@ -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\Core\Security\ContentSecurityPolicy\Middleware; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\Behavior; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Type\Map; + +/** + * @internal + */ +final class PolicyBag +{ + private Map $policyMap; + + public function __construct( + public readonly Scope $scope, + public readonly Map $dispositionMap, + public readonly Behavior $behavior, + public readonly ConsumableNonce $nonce, + public readonly DirectiveHashCollection $directiveHashCollection, + ) { + $this->policyMap = new Map(); + } + + public function hasPolicies(): bool + { + return count($this->policyMap) !== 0; + } + + public function hasPolicy(Disposition $disposition): bool + { + return isset($this->policyMap[$disposition]); + } + + public function getPolicy(Disposition $disposition): Policy + { + return $this->policyMap[$disposition]; + } + + public function setPolicy(Disposition $disposition, Policy $policy): void + { + if (isset($this->policyMap[$disposition])) { + throw new \LogicException('Policy already set', 1646348401); + } + $this->policyMap[$disposition] = $policy; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Middleware/ResponseService.php b/Classes/Security/ContentSecurityPolicy/Middleware/ResponseService.php new file mode 100644 index 0000000..4f32f39 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Middleware/ResponseService.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce; + +/** + * @internal + */ +final readonly class ResponseService +{ + public function __construct(private StreamFactoryInterface $streamFactory) {} + + public function dropNonceFromHtmlResponse(ResponseInterface $response, ConsumableNonce $nonce): ResponseInterface + { + if (!str_starts_with($response->getHeaderLine('Content-Type'), 'text/html')) { + return $response; + } + $responseBody = $response->getBody(); + if (!$responseBody->isReadable() || !$responseBody->isWritable() || $responseBody->getSize() === 0) { + return $response; + } + $stream = $this->streamFactory->createStream($this->dropNonceFromHtml((string)$responseBody, $nonce)); + return $response->withBody($stream); + } + + public function dropNonceFromHtml(string $html, ConsumableNonce $nonce): string + { + $noncePattern = preg_quote($nonce->value, '/'); + return preg_replace( + '/\s*nonce="' . $noncePattern . '"|' . $noncePattern . '/', + '', + $html + ); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/ModelService.php b/Classes/Security/ContentSecurityPolicy/ModelService.php new file mode 100644 index 0000000..1a0f505 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/ModelService.php @@ -0,0 +1,171 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; + +/** + * Helpers for working with Content-Security-Policy models. + * + * @internal + */ +readonly class ModelService +{ + private const array SOURCE_PARSING_PRIORITIES = [ + HashProxy::class => 50, + HashValue::class => 50, + ]; + + /** + * @param ?FrontendInterface $cache to be used for storing compiled CSP aspects (disabled in install tool) + */ + public function __construct(private ?FrontendInterface $cache = null) {} + + public function buildMutationSuggestionFromArray(array $array): MutationSuggestion + { + return new MutationSuggestion( + $this->buildMutationCollectionFromArray($array['collection'] ?? []), + (string)($array['identifier'] ?? ''), + isset($array['priority']) ? (int)$array['priority'] : null, + $array['label'] ?? null + ); + } + + public function buildMutationCollectionFromArray(array $array): MutationCollection + { + $mutations = array_map( + [$this, 'buildMutationFromArray'], + $array['mutations'] ?? [] + ); + return new MutationCollection(...$mutations); + } + + public function buildMutationFromArray(array $array): Mutation + { + return new Mutation( + MutationMode::from($array['mode'] ?? ''), + Directive::from($array['directive'] ?? ''), + ...$this->buildSourcesFromItems(...($array['sources'] ?? [])) + ); + } + + public function buildSourcesFromItems(string ...$items): array + { + $sources = []; + foreach ($items as $item) { + $source = $this->buildSourceFromString($item); + if ($source === null) { + throw new \InvalidArgumentException( + sprintf('Could not convert source item "%s"', $item), + 1677261214 + ); + } + $sources[] = $source; + } + return $sources; + } + + public function buildSourceFromString(string $string): ?SourceInterface + { + if (str_starts_with($string, "'nonce-") && $string[-1] === "'") { + // use a proxy instead of a real Nonce instance + return SourceKeyword::nonceProxy; + } + try { + if ($string[0] === "'" && $string[-1] === "'") { + return SourceKeyword::from(substr($string, 1, -1)); + } + if ($string[-1] === ':') { + return SourceScheme::from(substr($string, 0, -1)); + } + return new UriValue($string); + } catch (\InvalidArgumentException|\ValueError) { + // no handling here + } + /** @var SourceValueInterface $sourceInterface */ + foreach ($this->resolvePrioritizedSourceInterfaces() as $sourceInterface) { + if ($sourceInterface::knows($string)) { + return $sourceInterface::parse($string); + } + } + return new RawValue($string); + } + + // @todo use SourceCollection instead? + public function serializeSources(SourceInterface ...$sources): array + { + $serialized = []; + foreach ($sources as $source) { + if ($source instanceof SourceKeyword && $source->vetoes()) { + $serialized = []; + } + $serialized[] = $this->serializeSource($source); + } + return $serialized; + } + + public function compileSources(ConsumableNonce $nonce, SourceCollection $collection): array + { + $compiled = []; + foreach ($collection->sources as $source) { + if ($source instanceof SourceKeyword && $source->vetoes()) { + $compiled = []; + } + if ($source instanceof SourceValueInterface) { + $compiled[] = $source->compile($this->cache); + } else { + $compiled[] = $this->serializeSource($source, $nonce); + } + } + return array_filter($compiled); + } + + /** + * @param ConsumableNonce|null $nonce used to substitute `SourceKeyword::nonceProxy` items during compilation + */ + public function serializeSource(SourceInterface $source, ?ConsumableNonce $nonce = null): string + { + if ($source === SourceKeyword::nonceProxy && $nonce !== null) { + return $nonce->count() > 0 ? "'nonce-" . $nonce->value . "'" : ''; + } + if ($source instanceof SourceKeyword) { + return "'" . $source->value . "'"; + } + if ($source instanceof SourceScheme) { + return $source->value . ':'; + } + if ($source instanceof SourceValueInterface) { + return $source->serialize(); + } + if ($source instanceof \Stringable) { + return (string)$source; + } + return ''; + } + + /** + * Resolves reverse sorted `SourceInterface` classes (higher priorities first). + * @return list<class-string<SourceValueInterface>> + */ + private function resolvePrioritizedSourceInterfaces(): array + { + $interfaces = self::SOURCE_PARSING_PRIORITIES; + arsort($interfaces); + return array_keys($interfaces); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Mutation.php b/Classes/Security/ContentSecurityPolicy/Mutation.php new file mode 100644 index 0000000..399b70e --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Mutation.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a Content-Security-Policy mutation, changing an existing policy directive. + */ +class Mutation implements \JsonSerializable +{ + /** + * @var list<SourceInterface> + */ + public readonly array $sources; + + public function __construct( + public readonly MutationMode $mode, + public readonly Directive $directive, + SourceInterface ...$sources, + ) { + // @todo continue with source collecting internally? + if ($sources !== [] && $this->mode === MutationMode::Remove) { + throw new \LogicException( + 'Cannot remove and declare sources at the same time', + 1677244893 + ); + } + $this->sources = $sources; + } + + public function jsonSerialize(): array + { + $service = GeneralUtility::makeInstance(ModelService::class); + return [ + 'mode' => $this->mode, + 'directive' => $this->directive, + 'sources' => $service->serializeSources(...$this->sources), + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationCollection.php b/Classes/Security/ContentSecurityPolicy/MutationCollection.php new file mode 100644 index 0000000..26f8fe0 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationCollection.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * A collection of mutations (sic!). + */ +final class MutationCollection implements \JsonSerializable +{ + /** + * @var list<Mutation> + */ + public readonly array $mutations; + + public function __construct(Mutation ...$mutations) + { + $this->mutations = $mutations; + } + + public function jsonSerialize(): array + { + return [ + 'mutations' => $this->mutations, + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationMode.php b/Classes/Security/ContentSecurityPolicy/MutationMode.php new file mode 100644 index 0000000..5b2ee56 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationMode.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * The mode used in mutations (sic!). + */ +enum MutationMode: string +{ + /** + * sets (overrides) a directive completely + */ + case Set = 'set'; + + /** + * just appends to a given directive + */ + case Append = 'append'; + + /** + * inherits once from the corresponding ancestor chain + */ + case InheritOnce = 'inherit-once'; + + /** + * inherits again from the corresponding ancestor chain and merges existing sources + */ + case InheritAgain = 'inherit-again'; + + /** + * shortcut for `InheritOnce` and `Append` + */ + case Extend = 'extend'; + + /** + * reduces a directive by a given aspect + */ + case Reduce = 'reduce'; + + /** + * removes a directive completely + */ + case Remove = 'remove'; +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationOrigin.php b/Classes/Security/ContentSecurityPolicy/MutationOrigin.php new file mode 100644 index 0000000..3995c0d --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationOrigin.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Representation of a mutation origin, to keep track of resolutions + * to the Content-Security-Policy and how to revert again later. + */ +readonly class MutationOrigin +{ + public function __construct( + public MutationOriginType $type, + public string $value + ) {} +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationOriginType.php b/Classes/Security/ContentSecurityPolicy/MutationOriginType.php new file mode 100644 index 0000000..9a73248 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationOriginType.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +enum MutationOriginType: string +{ + case site = 'site'; + case package = 'package'; + case resolution = 'resolution'; +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationRepository.php b/Classes/Security/ContentSecurityPolicy/MutationRepository.php new file mode 100644 index 0000000..97835c1 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationRepository.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\CspConfigurationFactory; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ResolutionRepository; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Type\Map; + +/** + * @internal + */ +final class MutationRepository +{ + /** + * @var Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>> + */ + private ?Map $resolvedMutations; + + /** + * @param Map<Scope, Map<MutationOrigin, MutationCollection>> $staticMutations + * (from DI, declared in `Configuration/ContentSecurityPolicies.php`) + */ + public function __construct( + #[Autowire(service: 'content.security.policies')] + private readonly Map $staticMutations, + private readonly SiteFinder $siteFinder, + private readonly ModelService $modelService, + private readonly ScopeRepository $scopeRepository, + private readonly ResolutionRepository $resolutionRepository, + private readonly CspConfigurationFactory $cspConfigurationFactory, + ) { + $this->resolvedMutations = null; + } + + /** + * @return Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>> + */ + public function findAll(): Map + { + if ($this->resolvedMutations === null) { + $this->resolveMutations(); + } + return $this->resolvedMutations; + } + + /** + * @return Map<MutationOrigin, MutationCollection> + */ + public function findByScope(Scope $scope, Disposition $disposition = Disposition::enforce): Map + { + if ($this->resolvedMutations === null) { + $this->resolveMutations(); + } + $scope = $this->reduceScope($scope); + return $this->resolvedMutations[$scope][$disposition] ?? new Map(); + } + + private function resolveMutations(): void + { + if ($this->resolvedMutations !== null) { + return; + } + + $this->resolvedMutations = new Map(); + $allScopes = $this->scopeRepository->findAll(); + // fetch resolutions from the database & assign them later to the resolved mutations map + $resolutions = new Map(); + foreach ($this->resolutionRepository->findAll() as $resolution) { + // only for existing scopes (e.g. ignore scopes for sites, that are not existing anymore) + if (in_array($resolution->scope, $allScopes, true)) { + $mutationOrigin = new MutationOrigin(MutationOriginType::resolution, $resolution->summary); + $scopedTarget = $this->provideScopeInMap($resolution->scope, $resolutions); + $scopedTarget[$mutationOrigin] = $resolution->mutationCollection; + } + } + // assign generic backend and frontend scopes + foreach ([Scope::backend(), Scope::frontend()] as $scope) { + $scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations); + $dispositions = $scope === Scope::frontend() + ? $this->cspConfigurationFactory->resolveFallbackDispositions() + : [Disposition::enforce]; + foreach ($dispositions as $disposition) { + $disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget); + if (isset($this->staticMutations[$scope])) { + $disposedTarget->assign($this->staticMutations[$scope]); + } + if (isset($resolutions[$scope])) { + $disposedTarget->assign($resolutions[$scope]); + } + } + } + // fetch and assign site-specific mutations + foreach ($this->scopeRepository->findAllFrontendSites() as $scope) { + $site = $this->resolveSite($scope); + $scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations); + // fetch site-specific `enforce` and/or `report` disposition configuration + $dispositionMap = $this->cspConfigurationFactory->buildDispositionMap( + $site->getConfiguration()['contentSecurityPolicies'] ?? [] + ); + /** + * @var Disposition $disposition + * @var DispositionConfiguration $dispositionConfiguration + */ + foreach ($dispositionMap as $disposition => $dispositionConfiguration) { + $disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget); + $disposedTarget->assign($this->resolveStaticMutations($scope, $dispositionConfiguration)); + if ($dispositionConfiguration->includeResolutions && isset($resolutions[$scope])) { + $disposedTarget->assign($resolutions[$scope]); + } + $mutationCollection = $this->resolveFrontendSiteMutationCollection($dispositionConfiguration); + if ($mutationCollection !== null) { + $mutationOrigin = new MutationOrigin(MutationOriginType::site, $scope->siteIdentifier); + $disposedTarget[$mutationOrigin] = $mutationCollection; + } + } + } + } + + /** + * Resolves site-specific static mutations, applies `inheritDefault` configuration + * and filters generic static mutations based on the `packages` configuration. + * + * @return Map<MutationOrigin, MutationCollection> + */ + private function resolveStaticMutations(Scope $scope, DispositionConfiguration $dispositionConfiguration): Map + { + $target = new Map(); + $scope = $this->reduceScope($scope); + + if ($dispositionConfiguration->inheritDefault && isset($this->staticMutations[Scope::frontend()])) { + // mutations from `ContentSecurityPolicies.php` for generic frontend scope + $target->assign($this->staticMutations[Scope::frontend()]); + } + // mutations from `ContentSecurityPolicies.php` for a specific site identifier + if (isset($this->staticMutations[$scope])) { + $target->assign($this->staticMutations[$scope]); + } + + // filter mutation origins by effective package names + $packageOrigins = array_filter( + $target->keys(), + static fn(MutationOrigin $origin) => $origin->type === MutationOriginType::package + ); + $packageNames = array_map(static fn(MutationOrigin $origin) => $origin->value, $packageOrigins); + $effectivePackageNames = $dispositionConfiguration->resolveEffectivePackages(...$packageNames); + foreach ($packageOrigins as $mutationOrigin) { + if (!in_array($mutationOrigin->value, $effectivePackageNames, true)) { + unset($target[$mutationOrigin]); + } + } + return $target; + } + + private function resolveFrontendSiteMutationCollection(DispositionConfiguration $dispositionConfiguration): ?MutationCollection + { + if ($dispositionConfiguration->mutations === []) { + return null; + } + $mutations = array_map( + fn(array $array) => $this->modelService->buildMutationFromArray($array), + $dispositionConfiguration->mutations + ); + return new MutationCollection(...$mutations); + } + + private function provideDispositionInMap(Disposition $disposition, Map $map): Map + { + if (!isset($map[$disposition])) { + $map[$disposition] = new Map(); + } + return $map[$disposition]; + } + + /** + * @return Map<MutationOrigin, MutationCollection> + */ + private function provideScopeInMap(Scope $scope, Map $map): Map + { + $reducedScope = $this->reduceScope($scope); + if (!isset($map[$reducedScope])) { + $map[$reducedScope] = new Map(); + } + return $map[$reducedScope]; + } + + /** + * Returns a reduce representation of the current object. + * In case a `Site` object was given, it will be reduced to just contain the site identifier. + */ + private function reduceScope(Scope $scope): Scope + { + if ($scope->isFrontendSite()) { + return Scope::frontendSiteIdentifier($scope->siteIdentifier); + } + return $scope; + } + + private function resolveSite(Scope $scope): Site + { + return $scope->site ?? $this->siteFinder->getSiteByIdentifier($scope->siteIdentifier); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/MutationSuggestion.php b/Classes/Security/ContentSecurityPolicy/MutationSuggestion.php new file mode 100644 index 0000000..c15ff62 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/MutationSuggestion.php @@ -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\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a mutation suggested by a handler. + * The identifier is used to keep track of the original handling class/aspect. + * Higher priorities take precedence when being visualized in the backend module. + */ +final readonly class MutationSuggestion implements \JsonSerializable +{ + /** + * @param string $identifier a unique identifier (e.g. `Vendor\Extension\MyHandler@knownJavaScript`) + * @param ?int $priority an integer in the range of [0; 10] + * @param ?string $label to be shown in backend module + */ + public function __construct( + public MutationCollection $collection, + public string $identifier, + public ?int $priority = null, + public ?string $label = null, + ) { + if ($this->priority !== null && ($this->priority < 0 || $this->priority > 10)) { + throw new \LogicException('Priority must be in range [0; 10]', 1679601774); + } + if ($this->identifier === '') { + throw new \LogicException('Identifer cannot be empty', 1679601795); + } + } + + public function hash(): string + { + return sha1(json_encode($this->getHashProperties())); + } + + public function hmac(): string + { + $hashService = GeneralUtility::makeInstance(HashService::class); + return $hashService->hmac(json_encode($this->getHashProperties()), self::class); + } + + public function jsonSerialize(): array + { + $properties = [ + 'collection' => $this->collection, + 'identifier' => $this->identifier, + 'priority' => $this->priority, + 'label' => $this->label, + ]; + $hashService = GeneralUtility::makeInstance(HashService::class); + $hashContent = json_encode($this->getHashProperties()); + $properties['hash'] = sha1($hashContent); + $properties['hmac'] = $hashService->hmac($hashContent, self::class); + return $properties; + } + + private function getHashProperties(): array + { + return [ + 'collection' => $this->collection, + 'identifier' => $this->identifier, + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Policy.php b/Classes/Security/ContentSecurityPolicy/Policy.php new file mode 100644 index 0000000..b93c762 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Policy.php @@ -0,0 +1,368 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag; +use TYPO3\CMS\Core\Type\Map; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of the whole Content-Security-Policy + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + * + * @internal This implementation still might be adjusted + */ +class Policy +{ + /** + * @var Map<Directive, SourceCollection> + */ + protected Map $directives; + + /** + * @param SourceCollection|SourceInterface ...$sources (optional) default-src sources + */ + public function __construct(SourceCollection|SourceInterface ...$sources) + { + $this->directives = new Map(); + $directive = Directive::DefaultSrc; + $collection = $this->asMergedSourceCollection(...$sources); + $collection = $this->purgeNonApplicableSources($directive, $collection); + if (!$collection->isEmpty()) { + $this->directives[$directive] = $collection; + } + } + + public function isEmpty(): bool + { + return count($this->directives) === 0; + } + + /** + * Applies mutations/changes to the current policy. + */ + public function mutate(MutationCollection|Mutation ...$mutations): self + { + $self = $this; + foreach ($mutations as $mutation) { + if ($mutation instanceof MutationCollection) { + $self = $self->mutate(...$mutation->mutations); + } elseif ($mutation->mode === MutationMode::Set) { + $self = $self->set($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::Append) { + $self = $self->append($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::InheritOnce) { + $self = $self->inherit($mutation->directive); + } elseif ($mutation->mode === MutationMode::InheritAgain) { + $self = $self->inherit($mutation->directive, true); + } elseif ($mutation->mode === MutationMode::Extend) { + $self = $self->extend($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::Reduce) { + $self = $self->reduce($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::Remove) { + $self = $self->remove($mutation->directive); + } + } + return $self; + } + + /** + * Sets (overrides) the 'default-src' directive, which is also the fall-back for other more specific directives. + */ + public function default(SourceCollection|SourceInterface ...$sources): self + { + return $this->set(Directive::DefaultSrc, ...$sources); + } + + /** + * Appends to an existing directive, or a new source collection in case it was empty. + */ + public function append(Directive $directive, SourceCollection|SourceInterface ...$sources): self + { + $collection = $this->asMergedSourceCollection(...$sources); + $collection = $this->purgeNonApplicableSources($directive, $collection); + if ($collection->isEmpty() && !$directive->isStandAlone()) { + return $this; + } + $targetCollection = $this->asMergedSourceCollection(...array_filter([ + $this->directives[$directive] ?? null, + $collection, + ])); + return $this->changeDirectiveSources($directive, $targetCollection); + } + + /** + * Inherits the current source collection of the closest non-empty ancestor in the chain. + * + * @param bool $again whether to inherit again and merge with the existing source collection + */ + public function inherit(Directive $directive, bool $again = false): self + { + $currentSources = $this->directives[$directive] ?? null; + if ($again || $currentSources === null) { + foreach ($directive->getAncestors() as $ancestorDirective) { + if ($this->has($ancestorDirective)) { + $ancestorCollection = $this->directives[$ancestorDirective]; + break; + } + } + } + $targetCollection = $this->asMergedSourceCollection(...array_filter([ + $ancestorCollection ?? null, + $currentSources, + ])); + return $this->changeDirectiveSources($directive, $targetCollection); + } + + /** + * Extends a specific directive, either by appending sources or by inheriting from an ancestor directive. + */ + public function extend(Directive $directive, SourceCollection|SourceInterface ...$sources): self + { + return $this->inherit($directive)->append($directive, ...$sources); + } + + public function reduce(Directive $directive, SourceCollection|SourceInterface ...$sources): self + { + if (!$this->has($directive)) { + return $this; + } + $collection = $this->asMergedSourceCollection(...$sources); + $targetCollection = $this->directives[$directive]->exclude($collection); + return $this->changeDirectiveSources($directive, $targetCollection); + } + + /** + * Sets (overrides) a specific directive. + */ + public function set(Directive $directive, SourceCollection|SourceInterface ...$sources): self + { + $collection = $this->asMergedSourceCollection(...$sources); + $collection = $this->purgeNonApplicableSources($directive, $collection); + return $this->changeDirectiveSources($directive, $collection); + } + + /** + * Removes a specific directive. + */ + public function remove(Directive $directive): self + { + if (!$this->has($directive)) { + return $this; + } + $target = clone $this; + unset($target->directives[$directive]); + return $target; + } + + /** + * Sets the 'report-uri' directive and appends 'report-sample' to existing & applicable directives. + */ + public function report(UriValue $reportUri): self + { + $target = $this->set(Directive::ReportUri, $reportUri); + $reportSample = SourceKeyword::reportSample; + foreach ($target->directives as $directive => $collection) { + if ($reportSample->isApplicable($directive)) { + $target->directives[$directive] = $collection->with($reportSample); + } + } + return $target; + } + + public function has(Directive $directive): bool + { + return isset($this->directives[$directive]); + } + + public function get(Directive $directive): ?SourceCollection + { + return $this->directives[$directive] ?? null; + } + + /** + * Prepares the policy for finally being serialized and issued as HTTP header. + * This step aims to optimize several combinations, or adjusts directives when 'strict-dynamic' is used. + */ + public function prepare(PolicyBag $policyBag): self + { + $hashCollection = $policyBag->directiveHashCollection; + $useHash = $policyBag->behavior->useHash; + $useNonce = $policyBag->behavior->useNonce; + + // Apply collected asset hashes as mutations when hashes are enabled + $target = ($useHash && !$hashCollection->isEmpty()) + ? $this->mutate(...$hashCollection->asMutationCollections()) + : $this; + + $nonceProxyDirectives = SourceKeyword::nonceProxy->getApplicableDirectives(); + + $directives = clone $target->directives; + $comparator = [$this, 'compareSources']; + /** + * @var Directive $directive + * @var SourceCollection $collection + */ + foreach ($directives as $directive => $collection) { + if (!in_array($directive, $nonceProxyDirectives, true)) { + continue; + } + $containsNonceProxy = $collection->contains(SourceKeyword::nonceProxy); + if ($useNonce === false && $containsNonceProxy) { + $directives[$directive] = $collection->without(SourceKeyword::nonceProxy); + } + } + foreach ($directives as $directive => $collection) { + foreach ($directive->getAncestors() as $ancestorDirective) { + $ancestorCollection = $directives[$ancestorDirective] ?? null; + if ($ancestorCollection !== null + && array_udiff($collection->sources, $ancestorCollection->sources, $comparator) === [] + && array_udiff($ancestorCollection->sources, $collection->sources, $comparator) === [] + ) { + unset($directives[$directive]); + continue 2; + } + } + } + foreach ($directives as $directive => $collection) { + // applies implicit changes to sources in case 'strict-dynamic' is used for applicable directives + if ($collection->contains(SourceKeyword::strictDynamic) && SourceKeyword::strictDynamic->isApplicable($directive)) { + if ($useNonce === false) { + $directives[$directive] = $collection->without(SourceKeyword::strictDynamic, SourceKeyword::nonceProxy); + } else { + // @todo strict-dynamic either needs hashes or nonces + $directives[$directive] = SourceKeyword::strictDynamic->applySourceImplications($collection) ?? $collection; + } + } + } + $result = clone $target; + $result->directives = $directives; + return $result; + } + + /** + * Compiles this policy and returns the serialized representation to be used as HTTP header value. + * + * @param ?FrontendInterface $cache to be used for storing compiled CSP aspects (disabled in install tool) + */ + public function compile(PolicyBag $policyBag, ?FrontendInterface $cache = null): string + { + $nonce = $policyBag->nonce; + $policyParts = []; + $service = GeneralUtility::makeInstance(ModelService::class, $cache); + foreach ($this->prepare($policyBag)->directives as $directive => $collection) { + $directiveParts = $service->compileSources($nonce, $collection); + if ($directiveParts !== [] || $directive->isStandAlone()) { + array_unshift($directiveParts, $directive->value); + $policyParts[] = implode(' ', $directiveParts); + } + } + return implode('; ', $policyParts); + } + + /** + * Determines whether all sources are contained (in terms of instances and values, but without inference). + */ + public function containsDirective(Directive $directive, SourceCollection|SourceInterface ...$sources): bool + { + $sources = $this->asMergedSourceCollection(...$sources); + return (bool)$this->directives[$directive]?->contains(...$sources->sources); + } + + /** + * Determines whether all sources are covered (in terms of CSP inference, considering wildcards and similar). + */ + public function coversDirective(Directive $directive, SourceCollection|SourceInterface ...$sources): bool + { + $sources = $this->asMergedSourceCollection(...$sources); + return (bool)$this->directives[$directive]?->covers(...$sources->sources); + } + + /** + * Whether the current policy contains another policy (in terms of instances and values, but without inference). + */ + public function contains(Policy $other): bool + { + if ($other->isEmpty()) { + return false; + } + foreach ($other->directives as $directive => $collection) { + if (!$this->containsDirective($directive, $collection)) { + return false; + } + } + return true; + } + + /** + * Whether the current policy covers another policy (in terms of CSP inference, considering wildcards and similar). + */ + public function covers(Policy $other): bool + { + if ($other->isEmpty()) { + return false; + } + foreach ($other->directives as $directive => $collection) { + if (!$this->coversDirective($directive, $collection)) { + return false; + } + } + return true; + } + + protected function compareSources(SourceInterface $a, SourceInterface $b): int + { + $service = GeneralUtility::makeInstance(ModelService::class); + return $service->serializeSource($a) <=> $service->serializeSource($b); + } + + protected function changeDirectiveSources(Directive $directive, SourceCollection $sources): self + { + $isAlreadyEmpty = empty($this->directives[$directive]) || $this->directives[$directive]->isEmpty(); + if ($isAlreadyEmpty && $sources->isEmpty() && !$directive->isStandAlone()) { + return $this; + } + $target = clone $this; + $target->directives[$directive] = $sources; + return $target; + } + + protected function asMergedSourceCollection(SourceCollection|SourceInterface ...$subjects): SourceCollection + { + $collections = array_filter($subjects, static fn($source) => $source instanceof SourceCollection); + $sources = array_filter($subjects, static fn($source) => !$source instanceof SourceCollection); + if ($sources !== []) { + $collections[] = new SourceCollection(...$sources); + } + $target = new SourceCollection(); + foreach ($collections as $collection) { + $target = $target->merge($collection); + } + return $target; + } + + protected function purgeNonApplicableSources(Directive $directive, SourceCollection $collection): SourceCollection + { + $sources = array_filter( + $collection->sources, + static fn(SourceInterface $source): bool => $source instanceof SourceKeyword ? $source->isApplicable($directive) : true + ); + return new SourceCollection(...$sources); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/PolicyProvider.php b/Classes/Security/ContentSecurityPolicy/PolicyProvider.php new file mode 100644 index 0000000..5879fca --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/PolicyProvider.php @@ -0,0 +1,183 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Middleware\AbstractContentSecurityPolicyReporter; +use TYPO3\CMS\Core\Routing\BackendEntryPointResolver; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyPreparedEvent; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Site\SiteFinder; + +/** + * Provide a Content-Security-Policy representation for a given scope (e.g. backend, frontend, frontend.my-site). + * + * @internal + */ +#[Autoconfigure(public: true)] +final readonly class PolicyProvider +{ + private const string REPORTING_URI = '@http-reporting'; + + public function __construct( + private RequestId $requestId, + private SiteFinder $siteFinder, + private PolicyRegistry $policyRegistry, + private EventDispatcherInterface $eventDispatcher, + private MutationRepository $mutationRepository, + private BackendEntryPointResolver $backendEntryPointResolver, + private HashService $hashService, + ) {} + + public function prepare( + PolicyBag $policyBag, + ServerRequestInterface $request, + string|ResponseInterface|null $response, + ): void { + foreach ($policyBag->dispositionMap as $disposition => $configuration) { + if ($policyBag->hasPolicy($disposition)) { + continue; + } + $policy = $this->provideFor($policyBag->scope, $disposition, $request); + if (!$policy->isEmpty()) { + $reportingUrl = $this->getReportingUrlFor( + $policyBag->scope, + $request, + $policyBag->dispositionMap[$disposition] + ); + if ($reportingUrl !== null) { + $policy = $policy->report(UriValue::fromUri($reportingUrl)); + } + } + $policyBag->setPolicy($disposition, $policy); + } + $this->eventDispatcher->dispatch( + new PolicyPreparedEvent($policyBag, $request, $response) + ); + } + + /** + * Provides the complete, dynamically mutated policy to be used in HTTP responses. + */ + public function provideFor( + Scope $scope, + Disposition $disposition = Disposition::enforce, + ?ServerRequestInterface $request = null, + ): Policy { + // @todo add policy cache per scope + $defaultPolicy = new Policy(); + $mutationCollections = iterator_to_array( + $this->mutationRepository->findByScope($scope, $disposition), + false + ); + // add temporary(!) mutations that were collected during processing this request + if ($this->policyRegistry->hasMutationCollections()) { + $mutationCollections = array_merge( + $mutationCollections, + $this->policyRegistry->getMutationCollections() + ); + } + // apply all mutations to current policy + $currentPolicy = $defaultPolicy->mutate(...$mutationCollections); + // allow other components to modify the current policy individually via PSR-14 event + $event = new PolicyMutatedEvent($scope, $request, $defaultPolicy, $currentPolicy, ...$mutationCollections); + $this->eventDispatcher->dispatch($event); + return $event->getCurrentPolicy(); + } + + public function getReportingUrlFor( + Scope $scope, + ServerRequestInterface $request, + ?DispositionConfiguration $dispositionConfiguration = null, + ): ?UriInterface { + $value = $dispositionConfiguration->reportingUrl + ?? DispositionConfiguration::normalizeReportingUrl( + $GLOBALS['TYPO3_CONF_VARS'][$scope->type->abbreviate()]['contentSecurityPolicyReportingUrl'] ?? null + ); + // using the local reporting URI is explicitly disabled + if ($value === false) { + return null; + } + if (is_string($value) && $value !== '') { + try { + return new Uri($value); + } catch (\InvalidArgumentException) { + return null; + } + } + $requestTime = (string)$this->requestId->microtime; + $requestHash = $this->hashService->hmac($requestTime, AbstractContentSecurityPolicyReporter::class); + $uriBase = $this->getDefaultReportingUriBase($scope, $request); + return $uriBase->withQuery( + $uriBase->getQuery() . '&requestTime=' . $requestTime . '&requestHash=' . $requestHash + ); + } + + /** + * Returns the URI base, for better partitioning it should be extended by `&requestTime=...` + */ + public function getDefaultReportingUriBase(Scope $scope, ServerRequestInterface $request, bool $absolute = true): UriInterface + { + $normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request); + // resolve URI from current site language or site default language in frontend scope + if ($scope->isFrontendSite()) { + $site = $this->resolveSite($scope); + $siteLanguage = $request->getAttribute('siteLanguage'); + $siteLanguage = $siteLanguage instanceof SiteLanguage ? $siteLanguage : $site->getDefaultLanguage(); + $uri = $siteLanguage->getBase(); + $uri = $uri->withPath(rtrim($uri->getPath(), '/') . '/'); + // otherwise fall back to current request URI + } else { + $uri = new Uri($normalizedParams->getSitePath()); + } + // add backend entryPoint route prefix in backend scope + if ($scope->type->isBackend()) { + $uri = $this->backendEntryPointResolver->getUriFromRequest($request); + } + // prefix current require scheme, host, port in case it's not given + if ($absolute && ($uri->getScheme() === '' || $uri->getHost() === '')) { + $current = new Uri($normalizedParams->getSiteUrl()); + $uri = $uri + ->withScheme($current->getScheme()) + ->withHost($current->getHost()) + ->withPort($current->getPort()); + } elseif (!$absolute && ($uri->getScheme() !== '' || $uri->getHost() !== '')) { + $uri = $uri->withScheme('')->withHost('')->withPort(null); + } + // `/en/@http-reporting?csp=report` (relative) + // `https://ip12.anyhost.it:8443/en/@http-reporting?csp=report` (absolute) + return $uri->withPath($uri->getPath() . self::REPORTING_URI)->withQuery('csp=report'); + } + + private function resolveSite(Scope $scope): Site + { + return $scope->site ?? $this->siteFinder->getSiteByIdentifier($scope->siteIdentifier); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/PolicyRegistry.php b/Classes/Security/ContentSecurityPolicy/PolicyRegistry.php new file mode 100644 index 0000000..d1b806d --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/PolicyRegistry.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; + +/** + * A shared service registry to hold additional adjustments that were collected during + * processing the current request. For instance, it would be used to temporarily(!) allow + * a particular CSP URL/aspect. + * + * @internal + */ +#[Autoconfigure(public: true)] +final class PolicyRegistry +{ + /** + * @var list<MutationCollection> + */ + private array $mutationCollections = []; + + public function appendMutationCollection(MutationCollection $collection): void + { + $this->mutationCollections[] = $collection; + } + + /** + * @return list<MutationCollection> + */ + public function getMutationCollections(): array + { + return $this->mutationCollections; + } + + public function setMutationsCollections(MutationCollection ...$collections): void + { + $this->mutationCollections = $collections; + } + + public function hasMutationCollections(): bool + { + return $this->mutationCollections !== []; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Processing/AssetHandler.php b/Classes/Security/ContentSecurityPolicy/Processing/AssetHandler.php new file mode 100644 index 0000000..a2a0cbb --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Processing/AssetHandler.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Processing; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + +/** + * Suggest potential resolutions for simple asset violations, e.g. + * in case https://example.org/file.js could was blocked to be loaded, + * it would exactly suggest this mutation for the given directive. + */ +class AssetHandler +{ + use HandlerTrait; + + #[AsEventListener('security-csp-asset-handler')] + public function __invoke(InvestigateMutationsEvent $event): void + { + // skip, in case there are mutations already + if ($event->getMutationSuggestions() !== []) { + return; + } + $effectiveDirective = $this->resolveEffectiveDirective($event->report); + $blockedUri = $this->resolveBlockedUri($event->report); + // skip in case `blocked-uri` is not actually a URI with hostname, + // or directive is not reasonable to be mutated at all + if ($effectiveDirective === null + || $blockedUri === null + || $blockedUri->getHost() === '' + || !$effectiveDirective->isMutationReasonable()) { + return; + } + + $event->appendMutationSuggestions( + ...$this->createSuggestions($effectiveDirective, $blockedUri) + ); + } + + /** + * @return list<MutationSuggestion> + */ + private function createSuggestions(Directive $effectiveDirective, UriInterface $blockedUri): array + { + // @todo resolve URLs to current scope to 'self' instead of using the URL + + $suggestions = []; + $hostUri = $blockedUri->withUserInfo('')->withQuery('')->withFragment(''); + // resolves 'https://example.org/' + if ($hostUri->getScheme() !== '') { + $suggestions[] = new MutationSuggestion( + $this->createExtendingMutationCollection( + $effectiveDirective, + UriValue::fromUri($hostUri->withPath('')) + ), + self::class . '@hostWithScheme', + 3, + 'Assets from host' + ); + } + // resolves 'https://example.org/path/to/resource.js' + if ($hostUri->getScheme() !== '' && $hostUri->getPath() !== '') { + $suggestions[] = new MutationSuggestion( + $this->createExtendingMutationCollection( + $effectiveDirective, + UriValue::fromUri($hostUri) + ), + self::class . '@completeUrl', + 2, + 'Asset from specific URL' + ); + } + // resolves '*.example.org' + $suggestions[] = new MutationSuggestion( + $this->createExtendingMutationCollection( + $effectiveDirective, + new UriValue('*.' . $hostUri->getHost()) + ), + self::class . '@wildcardHost', + 1, + 'Asset from wildcard host' + ); + return $suggestions; + } + + private function createExtendingMutationCollection(Directive $effectiveDirective, UriValue $value): MutationCollection + { + return new MutationCollection( + new Mutation(MutationMode::Extend, $effectiveDirective, $value) + ); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Processing/GoogleMapsHandler.php b/Classes/Security/ContentSecurityPolicy/Processing/GoogleMapsHandler.php new file mode 100644 index 0000000..1366707 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Processing/GoogleMapsHandler.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Processing; + +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationSuggestion; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + +/** + * Suggests resolutions for Google-specific assets (e.g. Google Maps JS API). + */ +class GoogleMapsHandler +{ + use HandlerTrait; + + private const array DOMAIN_NAMES = [ + 'fonts.googleapis.com', + 'maps.googleapis.com', + 'fonts.gstatic.com', + 'maps.gstatic.com', + ]; + + private static MutationSuggestion $suggestion; + private static Policy $policyNarrative; + + public function __construct() + { + if (!isset(self::$suggestion)) { + self::$suggestion = $this->createGoogleMapsSuggestion(); + self::$policyNarrative = (new Policy())->mutate(self::$suggestion->collection); + } + } + + #[AsEventListener('security-csp-google-maps-handler')] + public function __invoke(InvestigateMutationsEvent $event): void + { + $effectiveDirective = $this->resolveEffectiveDirective($event->report); + $blockedUri = $this->resolveBlockedUri($event->report); + if ($effectiveDirective === null || $blockedUri === null || !$this->isInDomainNames($blockedUri->getHost())) { + return; + } + // skip other handlers + $event->stopPropagation(); + // clear mutations in case a resolution is contained (without inference) in current policy already + if ($event->policy->contains(self::$policyNarrative)) { + $event->setMutationSuggestions(); + return; + } + // otherwise create mutations for Google Maps JS API, + // in case the policy narrative would cover (with inference) the current violation + if (self::$policyNarrative->coversDirective($effectiveDirective, UriValue::fromUri($blockedUri))) { + // override existing mutations (this handler seems to be more specific) + $event->setMutationSuggestions(self::$suggestion); + } + } + + private function createGoogleMapsSuggestion(): MutationSuggestion + { + // see https://developers.google.com/maps/documentation/javascript/content-security-policy + // @todo `Note that 'strict-dynamic' is present, so host-based allowlisting is disabled.` + $collection = new MutationCollection( + new Mutation( + MutationMode::Extend, + Directive::ScriptSrcElem, + SourceKeyword::strictDynamic, // requires(!) Nonce everywhere + SourceScheme::https, // thx Google! + SourceKeyword::unsafeEval, // thx Google! + SourceScheme::blob, // thx Google! + ), + new Mutation( + MutationMode::Extend, + Directive::ImgSrc, + // @todo should be UriValue (which currently does not support scheme wildcards) + new UriValue('https://*.googleapis.com'), + new UriValue('https://*.gstatic.com'), + new UriValue('*.google.com'), + new UriValue('*.googleusercontent.com'), + ), + new Mutation( + MutationMode::Extend, + Directive::FrameSrc, + new UriValue('*.google.com'), + ), + new Mutation( + MutationMode::Extend, + Directive::ConnectSrc, + new UriValue('*.google.com'), + new UriValue('https://*.googleapis.com'), + new UriValue('https://*.gstatic.com'), + SourceScheme::blob, // thx Google! + SourceScheme::data, // thx Google! + ), + new Mutation( + MutationMode::Extend, + Directive::FontSrc, + new UriValue('https://fonts.gstatic.com'), + ), + new Mutation( + MutationMode::Extend, + Directive::StyleSrcElem, + SourceKeyword::nonceProxy, + new UriValue('https://fonts.gstatic.com'), + new UriValue('https://fonts.googleapis.com'), + ), + new Mutation( + MutationMode::Extend, + Directive::WorkerSrc, + SourceScheme::blob, + ), + ); + return new MutationSuggestion($collection, self::class, 5, 'Google Maps'); + } + + private function isInDomainNames(string $hostName): bool + { + if (in_array($hostName, self::DOMAIN_NAMES, true)) { + return true; + } + foreach (self::DOMAIN_NAMES as $domainName) { + if (str_ends_with($hostName, '.' . $domainName)) { + return true; + } + } + return false; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php b/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php new file mode 100644 index 0000000..d2ef14b --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php @@ -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\Core\Security\ContentSecurityPolicy\Processing; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report; + +trait HandlerTrait +{ + private function resolveBlockedUri(Report $report): ?UriInterface + { + try { + return new Uri($report?->details['blocked-uri'] ?? ''); + } catch (\InvalidArgumentException) { + return null; + } + } + + /** + * `violatedDirective` is a historical alias of `effectiveDirective` + * see https://www.w3.org/TR/CSP3/#violation-events + */ + private function resolveEffectiveDirective(Report $report): ?Directive + { + return Directive::tryFrom($report?->details['effective-directive'] ?? ''); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/RawValue.php b/Classes/Security/ContentSecurityPolicy/RawValue.php new file mode 100644 index 0000000..6b8b35b --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/RawValue.php @@ -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\Core\Security\ContentSecurityPolicy; + +/** + * Representation of a plain, raw string value that does not have + * a particular meaning in the terms of Content-Security-Policy. + * + * @internal Might be changed or removed at a later time + */ +readonly class RawValue implements \Stringable, SourceInterface +{ + public function __construct(public string $value) {} + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/Report.php b/Classes/Security/ContentSecurityPolicy/Reporting/Report.php new file mode 100644 index 0000000..b369efa --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/Report.php @@ -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\Core\Security\ContentSecurityPolicy\Reporting; + +use Symfony\Component\Uid\UuidV4; +use TYPO3\CMS\Core\Domain\DateTimeFactory; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +/** + * @internal + */ +class Report implements \JsonSerializable +{ + public readonly UuidV4 $uuid; + public readonly \DateTimeImmutable $created; + public readonly \DateTimeImmutable $changed; + + public static function fromArray(array $array): static + { + $meta = json_decode($array['meta'] ?? '', true, 16, JSON_THROW_ON_ERROR); + $details = json_decode($array['details'] ?? '', true, 16, JSON_THROW_ON_ERROR); + return new static( + Scope::from($array['scope'] ?? ''), + ReportStatus::from($array['status'] ?? 0), + $array['request_time'] ?? 0, + $meta ?: [], + new ReportDetails($details ?: []), + $array['summary'] ?? '', + UuidV4::fromString($array['uuid'] ?? ''), + DateTimeFactory::createFromTimestamp((int)($array['created'] ?? 0)), + DateTimeFactory::createFromTimestamp((int)($array['changed'] ?? 0)), + ); + } + + final public function __construct( + public readonly Scope $scope, + public readonly ReportStatus $status, + public readonly int $requestTime, + public readonly array $meta, + public readonly ReportDetails $details, + public readonly string $summary = '', + ?UuidV4 $uuid = null, + ?\DateTimeImmutable $created = null, + ?\DateTimeImmutable $changed = null, + ) { + $this->uuid = $uuid ?? new UuidV4(); + $this->created = $created ?? new \DateTimeImmutable(); + $this->changed = $changed ?? $this->created; + } + + public function jsonSerialize(): array + { + return [ + 'uuid' => $this->uuid, + 'status' => $this->status->value, + 'created' => $this->created->format(\DateTimeInterface::ATOM), + 'changed' => $this->changed->format(\DateTimeInterface::ATOM), + 'scope' => $this->scope, + 'request_time' => $this->requestTime, + 'meta' => $this->meta, + 'details' => $this->details, + 'summary' => $this->summary, + ]; + } + + public function toArray(): array + { + return [ + 'uuid' => (string)$this->uuid, + 'status' => $this->status->value, + 'created' => $this->created->getTimestamp(), + 'changed' => $this->changed->getTimestamp(), + 'scope' => (string)$this->scope, + 'request_time' => $this->requestTime, + 'meta' => json_encode($this->meta), + 'details' => json_encode($this->details->getArrayCopy()), + 'summary' => $this->summary, + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ReportAttribute.php b/Classes/Security/ContentSecurityPolicy/Reporting/ReportAttribute.php new file mode 100644 index 0000000..7f5b2d7 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ReportAttribute.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +/** + * @internal + */ +enum ReportAttribute: string +{ + case fixable = 'fixable'; + case irrelevant = 'irrelevant'; + case suspicious = 'suspicious'; +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ReportDemand.php b/Classes/Security/ContentSecurityPolicy/Reporting/ReportDemand.php new file mode 100644 index 0000000..7765e87 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ReportDemand.php @@ -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\Core\Security\ContentSecurityPolicy\Reporting; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Demand DTO for querying Report entities from the corresponding repository. + * @internal + */ +class ReportDemand +{ + public ?ReportStatus $status = ReportStatus::New; + public ?Scope $scope = null; + public ?array $summaries = null; + public ?int $requestTime = null; + public bool $afterRequestTime = false; + public ?string $orderFieldName = 'created'; + public ?string $orderDirection = 'desc'; + + public static function create(): self + { + return GeneralUtility::makeInstance(self::class); + } + + public static function forSummaries(array $summaries): self + { + $target = self::create(); + $target->status = null; + $target->summaries = $summaries; + return $target; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php b/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php new file mode 100644 index 0000000..6365318 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; + +/** + * @internal + */ +class ReportDetails extends \ArrayObject implements \JsonSerializable +{ + public function __construct(array $array) + { + if (!empty($array['violated-directive']) && !isset($array['effective-directive'])) { + $array['effective-directive'] = $array['violated-directive']; + } + parent::__construct($array); + } + + public function jsonSerialize(): array + { + $details = $this->getArrayCopy(); + return array_combine( + array_map(self::toCamelCase(...), array_keys($details)), + array_values($details) + ); + } + + public function resolveDisposition(): Disposition + { + return Disposition::tryFrom($this['disposition'] ?? '') ?? Disposition::enforce; + } + + protected static function toCamelCase(string $value): string + { + return lcfirst(str_replace('-', '', ucwords($value, '-'))); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ReportRepository.php b/Classes/Security/ContentSecurityPolicy/Reporting/ReportRepository.php new file mode 100644 index 0000000..c4851ff --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ReportRepository.php @@ -0,0 +1,331 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +use Doctrine\DBAL\ArrayParameterType; +use Symfony\Component\Uid\UuidV4; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +/** + * @internal + */ +readonly class ReportRepository +{ + protected const TABLE_NAME = 'sys_http_report'; + protected const TYPE = 'csp-report'; + + public function __construct(protected ConnectionPool $pool) {} + + /** + * @return list<Report> + */ + public function findAll(?ReportDemand $demand = null): array + { + $demand ??= ReportDemand::create(); + $result = $this->prepareQueryBuilder($demand) + ->select('*') + ->executeQuery(); + return array_map( + static fn(array $row) => Report::fromArray($row), + $result->fetchAllAssociative() + ); + } + + /** + * @return list<SummarizedReport> + */ + public function findAllSummarized(?ReportDemand $demand = null): array + { + $demand ??= ReportDemand::create(); + $queryBuilder = $this->prepareQueryBuilder($demand, 'report'); + $uuidQueryBuilder = $this->getQueryBuilder()->from(self::TABLE_NAME, 'tab_uuid'); + $summaryQueryBuilder = $this->getQueryBuilder()->from(self::TABLE_NAME, 'tab_summary'); + $expr = $queryBuilder->expr(); + + // these nested query builders are doing a bunch of things to meet `ONLY_FULL_GROUP_BY` constraints + // + inner "summary" builder: build [summary; created] relation, summary must be distinct + // + helping "uuid" builder: build [uuid <= {summary; created}] relation, summary must be distinct + // + outer "report" builder: finally query [* <= {uuid <= {summary; created}}], + // conditions/filters are applied to this effective outer query builder + + $summaryQueryBuilder + ->selectLiteral($this->createFunctionLiteral( + $queryBuilder, + 'MAX', + 'tab_summary.created', + 'created' + )) + ->addSelectLiteral('summary') + ->groupBy('summary'); + + $this->applySummaryJoin( + $uuidQueryBuilder, + 'tab_uuid', + $summaryQueryBuilder->getSQL(), + 'res_summary', + (string)$expr->and( + $expr->eq('tab_uuid.summary', 'res_summary.summary'), + $expr->eq('tab_uuid.created', 'res_summary.created') + ) + ); + $uuidQueryBuilder + ->selectLiteral($this->createFunctionLiteral( + $queryBuilder, + // using `MAX(col)` since `ANY_VALUE(col)` is not supported by PostgreSQL + 'MAX', + 'tab_uuid.uuid', + 'uuid' + )) + ->groupBy('tab_uuid.summary'); + + $this->applySummaryJoin( + $queryBuilder, + 'report', + $uuidQueryBuilder->getSQL(), + 'res_uuid', + $expr->eq('report.uuid', 'res_uuid.uuid') + ); + $result = $queryBuilder + ->select('report.*') + ->executeQuery(); + + $summaryCountMap = $this->fetchSummaryCountMap(); + + return array_map( + static fn(array $row) => SummarizedReport::fromArray($row) + ->withCount($summaryCountMap[$row['summary']] ?? 0), + $result->fetchAllAssociative() + ); + } + + public function findByUuid(UuidV4 $uuid): ?Report + { + $result = $this->getConnection()->select( + ['*'], + self::TABLE_NAME, + ['uuid' => (string)$uuid] + ); + $row = $result->fetchAssociative(); + if (empty($row)) { + return null; + } + return Report::fromArray($row); + } + + /** + * @return list<Report> + */ + public function findBySummary(string ...$summaries): array + { + if ($summaries === []) { + return []; + } + $demand = ReportDemand::forSummaries($summaries); + $result = $this->prepareQueryBuilder($demand) + ->select('*') + ->executeQuery(); + return array_map( + static fn(array $row) => SummarizedReport::fromArray($row), + $result->fetchAllAssociative() + ); + } + + public function add(Report $report): bool + { + return $this->getConnection()->insert( + self::TABLE_NAME, + array_merge( + $report->toArray(), + ['type' => self::TYPE] + ) + ) === 1; + } + + public function updateStatus(ReportStatus $status, UuidV4 ...$uuids): int + { + $queryBuilder = $this->getQueryBuilder(); + return $queryBuilder + ->update(self::TABLE_NAME) + ->set('status', $status->value) + ->set('changed', time()) + ->where( + $queryBuilder->expr()->in( + 'uuid', + $queryBuilder->createNamedParameter($uuids, ArrayParameterType::STRING) + ) + ) + ->executeStatement(); + } + + public function remove(UuidV4 $uuid): bool + { + return $this->getConnection()->delete( + self::TABLE_NAME, + ['uuid' => (string)$uuid] + ) === 1; + } + + public function removeAll(?Scope $scope = null): int + { + if ($scope === null) { + return $this->getConnection()->truncate(self::TABLE_NAME); + } + return $this->getConnection()->delete(self::TABLE_NAME, ['scope' => (string)$scope]); + } + + /** + * @return array<string, int> + */ + protected function fetchSummaryCountMap(): array + { + $queryBuilder = $this->getQueryBuilder(); + $rows = $queryBuilder + ->select('summary') + ->addSelectLiteral(sprintf( + 'COUNT(%s) AS %s', + $queryBuilder->quoteIdentifier('summary'), + $queryBuilder->quoteIdentifier('summary_count') + )) + ->from(self::TABLE_NAME) + ->groupBy('summary') + ->executeQuery() + ->fetchAllAssociative(); + return array_combine( + array_column($rows, 'summary'), + array_column($rows, 'summary_count'), + ); + } + + protected function prepareQueryBuilder(ReportDemand $demand, ?string $alias = null): QueryBuilder + { + $queryBuilder = $this->getQueryBuilder(); + $queryBuilder->from(self::TABLE_NAME, $alias); + $this->applyStaticTypeCondition($queryBuilder, $alias); + $this->applyDemand($demand, $queryBuilder, $alias); + return $queryBuilder; + } + + protected function applyDemand(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void + { + $this->applyDemandConditions($demand, $queryBuilder, $alias); + $this->applyDemandSorting($demand, $queryBuilder, $alias); + } + + protected function applyDemandConditions(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void + { + $expr = $queryBuilder->expr(); + $aliasPrefix = $this->prepareAliasPrefix($alias); + if ($demand->status !== null) { + $queryBuilder->andWhere($expr->eq( + $aliasPrefix . 'status', + $queryBuilder->createNamedParameter($demand->status->value, Connection::PARAM_INT) + )); + } + if ($demand->scope !== null) { + $queryBuilder->andWhere($expr->eq( + $aliasPrefix . 'scope', + $queryBuilder->createNamedParameter((string)$demand->scope) + )); + } + if ($demand->summaries !== null) { + $queryBuilder->andWhere($expr->in( + $aliasPrefix . 'summary', + $queryBuilder->createNamedParameter( + $demand->summaries, + ArrayParameterType::STRING + ), + )); + } + if ($demand->requestTime !== null) { + $requestTimeParam = $queryBuilder->createNamedParameter( + $demand->requestTime, + Connection::PARAM_INT + ); + if ($demand->afterRequestTime) { + $queryBuilder->andWhere($expr->gt($aliasPrefix . 'request_time', $requestTimeParam)); + } else { + $queryBuilder->andWhere($expr->eq($aliasPrefix . 'request_time', $requestTimeParam)); + } + } + } + + protected function applyDemandSorting(ReportDemand $demand, QueryBuilder $queryBuilder, ?string $alias = null): void + { + $aliasPrefix = $this->prepareAliasPrefix($alias); + if ($demand->orderFieldName !== null && $demand->orderDirection !== null) { + $queryBuilder->orderBy( + $aliasPrefix . $demand->orderFieldName, + $demand->orderDirection + ); + } + } + + protected function applyStaticTypeCondition(QueryBuilder $queryBuilder, ?string $alias = null): void + { + $aliasPrefix = $this->prepareAliasPrefix($alias); + $queryBuilder->andWhere( + $queryBuilder->expr()->eq( + $aliasPrefix . 'type', + $queryBuilder->createNamedParameter(self::TYPE) + ) + ); + } + + protected function applySummaryJoin(QueryBuilder $queryBuilder, string $fromAlias, string $join, string $alias, string $condition): void + { + $queryBuilder->getConcreteQueryBuilder()->join( + $queryBuilder->quoteIdentifier($fromAlias), + sprintf('(%s)', $join), + $queryBuilder->quoteIdentifier($alias), + $condition + ); + } + + protected function createFunctionLiteral(QueryBuilder $queryBuilder, string $functionName, string $fieldName, ?string $alias = null): string + { + $values = [ + $functionName, + $queryBuilder->quoteIdentifier($fieldName), + ]; + if ($alias === null) { + $format = '%s(%s)'; + } else { + $format = '%s(%s) AS %s'; + $values[] = $queryBuilder->quoteIdentifier($alias); + } + return vsprintf($format, $values); + } + + protected function prepareAliasPrefix(?string $alias = null): string + { + return $alias === null ? '' : $alias . '.'; + } + + protected function getQueryBuilder(): QueryBuilder + { + return $this->pool->getQueryBuilderForTable(self::TABLE_NAME); + } + + protected function getConnection(): Connection + { + return $this->pool->getConnectionForTable(self::TABLE_NAME); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ReportStatus.php b/Classes/Security/ContentSecurityPolicy/Reporting/ReportStatus.php new file mode 100644 index 0000000..11be6a1 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ReportStatus.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +/** + * @internal + */ +enum ReportStatus: int +{ + case New = 0; + case Handled = 1; + case Muted = 2; + case Deleted = 9; +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/Resolution.php b/Classes/Security/ContentSecurityPolicy/Reporting/Resolution.php new file mode 100644 index 0000000..4c007a1 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/Resolution.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +use TYPO3\CMS\Core\Domain\DateTimeFactory; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ModelService; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class Resolution implements \JsonSerializable +{ + public readonly \DateTimeImmutable $created; + + public static function fromArray(array $array): static + { + if (!isset($array['summary'])) { + throw new \LogicException('Summary must be given', 1677263951); + } + $service = GeneralUtility::makeInstance(ModelService::class); + $mutationCollection = $array['mutation_collection'] ?? null; + if (is_string($mutationCollection)) { + $mutationCollection = json_decode($mutationCollection, true, 16, JSON_THROW_ON_ERROR); + } + $mutationCollection = $service->buildMutationCollectionFromArray( + is_array($mutationCollection) ? $mutationCollection : [] + ); + $meta = json_decode($array['meta'] ?? '', true, 16, JSON_THROW_ON_ERROR); + return new static( + $array['summary'], + Scope::from($array['scope'] ?? ''), + $array['mutation_identifier'], + $mutationCollection, + $meta ?: [], + DateTimeFactory::createFromTimestamp((int)($array['created'] ?? 0)), + ); + } + + final public function __construct( + public readonly string $summary, + public readonly Scope $scope, + public readonly string $mutationIdentifier, + public readonly MutationCollection $mutationCollection, + public readonly array $meta = [], + ?\DateTimeImmutable $created = null, + ) { + $this->created = $created ?? new \DateTimeImmutable(); + } + + public function jsonSerialize(): array + { + return [ + 'summary' => $this->summary, + 'created' => $this->created->format(\DateTimeInterface::ATOM), + 'scope' => $this->scope, + 'mutationIdentifier' => $this->mutationIdentifier, + 'mutationCollection' => $this->mutationCollection, + 'meta' => $this->meta, + ]; + } + + public function toArray(): array + { + return [ + 'summary' => $this->summary, + 'created' => $this->created->getTimestamp(), + 'scope' => (string)$this->scope, + 'mutation_identifier' => $this->mutationIdentifier, + 'mutation_collection' => json_encode($this->mutationCollection), + 'meta' => json_encode($this->meta), + ]; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/ResolutionRepository.php b/Classes/Security/ContentSecurityPolicy/Reporting/ResolutionRepository.php new file mode 100644 index 0000000..b7e5531 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/ResolutionRepository.php @@ -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\Core\Security\ContentSecurityPolicy\Reporting; + +use Doctrine\DBAL\Exception\TableNotFoundException; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +/** + * @internal + */ +readonly class ResolutionRepository +{ + protected const TABLE_NAME = 'sys_csp_resolution'; + + public function __construct(protected ConnectionPool $pool) {} + + /** + * @return list<Resolution> + */ + public function findAll(): array + { + $result = $this->getConnection()->select( + ['*'], + self::TABLE_NAME, + [], + [], + ['created' => 'asc'] + ); + return array_map( + static fn(array $row) => Resolution::fromArray($row), + $result->fetchAllAssociative() + ); + } + + /** + * @return list<Resolution> + */ + public function findByScope(Scope $scope): array + { + try { + $result = $this->getConnection()->select( + ['*'], + self::TABLE_NAME, + ['scope' => (string)$scope], + [], + ['created' => 'asc'] + ); + } catch (TableNotFoundException) { + // We usually don't take care of non-existing table throughout the system. + // This one however can happen when major upgrading TYPO3 and calling the + // backend first time. It is fair to catch this case to prevent forcing admins + // to unlock standalone install tool or to use cli to fix db schema. + return []; + } + return array_map( + static fn(array $row) => Resolution::fromArray($row), + $result->fetchAllAssociative() + ); + } + + public function findBySummary(string $summary): ?Resolution + { + if ($summary === '') { + return null; + } + $result = $this->getConnection()->select( + ['*'], + self::TABLE_NAME, + ['summary' => $summary] + ); + $row = $result->fetchAssociative(); + if (empty($row)) { + return null; + } + return Resolution::fromArray($row); + } + + /** + * @return list<Resolution> + */ + public function findByIdentifier(string $identifier, bool $prefix = false): array + { + if ($identifier === '') { + return []; + } + if ($prefix) { + $queryBuilder = $this->pool->getQueryBuilderForTable(self::TABLE_NAME); + $result = $queryBuilder + ->select('*') + ->from(self::TABLE_NAME) + ->where($queryBuilder->expr()->like( + 'mutation_identifier', + $queryBuilder->createNamedParameter($queryBuilder->escapeLikeWildcards($identifier) . '%') + )) + ->executeQuery(); + } else { + $result = $this->getConnection()->select( + ['*'], + self::TABLE_NAME, + ['mutation_identifier' => $identifier] + ); + } + return array_map( + static fn(array $row) => Resolution::fromArray($row), + $result->fetchAllAssociative() + ); + } + + public function add(Resolution $resolution): bool + { + return $this->getConnection()->insert( + self::TABLE_NAME, + $resolution->toArray() + ) === 1; + } + + public function remove(string $summary): bool + { + return $this->getConnection()->delete( + self::TABLE_NAME, + ['summary' => $summary] + ) === 1; + } + + protected function getConnection(): Connection + { + return $this->pool->getConnectionForTable(self::TABLE_NAME); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Reporting/SummarizedReport.php b/Classes/Security/ContentSecurityPolicy/Reporting/SummarizedReport.php new file mode 100644 index 0000000..4db7a92 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Reporting/SummarizedReport.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +/** + * @internal + */ +class SummarizedReport extends Report +{ + protected int $count = 0; + /** + * @var list<ReportAttribute> + */ + protected array $attributes = []; + + /** + * @var list<string> + */ + protected array $mutationHashes = []; + + public function withCount(int $count): self + { + $target = clone $this; + $target->count = $count; + return $target; + } + + public function withAttribute(ReportAttribute $attribute): self + { + if (in_array($attribute, $this->attributes, true)) { + return $this; + } + $target = clone $this; + $target->attributes[] = $attribute; + return $target; + } + + public function withMutationHashes(string ...$mutationHashes): self + { + if ($this->mutationHashes === $mutationHashes) { + return $this; + } + $target = clone $this; + $target->mutationHashes = $mutationHashes; + return $target; + } + + public function jsonSerialize(): array + { + $data = parent::jsonSerialize(); + $data['count'] = $this->count; + $data['attributes'] = $this->attributes; + $data['mutationHashes'] = $this->mutationHashes; + return $data; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/Scope.php b/Classes/Security/ContentSecurityPolicy/Scope.php new file mode 100644 index 0000000..94eb4df --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/Scope.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a specific application type scope (backend, frontend), + * which can optionally be enriched by site-related details. + */ +final class Scope implements \Stringable, \JsonSerializable +{ + /** + * @var array<string, self> + */ + private static array $singletons = []; + + /** + * @deprecated actually just `@internal` - but it might be removed later + */ + public readonly ?Site $site; + + public static function backend(): self + { + return self::asSingleton(new self(ApplicationType::BACKEND)); + } + + public static function frontend(): self + { + return self::asSingleton(new self(ApplicationType::FRONTEND)); + } + + /** + * @internal might be removed later + */ + public static function frontendSite(?SiteInterface $site): self + { + if (!$site instanceof Site || is_subclass_of($site, Site::class)) { + return self::frontend(); + } + return self::asSingleton(new self(ApplicationType::FRONTEND, $site->getIdentifier(), $site)); + } + + public static function frontendSiteIdentifier(string $siteIdentifier): self + { + return self::asSingleton(new self(ApplicationType::FRONTEND, $siteIdentifier)); + } + + public static function from(string $value): self + { + $parts = GeneralUtility::trimExplode('.', $value, true); + $type = ApplicationType::tryFrom($parts[0] ?? ''); + $siteIdentifier = $parts[1] ?? null; + if ($type === null) { + throw new \LogicException( + sprintf('Could not resolve application type from "%s"', $value), + 1677424928 + ); + } + return self::asSingleton(new self($type, $siteIdentifier)); + } + + public static function reset(): void + { + self::$singletons = []; + } + + public static function tryFrom(string $value): ?self + { + try { + return self::from($value); + } catch (\LogicException) { + return null; + } + } + + private static function asSingleton(self $self): self + { + $id = (string)$self; + if (!isset(self::$singletons[$id])) { + self::$singletons[$id] = $self; + } + return self::$singletons[$id]; + } + + /** + * Use static functions to create singleton instances. + */ + private function __construct( + public readonly ApplicationType $type, + public readonly ?string $siteIdentifier = null, + ?Site $site = null, + ) { + $this->site = $site; + } + + public function __toString(): string + { + $value = $this->type->value; + if ($this->siteIdentifier !== null) { + $value .= '.' . $this->siteIdentifier; + } + return $value; + } + + public function isFrontendSite(): bool + { + return $this->siteIdentifier !== null && $this->type->isFrontend(); + } + + public function jsonSerialize(): string + { + return (string)$this; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/ScopeRepository.php b/Classes/Security/ContentSecurityPolicy/ScopeRepository.php new file mode 100644 index 0000000..b064593 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/ScopeRepository.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Site\SiteFinder; + +/** + * @internal + */ +readonly class ScopeRepository +{ + public function __construct(protected SiteFinder $siteFinder) {} + + /** + * @return list<Scope> + */ + public function findAll(): array + { + return array_merge( + [Scope::backend(), Scope::frontend()], + $this->findAllFrontendSites() + ); + } + + /** + * @return list<Scope> + */ + public function findAllFrontendSites(): array + { + return array_map( + static fn(SiteInterface $site) => Scope::frontendSiteIdentifier($site->getIdentifier()), + array_values($this->siteFinder->getAllSites()) + ); + } +} diff --git a/Classes/Security/ContentSecurityPolicy/SourceCollection.php b/Classes/Security/ContentSecurityPolicy/SourceCollection.php new file mode 100644 index 0000000..009376a --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/SourceCollection.php @@ -0,0 +1,185 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Domain\EqualityInterface; + +/** + * A collection of sources (sic!). + * @internal This implementation still might be adjusted + */ +final class SourceCollection +{ + /** + * @var list<SourceInterface> + */ + public readonly array $sources; + + public function __construct(SourceInterface ...$sources) + { + $this->sources = $sources; + } + + public function isEmpty(): bool + { + return $this->sources === []; + } + + public function merge(self $other): self + { + return $this->with(...$other->sources); + } + + public function exclude(self $other): self + { + return $this->without(...$other->sources); + } + + public function with(SourceInterface ...$subjects): self + { + $uniqueSubjects = []; + foreach ($subjects as $subject) { + if (!(in_array($subject, $uniqueSubjects, true) + || ($subject instanceof EqualityInterface && $this->hasEqualSource($subject, ...$uniqueSubjects))) + && !(in_array($subject, $this->sources, true) + || ($subject instanceof EqualityInterface && $this->hasEqualSource($subject, ...$this->sources))) + ) { + $uniqueSubjects[] = $subject; + } + } + if ($uniqueSubjects === []) { + return $this; + } + return new self(...array_merge($this->sources, $uniqueSubjects)); + } + + public function without(SourceInterface ...$subjects): self + { + $sources = array_filter( + $this->sources, + fn($source) => !(in_array($source, $subjects, true) + || ($source instanceof EqualityInterface && $this->hasEqualSource($source, ...$subjects))) + ); + if (count($this->sources) === count($sources)) { + return $this; + } + return new self(...$sources); + } + + /** + * @param class-string ...$subjectTypes + */ + public function withoutTypes(string ...$subjectTypes): self + { + $sources = array_filter( + $this->sources, + fn($source) => !$this->isSourceOfTypes($source, ...$subjectTypes) + ); + if (count($this->sources) === count($sources)) { + return $this; + } + return new self(...$sources); + } + + /** + * Determines whether all sources are contained (in terms of instances and values, but without inference). + */ + public function contains(SourceInterface ...$subjects): bool + { + if ($subjects === []) { + return false; + } + foreach ($subjects as $subject) { + if ($subject instanceof EqualityInterface) { + if (!$this->hasEqualSource($subject, ...$this->sources)) { + return false; + } + } elseif (!in_array($subject, $this->sources, true)) { + return false; + } + } + return true; + } + + /** + * Determines whether all sources are covered (in terms of CSP inference, considering wildcards and similar). + */ + public function covers(SourceInterface ...$subjects): bool + { + if ($subjects === []) { + return false; + } + foreach ($subjects as $subject) { + if ($subject instanceof CoveringInterface) { + if (!$this->hasCoveredSource($subject)) { + return false; + } + } elseif (!in_array($subject, $this->sources, true)) { + return false; + } + } + return true; + } + + /** + * Determines whether at least one type matches. + * @param class-string ...$subjectTypes + */ + public function containsTypes(string ...$subjectTypes): bool + { + foreach ($this->sources as $source) { + if ($this->isSourceOfTypes($source, ...$subjectTypes)) { + return true; + } + } + return false; + } + + private function hasEqualSource(EqualityInterface $subject, SourceInterface ...$sources): bool + { + foreach ($sources as $source) { + if ($source instanceof EqualityInterface && $source->equals($subject)) { + return true; + } + } + return false; + } + + private function hasCoveredSource(CoveringInterface $subject): bool + { + foreach ($this->sources as $source) { + if ($source instanceof CoveringInterface && $source->covers($subject)) { + return true; + } + } + return false; + } + + /** + * @param class-string ...$types + */ + private function isSourceOfTypes(SourceInterface $source, string ...$types): bool + { + foreach ($types as $type) { + if (is_a($source, $type)) { + return true; + } + } + return false; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/SourceInterface.php b/Classes/Security/ContentSecurityPolicy/SourceInterface.php new file mode 100644 index 0000000..50305a7 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/SourceInterface.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Semantic interface for anything that can be used as "source" in the terms + * of Content-Security-Policy - it includes `enum` objects, as well as real + * object instances that would be using `SourceValueInterface` instead. + * + * @internal This implementation might still change + */ +interface SourceInterface {} diff --git a/Classes/Security/ContentSecurityPolicy/SourceKeyword.php b/Classes/Security/ContentSecurityPolicy/SourceKeyword.php new file mode 100644 index 0000000..2c58030 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/SourceKeyword.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy source keywords + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources + */ +enum SourceKeyword: string implements SourceInterface +{ + case none = 'none'; + case self = 'self'; + case unsafeInline = 'unsafe-inline'; + case unsafeEval = 'unsafe-eval'; + // see https://www.w3.org/TR/CSP3/#unsafe-hashes-usage + case unsafeHashes = 'unsafe-hashes'; + case wasmUnsafeEval = 'wasm-unsafe-eval'; + case reportSample = 'report-sample'; + case strictDynamic = 'strict-dynamic'; + // nonce proxy is substituted when compiling the whole policy + // (this value does NOT exist in the CSP definition, it's specific to TYPO3 only) + case nonceProxy = 'nonce-proxy'; + + public function vetoes(): bool + { + return $this === self::none; + } + + public function isApplicable(Directive $directive): bool + { + $onlyApplicableTo = self::onlyApplicableToMap(); + return !isset($onlyApplicableTo[$this]) || in_array($directive, $onlyApplicableTo[$this], true); + } + + /** + * @return list<Directive> + * @internal + */ + public function getApplicableDirectives(): array + { + $onlyApplicableTo = self::onlyApplicableToMap(); + return $onlyApplicableTo[$this] ?? []; + } + + public function applySourceImplications(SourceCollection $sources): ?SourceCollection + { + // apply implications for `'strict-dynamic'` + if ($this === self::strictDynamic) { + // add nonce-proxy in case it's not defined + if (!$sources->contains(self::nonceProxy)) { + return $sources->with(self::nonceProxy); + } + } + return null; + } + + /** + * @return \WeakMap<self, list<Directive>> + */ + private static function onlyApplicableToMap(): \WeakMap + { + /** @var \WeakMap<self, list<Directive>> $map temporary, internal \WeakMap */ + $map = new \WeakMap(); + $map[self::reportSample] = [ + ...Directive::ScriptSrc->getFamily(), + ...Directive::StyleSrc->getFamily(), + ]; + $map[self::strictDynamic] = [ + ...Directive::ScriptSrc->getFamily(), + ]; + $map[self::unsafeHashes] = [ + Directive::DefaultSrc, + ...Directive::ScriptSrc->getFamily(), + ...Directive::StyleSrc->getFamily(), + ]; + $map[self::unsafeInline] = [ + Directive::DefaultSrc, + ...Directive::ScriptSrc->getFamily(), + ...Directive::StyleSrc->getFamily(), + ]; + // `'nonce-*'` cannot be used in + // + `script-src-attr` (e.g. `onclick="alert(123)"`), + // + `style-src-attr` (e.g. `style="color: #fff`) + $map[self::nonceProxy] = [ + Directive::DefaultSrc, + Directive::ScriptSrc, Directive::ScriptSrcElem, + Directive::StyleSrc, Directive::StyleSrcElem, + ]; + return $map; + } +} diff --git a/Classes/Security/ContentSecurityPolicy/SourceScheme.php b/Classes/Security/ContentSecurityPolicy/SourceScheme.php new file mode 100644 index 0000000..c5545b7 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/SourceScheme.php @@ -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\Core\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy source schemes + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources + */ +enum SourceScheme: string implements SourceInterface +{ + case blob = 'blob'; + case data = 'data'; + case filesystem = 'filesystem'; + case http = 'http'; + case https = 'https'; + case mediastream = 'mediastream'; + case ws = 'ws'; + case wss = 'wss'; +} diff --git a/Classes/Security/ContentSecurityPolicy/SourceValueInterface.php b/Classes/Security/ContentSecurityPolicy/SourceValueInterface.php new file mode 100644 index 0000000..6e7db84 --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/SourceValueInterface.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; + +/** + * Interface used for self-contained source value models. + * The parent `SourceInterface` is basically just a type interface, since + * type cannot be declared better in PHP. This `SourceValueInterface` is + * focussed on real class instances, but not on `enum` objects. + * + * @internal This implementation might still change + */ +interface SourceValueInterface extends SourceInterface +{ + /** + * Determines whether a serialized representation is known and can be handled + * by a specific implementation, (e.g. "string starts with 'hash-proxy-"). + */ + public static function knows(string $value): bool; + + /** + * Parses a known serialized representation as object representation. + */ + public static function parse(string $value): self; + + /** + * Compiled representation to be used for Content-Security-Policy HTTP header. + * @return ?string `null` means "not applicable / skip" + */ + public function compile(?FrontendInterface $cache = null): ?string; + + /** + * Serialized representation to be used for persisting declaration (e.g. in database). + * @return ?string `null` means "not applicable / skip" + */ + public function serialize(): ?string; +} diff --git a/Classes/Security/ContentSecurityPolicy/UriValue.php b/Classes/Security/ContentSecurityPolicy/UriValue.php new file mode 100644 index 0000000..942660f --- /dev/null +++ b/Classes/Security/ContentSecurityPolicy/UriValue.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Domain\EqualityInterface; +use TYPO3\CMS\Core\Http\Uri; + +/** + * Bridge to UriInterface to be used in Content-Security-Policy models, + * which e.g. supports wildcard domains, like `*.typo3.org` or `https://*.typo3.org`. + */ +final class UriValue extends Uri implements \Stringable, EqualityInterface, CoveringInterface, SourceInterface +{ + private string $domainName = ''; + private bool $entireWildcard = false; + private bool $domainWildcard = false; + + public static function fromUri(UriInterface $other): self + { + return new self((string)$other); + } + + protected function validate(): bool + { + $backupHost = null; + if ($this->host) { + $backupHost = $this->host; + $this->host = str_replace('*', 'wildcard', $this->host); + } + $ret = parent::validate(); + if ($backupHost !== null) { + $this->host = $backupHost; + } + return $ret; + } + + public function __toString(): string + { + if ($this->entireWildcard) { + return '*'; + } + if ($this->domainName !== '') { + return ($this->domainWildcard ? '*.' : '') . $this->domainName; + } + return parent::__toString(); + } + + public function equals(EqualityInterface $other): bool + { + return $other instanceof self && (string)$other === (string)$this; + } + + public function covers(CoveringInterface $other): bool + { + if (!$other instanceof self) { + return false; + } + // `*` matches anything + if ($this->entireWildcard) { + return true; + } + // `*.example.com` or `example.com` + if ($this->domainName !== '') { + if ($this->domainWildcard) { + if (($other->domainName !== '' && str_ends_with($other->domainName, '.' . $this->domainName)) + || ($other->host !== '' && str_ends_with($other->host, '.' . $this->domainName)) + ) { + return true; + } + } else { + if (($other->domainName !== '' && $other->domainName === $this->domainName) + || ($other->host !== '' && $other->host === $this->domainName) + ) { + return true; + } + } + } + // `https://*.example.com` + if ($other->host !== '' + && $this->scheme === $other->scheme + && str_starts_with($this->host, '*.') + && str_ends_with($other->host, substr($this->host, 1)) + ) { + return true; + } + return str_starts_with((string)$other, (string)$this); + } + + public function getDomainName(): string + { + return $this->domainName; + } + + protected function parseUri(string $uri): void + { + if ($uri === '*') { + $this->entireWildcard = true; + return; + } + parent::parseUri($uri); + // ignore fragments per default + $this->fragment = ''; + // handle domain names that were recognized as paths + if ($this->canBeParsedAsWildcardDomainName()) { + $this->domainName = substr($this->path, 4); + $this->domainWildcard = true; + } elseif ($this->canBeParsedAsDomainName()) { + $this->domainName = $this->path; + } + } + + private function canBeParsedAsDomainName(): bool + { + return $this->path !== '' + && $this->scheme === '' + && $this->host === '' + && $this->query === '' + && $this->userInfo === '' + && $this->validateDomainName($this->path); + } + + private function canBeParsedAsWildcardDomainName(): bool + { + if ($this->path === '' + || $this->scheme !== '' + || $this->host !== '' + || $this->query !== '' + || $this->userInfo !== '' + || !str_starts_with($this->path, '%2A') + ) { + return false; + } + $possibleDomainName = substr($this->path, 4); + return $this->validateDomainName($possibleDomainName); + } + + private function validateDomainName(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_DOMAIN) !== false; + } +} diff --git a/Classes/Security/JwtTrait.php b/Classes/Security/JwtTrait.php new file mode 100644 index 0000000..c9945c3 --- /dev/null +++ b/Classes/Security/JwtTrait.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +/** + * Trait providing support for JWT using symmetric hash signing. + * + * The benefit of using a trait in this particular case is, that defaults in `self::class` + * (used as context during key derivation) are specific to a particular implementation. + * + * @internal + */ +trait JwtTrait +{ + private static function getDefaultSigningAlgorithm(): string + { + return 'HS256'; + } + + private static function deriveKey( + #[\SensitiveParameter] + string $baseKey, + string $context, + ): Key { + $jwtAlgo = self::getDefaultSigningAlgorithm(); + [$hashAlgo, $length] = match ($jwtAlgo) { + 'HS256' => ['sha256', 32], + 'HS384' => ['sha384', 48], + 'HS512' => ['sha512', 64], + default => throw new \InvalidArgumentException('Unsupported JWT algorithm: ' . $jwtAlgo, 1774954888), + }; + return new Key( + hash_hkdf( + algo: $hashAlgo, + key: $baseKey, + length: $length, + info: $context, + ), + $jwtAlgo + ); + } + + private static function createSigningKeyFromEncryptionKey(string $context = self::class): Key + { + return self::deriveKey( + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? '', + $context === '' ? self::class : $context + ); + } + + private static function createSigningSecret(SigningSecretInterface $secret, string $context = self::class): Key + { + return self::deriveKey( + $secret->getSigningSecret(), + $context === '' ? self::class : $context + ); + } + + private static function encodeHashSignedJwt(array $payload, Key $key, ?SecretIdentifier $identifier = null): string + { + $keyId = $identifier !== null ? json_encode($identifier) : null; + return JWT::encode($payload, $key->getKeyMaterial(), $key->getAlgorithm(), $keyId); + } + + private static function decodeJwt(string $jwt, Key $key, bool $associative = false): \stdClass|array + { + $payload = JWT::decode($jwt, $key); + return $associative ? json_decode(json_encode($payload), true) : $payload; + } + + private static function decodeJwtHeader(string $jwt, string $property): mixed + { + $parts = explode('.', $jwt); + if (count($parts) !== 3) { + return null; + } + $headerRaw = JWT::urlsafeB64Decode($parts[0]); + if (($header = JWT::jsonDecode($headerRaw)) === null) { + return null; + } + return $header->{$property} ?? null; + } +} diff --git a/Classes/Security/Nonce.php b/Classes/Security/Nonce.php new file mode 100644 index 0000000..715e8a0 --- /dev/null +++ b/Classes/Security/Nonce.php @@ -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\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Number used once... + * + * @internal + */ +class Nonce implements SigningSecretInterface +{ + use JwtTrait; + + protected const MIN_BYTES = 40; + + public readonly string $b64; + public readonly \DateTimeImmutable $time; + + public static function create(int $length = self::MIN_BYTES): self + { + return GeneralUtility::makeInstance(self::class, random_bytes(max(self::MIN_BYTES, $length))); + } + + public static function fromHashSignedJwt(string $jwt): self + { + try { + $payload = self::decodeJwt($jwt, self::createSigningKeyFromEncryptionKey(Nonce::class), true); + return GeneralUtility::makeInstance( + self::class, + StringUtility::base64urlDecode($payload['nonce'] ?? '', true), + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null) + ); + } catch (\Throwable $t) { + throw new NonceException('Could not reconstitute nonce', 1651771351, $t); + } + } + + public function __construct(public readonly string $binary, ?\DateTimeImmutable $time = null) + { + if (strlen($this->binary) < self::MIN_BYTES) { + throw new \LogicException( + sprintf('Value must have at least %d bytes', self::MIN_BYTES), + 1651785134 + ); + } + $this->b64 = StringUtility::base64urlEncode($this->binary); + // drop microtime, second is the minimum date-interval + $this->time = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + } + + public function getSigningIdentifier(): SecretIdentifier + { + return new SecretIdentifier('nonce', StringUtility::base64urlEncode(md5($this->binary, true))); + } + + public function getSigningSecret(): string + { + return $this->binary; + } + + public function toHashSignedJwt(): string + { + $payload = [ + 'nonce' => $this->b64, + 'time' => $this->time->format(\DateTimeImmutable::RFC3339), + ]; + return self::encodeHashSignedJwt($payload, self::createSigningKeyFromEncryptionKey(Nonce::class)); + } +} diff --git a/Classes/Security/NonceException.php b/Classes/Security/NonceException.php new file mode 100644 index 0000000..5e87b1c --- /dev/null +++ b/Classes/Security/NonceException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +class NonceException extends Exception {} diff --git a/Classes/Security/NoncePool.php b/Classes/Security/NoncePool.php new file mode 100644 index 0000000..db6d696 --- /dev/null +++ b/Classes/Security/NoncePool.php @@ -0,0 +1,167 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * @internal + */ +class NoncePool implements SigningProviderInterface +{ + /** + * maximum amount of items in pool + */ + protected const DEFAULT_SIZE = 5; + + /** + * items will expire after this amount of seconds + */ + protected const DEFAULT_EXPIRATION = 900; + + /** + * @var array{size: positive-int, expiration: int} + */ + protected array $options; + + /** + * @var array<string, Nonce> + */ + protected array $items; + + /** + * @var array<string, ?Nonce> + */ + protected array $changeItems = []; + + /** + * @param array<string, mixed> $options + */ + public function __construct(array $nonces = [], array $options = []) + { + $this->options = [ + 'size' => max(1, (int)($options['size'] ?? self::DEFAULT_SIZE)), + 'expiration' => max(0, (int)($options['expiration'] ?? self::DEFAULT_EXPIRATION)), + ]; + + foreach ($nonces as $name => $value) { + if ($value !== null && !$value instanceof Nonce) { + throw new \LogicException(sprintf('Invalid valid for nonce "%s"', $name), 1664195013); + } + } + // filter valid items + $this->items = array_filter( + $nonces, + fn(?Nonce $item, string $name) => $item !== null + && $this->isValidNonceName($item, $name) + && $this->isNonceUpToDate($item), + ARRAY_FILTER_USE_BOTH + ); + // items that were not valid -> to be revoked + $invalidItems = array_diff_key($nonces, $this->items); + $this->changeItems = array_fill_keys(array_keys($invalidItems), null); + } + + public function findSigningSecret(string $name): ?Nonce + { + return $this->items[$name] ?? null; + } + + public function provideSigningSecret(): Nonce + { + $items = array_filter($this->changeItems); + $nonce = reset($items); + if (!$nonce instanceof Nonce) { + $nonce = Nonce::create(); + $this->emit($nonce); + } + return $nonce; + } + + public function merge(self $other): self + { + $this->items = array_merge($this->items, $other->items); + $this->changeItems = array_merge($this->changeItems, $other->changeItems); + return $this; + } + + public function purge(): self + { + $size = $this->options['size']; + $items = array_filter($this->items); + if (count($items) <= $size) { + return $this; + } + uasort($items, static fn(Nonce $a, Nonce $b) => $b->time <=> $a->time); + $exceedingItems = array_splice($items, $size, null, []); + foreach ($exceedingItems as $name => $_) { + $this->changeItems[$name] = null; + } + return $this; + } + + public function emit(Nonce $nonce): self + { + $this->changeItems[$nonce->getSigningIdentifier()->name] = $nonce; + return $this; + } + + public function revoke(Nonce $nonce): self + { + $this->revokeSigningSecret($nonce->getSigningIdentifier()->name); + return $this; + } + + public function revokeSigningSecret(string $name): void + { + if (isset($this->items[$name])) { + $this->changeItems[$name] = null; + } + } + + /** + * @return array<string, Nonce> + */ + public function getEmittableNonces(): array + { + return array_filter($this->changeItems); + } + + /** + * @return list<string> + */ + public function getRevocableNames(): array + { + return array_keys( + array_diff_key($this->changeItems, $this->getEmittableNonces()) + ); + } + + protected function isValidNonceName(Nonce $nonce, $name): bool + { + return $nonce->getSigningIdentifier()->name === $name; + } + + protected function isNonceUpToDate(Nonce $nonce): bool + { + if ($this->options['expiration'] <= 0) { + return true; + } + $now = new \DateTimeImmutable(); + $interval = new \DateInterval(sprintf('PT%dS', $this->options['expiration'])); + return $nonce->time->add($interval) > $now; + } +} diff --git a/Classes/Security/PermissionSet/PrincipalContext.php b/Classes/Security/PermissionSet/PrincipalContext.php new file mode 100644 index 0000000..d0952fd --- /dev/null +++ b/Classes/Security/PermissionSet/PrincipalContext.php @@ -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\Core\Security\PermissionSet; + +/** + * Represents the identity and group membership of a principal for authorization. + * + * Contains the core identity information needed for permission evaluation, + * including the role level, unique identifier, group memberships, and + * impersonation context. This is used as part of the Principal object + * to provide context for permission checks. + * + * @internal + */ +final readonly class PrincipalContext +{ + /** + * @param PrincipalRole $role The role level of the principal + * @param int $id The unique identifier of the principal (user ID, system ID) + * @param array<int> $groupIds List of group IDs the principal belongs to + * @param self|null $impersonatedBy Optional parent context when impersonation is active + */ + public function __construct( + public PrincipalRole $role, + public int $id, + public array $groupIds = [], + public ?self $impersonatedBy = null, + ) {} +} diff --git a/Classes/Security/PermissionSet/PrincipalRole.php b/Classes/Security/PermissionSet/PrincipalRole.php new file mode 100644 index 0000000..e7ee23e --- /dev/null +++ b/Classes/Security/PermissionSet/PrincipalRole.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\PermissionSet; + +/** + * Principal roles for determining privilege levels. + * + * Defines the role-based access control levels: + * - SYSTEM: System-level operations (CLI, install tool internal) + * - MAINTAINER: Install tool users, system maintenance tasks + * - ADMIN: Backend administrators (bypasses permission checks) + * - USER: Regular backend users (respects permission grants) + * + * @internal + */ +enum PrincipalRole: string +{ + case SYSTEM = 'system'; + case MAINTAINER = 'maintainer'; + case ADMIN = 'admin'; + case USER = 'user'; +} diff --git a/Classes/Security/PermissionSet/ProcessingContext.php b/Classes/Security/PermissionSet/ProcessingContext.php new file mode 100644 index 0000000..6e847c0 --- /dev/null +++ b/Classes/Security/PermissionSet/ProcessingContext.php @@ -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\Core\Security\PermissionSet; + +/** + * Provides workspace and user-specific configuration during data processing operations. + * + * Contains context information needed for permission evaluation and data manipulation, + * primarily used by DataHandler and related components. Includes the current workspace + * ID for versioning support and user-specific configuration (TSconfig, preferences) + * that may affect permission checks and data handling behavior. + * + * @internal + */ +final class ProcessingContext +{ + /** + * @param int $workspaceId The workspace ID for the current operation (mutable for DataHandler compatibility) + * @param array<string, mixed> $userTsConfig User TSconfig configuration array + * @param array<string, mixed> $userPreferences User preferences array + */ + public function __construct( + // @todo still required writable due to `DataHandler` + public int $workspaceId = 0, + public readonly array $userTsConfig = [], + public readonly array $userPreferences = [], + ) {} +} diff --git a/Classes/Security/RawValue.php b/Classes/Security/RawValue.php new file mode 100644 index 0000000..cc39047 --- /dev/null +++ b/Classes/Security/RawValue.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * This class represents a value that can be either untrusted + * (meaning it needs to be verified later) or trusted + * (meaning it has already been verified). + */ +final readonly class RawValue +{ + public function __construct(public string $value, public bool $trusted = false) {} + + public function trust(): self + { + return new self($this->value, true); + } +} diff --git a/Classes/Security/RequestToken.php b/Classes/Security/RequestToken.php new file mode 100644 index 0000000..f4695ce --- /dev/null +++ b/Classes/Security/RequestToken.php @@ -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\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +class RequestToken +{ + use JwtTrait; + + public const PARAM_NAME = '__RequestToken'; + public const HEADER_NAME = 'X-TYPO3-RequestToken'; + + public readonly string $scope; + public readonly \DateTimeImmutable $time; + /** + * @var array<int|string, mixed> + */ + public readonly array $params; + + /** + * Identifier that was used for signing, filled when decoding. + */ + private ?SecretIdentifier $signingSecretIdentifier = null; + + public static function create(string $scope): self + { + return GeneralUtility::makeInstance(self::class, $scope); + } + + public static function fromHashSignedJwt(string $jwt, SigningSecretInterface|SigningSecretResolver $secret): self + { + // invokes resolver to retrieve corresponding secret + // a hint was stored in the `kid` (keyId) property of the JWT header + if ($secret instanceof SigningSecretResolver) { + try { + $kid = (string)self::decodeJwtHeader($jwt, 'kid'); + $identifier = SecretIdentifier::fromJson($kid); + $secret = $secret->findByIdentifier($identifier); + } catch (\Throwable $t) { + throw new RequestTokenException('Could not reconstitute request token', 1664202134, $t); + } + if ($secret === null) { + throw new RequestTokenException('Could not reconstitute request token', 1664202135); + } + } + + try { + $payload = self::decodeJwt($jwt, self::createSigningSecret($secret, RequestToken::class), true); + $subject = GeneralUtility::makeInstance( + self::class, + $payload['scope'] ?? '', + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $payload['time'] ?? null), + $payload['params'] ?? [] + ); + $subject->signingSecretIdentifier = $secret->getSigningIdentifier(); + return $subject; + } catch (\Throwable $t) { + throw new RequestTokenException('Could not reconstitute request token', 1651771352, $t); + } + } + + public function __construct(string $scope, ?\DateTimeImmutable $time = null, array $params = []) + { + $this->scope = $scope; + // drop microtime, second is the minimum date-interval + $this->time = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::RFC3339, + ($time ?? new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339) + ); + $this->params = $params; + } + + public function toHashSignedJwt(SigningSecretInterface $secret): string + { + $payload = [ + 'scope' => $this->scope, + 'time' => $this->time->format(\DateTimeImmutable::RFC3339), + 'params' => $this->params, + ]; + return self::encodeHashSignedJwt( + $payload, + self::createSigningSecret($secret, RequestToken::class), + $secret->getSigningIdentifier() + ); + } + + public function withParams(array $params): self + { + return GeneralUtility::makeInstance(self::class, $this->scope, $this->time, $params); + } + + public function withMergedParams(array $params): self + { + return $this->withParams(array_merge_recursive($this->params, $params)); + } + + public function getSigningSecretIdentifier(): ?SecretIdentifier + { + return $this->signingSecretIdentifier; + } +} diff --git a/Classes/Security/RequestTokenException.php b/Classes/Security/RequestTokenException.php new file mode 100644 index 0000000..37f0869 --- /dev/null +++ b/Classes/Security/RequestTokenException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +class RequestTokenException extends Exception {} diff --git a/Classes/Security/SecretIdentifier.php b/Classes/Security/SecretIdentifier.php new file mode 100644 index 0000000..a0f97e4 --- /dev/null +++ b/Classes/Security/SecretIdentifier.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Model used to identify a secret, without actually containing the secret value. + * + * @internal + */ +readonly class SecretIdentifier implements \JsonSerializable +{ + public static function fromJson(string $json): self + { + return self::fromArray( + (array)json_decode($json, true, 8, JSON_THROW_ON_ERROR) + ); + } + + public static function fromArray(array $payload): self + { + $type = $payload['type'] ?? null; + $name = $payload['name'] ?? null; + if (!is_string($type) || !is_string($name)) { + throw new \LogicException('Properties "type" and "name" must be of type string', 1664215980); + } + return GeneralUtility::makeInstance(self::class, $type, $name); + } + + public function __construct(public string $type, public string $name) {} + + /** + * @return array{type: string, name: string} + */ + public function jsonSerialize(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + ]; + } +} diff --git a/Classes/Security/SigningProviderInterface.php b/Classes/Security/SigningProviderInterface.php new file mode 100644 index 0000000..ee37318 --- /dev/null +++ b/Classes/Security/SigningProviderInterface.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * @internal + */ +interface SigningProviderInterface +{ + /** + * Provides a signing secret independently of any name or identifier. + * In case there is none, the corresponding provider has to create a new one. + */ + public function provideSigningSecret(): SigningSecretInterface; + + /** + * Finds a signing secret for a given name + */ + public function findSigningSecret(string $name): ?SigningSecretInterface; + + /** + * Revokes a signing secret for a given name + * (providers without revocation functionality use an empty method body) + */ + public function revokeSigningSecret(string $name): void; +} diff --git a/Classes/Security/SigningSecretInterface.php b/Classes/Security/SigningSecretInterface.php new file mode 100644 index 0000000..f20e873 --- /dev/null +++ b/Classes/Security/SigningSecretInterface.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * Provides the value that is used as secret in a cryptographic signing process. + * + * @internal + */ +interface SigningSecretInterface +{ + /** + * Returns a public identifier of the secret. + */ + public function getSigningIdentifier(): SecretIdentifier; + + /** + * Returns secret used for signing messages. + */ + public function getSigningSecret(): string; +} diff --git a/Classes/Security/SigningSecretResolver.php b/Classes/Security/SigningSecretResolver.php new file mode 100644 index 0000000..fc640ca --- /dev/null +++ b/Classes/Security/SigningSecretResolver.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security; + +/** + * Resolves SigningSecretInterface items. + * + * @internal This class with change! + */ +class SigningSecretResolver +{ + /** + * @var array<string, SigningProviderInterface> + */ + protected array $providers; + + public function __construct(array $providers) + { + $this->providers = array_filter( + $providers, + static fn($provider) => $provider instanceof SigningProviderInterface + ); + } + + /** + * Resolves a signing provider by its type (e.g. `NoncePool` from type `'nonce'`) + */ + public function findByType(string $type): ?SigningProviderInterface + { + return $this->providers[$type] ?? null; + } + + /** + * Resolves a specific signing secret by its public identifier + * (e.g. specific `Nonce` from `NoncePool` by given public identifier "nonce:[public-name]") + */ + public function findByIdentifier(SecretIdentifier $identifier): ?SigningSecretInterface + { + if (!isset($this->providers[$identifier->type])) { + return null; + } + return $this->providers[$identifier->type]->findSigningSecret($identifier->name); + } + + /** + * Revokes a specific signing secret. + */ + public function revokeIdentifier(SecretIdentifier $identifier): void + { + if (!isset($this->providers[$identifier->type])) { + return; + } + $this->providers[$identifier->type]->revokeSigningSecret($identifier->name); + } +} diff --git a/Classes/Serializer/AuthenticatedMessageDeserializer.php b/Classes/Serializer/AuthenticatedMessageDeserializer.php new file mode 100644 index 0000000..2f3ded7 --- /dev/null +++ b/Classes/Serializer/AuthenticatedMessageDeserializer.php @@ -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\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Crypto\HashAlgo; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class AuthenticatedMessageDeserializer +{ + private const HASH_ALGO = HashAlgo::SHA3_384; + + public function __construct( + private HashService $hashService, + private DeserializationService $deserializationService, + ) {} + + public function serialize(mixed $payload, string $additionalSecret): string + { + return $this->hashService->appendHmac( + serialize($payload), + $additionalSecret, + self::HASH_ALGO + ); + } + + public function deserialize(string $payload, string $additionalSecret): mixed + { + try { + $serialized = $this->hashService->validateAndStripHmac( + $payload, + $additionalSecret, + self::HASH_ALGO + ); + } catch (InvalidHashStringException $e) { + $classNames = $this->deserializationService->parseClassNames($payload); + // in case the payload does not contain any class names, continue with + // a secure deserialization attempt, not allowing any class names + if ($classNames === []) { + return @unserialize($payload, ['allowed_classes' => false]); + } + throw new DeserializerException( + 'Authenticated Message Deserialization failed', + 1780317744, + $e + ); + } + // explicitly allowing all classes here after successful HMAC validation + /* @phpstan-ignore unserialize.allowedClasses.insecure (Integrity check already happens via HMAC validation) */ + return unserialize($serialized, ['allowed_classes' => true]); + } +} diff --git a/Classes/Serializer/DenyListDeserializer.php b/Classes/Serializer/DenyListDeserializer.php new file mode 100644 index 0000000..fb8f776 --- /dev/null +++ b/Classes/Serializer/DenyListDeserializer.php @@ -0,0 +1,167 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Security\BlockSerializationTrait; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * Deserializes a PHP-serialized payload while refusing any class that carries + * a user-defined __destruct() or an exploitable __wakeup() (one not provided + * solely by BlockSerializationTrait). + * + * The per-class deny/allow decision is made lazily via ReflectionClass at the + * first encounter of each class name, then cached in cache:core so that + * reflection is never repeated for the same class within a cache lifetime. + * + * Use this instead of a raw unserialize() call when the set of expected classes + * is not known upfront but dangerous gadget classes must still be excluded. + * + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class DenyListDeserializer +{ + /** + * @var list<string> + */ + private array $allowedClassNames; + private \ReflectionMethod $blockSerializationWakeup; + + public function __construct( + #[Autowire(service: 'cache.core')] + private PhpFrontend $cache, + private HashService $hashService, + private DeserializationService $deserializationService, + ) { + $allowedClassNames = $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] ?? null; + $this->allowedClassNames = is_array($allowedClassNames) ? $allowedClassNames : []; + $this->blockSerializationWakeup = (new \ReflectionClass(BlockSerializationTrait::class))->getMethod('__wakeup'); + } + + /** + * Deserializes $payload, throwing DeserializerException if any class name + * found in the payload is a deserialization gadget, or if the payload is + * syntactically malformed. + */ + public function deserialize(string $payload): mixed + { + $classNames = $this->deserializationService->parseClassNames($payload); + foreach ($classNames as $className) { + if ($this->shallClassBeDenied($className)) { + throw new DeserializerException( + 'Denied class name "' . $className . '" found in payload', + 1778594101 + ); + } + } + + return $this->deserializationService->deserialize($payload, $classNames ?: false); + } + + private function shallClassBeDenied(string $className): bool + { + if (in_array($className, $this->allowedClassNames, true)) { + return false; + } + + $cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className); + if ($this->cache->has($cacheKey)) { + $entry = $this->cache->require($cacheKey); + if (is_array($entry) + && isset($entry['denied'], $entry['hmac']) + && $this->hashService->validateHmac( + $this->createHmacPayload($className, (bool)$entry['denied']), + DenyListDeserializer::class, + $entry['hmac'] + ) + ) { + return (bool)$entry['denied']; + } + // Tampered or stale entry — fall through to recompute + } + + $denied = $this->resolveClassDenyStatus($className); + $hmac = $this->hashService->hmac($this->createHmacPayload($className, $denied), DenyListDeserializer::class); + $this->cache->set($cacheKey, 'return ' . var_export(['denied' => $denied, 'hmac' => $hmac], true) . ';'); + return $denied; + } + + private function createHmacPayload(string $className, bool $denied): string + { + return $className . ':' . ($denied ? '1' : '0'); + } + + private function resolveClassDenyStatus(string $className): bool + { + try { + $rc = new \ReflectionClass($className); + } catch (\ReflectionException) { + // The class does not exist or cannot be reflected (and not instantiated). + // Thus, the class is allowed, since it cannot be a gadget and would + // result in a `__PHP_Incomplete_Class` during deserialization. + return false; + } + if ($rc->isInterface() || $rc->isTrait()) { + return false; + } + return $this->getUserDefinedMethod($rc, '__destruct') !== null + || $this->hasDeniableWakeupMethod($rc); + } + + /** + * Returns the method when $methodName is declared in user-defined (non-internal) code + * somewhere in the class hierarchy. This excludes methods like Exception::__wakeup() + * that PHP declares internally and that are harmless for deserialization purposes. + */ + private function getUserDefinedMethod(\ReflectionClass $rc, string $methodName): ?\ReflectionMethod + { + if (!$rc->hasMethod($methodName)) { + return null; + } + $method = $rc->getMethod($methodName); + if ($method->getDeclaringClass()->isInternal()) { + return null; + } + return $method; + } + + /** + * Returns true when the class has a user-defined __wakeup() that is NOT + * BlockSerializationTrait::__wakeup(). Classes whose only __wakeup comes + * from BlockSerializationTrait are already protected against deserialization + * (the trait throws unconditionally) and must not be treated as gadgets. + * + * Note: for trait methods getDeclaringClass() returns the using class, not the + * trait — so the origin is identified by comparing the method's source file and line + * against the trait's own __wakeup declaration. + */ + private function hasDeniableWakeupMethod(\ReflectionClass $rc): bool + { + $method = $this->getUserDefinedMethod($rc, '__wakeup'); + if ($method === null) { + return false; + } + return $method->getFileName() !== $this->blockSerializationWakeup->getFileName() + || $method->getStartLine() !== $this->blockSerializationWakeup->getStartLine(); + } +} diff --git a/Classes/Serializer/DeserializationService.php b/Classes/Serializer/DeserializationService.php new file mode 100644 index 0000000..9923a09 --- /dev/null +++ b/Classes/Serializer/DeserializationService.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * Low-level utilities for PHP serialization format inspection. + * + * @internal Only to be used by TYPO3 core + */ +final readonly class DeserializationService +{ + /** + * Extracts all class names from a PHP-serialized payload, ignoring any + * class-name tokens that appear inside serialized string values. + * + * Returns an empty array for payloads that contain no objects, and skips + * any token whose declared byte-length does not match the actual class-name + * length (malformed entries). + * + * @return list<class-string> + */ + public function parseClassNames(string $payload): array + { + // Build string ranges once upfront to avoid re-scanning the payload per class-name token + $stringRanges = []; + if (preg_match_all('/s:(\d+):"/', $payload, $stringMatches, PREG_OFFSET_CAPTURE)) { + foreach ($stringMatches[0] as $i => $match) { + $contentStart = $match[1] + strlen($match[0]); + $stringRanges[] = [$contentStart, $contentStart + (int)$stringMatches[1][$i][0]]; + } + } + + $classNames = []; + if (preg_match_all('/[CO]:(?P<length>\d+):"(?P<className>[^"]+)"/', $payload, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches['className'] as $i => $classNameMatch) { + $className = $classNameMatch[0]; + $matchOffset = (int)$matches[0][$i][1]; + $declaredLength = (int)$matches['length'][$i][0]; + + if (strlen($className) !== $declaredLength) { + continue; + } + if (in_array($className, $classNames, true)) { + continue; + } + $insideString = false; + foreach ($stringRanges as [$start, $end]) { + if ($matchOffset >= $start && $matchOffset < $end) { + $insideString = true; + break; + } + } + if (!$insideString) { + $classNames[] = $className; + } + } + } + return $classNames; + } + + /** + * @param string $payload + * @param bool|list<class-string> $allowedClasses + */ + public function deserialize(string $payload, bool|array $allowedClasses = false): mixed + { + $result = @unserialize($payload, ['allowed_classes' => $allowedClasses]); + if ($result === false) { + if ($payload === serialize(false)) { + // Do not throw an exception in case the serialized string is *actually* false + // See https://www.php.net/manual/en/function.unserialize.php#refsect1-function.unserialize-notes + return false; + } + $exceptionMessage = 'Syntax error in payload, unable to de-serialize'; + $lastError = error_get_last(); + if ($lastError !== null) { + $exceptionMessage .= ': ' . $lastError['message']; + } + throw new DeserializerException($exceptionMessage, 1768212616); + } + return $result; + } +} diff --git a/Classes/Serializer/Exception/DeserializerException.php b/Classes/Serializer/Exception/DeserializerException.php new file mode 100644 index 0000000..c62cd82 --- /dev/null +++ b/Classes/Serializer/Exception/DeserializerException.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * Base exception for deserialization failures. + * + * @internal + */ +class DeserializerException extends Exception {} diff --git a/Classes/Serializer/Exception/InvalidDataException.php b/Classes/Serializer/Exception/InvalidDataException.php new file mode 100644 index 0000000..0012169 --- /dev/null +++ b/Classes/Serializer/Exception/InvalidDataException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * An exception if something is wrong with the data to be encoded or decoded + */ +class InvalidDataException extends Exception {} diff --git a/Classes/Serializer/PolymorphicDeserializer.php b/Classes/Serializer/PolymorphicDeserializer.php new file mode 100644 index 0000000..9c71f06 --- /dev/null +++ b/Classes/Serializer/PolymorphicDeserializer.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +final readonly class PolymorphicDeserializer +{ + public function __construct( + private DeserializationService $deserializationService = new DeserializationService(), + ) {} + + /** + * Validates the serialized payload by checking a static list of base classes or interfaces to be included in the + * de-serialized output. If a non-allowed class is hit, the method throws an PolymorphicDeserializerException. + * If the serialized payload is syntactically incorrect, PolymorphicDeserializerException is thrown as well. + * + * @param list<class-string> $allowedClasses + * @throws DeserializerException + */ + public function deserialize(string $payload, array $allowedClasses): mixed + { + // When allowing inheritance, extract all class names from payload and validate them + $classNames = $this->deserializationService->parseClassNames($payload); + + foreach ($classNames as $className) { + if (!$this->isInstanceOf($className, $allowedClasses)) { + throw new DeserializerException('Invalid class name "' . $className . '" found in payload', 1767987405); + } + + // Add the class if it's a valid subclass of any allowed class + if (!in_array($className, $allowedClasses, true)) { + $allowedClasses[] = $className; + } + } + + return $this->deserializationService->deserialize($payload, $allowedClasses); + } + + /** + * @return list<class-string> + * @deprecated use DeserializationService::parseClassNames instead; will be removed in v15 + */ + public function parseClassNames(string $payload): array + { + return $this->deserializationService->parseClassNames($payload); + } + + /** + * @param list<class-string> $allowedClassNames + */ + private function isInstanceOf(string $className, array $allowedClassNames): bool + { + foreach ($allowedClassNames as $allowedClassName) { + if (is_a($className, $allowedClassName, true) || is_subclass_of($className, $allowedClassName)) { + return true; + } + } + return false; + } +} diff --git a/Classes/Serializer/Typo3XmlParser.php b/Classes/Serializer/Typo3XmlParser.php new file mode 100644 index 0000000..f9627ab --- /dev/null +++ b/Classes/Serializer/Typo3XmlParser.php @@ -0,0 +1,250 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\InvalidDataException; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Decodes XML string to PHP array. + * + * A dedicated set of node attributes is considered during conversion: + * - attribute "index" specifies the final node name which is used as key in the PHP array + * - attribute "type" specifies the node value type which is used for casting + * - attribute "base64" specifies the node value type being binary and requiring a + * base64-decoding + * These attributes were applied during encoding of the PHP array with XmlEncoder::encode(). + * + * The node name "n{number}" is converted to a number-indexed array key "{number}". + * + * @internal still experimental + */ +readonly class Typo3XmlParser +{ + /** + * This method serves as a wrapper for decode() and is used to replace + * GeneralUtility::xml2array(), which returns an exception as a string instead of throwing it. + * In perspective, all uses of this method should be replaced by decode() and the exceptions + * should be handled locally. + * + * @param string $xml XML string + * @param Typo3XmlSerializerOptions|null $options Decoding configuration - see decode() for details + * @return array|string PHP array - or a string if the XML root node is empty or an exception + */ + public function decodeWithReturningExceptionAsString( + string $xml, + ?Typo3XmlSerializerOptions $options = null + ): array|string { + try { + return $this->decode($xml, $options); + } catch (\Throwable $e) { + return $e->getMessage(); + } + } + + /** + * @param string $xml XML string + * @param Typo3XmlSerializerOptions|null $options Apply specific decoding configuration - Ignored node types, libxml2 options, ... + * @return array|string PHP array - or a string if the XML root node is empty + * @throws InvalidDataException + */ + public function decode( + string $xml, + ?Typo3XmlSerializerOptions $options = null + ): array|string { + $xml = trim($xml); + if ($xml === '') { + throw new InvalidDataException( + 'Invalid XML data, it can not be empty.', + 1630773210 + ); + } + + $options = $options ?? new Typo3XmlSerializerOptions(); + + if ($options->allowUndefinedNamespaces()) { + $xml = $this->disableNamespaceInNodeNames($xml); + } + + $internalErrors = libxml_use_internal_errors(true); + libxml_clear_errors(); + + $dom = new \DOMDocument(); + $dom->loadXML($xml, $options->getLoadOptions()); + + libxml_use_internal_errors($internalErrors); + + if ($error = libxml_get_last_error()) { + libxml_clear_errors(); + throw new InvalidDataException( + 'Line ' . $error->line . ': ' . xml_error_string($error->code), + 1630773230 + ); + } + + $rootNode = null; + foreach ($dom->childNodes as $child) { + if ($child->nodeType === \XML_DOCUMENT_TYPE_NODE) { + throw new InvalidDataException( + 'Document types are not allowed.', + 1630773261 + ); + } + if (in_array($child->nodeType, $options->getIgnoredNodeTypes(), true)) { + continue; + } + $rootNode = $child; + break; + } + if ($rootNode === null) { + throw new InvalidDataException( + 'Root node cannot be determined.', + 1630773276 + ); + } + + $rootNodeName = $rootNode->nodeName; + if ($options->allowUndefinedNamespaces()) { + $rootNodeName = $this->reactivateNamespaceInNodeName($rootNodeName); + } + if (!$rootNode->hasChildNodes()) { + if ($options->includeRootNode()) { + $result = [$rootNodeName => $rootNode->nodeValue]; + } else { + $result = $rootNode->nodeValue; + } + } else { + if ($options->includeRootNode()) { + $result = [$rootNodeName => $this->parseXml($rootNode, $options)]; + } else { + $result = $this->parseXml($rootNode, $options); + } + } + if ($options->returnRootNodeName() && is_array($result)) { + $result['_DOCUMENT_TAG'] = $rootNodeName; + } + + return $result; + } + + /** + * DOMDocument::loadXML() breaks if prefixes of undefined namespaces are used in node names: + * Replace namespace divider ":" by temporary "___" string before parsing the XML. + */ + protected function disableNamespaceInNodeNames(string $value): string + { + return preg_replace( + ['#<([/]?)([[:alnum:]_-]*):([[:alnum:]_-]*)([ >]?)#'], + ['<$1$2___$3$4'], + $value + ); + } + + /** + * Re-insert the namespace divider ":" into all node names again after parsing the XML. + */ + protected function reactivateNamespaceInNodeNames(string $value): string + { + if (!str_contains($value, '___')) { + return $value; + } + + return preg_replace( + ['#<([/]?)([[:alnum:]_-]*)___([[:alnum:]_-]*)([ >]?)#'], + ['<$1$2:$3$4'], + $value + ); + } + + /** + * Re-insert the namespace divider ":" into single node name again after parsing the XML. + */ + protected function reactivateNamespaceInNodeName(string $value): string + { + return str_replace('___', ':', $value); + } + + protected function parseXml(\DOMNode $node, Typo3XmlSerializerOptions $options): array|string|null + { + if (!$node->hasChildNodes()) { + return $node->nodeValue; + } + + if ($node->childNodes->length === 1 + && in_array($node->firstChild->nodeType, [\XML_TEXT_NODE, \XML_CDATA_SECTION_NODE]) + ) { + $value = $node->firstChild->nodeValue; + if ($options->allowUndefinedNamespaces()) { + $value = $this->reactivateNamespaceInNodeNames($value); + } + return $value; + } + + $result = []; + foreach ($node->childNodes as $child) { + if (in_array($child->nodeType, $options->getIgnoredNodeTypes(), true)) { + continue; + } + + $value = $this->parseXml($child, $options); + + if ($child instanceof \DOMElement && $child->hasAttribute('index')) { + $key = $child->getAttribute('index'); + } else { + $key = $child->nodeName; + if ($options->allowUndefinedNamespaces()) { + $key = $this->reactivateNamespaceInNodeName($key); + } + if ($options->hasNamespacePrefix() + && str_starts_with($key, $options->getNamespacePrefix()) + ) { + $key = substr($key, strlen($options->getNamespacePrefix())); + } + if (str_starts_with($key, 'n') + && MathUtility::canBeInterpretedAsInteger($index = substr($key, 1)) + ) { + $key = (int)$index; + } + } + + if ($child instanceof \DOMElement && $child->hasAttribute('base64') && is_string($value)) { + $value = base64_decode($value); + } elseif ($child instanceof \DOMElement && $child->hasAttribute('type')) { + switch ($child->getAttribute('type')) { + case 'integer': + $value = (int)$value; + break; + case 'double': + $value = (float)$value; + break; + case 'boolean': + $value = (bool)$value; + break; + case 'NULL': + $value = null; + break; + case 'array': + $value = is_array($value) ? $value : (empty(trim($value)) ? [] : (array)$value); + break; + } + } + $result[$key] = $value; + } + return $result; + } +} diff --git a/Classes/Serializer/Typo3XmlParserOptions.php b/Classes/Serializer/Typo3XmlParserOptions.php new file mode 100644 index 0000000..7096fda --- /dev/null +++ b/Classes/Serializer/Typo3XmlParserOptions.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +/** + * @internal still experimental + */ +class Typo3XmlParserOptions +{ + public const FORMAT = 'format'; + public const FORMAT_INLINE = -1; + public const FORMAT_PRETTY_WITH_TAB = 0; + public const NAMESPACE_PREFIX = 'namespace_prefix'; + public const ROOT_NODE_NAME = 'root_node_name'; + + protected array $options = [ + // Format XML with + // - "-1" is inline XML + // - "0" is pretty XML with tabs + // - "1...x" is pretty XML with x spaces. + self::FORMAT => self::FORMAT_PRETTY_WITH_TAB, + // This XML namespace is prepended to each XML node, for example "T3:". + self::NAMESPACE_PREFIX => '', + // Wrap the XML with a root node of that name or set it to '' to skip wrapping. + self::ROOT_NODE_NAME => 'phparray', + ]; + + public function __construct(array $options = []) + { + $this->options = array_merge($this->options, $options); + } + + public function getRootNodeName(): string + { + return $this->options[self::ROOT_NODE_NAME]; + } + + public function getNewlineChar(): string + { + return $this->options[self::FORMAT] === self::FORMAT_INLINE ? '' : LF; + } + + public function getIndentationStep(): string + { + return match ($this->options[self::FORMAT]) { + self::FORMAT_INLINE => '', + self::FORMAT_PRETTY_WITH_TAB => "\t", + default => str_repeat(' ', max(0, $this->options[self::FORMAT])), + }; + } + + public function getNamespacePrefix(): string + { + return $this->options[self::NAMESPACE_PREFIX]; + } +} diff --git a/Classes/Serializer/Typo3XmlSerializer.php b/Classes/Serializer/Typo3XmlSerializer.php new file mode 100644 index 0000000..b1bcede --- /dev/null +++ b/Classes/Serializer/Typo3XmlSerializer.php @@ -0,0 +1,335 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Encodes PHP array to XML string. + * + * A dedicated set of entry properties is stored in XML during conversion: + * - XML node attribute "index" stores original entry key if XML node name differs from entry + * key + * - XML node attribute "type" stores entry value type ("bool", "int", "double", ...) + * - XML node attribute "base64" specifies if entry value is binary (for example an image) + * These attributes are interpreted during decoding of the XML string with XmlDecoder::decode(). + * + * Specific encoding configuration can be set by $additionalOptions - for the full array or array paths. + * For example + * ```php + * $input = [ + * 'numeric' => [ + * 'value1', + * 'value2' + * ], + * 'numeric-n-index' => [ + * 'value1', + * 'value2' + * ], + * 'nested' => [ + * 'node1' => 'value1', + * 'node2' => [ + * 'node' => 'value' + * ] + * ] + * ]; + * $additionalOptions = [ + * 'useIndexTagForNum' => 'numbered-index' + * 'alt_options' => [ + * '/numeric-n-index' => [ + * 'useNindex' => true + * ], + * '/nested' => [ + * 'useIndexTagForAssoc' => 'nested-outer', + * 'clearStackPath' => true, + * 'alt_options' => [ + * '/nested-outer' => [ + * 'useIndexTagForAssoc' => 'nested-inner' + * ] + * ] + * ] + * ] + * ]; + * ``` + * => + * ```xml + * <phparray> + * <numeric type="array"> + * <numbered-index index="0">value1</numbered-index> + * <numbered-index index="1">value2</numbered-index> + * </numeric> + * <numeric-n-index type="array"> + * <n0>value1</n0> + * <n1>value2</n1> + * </numeric-n-index> + * <nested type="array"> + * <nested-outer index="node1">value1</nested-outer> + * <nested-outer index="node2" type="array"> + * <nested-inner index="node">value</nested-inner> + * </nested-outer> + * </nested> + * </phparray> + * ``` + * Available options are: + * - grandParentTagMap[grandParentTagName/parentTagName] [string] + * Convert array key X to XML node name "{grandParentTagMap}" with node attribute "index=X" + * - if grand-parent is "{grandParentTagName}" and parent node is "{parentTagName}". + * - parentTagMap[parentTagName:_IS_NUM] [string] + * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" + * - if parent node is "{parentTagName}" and current node is number-indexed. + * - parentTagMap[parentTagName:nodeName] [string] + * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" + * - if parent node is "{parentTagName}" and current node is "{nodeName}". + * - parentTagMap[parentTagName] [string] + * Convert array key X to XML node name "{parentTagMap}" with node attribute "index=X" + * - if parent node is "{parentTagName}". + * - useNindex [bool] + * Convert number-indexed array key X to XML node name "nX". + * - useIndexTagForNum [string] + * Convert number-indexed array key X to XML node name "{useIndexTagForNum}" with node + * attribute "index=X". + * - useIndexTagForAssoc [string] + * Convert associative array key X to XML node name "{useIndexTagForAssoc}" with node + * attribute "index=X". + * - disableTypeAttrib [bool|int] + * Disable node attribute "type" for all value types + * (true = disable for all except arrays, 2 = disable for all). + * - alt_options[/.../nodeName] [array] + * Set new options for specific array path. + * - clearStackPath [bool] + * Resetting internal counter when descending the array hierarchy: Allows using relative + * array path in nested "alt_options" instead of absolute path. + * + * @internal still experimental + */ +readonly class Typo3XmlSerializer +{ + /** + * This method serves as a wrapper for encode() and is used to replace + * GeneralUtility::array2xml(), which returns an exception as a string instead of throwing it. + * In perspective, all uses of this method should be replaced by encode() and the exceptions + * should be handled locally. + * + * @param array $input PHP array + * @param Typo3XmlParserOptions|null $options Encoding configuration - see encode() for details + * @param array $additionalOptions Encoding options - see encode() for details + * @return string XML or exception + */ + public function encodeWithReturningExceptionAsString( + array $input, + ?Typo3XmlParserOptions $options = null, + array $additionalOptions = [] + ): string { + try { + return $this->encode($input, $options, $additionalOptions); + } catch (\Throwable $e) { + return $e->getMessage(); + } + } + + /** + * @param array $input PHP array + * @param Typo3XmlParserOptions|null $options Apply specific encoding configuration - XML format, namespace prefix and root node name + * @param array $additionalOptions Apply specific encoding options - for the full array or specific array paths. + * @return string XML string + */ + public function encode( + array $input, + ?Typo3XmlParserOptions $options = null, + array $additionalOptions = [] + ): string { + $options = $options ?? new Typo3XmlParserOptions(); + return $this->parseArray( + $input, + $options, + $additionalOptions + ); + } + + protected function parseArray( + array $input, + Typo3XmlParserOptions $options, + array $additionalOptions, + int $level = 0, + array $stackData = [] + ): string { + $xml = ''; + + $rootNodeName = $options->getRootNodeName(); + if (empty($rootNodeName)) { + $indentation = str_repeat($options->getIndentationStep(), $level); + } else { + $indentation = str_repeat($options->getIndentationStep(), $level + 1); + } + + foreach ($input as $key => $value) { + // Construct the node name + attributes + $nodeName = $key = (string)$key; + $nodeAttributes = ''; + if (isset( + $stackData['grandParentTagName'], + $stackData['parentTagName'], + $additionalOptions['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']] + )) { + // ... based on grand-parent + parent node name + $nodeName = (string)$additionalOptions['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']]; + $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; + } elseif (isset( + $stackData['parentTagName'], + $additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM'] + ) && MathUtility::canBeInterpretedAsInteger($nodeName) + ) { + // ... based on parent node name + if current node name is numeric + $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM']; + $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; + } elseif (isset( + $stackData['parentTagName'], + $additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':' . $nodeName] + )) { + // ... based on parent node name + current node name + $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName'] . ':' . $nodeName]; + $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; + } elseif (isset( + $stackData['parentTagName'], + $additionalOptions['parentTagMap'][$stackData['parentTagName']] + )) { + // ... based on parent node name + $nodeName = (string)$additionalOptions['parentTagMap'][$stackData['parentTagName']]; + $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; + } elseif (MathUtility::canBeInterpretedAsInteger($nodeName)) { + // ... if current node name is numeric + if ($additionalOptions['useNindex'] ?? false) { + $nodeName = 'n' . $nodeName; + } else { + $nodeName = ($additionalOptions['useIndexTagForNum'] ?? false) ?: 'numIndex'; + $nodeAttributes = ' index="' . $key . '"'; + } + } elseif (!empty($additionalOptions['useIndexTagForAssoc'])) { + // ... if current node name is string + $nodeName = $additionalOptions['useIndexTagForAssoc']; + $nodeAttributes = ' index="' . htmlspecialchars($key) . '"'; + } + $nodeName = $this->cleanUpNodeName($nodeName); + + // Construct the node value + if (is_array($value)) { + // ... if has sub elements + if (isset($additionalOptions['alt_options']) + && ($additionalOptions['alt_options'][($stackData['path'] ?? '') . '/' . $nodeName] ?? false) + ) { + $subOptions = $additionalOptions['alt_options'][($stackData['path'] ?? '') . '/' . $nodeName]; + $clearStackPath = (bool)($subOptions['clearStackPath'] ?? false); + } else { + $subOptions = $additionalOptions; + $clearStackPath = false; + } + if (empty($value)) { + $nodeValue = ''; + } else { + $nodeValue = $options->getNewlineChar(); + $nodeValue .= $this->parseArray( + $value, + $options, + $subOptions, + $level + 1, + [ + 'parentTagName' => $nodeName, + 'grandParentTagName' => $stackData['parentTagName'] ?? '', + 'path' => $clearStackPath ? '' : ($stackData['path'] ?? '') . '/' . $nodeName, + ] + ); + $nodeValue .= $indentation; + } + // Dropping the "type=array" attribute makes the XML prettier, but means that empty + // arrays are not restored with XmlDecoder::decode(). + if (($additionalOptions['disableTypeAttrib'] ?? false) !== 2) { + $nodeAttributes .= ' type="array"'; + } + } else { + // ... if is simple value + if ($this->isBinaryValue($value)) { + $nodeValue = $options->getNewlineChar() . chunk_split(base64_encode($value)); + $nodeAttributes .= ' base64="1"'; + } else { + $type = gettype($value); + if ($type === 'string') { + $nodeValue = htmlspecialchars($value); + } else { + $nodeValue = $value; + if (($additionalOptions['disableTypeAttrib'] ?? false) === false) { + $nodeAttributes .= ' type="' . $type . '"'; + } + } + } + } + + // Construct the node + if ($nodeName !== '') { + $xml .= $indentation; + $xml .= '<' . $options->getNamespacePrefix() . $nodeName . $nodeAttributes . '>'; + $xml .= $nodeValue; + $xml .= '</' . $options->getNamespacePrefix() . $nodeName . '>'; + $xml .= $options->getNewlineChar(); + } + } + + // Wrap with the root node if it is on the outermost level. + if ($level === 0 && !empty($rootNodeName)) { + $xml = '<' . $rootNodeName . '>' . $options->getNewlineChar() . $xml . '</' . $rootNodeName . '>'; + } + + return $xml; + } + + /** + * The node name is cleaned so that it contains only alphanumeric characters (plus - and _) and + * is no longer than 100 characters. + * + * @param string $nodeName + * @return string Cleaned node name + */ + protected function cleanUpNodeName(string $nodeName): string + { + return substr((string)preg_replace('/[^[:alnum:]_-]/', '', $nodeName), 0, 100); + } + + /** + * Is $value the content of a binary file, for example an image? If so, this value must be + * stored in a binary-safe manner so that it can be decoded correctly later. + * + * @param mixed $value + * @return bool + */ + protected function isBinaryValue(mixed $value): bool + { + if (!is_string($value)) { + return false; + } + + $binaryChars = "\0" . chr(1) . chr(2) . chr(3) . chr(4) . chr(5) + . chr(6) . chr(7) . chr(8) . chr(11) . chr(12) + . chr(14) . chr(15) . chr(16) . chr(17) . chr(18) + . chr(19) . chr(20) . chr(21) . chr(22) . chr(23) + . chr(24) . chr(25) . chr(26) . chr(27) . chr(28) + . chr(29) . chr(30) . chr(31); + + $length = strlen($value); + + return $length && strcspn($value, $binaryChars) !== $length; + } +} diff --git a/Classes/Serializer/Typo3XmlSerializerOptions.php b/Classes/Serializer/Typo3XmlSerializerOptions.php new file mode 100644 index 0000000..f02a418 --- /dev/null +++ b/Classes/Serializer/Typo3XmlSerializerOptions.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +/** + * @internal still experimental + */ +class Typo3XmlSerializerOptions +{ + public const INCLUDE_ROOT_NODE = 'include_root_node'; + public const IGNORED_NODE_TYPES = 'ignored_node_types'; + public const LOAD_OPTIONS = 'load_options'; + public const NAMESPACE_PREFIX = 'namespace_prefix'; + public const ALLOW_UNDEFINED_NAMESPACES = 'allow_undefined_namespaces'; + public const RETURN_ROOT_NODE_NAME = 'return_root_node_name'; + + protected array $options = [ + // Ignore XML node types when converting to a PHP array. + self::IGNORED_NODE_TYPES => [\XML_PI_NODE, \XML_COMMENT_NODE], + // Use the XML root node or its children as the first level of the PHP array. + self::INCLUDE_ROOT_NODE => false, + // Apply these libxml2 options when loading the XML. + self::LOAD_OPTIONS => \LIBXML_NONET | \LIBXML_NOBLANKS, + // Remove this XML namespace from each XML node, for example "T3:". + self::NAMESPACE_PREFIX => '', + // Gracefully handle missing namespace declarations, for example <T3:T3FlexForms> without xmlns attribute. + self::ALLOW_UNDEFINED_NAMESPACES => false, + // Append the name of the XML root node to the PHP array key "_DOCUMENT_TAG". + self::RETURN_ROOT_NODE_NAME => false, + ]; + + public function __construct(array $options = []) + { + $this->options = array_merge($this->options, $options); + } + public function getLoadOptions(): int + { + return $this->options[self::LOAD_OPTIONS]; + } + public function getIgnoredNodeTypes(): array + { + return $this->options[self::IGNORED_NODE_TYPES]; + } + public function includeRootNode(): bool + { + return $this->options[self::INCLUDE_ROOT_NODE]; + } + public function hasNamespacePrefix(): bool + { + return $this->options[self::NAMESPACE_PREFIX] !== ''; + } + public function getNamespacePrefix(): string + { + return $this->options[self::NAMESPACE_PREFIX]; + } + public function allowUndefinedNamespaces(): bool + { + return $this->options[self::ALLOW_UNDEFINED_NAMESPACES]; + } + public function returnRootNodeName(): bool + { + return $this->options[self::RETURN_ROOT_NODE_NAME]; + } +} diff --git a/Classes/Service/Archive/ZipService.php b/Classes/Service/Archive/ZipService.php new file mode 100644 index 0000000..c53f509 --- /dev/null +++ b/Classes/Service/Archive/ZipService.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service\Archive; + +use TYPO3\CMS\Core\Exception\Archive\ExtractException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Service that handles zip creation and extraction + * + * @internal + */ +readonly class ZipService +{ + /** + * Extracts the zip archive to a given directory. This method makes sure a file cannot be placed outside the directory. + * + * @throws ExtractException + */ + public function extract(string $fileName, string $directory): bool + { + $this->assertDirectoryIsWritable($directory); + + $zip = new \ZipArchive(); + $state = $zip->open($fileName); + if ($state !== true) { + throw new ExtractException( + sprintf('Unable to open zip file %s, error code %d', $fileName, $state), + 1565709712 + ); + } + + $result = $zip->extractTo($directory); + $zip->close(); + if ($result) { + GeneralUtility::fixPermissions(rtrim($directory, '/'), true); + } + return $result; + } + + /** + * @throws ExtractException + */ + public function verify(string $fileName): bool + { + $zip = new \ZipArchive(); + $state = $zip->open($fileName); + if ($state !== true) { + throw new ExtractException( + sprintf('Unable to open zip file %s, error code %d', $fileName, $state), + 1565709713 + ); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = str_replace('\\', '/', (string)$zip->getNameIndex($i)); + if (preg_match('#/(?:\.{2,})+#', $entryName) // Contains any traversal sequence starting with a slash, e.g. /../, /.., /.../ + || preg_match('#^(?:\.{2,})+/#', $entryName) // Starts with a traversal sequence, e.g. ../, .../ + ) { + throw new ExtractException( + sprintf('Suspicious sequence in zip file %s: %s', $fileName, $entryName), + 1565709714 + ); + } + } + + $zip->close(); + return true; + } + + private function assertDirectoryIsWritable(string $directory): void + { + if (!is_dir($directory)) { + throw new \RuntimeException( + sprintf('Directory %s does not exist', $directory), + 1565773005 + ); + } + if (!is_writable($directory)) { + throw new \RuntimeException( + sprintf('Directory %s is not writable', $directory), + 1565773006 + ); + } + } +} diff --git a/Classes/Service/DatabaseUpgradeWizardsService.php b/Classes/Service/DatabaseUpgradeWizardsService.php new file mode 100644 index 0000000..9dd6842 --- /dev/null +++ b/Classes/Service/DatabaseUpgradeWizardsService.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service; + +use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform; +use Doctrine\DBAL\Schema\Column; +use Doctrine\DBAL\Schema\Table; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Schema\SchemaMigrator; +use TYPO3\CMS\Core\Database\Schema\SqlReader; + +/** + * Service class executing database tasks for upgrade wizards + * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. + */ +#[Autoconfigure(public: true)] +readonly class DatabaseUpgradeWizardsService +{ + public function __construct( + private ConnectionPool $connectionPool, + private SqlReader $sqlReader, + private SchemaMigrator $schemaMigrator, + ) {} + + /** + * Get a list of tables, single columns and indexes to add. + * + * @return array{ + * tables?: list<array{table: string}>, + * columns?: list<array{table: string, field: string}>, + * indexes?: list<array{table: string, index: string}> + * } + */ + public function getBlockingDatabaseAdds(): array + { + $databaseDefinitions = $this->sqlReader->getCreateTableStatementArray($this->sqlReader->getTablesDefinitionString()); + $databaseDifferences = $this->schemaMigrator->getSchemaDiffs($databaseDefinitions); + $adds = []; + foreach ($databaseDifferences as $schemaDiff) { + foreach ($schemaDiff->getCreatedTables() as $newTable) { + /** @var Table $newTable */ + if (!is_array($adds['tables'] ?? false)) { + $adds['tables'] = []; + } + $adds['tables'][] = [ + 'table' => $newTable->getName(), + ]; + } + foreach ($schemaDiff->getAlteredTables() as $changedTable) { + foreach ($changedTable->getAddedColumns() as $addedColumn) { + /** @var Column $addedColumn */ + if (!is_array($adds['columns'] ?? false)) { + $adds['columns'] = []; + } + $adds['columns'][] = [ + 'table' => $changedTable->getOldTable()->getName(), + 'field' => $addedColumn->getName(), + ]; + } + foreach ($changedTable->getAddedIndexes() as $addedIndex) { + /** $var Index $addedIndex */ + if (!is_array($adds['indexes'] ?? false)) { + $adds['indexes'] = []; + } + $adds['indexes'][] = [ + 'table' => $changedTable->getOldTable()->getName(), + 'index' => $addedIndex->getName(), + ]; + } + } + } + + return $adds; + } + + /** + * Add missing tables, indexes and fields to DB. + * + * @return array<string, string> Every sql statement as key with empty string or error message as value + */ + public function addMissingTablesAndFields(): array + { + $databaseDefinitions = $this->sqlReader->getCreateTableStatementArray($this->sqlReader->getTablesDefinitionString()); + return $this->schemaMigrator->install($databaseDefinitions, true); + } + + /** + * True if DB main charset on mysql is utf8 + * + * @return bool True if charset is ok + */ + public function isDatabaseCharsetUtf8(): bool + { + $connection = $this->connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); + + $platform = $connection->getDatabasePlatform(); + $isDefaultConnectionMysql = $platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform; + if (!$isDefaultConnectionMysql) { + // Not tested on non mysql + $charsetOk = true; + } else { + $queryBuilder = $connection->createQueryBuilder(); + $charset = (string)$queryBuilder->select('DEFAULT_CHARACTER_SET_NAME') + ->from('information_schema.SCHEMATA') + ->where( + $queryBuilder->expr()->eq( + 'SCHEMA_NAME', + $queryBuilder->createNamedParameter($connection->getDatabase()) + ) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + // check if database charset is utf-8, also allows utf8mb3 and utf8mb4 + $charsetOk = str_starts_with($charset, 'utf8'); + } + return $charsetOk; + } + + /** + * Set default connection MySQL database charset to utf8. + * Should be called only *if* default database connection is actually MySQL + */ + public function setDatabaseCharsetUtf8() + { + $connection = $this->connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); + $sql = 'ALTER DATABASE ' . $connection->quoteIdentifier($connection->getDatabase()) . ' CHARACTER SET utf8'; + $connection->executeStatement($sql); + } +} diff --git a/Classes/Service/DependencyOrderingService.php b/Classes/Service/DependencyOrderingService.php new file mode 100644 index 0000000..22bbd3b --- /dev/null +++ b/Classes/Service/DependencyOrderingService.php @@ -0,0 +1,289 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service; + +/** + * This class provides functionality to build + * an ordered list from a set of dependencies. + * + * We use an adjacency matrix for the dependency graph (DAG) + * + * Example structure of the DAG is: + * A => (A => FALSE, B => TRUE, C => FALSE) + * B => (A => FALSE, B => FALSE, C => FALSE) + * C => (A => TRUE, B => FALSE, C => FALSE) + * + * A depends on B, C depends on A, B is independent + */ +readonly class DependencyOrderingService +{ + /** + * Order items by specified dependencies before/after + * + * The dependencies of an items are specified as: + * 'someItemKey' => [ + * 'before' => ['someItemKeyA', 'someItemKeyB'] + * 'after' => ['someItemKeyC'] + * ] + * + * If your items use different keys for specifying the relations, you can define the appropriate keys + * by setting the $beforeKey and $afterKey parameters accordingly. + * + * @param string $beforeKey The key to use in a dependency which specifies the "before"-relation. eg. 'sortBefore', 'loadBefore' + * @param string $afterKey The key to use in a dependency which specifies the "after"-relation. eg. 'sortAfter', 'loadAfter' + */ + public function orderByDependencies(array $items, string $beforeKey = 'before', string $afterKey = 'after'): array + { + $graph = $this->buildDependencyGraph($items, $beforeKey, $afterKey); + $sortedItems = []; + foreach ($this->calculateOrder($graph) as $id) { + if (isset($items[$id])) { + $sortedItems[$id] = $items[$id]; + } + } + return $sortedItems; + } + + /** + * Builds the dependency graph for the given dependencies + * + * The dependencies have to specified in the following structure: + * + * ``` + * $dependencies = [ + * 'someKey' => [ + * 'before' => ['someKeyA', 'someKeyB'] + * 'after' => ['someKeyC'] + * ] + * ] + * ``` + * + * We interpret a dependency like + * + * ``` + * 'A' => [ + * 'before' => ['B'], + * 'after' => ['C', 'D'] + * ] + * ``` + * + * as + * - A depends on C + * - A depends on D + * - B depends on A + * + * @param array $dependencies + * @param string $beforeKey The key to use in a dependency which specifies the "before"-relation. eg. 'sortBefore', 'loadBefore' + * @param string $afterKey The key to use in a dependency which specifies the "after"-relation. eg. 'sortAfter', 'loadAfter' + * @return array<array-key, array<array-key, bool>> The dependency graph + */ + public function buildDependencyGraph(array $dependencies, string $beforeKey = 'before', string $afterKey = 'after'): array + { + $dependencies = $this->prepareDependencies($dependencies, $beforeKey, $afterKey); + + $identifiers = array_keys($dependencies); + sort($identifiers); + // $dependencyGraph is the adjacency matrix as two-dimensional array initialized to FALSE (empty graph) + /** @var array<array-key, array<array-key, bool>> $dependencyGraph */ + $dependencyGraph = array_fill_keys($identifiers, array_fill_keys($identifiers, false)); + + foreach ($identifiers as $id) { + foreach ($dependencies[$id][$beforeKey] as $beforeId) { + $dependencyGraph[$beforeId][$id] = true; + } + foreach ($dependencies[$id][$afterKey] as $afterId) { + $dependencyGraph[$id][$afterId] = true; + } + } + + // @internal PackageManager + // this is a dirty special case for suggestion handling of packages + // see \TYPO3\CMS\Core\Package\PackageManager::convertConfigurationForGraph for details + // DO NOT use this for any other case + foreach ($identifiers as $id) { + if (isset($dependencies[$id]['after-resilient'])) { + foreach ($dependencies[$id]['after-resilient'] as $afterId) { + $reverseDependencies = $this->findPathInGraph($dependencyGraph, $afterId, $id); + if (empty($reverseDependencies)) { + $dependencyGraph[$id][$afterId] = true; + } + } + } + } + + return $dependencyGraph; + } + + /** + * Calculate an ordered list for a dependencyGraph + * + * @param bool[][] $dependencyGraph + * @return mixed[] Sorted array of keys of $dependencies + */ + public function calculateOrder(array $dependencyGraph): array + { + $rootIds = array_flip($this->findRootIds($dependencyGraph)); + + // Add number of dependencies for each root node + foreach ($rootIds as $id => &$dependencies) { + $dependencies = count(array_filter($dependencyGraph[$id])); + } + unset($dependencies); + + // This will contain our final result in reverse order, + // meaning a result of [A, B, C] equals "A after B after C" + $sortedIds = []; + + // Walk through the graph, level by level + while (!empty($rootIds)) { + ksort($rootIds); + // We take those with fewer dependencies first, to have them at the end of the list in the final result. + $minimum = PHP_INT_MAX; + $currentId = 0; + foreach ($rootIds as $id => $count) { + if ($count <= $minimum) { + $minimum = $count; + $currentId = $id; + } + } + unset($rootIds[$currentId]); + + $sortedIds[] = $currentId; + + // Process the dependencies of the current node + foreach (array_filter($dependencyGraph[$currentId] ?? []) as $dependingId => $_) { + // Remove the edge to this dependency + $dependencyGraph[$currentId][$dependingId] = false; + if (!$this->getIncomingEdgeCount($dependencyGraph, (string)$dependingId)) { + // We found a new root, lets add it to the list + $rootIds[$dependingId] = count(array_filter($dependencyGraph[$dependingId] ?? [])); + } + } + } + + // Check for remaining edges in the graph + $cycles = []; + array_walk($dependencyGraph, static function ($dependencies, $fromId) use (&$cycles) { + array_walk($dependencies, static function ($dependency, $toId) use (&$cycles, $fromId) { + if ($dependency) { + $cycles[] = $fromId . '->' . $toId; + } + }); + }); + if (!empty($cycles)) { + throw new \UnexpectedValueException('Your dependencies have cycles. That will not work out. Cycles found: ' . implode(', ', $cycles), 1381960494); + } + + // We now built a list of dependencies + // Reverse the list to get the correct sorting order + return array_reverse($sortedIds); + } + + /** + * Get the number of incoming edges in the dependency graph for given identifier + */ + protected function getIncomingEdgeCount(array $dependencyGraph, string $identifier): int + { + $incomingEdgeCount = 0; + foreach ($dependencyGraph as $dependencies) { + if ($dependencies[$identifier] ?? []) { + $incomingEdgeCount++; + } + } + return $incomingEdgeCount; + } + + /** + * Find all root nodes of a graph + * + * Root nodes are those, where nothing else depends on (they can be the last in the loading order). + * If there are no dependencies at all, all nodes are root nodes. + * + * @param bool[][] $dependencyGraph + * @return array List of identifiers which are root nodes + */ + public function findRootIds(array $dependencyGraph): array + { + // Filter nodes with no incoming edge (aka root nodes) + $rootIds = []; + foreach ($dependencyGraph as $id => $_) { + if (!$this->getIncomingEdgeCount($dependencyGraph, (string)$id)) { + $rootIds[] = $id; + } + } + return $rootIds; + } + + /** + * Find any path in the graph from given start node to destination node + * + * @param array $graph Directed graph + * @param string $from Start node + * @param string $to Destination node + * @return array Nodes of the found path; empty if no path is found + */ + protected function findPathInGraph(array $graph, string $from, string $to): array + { + foreach (array_filter($graph[$from] ?? []) as $node => $_) { + if ($node === $to) { + return [$from, $to]; + } + $subPath = $this->findPathInGraph($graph, $node, $to); + if (!empty($subPath)) { + array_unshift($subPath, $from); + return $subPath; + } + } + return []; + } + + /** + * Prepare dependencies + * + * Ensure that all discovered identifiers are added to the dependency list + * so we can reliably use the identifiers to build the matrix. + * Additionally, fix all invalid or missing before/after arrays + * + * @param array $dependencies + * @param string $beforeKey The key to use in a dependency which specifies the "before"-relation. eg. 'sortBefore', 'loadBefore' + * @param string $afterKey The key to use in a dependency which specifies the "after"-relation. eg. 'sortAfter', 'loadAfter' + * @return array Prepared dependencies + */ + protected function prepareDependencies(array $dependencies, string $beforeKey = 'before', string $afterKey = 'after'): array + { + $preparedDependencies = []; + foreach ($dependencies as $id => $dependency) { + foreach ([$beforeKey, $afterKey] as $relation) { + if (!isset($dependency[$relation]) || !is_array($dependency[$relation])) { + $dependency[$relation] = []; + } + // add all missing, but referenced identifiers to the $dependency list + foreach ($dependency[$relation] as $dependingId) { + if (!isset($dependencies[$dependingId]) && !isset($preparedDependencies[$dependingId])) { + $preparedDependencies[$dependingId] = [ + $beforeKey => [], + $afterKey => [], + ]; + } + } + } + $preparedDependencies[$id] = $dependency; + } + return $preparedDependencies; + } +} diff --git a/Classes/Service/Exception.php b/Classes/Service/Exception.php new file mode 100644 index 0000000..8494bba --- /dev/null +++ b/Classes/Service/Exception.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service; + +/** + * A service exception + */ +class Exception extends \TYPO3\CMS\Core\Exception {} diff --git a/Classes/Service/Exception/ConfigurationChangedException.php b/Classes/Service/Exception/ConfigurationChangedException.php new file mode 100644 index 0000000..53e3ee3 --- /dev/null +++ b/Classes/Service/Exception/ConfigurationChangedException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service\Exception; + +use TYPO3\CMS\Core\Service\Exception; + +/** + * An exception thrown if the silent configuration updater changed configuration + */ +class ConfigurationChangedException extends Exception {} diff --git a/Classes/Service/Exception/SilentConfigurationUpgradeReadonlyException.php b/Classes/Service/Exception/SilentConfigurationUpgradeReadonlyException.php new file mode 100644 index 0000000..c0fceb7 --- /dev/null +++ b/Classes/Service/Exception/SilentConfigurationUpgradeReadonlyException.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * Thrown when the SilentConfigurationUpgrade cannot make changes to the settings.php. + * + * @internal + */ +class SilentConfigurationUpgradeReadonlyException extends Exception +{ + protected $message = 'The SilentConfigurationUpgradeService needs to make changes to the settings.php but that file is read-only. ' + . 'Please (temporarily) clear the read-only status and open the install tool or run the UpgradeWizards command on CLI (typo3 upgrade:run). ' + . 'Once the SilentConfigurationUpgrade has been run, you may restrict writing to the settings.php again.'; + + public function __construct(int $code = 0, ?\Throwable $throwable = null) + { + parent::__construct($this->message, $code, $throwable); + } +} diff --git a/Classes/Service/MarkerBasedTemplateService.php b/Classes/Service/MarkerBasedTemplateService.php new file mode 100644 index 0000000..d82a633 --- /dev/null +++ b/Classes/Service/MarkerBasedTemplateService.php @@ -0,0 +1,519 @@ +<?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\Core\Service; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * Helper functionality for subparts and marker substitution + * ###MYMARKER### + */ +#[Autoconfigure(public: true)] +readonly class MarkerBasedTemplateService +{ + public function __construct( + #[Autowire(service: 'cache.assets')] + protected FrontendInterface $hashCache, + #[Autowire(service: 'cache.runtime')] + protected FrontendInterface $runtimeCache, + ) {} + + /** + * Returns the first subpart encapsulated in the marker, $marker + * (possibly present in $content as a HTML comment) + * + * @param string $content Content with subpart wrapped in fx. "###CONTENT_PART###" inside. + * @param string $marker Marker string, eg. "###CONTENT_PART### + * + * @return string + */ + public function getSubpart($content, $marker) + { + $start = strpos($content, $marker); + if ($start === false) { + return ''; + } + $start += strlen($marker); + $stop = strpos($content, $marker, $start); + // Q: What shall get returned if no stop marker is given + // Everything till the end or nothing? + if ($stop === false) { + return ''; + } + $content = substr($content, $start, $stop - $start); + $matches = []; + if (preg_match('/^([^\\<]*\\-\\-\\>)(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $content, $matches) === 1) { + return $matches[2]; + } + // Resetting $matches + $matches = []; + if (preg_match('/(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $content, $matches) === 1) { + return $matches[1]; + } + // Resetting $matches + $matches = []; + if (preg_match('/^([^\\<]*\\-\\-\\>)(.*)$/s', $content, $matches) === 1) { + return $matches[2]; + } + + return $content; + } + + /** + * Substitutes a subpart in $content with the content of $subpartContent. + * + * @param string $content Content with subpart wrapped in fx. "###CONTENT_PART###" inside. + * @param string $marker Marker string, eg. "###CONTENT_PART### + * @param string|array $subpartContent If $subpartContent happens to be an array, it's [0] and [1] elements are wrapped around the content of the subpart (fetched by getSubpart()) + * @param bool $recursive If $recursive is set, the function calls itself with the content set to the remaining part of the content after the second marker. This means that proceeding subparts are ALSO substituted! + * @param bool $keepMarker If set, the marker around the subpart is not removed, but kept in the output + * + * @return string Processed input content + */ + public function substituteSubpart($content, $marker, $subpartContent, $recursive = true, $keepMarker = false) + { + $start = strpos($content, $marker); + if ($start === false) { + return $content; + } + $startAM = $start + strlen($marker); + $stop = strpos($content, $marker, $startAM); + if ($stop === false) { + return $content; + } + $stopAM = $stop + strlen($marker); + $before = substr($content, 0, $start); + $after = substr($content, $stopAM); + $between = substr($content, $startAM, $stop - $startAM); + if ($recursive) { + $after = $this->substituteSubpart($after, $marker, $subpartContent, $recursive, $keepMarker); + } + if ($keepMarker) { + $matches = []; + if (preg_match('/^([^\\<]*\\-\\-\\>)(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $between, $matches) === 1) { + $before .= $marker . $matches[1]; + $between = $matches[2]; + $after = $matches[3] . $marker . $after; + } elseif (preg_match('/^(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $between, $matches) === 1) { + $before .= $marker; + $between = $matches[1]; + $after = $matches[2] . $marker . $after; + } elseif (preg_match('/^([^\\<]*\\-\\-\\>)(.*)$/s', $between, $matches) === 1) { + $before .= $marker . $matches[1]; + $between = $matches[2]; + $after = $marker . $after; + } else { + $before .= $marker; + $after = $marker . $after; + } + } else { + $matches = []; + if (preg_match('/^(.*)\\<\\!\\-\\-[^\\>]*$/s', $before, $matches) === 1) { + $before = $matches[1]; + } + if (is_array($subpartContent)) { + $matches = []; + if (preg_match('/^([^\\<]*\\-\\-\\>)(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $between, $matches) === 1) { + $between = $matches[2]; + } elseif (preg_match('/^(.*)(\\<\\!\\-\\-[^\\>]*)$/s', $between, $matches) === 1) { + $between = $matches[1]; + } elseif (preg_match('/^([^\\<]*\\-\\-\\>)(.*)$/s', $between, $matches) === 1) { + $between = $matches[2]; + } + } + $matches = []; + // resetting $matches + if (preg_match('/^[^\\<]*\\-\\-\\>(.*)$/s', $after, $matches) === 1) { + $after = $matches[1]; + } + } + if (is_array($subpartContent)) { + $between = $subpartContent[0] . $between . $subpartContent[1]; + } else { + $between = $subpartContent; + } + + return $before . $between . $after; + } + + /** + * Substitutes multiple subparts at once + * + * @param string $content The content stream, typically HTML template content. + * @param array $subpartsContent The array of key/value pairs being subpart/content values used in the substitution. For each element in this array the function will substitute a subpart in the content stream with the content. + * + * @return string The processed HTML content string. + */ + public function substituteSubpartArray($content, array $subpartsContent) + { + foreach ($subpartsContent as $subpartMarker => $subpartContent) { + $content = $this->substituteSubpart($content, $subpartMarker, $subpartContent); + } + + return $content; + } + + /** + * Substitutes a marker string in the input content + * (by a simple str_replace()) + * + * @param string $content The content stream, typically HTML template content. + * @param string $marker The marker string, typically on the form "###[the marker string]### + * @param mixed $markContent The content to insert instead of the marker string found. + * + * @return string The processed HTML content string. + * @see substituteSubpart() + */ + public function substituteMarker($content, $marker, $markContent) + { + return str_replace($marker, $markContent, $content); + } + + /** + * Traverses the input $markContentArray array and for each key the marker + * by the same name (possibly wrapped and in upper case) will be + * substituted with the keys value in the array. This is very useful if you + * have a data-record to substitute in some content. In particular when you + * use the $wrap and $uppercase values to pre-process the markers. Eg. a + * key name like "myfield" could effectively be represented by the marker + * "###MYFIELD###" if the wrap value was "###|###" and the $uppercase + * boolean TRUE. + * + * @param string $content The content stream, typically HTML template content. + * @param array|null $markContentArray The array of key/value pairs being marker/content values used in the substitution. For each element in this array the function will substitute a marker in the content stream with the content. + * @param string $wrap A wrap value - [part 1] | [part 2] - for the markers before substitution + * @param bool $uppercase If set, all marker string substitution is done with upper-case markers. + * @param bool $deleteUnused If set, all unused marker are deleted. + * + * @return string The processed output stream + * @see substituteMarker() + * @see substituteMarkerInObject() + */ + public function substituteMarkerArray($content, $markContentArray, $wrap = '', $uppercase = false, $deleteUnused = false) + { + if (is_array($markContentArray)) { + $wrapArr = GeneralUtility::trimExplode('|', $wrap); + $search = []; + $replace = []; + foreach ($markContentArray as $marker => $markContent) { + if ($uppercase) { + // use strtr instead of strtoupper to avoid locale problems with Turkish + $marker = strtr($marker, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + if (isset($wrapArr[0], $wrapArr[1])) { + $marker = $wrapArr[0] . $marker . $wrapArr[1]; + } + $search[] = $marker; + $replace[] = $markContent; + } + $content = str_replace($search, $replace, $content); + unset($search, $replace); + if ($deleteUnused) { + if (empty($wrap)) { + $wrapArr = ['###', '###']; + } + $content = preg_replace('/' . preg_quote($wrapArr[0], '/') . '([A-Z0-9_|\\-]*)' . preg_quote($wrapArr[1], '/') . '/is', '', $content); + } + } + + return $content; + } + + /** + * Replaces all markers and subparts in a template with the content provided in the structured array. + * + * The array is built like the template with its markers and subparts. Keys represent the marker name and the values the + * content. + * If the value is not an array the key will be treated as a single marker. + * If the value is an array the key will be treated as a subpart marker. + * Repeated subpart contents are of course elements in the array, so every subpart value must contain an array with its + * markers. + * + * ``` + * $markersAndSubparts = array ( + * '###SINGLEMARKER1###' => 'value 1', + * '###SUBPARTMARKER1###' => array( + * 0 => array( + * '###SINGLEMARKER2###' => 'value 2', + * ), + * 1 => array( + * '###SINGLEMARKER2###' => 'value 3', + * ) + * ), + * '###SUBPARTMARKER2###' => array( + * ), + * ) + * ``` + * + * Subparts can be nested, so below the 'SINGLEMARKER2' it is possible to have another subpart marker with an array as the + * value, which in its turn contains the elements of the sub-subparts. + * Empty arrays for Subparts will cause the subtemplate to be cleared. + * + * @param string $content The content stream, typically HTML template content. + * @param array $markersAndSubparts The array of single markers and subpart contents. + * @param string $wrap A wrap value - [part1] | [part2] - for the markers before substitution. + * @param bool $uppercase If set, all marker string substitution is done with upper-case markers. + * @param bool $deleteUnused If set, all unused single markers are deleted. + * + * @return string The processed output stream + */ + public function substituteMarkerAndSubpartArrayRecursive($content, array $markersAndSubparts, $wrap = '', $uppercase = false, $deleteUnused = false) + { + $wraps = GeneralUtility::trimExplode('|', $wrap); + $singleItems = []; + $compoundItems = []; + // Split markers and subparts into separate arrays + foreach ($markersAndSubparts as $markerName => $markerContent) { + if (is_array($markerContent)) { + $compoundItems[] = $markerName; + } else { + $singleItems[$markerName] = $markerContent; + } + } + $subTemplates = []; + $subpartSubstitutes = []; + // Build a cache for the sub template + foreach ($compoundItems as $subpartMarker) { + if ($uppercase) { + // Use strtr instead of strtoupper to avoid locale problems with Turkish + $subpartMarker = strtr($subpartMarker, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + if (isset($wraps[0], $wraps[1])) { + $subpartMarker = $wraps[0] . $subpartMarker . $wraps[1]; + } + $subTemplates[$subpartMarker] = $this->getSubpart($content, $subpartMarker); + } + // Replace the subpart contents recursively + foreach ($compoundItems as $subpartMarker) { + $completeMarker = $subpartMarker; + if ($uppercase) { + // use strtr instead of strtoupper to avoid locale problems with Turkish + $completeMarker = strtr($completeMarker, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + if (isset($wraps[0], $wraps[1])) { + $completeMarker = $wraps[0] . $completeMarker . $wraps[1]; + } + if (!empty($markersAndSubparts[$subpartMarker])) { + $subpartSubstitutes[$completeMarker] = ''; + foreach ($markersAndSubparts[$subpartMarker] as $partialMarkersAndSubparts) { + $subpartSubstitutes[$completeMarker] .= $this->substituteMarkerAndSubpartArrayRecursive( + $subTemplates[$completeMarker], + $partialMarkersAndSubparts, + $wrap, + $uppercase, + $deleteUnused + ); + } + } else { + $subpartSubstitutes[$completeMarker] = ''; + } + } + // Substitute the single markers and subparts + $result = $this->substituteSubpartArray($content, $subpartSubstitutes); + $result = $this->substituteMarkerArray($result, $singleItems, $wrap, $uppercase, $deleteUnused); + + return $result; + } + + /** + * Multi substitution function with caching. + * + * This function should be a one-stop substitution function for working + * with HTML-template. It does not substitute by str_replace but by + * splitting. This secures that the value inserted does not themselves + * contain markers or subparts. + * + * Note that the "caching" won't cache the content of the substitution, + * but only the splitting of the template in various parts. So if you + * want only one cache-entry per template, make sure you always pass the + * exact same set of marker/subpart keys. Else you will be flooding the + * user's cache table. + * + * This function takes three kinds of substitutions in one: + * $markContentArray is a regular marker-array where the 'keys' are + * substituted in $content with their values + * + * $subpartContentArray works exactly like markContentArray only is whole + * subparts substituted and not only a single marker. + * + * $wrappedSubpartContentArray is an array of arrays with 0/1 keys where + * the subparts pointed to by the main key is wrapped with the 0/1 value + * alternating. + * + * @param string $content The content stream, typically HTML template content. + * @param array $markContentArray Regular marker-array where the 'keys' are substituted in $content with their values + * @param array $subpartContentArray Exactly like markContentArray only is whole subparts substituted and not only a single marker. + * @param array $wrappedSubpartContentArray An array of arrays with 0/1 keys where the subparts pointed to by the main key is wrapped with the 0/1 value alternating. + * @return string The output content stream + * @see substituteSubpart() + * @see substituteMarker() + * @see substituteMarkerInObject() + */ + public function substituteMarkerArrayCached($content, ?array $markContentArray = null, ?array $subpartContentArray = null, ?array $wrappedSubpartContentArray = null) + { + // If not arrays then set them + if ($markContentArray === null) { + // Plain markers + $markContentArray = []; + } + if ($subpartContentArray === null) { + // Subparts being directly substituted + $subpartContentArray = []; + } + if ($wrappedSubpartContentArray === null) { + // Subparts being wrapped + $wrappedSubpartContentArray = []; + } + // Finding keys and check hash: + $sPkeys = array_keys($subpartContentArray); + $wPkeys = array_keys($wrappedSubpartContentArray); + $keysToReplace = array_merge(array_keys($markContentArray), $sPkeys, $wPkeys); + if (empty($keysToReplace)) { + return $content; + } + asort($keysToReplace); + $storeKey = md5('substituteMarkerArrayCached_storeKey:' . serialize([$content, $keysToReplace])); + $fromCache = $this->runtimeCache->get($storeKey); + if ($fromCache) { + $storeArr = $fromCache; + } else { + $storeArrDat = $this->hashCache->get($storeKey); + if (is_array($storeArrDat)) { + $storeArr = $storeArrDat; + // Setting the data in the first level cache + $this->runtimeCache->set($storeKey, $storeArr); + } else { + // Finding subparts and substituting them with the subpart as a marker + foreach ($sPkeys as $sPK) { + $content = $this->substituteSubpart($content, $sPK, $sPK); + } + // Finding subparts and wrapping them with markers + foreach ($wPkeys as $wPK) { + $content = $this->substituteSubpart($content, $wPK, [ + $wPK, + $wPK, + ]); + } + + $storeArr = []; + // search all markers in the content + $result = preg_match_all('/###([^#](?:[^#]*+|#{1,2}[^#])+)###/', $content, $markersInContent); + if ($result !== false && !empty($markersInContent[1])) { + $keysToReplaceFlipped = array_flip($keysToReplace); + $regexKeys = []; + $wrappedKeys = []; + // Traverse keys and quote them for reg ex. + foreach ($markersInContent[1] as $key) { + if (isset($keysToReplaceFlipped['###' . $key . '###'])) { + $regexKeys[] = preg_quote($key, '/'); + $wrappedKeys[] = '###' . $key . '###'; + } + } + $regex = '/###(?:' . implode('|', $regexKeys) . ')###/'; + $storeArr['c'] = preg_split($regex, $content); // contains all content parts around markers + $storeArr['k'] = $wrappedKeys; // contains all markers incl. ### + // Setting the data inside the second-level cache + $this->runtimeCache->set($storeKey, $storeArr); + // Storing the cached data permanently + $this->hashCache->set($storeKey, $storeArr, ['substMarkArrayCached'], 0); + } + } + } + if (!empty($storeArr['k']) && is_array($storeArr['k'])) { + // Substitution/Merging: + // Merging content types together, resetting + $valueArr = array_merge($markContentArray, $subpartContentArray, $wrappedSubpartContentArray); + $wSCA_reg = []; + $content = ''; + // Traversing the keyList array and merging the static and dynamic content + foreach ($storeArr['k'] as $n => $keyN) { + // add content before marker + $content .= $storeArr['c'][$n]; + if (!is_array($valueArr[$keyN])) { + // fetch marker replacement from $markContentArray or $subpartContentArray + $content .= $valueArr[$keyN]; + } else { + if (!isset($wSCA_reg[$keyN])) { + $wSCA_reg[$keyN] = 0; + } + // fetch marker replacement from $wrappedSubpartContentArray + $content .= $valueArr[$keyN][$wSCA_reg[$keyN] % 2]; + $wSCA_reg[$keyN]++; + } + } + // add remaining content + $content .= $storeArr['c'][count($storeArr['k'])]; + } + return $content; + } + + /** + * Substitute marker array in an array of values + * + * @param mixed $tree If string, then it just calls substituteMarkerArray. If array(and even multi-dim) then for each key/value pair the marker array will be substituted (by calling this function recursively) + * @param array $markContentArray The array of key/value pairs being marker/content values used in the substitution. For each element in this array the function will substitute a marker in the content string/array values. + * @return mixed The processed input variable. + * @see substituteMarker() + */ + public function substituteMarkerInObject(&$tree, array $markContentArray) + { + if (is_array($tree)) { + foreach ($tree as $key => $value) { + $this->substituteMarkerInObject($tree[$key], $markContentArray); + } + } else { + $tree = $this->substituteMarkerArray($tree, $markContentArray); + } + return $tree; + } + + /** + * Adds elements to the input $markContentArray based on the values from + * the fields from $fieldList found in $row + * + * @param array $markContentArray Array with key/values being marker-strings/substitution values. + * @param array $row An array with keys found in the $fieldList (typically a record) which values should be moved to the $markContentArray + * @param string $fieldList A list of fields from the $row array to add to the $markContentArray array. If empty all fields from $row will be added (unless they are integers) + * @param bool $nl2br If set, all values added to $markContentArray will be nl2br()'ed + * @param string $prefix Prefix string to the fieldname before it is added as a key in the $markContentArray. Notice that the keys added to the $markContentArray always start and end with "### + * @param bool $htmlSpecialCharsValue If set, all values are passed through htmlspecialchars() - RECOMMENDED to avoid most obvious XSS and maintain XHTML compliance. + * @param bool $respectXhtml if set, and $nl2br is set, then the new lines are added with <br /> instead of <br> + * @return array The modified $markContentArray + */ + public function fillInMarkerArray(array $markContentArray, array $row, $fieldList = '', $nl2br = true, $prefix = 'FIELD_', $htmlSpecialCharsValue = false, $respectXhtml = false) + { + if ($fieldList) { + $fArr = GeneralUtility::trimExplode(',', $fieldList, true); + foreach ($fArr as $field) { + $markContentArray['###' . $prefix . $field . '###'] = $nl2br ? nl2br($row[$field], $respectXhtml) : $row[$field]; + } + } else { + foreach ($row as $field => $value) { + if (!MathUtility::canBeInterpretedAsInteger($field)) { + if ($htmlSpecialCharsValue) { + $value = htmlspecialchars($value); + } + $markContentArray['###' . $prefix . $field . '###'] = $nl2br ? nl2br($value, $respectXhtml) : $value; + } + } + } + return $markContentArray; + } +} diff --git a/Classes/Service/OpcodeCacheService.php b/Classes/Service/OpcodeCacheService.php new file mode 100644 index 0000000..991149c --- /dev/null +++ b/Classes/Service/OpcodeCacheService.php @@ -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\Core\Service; + +/** + * Class with helper functions for clearing the PHP opcache. + * It auto-detects the opcache system and invalidates/resets it. + * https://forge.typo3.org/issues/55252 + * Supported opcaches are: OPcache >= 7.0 (PHP 5.5) + */ +readonly class OpcodeCacheService +{ + /** + * Returns all supported and active opcaches + * + * @return array Array filled with supported and active opcaches + */ + public function getAllActive(): array + { + $supportedCaches = [ + 'OPcache' => [ + 'active' => extension_loaded('Zend OPcache') && ini_get('opcache.enable') === '1', + 'version' => phpversion('Zend OPcache'), + 'warning' => self::isClearable() ? false : 'Either opcache_invalidate or opcache_reset are disabled in this installation. Clearing will not work.', + 'clearCallback' => static function ($fileAbsPath) { + if (self::isClearable()) { + if ($fileAbsPath !== null) { + opcache_invalidate($fileAbsPath); + } else { + opcache_reset(); + } + } + }, + ], + ]; + $activeCaches = []; + foreach ($supportedCaches as $opcodeCache => $properties) { + if ($properties['active']) { + $activeCaches[$opcodeCache] = $properties; + } + } + return $activeCaches; + } + + /** + * Clears a file from an opcache, if one exists. + * + * @param string|null $fileAbsPath The file as absolute path to be cleared or NULL to clear completely. + */ + public function clearAllActive(?string $fileAbsPath = null): void + { + foreach ($this->getAllActive() as $properties) { + $callback = $properties['clearCallback']; + $callback($fileAbsPath); + } + } + + protected static function isClearable(): bool + { + $disabled = explode(',', (string)ini_get('disable_functions')); + return function_exists('opcache_invalidate') + && function_exists('opcache_reset') + && !(in_array('opcache_invalidate', $disabled, true) || in_array('opcache_reset', $disabled, true)); + } +} diff --git a/Classes/Service/SilentConfigurationUpgradeService.php b/Classes/Service/SilentConfigurationUpgradeService.php new file mode 100644 index 0000000..68fc81a --- /dev/null +++ b/Classes/Service/SilentConfigurationUpgradeService.php @@ -0,0 +1,1208 @@ +<?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\Core\Service; + +use TYPO3\CMS\Core\Configuration\ConfigurationManager; +use TYPO3\CMS\Core\Configuration\Exception\SettingsWriteException; +use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash; +use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash; +use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash; +use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface; +use TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash; +use TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash; +use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Service\Exception\ConfigurationChangedException; +use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Execute "silent" LocalConfiguration upgrades if needed. + * + * Some LocalConfiguration settings are obsolete or changed over time. + * This class handles upgrades of these settings. It is called by + * the step controller at an early point. + * + * Every change is encapsulated in one method and must throw a ConfigurationChangedException + * if new data is written to LocalConfiguration. This is caught by above + * step controller to initiate a redirect and start again with adapted configuration. + * + * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. + */ +class SilentConfigurationUpgradeService +{ + /** + * List of obsolete configuration options in LocalConfiguration to be removed + * Example: + * // #forge-ticket + * 'BE/somesetting', + */ + protected array $obsoleteLocalConfigurationSettings = [ + // #72400 + 'BE/spriteIconGenerator_handler', + // #72417 + 'SYS/lockingMode', + // #72473 + 'FE/secureFormmail', + 'FE/strictFormmail', + 'FE/formmailMaxAttachmentSize', + // #72337 + 'SYS/t3lib_cs_utils', + 'SYS/t3lib_cs_convMethod', + // #72604 + 'SYS/maxFileNameLength', + // #72602 + 'BE/unzip_path', + // #72615 + 'BE/notificationPrefix', + // #72616 + 'BE/XCLASS', + 'FE/XCLASS', + // #43085 + 'GFX/image_processing', + // #70056 + 'SYS/curlUse', + 'SYS/curlProxyNTLM', + 'SYS/curlProxyServer', + 'SYS/curlProxyTunnel', + 'SYS/curlProxyUserPass', + 'SYS/curlTimeout', + // #75355 + 'BE/niceFlexFormXMLtags', + 'BE/compactFlexFormXML', + // #75625 + 'SYS/clearCacheSystem', + // #77411 + 'SYS/caching/cacheConfigurations/extbase_typo3dbbackend_tablecolumns', + // #77460 + 'SYS/caching/cacheConfigurations/extbase_typo3dbbackend_queries', + // #79513 + 'FE/lockHashKeyWords', + 'BE/lockHashKeyWords', + // #78835 + 'SYS/cookieHttpOnly', + // #71095 + 'BE/lang', + // #80050 + 'FE/cHashIncludePageId', + // #80711 + 'FE/noPHPscriptInclude', + 'FE/maxSessionDataSize', + // #82162 + 'SYS/enable_errorDLOG', + 'SYS/enable_exceptionDLOG', + // #82377 + 'EXT/allowSystemInstall', + // #82421 + 'SYS/sqlDebug', + 'SYS/no_pconnect', + 'SYS/setDBinit', + 'SYS/dbClientCompress', + // #82430 + 'SYS/syslogErrorReporting', + // #82639 + 'SYS/enable_DLOG', + 'SC_OPTIONS/t3lib/class.t3lib_userauth.php/writeDevLog', + 'SC_OPTIONS/t3lib/class.t3lib_userauth.php/writeDevLogBE', + 'SC_OPTIONS/t3lib/class.t3lib_userauth.php/writeDevLogFE', + // #82438 + 'SYS/enableDeprecationLog', + // #82680 + 'GFX/png_truecolor', + // #82803 + 'FE/content_doktypes', + // #83081 + 'BE/fileExtensions', + // #83768 + 'SYS/doNotCheckReferer', + // #83878 + 'SYS/isInitialInstallationInProgress', + 'SYS/isInitialDatabaseImportDone', + // #84810 + 'BE/explicitConfirmationOfTranslation', + // #87482 + 'EXT/extConf', + // #87767 + 'SYS/recursiveDomainSearch', + // #88376 + 'FE/pageNotFound_handling', + 'FE/pageNotFound_handling_statheader', + 'FE/pageNotFound_handling_accessdeniedheader', + 'FE/pageUnavailable_handling', + 'FE/pageUnavailable_handling_statheader', + // #88458 + 'FE/get_url_id_token', + // #88500 + 'BE/RTE_imageStorageDir', + // #89645 + 'SYS/systemLog', + 'SYS/systemLogLevel', + // #91974 + 'FE/IPmaskMountGroups', + // #87301 + 'SYS/cookieSecure', + // #92940 + 'BE/lockBeUserToDBmounts', + // #92941 + 'BE/enabledBeUserIPLock', + // #94312 + 'BE/loginSecurityLevel', + 'FE/loginSecurityLevel', + // #94871 + 'SYS/features/form.legacyUploadMimeTypes', + // #96550 + 'SYS/USdateFormat', + // #96982 + 'EXT/allowGlobalInstall', + // #96988 + 'EXT/allowLocalInstall', + // #97265 + 'BE/explicitADmode', + // #98179 + 'BE/interfaces', + // Please note that further migrations in this file are kept in order to remove the setting at the very end + // #97797 + 'GFX/processor_path_lzw', + // #98503 + 'SYS/caching/cacheConfigurations/pagesection', + // #99075 + 'FE/defaultUserTSconfig', + // #101037 + 'BE/languageDebug', + 'BE/lang/debug', + // #101793 + 'BE/checkStoredRecords', + 'BE/checkStoredRecordsLoose', + // #101941 + 'GFX/thumbnails_png', + 'GFX/gif_compress', + // #101950 + 'GFX/processor_allowTemporaryMasksAsPng', + // #102020 + 'GFX/gdlib_png', + // #102023 + 'SYS/features/security.usePasswordPolicyForFrontendUsers', + // #102113 + 'GFX/gdlib', + // #102146 + 'BE/flexformForceCDATA', + // #103752 + 'FE/addRootLineFields', + // #104104 + 'EXTENSIONS/indexed_search/debugMode', + // #101807 + 'BE/defaultUserTSconfig', + // #101799 + 'BE/defaultPageTSconfig', + // #107436 + // symfony/translation loaders used now + 'SYS/lang/parser', + // #107943 + 'BE/compressionLevel', + // #108055 + 'FE/compressionLevel', + // #108114 + 'FE/additionalAbsRefPrefixDirectories', + // #109959 + 'FE/cacheHash/fallbackToLegacyHash', + // #109986 + 'FE/addAllowedPaths', + ]; + + public function __construct(private readonly ConfigurationManager $configurationManager) {} + + /** + * Executed configuration upgrades. Single upgrade methods must throw a + * ConfigurationChangedException if something was written to LocalConfiguration. + * + * @throws ConfigurationChangedException + * @throws SettingsWriteException + */ + public function execute(): void + { + $this->generateEncryptionKeyIfNeeded(); + $this->migrateImageProcessorSetting(); + $this->transferHttpSettings(); + $this->disableImageMagickDetailSettingsIfImageMagickIsDisabled(); + $this->setImageMagickDetailSettings(); + $this->removeDefaultColorspaceSettings(); + $this->migrateLockSslSetting(); + $this->migrateDatabaseConnectionSettings(); + $this->migrateDatabaseConnectionCharset(); + $this->migrateDatabaseDriverOptions(); + $this->migrateCacheHashOptions(); + $this->migrateExceptionErrors(); + $this->migrateDisplayErrorsSetting(); + $this->migrateSaltedPasswordsSettings(); + $this->migrateCachingFrameworkCaches(); + $this->migrateMailSettingsToSendmail(); + $this->migrateMailSmtpEncryptSetting(); + $this->migrateVersionNumberInFileNameSetting(); + $this->migrateLanguageSettings(); + + // Should run at the end to prevent obsolete settings are removed before migration + $this->removeObsoleteLocalConfigurationSettings(); + } + + /** + * Throw exception after configuration change to trigger a redirect. + * + * @throws ConfigurationChangedException + */ + protected function throwConfigurationChangedException(): void + { + throw new ConfigurationChangedException( + 'Configuration updated, reload needed', + 1379024938 + ); + } + + /** + * Some settings in LocalConfiguration vanished in DefaultConfiguration + * and have no impact on the core anymore. + * To keep the configuration clean, those old settings are just silently + * removed from LocalConfiguration if set. + * + * @throws ConfigurationChangedException + */ + protected function removeObsoleteLocalConfigurationSettings(): void + { + $removed = $this->configurationManager->removeLocalConfigurationKeysByPath($this->obsoleteLocalConfigurationSettings); + + // If something was changed: Trigger reload to have new values in next request + if ($removed) { + $this->throwConfigurationChangedException(); + } + } + + /** + * The encryption key is crucial for securing form tokens + * and the whole TYPO3 link rendering later on. A random key is set here in + * LocalConfiguration if it does not exist yet. This might happen + * during upgrading and will happen during first install. + * + * @throws ConfigurationChangedException + */ + protected function generateEncryptionKeyIfNeeded(): void + { + try { + $currentValue = $this->configurationManager->getLocalConfigurationValueByPath('SYS/encryptionKey'); + } catch (MissingArrayPathException) { + // If an exception is thrown, the value is not set in LocalConfiguration + $currentValue = ''; + } + + if (empty($currentValue)) { + $randomKey = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96); + $this->configurationManager->setLocalConfigurationValueByPath('SYS/encryptionKey', $randomKey); + $this->throwConfigurationChangedException(); + } + } + + /** + * Parse old curl and HTTP options and set new HTTP options, related to Guzzle + * + * @throws ConfigurationChangedException + */ + protected function transferHttpSettings(): void + { + $changed = false; + $newParameters = []; + $obsoleteParameters = []; + + // Remove / migrate options to new options + try { + // Check if the adapter option is set, if so, set it to the parameters that are obsolete + $this->configurationManager->getLocalConfigurationValueByPath('HTTP/adapter'); + $obsoleteParameters[] = 'HTTP/adapter'; + } catch (MissingArrayPathException) { + // Migration done already + } + try { + $newParameters['HTTP/version'] = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/protocol_version'); + $obsoleteParameters[] = 'HTTP/protocol_version'; + } catch (MissingArrayPathException) { + // Migration done already + } + try { + $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_verify_host'); + $obsoleteParameters[] = 'HTTP/ssl_verify_host'; + } catch (MissingArrayPathException) { + // Migration done already + } + try { + $legacyUserAgent = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/userAgent'); + $newParameters['HTTP/headers/User-Agent'] = $legacyUserAgent; + $obsoleteParameters[] = 'HTTP/userAgent'; + } catch (MissingArrayPathException) { + // Migration done already + } + + // Redirects + try { + $legacyFollowRedirects = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/follow_redirects'); + $obsoleteParameters[] = 'HTTP/follow_redirects'; + } catch (MissingArrayPathException) { + $legacyFollowRedirects = ''; + } + try { + $legacyMaximumRedirects = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/max_redirects'); + $obsoleteParameters[] = 'HTTP/max_redirects'; + } catch (MissingArrayPathException) { + $legacyMaximumRedirects = ''; + } + try { + $legacyStrictRedirects = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/strict_redirects'); + $obsoleteParameters[] = 'HTTP/strict_redirects'; + } catch (MissingArrayPathException) { + $legacyStrictRedirects = ''; + } + + // Check if redirects have been disabled + if ($legacyFollowRedirects !== '' && (bool)$legacyFollowRedirects === false) { + $newParameters['HTTP/allow_redirects'] = false; + } elseif ($legacyMaximumRedirects !== '' || $legacyStrictRedirects !== '') { + $newParameters['HTTP/allow_redirects'] = []; + if ($legacyMaximumRedirects !== '' && (int)$legacyMaximumRedirects !== 5) { + $newParameters['HTTP/allow_redirects']['max'] = (int)$legacyMaximumRedirects; + } + if ($legacyStrictRedirects !== '' && (bool)$legacyStrictRedirects === true) { + $newParameters['HTTP/allow_redirects']['strict'] = true; + } + // defaults are used, no need to set the option in system/settings.php + if (empty($newParameters['HTTP/allow_redirects'])) { + unset($newParameters['HTTP/allow_redirects']); + } + } + + // Migrate Proxy settings + try { + // Currently without protocol or port + $legacyProxyHost = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/proxy_host'); + $obsoleteParameters[] = 'HTTP/proxy_host'; + } catch (MissingArrayPathException) { + $legacyProxyHost = ''; + } + try { + $legacyProxyPort = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/proxy_port'); + $obsoleteParameters[] = 'HTTP/proxy_port'; + } catch (MissingArrayPathException) { + $legacyProxyPort = ''; + } + try { + $legacyProxyUser = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/proxy_user'); + $obsoleteParameters[] = 'HTTP/proxy_user'; + } catch (MissingArrayPathException) { + $legacyProxyUser = ''; + } + try { + $legacyProxyPassword = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/proxy_password'); + $obsoleteParameters[] = 'HTTP/proxy_password'; + } catch (MissingArrayPathException) { + $legacyProxyPassword = ''; + } + // Auth Scheme: Basic, digest etc. + try { + $legacyProxyAuthScheme = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/proxy_auth_scheme'); + $obsoleteParameters[] = 'HTTP/proxy_auth_scheme'; + } catch (MissingArrayPathException) { + $legacyProxyAuthScheme = ''; + } + + if ($legacyProxyHost !== '') { + $proxy = 'http://'; + if ($legacyProxyAuthScheme !== '' && $legacyProxyUser !== '' && $legacyProxyPassword !== '') { + $proxy .= $legacyProxyUser . ':' . $legacyProxyPassword . '@'; + } + $proxy .= $legacyProxyHost; + if ($legacyProxyPort !== '') { + $proxy .= ':' . $legacyProxyPort; + } + $newParameters['HTTP/proxy'] = $proxy; + } + + // Verify peers + // see http://docs.guzzlephp.org/en/latest/request-options.html#verify + try { + $legacySslVerifyPeer = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_verify_peer'); + $obsoleteParameters[] = 'HTTP/ssl_verify_peer'; + } catch (MissingArrayPathException) { + $legacySslVerifyPeer = ''; + } + + // Directory holding multiple Certificate Authority files + try { + $legacySslCaPath = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_capath'); + $obsoleteParameters[] = 'HTTP/ssl_capath'; + } catch (MissingArrayPathException) { + $legacySslCaPath = ''; + } + // Certificate Authority file to verify the peer with (use when ssl_verify_peer is TRUE) + try { + $legacySslCaFile = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_cafile'); + $obsoleteParameters[] = 'HTTP/ssl_cafile'; + } catch (MissingArrayPathException) { + $legacySslCaFile = ''; + } + if ($legacySslVerifyPeer !== '') { + if ($legacySslCaFile !== '' && $legacySslCaPath !== '') { + $newParameters['HTTP/verify'] = $legacySslCaPath . $legacySslCaFile; + } elseif ((bool)$legacySslVerifyPeer === false) { + $newParameters['HTTP/verify'] = false; + } + } + + // SSL Key + Passphrase + // Name of a file containing local certificate + try { + $legacySslLocalCert = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_local_cert'); + $obsoleteParameters[] = 'HTTP/ssl_local_cert'; + } catch (MissingArrayPathException) { + $legacySslLocalCert = ''; + } + + // Passphrase with which local certificate was encoded + try { + $legacySslPassphrase = $this->configurationManager->getLocalConfigurationValueByPath('HTTP/ssl_passphrase'); + $obsoleteParameters[] = 'HTTP/ssl_passphrase'; + } catch (MissingArrayPathException) { + $legacySslPassphrase = ''; + } + + if ($legacySslLocalCert !== '') { + if ($legacySslPassphrase !== '') { + $newParameters['HTTP/ssl_key'] = [ + $legacySslLocalCert, + $legacySslPassphrase, + ]; + } else { + $newParameters['HTTP/ssl_key'] = $legacySslLocalCert; + } + } + + // Update the LocalConfiguration file if obsolete parameters or new parameters are set + if (!empty($obsoleteParameters)) { + $this->configurationManager->removeLocalConfigurationKeysByPath($obsoleteParameters); + $changed = true; + } + if (!empty($newParameters)) { + $this->configurationManager->setLocalConfigurationValuesByPathValuePairs($newParameters); + $changed = true; + } + if ($changed) { + $this->throwConfigurationChangedException(); + } + } + + /** + * Detail configuration of Image Magick settings must be cleared + * if Image Magick handling is disabled. + * + * "Configuration presets" in install tool is not type safe, so value + * comparisons here are not type safe too, to not trigger changes to + * LocalConfiguration again. + * + * @throws ConfigurationChangedException + */ + protected function disableImageMagickDetailSettingsIfImageMagickIsDisabled(): void + { + $changedValues = []; + try { + $currentEnabledValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/processor_enabled'); + } catch (MissingArrayPathException) { + $currentEnabledValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/processor_enabled'); + } + + try { + $currentPathValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/processor_path'); + } catch (MissingArrayPathException) { + $currentPathValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/processor_path'); + } + + try { + $currentImageFileExtValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/imagefile_ext'); + } catch (MissingArrayPathException) { + $currentImageFileExtValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/imagefile_ext'); + } + + try { + $currentThumbnailsValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/thumbnails'); + } catch (MissingArrayPathException) { + $currentThumbnailsValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/thumbnails'); + } + + if (!$currentEnabledValue) { + if ($currentPathValue != '') { + $changedValues['GFX/processor_path'] = ''; + } + if ($currentImageFileExtValue !== 'gif,jpg,jpeg,png') { + $changedValues['GFX/imagefile_ext'] = 'gif,jpg,jpeg,png'; + } + if ($currentThumbnailsValue != 0) { + $changedValues['GFX/thumbnails'] = 0; + } + } + if (!empty($changedValues)) { + $this->configurationManager->setLocalConfigurationValuesByPathValuePairs($changedValues); + $this->throwConfigurationChangedException(); + } + } + + /** + * Detail configuration of Image Magick and Graphics Magick settings + * depending on main values. + * + * "Configuration presets" in install tool is not type safe, so value + * comparisons here are not type safe too, to not trigger changes to + * LocalConfiguration again. + * + * @throws ConfigurationChangedException + */ + protected function setImageMagickDetailSettings(): void + { + $changedValues = []; + try { + $currentProcessorValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/processor'); + } catch (MissingArrayPathException) { + $currentProcessorValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/processor'); + } + + try { + $currentProcessorEffectsValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/processor_effects'); + } catch (MissingArrayPathException) { + $currentProcessorEffectsValue = $this->configurationManager->getDefaultConfigurationValueByPath('GFX/processor_effects'); + } + + if ((string)$currentProcessorValue !== '') { + if (!is_bool($currentProcessorEffectsValue)) { + $changedValues['GFX/processor_effects'] = (int)$currentProcessorEffectsValue > 0; + } + } + if (!empty($changedValues)) { + $this->configurationManager->setLocalConfigurationValuesByPathValuePairs($changedValues); + $this->throwConfigurationChangedException(); + } + } + + /** + * Migrate the definition of the image processor from the configuration value + * im_version_5 to the setting processor. + * + * @throws ConfigurationChangedException + */ + protected function migrateImageProcessorSetting(): void + { + $changedSettings = []; + $settingsToRename = [ + 'GFX/im' => 'GFX/processor_enabled', + 'GFX/im_version_5' => 'GFX/processor', + 'GFX/im_v5effects' => 'GFX/processor_effects', + 'GFX/im_path' => 'GFX/processor_path', + 'GFX/im_path_lzw' => 'GFX/processor_path_lzw', + 'GFX/im_mask_temp_ext_gif' => 'GFX/processor_allowTemporaryMasksAsPng', + 'GFX/im_noScaleUp' => 'GFX/processor_allowUpscaling', + 'GFX/im_noFramePrepended' => 'GFX/processor_allowFrameSelection', + 'GFX/im_stripProfileCommand' => 'GFX/processor_stripColorProfileCommand', + 'GFX/im_useStripProfileByDefault' => 'GFX/processor_stripColorProfileByDefault', + 'GFX/colorspace' => 'GFX/processor_colorspace', + ]; + + foreach ($settingsToRename as $oldPath => $newPath) { + try { + $value = $this->configurationManager->getLocalConfigurationValueByPath($oldPath); + $this->configurationManager->setLocalConfigurationValueByPath($newPath, $value); + $changedSettings[$oldPath] = true; + } catch (MissingArrayPathException) { + // If an exception is thrown, the value is not set in LocalConfiguration + $changedSettings[$oldPath] = false; + } + } + + if (!empty($changedSettings['GFX/im_version_5'])) { + $currentProcessorValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/im_version_5'); + $newProcessorValue = $currentProcessorValue === 'gm' ? 'GraphicsMagick' : 'ImageMagick'; + $this->configurationManager->setLocalConfigurationValueByPath('GFX/processor', $newProcessorValue); + } + + if (!empty($changedSettings['GFX/im_noScaleUp'])) { + $currentProcessorValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/im_noScaleUp'); + $newProcessorValue = !$currentProcessorValue; + $this->configurationManager->setLocalConfigurationValueByPath( + 'GFX/processor_allowUpscaling', + $newProcessorValue + ); + } + + if (!empty($changedSettings['GFX/im_noFramePrepended'])) { + $currentProcessorValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/im_noFramePrepended'); + $newProcessorValue = !$currentProcessorValue; + $this->configurationManager->setLocalConfigurationValueByPath( + 'GFX/processor_allowFrameSelection', + $newProcessorValue + ); + } + + if (!empty($changedSettings['GFX/im_mask_temp_ext_gif'])) { + $currentProcessorValue = $this->configurationManager->getLocalConfigurationValueByPath('GFX/im_mask_temp_ext_gif'); + $newProcessorValue = !$currentProcessorValue; + $this->configurationManager->setLocalConfigurationValueByPath( + 'GFX/processor_allowTemporaryMasksAsPng', + $newProcessorValue + ); + } + + if (!empty(array_filter($changedSettings))) { + $this->configurationManager->removeLocalConfigurationKeysByPath(array_keys($changedSettings)); + $this->throwConfigurationChangedException(); + } + } + + /** + * Migrate the configuration setting BE/lockSSL to boolean if set in the system/settings.php file + * + * @throws ConfigurationChangedException + */ + protected function migrateLockSslSetting(): void + { + try { + $currentOption = $this->configurationManager->getLocalConfigurationValueByPath('BE/lockSSL'); + // check if the current option is an integer/string and if it is active + if (!is_bool($currentOption) && (int)$currentOption > 0) { + $this->configurationManager->setLocalConfigurationValueByPath('BE/lockSSL', true); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Move the database connection settings to a "Default" connection + * + * @throws ConfigurationChangedException + */ + protected function migrateDatabaseConnectionSettings(): void + { + $confManager = $this->configurationManager; + + $newSettings = []; + $removeSettings = []; + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/username'); + $removeSettings[] = 'DB/username'; + $newSettings['DB/Connections/Default/user'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/password'); + $removeSettings[] = 'DB/password'; + $newSettings['DB/Connections/Default/password'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/host'); + $removeSettings[] = 'DB/host'; + $newSettings['DB/Connections/Default/host'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/port'); + $removeSettings[] = 'DB/port'; + $newSettings['DB/Connections/Default/port'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/socket'); + $removeSettings[] = 'DB/socket'; + // Remove empty socket connects + if (!empty($value)) { + $newSettings['DB/Connections/Default/unix_socket'] = $value; + } + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('DB/database'); + $removeSettings[] = 'DB/database'; + $newSettings['DB/Connections/Default/dbname'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = (bool)$confManager->getLocalConfigurationValueByPath('SYS/dbClientCompress'); + $removeSettings[] = 'SYS/dbClientCompress'; + if ($value) { + $newSettings['DB/Connections/Default/driverOptions'] = [ + 'flags' => MYSQLI_CLIENT_COMPRESS, + ]; + } + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = (bool)$confManager->getLocalConfigurationValueByPath('SYS/no_pconnect'); + $removeSettings[] = 'SYS/no_pconnect'; + if (!$value) { + $newSettings['DB/Connections/Default/persistentConnection'] = true; + } + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('SYS/setDBinit'); + $removeSettings[] = 'SYS/setDBinit'; + $newSettings['DB/Connections/Default/initCommands'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + try { + $confManager->getLocalConfigurationValueByPath('DB/Connections/Default/charset'); + } catch (MissingArrayPathException) { + // If there is no charset option yet, add it. + $newSettings['DB/Connections/Default/charset'] = 'utf8'; + } + + try { + $confManager->getLocalConfigurationValueByPath('DB/Connections/Default/driver'); + } catch (MissingArrayPathException) { + // Use the mysqli driver by default if no value has been provided yet + $newSettings['DB/Connections/Default/driver'] = 'mysqli'; + } + + // Add new settings and remove old ones + if (!empty($newSettings)) { + $confManager->setLocalConfigurationValuesByPathValuePairs($newSettings); + } + if (!empty($removeSettings)) { + $confManager->removeLocalConfigurationKeysByPath($removeSettings); + } + + // Throw redirect if something was changed + if (!empty($newSettings) || !empty($removeSettings)) { + $this->throwConfigurationChangedException(); + } + } + + /** + * Migrate the configuration setting DB/Connections/Default/charset to 'utf8' as + * 'utf-8' is not supported by all MySQL versions. + * + * @throws ConfigurationChangedException + */ + protected function migrateDatabaseConnectionCharset(): void + { + $confManager = $this->configurationManager; + try { + $driver = $confManager->getLocalConfigurationValueByPath('DB/Connections/Default/driver'); + $charset = $confManager->getLocalConfigurationValueByPath('DB/Connections/Default/charset'); + if (in_array($driver, ['mysqli', 'pdo_mysql', 'drizzle_pdo_mysql'], true) && $charset === 'utf-8') { + $confManager->setLocalConfigurationValueByPath('DB/Connections/Default/charset', 'utf8'); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no incompatible charset configuration found, so nothing needs to be modified + } + } + + /** + * Migrate the configuration setting DB/Connections/Default/driverOptions to array type. + * + * @throws ConfigurationChangedException + */ + protected function migrateDatabaseDriverOptions(): void + { + $confManager = $this->configurationManager; + try { + $options = $confManager->getLocalConfigurationValueByPath('DB/Connections/Default/driverOptions'); + if (!is_array($options)) { + $confManager->setLocalConfigurationValueByPath( + 'DB/Connections/Default/driverOptions', + ['flags' => (int)$options] + ); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no driver options found, nothing needs to be modified + } + } + + /** + * Migrate single cache hash related options under "FE" into "FE/cacheHash" + * + * @throws ConfigurationChangedException + */ + protected function migrateCacheHashOptions(): void + { + $confManager = $this->configurationManager; + $removeSettings = []; + $newSettings = []; + + try { + $value = $confManager->getLocalConfigurationValueByPath('FE/cHashOnlyForParameters'); + $removeSettings[] = 'FE/cHashOnlyForParameters'; + $newSettings['FE/cacheHash/cachedParametersWhiteList'] = GeneralUtility::trimExplode(',', $value, true); + } catch (MissingArrayPathException) { + // Migration done already + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('FE/cHashExcludedParameters'); + $removeSettings[] = 'FE/cHashExcludedParameters'; + $newSettings['FE/cacheHash/excludedParameters'] = GeneralUtility::trimExplode(',', $value, true); + } catch (MissingArrayPathException) { + // Migration done already + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('FE/cHashRequiredParameters'); + $removeSettings[] = 'FE/cHashRequiredParameters'; + $newSettings['FE/cacheHash/requireCacheHashPresenceParameters'] = GeneralUtility::trimExplode(',', $value, true); + } catch (MissingArrayPathException) { + // Migration done already + } + + try { + $value = $confManager->getLocalConfigurationValueByPath('FE/cHashExcludedParametersIfEmpty'); + $removeSettings[] = 'FE/cHashExcludedParametersIfEmpty'; + if (trim($value) === '*') { + $newSettings['FE/cacheHash/excludeAllEmptyParameters'] = true; + } else { + $newSettings['FE/cacheHash/excludedParametersIfEmpty'] = GeneralUtility::trimExplode(',', $value, true); + } + } catch (MissingArrayPathException) { + // Migration done already + } + + // Add new settings and remove old ones + if (!empty($newSettings)) { + $confManager->setLocalConfigurationValuesByPathValuePairs($newSettings); + } + if (!empty($removeSettings)) { + $confManager->removeLocalConfigurationKeysByPath($removeSettings); + } + + // Throw redirect if something was changed + if (!empty($newSettings) || !empty($removeSettings)) { + $this->throwConfigurationChangedException(); + } + } + + /** + * Migrate SYS/exceptionalErrors to not contain E_USER_DEPRECATED + * + * @throws ConfigurationChangedException + */ + protected function migrateExceptionErrors(): void + { + $confManager = $this->configurationManager; + try { + $currentOption = (int)$confManager->getLocalConfigurationValueByPath('SYS/exceptionalErrors'); + // make sure E_USER_DEPRECATED is not part of the exceptionalErrors + if ($currentOption & E_USER_DEPRECATED) { + $confManager->setLocalConfigurationValueByPath('SYS/exceptionalErrors', $currentOption & ~E_USER_DEPRECATED); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrate SYS/displayErrors to not contain 2 + * + * @throws ConfigurationChangedException + */ + protected function migrateDisplayErrorsSetting(): void + { + $confManager = $this->configurationManager; + try { + $currentOption = (int)$confManager->getLocalConfigurationValueByPath('SYS/displayErrors'); + // make sure displayErrors is set to 2 + if ($currentOption === 2) { + $confManager->setLocalConfigurationValueByPath('SYS/displayErrors', -1); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrate salted passwords extension configuration settings to BE/passwordHashing and FE/passwordHashing + * + * @throws ConfigurationChangedException + */ + protected function migrateSaltedPasswordsSettings(): void + { + $confManager = $this->configurationManager; + $configsToRemove = []; + try { + $extensionConfiguration = (array)$confManager->getLocalConfigurationValueByPath('EXTENSIONS/saltedpasswords'); + $configsToRemove[] = 'EXTENSIONS/saltedpasswords'; + } catch (MissingArrayPathException) { + $extensionConfiguration = []; + } + // Migration already done + if (empty($extensionConfiguration)) { + return; + } + // Upgrade to the best available hash method. This is only done once since that code will no longer be reached + // after first migration because extConf and EXTENSIONS array entries are gone then. Thus, a manual selection + // to some different hash mechanism will not be touched again after first upgrade. + // Phpass is always available, so we have some last fallback if the others don't kick in + $okHashMethods = [ + Argon2iPasswordHash::class, + Argon2idPasswordHash::class, + BcryptPasswordHash::class, + Pbkdf2PasswordHash::class, + PhpassPasswordHash::class, + ]; + $newMethods = []; + foreach (['BE', 'FE'] as $mode) { + foreach ($okHashMethods as $className) { + /** @var PasswordHashInterface $instance */ + $instance = GeneralUtility::makeInstance($className); + if ($instance->isAvailable()) { + $newMethods[$mode] = $className; + break; + } + } + } + // We only need to write to LocalConfiguration if method is different from Argon2i in DefaultConfiguration + $newConfig = []; + if ($newMethods['BE'] !== Argon2iPasswordHash::class) { + $newConfig['BE/passwordHashing/className'] = $newMethods['BE']; + } + if ($newMethods['FE'] !== Argon2iPasswordHash::class) { + $newConfig['FE/passwordHashing/className'] = $newMethods['FE']; + } + if (!empty($newConfig)) { + $confManager->setLocalConfigurationValuesByPathValuePairs($newConfig); + } + $confManager->removeLocalConfigurationKeysByPath($configsToRemove); + $this->throwConfigurationChangedException(); + } + + /** + * Renames all SYS[caching][cache] configuration names to names without the prefix "cache_". + * see #88366 + * + * @throws ConfigurationChangedException + */ + protected function migrateCachingFrameworkCaches(): void + { + $confManager = $this->configurationManager; + try { + $cacheConfigurations = (array)$confManager->getLocalConfigurationValueByPath('SYS/caching/cacheConfigurations'); + $newConfig = []; + $hasBeenModified = false; + foreach ($cacheConfigurations as $identifier => $cacheConfiguration) { + if (str_starts_with($identifier, 'cache_')) { + $identifier = substr($identifier, 6); + $hasBeenModified = true; + } + $newConfig[$identifier] = $cacheConfiguration; + } + + if ($hasBeenModified) { + $confManager->setLocalConfigurationValueByPath('SYS/caching/cacheConfigurations', $newConfig); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrates "mail" to "sendmail" as "mail" (PHP's built-in mail() method) is not supported anymore + * with Symfony components. + * See #88643 + * + * @throws ConfigurationChangedException + */ + protected function migrateMailSettingsToSendmail(): void + { + $confManager = $this->configurationManager; + try { + $transport = $confManager->getLocalConfigurationValueByPath('MAIL/transport'); + if ($transport === 'mail') { + $confManager->setLocalConfigurationValueByPath('MAIL/transport', 'sendmail'); + $confManager->setLocalConfigurationValueByPath('MAIL/transport_sendmail_command', (string)@ini_get('sendmail_path')); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrates MAIL/transport_smtp_encrypt to a boolean value + * See #91070, #90295, #88643 and https://github.com/symfony/symfony/commit/5b8c4676d059 + * + * @throws ConfigurationChangedException + */ + protected function migrateMailSmtpEncryptSetting(): void + { + $confManager = $this->configurationManager; + try { + $transport = $confManager->getLocalConfigurationValueByPath('MAIL/transport'); + if ($transport === 'smtp') { + $encrypt = $confManager->getLocalConfigurationValueByPath('MAIL/transport_smtp_encrypt'); + if (is_string($encrypt)) { + // SwiftMailer used 'tls' as identifier to connect with STARTTLS via SMTP (as usually used with port 587). + // See https://github.com/swiftmailer/swiftmailer/blob/v5.4.10/lib/classes/Swift/Transport/EsmtpTransport.php#L144 + if ($encrypt === 'tls') { + // With TYPO3 v10 the MAIL/transport_smtp_encrypt option is passed as constructor parameter $tls to + // Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + // $tls = true instructs to start a SMTPS connection – that means SSL/TLS via SMTPS, not STARTTLS via SMTP. + // That means symfony/mailer will use STARTTLS when $tls = false or ($tls = null with port != 465) is passed. + // Actually symfony/mailer will use STARTTLS by default now. + // Due to the misleading name (transport_smtp_encrypt) we avoid to set the option to false, but rather remove it. + // Note: symfony/mailer provides no way to enforce STARTTLS usage, see https://github.com/symfony/symfony/commit/5b8c4676d059 + $confManager->removeLocalConfigurationKeysByPath(['MAIL/transport_smtp_encrypt']); + } elseif ($encrypt === '') { + $confManager->setLocalConfigurationValueByPath('MAIL/transport_smtp_encrypt', false); + } else { + $confManager->setLocalConfigurationValueByPath('MAIL/transport_smtp_encrypt', true); + } + $this->throwConfigurationChangedException(); + } + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrate [FE][versionNumberInFilename] to become a boolean flag + * + * @throws ConfigurationChangedException + */ + protected function migrateVersionNumberInFileNameSetting(): void + { + try { + $confManager = $this->configurationManager; + $currentOption = $confManager->getLocalConfigurationValueByPath('FE/versionNumberInFilename'); + if ($currentOption === true) { + return; + } + if ($currentOption === 'embed') { + $confManager->setLocalConfigurationValueByPath('FE/versionNumberInFilename', true); + } else { + $confManager->removeLocalConfigurationKeysByPath(['FE/versionNumberInFilename']); + } + $this->throwConfigurationChangedException(); + } catch (MissingArrayPathException) { + // no flag set, so nothing to be configured + } + } + + /** + * Remove the colorspace setting if it's already the recommended default for a given processor + * + * @throws ConfigurationChangedException + */ + protected function removeDefaultColorspaceSettings(): void + { + try { + $confManager = $this->configurationManager; + $currentProcessor = $confManager->getLocalConfigurationValueByPath('GFX/processor'); + $currentColorspace = $confManager->getLocalConfigurationValueByPath('GFX/processor_colorspace'); + + if ($currentProcessor === 'ImageMagick' && $currentColorspace === 'sRGB' + || $currentProcessor === 'GraphicsMagick' && $currentColorspace === 'RGB') { + $confManager->removeLocalConfigurationKeysByPath(['GFX/processor_colorspace']); + $this->throwConfigurationChangedException(); + } + } catch (MissingArrayPathException) { + // no change inside the system/settings.php found, so nothing needs to be modified + } + } + + /** + * Migrate 'SYS'/'lang' related configuration settings to 'LANG' + * + * @throws ConfigurationChangedException + */ + protected function migrateLanguageSettings(): void + { + $confManager = $this->configurationManager; + $newSettings = []; + $removeSettings = []; + + // Migrate SYS/lang/requireApprovedLocalizations => LANG/requireApprovedLocalizations + try { + $value = $confManager->getLocalConfigurationValueByPath('SYS/lang/requireApprovedLocalizations'); + $removeSettings[] = 'SYS/lang/requireApprovedLocalizations'; + $newSettings['LANG/requireApprovedLocalizations'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + // Migrate SYS/lang/format => LANG/format + try { + $value = $confManager->getLocalConfigurationValueByPath('SYS/lang/format'); + $removeSettings[] = 'SYS/lang/format'; + $newSettings['LANG/format'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + // Migrate EXTCONF/lang/availableLanguages => LANG/availableLocales + try { + $value = $confManager->getLocalConfigurationValueByPath('EXTCONF/lang/availableLanguages'); + $removeSettings[] = 'EXTCONF/lang/availableLanguages'; + $newSettings['LANG/availableLocales'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + // Migrate SYS/locallangXMLOverride => LANG/resourceOverrides + try { + $value = $confManager->getLocalConfigurationValueByPath('SYS/locallangXMLOverride'); + $removeSettings[] = 'SYS/locallangXMLOverride'; + $newSettings['LANG/resourceOverrides'] = $value; + } catch (MissingArrayPathException) { + // Old setting does not exist, do nothing + } + + // Add new settings and remove old ones + if (!empty($newSettings)) { + $confManager->setLocalConfigurationValuesByPathValuePairs($newSettings); + } + if (!empty($removeSettings)) { + $confManager->removeLocalConfigurationKeysByPath($removeSettings); + } + + // Throw redirect if something was changed + if (!empty($newSettings) || !empty($removeSettings)) { + $this->throwConfigurationChangedException(); + } + } +} diff --git a/Classes/Service/UpgradeWizardsService.php b/Classes/Service/UpgradeWizardsService.php new file mode 100644 index 0000000..5b40ad5 --- /dev/null +++ b/Classes/Service/UpgradeWizardsService.php @@ -0,0 +1,377 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Service; + +use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Upgrades\ChattyInterface; +use TYPO3\CMS\Core\Upgrades\ConfirmableInterface; +use TYPO3\CMS\Core\Upgrades\RepeatableInterface; +use TYPO3\CMS\Core\Upgrades\RowUpdater\RowUpdaterInterface; +use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface; +use TYPO3\CMS\Core\Upgrades\UpgradeWizardRegistry; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Service class helps to manage upgrade wizards. + * + * @internal This class is only meant to be used within `EXT:core` and `EXT:install` and is not part of the TYPO3 Core API. + */ +#[Autoconfigure(public: true)] +final class UpgradeWizardsService +{ + private StreamOutput $output; + + public function __construct( + private readonly UpgradeWizardRegistry $upgradeWizardRegistry, + private readonly Registry $registry, + ) { + $fileName = 'php://temp'; + if (($stream = fopen($fileName, 'wb')) === false) { + throw new \RuntimeException('Unable to open stream "' . $fileName . '"', 1598341765); + } + $this->output = new StreamOutput($stream, Output::VERBOSITY_NORMAL, false); + } + + /** + * @return array List of wizards marked as done in registry + */ + public function listOfWizardsDone(): array + { + $wizardsDoneInRegistry = []; + foreach ($this->upgradeWizardRegistry->getUpgradeWizards() as $identifier => $serviceName) { + if ($this->registry->get('installUpdate', $serviceName, false)) { + $wizardsDoneInRegistry[] = [ + 'class' => $serviceName, + 'identifier' => $identifier, + // @todo fetching the service to get the title should be improved + 'title' => $this->upgradeWizardRegistry->getUpgradeWizard($identifier)->getTitle(), + ]; + } + } + return $wizardsDoneInRegistry; + } + + /** + * @return array List of row updaters marked as done in registry + * @throws \RuntimeException + */ + public function listOfRowUpdatersDone(): array + { + $rowUpdatersDoneClassNames = $this->registry->get('installUpdateRows', 'rowUpdatersDone', []); + $rowUpdatersDone = []; + foreach ($rowUpdatersDoneClassNames as $rowUpdaterClassName) { + // Silently skip non-existing DatabaseRowsUpdateWizards + if (!class_exists($rowUpdaterClassName)) { + continue; + } + $rowUpdater = GeneralUtility::makeInstance($rowUpdaterClassName); + if (!$rowUpdater instanceof RowUpdaterInterface) { + throw new \RuntimeException( + 'Row updater must implement RowUpdaterInterface', + 1484152906 + ); + } + $rowUpdatersDone[] = [ + 'class' => $rowUpdaterClassName, + 'identifier' => $rowUpdaterClassName, + 'title' => $rowUpdater->getTitle(), + ]; + } + return $rowUpdatersDone; + } + + /** + * Mark one wizard as undone. This can be a "casual" wizard + * or a single "row updater". + * + * @param string $identifier Wizard or RowUpdater identifier + * @return bool True if wizard has been marked as undone + * @throws \RuntimeException + */ + public function markWizardUndone(string $identifier): bool + { + $this->assertIdentifierIsValid($identifier); + + $aWizardHasBeenMarkedUndone = false; + foreach ($this->listOfWizardsDone() as $wizard) { + if ($wizard['identifier'] === $identifier) { + $aWizardHasBeenMarkedUndone = true; + $this->registry->set('installUpdate', $wizard['class'], 0); + } + } + if (!$aWizardHasBeenMarkedUndone) { + $rowUpdatersDoneList = $this->listOfRowUpdatersDone(); + $registryArray = $this->registry->get('installUpdateRows', 'rowUpdatersDone', []); + foreach ($rowUpdatersDoneList as $rowUpdater) { + if ($rowUpdater['identifier'] === $identifier) { + $aWizardHasBeenMarkedUndone = true; + foreach ($registryArray as $rowUpdaterMarkedAsDonePosition => $rowUpdaterMarkedAsDone) { + if ($rowUpdaterMarkedAsDone === $rowUpdater['class']) { + unset($registryArray[$rowUpdaterMarkedAsDonePosition]); + break; + } + } + $this->registry->set('installUpdateRows', 'rowUpdatersDone', $registryArray); + } + } + } + return $aWizardHasBeenMarkedUndone; + } + + /** + * Get list of registered upgrade wizards not marked done. + * + * @return array List of upgrade wizards in correct order with detail information + */ + public function getUpgradeWizardsList(): array + { + $wizards = []; + foreach (array_keys($this->upgradeWizardRegistry->getUpgradeWizards()) as $identifier) { + if ($this->isWizardDone($identifier)) { + continue; + } + + $wizards[] = $this->getWizardInformationByIdentifier($identifier); + } + return $wizards; + } + + public function getWizardInformationByIdentifier(string $identifier): array + { + $this->assertIdentifierIsValid($identifier); + + if (is_subclass_of($identifier, RowUpdaterInterface::class)) { + return [ + 'class' => $identifier, + 'identifier' => $identifier, + 'title' => $identifier, + 'shouldRenderWizard' => false, + 'explanation' => '', + ]; + } + + $wizard = $this->upgradeWizardRegistry->getUpgradeWizard($identifier); + + if ($wizard instanceof ChattyInterface) { + $wizard->setOutput($this->output); + } + + return [ + 'class' => $wizard::class, + 'identifier' => $identifier, + 'title' => $wizard->getTitle(), + 'shouldRenderWizard' => $wizard->updateNecessary(), + 'explanation' => $wizard->getDescription(), + ]; + } + + /** + * Execute the "get user input" step of a wizard + * + * @throws \RuntimeException + */ + public function getWizardUserInput(string $identifier): array + { + $this->assertIdentifierIsValid($identifier); + + $wizard = $this->upgradeWizardRegistry->getUpgradeWizard($identifier); + $wizardHtml = ''; + if ($wizard instanceof ConfirmableInterface) { + $markup = []; + $radioAttributes = [ + 'type' => 'radio', + 'class' => 'btn-check', + 'name' => 'install[values][' . $identifier . '][install]', + 'value' => '0', + ]; + $markup[] = '<div class="panel panel-danger">'; + $markup[] = ' <div class="panel-heading">'; + $markup[] = htmlspecialchars($wizard->getConfirmation()->getTitle()); + $markup[] = ' </div>'; + $markup[] = ' <div class="panel-body">'; + $markup[] = ' <p>' . nl2br(htmlspecialchars($wizard->getConfirmation()->getMessage())) . '</p>'; + $markup[] = ' <div class="btn-group">'; + if (!$wizard->getConfirmation()->isRequired()) { + $denyChecked = $wizard->getConfirmation()->getDefaultValue() === false ? ' checked' : ''; + $markup[] = ' <input ' . GeneralUtility::implodeAttributes($radioAttributes, true) . $denyChecked . ' id="upgrade-wizard-deny">'; + $markup[] = ' <label class="btn btn-default" for="upgrade-wizard-deny">' . $wizard->getConfirmation()->getDeny() . '</label>'; + } + $radioAttributes['value'] = '1'; + $confirmChecked = $wizard->getConfirmation()->getDefaultValue() === true ? ' checked' : ''; + $markup[] = ' <input ' . GeneralUtility::implodeAttributes($radioAttributes, true) . $confirmChecked . ' id="upgrade-wizard-confirm">'; + $markup[] = ' <label class="btn btn-default" for="upgrade-wizard-confirm">' . $wizard->getConfirmation()->getConfirm() . '</label>'; + $markup[] = ' </div>'; + $markup[] = ' </div>'; + $markup[] = '</div>'; + $wizardHtml = implode('', $markup); + } + + $result = [ + 'identifier' => $identifier, + 'title' => $wizard->getTitle(), + 'description' => $wizard->getDescription(), + 'wizardHtml' => $wizardHtml, + ]; + + return $result; + } + + /** + * Execute a single update wizard + * + * @throws \RuntimeException + */ + public function executeWizard(string $identifier, array $values): FlashMessageQueue + { + $performResult = false; + $this->assertIdentifierIsValid($identifier); + + $wizard = $this->upgradeWizardRegistry->getUpgradeWizard($identifier); + + if ($wizard instanceof ChattyInterface) { + $wizard->setOutput($this->output); + } + $messages = new FlashMessageQueue('install'); + + if ($wizard instanceof ConfirmableInterface) { + // value is set in request but is empty + $isSetButEmpty = isset($values[$identifier]['install']) && empty($values[$identifier]['install']); + $checkValue = (int)$values[$identifier]['install']; + + if ($checkValue === 1) { + // confirmation = yes, we do the update + $performResult = $wizard->executeUpdate(); + } elseif ($wizard->getConfirmation()->isRequired()) { + // confirmation = no, but is required, we do *not* the update and fail + $performResult = false; + } elseif ($isSetButEmpty) { + // confirmation = no, but it is *not* required, we do *not* the update, but mark the wizard as done + $this->output->writeln('No changes applied, marking wizard as done.'); + // confirmation was set to "no" + $performResult = true; + } + } else { + // confirmation yes or non-confirmable + $performResult = $wizard->executeUpdate(); + } + + $stream = $this->output->getStream(); + rewind($stream); + if ($performResult) { + if (!$wizard instanceof RepeatableInterface) { + // mark wizard as done if it's not repeatable and was successful + $this->markWizardAsDone($wizard); + } + $messages->enqueue( + new FlashMessage( + (string)stream_get_contents($stream), + 'Update successful' + ) + ); + } else { + $messages->enqueue( + new FlashMessage( + (string)stream_get_contents($stream), + 'Update failed!', + ContextualFeedbackSeverity::ERROR + ) + ); + } + return $messages; + } + + /** + * Marks some wizard as being "seen" so that it not shown again. + * Writes the info in system/settings.php + */ + public function markWizardAsDone(UpgradeWizardInterface $upgradeWizard): void + { + $this->registry->set('installUpdate', $upgradeWizard::class, 1); + } + + /** + * Checks if this wizard has been "done" before + * + * @return bool TRUE if wizard has been done before, FALSE otherwise + * @throws \RuntimeException + */ + public function isWizardDone(string $identifier): bool + { + $this->assertIdentifierIsValid($identifier); + + return (bool)$this->registry->get( + 'installUpdate', + $this->upgradeWizardRegistry->getUpgradeWizard($identifier)::class, + false + ); + } + + /** + * Wrapper to catch \UnexpectedValueException for backwards compatibility reasons + */ + public function getUpgradeWizard(string $identifier): ?UpgradeWizardInterface + { + try { + return $this->upgradeWizardRegistry->getUpgradeWizard($identifier); + } catch (\UnexpectedValueException) { + return null; + } + } + + public function getUpgradeWizardIdentifiers(): array + { + return array_keys($this->upgradeWizardRegistry->getUpgradeWizards()); + } + + public function getNonRepeatableUpgradeWizards(): array + { + $nonRepeatableUpgradeWizards = []; + foreach ($this->upgradeWizardRegistry->getUpgradeWizards() as $identifier => $updateClassName) { + if (!in_array(RepeatableInterface::class, class_implements($updateClassName) ?: [], true)) { + $nonRepeatableUpgradeWizards[$identifier] = $updateClassName; + } + } + return $nonRepeatableUpgradeWizards; + } + + /** + * Validate identifier exists in upgrade wizard list + * + * @throws \RuntimeException + */ + private function assertIdentifierIsValid(string $identifier): void + { + if ($identifier === '') { + throw new \RuntimeException('Empty upgrade wizard identifier given', 1650579934); + } + if (!is_subclass_of($identifier, RowUpdaterInterface::class) + && !$this->upgradeWizardRegistry->hasUpgradeWizard($identifier) + ) { + throw new \RuntimeException( + 'The upgrade wizard identifier "' . $identifier . '" must either be registered as upgrade wizard or it must implement TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface', + 1650546252 + ); + } + } +} diff --git a/Classes/ServiceProvider.php b/Classes/ServiceProvider.php new file mode 100644 index 0000000..c922f00 --- /dev/null +++ b/Classes/ServiceProvider.php @@ -0,0 +1,806 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core; + +use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Console\Command\DumpCompletionCommand as SymfonyDumpCompletionCommand; +use Symfony\Component\Console\Command\HelpCommand; +use Symfony\Component\Translation\Translator as SymfonyTranslator; +use Symfony\Component\Yaml\Command\LintCommand as SymfonyLintCommand; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyEventDispatcherInterface; +use TYPO3\CMS\Core\Adapter\EventDispatcherAdapter as SymfonyEventDispatcher; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Command\Output\MessageRenderer; +use TYPO3\CMS\Core\Configuration\ConfigurationManager; +use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader; +use TYPO3\CMS\Core\Core\Bootstrap; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\DependencyInjection\ContainerBuilder; +use TYPO3\CMS\Core\Imaging\IconRegistry; +use TYPO3\CMS\Core\Package\AbstractServiceProvider; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Resource\Security\FileNameValidator; +use TYPO3\CMS\Core\Service\SilentConfigurationUpgradeService; +use TYPO3\CMS\Core\Type\Map; +use TYPO3\CMS\Core\TypoScript\Tokenizer\LossyTokenizer; +use TYPO3\CMS\Core\Utility\File\FileSystem; + +/** + * @internal + */ +class ServiceProvider extends AbstractServiceProvider +{ + protected static function getPackagePath(): string + { + return __DIR__ . '/../'; + } + + protected static function getPackageName(): string + { + return 'typo3/cms-core'; + } + + public function getFactories(): array + { + return [ + SymfonyEventDispatcher::class => self::getSymfonyEventDispatcher(...), + SymfonyLintCommand::class => self::getSymfonyLintCommand(...), + SymfonyDumpCompletionCommand::class => self::getSymfonyDumpCompletionCommand(...), + SymfonyTranslator::class => self::getSymfonyTranslator(...), + Cache\CacheManager::class => self::getCacheManager(...), + Charset\CharsetConverter::class => self::getCharsetConverter(...), + Charset\CharsetProvider::class => self::getCharsetProvider(...), + Configuration\Features::class => self::getFeatures(...), + Configuration\Loader\YamlFileLoader::class => self::getYamlFileLoader(...), + Configuration\SiteWriter::class => self::getSiteWriter(...), + Command\ListCommand::class => self::getListCommand(...), + HelpCommand::class => self::getHelpCommand(...), + Command\AssetPublishCommand::class => self::getAssetPublishCommand(...), + Command\CacheFlushCommand::class => self::getCacheFlushCommand(...), + Command\CacheWarmupCommand::class => self::getCacheWarmupCommand(...), + Command\DumpAutoloadCommand::class => self::getDumpAutoloadCommand(...), + Command\UpdateLanguagePackCommand::class => self::getUpdateLanguagePackCommand(...), + Command\UpgradeWizardRunCommand::class => self::getUpgradeWizardRunCommand(...), + Command\UpgradeWizardListCommand::class => self::getUpgradeWizardListCommand(...), + Command\UpgradeWizardMarkUndoneCommand::class => self::getUpgradeWizardMarkUndoneCommand(...), + Console\CommandApplication::class => self::getConsoleCommandApplication(...), + Console\CommandRegistry::class => self::getConsoleCommandRegistry(...), + Context\Context::class => self::getContext(...), + Core\BootService::class => self::getBootService(...), + Crypto\HashService::class => self::getHashService(...), + Crypto\PasswordHashing\PasswordHashFactory::class => self::getPasswordHashFactory(...), + EventDispatcher\EventDispatcher::class => self::getEventDispatcher(...), + EventDispatcher\ListenerProvider::class => self::getEventListenerProvider(...), + FormProtection\FormProtectionFactory::class => self::getFormProtectionFactory(...), + Http\Client\GuzzleClientFactory::class => self::getGuzzleClientFactory(...), + Http\RequestFactory::class => self::getRequestFactory(...), + Imaging\IconFactory::class => self::getIconFactory(...), + Imaging\IconRegistry::class => self::getIconRegistry(...), + Imaging\IconProvider\SvgIconProvider::class => self::getSvgIconProvider(...), + Imaging\IconProvider\SvgSpriteIconProvider::class => self::getSvgSpriteIconProvider(...), + Imaging\Svg\SvgDocumentFactory::class => self::getSvgDocumentFactory(...), + Imaging\Svg\SvgDocumentService::class => self::getSvgDocumentService(...), + Localization\LabelFileResolver::class => self::getLabelFileResolver(...), + Localization\TranslationDomainResolver::class => self::getTranslationDomainResolver(...), + Localization\TranslationDomainMapper::class => self::getTranslationDomainMapper(...), + Localization\LanguageServiceFactory::class => self::getLanguageServiceFactory(...), + Localization\Locales::class => self::getLocales(...), + Localization\LocalizationFactory::class => self::getLocalizationFactory(...), + Mail\Mailer::class => self::getMailer(...), + Mail\TemplatedEmailFactory::class => self::getTemplatedEmailFactory(...), + Mail\TransportFactory::class => self::getMailTransportFactory(...), + Messaging\FlashMessageService::class => self::getFlashMessageService(...), + Middleware\ResponsePropagation::class => self::getResponsePropagationMiddleware(...), + Middleware\VerifyHostHeader::class => self::getVerifyHostHeaderMiddleware(...), + Package\FailsafePackageManager::class => self::getFailsafePackageManager(...), + Package\Cache\PackageDependentCacheIdentifier::class => self::getPackageDependentCacheIdentifier(...), + PasswordPolicy\PasswordService::class => self::getPasswordService(...), + Routing\BackendEntryPointResolver::class => self::getBackendEntryPointResolver(...), + Routing\RequestContextFactory::class => self::getRequestContextFactory(...), + Resource\Security\FileNameValidator::class => self::getFileNameValidator(...), + Resource\Security\SvgSanitizer::class => self::getSvgSanitizer(...), + Service\DependencyOrderingService::class => self::getDependencyOrderingService(...), + Service\OpcodeCacheService::class => self::getOpcodeCacheService(...), + Service\SilentConfigurationUpgradeService::class => self::getSilentConfigurationUpgradeService(...), + TypoScript\TypoScriptStringFactory::class => self::getTypoScriptStringFactory(...), + TypoScript\TypoScriptService::class => self::getTypoScriptService(...), + TypoScript\AST\Traverser\AstTraverser::class => self::getAstTraverser(...), + TypoScript\AST\CommentAwareAstBuilder::class => self::getCommentAwareAstBuilder(...), + TypoScript\Tokenizer\LosslessTokenizer::class => [ self::class, 'getLosslessTokenizer'], + 'icons' => self::getIcons(...), + 'middlewares' => self::getMiddlewares(...), + 'cache.assets' => self::getAssetsCache(...), + 'cache.runtime' => self::getRuntimeCache(...), + 'content.security.policies' => self::getContentSecurityPolicies(...), + 'fluid.namespaces' => self::getFluidNamespaces(...), + 'fluid.component.collections' => self::getFluidComponentCollections(...), + ]; + } + + public function getExtensions(): array + { + return [ + Console\CommandRegistry::class => self::configureCommands(...), + Imaging\IconRegistry::class => self::configureIconRegistry(...), + EventDispatcherInterface::class => self::provideFallbackEventDispatcher(...), + EventDispatcher\ListenerProvider::class => self::extendEventListenerProvider(...), + Database\ConnectionPool::class => self::ensureConnectionPoolBootState(...), + SystemResource\SystemResourceFactory::class => self::provideFallbackSystemResourceFactory(...), + SystemResource\Publishing\SystemResourcePublisherInterface::class => self::provideFallbackSystemResourcePublisher(...), + SystemResource\Identifier\SystemResourceIdentifierFactory::class => self::provideFallbackSystemResourceIdentifierFactory(...), + ] + parent::getExtensions(); + } + + public static function getSymfonyEventDispatcher(ContainerInterface $container): SymfonyEventDispatcherInterface + { + return self::new($container, SymfonyEventDispatcher::class, [ + $container->get(EventDispatcherInterface::class), + ]); + } + + public static function getCacheManager(ContainerInterface $container): Cache\CacheManager + { + if (!$container->get('boot.state')->complete) { + throw new \LogicException(Cache\CacheManager::class . ' can not be injected/instantiated during ext_localconf.php or TCA loading. Use lazy loading instead.', 1638976434); + } + + $cacheConfigurations = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] ?? []; + $disableCaching = $container->get('boot.state')->cacheDisabled; + $defaultCaches = [ + $container->get('cache.core'), + $container->get('cache.assets'), + $container->get('cache.runtime'), + $container->get('cache.di'), + ]; + + $cacheManager = self::new($container, Cache\CacheManager::class, [$disableCaching]); + $cacheManager->setCacheConfigurations($cacheConfigurations); + $cacheConfigurations['di']['groups'] = ['system']; + foreach ($defaultCaches as $cache) { + $cacheManager->registerCache($cache, $cacheConfigurations[$cache->getIdentifier()]['groups'] ?? ['all']); + } + + return $cacheManager; + } + + public static function ensureConnectionPoolBootState(ContainerInterface $container, ?Database\ConnectionPool $connectionPool): Database\ConnectionPool + { + if ($connectionPool === null) { + throw new \LogicException(Database\ConnectionPool::class . ' can not be used from failsafe container. Please use LateBootService to obtain a container instance.', 1751358504); + } + if (!$container->get('boot.state')->complete) { + throw new \LogicException(Database\ConnectionPool::class . ' can not be injected/instantiated during ext_localconf.php or TCA loading. Use lazy loading instead.', 1638976490); + } + return $connectionPool; + } + + public static function getCharsetConverter(ContainerInterface $container): Charset\CharsetConverter + { + return self::new($container, Charset\CharsetConverter::class, [ + $container->get(Charset\CharsetProvider::class), + ]); + } + + public static function getCharsetProvider(ContainerInterface $container): Charset\CharsetProvider + { + return self::new($container, Charset\CharsetProvider::class); + } + + public static function getFeatures(ContainerInterface $container): Configuration\Features + { + return self::new($container, Configuration\Features::class); + } + + public static function getYamlFileLoader(ContainerInterface $container): Configuration\Loader\YamlFileLoader + { + return self::new($container, Configuration\Loader\YamlFileLoader::class, [ + $container->get(Log\LogManager::class)->getLogger(Configuration\Loader\YamlFileLoader::class), + ]); + } + + public static function getSiteWriter(ContainerInterface $container): Configuration\SiteWriter + { + return self::new($container, Configuration\SiteWriter::class, [ + Environment::getConfigPath() . '/sites', + $container->get(EventDispatcherInterface::class), + $container->get(YamlFileLoader::class), + ]); + } + + public static function getListCommand(ContainerInterface $container): Command\ListCommand + { + return new Command\ListCommand( + $container, + $container->get(Core\BootService::class) + ); + } + + public static function getHelpCommand(ContainerInterface $container): HelpCommand + { + return new HelpCommand(); + } + + public static function getSymfonyLintCommand(ContainerInterface $container): SymfonyLintCommand + { + return new SymfonyLintCommand(); + } + + public static function getSymfonyDumpCompletionCommand(ContainerInterface $container): SymfonyDumpCompletionCommand + { + return new SymfonyDumpCompletionCommand(); + } + + public static function getAssetPublishCommand(ContainerInterface $container): Command\AssetPublishCommand + { + return new Command\AssetPublishCommand( + $container->get(Core\BootService::class), + $container->get(Package\PackageManager::class), + new MessageRenderer(), + ); + } + + public static function getCacheFlushCommand(ContainerInterface $container): Command\CacheFlushCommand + { + return new Command\CacheFlushCommand( + $container->get(Core\BootService::class), + $container->get('cache.di') + ); + } + + public static function getCacheWarmupCommand(ContainerInterface $container): Command\CacheWarmupCommand + { + return new Command\CacheWarmupCommand( + $container->get(ContainerBuilder::class), + $container->get(Package\PackageManager::class), + $container->get(Core\BootService::class), + $container->get('cache.di') + ); + } + + public static function getDumpAutoloadCommand(ContainerInterface $container): Command\DumpAutoloadCommand + { + return new Command\DumpAutoloadCommand(); + } + + public static function getUpdateLanguagePackCommand(ContainerInterface $container): Command\UpdateLanguagePackCommand + { + return new Command\UpdateLanguagePackCommand( + 'language:update', + $container->get(Core\BootService::class) + ); + } + + public static function getUpgradeWizardRunCommand(ContainerInterface $container): Command\UpgradeWizardRunCommand + { + return new Command\UpgradeWizardRunCommand( + 'upgrade:run', + $container->get(Core\BootService::class), + $container->get(SilentConfigurationUpgradeService::class) + ); + } + + public static function getUpgradeWizardListCommand(ContainerInterface $container): Command\UpgradeWizardListCommand + { + return new Command\UpgradeWizardListCommand( + 'upgrade:list', + $container->get(Core\BootService::class), + ); + } + + public static function getUpgradeWizardMarkUndoneCommand(ContainerInterface $container): Command\UpgradeWizardMarkUndoneCommand + { + return new Command\UpgradeWizardMarkUndoneCommand( + 'upgrade:mark:undone', + $container->get(Core\BootService::class), + ); + } + + public static function getConsoleCommandApplication(ContainerInterface $container): Console\CommandApplication + { + return new Console\CommandApplication( + $container->get(Context\Context::class), + $container->get(Console\CommandRegistry::class), + $container->get(SymfonyEventDispatcher::class), + $container->get(Configuration\ConfigurationManager::class), + $container->get(Core\BootService::class), + $container->get(Localization\LanguageServiceFactory::class) + ); + } + + public static function getConsoleCommandRegistry(ContainerInterface $container): Console\CommandRegistry + { + return new Console\CommandRegistry($container); + } + + public static function getEventDispatcher(ContainerInterface $container): EventDispatcher\EventDispatcher + { + return new EventDispatcher\EventDispatcher( + $container->get(EventDispatcher\ListenerProvider::class) + ); + } + + public static function getEventListenerProvider(ContainerInterface $container): EventDispatcher\ListenerProvider + { + return new EventDispatcher\ListenerProvider($container); + } + + public static function extendEventListenerProvider( + ContainerInterface $container, + EventDispatcher\ListenerProvider $listenerProvider + ): EventDispatcher\ListenerProvider { + $listenerProvider->addListener( + Package\Event\PackagesMayHaveChangedEvent::class, + Package\PackageManager::class, + 'packagesMayHaveChanged' + ); + + $cacheWarmers = [ + Imaging\IconRegistry::class, + Package\PackageManager::class, + ]; + foreach ($cacheWarmers as $service) { + $listenerProvider->addListener(Cache\Event\CacheWarmupEvent::class, $service, 'warmupCaches'); + } + + $listenerProvider->addListener(Cache\Event\CacheFlushEvent::class, Cache\CacheManager::class, 'handleCacheFlushEvent'); + + return $listenerProvider; + } + + public static function getContext(ContainerInterface $container): Context\Context + { + return new Context\Context(); + } + + public static function getBootService(ContainerInterface $container): Core\BootService + { + if ($container->has('_early.boot-service')) { + return $container->get('_early.boot-service'); + } + return new Core\BootService( + $container->get(ContainerBuilder::class), + $container + ); + } + + public static function getPasswordHashFactory(ContainerInterface $container): Crypto\PasswordHashing\PasswordHashFactory + { + return new Crypto\PasswordHashing\PasswordHashFactory(); + } + + public static function getIconFactory(ContainerInterface $container): Imaging\IconFactory + { + return self::new($container, Imaging\IconFactory::class, [ + $container->get(EventDispatcherInterface::class), + $container->get(Imaging\IconRegistry::class), + $container, + $container->get('cache.runtime'), + ]); + } + + public static function getSvgIconProvider(ContainerInterface $container): Imaging\IconProvider\SvgIconProvider + { + $provider = self::new($container, Imaging\IconProvider\SvgIconProvider::class); + $provider->injectSvgDocumentFactory($container->get(Imaging\Svg\SvgDocumentFactory::class)); + $provider->injectSvgDocumentService($container->get(Imaging\Svg\SvgDocumentService::class)); + return $provider; + } + + public static function getSvgSpriteIconProvider(ContainerInterface $container): Imaging\IconProvider\SvgSpriteIconProvider + { + $provider = self::new($container, Imaging\IconProvider\SvgSpriteIconProvider::class); + $provider->injectSvgDocumentFactory($container->get(Imaging\Svg\SvgDocumentFactory::class)); + $provider->injectSvgDocumentService($container->get(Imaging\Svg\SvgDocumentService::class)); + return $provider; + } + + public static function getSvgDocumentFactory(ContainerInterface $container): Imaging\Svg\SvgDocumentFactory + { + return self::new($container, Imaging\Svg\SvgDocumentFactory::class, [ + $container->get(Resource\Security\SvgSanitizer::class), + ]); + } + + public static function getSvgDocumentService(ContainerInterface $container): Imaging\Svg\SvgDocumentService + { + return self::new($container, Imaging\Svg\SvgDocumentService::class); + } + + public static function getSvgSanitizer(ContainerInterface $container): Resource\Security\SvgSanitizer + { + return self::new($container, Resource\Security\SvgSanitizer::class); + } + + public static function configureIconRegistry(ContainerInterface $container, IconRegistry $iconRegistry): IconRegistry + { + $cache = $container->get('cache.core'); + + $cacheIdentifier = $container->get(Package\Cache\PackageDependentCacheIdentifier::class)->withPrefix('Icons')->toString(); + $iconsFromPackages = $cache->require($cacheIdentifier); + if ($iconsFromPackages === false) { + $iconsFromPackages = $container->get('icons')->getArrayCopy(); + $cache->set($cacheIdentifier, 'return ' . var_export($iconsFromPackages, true) . ';'); + } + + foreach ($iconsFromPackages as $icon => $options) { + $provider = $options['provider'] ?? null; + unset($options['provider']); + $options ??= []; + if ($provider === null && ($options['source'] ?? false)) { + $provider = $iconRegistry->detectIconProvider($options['source']); + } + if ($provider === null) { + continue; + } + $iconRegistry->registerIcon($icon, $provider, $options); + } + return $iconRegistry; + } + + public static function getIcons(ContainerInterface $container): \ArrayObject + { + return new \ArrayObject(); + } + + public static function getIconRegistry(ContainerInterface $container): Imaging\IconRegistry + { + if ($container->get('boot.state')->complete === false) { + throw new \RuntimeException( + 'Instantiating \TYPO3\CMS\Core\Imaging\IconRegistry in ext_localconf.php must be replaced by' + . ' either Configuration/Icons.php or by listening to \TYPO3\CMS\Core\Core\Event\BootCompletedEvent', + 1729784545 + ); + } + return self::new($container, Imaging\IconRegistry::class, [$container->get('cache.assets'), $container->get(Package\Cache\PackageDependentCacheIdentifier::class)->withPrefix('BackendIcons')->toString()]); + } + + public static function getLanguageServiceFactory(ContainerInterface $container): Localization\LanguageServiceFactory + { + return self::new($container, Localization\LanguageServiceFactory::class, [ + $container->get(Localization\Locales::class), + $container->get(Localization\LocalizationFactory::class), + $container->get(Cache\CacheManager::class)->getCache('runtime'), + ]); + } + + public static function getLocales(ContainerInterface $container): Localization\Locales + { + return self::new($container, Localization\Locales::class); + } + + public static function getLocalizationFactory(ContainerInterface $container): Localization\LocalizationFactory + { + return self::new($container, Localization\LocalizationFactory::class, [ + $container->get(SymfonyTranslator::class), + $container->get(Cache\CacheManager::class)->getCache('l10n'), + $container->get(Cache\CacheManager::class)->getCache('runtime'), + $container->get(Localization\TranslationDomainMapper::class), + $container->get(Localization\LabelFileResolver::class), + $container->get(Localization\TranslationDomainResolver::class), + ]); + } + + public static function getSymfonyTranslator(ContainerInterface $container): SymfonyTranslator + { + return self::new($container, SymfonyTranslator::class, ['en']); + } + + public static function getLabelFileResolver(ContainerInterface $container): Localization\LabelFileResolver + { + return self::new($container, Localization\LabelFileResolver::class, [ + $container->get(PackageManager::class), + $container->get(Localization\TranslationDomainResolver::class), + ]); + } + + public static function getTranslationDomainResolver(ContainerInterface $container): Localization\TranslationDomainResolver + { + return self::new($container, Localization\TranslationDomainResolver::class, []); + } + + public static function getTranslationDomainMapper(ContainerInterface $container): Localization\TranslationDomainMapper + { + return self::new($container, Localization\TranslationDomainMapper::class, [ + $container->get(PackageManager::class), + $container->get(Localization\LabelFileResolver::class), + $container->get(Localization\TranslationDomainResolver::class), + $container->get(Cache\CacheManager::class)->getCache('l10n'), + $container->get(EventDispatcherInterface::class), + ]); + } + + public static function getMailer(ContainerInterface $container): Mail\Mailer + { + return self::new($container, Mail\Mailer::class, [ + null, + $container->get(EventDispatcherInterface::class), + ]); + } + + public static function getTemplatedEmailFactory(ContainerInterface $container) + { + return self::new($container, Mail\TemplatedEmailFactory::class); + } + + public static function getMailTransportFactory(ContainerInterface $container): Mail\TransportFactory + { + return self::new($container, Mail\TransportFactory::class, [ + $container->get(SymfonyEventDispatcher::class), + $container->get(Log\LogManager::class), + $container->get(Log\LogManager::class)->getLogger(Mail\TransportFactory::class), + $container->get(Resource\Security\FileNameValidator::class), + ]); + } + + public static function getFlashMessageService(ContainerInterface $container): Messaging\FlashMessageService + { + return self::new($container, Messaging\FlashMessageService::class); + } + + public static function getResponsePropagationMiddleware(ContainerInterface $container): Middleware\ResponsePropagation + { + return self::new($container, Middleware\ResponsePropagation::class); + } + + public static function getVerifyHostHeaderMiddleware(ContainerInterface $container): Middleware\VerifyHostHeader + { + return self::new($container, Middleware\VerifyHostHeader::class, [ + $GLOBALS['TYPO3_CONF_VARS']['SYS']['trustedHostsPattern'] ?? '', + ]); + } + + public static function getFailsafePackageManager(ContainerInterface $container): Package\FailsafePackageManager + { + $packageManager = $container->get(Package\PackageManager::class); + if ($packageManager instanceof Package\FailsafePackageManager) { + return $packageManager; + } + throw new \RuntimeException('FailsafePackageManager can only be instantiated in failsafe (maintenance tool) mode.', 1586861816); + } + + public static function getPackageDependentCacheIdentifier(ContainerInterface $container): Package\Cache\PackageDependentCacheIdentifier + { + return new Package\Cache\PackageDependentCacheIdentifier($container->get(Package\PackageManager::class)); + } + + public static function getFileNameValidator(ContainerInterface $container): Resource\Security\FileNameValidator + { + return new FileNameValidator(); + } + + public static function getDependencyOrderingService(ContainerInterface $container): Service\DependencyOrderingService + { + return new Service\DependencyOrderingService(); + } + + public static function getOpcodeCacheService(ContainerInterface $container): Service\OpcodeCacheService + { + return self::new($container, Service\OpcodeCacheService::class); + } + + public static function getTypoScriptStringFactory(ContainerInterface $container): TypoScript\TypoScriptStringFactory + { + return new TypoScript\TypoScriptStringFactory($container, new LossyTokenizer()); + } + + public static function getTypoScriptService(ContainerInterface $container): TypoScript\TypoScriptService + { + return self::new($container, TypoScript\TypoScriptService::class); + } + + public static function getAstTraverser(ContainerInterface $container): TypoScript\AST\Traverser\AstTraverser + { + return self::new($container, TypoScript\AST\Traverser\AstTraverser::class); + } + + public static function getCommentAwareAstBuilder(ContainerInterface $container): TypoScript\AST\CommentAwareAstBuilder + { + return self::new($container, TypoScript\AST\CommentAwareAstBuilder::class, [ + $container->get(EventDispatcherInterface::class), + ]); + } + + public static function getLosslessTokenizer(ContainerInterface $container): TypoScript\Tokenizer\LosslessTokenizer + { + return self::new($container, TypoScript\Tokenizer\LosslessTokenizer::class); + } + + public static function getBackendEntryPointResolver(ContainerInterface $container): Routing\BackendEntryPointResolver + { + return self::new($container, Routing\BackendEntryPointResolver::class); + } + + public static function getRequestContextFactory(ContainerInterface $container): Routing\RequestContextFactory + { + return self::new($container, Routing\RequestContextFactory::class, [ + $container->get(Routing\BackendEntryPointResolver::class), + ]); + } + + public static function getFormProtectionFactory(ContainerInterface $container): FormProtection\FormProtectionFactory + { + return self::new( + $container, + FormProtection\FormProtectionFactory::class, + [ + $container->get(Messaging\FlashMessageService::class), + $container->get(Localization\LanguageServiceFactory::class), + $container->get(CacheManager::class)->getCache('runtime'), + $container, + ] + ); + } + + public static function getGuzzleClientFactory(ContainerInterface $container): Http\Client\GuzzleClientFactory + { + return new Http\Client\GuzzleClientFactory(); + } + + public static function getRequestFactory(ContainerInterface $container): Http\RequestFactory + { + return new Http\RequestFactory( + $container->get(Http\Client\GuzzleClientFactory::class) + ); + } + + public static function getMiddlewares(ContainerInterface $container): \ArrayObject + { + return new \ArrayObject(); + } + + public static function getFluidNamespaces(ContainerInterface $container): \ArrayObject + { + return new \ArrayObject(); + } + + public static function getFluidComponentCollections(ContainerInterface $container): \ArrayObject + { + return new \ArrayObject(); + } + + public static function getContentSecurityPolicies(ContainerInterface $container): Map + { + return new Map(); + } + + public static function getAssetsCache(ContainerInterface $container): FrontendInterface + { + return Bootstrap::createCache('assets', $container->get('boot.state')->cacheDisabled); + } + + public static function getRuntimeCache(ContainerInterface $container): FrontendInterface + { + $defaultBackend = Cache\Backend\TransientMemoryBackend::class; + $cacheBackend = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['runtime']['backend'] ?? $defaultBackend; + if (!array_key_exists(Cache\Backend\TransientBackendInterface::class, class_implements($cacheBackend))) { + $cacheBackend = $defaultBackend; + } + return Bootstrap::createCache('runtime', false, $cacheBackend); + } + + public static function getHashService(): HashService + { + return new HashService(); + } + + public static function getSilentConfigurationUpgradeService(ContainerInterface $container): Service\SilentConfigurationUpgradeService + { + return new Service\SilentConfigurationUpgradeService( + $container->get(ConfigurationManager::class) + ); + } + + public static function getPasswordService(): PasswordPolicy\PasswordService + { + return new PasswordPolicy\PasswordService(); + } + + public static function provideFallbackEventDispatcher( + ContainerInterface $container, + ?EventDispatcherInterface $eventDispatcher = null + ): EventDispatcherInterface { + // Provide a dummy / empty event dispatcher for the install tool when $eventDispatcher is null (that means when we run without symfony DI) + return $eventDispatcher ?? new EventDispatcher\EventDispatcher( + new EventDispatcher\ListenerProvider($container) + ); + } + + public static function provideFallbackSystemResourceIdentifierFactory( + ContainerInterface $container, + ?SystemResource\Identifier\SystemResourceIdentifierFactory $identifierFactory = null + ): SystemResource\Identifier\SystemResourceIdentifierFactory { + // Provide resource uri factory for the install tool when $identifierFactory is null (that means when we run without symfony DI) + return $identifierFactory ?? new SystemResource\Identifier\SystemResourceIdentifierFactory($container->get(PackageManager::class)); + } + + public static function provideFallbackSystemResourceFactory( + ContainerInterface $container, + ?SystemResource\SystemResourceFactory $resourceFactory = null + ): SystemResource\SystemResourceFactory { + // Provide a simplified resource factory for the install tool when $resourceFactory is null (that means when we run without symfony DI) + return $resourceFactory ?? new SystemResource\SystemResourceFactory( + $container->get(SystemResource\Identifier\SystemResourceIdentifierFactory::class), + null, + null, + ); + } + + public static function provideFallbackSystemResourcePublisher( + ContainerInterface $container, + ?SystemResource\Publishing\SystemResourcePublisherInterface $resourcePublisher = null + ): SystemResource\Publishing\SystemResourcePublisherInterface { + // Provide a simplified resource factory for the install tool when $resourcePublisher is null (that means when we run without symfony DI) + return $resourcePublisher ?? new SystemResource\Publishing\DefaultSystemResourcePublisher( + [ + new SystemResource\Publishing\FileSystem\SymlinkPublisher(new FileSystem()), + new SystemResource\Publishing\FileSystem\JunctionPublisher(new FileSystem()), + new SystemResource\Publishing\FileSystem\MirrorPublisher(), + ], + true, + ); + } + + public static function configureCommands(ContainerInterface $container, Console\CommandRegistry $commandRegistry): Console\CommandRegistry + { + $commandRegistry->addLazyCommand('list', Command\ListCommand::class, 'Lists commands'); + + $commandRegistry->addLazyCommand('help', HelpCommand::class, 'Displays help for a command'); + + $commandRegistry->addLazyCommand('asset:publish', Command\AssetPublishCommand::class, 'Publishes public assets. Needs to be run after composer install.'); + + $commandRegistry->addLazyCommand('cache:warmup', Command\CacheWarmupCommand::class, 'Cache warmup for all, system or, if implemented, frontend caches.'); + + $commandRegistry->addLazyCommand('cache:flush', Command\CacheFlushCommand::class, 'Cache clearing for all, system or frontend caches.'); + + $commandRegistry->addLazyCommand('dumpautoload', Command\DumpAutoloadCommand::class, 'Updates class loading information in non-composer mode.', Environment::isComposerMode()); + $commandRegistry->addLazyCommand('extensionmanager:extension:dumpclassloadinginformation', Command\DumpAutoloadCommand::class, null, Environment::isComposerMode(), false, 'dumpautoload'); + $commandRegistry->addLazyCommand('extension:dumpclassloadinginformation', Command\DumpAutoloadCommand::class, null, Environment::isComposerMode(), false, 'dumpautoload'); + + $commandRegistry->addLazyCommand('lint:yaml', SymfonyLintCommand::class, 'Lint yaml files.'); + $commandRegistry->addLazyCommand('completion', SymfonyDumpCompletionCommand::class, 'Dump the shell completion script'); + + $commandRegistry->addLazyCommand( + 'upgrade:run', + Command\UpgradeWizardRunCommand::class, + 'Run upgrade wizard. Without arguments all available wizards will be run.' + ); + $commandRegistry->addLazyCommand( + 'upgrade:list', + Command\UpgradeWizardListCommand::class, + 'List available upgrade wizards.' + ); + $commandRegistry->addLazyCommand( + 'upgrade:mark:undone', + Command\UpgradeWizardMarkUndoneCommand::class, + 'Mark upgrade wizard as undone.' + ); + + $commandRegistry->addLazyCommand( + 'language:update', + Command\UpdateLanguagePackCommand::class, + 'Update the language files of all activated extensions', + false, + true, + ); + + return $commandRegistry; + } +} diff --git a/Classes/Session/Backend/DatabaseSessionBackend.php b/Classes/Session/Backend/DatabaseSessionBackend.php new file mode 100644 index 0000000..dac5e7f --- /dev/null +++ b/Classes/Session/Backend/DatabaseSessionBackend.php @@ -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\Core\Session\Backend; + +use Doctrine\DBAL\Exception as DBALException; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Crypto\HashAlgo; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This session backend requires the 'table' configuration option. If the backend is used to holds non-authenticated + * sessions (default in frontend application), the 'ses_userid' configuration option must be set to `0`. + */ +#[Autoconfigure(public: true, shared: false)] +class DatabaseSessionBackend implements SessionBackendInterface, HashableSessionBackendInterface +{ + /** + * @var array + */ + protected $configuration = []; + + /** + * @var bool Indicates whether the ses_userid is set to `0` in the sessions table + */ + protected $hasAnonymousSessions = false; + + public function __construct( + private readonly ConnectionPool $connectionPool, + ) {} + + /** + * Initializes the session backend + * + * @param string $identifier Name of the session type, e.g. FE or BE + * @internal To be used only by SessionManager + */ + public function initialize(string $identifier, array $configuration) + { + $this->hasAnonymousSessions = (bool)($configuration['has_anonymous'] ?? false); + $this->configuration = $configuration; + } + + /** + * Checks if the configuration is valid + * + * @throws \InvalidArgumentException + * @internal To be used only by SessionManager + */ + public function validateConfiguration(): bool + { + if (empty($this->configuration['table'])) { + throw new \InvalidArgumentException( + 'The session backend "' . static::class . '" needs a "table" configuration.', + 1442996707 + ); + } + return true; + } + + public function hash(string $sessionId): string + { + return GeneralUtility::makeInstance(HashService::class) + ->hmac($sessionId, 'core-session-backend', HashAlgo::SHA3_256); + } + + /** + * Read session data + * + * @return array Returns the session data + * @throws SessionNotFoundException + */ + public function get(string $sessionId): array + { + $query = $this->getQueryBuilder(); + $query->select('*') + ->from($this->configuration['table']) + ->where($query->expr()->eq('ses_id', $query->createNamedParameter($this->hash($sessionId)))); + $result = $query->executeQuery()->fetchAssociative(); + if (!is_array($result)) { + throw new SessionNotFoundException( + 'The session with identifier ' . $sessionId . ' was not found ', + 1481885483 + ); + } + return $result; + } + + /** + * Delete a session record + * + * @return bool true if the session was deleted, false it session could not be found + */ + public function remove(string $sessionId): bool + { + $query = $this->getQueryBuilder(); + $query->delete($this->configuration['table']) + ->where( + $query->expr()->or( + $query->expr()->eq('ses_id', $query->createNamedParameter($this->hash($sessionId))), + $query->expr()->eq('ses_id', $query->createNamedParameter($sessionId)) + ) + ); + + return (bool)$query->executeStatement(); + } + + /** + * Write session data. This method prevents overriding existing session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @return array The newly created session record. + * @throws SessionNotCreatedException + */ + public function set(string $sessionId, array $sessionData): array + { + $sessionId = $this->hash($sessionId); + $sessionData['ses_id'] = $sessionId; + $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time(); + + try { + $this->getConnection()->insert( + $this->configuration['table'], + $sessionData, + ['ses_data' => Connection::PARAM_LOB] + ); + } catch (DBALException $e) { + throw new SessionNotCreatedException( + 'Session could not be written to database: ' . $e->getMessage(), + 1481895005, + $e + ); + } + + return $sessionData; + } + + /** + * Updates the session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @param array $sessionData The session data to update. Data may be partial. + * @return array $sessionData The newly updated session record. + * @throws SessionNotUpdatedException + */ + public function update(string $sessionId, array $sessionData): array + { + $hashedSessionId = $this->hash($sessionId); + $sessionData['ses_id'] = $hashedSessionId; + $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time(); + + try { + // allow 0 records to be affected, happens when no columns where changed + $this->getConnection()->update( + $this->configuration['table'], + $sessionData, + ['ses_id' => $hashedSessionId], + ['ses_data' => Connection::PARAM_LOB] + ); + } catch (DBALException $e) { + throw new SessionNotUpdatedException( + 'Session with id ' . $sessionId . ' could not be updated: ' . $e->getMessage(), + 1481889220, + $e + ); + } + return $sessionData; + } + + /** + * Garbage Collection + * + * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds. + * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, non-authenticated sessions are ignored. + */ + public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0) + { + $query = $this->getQueryBuilder(); + + $query->delete($this->configuration['table']) + ->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumLifetime))) + ->andWhere($this->hasAnonymousSessions ? $query->expr()->neq('ses_userid', 0) : ' 1 = 1'); + $query->executeStatement(); + + if ($maximumAnonymousLifetime > 0 && $this->hasAnonymousSessions) { + $query = $this->getQueryBuilder(); + $query->delete($this->configuration['table']) + ->where($query->expr()->lt('ses_tstamp', (int)($GLOBALS['EXEC_TIME'] - (int)$maximumAnonymousLifetime))) + ->andWhere($query->expr()->eq('ses_userid', 0)); + $query->executeStatement(); + } + } + + /** + * List all sessions + * + * @return array Return a list of all user sessions. The list may be empty + */ + public function getAll(): array + { + $query = $this->getQueryBuilder(); + $query->select('*')->from($this->configuration['table']); + return $query->executeQuery()->fetchAllAssociative(); + } + + protected function getQueryBuilder(): QueryBuilder + { + return $this->getConnection()->createQueryBuilder(); + } + + protected function getConnection(): Connection + { + return $this->connectionPool->getConnectionForTable($this->configuration['table']); + } +} diff --git a/Classes/Session/Backend/Exception/AbstractBackendException.php b/Classes/Session/Backend/Exception/AbstractBackendException.php new file mode 100644 index 0000000..0374dcc --- /dev/null +++ b/Classes/Session/Backend/Exception/AbstractBackendException.php @@ -0,0 +1,23 @@ +<?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\Core\Session\Backend\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * An abstract session backend exception, specific exceptions extend this. + */ +abstract class AbstractBackendException extends Exception {} diff --git a/Classes/Session/Backend/Exception/SessionNotCreatedException.php b/Classes/Session/Backend/Exception/SessionNotCreatedException.php new file mode 100644 index 0000000..aaf7952 --- /dev/null +++ b/Classes/Session/Backend/Exception/SessionNotCreatedException.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session\Backend\Exception; + +class SessionNotCreatedException extends AbstractBackendException {} diff --git a/Classes/Session/Backend/Exception/SessionNotFoundException.php b/Classes/Session/Backend/Exception/SessionNotFoundException.php new file mode 100644 index 0000000..8469112 --- /dev/null +++ b/Classes/Session/Backend/Exception/SessionNotFoundException.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session\Backend\Exception; + +class SessionNotFoundException extends AbstractBackendException {} diff --git a/Classes/Session/Backend/Exception/SessionNotUpdatedException.php b/Classes/Session/Backend/Exception/SessionNotUpdatedException.php new file mode 100644 index 0000000..e329e4c --- /dev/null +++ b/Classes/Session/Backend/Exception/SessionNotUpdatedException.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session\Backend\Exception; + +class SessionNotUpdatedException extends AbstractBackendException {} diff --git a/Classes/Session/Backend/HashableSessionBackendInterface.php b/Classes/Session/Backend/HashableSessionBackendInterface.php new file mode 100644 index 0000000..d0707e5 --- /dev/null +++ b/Classes/Session/Backend/HashableSessionBackendInterface.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session\Backend; + +interface HashableSessionBackendInterface +{ + public function hash(string $sessionId): string; +} diff --git a/Classes/Session/Backend/RedisSessionBackend.php b/Classes/Session/Backend/RedisSessionBackend.php new file mode 100644 index 0000000..5278748 --- /dev/null +++ b/Classes/Session/Backend/RedisSessionBackend.php @@ -0,0 +1,348 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session\Backend; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Crypto\HashAlgo; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This session backend takes these optional configuration options: 'hostname' (default '127.0.0.1'), + * 'database' (default 0), 'port' (default 3679), 'username' (no default value) and 'password' (no default value). + * + * @todo: Declare this class final. + */ +class RedisSessionBackend implements SessionBackendInterface, HashableSessionBackendInterface, LoggerAwareInterface +{ + use LoggerAwareTrait; + + protected array $configuration = []; + + /** + * Indicates whether the server is connected + */ + protected bool $connected = false; + + /** + * Used as instance independent identifier + * (e.g. if multiple installations write into the same database) + */ + protected string $applicationIdentifier = ''; + + protected \Redis $redis; + + protected string $identifier; + + /** + * Initializes the session backend + * + * @param string $identifier Name of the session type, e.g. FE or BE + * @internal To be used only by SessionManager + */ + public function initialize(string $identifier, array $configuration): void + { + $this->redis = new \Redis(); + + $this->configuration = $configuration; + $this->identifier = $identifier; + $this->applicationIdentifier = ($configuration['keyPrefix'] ?? '') . 'typo3_ses_' + . $identifier . '_' + . sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']) . '_'; + } + + /** + * Checks if the configuration is valid + * + * @throws \InvalidArgumentException + * @internal To be used only by SessionManager + */ + public function validateConfiguration(): void + { + if (!extension_loaded('redis')) { + throw new \RuntimeException( + 'The PHP extension "redis" must be installed and loaded in order to use the redis session backend.', + 1481269826 + ); + } + + if (isset($this->configuration['database'])) { + if (!is_int($this->configuration['database'])) { + throw new \InvalidArgumentException( + 'The specified database number is of type "' . gettype($this->configuration['database']) + . '" but an integer is expected.', + 1481270871 + ); + } + + if ($this->configuration['database'] < 0) { + throw new \InvalidArgumentException( + 'The specified database "' . $this->configuration['database'] . '" must be greater or equal than zero.', + 1481270923 + ); + } + } + + if (!is_string($this->configuration['password'] ?? '')) { + throw new \InvalidArgumentException( + 'The specified password must be a string. To authenticate with a username and password' + . ' tuple, use the separate "username" and "password" options.', + 1780850765 + ); + } + } + + public function hash(string $sessionId): string + { + return GeneralUtility::makeInstance(HashService::class) + ->hmac($sessionId, 'core-session-backend', HashAlgo::SHA3_256); + } + + /** + * Read session data + * + * @return array Returns the session data + * @throws SessionNotFoundException + */ + public function get(string $sessionId): array + { + $this->initializeConnection(); + + $hashedSessionId = $this->hash($sessionId); + $rawData = $this->redis->get($this->getSessionKeyName($hashedSessionId)); + if ($rawData !== false) { + $decodedValue = json_decode($rawData, true); + if (is_array($decodedValue)) { + return $decodedValue; + } + } + throw new SessionNotFoundException('Session could not be fetched from redis', 1481885583); + } + + /** + * Delete a session record + */ + public function remove(string $sessionId): bool + { + $this->initializeConnection(); + + $deleteResult = $this->redis->del($this->getSessionKeyName($this->hash($sessionId))); + + // Redis delete result is either `int`, `false` or a `\Redis` multi mode object, where delete state cannot get + // determined. Multi mode is not even supported by this session backend at all, therefore we handle this case as + // "not successful". + return is_int($deleteResult) && $deleteResult >= 1; + } + + /** + * Write session data. This method prevents overriding existing session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @return array The newly created session record. + * @throws SessionNotCreatedException + */ + public function set(string $sessionId, array $sessionData): array + { + $this->initializeConnection(); + + $hashedSessionId = $this->hash($sessionId); + $sessionData['ses_id'] = $hashedSessionId; + $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time(); + + // nx will not allow overwriting existing keys + $jsonString = json_encode($sessionData); + $wasSet = is_string($jsonString) && $this->redis->set( + $this->getSessionKeyName($hashedSessionId), + $jsonString, + ['nx'] + ); + + if (!$wasSet) { + throw new SessionNotCreatedException('Session could not be written to Redis', 1481895647); + } + + return $sessionData; + } + + /** + * Updates the session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @param array $sessionData The session data to update. Data may be partial. + * @return array $sessionData The newly updated session record. + * @throws SessionNotUpdatedException + */ + public function update(string $sessionId, array $sessionData): array + { + $hashedSessionId = $this->hash($sessionId); + try { + $sessionData = array_merge($this->get($sessionId), $sessionData); + } catch (SessionNotFoundException $e) { + throw new SessionNotUpdatedException('Cannot update non-existing record', 1484389971, $e); + } + $sessionData['ses_id'] = $hashedSessionId; + $sessionData['ses_tstamp'] = $GLOBALS['EXEC_TIME'] ?? time(); + + $key = $this->getSessionKeyName($hashedSessionId); + $jsonString = json_encode($sessionData); + $wasSet = is_string($jsonString) && $this->redis->set($key, $jsonString); + + if (!$wasSet) { + throw new SessionNotUpdatedException('Session could not be updated in Redis', 1481896383); + } + + return $sessionData; + } + + /** + * Garbage Collection + * + * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds. + * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected. + */ + public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0): void + { + foreach ($this->getAll() as $sessionRecord) { + if (!($sessionRecord['ses_userid'] ?? false)) { + if ($maximumAnonymousLifetime > 0 && ($sessionRecord['ses_tstamp'] + $maximumAnonymousLifetime) < $GLOBALS['EXEC_TIME']) { + $this->redis->del($this->getSessionKeyName($sessionRecord['ses_id'])); + } + } elseif (($sessionRecord['ses_tstamp'] + $maximumLifetime) < $GLOBALS['EXEC_TIME']) { + $this->redis->del($this->getSessionKeyName($sessionRecord['ses_id'])); + } + } + } + + /** + * Initializes the redis backend + * + * @throws \RuntimeException if access to redis with password is denied or if database selection fails + */ + protected function initializeConnection(): void + { + if ($this->connected) { + return; + } + + try { + $this->connected = $this->redis->pconnect( + $this->configuration['hostname'] ?? '127.0.0.1', + $this->configuration['port'] ?? 6379, + 0.0, + $this->identifier + ); + } catch (\RedisException $e) { + $this->logger->alert('Could not connect to redis server.', ['exception' => $e]); + } + + if (!$this->connected) { + throw new \RuntimeException( + 'Could not connect to redis server at ' . $this->configuration['hostname'] . ':' . $this->configuration['port'], + 1482242961 + ); + } + + if ($this->getAuthentication() !== null + && !$this->redis->auth($this->getAuthentication()) + ) { + throw new \RuntimeException( + 'Authentication to Redis failed”.', + 1481270961 + ); + } + + if (isset($this->configuration['database']) + && $this->configuration['database'] >= 0 + && !$this->redis->select($this->configuration['database']) + ) { + throw new \RuntimeException( + 'The given database "' . $this->configuration['database'] . '" could not be selected.', + 1481270987 + ); + } + } + + protected function getAuthentication(): array|string|null + { + $username = $this->configuration['username'] ?? null; + $password = $this->configuration['password'] ?? null; + + return match (true) { + // Username and password configured for authentication, build associative array + // out of possible and supported array variants by `php-redis::auth()`. + ($username !== null && $password !== null) => [ + 'user' => $username, + 'pass' => $password, + ], + // Password-only authentication configured. + ($username === null && $password !== null) => $password, + // No authentication configured. + default => null, + }; + } + + /** + * List all sessions + * + * @return array Return a list of all user sessions. The list may be empty. + */ + public function getAll(): array + { + $this->initializeConnection(); + + $keys = []; + // Initialize our iterator to null, needed by redis->scan + $iterator = null; + $this->redis->setOption(\Redis::OPT_SCAN, (string)\Redis::SCAN_RETRY); + $pattern = $this->getSessionKeyName('*'); + // retry when we get no keys back, redis->scan returns a chunk (array) of keys per iteration + while (($keyChunk = $this->redis->scan($iterator, $pattern)) !== false) { + foreach ($keyChunk as $key) { + $keys[] = $key; + } + } + + $encodedSessions = $this->redis->mGet($keys); + if (!is_array($encodedSessions)) { + return []; + } + + $sessions = []; + foreach ($encodedSessions as $session) { + if (is_string($session)) { + $decodedSession = json_decode($session, true); + if ($decodedSession) { + $sessions[] = $decodedSession; + } + } + } + + return $sessions; + } + + protected function getSessionKeyName(string $sessionId): string + { + return $this->applicationIdentifier . $sessionId; + } +} diff --git a/Classes/Session/Backend/SessionBackendInterface.php b/Classes/Session/Backend/SessionBackendInterface.php new file mode 100644 index 0000000..70dc826 --- /dev/null +++ b/Classes/Session/Backend/SessionBackendInterface.php @@ -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\Core\Session\Backend; + +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotCreatedException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotUpdatedException; + +/** + * Interface SessionBackendInterface + */ +interface SessionBackendInterface +{ + /** + * Initializes the session backend + * + * @param string $identifier Name of the session type, e.g. FE or BE + * @internal To be used only by SessionManager + */ + public function initialize(string $identifier, array $configuration); + + /** + * Checks if the configuration is valid + * + * @throws \InvalidArgumentException + * @internal To be used only by SessionManager + */ + public function validateConfiguration(); + + /** + * List all sessions + * + * @return array Return a list of all user sessions. The list may be empty. + */ + public function getAll(): array; + + /** + * Read session data + * + * @return array Returns the session data + * @throws SessionNotFoundException + */ + public function get(string $sessionId): array; + + /** + * Delete a session record + * + * @return bool true if the session was deleted, false it session could not be found + */ + public function remove(string $sessionId): bool; + + /** + * Write session data. This method prevents overriding existing session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @return array The newly created session record. + * @throws SessionNotCreatedException + */ + public function set(string $sessionId, array $sessionData): array; + + /** + * Updates the session data. + * ses_id will always be set to $sessionId and overwritten if existing in $sessionData + * This method updates ses_tstamp automatically + * + * @param array $sessionData The session data to update. Data may be partial. + * @return array $sessionData The newly updated session record. + * @throws SessionNotUpdatedException + */ + public function update(string $sessionId, array $sessionData): array; + + /** + * Garbage Collection + * + * @param int $maximumLifetime maximum lifetime of authenticated user sessions, in seconds. + * @param int $maximumAnonymousLifetime maximum lifetime of non-authenticated user sessions, in seconds. If set to 0, nothing is collected. + */ + public function collectGarbage(int $maximumLifetime, int $maximumAnonymousLifetime = 0); +} diff --git a/Classes/Session/SessionManager.php b/Classes/Session/SessionManager.php new file mode 100644 index 0000000..966406f --- /dev/null +++ b/Classes/Session/SessionManager.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication; +use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface; +use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Example Configuration + * + * ``` + * $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'] => [ + * 'BE' => [ + * 'backend' => \TYPO3\CMS\Core\Session\Backend\FileSessionBackend::class, + * 'savePath' => '/var/www/t3sessionframework/data/' + * ], + * ]; + * ``` + */ +#[AsAlias('session-manager', public: true)] +class SessionManager implements SingletonInterface +{ + /** + * @var SessionBackendInterface[] + */ + protected $sessionBackends = []; + + public function __construct( + private readonly ContainerInterface $container, + ) {} + + /** + * Gets the currently running session backend for the given context + * + * @throws \InvalidArgumentException + */ + public function getSessionBackend(string $identifier): SessionBackendInterface + { + if (!isset($this->sessionBackends[$identifier])) { + $configuration = $GLOBALS['TYPO3_CONF_VARS']['SYS']['session'][$identifier] ?? false; + if (!$configuration) { + throw new \InvalidArgumentException('Session configuration for identifier ' . $identifier . ' was not found', 1482234750); + } + + $sessionBackend = $this->createSessionBackendFromConfiguration($identifier, $configuration); + + // Validates the session backend configuration and throws an exception if something's wrong + $sessionBackend->validateConfiguration(); + $this->sessionBackends[$identifier] = $sessionBackend; + } + return $this->sessionBackends[$identifier]; + } + + /** + * Removes all sessions for a specific user ID + * + * @param SessionBackendInterface $backend see constants + */ + public function invalidateAllSessionsByUserId(SessionBackendInterface $backend, int $userId, ?AbstractUserAuthentication $userAuthentication = null) + { + $sessionToRenew = ''; + $hashedSessionToRenew = ''; + // Prevent destroying the session of the current user session, but renew session id + if ($userAuthentication !== null && (int)$userAuthentication->user['uid'] === $userId) { + $sessionToRenew = $userAuthentication->getSession()->getIdentifier(); + } + if ($sessionToRenew !== '' && $backend instanceof HashableSessionBackendInterface) { + $hashedSessionToRenew = $backend->hash($sessionToRenew); + } + + foreach ($backend->getAll() as $session) { + if ($userAuthentication !== null) { + if ($session['ses_id'] === $sessionToRenew || $session['ses_id'] === $hashedSessionToRenew) { + $userAuthentication->enforceNewSessionId(); + continue; + } + } + if ((int)$session['ses_userid'] === $userId) { + $backend->remove($session['ses_id']); + } + } + } + + /** + * Creates a session backend from the configuration + * + * @param string $identifier the identifier + * @param array<string, class-string> $configuration The session configuration array + * @throws \InvalidArgumentException + */ + protected function createSessionBackendFromConfiguration(string $identifier, array $configuration): SessionBackendInterface + { + $className = $configuration['backend']; + + if (!is_subclass_of($className, SessionBackendInterface::class)) { + throw new \InvalidArgumentException('Configured session backend ' . $className . ' does not implement ' . SessionBackendInterface::class, 1482235035); + } + + $options = $configuration['options'] ?? []; + + /** @var SessionBackendInterface $backend */ + $backend = $this->container->has($className) ? $this->container->get($className) : GeneralUtility::makeInstance($className); + $backend->initialize($identifier, $options); + return $backend; + } +} diff --git a/Classes/Session/UserSession.php b/Classes/Session/UserSession.php new file mode 100644 index 0000000..62665da --- /dev/null +++ b/Classes/Session/UserSession.php @@ -0,0 +1,308 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session; + +use TYPO3\CMS\Core\Http\CookieScope; +use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Security\JwtTrait; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Represents all information about a user's session. + * A user session can be bound to a frontend / backend user, or an anonymous session based on session data stored + * in the session backend. + * + * If a session is anonymous, it can be fixated by storing the session in the backend, but only if there + * is data in the session. + * + * if a session is user-bound, it is automatically fixated. + * + * The `$isNew` flag is meant to show that this user session object was not + * fetched from the session backend, but initialized in the first place by + * the current request. + * + * The `$data` argument stores arbitrary data valid for the user's session. + * + * A permanent session is not issued by a session-based cookie but a + * time-based cookie. The session might be persisted in the user's browser. + */ +class UserSession +{ + use JwtTrait; + + protected const SESSION_UPDATE_GRACE_PERIOD = 61; + protected string $identifier; + protected ?int $userId; + protected int $lastUpdated; + protected array $data; + protected bool $wasUpdated = false; + protected string $ipLock = ''; + protected bool $isNew = true; + protected bool $isPermanent = false; + + protected function __construct(string $identifier, int $userId, int $lastUpdated, array $data = []) + { + $this->identifier = $identifier; + $this->userId = $userId > 0 ? $userId : null; + $this->lastUpdated = $lastUpdated; + $this->data = $data; + } + + /** + * @return string the session ID. This is the `ses_id` respectively the `AbstractUserAuthentication->id` + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return ?int the user ID the session belongs to. Can also return `0` or `NULL` Which indicates an anonymous session. This is the `ses_userid`. + */ + public function getUserId(): ?int + { + return $this->userId; + } + + /** + * @return int the timestamp of the last session data update. This is the `ses_tstamp`. + */ + public function getLastUpdated(): int + { + return $this->lastUpdated; + } + + /** + * Sets or updates session data value for a given `$key`. It is also + * internally used if calling `AbstractUserAuthentication->setSessionData()` + * + * @param string $key The key whose value should be updated + * @param mixed $value The value or `NULL` to unset the key + */ + public function set(string $key, $value): void + { + if ($key === '') { + throw new \InvalidArgumentException('Argument key must not be empty', 1484312516); + } + if ($value === null) { + unset($this->data[$key]); + } else { + $this->data[$key] = $value; + } + $this->wasUpdated = true; + } + + /** + * Checks whether the session has data assigned + */ + public function hasData(): bool + { + return $this->data !== []; + } + + /** + * Returns the session data for the given `$key` or `NULL` if the key does + * not exist. It is internally used if calling + * `AbstractUserAuthentication->getSessionData()` + */ + public function get(string $key) + { + return $this->data[$key] ?? null; + } + + /** + * @return array the whole data array. + */ + public function getData(): array + { + return $this->data; + } + + /** + * Overrides the whole data array. Can also be used to unset the array. + * This also sets the `$wasUpdated` pointer to `true` + */ + public function overrideData(array $data): void + { + if ($this->data !== $data) { + // Only set update flag if there is change in the $data array + $this->wasUpdated = true; + } + + $this->data = $data; + } + + /** + * Checks whether the session data has been updated + */ + public function dataWasUpdated(): bool + { + return $this->wasUpdated; + } + + /** + * Checks if the user session is an anonymous one. This means, the + * session does not belong to a logged-in user + */ + public function isAnonymous(): bool + { + return $this->userId === 0 || $this->userId === null; + } + + /** + * @return string the `ipLock` state of the session + */ + public function getIpLock(): string + { + return $this->ipLock; + } + + /** + * Checks whether the session is marked as new + */ + public function isNew(): bool + { + return $this->isNew; + } + + /** + * Checks whether the session was marked as permanent + */ + public function isPermanent(): bool + { + return $this->isPermanent; + } + + /** + * Checks whether the session has to be updated + */ + public function needsUpdate(): bool + { + return $GLOBALS['EXEC_TIME'] > ($this->lastUpdated + self::SESSION_UPDATE_GRACE_PERIOD); + } + + /** + * Gets session ID wrapped in JWT to be used for emitting a new cookie. + * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>` + * + * @param ?CookieScope $scope + * @return string the session ID wrapped in JWT to be used for emitting a new cookie + */ + public function getJwt(?CookieScope $scope = null): string + { + // @todo payload could be organized in a new `SessionToken` object + return self::encodeHashSignedJwt( + [ + 'identifier' => $this->identifier, + 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339), + 'scope' => $scope, + ], + self::createSigningKeyFromEncryptionKey(UserSession::class) + ); + } + + /** + * Creates a new user session based on the provided session record + * + * @param string $id the session identifier + */ + public static function createFromRecord(string $id, array $record, bool $markAsNew = false): self + { + $userSession = new self( + $id, + (int)($record['ses_userid'] ?? 0), + (int)($record['ses_tstamp'] ?? 0), + unserialize($record['ses_data'] ?? '', ['allowed_classes' => false]) ?: [] + ); + $userSession->ipLock = $record['ses_iplock'] ?? ''; + $userSession->isNew = $markAsNew; + if (isset($record['ses_permanent'])) { + $userSession->isPermanent = (bool)$record['ses_permanent']; + } + return $userSession; + } + + /** + * Creates a non fixated user session. This means the + * session does not belong to a logged-in user + */ + public static function createNonFixated(string $identifier): self + { + $userSession = new self($identifier, 0, $GLOBALS['EXEC_TIME'], []); + $userSession->isPermanent = false; + $userSession->isNew = true; + return $userSession; + } + + /** + * Verifies and resolves the session ID from a submitted cookie value: + * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>` + * + * @param string $cookieValue submitted cookie value + * @param CookieScope $scope + * @return non-empty-string|null session ID, null in case verification failed + * @throws \Exception + * @see getJwt() + */ + public static function resolveIdentifierFromJwt(string $cookieValue, CookieScope $scope): ?string + { + if ($cookieValue === '') { + return null; + } + + $payload = self::decodeJwt($cookieValue, self::createSigningKeyFromEncryptionKey(UserSession::class)); + + $identifier = !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null; + if ($identifier === null) { + return null; + } + + $domainScope = (string)($payload->scope->domain ?? ''); + $pathScope = (string)($payload->scope->path ?? ''); + if ($domainScope === '' || $pathScope === '') { + $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(self::class); + $logger->notice('A session cookie with out a domain scope has been used', ['cookieHash' => substr(sha1($cookieValue), 0, 12)]); + return $identifier; + } + if ($domainScope !== $scope->domain || $pathScope !== $scope->path) { + // invalid scope, the cookie jwt has been used on a wrong path or domain + return null; + } + + return $identifier; + } + + /** + * @internal Used internally to store data in the backend + * @return array The session record as array + */ + public function toArray(): array + { + $data = [ + 'ses_id' => $this->identifier, + 'ses_data' => serialize($this->data), + 'ses_userid' => (int)$this->userId, + 'ses_iplock' => $this->ipLock, + 'ses_tstamp' => $this->lastUpdated, + ]; + if ($this->isPermanent) { + $data['ses_permanent'] = 1; + } + return $data; + } +} diff --git a/Classes/Session/UserSessionManager.php b/Classes/Session/UserSessionManager.php new file mode 100644 index 0000000..776f536 --- /dev/null +++ b/Classes/Session/UserSessionManager.php @@ -0,0 +1,372 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Session; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Authentication\IpLocker; +use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Http\CookieScopeTrait; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; +use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * The purpose of the UserSessionManager is to create new user session objects (acting as a factory), + * depending on the need / request, and to fetch sessions from the session backend, effectively + * encapsulating all calls to the `SessionManager`. + * + * The UserSessionManager can be retrieved using its static factory method create(): + * + * ``` + * use TYPO3\CMS\Core\Session\UserSessionManager; + * + * $loginType = 'BE'; // or 'FE' for frontend + * $userSessionManager = UserSessionManager::create($loginType); + * ``` + */ +class UserSessionManager implements LoggerAwareInterface +{ + use LoggerAwareTrait; + use CookieScopeTrait; + + protected const SESSION_ID_LENGTH = 32; + protected const GARBAGE_COLLECTION_LIFETIME = 86400; + protected const LIFETIME_OF_ANONYMOUS_SESSION_DATA = 86400; + + /** + * Session timeout (on the storage-side, used to know until a session (timestamp) is valid + * + * If >0: session-timeout in seconds. + * If =0: Instant logout after login. + */ + protected int $sessionLifetime; + + protected int $garbageCollectionForAnonymousSessions = self::LIFETIME_OF_ANONYMOUS_SESSION_DATA; + protected SessionBackendInterface $sessionBackend; + protected IpLocker $ipLocker; + protected string $loginType; + + /** + * Constructor. Marked as internal, as it is recommended to use the factory method "create" + * + * @internal it is recommended to use the factory method "create" + */ + public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker, string $loginType) + { + $this->sessionBackend = $sessionBackend; + $this->sessionLifetime = $sessionLifetime; + $this->ipLocker = $ipLocker; + $this->loginType = $loginType; + } + + protected function setGarbageCollectionTimeoutForAnonymousSessions(int $garbageCollectionForAnonymousSessions = 0): void + { + if ($garbageCollectionForAnonymousSessions > 0) { + $this->garbageCollectionForAnonymousSessions = $garbageCollectionForAnonymousSessions; + } + } + + /** + * Creates and returns a session from the given request. If the given + * `$cookieName` can not be obtained from the request an anonymous + * session will be returned. + * + * @param string $cookieName Name of the cookie that might contain the session + * @return UserSession An existing session if one is stored in the cookie, an anonymous session otherwise + */ + public function createFromRequestOrAnonymous(ServerRequestInterface $request, string $cookieName): UserSession + { + try { + $cookieValue = (string)($request->getCookieParams()[$cookieName] ?? ''); + $scope = $this->getCookieScope($request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request)); + $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue, $scope); + } catch (\Exception $exception) { + $this->logger->debug('Could not resolve session identifier from JWT', ['exception' => $exception]); + } + return $this->getSessionFromSessionId($sessionId ?? '') ?? $this->createAnonymousSession(); + } + + /** + * Creates and returns an anonymous session object (which is not persisted) + */ + public function createAnonymousSession(): UserSession + { + $randomSessionId = $this->createSessionId(); + return UserSession::createNonFixated($randomSessionId); + } + + /** + * Creates and returns a new session object for a given session id + * + * @param string $sessionId The session id to be looked up in the session backend + * @return UserSession The created user session object + * @internal this is only used as a bridge for existing methods, might be removed or renamed without further notice + */ + public function createSessionFromStorage(string $sessionId): UserSession + { + $this->logger->debug('Fetch session with identifier {session}', ['session' => sha1($sessionId)]); + $sessionRecord = $this->sessionBackend->get($sessionId); + return UserSession::createFromRecord($sessionId, $sessionRecord); + } + + /** + * Checks whether a session has expired. This is also the case if `sessionLifetime` is `0` + */ + public function hasExpired(UserSession $session): bool + { + return $this->sessionLifetime === 0 || $GLOBALS['EXEC_TIME'] > $session->getLastUpdated() + $this->sessionLifetime; + } + + /** + * Checks whether a given user session will expire within the given grace period + * + * @param int $gracePeriod in seconds + */ + public function willExpire(UserSession $session, int $gracePeriod): bool + { + return $GLOBALS['EXEC_TIME'] >= ($session->getLastUpdated() + $this->sessionLifetime) - $gracePeriod; + } + + /** + * Persists an anonymous session without a user logged-in, + * in order to store session data between requests + * + * @param UserSession $session The user session to fixate + * @param bool $isPermanent If `true`, the session will get the `ses_permanent` flag + * @return UserSession a new session object with an updated `ses_tstamp` (allowing to keep the session alive) + */ + public function fixateAnonymousSession(UserSession $session, bool $isPermanent = false): UserSession + { + // @todo: Refactor. Get Request or at least remote address hand over + $sessionIpLock = $this->ipLocker->getSessionIpLock(NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress()); + $sessionRecord = $session->toArray(); + $sessionRecord['ses_iplock'] = $sessionIpLock; + // Ensure the user is not set, as this is always an anonymous session (see elevateToFixatedUserSession) + $sessionRecord['ses_userid'] = 0; + if ($isPermanent) { + $sessionRecord['ses_permanent'] = 1; + } + // The updated session record now also contains an updated timestamp (ses_tstamp) + $updatedSessionRecord = $this->sessionBackend->set($session->getIdentifier(), $sessionRecord); + return $this->recreateUserSession($session, $updatedSessionRecord); + } + + /** + * Removes existing entries, creates and returns a new user session object. + * See `regenerateSession()` below. + * + * @param UserSession $session The user session to recreate + * @param int $userId The user id the session belongs to + * @param bool $isPermanent If `true`, the session will get the `ses_permanent` flag + * @return UserSession The newly created user session object + * + * @throws Backend\Exception\SessionNotCreatedException + */ + public function elevateToFixatedUserSession(UserSession $session, int $userId, bool $isPermanent = false): UserSession + { + $sessionId = $session->getIdentifier(); + $this->logger->debug('Create session ses_id = {session}', ['session' => sha1($sessionId)]); + // Delete any session entry first + $this->sessionBackend->remove($sessionId); + // Re-create session entry + // @todo: Refactor. Get Request or at least remote address hand over + $sessionIpLock = $this->ipLocker->getSessionIpLock(NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress()); + $sessionRecord = [ + 'ses_iplock' => $sessionIpLock, + 'ses_userid' => $userId, + 'ses_tstamp' => $GLOBALS['EXEC_TIME'], + 'ses_data' => '', + ]; + if ($isPermanent) { + $sessionRecord['ses_permanent'] = 1; + } + $sessionRecord = $this->sessionBackend->set($sessionId, $sessionRecord); + return UserSession::createFromRecord($sessionId, $sessionRecord, true); + } + + /** + * Regenerates the given session. This method should be used whenever a + * user proceeds to a higher authorization level, for example when an + * anonymous session is now authenticated. + * + * @param string $sessionId The session id + * @param array $existingSessionRecord If given, this session record will be used instead of fetching again + * @param bool $anonymous If true session will be regenerated as anonymous session + */ + public function regenerateSession( + string $sessionId, + array $existingSessionRecord = [], + bool $anonymous = false + ): UserSession { + if (empty($existingSessionRecord)) { + $existingSessionRecord = $this->sessionBackend->get($sessionId); + } + if ($anonymous) { + $existingSessionRecord['ses_userid'] = 0; + } + // Update session record with new ID + $newSessionId = $this->createSessionId(); + $this->sessionBackend->set($newSessionId, $existingSessionRecord); + $this->sessionBackend->remove($sessionId); + return UserSession::createFromRecord($newSessionId, $existingSessionRecord, true); + } + + /** + * Updates the session timestamp for the given user session if the session + * is marked as "needs update" (which means the current timestamp is + * greater than "last updated + a specified grace-time"). + * + * @return UserSession a modified user session with a last updated value if needed + */ + public function updateSessionTimestamp(UserSession $session): UserSession + { + if ($session->needsUpdate()) { + // Update the session timestamp by writing a dummy update. (Backend will update the timestamp) + $this->sessionBackend->update($session->getIdentifier(), []); + $session = $this->recreateUserSession($session); + } + return $session; + } + + /** + * Checks whether a given session is already persisted + */ + public function isSessionPersisted(UserSession $session): bool + { + return $this->getSessionFromSessionId($session->getIdentifier()) !== null; + } + + /** + * Removes a given session from the session backend + */ + public function removeSession(UserSession $session): void + { + $this->sessionBackend->remove($session->getIdentifier()); + } + + /** + * Updates the session data + timestamp in the session backend + */ + public function updateSession(UserSession $session): UserSession + { + $sessionRecord = $this->sessionBackend->update($session->getIdentifier(), $session->toArray()); + return $this->recreateUserSession($session, $sessionRecord); + } + + /** + * Calls the session backends `collectGarbage()` method with the given probability in percent. + */ + public function collectGarbage(int $garbageCollectionProbability = 1): void + { + if (rand(0, 99) < $garbageCollectionProbability) { + $this->sessionBackend->collectGarbage( + $this->sessionLifetime > 0 ? $this->sessionLifetime : self::GARBAGE_COLLECTION_LIFETIME, + $this->garbageCollectionForAnonymousSessions + ); + } + } + + /** + * Creates a new session ID using a random with SESSION_ID_LENGTH as length + */ + protected function createSessionId(): string + { + return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(self::SESSION_ID_LENGTH); + } + + /** + * Tries to fetch a user session form the session backend. + * If none is given, an anonymous session will be created. + */ + protected function getSessionFromSessionId(string $id): ?UserSession + { + if ($id === '') { + return null; + } + try { + $sessionRecord = $this->sessionBackend->get($id); + if ($sessionRecord === []) { + return null; + } + // If the session does not match the current IP lock, it should be treated as invalid + // and a new session should be created. + // @todo: Refactor. Get Request or at least remote address hand over + if ($this->ipLocker->validateRemoteAddressAgainstSessionIpLock( + NormalizedParams::createFromServerParams($_SERVER)->getRemoteAddress(), + $sessionRecord['ses_iplock'] + )) { + return UserSession::createFromRecord($id, $sessionRecord); + } + } catch (SessionNotFoundException) { + return null; + } + + return null; + } + + /** + * Creates a `UserSessionManager` instance for the given login type. Has + * several optional arguments used for testing purposes to inject dummy + * objects if needed. + * + * Ideally, this factory encapsulates all `TYPO3_CONF_VARS` options, so + * the actual object does not need to consider any global state. + */ + public static function create(string $loginType, ?int $sessionLifetime = null, ?SessionManager $sessionManager = null, ?IpLocker $ipLocker = null): self + { + $sessionManager = $sessionManager ?? GeneralUtility::makeInstance(SessionManager::class); + $ipLocker = $ipLocker ?? GeneralUtility::makeInstance( + IpLocker::class, + (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIP'] ?? 0), + (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lockIPv6'] ?? 0) + ); + $lifetime = (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['lifetime'] ?? 0); + $sessionLifetime = $sessionLifetime ?? (int)$GLOBALS['TYPO3_CONF_VARS'][$loginType]['sessionTimeout']; + if ($sessionLifetime > 0 && $sessionLifetime < $lifetime && $lifetime > 0) { + // If server session timeout is non-zero but less than client session timeout: Copy this value instead. + $sessionLifetime = $lifetime; + } + $object = GeneralUtility::makeInstance( + self::class, + $sessionManager->getSessionBackend($loginType), + $sessionLifetime, + $ipLocker, + $loginType + ); + if ($loginType === 'FE') { + $object->setGarbageCollectionTimeoutForAnonymousSessions((int)($GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] ?? 0)); + } + return $object; + } + + /** + * Recreates a `UserSession` object from the existing session data - keeping `new` state. + * This method shall be used to reflect updated low-level session data in corresponding `UserSession` object. + */ + protected function recreateUserSession(UserSession $session, ?array $sessionRecord = null): UserSession + { + return UserSession::createFromRecord( + $session->getIdentifier(), + $sessionRecord ?? $this->sessionBackend->get($session->getIdentifier()), + $session->isNew() // keep state (required to emit e.g. cookies) + ); + } +} diff --git a/Classes/Settings/Category.php b/Classes/Settings/Category.php new file mode 100644 index 0000000..0f0a258 --- /dev/null +++ b/Classes/Settings/Category.php @@ -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\Core\Settings; + +use TYPO3\CMS\Backend\Dto\Settings\EditableSetting; + +/** + * @template T of SettingDefinition|EditableSetting + * @internal + */ +final readonly class Category implements \JsonSerializable +{ + /** + * @param list<T> $settings + * @param list<Category<T>> $categories + */ + public function __construct( + public string $key, + public string $label, + public ?string $description = null, + public ?string $icon = null, + public array $settings = [], + public array $categories = [], + ) {} + + public static function __set_state(array $state): self + { + return new self(...$state); + } + + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/Classes/Settings/CategoryAccumulator.php b/Classes/Settings/CategoryAccumulator.php new file mode 100644 index 0000000..45dcc32 --- /dev/null +++ b/Classes/Settings/CategoryAccumulator.php @@ -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\Core\Settings; + +class CategoryAccumulator +{ + /** + * Retrieve list of ordered sets, matched by + * $setNames, including their dependencies (recursive) + * + * @param CategoryDefinition[] $categoryDefinitions + * @param SettingDefinition[] $settingsDefinitions + * @return list<Category> + */ + public function getCategories(iterable $categoryDefinitions, iterable $settingsDefinitions): array + { + $categories = []; + foreach ($categoryDefinitions as $category) { + $data = $category->toArray(); + $parent = $data['parent'] ?? null; + unset($data['parent']); + $categories[$category->key] = [ + 'children' => [], + 'parent' => $parent, + 'data' => $data, + ]; + } + foreach ($categoryDefinitions as $category) { + if ($category->parent === null) { + continue; + } + if (!isset($categories[$category->parent])) { + throw new \RuntimeException('Missing parent category: ' . $category->parent, 1716291554); + } + $categories[$category->parent]['children'][] = $category->key; + } + + $categorizedSettings = []; + foreach ($settingsDefinitions as $definition) { + $category = $definition->category ?? ''; + $categorizedSettings[isset($categories[$category]) ? $category : 'other'][] = $definition; + } + + if (isset($categorizedSettings['other'])) { + $categories['other'] = [ + 'children' => [], + 'parent' => null, + 'data' => [ + 'key' => 'other', + 'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:categories.other', + 'description' => '', + ], + ]; + } + + $instances = []; + foreach ($categories as $key => $category) { + if ($category['parent'] === null) { + $instances[] = $this->createInstance($categories, $key, $categorizedSettings); + } + } + + return $instances; + } + + private function createInstance(array $categories, string $key, array $categorizedSettings): Category + { + try { + return new Category(...[ + ...$categories[$key]['data'], + 'settings' => $categorizedSettings[$key] ?? [], + 'categories' => array_map(fn($key) => $this->createInstance($categories, $key, $categorizedSettings), $categories[$key]['children']), + ]); + } catch (\Error $e) { + throw new \Exception('Invalid category definition: ' . json_encode($categories[$key]['data']), 1720528084, $e); + } + } +} diff --git a/Classes/Settings/CategoryDefinition.php b/Classes/Settings/CategoryDefinition.php new file mode 100644 index 0000000..4e17627 --- /dev/null +++ b/Classes/Settings/CategoryDefinition.php @@ -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\Core\Settings; + +/** + * @internal + */ +readonly class CategoryDefinition +{ + public function __construct( + public string $key, + public string $label, + public ?string $description = null, + public ?string $icon = null, + public ?string $parent = null, + ) {} + + public function toArray(): array + { + return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Settings/InvalidSettingDefinitionException.php b/Classes/Settings/InvalidSettingDefinitionException.php new file mode 100644 index 0000000..22ad8ff --- /dev/null +++ b/Classes/Settings/InvalidSettingDefinitionException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal Only to be used by internal settings functionality + */ +final class InvalidSettingDefinitionException extends \RuntimeException {} diff --git a/Classes/Settings/SettingDefinition.php b/Classes/Settings/SettingDefinition.php new file mode 100644 index 0000000..dde1dc9 --- /dev/null +++ b/Classes/Settings/SettingDefinition.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +final readonly class SettingDefinition implements \JsonSerializable +{ + /** + * @param array<int|string, string|int|float|bool> $enum + * @param list<string> $tags + * @param array<string, mixed> $options + */ + public function __construct( + public string $key, + public string $type, + public string|int|float|bool|array|object|null $default, + public string $label, + public ?string $description = null, + public bool $readonly = false, + public array $enum = [], + public ?string $category = null, + public array $tags = [], + public array $options = [], + ) {} + + public static function __set_state(array $state): self + { + return new self(...$state); + } + + public function jsonSerialize(): array + { + return [ + ...get_object_vars($this), + 'enum' => (object)$this->enum, + 'options' => (object)$this->options, + ]; + } +} diff --git a/Classes/Settings/SettingDefinitionValidation.php b/Classes/Settings/SettingDefinitionValidation.php new file mode 100644 index 0000000..ff6f0bb --- /dev/null +++ b/Classes/Settings/SettingDefinitionValidation.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +final readonly class SettingDefinitionValidation +{ + public function __construct( + private SettingsTypeRegistry $settingsTypeRegistry, + ) {} + + /** + * @throws InvalidSettingDefinitionException + */ + public function validate(SettingDefinition $definition): void + { + if (!$this->settingsTypeRegistry->has($definition->type)) { + throw new InvalidSettingDefinitionException('Invalid settings type "' . $definition->type . '" in setting "' . $definition->key . '"', 1732181103); + } + + $type = $this->settingsTypeRegistry->get($definition->type); + + // Only validate options if type supports them + if ($type instanceof SettingsTypeOptionAwareInterface) { + $this->validateSettingsTypeOptions($definition, $type); + } + + // Validate default value + if (!$type->validate($definition->default, $definition)) { + throw new InvalidSettingDefinitionException('Invalid default value in setting "' . $definition->key . '"', 1732181102); + } + } + + private function validateSettingsTypeOptions( + SettingDefinition $definition, + SettingsTypeInterface&SettingsTypeOptionAwareInterface $type + ): void { + $supportedOptions = $type->getSupportedOptions(); + + $options = $definition->options; + foreach ($supportedOptions as $optionName => $optionDefinition) { + if (!array_key_exists($optionName, $options)) { + if ($optionDefinition->required) { + throw new InvalidSettingDefinitionException( + 'Required option "' . $optionName . '" missing for type "' . $definition->type . '" in setting "' . $definition->key . '"', + 1732181110 + ); + } + continue; + } + + $value = $definition->options[$optionName]; + $isValid = match ($optionDefinition->type) { + 'string' => is_string($value), + 'int' => is_int($value), + 'number' => is_int($value) || is_float($value), + 'bool' => is_bool($value), + 'array' => is_array($value), + default => throw new InvalidSettingDefinitionException('Unsupported settings type option: ' . $optionDefinition->type, 1734513842), + }; + + if (!$isValid) { + throw new InvalidSettingDefinitionException( + 'Invalid value for option "' . $optionName . '" in setting "' . $definition->key . '": expected ' . $optionDefinition->type . ', got ' . gettype($value), + 1732181109 + ); + } + unset($options[$optionName]); + } + + if ($options !== []) { + throw new InvalidSettingDefinitionException( + 'Unsupported options [' . implode(', ', array_keys($options)) . '] for type "' . $definition->type . '" in setting "' . $definition->key . '". Supported options: [' . implode(', ', array_keys($supportedOptions)) . ']', + 1732181108 + ); + } + + if (!$type->validateOptions($definition)) { + throw new InvalidSettingDefinitionException( + 'Setting definition options for type "' . $definition->type . '" in setting "' . $definition->key . '" could not be validated.', + 1752821671 + ); + } + } +} diff --git a/Classes/Settings/SettingNotFoundException.php b/Classes/Settings/SettingNotFoundException.php new file mode 100644 index 0000000..8afd21e --- /dev/null +++ b/Classes/Settings/SettingNotFoundException.php @@ -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\Core\Settings; + +use Psr\Container\NotFoundExceptionInterface; +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +class SettingNotFoundException extends Exception implements NotFoundExceptionInterface {} diff --git a/Classes/Settings/SettingValue.php b/Classes/Settings/SettingValue.php new file mode 100644 index 0000000..7abf2aa --- /dev/null +++ b/Classes/Settings/SettingValue.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +final readonly class SettingValue +{ + public function __construct( + public mixed $value, + public string $key, + public ?SettingDefinition $definition, + ) {} +} diff --git a/Classes/Settings/Settings.php b/Classes/Settings/Settings.php new file mode 100644 index 0000000..5fb9308 --- /dev/null +++ b/Classes/Settings/Settings.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +final readonly class Settings implements SettingsInterface +{ + public function __construct( + private array $settings, + ) {} + + public function has(string $identifier): bool + { + return array_key_exists($identifier, $this->settings); + } + + public function get(string $identifier): mixed + { + if (!$this->has($identifier)) { + throw new SettingNotFoundException('Setting does not exist', 1709555772); + } + return $this->settings[$identifier]; + } + + public function getIdentifiers(): array + { + return array_keys($this->settings); + } + + public static function __set_state(array $state): static + { + return new static(...$state); + } +} diff --git a/Classes/Settings/SettingsDiff.php b/Classes/Settings/SettingsDiff.php new file mode 100644 index 0000000..35ea033 --- /dev/null +++ b/Classes/Settings/SettingsDiff.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * @internal + */ +final readonly class SettingsDiff +{ + /** + * @param string[] $changes + * @param string[] $deletions + */ + public function __construct( + public array $settings, + public array $changes, + public array $deletions, + ) {} + + public function asArray(): array + { + return $this->settings; + } + + /** + * Calculate a new settings tree for the given $targetSettings + * + * Settings that have the same value as their default value + * are removed (tree is minified) if the list of default settings + * via $defaultSettings. + * + * @param array $currentSettings Current settings + * In case of site settings: config/sites/…/settings.yaml + * @param SettingsInterface $targetSettings Target settings + * (values as supplied via the settings editor) + * @param SettingsInterface $defaultSettings Default settings, without local settings tree applied. + * In case of site settings: Combination of all settings + * defined in settings.definitions.yaml + setting.yaml + * from all selected sets combined + */ + public static function create( + array $currentSettings, + SettingsInterface $targetSettings, + ?SettingsInterface $defaultSettings = null, + ): self { + // Copy existing settings from current settings map/tree, to keep any settings + // that have been present before (and are not defined in $defaultSettings) + // Usecase for site settings: + // Preserve "anonymous" v12-style site settings that have no definition in settings.definitions.yaml + // and are stored as a tree instead of a map + $settings = $currentSettings; + + // Merge target settings into current settings + $changes = []; + $deletions = []; + foreach ($targetSettings->getIdentifiers() as $key) { + $value = $targetSettings->get($key); + if ($defaultSettings !== null && $value === $defaultSettings->get($key)) { + if (ArrayUtility::isValidPath($settings, $key, '.')) { + $settings = self::removeByPathWithAncestors($settings, $key, '.'); + $deletions[] = $key; + } + if (array_key_exists($key, $settings)) { + unset($settings[$key]); + $deletions[] = $key; + } + continue; + } + + // Remove key from legacy tree + if (str_contains($key, '.') && ArrayUtility::isValidPath($settings, $key, '.')) { + $settings = self::removeByPathWithAncestors($settings, $key, '.'); + } + + if (!array_key_exists($key, $settings) + || $value !== $settings[$key] + ) { + $settings[$key] = $value; + $changes[] = $key; + } + } + + return new self( + $settings, + $changes, + $deletions + ); + } + + private static function removeByPathWithAncestors(array $array, string $path, string $delimiter): array + { + if ($path === '' || !ArrayUtility::isValidPath($array, $path, $delimiter)) { + return $array; + } + + $array = ArrayUtility::removeByPath($array, $path, $delimiter); + $parts = explode($delimiter, $path); + array_pop($parts); + $parentPath = implode($delimiter, $parts); + + if ($parentPath !== '' && ArrayUtility::isValidPath($array, $parentPath, $delimiter)) { + $parent = ArrayUtility::getValueByPath($array, $parentPath, $delimiter); + if ($parent === []) { + return self::removeByPathWithAncestors($array, $parentPath, $delimiter); + } + } + return $array; + } +} diff --git a/Classes/Settings/SettingsFactory.php b/Classes/Settings/SettingsFactory.php new file mode 100644 index 0000000..381bfd6 --- /dev/null +++ b/Classes/Settings/SettingsFactory.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +final readonly class SettingsFactory +{ + public function __construct( + private SettingsTypeRegistry $settingsTypeRegistry, + ) {} + + public function resolveSettings(SettingsProviderInterface ...$providers): SettingsInterface + { + /** @var SettingDefinition[] $definitions */ + $definitions = []; + /** @var SettingValue[] $settings */ + $settings = []; + foreach ($providers as $provider) { + foreach ($provider->getDefinitions() as $definition) { + $definitions[$definition->key] = $definition; + } + $settings = [ + ...$settings, + ...$provider->getProvidedSettings($definitions), + ]; + } + + /** @var array<string, string|int|float|bool|array|null> $map */ + $map = []; + foreach (array_reverse($settings) as $setting) { + if (array_key_exists($setting->key, $map)) { + continue; + } + + $value = $setting->value; + if ($setting->definition !== null && !$this->validateAndTransformValue($value, $setting->definition)) { + continue; + } + $map[$setting->key] = $value; + } + + return new Settings($map); + } + + public function createSettingsFromFormData(array $settings, iterable $definitions): SettingsInterface + { + $definitionMap = []; + foreach ($definitions as $definition) { + $definitionMap[$definition->key] = $definition; + } + foreach ($settings as $key => $value) { + $definition = $definitionMap[$key] ?? null; + if ($definition === null) { + throw new \RuntimeException('Unexpected setting ' . $key . ' is not defined', 1724067004); + } + if ($definition->readonly) { + unset($settings[$key]); + continue; + } + // @todo We should collect invalid values and report in the UI instead of ignoring them + if (!$this->validateAndTransformValue($value, $definition)) { + $value = $definition->default; + } + $settings[$key] = $value; + } + + return new Settings($settings); + } + + private function validateAndTransformValue(mixed &$value, SettingDefinition $definition): bool + { + if (!$this->settingsTypeRegistry->has($definition->type)) { + throw new \RuntimeException('Setting type ' . $definition->type . ' is not defined.', 1712437727); + } + $type = $this->settingsTypeRegistry->get($definition->type); + if (!$type->validate($value, $definition)) { + return false; + } + + $value = $type->transformValue($value, $definition); + return true; + } +} diff --git a/Classes/Settings/SettingsInterface.php b/Classes/Settings/SettingsInterface.php new file mode 100644 index 0000000..3773e64 --- /dev/null +++ b/Classes/Settings/SettingsInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +use Psr\Container\ContainerInterface; + +/** + * @internal + */ +interface SettingsInterface extends ContainerInterface +{ + public function getIdentifiers(): array; + + public static function __set_state(array $state): SettingsInterface; +} diff --git a/Classes/Settings/SettingsProvider.php b/Classes/Settings/SettingsProvider.php new file mode 100644 index 0000000..4865fca --- /dev/null +++ b/Classes/Settings/SettingsProvider.php @@ -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\Core\Settings; + +/** + * Settings provider implementation for providing settings definitions and values. + * + * This provider is used internally by the Settings API to manage setting definitions + * and their corresponding values. It combines default values from definitions with + * runtime values provided through the settings array. + * + * @internal + */ +final readonly class SettingsProvider implements SettingsProviderInterface +{ + public function __construct( + public string $name, + private array $settings, + private array $definitions = [], + ) {} + + /** + * @return SettingDefinition[] + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * @return SettingValue[] + */ + public function getProvidedSettings(array $globalDefinitions): array + { + /** @var SettingValue[] $settings */ + $settings = []; + foreach ($this->definitions as $definition) { + $settings[] = new SettingValue( + value: $definition->default, + key: $definition->key, + definition: $definition, + ); + } + + foreach ($this->settings as $key => $value) { + $definition = $globalDefinitions[$key] ?? null; + if ($definition !== null) { + $settings[] = new SettingValue( + value: $value, + key: $key, + definition: $definition, + ); + } + } + + return $settings; + } +} diff --git a/Classes/Settings/SettingsProviderInterface.php b/Classes/Settings/SettingsProviderInterface.php new file mode 100644 index 0000000..3a2389d --- /dev/null +++ b/Classes/Settings/SettingsProviderInterface.php @@ -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\Core\Settings; + +/** + * @internal + */ +interface SettingsProviderInterface +{ + /** + * @return SettingDefinition[] + */ + public function getDefinitions(): array; + + /** + * @param SettingDefinition[] $currentDefinitions + * @return SettingValue[] + */ + public function getProvidedSettings(array $currentDefinitions): array; +} diff --git a/Classes/Settings/SettingsTypeInterface.php b/Classes/Settings/SettingsTypeInterface.php new file mode 100644 index 0000000..18e8ea1 --- /dev/null +++ b/Classes/Settings/SettingsTypeInterface.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; + +/** + * @internal + */ +#[AutoconfigureTag('settings.type')] +interface SettingsTypeInterface +{ + public function validate(mixed $value, SettingDefinition $definition): bool; + + public function transformValue(mixed $value, SettingDefinition $definition): mixed; + + public function getJavaScriptModule(): string; +} diff --git a/Classes/Settings/SettingsTypeOption.php b/Classes/Settings/SettingsTypeOption.php new file mode 100644 index 0000000..b9d51c7 --- /dev/null +++ b/Classes/Settings/SettingsTypeOption.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +final readonly class SettingsTypeOption +{ + public function __construct( + public string $type, + public string $description, + public bool $required = false, + ) {} +} diff --git a/Classes/Settings/SettingsTypeOptionAwareInterface.php b/Classes/Settings/SettingsTypeOptionAwareInterface.php new file mode 100644 index 0000000..88dd81f --- /dev/null +++ b/Classes/Settings/SettingsTypeOptionAwareInterface.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +interface SettingsTypeOptionAwareInterface +{ + /** + * @return array<string, SettingsTypeOption> + */ + public function getSupportedOptions(): array; + + public function validateOptions(SettingDefinition $definition): bool; +} diff --git a/Classes/Settings/SettingsTypeRegistry.php b/Classes/Settings/SettingsTypeRegistry.php new file mode 100644 index 0000000..cd260d8 --- /dev/null +++ b/Classes/Settings/SettingsTypeRegistry.php @@ -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\Core\Settings; + +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * @internal + */ +final readonly class SettingsTypeRegistry +{ + public function __construct( + #[AutowireLocator('settings.type')] + private ServiceLocator $types + ) {} + + public function has(string $type): bool + { + return $this->types->has($type); + } + + public function get(string $type): SettingsTypeInterface + { + return $this->types->get($type); + } +} diff --git a/Classes/Settings/Type/BoolType.php b/Classes/Settings/Type/BoolType.php new file mode 100644 index 0000000..e2d9452 --- /dev/null +++ b/Classes/Settings/Type/BoolType.php @@ -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\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; + +#[AsTaggedItem(index: 'bool')] +readonly class BoolType implements SettingsTypeInterface +{ + /** @var array<int|string, bool> */ + private array $stringMap; + + public function __construct( + protected LoggerInterface $logger, + ) { + $this->stringMap = [ + '0' => false, + '1' => true, + 'false' => false, + 'true' => true, + 'off' => false, + 'on' => true, + 'no' => false, + 'yes' => true, + ]; + } + + public function validate(mixed $value, SettingDefinition $definition): bool + { + if (is_bool($value)) { + return true; + } + if (($value === 0 || $value === 1)) { + return true; + } + if (is_string($value) && isset($this->stringMap[$value])) { + return true; + } + return false; + } + + public function transformValue(mixed $value, SettingDefinition $definition): bool + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return (bool)$value; + } + if (is_string($value)) { + return $this->stringMap[$value] ?? false; + } + return false; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/bool.js'; + } +} diff --git a/Classes/Settings/Type/ColorType.php b/Classes/Settings/Type/ColorType.php new file mode 100644 index 0000000..74204e4 --- /dev/null +++ b/Classes/Settings/Type/ColorType.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * @todo Extract color parsing into utility including a registry for + * custom color spaces. + */ +#[AsTaggedItem(index: 'color')] +readonly class ColorType implements SettingsTypeInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + $stringType = new StringType($this->logger); + if (!$stringType->validate($value, $definition)) { + return false; + } + + $value = $stringType->transformValue($value, $definition); + return $this->doColorNormalization($value) !== null; + } + + public function transformValue(mixed $value, SettingDefinition $definition): string + { + $stringType = new StringType($this->logger); + if (!$stringType->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + $value = $stringType->transformValue($value, $definition); + return $this->doColorNormalization($value) ?? $definition->default; + } + + private function doColorNormalization(string $value): ?string + { + if (str_starts_with($value, 'rgb(') && str_ends_with($value, ')')) { + $values = GeneralUtility::trimExplode(',', substr(substr($value, 0, -1), 4)); + return $this->normalizeRgb($values); + } + if (str_starts_with($value, 'rgba(') && str_ends_with($value, ')')) { + $values = GeneralUtility::trimExplode(',', substr(substr($value, 0, -1), 5)); + return $this->normalizeRgba($values); + } + + if (str_starts_with($value, '#')) { + return $this->normalizeHex(substr($value, 1)); + } + + return null; + } + + private function normalizeRgb(array $values): ?string + { + if (count($values) === 1) { + $values = GeneralUtility::trimExplode('/', $values[0]); + if (count($values) === 2) { + return $this->normalizeRgba([...GeneralUtility::trimExplode(' ', $values[0]), $values[1]]); + } + $values = GeneralUtility::trimExplode(' ', $values[0]); + } + if (count($values) !== 3) { + return null; + } + foreach ($values as $value) { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + $value = (int)$value; + if ($value < 0 || $value > 255) { + return null; + } + } + return 'rgb(' . implode(',', $values) . ')'; + } + + private function normalizeRgba(array $values): ?string + { + if (count($values) !== 4) { + return null; + } + + $a = array_pop($values); + if (!MathUtility::canBeInterpretedAsFloat($a)) { + return null; + } + + if ((float)$a < 0 || (float)$a > 1) { + return null; + } + + foreach ($values as $value) { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + $value = (int)$value; + if ($value < 0 || $value > 255) { + return null; + } + } + $values[] = $a; + return 'rgba(' . implode(',', $values) . ')'; + } + + private function normalizeHex(string $values): ?string + { + $len = strlen($values); + if ($len !== 3 && $len !== 6 && $len !== 8) { + return null; + } + + if (!preg_match('/^[0-9a-f]+$/i', $values)) { + return null; + } + + return '#' . $values; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/color.js'; + } +} diff --git a/Classes/Settings/Type/IntType.php b/Classes/Settings/Type/IntType.php new file mode 100644 index 0000000..7c7c765 --- /dev/null +++ b/Classes/Settings/Type/IntType.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Settings\SettingsTypeOption; +use TYPO3\CMS\Core\Settings\SettingsTypeOptionAwareInterface; +use TYPO3\CMS\Core\Utility\MathUtility; + +#[AsTaggedItem(index: 'int')] +readonly class IntType implements SettingsTypeInterface, SettingsTypeOptionAwareInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + // Normalize to integer if possible + if (is_int($value)) { + $intValue = $value; + } elseif (is_string($value) && MathUtility::canBeInterpretedAsInteger($value)) { + $intValue = (int)$value; + } else { + return false; + } + + // Check optional constraints + if (array_key_exists('min', $definition->options) + && $intValue < $definition->options['min'] + ) { + return false; + } + if (array_key_exists('max', $definition->options) + && $intValue > $definition->options['max'] + ) { + return false; + } + if (array_key_exists('step', $definition->options)) { + $stepBase = array_key_exists('min', $definition->options) ? $definition->options['min'] : 0; + if (($stepBase - $value) % $definition->options['step'] !== 0) { + return false; + } + } + + return true; + } + + public function transformValue(mixed $value, SettingDefinition $definition): int + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + return (int)$value; + } + + public function getSupportedOptions(): array + { + return [ + 'min' => new SettingsTypeOption( + type: 'int', + description: 'Minimum value allowed', + required: false, + ), + 'max' => new SettingsTypeOption( + type: 'int', + description: 'Maximum value allowed', + required: false, + ), + 'step' => new SettingsTypeOption( + type: 'int', + description: 'Step size', + required: false, + ), + ]; + } + + public function validateOptions(SettingDefinition $definition): bool + { + $min = $definition->options['min'] ?? null; + $max = $definition->options['max'] ?? null; + $step = $definition->options['step'] ?? null; + if ($min !== null && $max !== null && $min > $max) { + return false; + } + if ($min !== null && $max !== null && $step !== null) { + if ($max - $min < $step) { + return false; + } + if (($max - $min) % $step !== 0) { + return false; + } + } + return true; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/int.js'; + } +} diff --git a/Classes/Settings/Type/NumberType.php b/Classes/Settings/Type/NumberType.php new file mode 100644 index 0000000..c811422 --- /dev/null +++ b/Classes/Settings/Type/NumberType.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Settings\SettingsTypeOption; +use TYPO3\CMS\Core\Settings\SettingsTypeOptionAwareInterface; +use TYPO3\CMS\Core\Utility\MathUtility; + +#[AsTaggedItem(index: 'number')] +readonly class NumberType implements SettingsTypeInterface, SettingsTypeOptionAwareInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + // Normalize value + if (is_int($value) || is_float($value)) { + $numericValue = (float)$value; + } elseif (is_string($value) && ( + MathUtility::canBeInterpretedAsInteger($value) + || MathUtility::canBeInterpretedAsFloat($value) + )) { + $numericValue = (float)$value; + } else { + return false; + } + + // Check optional constraints + if (array_key_exists('min', $definition->options) + && $numericValue < $definition->options['min'] + ) { + return false; + } + if (array_key_exists('max', $definition->options) + && $numericValue > $definition->options['max'] + ) { + return false; + } + if (array_key_exists('step', $definition->options)) { + $stepBase = array_key_exists('min', $definition->options) ? $definition->options['min'] : 0.0; + $offset = ($stepBase - $numericValue) / $definition->options['step']; + if ((string)(int)$offset !== (string)$offset) { + return false; + } + } + + return true; + } + + public function transformValue(mixed $value, SettingDefinition $definition): int|float + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + if (is_string($value)) { + return MathUtility::canBeInterpretedAsInteger($value) + ? (int)$value + : (float)$value; + } + + return $value; + } + + public function getSupportedOptions(): array + { + return [ + 'min' => new SettingsTypeOption( + type: 'number', + description: 'Minimum value allowed', + required: false, + ), + 'max' => new SettingsTypeOption( + type: 'number', + description: 'Maximum value allowed', + required: false, + ), + 'step' => new SettingsTypeOption( + type: 'number', + description: 'Step size', + required: false, + ), + ]; + } + + public function validateOptions(SettingDefinition $definition): bool + { + $min = $definition->options['min'] ?? null; + $max = $definition->options['max'] ?? null; + $step = $definition->options['step'] ?? null; + if ($min !== null && $max !== null && $min > $max) { + return false; + } + if ($min !== null && $max !== null && $step !== null) { + if ($max - $min < $step) { + return false; + } + $steps = ($max - $min) / $step; + if ((string)(int)$steps !== (string)$steps) { + return false; + } + } + return true; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/number.js'; + } +} diff --git a/Classes/Settings/Type/PageType.php b/Classes/Settings/Type/PageType.php new file mode 100644 index 0000000..32b3c3c --- /dev/null +++ b/Classes/Settings/Type/PageType.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Utility\MathUtility; + +#[AsTaggedItem(index: 'page')] +readonly class PageType implements SettingsTypeInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + if (is_int($value)) { + return true; + } + + if (is_string($value) && MathUtility::canBeInterpretedAsInteger($value)) { + return true; + } + + return false; + } + + public function transformValue(mixed $value, SettingDefinition $definition): int + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + return (int)$value; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/page.js'; + } +} diff --git a/Classes/Settings/Type/StringListType.php b/Classes/Settings/Type/StringListType.php new file mode 100644 index 0000000..af33cf2 --- /dev/null +++ b/Classes/Settings/Type/StringListType.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; + +#[AsTaggedItem(index: 'stringlist')] +readonly class StringListType implements SettingsTypeInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + $value = $this->decodeJsonAsFallback($value); + if (!is_array($value)) { + return false; + } + return $this->doValidate(new StringType($this->logger), $value, $definition); + } + + public function transformValue(mixed $value, SettingDefinition $definition): array + { + $stringType = new StringType($this->logger); + $value = $this->decodeJsonAsFallback($value); + if (!is_array($value) || !$this->doValidate($stringType, $value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + return array_map(static fn(mixed $v): string => $stringType->transformValue($v, $definition), $value); + } + + public function doValidate(StringType $stringType, array $value, SettingDefinition $definition): bool + { + if (!array_is_list($value)) { + return false; + } + foreach ($value as $v) { + if (!$stringType->validate($v, $definition)) { + return false; + } + } + return true; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/stringlist.js'; + } + + private function decodeJsonAsFallback(mixed $value): mixed + { + if (is_array($value)) { + return $value; + } + + // Otherwise, check if given string value is a json-encoded string + if (is_string($value)) { + try { + // A json-encoded stringlist only needs 2-levels + $value = json_decode($value, false, 2, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + } + + return $value; + } +} diff --git a/Classes/Settings/Type/StringType.php b/Classes/Settings/Type/StringType.php new file mode 100644 index 0000000..980939a --- /dev/null +++ b/Classes/Settings/Type/StringType.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Settings\SettingsTypeOption; +use TYPO3\CMS\Core\Settings\SettingsTypeOptionAwareInterface; + +#[AsTaggedItem(index: 'string')] +readonly class StringType implements SettingsTypeInterface, SettingsTypeOptionAwareInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + if (is_object($value) && !$value instanceof \Stringable) { + return false; + } + + // Normalize value + $stringValue = (string)$value; + + // Check optional constraints + if (array_key_exists('min', $definition->options) + && mb_strlen($stringValue) < (int)$definition->options['min'] + ) { + return false; + } + if (array_key_exists('max', $definition->options) + && mb_strlen($stringValue) > (int)$definition->options['max'] + ) { + return false; + } + + return true; + } + + public function transformValue(mixed $value, SettingDefinition $definition): string + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + if (is_bool($value)) { + if ($value) { + return 'true'; + } + return 'false'; + } + + return (string)$value; + } + + public function getSupportedOptions(): array + { + return [ + 'min' => new SettingsTypeOption( + type: 'int', + description: 'Minimum character count allowed', + required: false, + ), + 'max' => new SettingsTypeOption( + type: 'int', + description: 'Maximum character count allowed', + required: false, + ), + ]; + } + + public function validateOptions(SettingDefinition $definition): bool + { + $min = $definition->options['min'] ?? null; + $max = $definition->options['max'] ?? null; + if ($min !== null && $min < 0) { + return false; + } + if ($max !== null && $max < 1) { + return false; + } + if ($min !== null && $max !== null && $min > $max) { + return false; + } + return true; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/string.js'; + } +} diff --git a/Classes/Settings/Type/TextType.php b/Classes/Settings/Type/TextType.php new file mode 100644 index 0000000..d19d9d1 --- /dev/null +++ b/Classes/Settings/Type/TextType.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; + +#[AsTaggedItem(index: 'text')] +readonly class TextType extends StringType {} diff --git a/Classes/Settings/Type/UrlType.php b/Classes/Settings/Type/UrlType.php new file mode 100644 index 0000000..6748014 --- /dev/null +++ b/Classes/Settings/Type/UrlType.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings\Type; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeInterface; +use TYPO3\CMS\Core\Settings\SettingsTypeOption; +use TYPO3\CMS\Core\Settings\SettingsTypeOptionAwareInterface; + +#[AsTaggedItem(index: 'url')] +readonly class UrlType implements SettingsTypeInterface, SettingsTypeOptionAwareInterface +{ + public function __construct( + protected LoggerInterface $logger, + ) {} + + public function validate(mixed $value, SettingDefinition $definition): bool + { + if ($value === null || $value === '') { + return true; + } + if (!is_string($value)) { + return false; + } + + if (filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + + // Check optional constraints + if (array_key_exists('pattern', $definition->options) + && preg_match('/' . str_replace('/', '\/', $definition->options['pattern']) . '/', $value) !== 1 + ) { + return false; + } + + return true; + } + + public function transformValue(mixed $value, SettingDefinition $definition): string + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Invalid URL, reverting to default: {key}', ['key' => $definition->key]); + return (string)$definition->default; + } + + return (string)$value; + } + + public function getSupportedOptions(): array + { + return [ + 'pattern' => new SettingsTypeOption( + type: 'string', + description: 'Regular expression pattern for URL validation', + required: false, + ), + ]; + } + + public function validateOptions(SettingDefinition $definition): bool + { + if (array_key_exists('pattern', $definition->options) + && @preg_match('/' . str_replace('/', '\/', $definition->options['pattern']) . '/', '') === false + ) { + return false; + } + return true; + } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/url.js'; + } +} diff --git a/Classes/SingletonInterface.php b/Classes/SingletonInterface.php new file mode 100644 index 0000000..8c95257 --- /dev/null +++ b/Classes/SingletonInterface.php @@ -0,0 +1,22 @@ +<?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\Core; + +/** + * "empty" interface for singletons (marker interface pattern) + * @see \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance() + */ +interface SingletonInterface {} diff --git a/Classes/Site/Entity/NullSite.php b/Classes/Site/Entity/NullSite.php new file mode 100644 index 0000000..13ffd44 --- /dev/null +++ b/Classes/Site/Entity/NullSite.php @@ -0,0 +1,183 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Entity representing a site for everything on "pid=0". Mostly used in TYPO3 Backend, not really in use elsewhere. + */ +class NullSite implements SiteInterface +{ + protected int $rootPageId = 0; + + /** + * @var SiteLanguage[] + */ + protected array $languages; + + /** + * Sets up a null site object + * + * @param array|null $languages site languages + * @param Uri|null $baseEntryPoint + */ + public function __construct(?array $languages = null, ?Uri $baseEntryPoint = null) + { + if (empty($languages)) { + // Create the default language if no language configuration is given + $this->languages[0] = new SiteLanguage( + 0, + '', + new Uri('/'), + ['enabled' => true] + ); + } else { + foreach ($languages as $languageConfiguration) { + $languageUid = (int)$languageConfiguration['languageId']; + // Language configuration does not have a base defined + // So the main site base is used (usually done for default languages) + $this->languages[$languageUid] = new SiteLanguage( + $languageUid, + $languageConfiguration['locale'] ?? '', + $baseEntryPoint ?: new Uri('/'), + $languageConfiguration + ); + } + } + } + + /** + * Returns always #NULL + */ + public function getIdentifier(): string + { + return '#NULL'; + } + + /** + * Always "/" + */ + public function getBase(): UriInterface + { + return new Uri('/'); + } + + /** + * Always zero + */ + public function getRootPageId(): int + { + return 0; + } + + /** + * Returns all available languages of this installation + * + * @return SiteLanguage[] + */ + public function getLanguages(): array + { + return $this->languages; + } + + /** + * Returns a language of this site, given by the sys_language_uid + * + * @throws \InvalidArgumentException + */ + public function getLanguageById(int $languageId): SiteLanguage + { + if (isset($this->languages[$languageId])) { + return $this->languages[$languageId]; + } + throw new \InvalidArgumentException( + 'Language ' . $languageId . ' does not exist on site ' . $this->getIdentifier() . '.', + 1522965188 + ); + } + + public function getDefaultLanguage(): SiteLanguage + { + return reset($this->languages); + } + + /** + * This takes page TSconfig into account (unlike Site interface) to find + * mod.SHARED.disableLanguages and mod.SHARED.defaultLanguageLabel + */ + public function getAvailableLanguages(BackendUserAuthentication $user, bool $includeAllLanguagesFlag = false, ?int $pageId = null): array + { + $availableLanguages = []; + + // Check if we need to add language "-1" + if ($includeAllLanguagesFlag && $user->checkLanguageAccess(-1)) { + $availableLanguages[-1] = new SiteLanguage(-1, '', $this->getBase(), [ + 'title' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:multipleLanguages'), + 'flag' => 'flags-multiple', + ]); + } + $pageTs = BackendUtility::getPagesTSconfig((int)$pageId); + $pageTs = $pageTs['mod.']['SHARED.'] ?? []; + + $disabledLanguages = GeneralUtility::intExplode(',', (string)($pageTs['disableLanguages'] ?? ''), true); + // Do not add the ones that are not allowed by the user + foreach ($this->languages as $language) { + if ($user->checkLanguageAccess($language) && !in_array($language->getLanguageId(), $disabledLanguages, true)) { + if ($language->getLanguageId() === 0) { + // 0: "Default" language + $defaultLanguageLabel = 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:defaultLanguage'; + $defaultLanguageLabel = $this->getLanguageService()->sL($defaultLanguageLabel); + if (isset($pageTs['defaultLanguageLabel'])) { + $defaultLanguageLabel = $pageTs['defaultLanguageLabel'] . ' (' . $defaultLanguageLabel . ')'; + } + $defaultLanguageFlag = ''; + if (isset($pageTs['defaultLanguageFlag'])) { + $defaultLanguageFlag = 'flags-' . $pageTs['defaultLanguageFlag']; + } + $language = new SiteLanguage(0, '', $language->getBase(), [ + 'title' => $defaultLanguageLabel, + 'flag' => $defaultLanguageFlag, + ]); + } + $availableLanguages[$language->getLanguageId()] = $language; + } + } + + return $availableLanguages; + } + + /** + * Returns a ready-to-use error handler, to be used within the ErrorController + */ + public function getErrorHandler(int $statusCode): PageErrorHandlerInterface + { + throw new \RuntimeException('No error handler given for the status code "' . $statusCode . '".', 1522495102); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Site/Entity/Site.php b/Classes/Site/Entity/Site.php new file mode 100644 index 0000000..0dde379 --- /dev/null +++ b/Classes/Site/Entity/Site.php @@ -0,0 +1,435 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +use Psr\Http\Message\UriInterface; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Error\PageErrorHandler\FluidPageErrorHandler; +use TYPO3\CMS\Core\Error\PageErrorHandler\InvalidPageErrorHandlerException; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageContentErrorHandler; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerNotConfiguredException; +use TYPO3\CMS\Core\Error\PageErrorHandler\RedirectLoginErrorHandler; +use TYPO3\CMS\Core\ExpressionLanguage\Resolver; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Routing\PageRouter; +use TYPO3\CMS\Core\Routing\RouterInterface; +use TYPO3\CMS\Core\Site\Set\SetError; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Entity representing a single site with available languages + * + * @phpstan-type LanguageRef -1|0|positive-int + */ +class Site implements SiteInterface +{ + protected const ERRORHANDLER_TYPE_PAGE = 'Page'; + protected const ERRORHANDLER_TYPE_FLUID = 'Fluid'; + protected const ERRORHANDLER_TYPE_PHP = 'PHP'; + protected const ERRORHANDLER_TYPE_LOGIN_REDIRECT = 'LoginRedirect'; + + /** + * @var string + */ + protected $identifier; + + /** + * @var UriInterface + */ + protected $base; + + /** + * @var int + */ + protected $rootPageId; + + /** + * Any attributes for this site + * @var array + */ + protected $configuration; + + /** + * Raw attributes for this site + * @var array + */ + protected $rawConfiguration; + + /** + * @var array<LanguageRef, SiteLanguage> + */ + protected $languages; + + /** + * @var list<string> + */ + protected array $sets; + + /** + * @var array<string, array{error: SetError, name: string, context: string}> + */ + public array $invalidSets = []; + + /** + * @var array + */ + protected $errorHandlers; + + protected SiteSettings $settings; + + protected ?SiteTypoScript $typoscript; + + protected ?SiteTSconfig $tsConfig; + + /** + * Sets up a site object, and its languages, error handlers and the settings + */ + public function __construct(string $identifier, int $rootPageId, array $configuration, ?SiteSettings $settings = null, ?SiteTypoScript $typoscript = null, ?SiteTSconfig $tsConfig = null) + { + $this->identifier = $identifier; + $this->rootPageId = $rootPageId; + if ($settings === null) { + // @todo deprecate null settings argument + $settings = SiteSettings::createFromSettingsTree($configuration['settings'] ?? []); + } + $this->settings = $settings; + $this->typoscript = $typoscript; + $this->tsConfig = $tsConfig; + $this->rawConfiguration = $configuration; + // Merge settings back in configuration for backwards-compatibility + $configuration['settings'] = $this->settings->getAll(); + $this->configuration = $configuration; + $configuration['languages'] = !empty($configuration['languages']) ? $configuration['languages'] : [ + 0 => [ + 'languageId' => 0, + 'title' => 'Default', + 'navigationTitle' => '', + 'flag' => 'us', + 'locale' => 'en_US.UTF-8', + ], + ]; + $baseUrl = $this->resolveBaseWithVariants( + $configuration['base'] ?? '', + $configuration['baseVariants'] ?? null + ); + $this->base = new Uri($this->sanitizeBaseUrl($baseUrl)); + + $this->sets = $configuration['dependencies'] ?? []; + foreach ($configuration['languages'] as $languageConfiguration) { + $languageUid = (int)$languageConfiguration['languageId']; + // site language has defined its own base, this is the case most of the time. + if (!empty($languageConfiguration['base'])) { + $base = $this->resolveBaseWithVariants( + $languageConfiguration['base'], + $languageConfiguration['baseVariants'] ?? null + ); + $base = new Uri($this->sanitizeBaseUrl($base)); + // no host given by the language-specific base, so lets prefix the main site base + if ($base->getScheme() === '' && $base->getHost() === '') { + $base = rtrim((string)$this->base, '/') . '/' . ltrim((string)$base, '/'); + $base = new Uri($this->sanitizeBaseUrl($base)); + } + } else { + // Language configuration does not have a base defined + // So the main site base is used (usually done for default languages) + $base = new Uri($this->sanitizeBaseUrl(rtrim((string)$this->base, '/') . '/')); + } + if (!empty($languageConfiguration['flag'])) { + if ($languageConfiguration['flag'] === 'global') { + $languageConfiguration['flag'] = 'flags-multiple'; + } elseif ($languageConfiguration['flag'] !== 'empty-empty') { + $languageConfiguration['flag'] = 'flags-' . $languageConfiguration['flag']; + } + } + $this->languages[$languageUid] = new SiteLanguage( + $languageUid, + $languageConfiguration['locale'], + $base, + $languageConfiguration + ); + } + foreach ($configuration['errorHandling'] ?? [] as $errorHandlingConfiguration) { + $code = $errorHandlingConfiguration['errorCode']; + unset($errorHandlingConfiguration['errorCode']); + $this->errorHandlers[(int)$code] = $errorHandlingConfiguration; + } + } + + /** + * Checks if the base has variants, and takes the first variant which matches an expression. + */ + protected function resolveBaseWithVariants(string $baseUrl, ?array $baseVariants): string + { + if (!empty($baseVariants)) { + $expressionLanguageResolver = GeneralUtility::makeInstance( + Resolver::class, + 'site', + [] + ); + foreach ($baseVariants as $baseVariant) { + try { + if ((bool)$expressionLanguageResolver->evaluate($baseVariant['condition'])) { + $baseUrl = $baseVariant['base']; + break; + } + } catch (SyntaxError $e) { + // silently fail and do not evaluate + // no logger here, as Site is currently cached and serialized + } + } + } + return $baseUrl; + } + + /** + * Gets the identifier of this site, + * mainly used when maintaining / configuring sites. + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * Returns the base URL of this site + */ + public function getBase(): UriInterface + { + return $this->base; + } + + /** + * Returns the root page ID of this site + */ + public function getRootPageId(): int + { + return $this->rootPageId; + } + + /** + * Returns all available languages of this site + * + * @return array<LanguageRef, SiteLanguage> + */ + public function getLanguages(): array + { + $languages = []; + foreach ($this->languages as $languageId => $language) { + if ($language->enabled()) { + $languages[$languageId] = $language; + } + } + return $languages; + } + + /** + * Returns configured sets of this site + * + * @return list<string> + */ + public function getSets(): array + { + return $this->sets; + } + + /** + * Returns all available languages of this site, even the ones disabled for frontend usages + * + * @return array<LanguageRef, SiteLanguage> + */ + public function getAllLanguages(): array + { + return $this->languages; + } + + /** + * Returns a language of this site, given by the sys_language_uid + * + * @throws \InvalidArgumentException + */ + public function getLanguageById(int $languageId): SiteLanguage + { + if (isset($this->languages[$languageId])) { + return $this->languages[$languageId]; + } + // @todo: Turn this into a specific exception to avoid catching \InvalidArgumentException + // since there is no hasLanguageById() or similar and some core places already + // call this method and try-catch global \InvalidArgumentException, which is bad practice. + throw new \InvalidArgumentException( + 'Language ' . $languageId . ' does not exist on site ' . $this->identifier . '.', + 1522960188 + ); + } + + public function getDefaultLanguage(): SiteLanguage + { + foreach ($this->languages as $language) { + if ($language->isPrimary()) { + return $language; + } + } + return reset($this->languages); + } + + /** + * @return array<LanguageRef, SiteLanguage> + */ + public function getAvailableLanguages(BackendUserAuthentication $user, bool $includeAllLanguagesFlag = false, ?int $pageId = null): array + { + $availableLanguages = []; + + // Check if we need to add language "-1" + if ($includeAllLanguagesFlag && $user->checkLanguageAccess(-1)) { + $availableLanguages[-1] = new SiteLanguage(-1, '', $this->getBase(), [ + 'title' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:multipleLanguages'), + 'flag' => 'flags-multiple', + ]); + } + + // Do not add the ones that are not allowed by the user + foreach ($this->languages as $language) { + if ($user->checkLanguageAccess($language)) { + $availableLanguages[$language->getLanguageId()] = $language; + } + } + + return $availableLanguages; + } + + /** + * Returns a ready-to-use error handler, to be used within the ErrorController + * + * @throws PageErrorHandlerNotConfiguredException + * @throws InvalidPageErrorHandlerException + */ + public function getErrorHandler(int $statusCode): PageErrorHandlerInterface + { + $errorHandlerConfiguration = $this->errorHandlers[$statusCode] ?? $this->errorHandlers[0] ?? null; + switch ($errorHandlerConfiguration['errorHandler'] ?? null) { + case self::ERRORHANDLER_TYPE_FLUID: + return GeneralUtility::makeInstance(FluidPageErrorHandler::class, $statusCode, $errorHandlerConfiguration); + case self::ERRORHANDLER_TYPE_PAGE: + return GeneralUtility::makeInstance(PageContentErrorHandler::class, $statusCode, $errorHandlerConfiguration); + case self::ERRORHANDLER_TYPE_LOGIN_REDIRECT: + return GeneralUtility::makeInstance(RedirectLoginErrorHandler::class, $statusCode, $errorHandlerConfiguration); + case self::ERRORHANDLER_TYPE_PHP: + $handler = GeneralUtility::makeInstance($errorHandlerConfiguration['errorPhpClassFQCN'], $statusCode, $errorHandlerConfiguration); + // Check if the interface is implemented + if (!($handler instanceof PageErrorHandlerInterface)) { + throw new InvalidPageErrorHandlerException('The configured error handler "' . (string)$errorHandlerConfiguration['errorPhpClassFQCN'] . '" for status code ' . $statusCode . ' must implement the PageErrorHandlerInterface.', 1527432330); + } + return $handler; + } + throw new PageErrorHandlerNotConfiguredException('No error handler given for the status code "' . $statusCode . '".', 1522495914); + } + + /** + * Returns the whole configuration for this site + */ + public function getConfiguration(): array + { + return $this->configuration; + } + + public function getRawConfiguration(): array + { + return $this->rawConfiguration; + } + + public function getSettings(): SiteSettings + { + return $this->settings; + } + + /** + * @internal + */ + public function isTypoScriptRoot(): bool + { + return $this->sets !== [] || $this->typoscript !== null || $this->tsConfig !== null; + } + + public function getTypoScript(): ?SiteTypoScript + { + return $this->typoscript; + } + + /** + * @internal + */ + public function getTSconfig(): ?SiteTSconfig + { + return $this->tsConfig; + } + + /** + * Returns a single configuration attribute + * + * @return mixed + * @throws \InvalidArgumentException + */ + public function getAttribute(string $attributeName) + { + if (isset($this->configuration[$attributeName])) { + return $this->configuration[$attributeName]; + } + throw new \InvalidArgumentException( + 'Attribute ' . $attributeName . ' does not exist on site ' . $this->identifier . '.', + 1522495954 + ); + } + + /** + * If a site base contains "/" or "www.domain.com", it is ensured that + * parse_url() can handle this kind of configuration properly. + */ + protected function sanitizeBaseUrl(string $base): string + { + // no protocol ("//") and the first part is no "/" (path), means that this is a domain like + // "www.domain.com/subpage", and we want to ensure that this one then gets a "no-scheme agnostic" part + if (!empty($base) && !str_contains($base, '//') && $base[0] !== '/') { + // either a scheme is added, or no scheme but with domain, or a path which is not absolute + // make the base prefixed with a slash, so it is recognized as path, not as domain + // treat as path + if (!str_contains($base, '.')) { + $base = '/' . $base; + } else { + // treat as domain name + $base = '//' . $base; + } + } + return $base; + } + + /** + * Returns the applicable router for this site. This might be configurable in the future. + */ + public function getRouter(?Context $context = null): RouterInterface + { + return GeneralUtility::makeInstance(PageRouter::class, $this, $context); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Site/Entity/SiteInterface.php b/Classes/Site/Entity/SiteInterface.php new file mode 100644 index 0000000..a30910f --- /dev/null +++ b/Classes/Site/Entity/SiteInterface.php @@ -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\Core\Site\Entity; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface; +use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerNotConfiguredException; + +interface SiteInterface +{ + /** + * Returns the root page ID of this site + */ + public function getRootPageId(): int; + + /** + * Returns an identifier for the site / configuration + */ + public function getIdentifier(): string; + + /** + * Returns the base URL + */ + public function getBase(): UriInterface; + + /** + * Returns all available languages of this site visible in the frontend + * + * @return SiteLanguage[] + */ + public function getLanguages(): array; + + /** + * Returns a language of this site, given by the sys_language_uid + * + * @throws \InvalidArgumentException + */ + public function getLanguageById(int $languageId): SiteLanguage; + + /** + * Returns the first language that was configured. This is usually language=0 + */ + public function getDefaultLanguage(): SiteLanguage; + + /** + * Fetch the available languages for a specific backend user, used in various places in Backend and Frontend + * when a Backend User is authenticated. + * + * @param BackendUserAuthentication $user the authenticated backend user to check access rights + * @param bool $includeAllLanguagesFlag whether "-1" should be included in the values or not. + * @param int|null $pageId usually used for resolving additional information from PageTS, only used for pseudo-sites. uid of the default language row! + * @return SiteLanguage[] + */ + public function getAvailableLanguages(BackendUserAuthentication $user, bool $includeAllLanguagesFlag = false, ?int $pageId = null): array; + + /** + * Returns a ready-to-use error handler, to be used within the ErrorController + * + * @throws PageErrorHandlerNotConfiguredException + */ + public function getErrorHandler(int $statusCode): PageErrorHandlerInterface; +} diff --git a/Classes/Site/Entity/SiteLanguage.php b/Classes/Site/Entity/SiteLanguage.php new file mode 100644 index 0000000..f1f3356 --- /dev/null +++ b/Classes/Site/Entity/SiteLanguage.php @@ -0,0 +1,311 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Localization\Locale; + +/** + * Entity representing a site_language configuration of a site object. + */ +class SiteLanguage +{ + /** + * The language id. + * + * @var int + */ + protected $languageId; + + /** + * The language tag, like 'de', 'en', 'de-CH'. + */ + protected string $languageTag = ''; + + /** + * Locale, like 'de-CH' or 'en-GB' + */ + protected Locale $locale; + + /** + * The Base URL for this language + * + * @var UriInterface + */ + protected $base; + + /** + * Label to be used within TYPO3 to identify the language + * @var string + */ + protected $title = 'Default'; + + /** + * Label to be used within language menus + * @var string + */ + protected $navigationTitle = ''; + + /** + * Localized title of the site to be used in title tag. + * @var string + */ + protected $websiteTitle = ''; + + /** + * The flag key (like "gb" or "fr") used to be used in TYPO3's Backend. + * @var string + */ + protected $flagIdentifier = ''; + + /** + * Language tag for this language defined by RFC 1766 / 3066 for "hreflang" attribute + * + * @var string + */ + protected $hreflang = ''; + + /** + * Prefix for TYPO3's language files. If empty, this + * is fetched from $locale + * + * @var string + */ + protected $typo3Language = ''; + + /** + * @var string + */ + protected $fallbackType = 'strict'; + + /** + * @var array + */ + protected $fallbackLanguageIds = []; + + /** + * @var bool + */ + protected $enabled = true; + + /** + * Whether this language is the primary language of the site. + */ + protected bool $primary = false; + + /** + * Additional parameters configured for this site language + * @var array + */ + protected $configuration = []; + + /** + * SiteLanguage constructor. + */ + public function __construct(int $languageId, string $locale, UriInterface $base, array $configuration) + { + $this->languageId = $languageId; + $this->locale = new Locale($locale); + $this->base = $base; + $this->configuration = $configuration; + + if (!empty($configuration['languageTag'])) { + $this->languageTag = $configuration['languageTag']; + } + if (!empty($configuration['title'])) { + $this->title = $configuration['title']; + } + if (!empty($configuration['navigationTitle'])) { + $this->navigationTitle = $configuration['navigationTitle']; + } + if (!empty($configuration['websiteTitle'])) { + $this->websiteTitle = $configuration['websiteTitle']; + } + if (!empty($configuration['flag'])) { + $this->flagIdentifier = $configuration['flag']; + } + if (!empty($configuration['typo3Language'])) { + $this->typo3Language = $configuration['typo3Language']; + } + if (!empty($configuration['hreflang'])) { + $this->hreflang = $configuration['hreflang']; + } + if (!empty($configuration['fallbackType'])) { + $this->fallbackType = $configuration['fallbackType']; + } + if (isset($configuration['fallbacks'])) { + $fallbackLanguageIds = $configuration['fallbacks']; + + // It is important to distinct between "0" and "" so, empty() should not be used here + if (is_string($fallbackLanguageIds)) { + if ($fallbackLanguageIds !== '') { + $fallbackLanguageIds = explode(',', $fallbackLanguageIds); + } else { + $fallbackLanguageIds = []; + } + } elseif (is_scalar($fallbackLanguageIds)) { + $fallbackLanguageIds = [$fallbackLanguageIds]; + } + $this->fallbackLanguageIds = array_map(intval(...), $fallbackLanguageIds); + } + if (isset($configuration['enabled'])) { + $this->enabled = (bool)$configuration['enabled']; + } + if (!empty($configuration['primary'])) { + $this->primary = true; + } + } + + /** + * Returns the SiteLanguage in an array representation for e.g. the usage + * in TypoScript. + */ + public function toArray(): array + { + return array_merge($this->configuration, [ + 'languageId' => $this->getLanguageId(), + 'languageTag' => $this->getLanguageTag(), + // kept for backwards-compat for the time being, might change to BGP-47 format + 'locale' => $this->getLocale()->posixFormatted(), + 'base' => (string)$this->getBase(), + 'title' => $this->getTitle(), + 'websiteTitle' => $this->getWebsiteTitle(), + 'navigationTitle' => $this->getNavigationTitle(), + 'hreflang' => $this->hreflang ?: $this->locale->getName(), + 'typo3Language' => $this->getTypo3Language(), + 'flagIdentifier' => $this->getFlagIdentifier(), + 'fallbackType' => $this->getFallbackType(), + 'enabled' => $this->enabled(), + 'primary' => $this->isPrimary(), + 'fallbackLanguageIds' => $this->getFallbackLanguageIds(), + ]); + } + + public function getConfiguration(): array + { + return $this->toArray(); + } + + public function getLanguageId(): int + { + return $this->languageId; + } + + public function getLanguageTag(): string + { + return $this->languageTag; + } + + public function getLocale(): Locale + { + return $this->locale; + } + + public function getBase(): UriInterface + { + return $this->base; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getNavigationTitle(): string + { + return $this->navigationTitle ?: $this->title; + } + + public function getWebsiteTitle(): string + { + return $this->websiteTitle; + } + + public function getFlagIdentifier(): string + { + return $this->flagIdentifier; + } + + /** + * Returns the XLF label language key. + * For locales like "en-US", this method returns "en_US" which can then be used + * for XLF file prefixes properly. + */ + public function getTypo3Language(): string + { + if ($this->typo3Language !== '') { + return $this->typo3Language; + } + $typo3Language = $this->locale->getLanguageCode(); + if ($this->locale->getCountryCode()) { + $typo3Language .= '_' . $this->locale->getCountryCode(); + } + return $typo3Language; + } + + /** + * @internal + */ + public function hasCustomTypo3Language(): bool + { + return $this->typo3Language !== ''; + } + + /** + * Returns the RFC 1766 / 3066 language tag for hreflang tags + */ + public function getHreflang(bool $fetchCustomSetting = false): string + { + // Ensure to check if a custom attribute is set + if ($fetchCustomSetting) { + return $this->hreflang; + } + return $this->hreflang ?: $this->locale->getName(); + } + + /** + * Returns true if the language is available in frontend usage + */ + public function enabled(): bool + { + return $this->enabled; + } + + /** + * Helper so fluid can work with this as well. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + public function isPrimary(): bool + { + return $this->primary; + } + + public function getFallbackType(): string + { + return $this->fallbackType; + } + + public function getFallbackLanguageIds(): array + { + return $this->fallbackLanguageIds; + } +} diff --git a/Classes/Site/Entity/SiteSettings.php b/Classes/Site/Entity/SiteSettings.php new file mode 100644 index 0000000..ecca35f --- /dev/null +++ b/Classes/Site/Entity/SiteSettings.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +use TYPO3\CMS\Core\Settings\Settings; +use TYPO3\CMS\Core\Settings\SettingsInterface; +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * Entity representing all settings for a site. These settings are not overlaid + * with TypoScript settings / constants which happens in the TypoScript Parser + * for a specific page. + */ +final readonly class SiteSettings implements SettingsInterface, \JsonSerializable +{ + /** + * @internal to be constructed by create() or createFromSettingsTree() + */ + public function __construct( + private SettingsInterface $settings, + private array $settingsTree, + private array $flattenedArrayValues, + ) {} + + public function has(string $identifier): bool + { + return $this->settings->has($identifier) || array_key_exists($identifier, $this->settingsTree) || array_key_exists($identifier, $this->flattenedArrayValues); + } + + public function isEmpty(): bool + { + return $this->settings->getIdentifiers() === []; + } + + public function get(string $identifier, mixed $defaultValue = null): mixed + { + if ($this->settings->has($identifier)) { + return $this->settings->get($identifier); + } + return $this->flattenedArrayValues[$identifier] ?? $this->settingsTree[$identifier] ?? $defaultValue; + } + + public function getAll(): array + { + return $this->settingsTree; + } + + public function getMap(): array + { + $map = []; + foreach ($this->settings->getIdentifiers() as $key) { + $map[$key] = $this->settings->get($key); + } + return $map; + } + + public function getAllFlat(): array + { + return [ + ...$this->flattenedArrayValues, + ...array_filter($this->getMap(), static fn(mixed $value): bool => !is_array($value)), + ]; + } + + /** + * @todo Update jsonSerialize() to return settings map and settings tree values, or remove altogether. + */ + public function jsonSerialize(): mixed + { + return json_encode($this->settingsTree); + } + + public function getIdentifiers(): array + { + return $this->settings->getIdentifiers(); + } + + public static function __set_state(array $state): static + { + return new static(...$state); + } + + /** + * @internal + */ + public static function create(SettingsInterface $settings): self + { + $tree = []; + $flattenedArrayValues = []; + foreach ($settings->getIdentifiers() as $key) { + $value = $settings->get($key); + $tree = ArrayUtility::setValueByPath($tree, $key, $value, '.'); + if (is_array($value)) { + foreach (ArrayUtility::flattenPlain($value) as $flatKey => $flatValue) { + $flattenedArrayValues[$key . '.' . $flatKey] = $flatValue; + } + } + } + + return new self( + settings: $settings, + settingsTree: $tree, + flattenedArrayValues: $flattenedArrayValues, + ); + } + + /** + * @internal + */ + public static function createFromSettingsTree(array $settingsTree): self + { + $flatSettings = $settingsTree === [] ? [] : ArrayUtility::flattenPlain($settingsTree); + return new self( + settings: new Settings($flatSettings), + settingsTree: $settingsTree, + flattenedArrayValues: [], + ); + } +} diff --git a/Classes/Site/Entity/SiteTSconfig.php b/Classes/Site/Entity/SiteTSconfig.php new file mode 100644 index 0000000..476f297 --- /dev/null +++ b/Classes/Site/Entity/SiteTSconfig.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +/** + * @internal + */ +final readonly class SiteTSconfig +{ + public function __construct( + public ?string $pageTSconfig = null, + ) {} + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Site/Entity/SiteTypoScript.php b/Classes/Site/Entity/SiteTypoScript.php new file mode 100644 index 0000000..c8d674b --- /dev/null +++ b/Classes/Site/Entity/SiteTypoScript.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Entity; + +final readonly class SiteTypoScript +{ + public function __construct( + public ?string $setup = null, + public ?string $constants = null, + ) {} + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Site/Set/CategoryRegistry.php b/Classes/Site/Set/CategoryRegistry.php new file mode 100644 index 0000000..8b0ee19 --- /dev/null +++ b/Classes/Site/Set/CategoryRegistry.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +use TYPO3\CMS\Core\Settings\Category; +use TYPO3\CMS\Core\Settings\CategoryAccumulator; + +class CategoryRegistry +{ + public function __construct( + protected SetRegistry $setRegistry, + ) {} + + /** + * Retrieve list of instantiated categories for the list of + * provided $setNames, including their dependencies (recursive) + * + * @return list<Category> + */ + public function getCategories(string ...$setNames): array + { + $sets = $this->setRegistry->getSets(...$setNames); + $categories = []; + + $categoryDefinitions = []; + foreach ($sets as $set) { + foreach ($set->categoryDefinitions as $definition) { + $categoryDefinitions[$definition->key] = $definition; + } + } + $settingsDefinitions = []; + foreach ($sets as $set) { + foreach ($set->settingsDefinitions as $definition) { + $settingsDefinitions[$definition->key] = $definition; + } + } + + $cateryAccumulator = new CategoryAccumulator(); + return $cateryAccumulator->getCategories( + $categoryDefinitions, + $settingsDefinitions, + ); + } +} diff --git a/Classes/Site/Set/InvalidCategoryDefinitionsException.php b/Classes/Site/Set/InvalidCategoryDefinitionsException.php new file mode 100644 index 0000000..5fc5520 --- /dev/null +++ b/Classes/Site/Set/InvalidCategoryDefinitionsException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal Only to be used by internal site settings functionality + */ +final class InvalidCategoryDefinitionsException extends \RuntimeException +{ + private readonly string $setName; + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $setName = '', + ) { + parent::__construct($message, $code, $previous); + $this->setName = $setName; + } + + public function getSetName(): string + { + return $this->setName; + } +} diff --git a/Classes/Site/Set/InvalidSetException.php b/Classes/Site/Set/InvalidSetException.php new file mode 100644 index 0000000..46c3c4e --- /dev/null +++ b/Classes/Site/Set/InvalidSetException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal Only to be used by internal site settings functionality + */ +class InvalidSetException extends \RuntimeException +{ + private readonly string $setName; + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $setName = '', + ) { + parent::__construct($message, $code, $previous); + $this->setName = $setName; + } + + public function getSetName(): string + { + return $this->setName; + } +} diff --git a/Classes/Site/Set/InvalidSetRouteEnhancersException.php b/Classes/Site/Set/InvalidSetRouteEnhancersException.php new file mode 100644 index 0000000..2e9cd02 --- /dev/null +++ b/Classes/Site/Set/InvalidSetRouteEnhancersException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal Only to be used by internal site set functionality + */ +final class InvalidSetRouteEnhancersException extends \RuntimeException +{ + private readonly string $setName; + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $setName = '', + ) { + parent::__construct($message, $code, $previous); + $this->setName = $setName; + } + + public function getSetName(): string + { + return $this->setName; + } +} diff --git a/Classes/Site/Set/InvalidSettingsDefinitionsException.php b/Classes/Site/Set/InvalidSettingsDefinitionsException.php new file mode 100644 index 0000000..286b018 --- /dev/null +++ b/Classes/Site/Set/InvalidSettingsDefinitionsException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal Only to be used by internal site settings functionality + */ +final class InvalidSettingsDefinitionsException extends \RuntimeException +{ + private readonly string $setName; + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $setName = '', + ) { + parent::__construct($message, $code, $previous); + $this->setName = $setName; + } + + public function getSetName(): string + { + return $this->setName; + } +} diff --git a/Classes/Site/Set/InvalidSettingsException.php b/Classes/Site/Set/InvalidSettingsException.php new file mode 100644 index 0000000..72bf08a --- /dev/null +++ b/Classes/Site/Set/InvalidSettingsException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal Only to be used by internal site settings functionality + */ +final class InvalidSettingsException extends \RuntimeException +{ + private readonly string $setName; + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $setName = '', + ) { + parent::__construct($message, $code, $previous); + $this->setName = $setName; + } + + public function getSetName(): string + { + return $this->setName; + } +} diff --git a/Classes/Site/Set/SetCollector.php b/Classes/Site/Set/SetCollector.php new file mode 100644 index 0000000..0a3e4bf --- /dev/null +++ b/Classes/Site/Set/SetCollector.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +/** + * @internal + */ +class SetCollector +{ + /** @var array<string, SetDefinition> */ + protected array $sets = []; + + /** @var array<string, array{ error: SetError, name: string, context: string }> */ + protected array $invalidSets = []; + + /** + * @return array<string, SetDefinition> + */ + public function getSetDefinitions(): array + { + return $this->sets; + } + + /** + * @return array<string, array{ error: SetError, name: string, context: string }> + */ + public function getInvalidSets(): array + { + return $this->invalidSets; + } + + public function add(SetDefinition $set): void + { + $this->sets[$set->name] = $set; + } + + public function addError(SetError $error, string $name, string $context): void + { + $this->invalidSets[$name] = [ + 'error' => $error, + 'name' => $name, + 'context' => $context, + ]; + } +} diff --git a/Classes/Site/Set/SetDefinition.php b/Classes/Site/Set/SetDefinition.php new file mode 100644 index 0000000..02f6211 --- /dev/null +++ b/Classes/Site/Set/SetDefinition.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +use TYPO3\CMS\Core\Settings\CategoryDefinition; +use TYPO3\CMS\Core\Settings\SettingDefinition; + +readonly class SetDefinition +{ + /** + * @param list<string> $dependencies + * @param SettingDefinition[] $settingsDefinitions + * @param CategoryDefinition[] $categoryDefinitions + * @param array<string, array<string, mixed>> $routeEnhancers Route enhancers keyed by identifier + */ + public function __construct( + public string $name, + public string $label, + public array $dependencies = [], + public array $optionalDependencies = [], + public array $settingsDefinitions = [], + public array $categoryDefinitions = [], + public ?string $typoscript = null, + public ?string $pagets = null, + public array $settings = [], + public bool $hidden = false, + public array $routeEnhancers = [], + ) {} + + public function toArray(): array + { + return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/Classes/Site/Set/SetError.php b/Classes/Site/Set/SetError.php new file mode 100644 index 0000000..99799cd --- /dev/null +++ b/Classes/Site/Set/SetError.php @@ -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\Core\Site\Set; + +enum SetError: string +{ + case notFound = 'not-found'; + case missingDependency = 'missing-dependency'; + case invalidSettingsDefinitions = 'invalid-settings-definitions'; + case invalidCategoryDefinitions = 'invalid-category-definitions'; + case invalidSettings = 'invalid-settings'; + case invalidRouteEnhancers = 'invalid-route-enhancers'; + case invalidSet = 'invalid-set'; + + public function getLabel(): string + { + return match ($this) { + self::notFound => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.notFound', + self::missingDependency => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.missingDependency', + self::invalidSettingsDefinitions => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.invalidSettingsDefinitions', + self::invalidCategoryDefinitions => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.invalidCategoryDefinitions', + self::invalidSettings => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.invalidSettings', + self::invalidRouteEnhancers => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.invalidRouteEnhancers', + self::invalidSet => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.siteSet.invalidSet', + }; + } +} diff --git a/Classes/Site/Set/SetRegistry.php b/Classes/Site/Set/SetRegistry.php new file mode 100644 index 0000000..27a4c30 --- /dev/null +++ b/Classes/Site/Set/SetRegistry.php @@ -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\Core\Site\Set; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Service\DependencyOrderingService; + +#[Autoconfigure(public: true)] +class SetRegistry +{ + /** @var list<SetDefinition>|null */ + protected ?array $orderedSets = null; + + /** @var array<string, array{ error: SetError, name: string, context: string }> */ + protected ?array $invalidSets = null; + + public function __construct( + protected DependencyOrderingService $dependencyOrderingService, + #[Autowire(expression: 'service("package-dependent-cache-identifier").withPrefix("Sets").toString()')] + protected readonly string $cacheIdentifier, + #[Autowire(service: 'cache.core')] + protected readonly PhpFrontend $cache, + #[Autowire(lazy: true)] + protected SetCollector $setCollector, + protected LoggerInterface $logger, + ) {} + + /** + * Retrieve list of ordered sets, matched by + * $setNames, including their dependencies (recursive) + * + * @return list<SetDefinition> + */ + public function getSets(string ...$setNames): array + { + return array_values(array_filter( + $this->getOrderedSets(), + fn(SetDefinition $set): bool + => in_array($set->name, $setNames, true) + || $this->hasDependency($setNames, $set->name) + )); + } + + public function hasSet(string $setName): bool + { + return isset($this->getOrderedSets()[$setName]); + } + + /** + * @return array<string, SetDefinition> + * @internal + */ + public function getAllSets(): array + { + return $this->getOrderedSets(); + } + + public function getSet(string $setName): ?SetDefinition + { + return $this->getOrderedSets()[$setName] ?? null; + } + + /** + * @return array<string, array{ error: SetError, name: string, context: string }> + */ + public function getInvalidSets(): array + { + // create ordered sets which logs invalidSets as out-of-band data + if ($this->orderedSets === null) { + $this->getOrderedSets(); + } + return $this->invalidSets; + } + + /** + * @return array<string, SetDefinition> + */ + protected function getOrderedSets(): array + { + return $this->orderedSets ?? $this->getFromCache() ?? $this->computeOrderedSets(); + } + + /** + * @return array<string, SetDefinition> + */ + protected function getFromCache(): ?array + { + if (!$this->cache->has($this->cacheIdentifier)) { + return null; + } + $setData = null; + try { + $setData = $this->cache->require($this->cacheIdentifier); + } catch (\Error) { + } + if ($setData === false) { + // Cache entry has been removed in the meantime + return null; + } + if (!is_array($setData) || !isset($setData['orderedSets']) || !isset($setData['invalidSets'])) { + throw new \RuntimeException('Invalid "Site Sets" cache entry', 1727809282); + } + $this->orderedSets = $setData['orderedSets']; + $this->invalidSets = $setData['invalidSets']; + return $this->orderedSets; + } + + protected function checkMissingDependencies(array $sets, SetDefinition $set): ?string + { + foreach ($set->dependencies as $dependencyName) { + $dependency = $sets[$dependencyName] ?? null; + if ($dependency === null) { + return $dependencyName; + } + $missingSubDependency = $this->checkMissingDependencies($sets, $dependency); + if ($missingSubDependency !== null) { + return $dependencyName . '[' . $missingSubDependency . ']'; + } + } + return null; + } + + /** + * @return array<string, SetDefinition> + */ + protected function computeOrderedSets(): array + { + $tmp = []; + $this->invalidSets = $this->setCollector->getInvalidSets(); + $sets = $this->setCollector->getSetDefinitions(); + foreach ($sets as $set) { + $missingDependency = $this->checkMissingDependencies($sets, $set); + if ($missingDependency !== null) { + $this->logger->error('Invalid set "{name}": Missing dependency "{dependency}"', [ + 'name' => $set->name, + 'dependency' => $missingDependency, + ]); + $this->invalidSets[$set->name] = [ + 'error' => SetError::missingDependency, + 'name' => $set->name, + 'context' => $missingDependency, + ]; + continue; + } + $tmp[$set->name] = [ + 'set' => $set, + 'after' => $set->dependencies, + 'after-resilient' => array_filter($set->optionalDependencies, static fn($dependency) => isset($sets[$dependency])), + ]; + } + + $this->orderedSets = array_map( + static fn(array $data): SetDefinition => $data['set'], + $this->dependencyOrderingService->orderByDependencies($tmp) + ); + + $setData = [ + 'orderedSets' => $this->orderedSets, + 'invalidSets' => $this->invalidSets, + ]; + $this->cache->set($this->cacheIdentifier, 'return ' . var_export($setData, true) . ';'); + return $this->orderedSets; + } + + protected function hasDependency(array $setNames, string $dependency): bool + { + foreach ($setNames as $setName) { + $set = $this->getSet($setName); + if ($set === null) { + continue; + } + + if (in_array($dependency, $set->dependencies, true)) { + return true; + } + + if (in_array($dependency, $set->optionalDependencies, true)) { + return true; + } + + if ($this->hasDependency($set->dependencies, $dependency)) { + return true; + } + + if ($this->hasDependency($set->optionalDependencies, $dependency)) { + return true; + } + } + return false; + } + + #[AsEventListener('typo3-core/set-registry')] + public function warmupCaches(CacheWarmupEvent $event): void + { + if ($event->hasGroup('system')) { + $this->computeOrderedSets(); + } + } +} diff --git a/Classes/Site/Set/YamlSetDefinitionProvider.php b/Classes/Site/Set/YamlSetDefinitionProvider.php new file mode 100644 index 0000000..3814127 --- /dev/null +++ b/Classes/Site/Set/YamlSetDefinitionProvider.php @@ -0,0 +1,371 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; +use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlParseException; +use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader; +use TYPO3\CMS\Core\Settings\CategoryDefinition; +use TYPO3\CMS\Core\Settings\InvalidSettingDefinitionException; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingDefinitionValidation; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +#[Autoconfigure(public: true)] +class YamlSetDefinitionProvider +{ + /** @var array<string, SetDefinition> */ + protected array $sets = []; + public function __construct( + protected readonly SettingDefinitionValidation $settingDefinitionValidation, + protected readonly YamlFileLoader $yamlFileLoader, + ) {} + + /** + * @return array<string, SetDefinition> + */ + public function getSetDefinitions(): array + { + return $this->sets; + } + + public function addSet(SetDefinition $set): void + { + $this->sets[$set->name] = $set; + } + + public function get(\SplFileInfo $fileInfo, ?string $virtualSetPath = null): SetDefinition + { + $filename = GeneralUtility::fixWindowsFilePath($fileInfo->getPathname()); + $path = dirname($filename); + $virtualSetPath ??= $path . '/'; + // No placeholders or imports processed on purpose + // Use dependencies for shared sets + try { + $set = Yaml::parseFile($filename); + } catch (ParseException $e) { + $source = $virtualSetPath . basename($filename); + throw new InvalidSetException('Failed to parse set definition from "' . $source . '": ' . $e->getMessage(), 1711024370, $e); + } + $setName = $set['name'] ?? ''; + + $settingsDefinitionsFile = $path . '/settings.definitions.yaml'; + if (is_file($settingsDefinitionsFile)) { + try { + $settingsDefinitions = Yaml::parseFile($settingsDefinitionsFile, Yaml::PARSE_OBJECT | Yaml::PARSE_OBJECT_FOR_MAP); + } catch (ParseException $e) { + $source = $virtualSetPath . basename($settingsDefinitionsFile); + throw new InvalidSettingsDefinitionsException( + 'Invalid settings definition. Source: ' . $source, + 1711024374, + $e, + $setName + ); + } + if (!is_object($settingsDefinitions->settings ?? null)) { + $source = $virtualSetPath . basename($settingsDefinitionsFile); + throw new InvalidSettingsDefinitionsException( + 'Missing "settings" key in settings definitions. Source: ' . $source, + 1711024378, + null, + $setName + ); + } + // YAML maps are decoded as objects. Normalize them to arrays so + // settings/category definitions can be handled uniformly below. + $set['settingsDefinitions'] = array_map( + static fn(?object $value): ?array => $value === null ? null : (array)$value, + get_object_vars($settingsDefinitions->settings) + ); + $set['categoryDefinitions'] = []; + if (isset($settingsDefinitions->categories)) { + $set['categoryDefinitions'] = array_map( + static fn(?object $value): ?array => $value === null ? null : (array)$value, + get_object_vars($settingsDefinitions->categories) + ); + } + } + + $settingsFile = $path . '/settings.yaml'; + if (is_file($settingsFile)) { + try { + // HEADS UP: YamlFileLoader::PROCESS_PLACEHOLDERS is omitted on purpose and MUST NOT be added. + // Site sets are intended to be self-contained and must not rely on implicit + // dependencies to global (environment) variables. + $settings = $this->yamlFileLoader->load($settingsFile, YamlFileLoader::PROCESS_IMPORTS | YamlFileLoader::ALLOW_EMPTY_FILE); + } catch (YamlParseException $e) { + $source = $virtualSetPath . basename($settingsFile); + throw new InvalidSettingsException('Invalid settings format. Source: ' . $source, 1711024380, $e, $setName); + } + $set['settings'] = $settings; + } + + $routeEnhancersFile = $path . '/route-enhancers.yaml'; + if (is_file($routeEnhancersFile)) { + try { + $routeEnhancers = $this->yamlFileLoader->load($routeEnhancersFile, YamlFileLoader::PROCESS_IMPORTS | YamlFileLoader::ALLOW_EMPTY_FILE); + } catch (YamlParseException $e) { + $source = $virtualSetPath . basename($routeEnhancersFile); + throw new InvalidSetRouteEnhancersException( + 'Invalid route enhancers format. Source: ' . $source, + 1764749081, + $e, + $setName + ); + } + if ($routeEnhancers !== [] && !is_array($routeEnhancers['routeEnhancers'] ?? null)) { + $source = $virtualSetPath . basename($routeEnhancersFile); + throw new InvalidSetRouteEnhancersException( + 'Missing "routeEnhancers" key in route enhancers file. Source: ' . $source, + 1764749082, + null, + $setName + ); + } + if ($routeEnhancers !== [] && array_keys($routeEnhancers) !== ['routeEnhancers']) { + $source = $virtualSetPath . basename($routeEnhancersFile); + throw new InvalidSetRouteEnhancersException( + 'Superfluous keys in route enhancers file. Use "routeEnhancers" as root level key. Source: ' . $source, + 1764749083, + null, + $setName + ); + } + $set['routeEnhancers'] = $routeEnhancers['routeEnhancers'] ?? []; + } + + if (($set['labels'] ?? '') === '') { + if (is_file($path . '/labels.xlf')) { + $set['labels'] = $virtualSetPath . 'labels.xlf'; + } + } + + return $this->createDefinition($set, $virtualSetPath); + } + + protected function createDefinition(array $set, string $basePath): SetDefinition + { + $settingsDefinitions = []; + $labels = $set['labels'] ?? null; + unset($set['labels']); + + if ($labels) { + $set['label'] ??= 'LLL:' . $labels . ':label'; + } + + foreach (($set['settingsDefinitions'] ?? []) as $setting => $options) { + // Cast objects to arrays + if (is_object($options['options'] ?? null)) { + $options['options'] = (array)$options['options']; + } + + if (is_array($options['enum'] ?? null)) { + $options['enum'] = array_combine( + $options['enum'], + array_map( + static fn(string|int|float|bool $value): string => sprintf( + '{label}:settings.%s.enum.%s', + $setting, + is_bool($value) ? ($value ? 'true' : 'false') : (string)$value + ), + $options['enum'] + ) + ); + } elseif (is_object($options['enum'] ?? null)) { + $options['enum'] = (array)$options['enum']; + } + if (is_array($options['enum'] ?? null)) { + foreach ($options['enum'] as $enumValue => $enumLabel) { + if ($enumLabel === null) { + $options['enum'][$enumValue] = (string)$enumValue; + } + } + } + + if (is_object($options['tags'] ?? null)) { + $options['tags'] = array_values((array)$options['tags']); + } + if ($labels) { + $domain = 'LLL:' . $labels . ':'; + $options['label'] ??= $domain . 'settings.' . $setting; + $options['description'] ??= $domain . 'settings.description.' . $setting; + if (is_array($options['enum'] ?? null)) { + foreach ($options['enum'] as $enumValue => $enumLabel) { + if (is_string($enumLabel) && str_starts_with($enumLabel, '{label}:')) { + $options['enum'][$enumValue] = $domain . substr($enumLabel, 8); + } + } + } + } + $settingDefinitionData = [...['key' => $setting], ...$options]; + try { + $definition = new SettingDefinition(...$settingDefinitionData); + } catch (\Error $e) { + throw new InvalidSettingsDefinitionsException( + 'Invalid setting definition "' . $setting . '": ' . json_encode($options) . ' – ' . $this->getObjectConstructionErrors($e, SettingDefinition::class, $settingDefinitionData), + 1702623312, + $e, + $set['name'] ?? '' + ); + } + try { + $this->settingDefinitionValidation->validate($definition); + } catch (InvalidSettingDefinitionException $e) { + throw new InvalidSettingsDefinitionsException( + $e->getMessage(), + 1752483401, + $e, + $set['name'] ?? '' + ); + } + $settingsDefinitions[] = $definition; + } + + $categoryDefinitions = []; + foreach (($set['categoryDefinitions'] ?? []) as $category => $options) { + if ($labels) { + $options['label'] ??= 'LLL:' . $labels . ':categories.' . $category; + $options['description'] ??= 'LLL:' . $labels . ':categories.description.' . $category; + } + try { + $definition = new CategoryDefinition(...[...['key' => $category], ...$options]); + } catch (\Error $e) { + throw new InvalidCategoryDefinitionsException( + 'Invalid category definition "' . $category . '": ' . json_encode($options), + 1702623313, + $e, + $set['name'] ?? '' + ); + } + $categoryDefinitions[] = $definition; + } + + foreach (($set['routeEnhancers'] ?? []) as $identifier => $config) { + if (!is_array($config)) { + throw new InvalidSetRouteEnhancersException( + sprintf('Invalid route enhancer definition "%s": expected array, got %s', $identifier, gettype($config)), + 1732800002, + null, + $set['name'] ?? '' + ); + } + } + + $setData = [ + ...$set, + 'settingsDefinitions' => $settingsDefinitions, + 'categoryDefinitions' => $categoryDefinitions, + ]; + $setData['typoscript'] ??= $basePath; + $setData['pagets'] ??= $basePath . 'page.tsconfig'; + try { + return new SetDefinition(...$setData); + } catch (\Error $e) { + throw new InvalidSetException( + 'Invalid set definition: ' . json_encode($set) . ' – ' . $this->getObjectConstructionErrors($e, SetDefinition::class, $setData), + 1170859526, + $e, + $set['name'] ?? '' + ); + } + } + + protected function getObjectConstructionErrors( + \Error $error, + string $className, + array $arguments, + ): string { + $reflection = new \ReflectionClass($className); + $constructor = $reflection->getConstructor(); + $parameters = $constructor->getParameters(); + $missingParameters = []; + $typeErrors = []; + foreach ($parameters as $parameter) { + if (isset($arguments[$parameter->name])) { + $value = $arguments[$parameter->name]; + unset($arguments[$parameter->name]); + $type = $parameter->getType(); + if (!$this->typeMatches($type, $value)) { + $typeErrors[$parameter->name] = (string)$type; + } + } elseif (!$parameter->isDefaultValueAvailable()) { + $missingParameters[] = $parameter->name; + } + } + + $errors = []; + if ($missingParameters !== []) { + $errors[] = 'Missing properties: ' . implode(', ', $missingParameters); + } + if ($arguments !== []) { + $errors[] = 'Invalid properties: ' . implode(', ', array_keys($arguments)); + } + if ($typeErrors !== []) { + $errors[] = 'Invalid type: ' . implode(', ', array_keys($typeErrors)); + } + + if ($errors === []) { + return $error->getMessage(); + } + + return implode('; ', $errors); + } + + protected function typeMatches( + \ReflectionType $type, + mixed $value + ): bool { + if ($type->allowsNull() && $value === null) { + return true; + } + + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $t) { + if ($this->typeMatches($t, $value)) { + return true; + } + } + return false; + } + + if ($type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if (!$this->typeMatches($t, $value)) { + return false; + } + } + return true; + } + + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + $valueType = gettype($value); + if ($valueType === 'object') { + return is_subclass_of($value, $typeName); + } + return $valueType === $typeName; + } + + return true; + } +} diff --git a/Classes/Site/SiteAwareInterface.php b/Classes/Site/SiteAwareInterface.php new file mode 100644 index 0000000..25db2d9 --- /dev/null +++ b/Classes/Site/SiteAwareInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use TYPO3\CMS\Core\Site\Entity\Site; + +/** + * Interface for SiteAware features of TYPO3 + */ +interface SiteAwareInterface +{ + public function setSite(Site $site): void; + + public function getSite(): Site; +} diff --git a/Classes/Site/SiteFinder.php b/Classes/Site/SiteFinder.php new file mode 100644 index 0000000..f527a01 --- /dev/null +++ b/Classes/Site/SiteFinder.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationChangedEvent; +use TYPO3\CMS\Core\Configuration\SiteConfiguration; +use TYPO3\CMS\Core\Exception\Page\PageNotFoundException; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; + +/** + * Is used in backend and frontend for all places where to read / identify sites and site languages. + */ +readonly class SiteFinder +{ + private const string CACHE_IDENTIFIER_ROOT_ID_TO_IDENTIFIER = 'sites-root-id-to-identifier'; + + public function __construct( + private SiteConfiguration $siteConfiguration, + #[Autowire(service: 'cache.runtime')] + private FrontendInterface $runtimeCache, + ) {} + + /** + * Return a list of all configured sites + * + * @return Site[] + */ + public function getAllSites(bool $useCache = true): array + { + return $this->siteConfiguration->getAllExistingSites($useCache); + } + + /** + * Find a site by given root page id + * + * @param int $rootPageId the page ID (default language) + * @throws SiteNotFoundException + * @internal only for usage in some places for managing Site Configuration, might be removed without further notice + */ + public function getSiteByRootPageId(int $rootPageId): Site + { + $mapping = $this->getRootPageIdToIdentifierMapping(); + $sites = $this->siteConfiguration->getAllExistingSites(); + if (isset($mapping[$rootPageId], $sites[$mapping[$rootPageId]])) { + return $sites[$mapping[$rootPageId]]; + } + throw new SiteNotFoundException('No site found for root page id ' . $rootPageId, 1521668882); + } + + /** + * Find a site by given identifier + * + * @throws SiteNotFoundException + */ + public function getSiteByIdentifier(string $identifier): Site + { + $sites = $this->siteConfiguration->getAllExistingSites(); + if (isset($sites[$identifier])) { + return $sites[$identifier]; + } + throw new SiteNotFoundException('No site found for identifier ' . $identifier, 1521716628); + } + + /** + * Traverses the rootline of a page up until a Site was found. + * + * @param string|null $mountPointParameter + * @throws SiteNotFoundException + */ + public function getSiteByPageId(int $pageId, ?array $rootLine = null, ?string $mountPointParameter = null): Site + { + if ($pageId === 0) { + // page uid 0 has no root line. We don't need to ask the root line resolver to know that. + $rootLine = []; + } + if (!is_array($rootLine)) { + try { + $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId, (string)$mountPointParameter)->get(); + } catch (PageNotFoundException) { + // Usually when a page was hidden or disconnected + // This could be improved by handing in a Context object and decide whether hidden pages + // Should be linkable too + $rootLine = []; + } + } + $sites = $this->siteConfiguration->getAllExistingSites(); + $mapping = $this->getRootPageIdToIdentifierMapping(); + foreach ($rootLine as $pageInRootLine) { + if (isset($mapping[(int)$pageInRootLine['uid']], $sites[$mapping[(int)$pageInRootLine['uid']]])) { + return $sites[$mapping[(int)$pageInRootLine['uid']]]; + } + } + throw new SiteNotFoundException('No site found in root line of page ' . $pageId, 1521716622); + } + + #[AsEventListener(event: SiteConfigurationChangedEvent::class)] + public function siteConfigurationChanged(): void + { + $this->runtimeCache->remove(self::CACHE_IDENTIFIER_ROOT_ID_TO_IDENTIFIER); + } + + private function getRootPageIdToIdentifierMapping(): array + { + $mapping = $this->runtimeCache->get(self::CACHE_IDENTIFIER_ROOT_ID_TO_IDENTIFIER); + if (is_array($mapping)) { + return $mapping; + } + $sites = $this->siteConfiguration->getAllExistingSites(); + $mapping = []; + foreach ($sites as $identifier => $site) { + $mapping[$site->getRootPageId()] = $identifier; + } + $this->runtimeCache->set(self::CACHE_IDENTIFIER_ROOT_ID_TO_IDENTIFIER, $mapping); + return $mapping; + } +} diff --git a/Classes/Site/SiteLanguageAwareInterface.php b/Classes/Site/SiteLanguageAwareInterface.php new file mode 100644 index 0000000..d4b5bf1 --- /dev/null +++ b/Classes/Site/SiteLanguageAwareInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + +/** + * Interface for SiteLanguageAware features of TYPO3 + */ +interface SiteLanguageAwareInterface +{ + public function setSiteLanguage(SiteLanguage $siteLanguage); + + public function getSiteLanguage(): SiteLanguage; +} diff --git a/Classes/Site/SiteLanguageAwareTrait.php b/Classes/Site/SiteLanguageAwareTrait.php new file mode 100644 index 0000000..5afb682 --- /dev/null +++ b/Classes/Site/SiteLanguageAwareTrait.php @@ -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\Core\Site; + +use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + +/** + * Helper trait to use a site language within a class. + * + * @internal this is not public API yet as this might change, and could be changed within TYPO3 Core at any time. + */ +trait SiteLanguageAwareTrait +{ + /** + * @var Entity\SiteLanguage + */ + protected $siteLanguage; + + public function setSiteLanguage(SiteLanguage $siteLanguage) + { + $this->siteLanguage = $siteLanguage; + } + + public function getSiteLanguage(): SiteLanguage + { + return $this->siteLanguage; + } +} diff --git a/Classes/Site/SiteLanguagePresets.php b/Classes/Site/SiteLanguagePresets.php new file mode 100644 index 0000000..3f1b252 --- /dev/null +++ b/Classes/Site/SiteLanguagePresets.php @@ -0,0 +1,436 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +/** + * Provides site language presets + * @internal + */ +class SiteLanguagePresets +{ + protected array $presets = [ + 'af-ZA' => [ + 'title' => 'Afrikaans', + 'navigationTitle' => 'Afrikaans', + 'locale' => 'af_ZA', + 'base' => '/af/', + 'flag' => 'af', + ], + 'ar-SA' => [ + 'title' => 'Arabic', + 'navigationTitle' => 'العربية', + 'locale' => 'ar_SA', + 'base' => '/ar/', + 'flag' => 'sa', + ], + 'bs-BA' => [ + 'title' => 'Bosnian', + 'navigationTitle' => 'Bosanski', + 'locale' => 'bs_BA', + 'base' => '/ba/', + 'flag' => 'ba', + ], + 'bg-BG' => [ + 'title' => 'Bulgarian', + 'navigationTitle' => 'Български', + 'locale' => 'bg_BG', + 'base' => '/bg/', + 'flag' => 'bg', + ], + 'ca-ES' => [ + 'title' => 'Catalan', + 'navigationTitle' => 'Català', + 'locale' => 'ca_ES', + 'base' => '/ca/', + 'flag' => 'catalonia', + ], + 'zh-CN' => [ + 'title' => 'Chinese (Simplified)', + 'navigationTitle' => '汉语', + 'locale' => 'zh_CN', + 'base' => '/cn/', + 'flag' => 'cn', + ], + 'cs-CZ' => [ + 'title' => 'Czech', + 'navigationTitle' => 'Čeština', + 'locale' => 'cs_CZ', + 'base' => '/cz/', + 'flag' => 'cz', + ], + 'cy-GB' => [ + 'title' => 'Welsh', + 'navigationTitle' => 'Cymraeg', + 'locale' => 'cy_GB', + 'base' => '/cy/', + 'flag' => 'cy', + ], + 'da-DK' => [ + 'title' => 'Danish', + 'navigationTitle' => 'Dansk', + 'locale' => 'da_DK', + 'base' => '/da/', + 'flag' => 'dk', + ], + 'de-DE' => [ + 'title' => 'German', + 'navigationTitle' => 'Deutsch', + 'locale' => 'de_DE', + 'base' => '/de/', + 'flag' => 'de', + ], + 'el-GR' => [ + 'title' => 'Greek', + 'navigationTitle' => 'Ελληνικά', + 'locale' => 'el_GR', + 'base' => '/gr/', + 'flag' => 'gr', + ], + 'en-US' => [ + 'title' => 'English', + 'navigationTitle' => 'English', + 'locale' => 'en_US', + 'base' => '/en/', + 'flag' => 'en-us-gb', + ], + 'eo-XX' => [ + 'title' => 'Esperanto', + 'navigationTitle' => 'Esperanto', + 'locale' => 'eo_XX', + 'base' => '/eo/', + 'flag' => 'eo', + ], + 'es-ES' => [ + 'title' => 'Spanish', + 'navigationTitle' => 'Español', + 'locale' => 'es_ES', + 'base' => '/es/', + 'flag' => 'es', + ], + 'et-EE' => [ + 'title' => 'Estonian', + 'navigationTitle' => 'Eesti', + 'locale' => 'et_EE', + 'base' => '/et/', + 'flag' => 'ee', + ], + 'eu-ES' => [ + 'title' => 'Basque', + 'navigationTitle' => 'Euskara', + 'locale' => 'eu_ES', + 'base' => '/eu/', + 'flag' => 'eu', + ], + 'fa-IR' => [ + 'title' => 'Persian', + 'navigationTitle' => 'فارسی', + 'locale' => 'fa_IR', + 'base' => '/fa/', + 'flag' => 'ir', + ], + 'fi-FI' => [ + 'title' => 'Finnish', + 'navigationTitle' => 'Suomi', + 'locale' => 'fi_FI', + 'base' => '/fi/', + 'flag' => 'fi', + ], + 'fo-FO' => [ + 'title' => 'Faeroese', + 'navigationTitle' => 'Føroyskt', + 'locale' => 'fo_FO', + 'base' => '/fo/', + 'flag' => 'fo', + ], + 'fr-FR' => [ + 'title' => 'French', + 'navigationTitle' => 'Français', + 'locale' => 'fr_FR', + 'base' => '/fr/', + 'flag' => 'fr', + ], + 'fr-CA' => [ + 'title' => 'Canadian French', + 'navigationTitle' => 'Français canadien', + 'locale' => 'fr_CA', + 'base' => '/qc/', + 'flag' => 'qc', + ], + 'gl-ES' => [ + 'title' => 'Galician', + 'navigationTitle' => 'Galego', + 'locale' => 'gl_ES', + 'base' => '/ga/', + 'flag' => 'gl', + ], + 'kl-DK' => [ + 'title' => 'Greenlandic', + 'navigationTitle' => 'Kalaallisut', + 'locale' => 'kl_DK', + 'base' => '/gl/', + 'flag' => 'kl', + ], + 'he-IL' => [ + 'title' => 'Hebrew', + 'navigationTitle' => 'עברית', + 'locale' => 'he_IL', + 'base' => '/he/', + 'flag' => 'il', + ], + 'hi-IN' => [ + 'title' => 'Hindi', + 'navigationTitle' => 'हिन्दी', + 'locale' => 'hi_IN', + 'base' => '/hi/', + 'flag' => 'in', + ], + 'hr-HR' => [ + 'title' => 'Croatian', + 'navigationTitle' => 'Hrvatski', + 'locale' => 'hr_HR', + 'base' => '/hr/', + 'flag' => 'hr', + ], + 'hu-HU' => [ + 'title' => 'Hungarian', + 'navigationTitle' => 'Magyar', + 'locale' => 'hu_HU', + 'base' => '/hu/', + 'flag' => 'hu', + ], + 'is-IS' => [ + 'title' => 'Icelandic', + 'navigationTitle' => 'Íslenska', + 'locale' => 'is_IS', + 'base' => '/is/', + 'flag' => 'is', + ], + 'it-IT' => [ + 'title' => 'Italian', + 'navigationTitle' => 'Italiano', + 'locale' => 'it_IT', + 'base' => '/it/', + 'flag' => 'it', + ], + 'ja-JP' => [ + 'title' => 'Japanese', + 'navigationTitle' => '日本語', + 'locale' => 'ja_JP', + 'base' => '/jp/', + 'flag' => 'jp', + ], + 'ka-GE' => [ + 'title' => 'Georgian', + 'navigationTitle' => 'ქართული', + 'locale' => 'ka_GE', + 'base' => '/ge/', + 'flag' => 'ge', + ], + 'km-KH' => [ + 'title' => 'Khmer', + 'navigationTitle' => 'ភាសាខ្មែរ', + 'locale' => 'km_KH', + 'base' => '/km/', + 'flag' => 'km', + ], + 'ko-KR' => [ + 'title' => 'Korean', + 'navigationTitle' => '한국말', + 'locale' => 'ko_KR', + 'base' => '/kr/', + 'flag' => 'kr', + ], + 'lt-LT' => [ + 'title' => 'Lithuanian', + 'navigationTitle' => 'Lietuvių', + 'locale' => 'lt_LT', + 'base' => '/lt/', + 'flag' => 'lt', + ], + 'lv-LV' => [ + 'title' => 'Latvian', + 'navigationTitle' => 'Latviešu', + 'locale' => 'lv_LV', + 'base' => '/lv/', + 'flag' => 'lv', + ], + 'mi-NZ' => [ + 'title' => 'Maori', + 'navigationTitle' => 'Māori', + 'locale' => 'mi_NZ', + 'base' => '/mi/', + 'flag' => 'mi', + ], + 'ms-MY' => [ + 'title' => 'Malay', + 'navigationTitle' => 'Bahasa Melayu', + 'locale' => 'ms_MY', + 'base' => '/ms/', + 'flag' => 'my', + ], + 'nl-NL' => [ + 'title' => 'Dutch', + 'navigationTitle' => 'Nederlands', + 'locale' => 'nl_NL', + 'base' => '/nl/', + 'flag' => 'nl', + ], + 'no-NO' => [ + 'title' => 'Norwegian', + 'navigationTitle' => 'Norsk', + 'locale' => 'no_NO', + 'base' => '/no/', + 'flag' => 'no', + ], + 'pl-PL' => [ + 'title' => 'Polish', + 'navigationTitle' => 'Polski', + 'locale' => 'pl_PL', + 'base' => '/pl/', + 'flag' => 'pl', + ], + 'pt-PT' => [ + 'title' => 'Portuguese', + 'navigationTitle' => 'Português', + 'locale' => 'pt_PT', + 'base' => '/pt/', + 'flag' => 'pt', + ], + 'pt-BR' => [ + 'title' => 'Brazilian Portuguese', + 'navigationTitle' => 'Português brasileiro', + 'locale' => 'pt_BR', + 'base' => '/br/', + 'flag' => 'br', + ], + 'ro-RO' => [ + 'title' => 'Romanian', + 'navigationTitle' => 'Română', + 'locale' => 'ro_RO', + 'base' => '/ro/', + 'flag' => 'ro', + ], + 'ru-RU' => [ + 'title' => 'Russian', + 'navigationTitle' => 'Русский', + 'locale' => 'ru_RU', + 'base' => '/ru/', + 'flag' => 'ru', + ], + 'sl-SI' => [ + 'title' => 'Slovenian', + 'navigationTitle' => 'Slovenščina', + 'locale' => 'sl_SI', + 'base' => '/si/', + 'flag' => 'si', + ], + 'sk-SK' => [ + 'title' => 'Slovak', + 'navigationTitle' => 'Slovenčina', + 'locale' => 'sk_SK', + 'base' => '/sk/', + 'flag' => 'sk', + ], + 'sn_ZW' => [ + 'title' => 'Shona (Bantu)', + 'navigationTitle' => 'chiShona', + 'locale' => 'sn_ZW', + 'base' => '/sn/', + 'flag' => 'zw', + ], + 'sv-SE' => [ + 'title' => 'Swedish', + 'navigationTitle' => 'Svenska', + 'locale' => 'sv_SE', + 'base' => '/se/', + 'flag' => 'se', + ], + 'sq-AL' => [ + 'title' => 'Albanian', + 'navigationTitle' => 'Gjuha shqipe', + 'locale' => 'sq_AL', + 'base' => '/sq/', + 'flag' => 'al', + ], + 'sr-YO' => [ + 'title' => 'Serbian', + 'navigationTitle' => 'Српски / Srpski', + 'locale' => 'sr_YO', + 'base' => '/sr/', + 'flag' => 'rs', + ], + 'th-TH' => [ + 'title' => 'Thai', + 'navigationTitle' => 'ภาษาไทย', + 'locale' => 'th_TH', + 'base' => '/th/', + 'flag' => 'th', + ], + 'tr-TR' => [ + 'title' => 'Turkish', + 'navigationTitle' => 'Türkçe', + 'locale' => 'tr_TR', + 'base' => '/tr/', + 'flag' => 'tr', + ], + 'uk-UA' => [ + 'title' => 'Ukrainian', + 'navigationTitle' => 'Українська', + 'locale' => 'uk_UA', + 'base' => '/ua/', + 'flag' => 'ua', + ], + 'vi-VN' => [ + 'title' => 'Vietnamese', + 'navigationTitle' => 'Tiếng Việt', + 'locale' => 'vi_VN', + 'base' => '/vn/', + 'flag' => 'vn', + ], + 'zh-HK' => [ + 'title' => 'Chinese (Traditional)', + 'navigationTitle' => '漢語', + 'locale' => 'zh_HK', + 'base' => '/hk/', + 'flag' => 'hk', + ], + ]; + + public function getAll(): array + { + return $this->presets; + } + + public function getPresetDetailsForLanguage(string $language): ?array + { + return $this->presets[$language] ?? null; + } + + public function getAllForSelector(): array + { + $presetOptions = []; + foreach ($this->presets as $language => $preset) { + $presetOptions[$preset['title']] = [ + 'value' => $language, + 'label' => $preset['title'], + ]; + } + ksort($presetOptions); + return $presetOptions; + } +} diff --git a/Classes/Site/SiteSettingsFactory.php b/Classes/Site/SiteSettingsFactory.php new file mode 100644 index 0000000..71ff180 --- /dev/null +++ b/Classes/Site/SiteSettingsFactory.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader; +use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier; +use TYPO3\CMS\Core\Settings\Settings; +use TYPO3\CMS\Core\Settings\SettingsFactory; +use TYPO3\CMS\Core\Settings\SettingsTypeRegistry; +use TYPO3\CMS\Core\Site\Entity\SiteSettings; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +#[Autoconfigure(public: true)] +readonly class SiteSettingsFactory +{ + public function __construct( + #[Autowire('%env(TYPO3:configPath)%/sites')] + protected string $configPath, + protected SetRegistry $setRegistry, + protected SettingsTypeRegistry $settingsTypeRegistry, + protected SettingsFactory $settingsFactory, + protected YamlFileLoader $yamlFileLoader, + #[Autowire(service: 'cache.core')] + protected PhpFrontend $cache, + #[Autowire(expression: 'service("package-dependent-cache-identifier").withPrefix("SiteSettings")')] + protected PackageDependentCacheIdentifier $cacheIdentifier, + protected string $settingsFileName = 'settings.yaml', + ) {} + + public function getSettings(string $siteIdentifier, array $siteConfiguration): SiteSettings + { + $cacheIdentifier = $this->cacheIdentifier->withAdditionalHashedIdentifier( + $siteIdentifier . '_' . json_encode($siteConfiguration) + )->toString(); + + try { + $settings = $this->cache->require($cacheIdentifier); + if ($settings instanceof SiteSettings) { + return $settings; + } + } catch (\Error) { + } + + $settings = $this->createSettings( + $siteConfiguration['dependencies'] ?? [], + $siteIdentifier, + $siteConfiguration['settings'] ?? [], + ); + $this->cache->set($cacheIdentifier, 'return ' . var_export($settings, true) . ';'); + return $settings; + } + + /** + * Load settings from config/sites/{$siteIdentifier}/settings.yaml. + */ + public function loadLocalSettings(string $siteIdentifier): ?array + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; + if (!file_exists($fileName)) { + return null; + } + + return $this->yamlFileLoader->load( + GeneralUtility::fixWindowsFilePath($fileName), + YamlFileLoader::PROCESS_PLACEHOLDERS | YamlFileLoader::PROCESS_IMPORTS | YamlFileLoader::ALLOW_EMPTY_FILE + ); + } + + /** + * Fetch the settings for a specific site and return the parsed Site Settings object. + * + * @todo This method resolves placeholders during the loading, which is okay as this is only used in context where + * the replacement is needed. However, this may change in the future, for example if loading is needed for + * implementing a GUI for the settings - which should either get a dedicated method or a flag to control if + * placeholder should be resolved during yaml file loading or not. The SiteConfiguration save action currently + * avoid calling this method. + */ + public function createSettings(array $sets = [], ?string $siteIdentifier = null, array $inlineSettings = []): SiteSettings + { + $rawSettings = []; + if ($siteIdentifier !== null) { + $rawSettings = $this->loadLocalSettings($siteIdentifier) ?? $inlineSettings; + } + + return $this->composeSettings($rawSettings, $sets); + } + + public function composeSettings(array $rawSettings, array $sets): SiteSettings + { + return SiteSettings::create( + $this->settingsFactory->resolveSettings( + ...$this->getSettingsProviders($rawSettings, $sets) + ) + ); + } + + /** + * @return SiteSettingsProvider[] + */ + protected function getSettingsProviders(array $settings, array $sets): array + { + $activeSets = []; + if ($sets !== []) { + $activeSets = $this->setRegistry->getSets(...$sets); + } + + /** @var SiteSettingsProvider[] $providers */ + $providers = []; + foreach ($activeSets as $set) { + $providers[] = new SiteSettingsProvider($set->settings, $set->settingsDefinitions); + } + + $providers[] = new SiteSettingsProvider($settings); + + return $providers; + } +} diff --git a/Classes/Site/SiteSettingsProvider.php b/Classes/Site/SiteSettingsProvider.php new file mode 100644 index 0000000..b9e1da6 --- /dev/null +++ b/Classes/Site/SiteSettingsProvider.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsProviderInterface; +use TYPO3\CMS\Core\Settings\SettingValue; +use TYPO3\CMS\Core\Utility\ArrayUtility; + +/** + * @internal + */ +final readonly class SiteSettingsProvider implements SettingsProviderInterface +{ + public function __construct( + private array $settings, + private array $definitions = [], + ) {} + + /** + * @return SettingDefinition[] + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * @return SettingValue[] + */ + public function getProvidedSettings(array $currentDefinitions): array + { + // Obtain default settings + /** @var SettingValue[] $defaultSettings */ + $defaultSettings = []; + foreach ($this->definitions as $definition) { + $defaultSettings[] = new SettingValue( + value: $definition->default, + key: $definition->key, + definition: $definition, + ); + } + + // Obtain defined setting values from map presentation + /** @var SettingValue[] $settings */ + $settings = []; + $treeSettings = $this->settings; + foreach ($this->settings as $key => $value) { + $definition = $currentDefinitions[$key] ?? null; + if ($definition !== null) { + $settings[] = new SettingValue( + value: $value, + key: $key, + definition: $definition, + ); + // A setting that is defined, is not to be interpreted as an anonymous legacy tree setting + // (otherwise the key would be duplicated, but with dots being escaped) + unset($treeSettings[$key]); + } + } + + // Obtain defined setting values from tree presentation + /** @var SettingValue[] $legacySettings */ + $legacySettings = []; + foreach ($currentDefinitions as $definition) { + if (!ArrayUtility::isValidPath($treeSettings, $definition->key, '.')) { + continue; + } + $value = ArrayUtility::getValueByPath($treeSettings, $definition->key, '.'); + $treeSettings = ArrayUtility::removeByPath($treeSettings, $definition->key, '.'); + $legacySettings[] = new SettingValue( + value: $value, + key: $definition->key, + definition: $definition, + ); + } + + // Derive anonymous setting values from tree by mapping tree nodes to dots + $flatSettings = ArrayUtility::flattenPlain($treeSettings); + foreach ($flatSettings as $key => $value) { + $legacySettings[] = new SettingValue( + value: $value, + key: $key, + definition: null, + ); + } + + return [ + ...$defaultSettings, + ...$legacySettings, + ...$settings, + ]; + } +} diff --git a/Classes/Site/SiteSettingsService.php b/Classes/Site/SiteSettingsService.php new file mode 100644 index 0000000..b8085fb --- /dev/null +++ b/Classes/Site/SiteSettingsService.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; +use TYPO3\CMS\Core\Configuration\SiteWriter; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\Settings; +use TYPO3\CMS\Core\Settings\SettingsDiff; +use TYPO3\CMS\Core\Settings\SettingsFactory; +use TYPO3\CMS\Core\Settings\SettingsInterface; +use TYPO3\CMS\Core\Settings\SettingsTypeRegistry; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteSettings; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; + +/** + * @internal + */ +readonly class SiteSettingsService +{ + public function __construct( + protected SiteWriter $siteWriter, + #[Autowire(service: 'cache.core')] + protected PhpFrontend $codeCache, + protected SetRegistry $setRegistry, + protected SiteSettingsFactory $siteSettingsFactory, + protected SettingsFactory $settingsFactory, + protected SettingsTypeRegistry $settingsTypeRegistry, + protected FlashMessageService $flashMessageService, + ) {} + + public function hasSettingsDefinitions(Site $site): bool + { + return count($this->getDefinitions($site)) > 0; + } + + public function getUncachedSettings(Site $site): SiteSettings + { + // create a fresh Settings instance instead of using + // $site->getSettings() which may have been loaded from cache + return $this->siteSettingsFactory->createSettings( + $site->getSets(), + $site->getIdentifier(), + $site->getRawConfiguration()['settings'] ?? [], + ); + } + + public function getSetSettings(Site $site): SettingsInterface + { + return $this->siteSettingsFactory->createSettings($site->getSets()); + } + + public function getLocalSettings(Site $site): SiteSettings + { + $settings = $this->siteSettingsFactory->createSettings( + $site->getSets(), + $site->getIdentifier(), + $site->getRawConfiguration()['settings'] ?? [], + ); + $setSettings = $this->getSetSettings($site); + $localSettings = []; + foreach ($settings->getIdentifiers() as $key) { + $value = $settings->get($key); + if ($setSettings->has($key) && $value === $setSettings->get($key)) { + continue; + } + $localSettings[$key] = $value; + } + return SiteSettings::create(new Settings($localSettings)); + } + + public function computeSettingsDiff(Site $site, SettingsInterface $newSettings, bool $minify = true): SettingsDiff + { + // Settings from sets only – setting values without site-local config/sites/*/settings.yaml applied + $defaultSettings = $minify ? $this->siteSettingsFactory->createSettings($site->getSets(), null) : null; + + // Settings from config/sites/*/settings.yaml only (our persistence target) + $localSettings = $this->siteSettingsFactory->loadLocalSettings($site->getIdentifier()) + ?? $site->getRawConfiguration()['settings'] ?? []; + + return SettingsDiff::create( + $localSettings, + $newSettings, + $defaultSettings, + ); + } + + public function writeSettings(Site $site, array $settings): void + { + try { + $this->siteWriter->writeSettings($site->getIdentifier(), $settings); + } catch (SiteConfigurationWriteException $e) { + $flashMessage = new FlashMessage($e->getMessage(), '', ContextualFeedbackSeverity::ERROR, true); + $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + // SiteWriter currently does not invalidate the code cache, see #103804 + $this->codeCache->flush(); + } + + /** + * @return array<string, SettingDefinition> + */ + public function getDefinitions(Site $site): array + { + $sets = $this->setRegistry->getSets(...$site->getSets()); + $definitions = []; + foreach ($sets as $set) { + foreach ($set->settingsDefinitions as $settingDefinition) { + $definitions[$settingDefinition->key] = $settingDefinition; + } + } + return $definitions; + } + + public function createSettingsFromFormData(Site $site, array $settingsMap): SettingsInterface + { + return $this->settingsFactory->createSettingsFromFormData($settingsMap, $this->getDefinitions($site)); + } +} diff --git a/Classes/Site/TcaSiteSetCollector.php b/Classes/Site/TcaSiteSetCollector.php new file mode 100644 index 0000000..c700f1e --- /dev/null +++ b/Classes/Site/TcaSiteSetCollector.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Site\Set\SetError; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +#[Autoconfigure(public: true)] +final readonly class TcaSiteSetCollector +{ + public function __construct( + private SetRegistry $setRegistry, + private FlashMessageService $flashMessageService, + ) {} + + public function populateSiteSets(array &$fieldConfiguration): void + { + $currentValue = $fieldConfiguration['row'][$fieldConfiguration['field']] ?? ''; + $selectedSets = $currentValue === '' ? [] : array_fill_keys(GeneralUtility::trimExplode(',', $currentValue), true); + + $hiddenSets = GeneralUtility::trimExplode(',', $this->getBackendUser()->getTSConfig()['options.']['sites.']['hideSets'] ?? '', true); + foreach ($this->setRegistry->getAllSets() as $set) { + $hidden = $set->hidden || in_array($set->name, $hiddenSets, true); + if ($hidden && !isset($selectedSets[$set->name])) { + continue; + } + $fieldConfiguration['items'][] = [ + 'label' => $this->getLanguageService()->sL($set->label) . ( + $hidden ? ' (' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.hidden') . ')' : '' + ), + 'value' => $set->name, + ]; + unset($selectedSets[$set->name]); + } + + $flashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $languageService = $this->getLanguageService(); + foreach ($selectedSets as $invalidSet => $_) { + $reason = $this->setRegistry->getInvalidSets()[$invalidSet] ?? [ + 'error' => SetError::notFound, + 'name' => $invalidSet, + 'context' => 'site:' . ($fieldConfiguration['row']['identifier'] ?? ''), + ]; + $error = sprintf( + $languageService->sL($reason['error']->getLabel()), + $reason['name'], + $reason['context'], + ); + + $fieldConfiguration['items'][] = [ + 'label' => sprintf( + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'), + $error + ), + 'value' => $invalidSet, + ]; + + $flashMessage = new FlashMessage( + $error, + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.site.invalidSetDependencies'), + ContextualFeedbackSeverity::ERROR, + false + ); + $flashMessageQueue->enqueue($flashMessage); + } + } + + private function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + private function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Slug/SlugNormalizer.php b/Classes/Slug/SlugNormalizer.php new file mode 100644 index 0000000..f0228e3 --- /dev/null +++ b/Classes/Slug/SlugNormalizer.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Slug; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Charset\CharsetConverter; + +/** + * Provides the ability to normalize a value to be used as url-part segment. + * + * @internal This class is still work in progress and subject of frequent change. Only use with caution. + */ +#[Autoconfigure(public: true)] +final readonly class SlugNormalizer +{ + public function __construct( + private CharsetConverter $charsetConverter, + ) {} + + /** + * Normalizes a value to be used directly as path segment of a URL. + */ + public function normalize(string $value, ?string $fallbackCharacter = '-'): string + { + $fallbackCharacter ??= '-'; + // Convert to lowercase + remove tags + $value = mb_strtolower($value, 'utf-8'); + $value = strip_tags($value); + + // Convert some special tokens (space, "_" and "-") to the space character + $value = (string)preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $value); + + if (!\Normalizer::isNormalized($value)) { + $value = \Normalizer::normalize($value) ?: $value; + } + + // Convert extended letters to ascii equivalents, for example "€" to "EUR" + $value = $this->charsetConverter->utf8_char_mapping($value); + + // Get rid of all invalid characters, but allow slashes + $value = (string)preg_replace('/[^\p{L}\p{M}0-9\/' . preg_quote($fallbackCharacter, '/') . ']/u', '', $value); + + // Convert multiple fallback characters to a single one + if ($fallbackCharacter !== '') { + $value = (string)preg_replace('/' . preg_quote($fallbackCharacter, '/') . '{2,}/', $fallbackCharacter, $value); + } + + // Ensure slug is lower cased after all replacement was done + $value = mb_strtolower($value, 'utf-8'); + // Extract slug, thus it does not have wrapping fallback and slash characters + $extractedSlug = trim($value, $fallbackCharacter . '/'); + + // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it + $appendTrailingSlash = $extractedSlug !== '' && substr($value, -1) === '/'; + $value = $extractedSlug . ($appendTrailingSlash ? '/' : ''); + + return $value; + } +} diff --git a/Classes/SysLog/Action.php b/Classes/SysLog/Action.php new file mode 100644 index 0000000..4109ddc --- /dev/null +++ b/Classes/SysLog/Action.php @@ -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\Core\SysLog; + +/** + * A class defining non type specific actions + */ +class Action +{ + public const UNDEFINED = 0; +} diff --git a/Classes/SysLog/Action/Cache.php b/Classes/SysLog/Action/Cache.php new file mode 100644 index 0000000..7a9432a --- /dev/null +++ b/Classes/SysLog/Action/Cache.php @@ -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\Core\SysLog\Action; + +/** + * A class defining possible Cache actions + */ +class Cache +{ + public const CLEAR = 1; +} diff --git a/Classes/SysLog/Action/Database.php b/Classes/SysLog/Action/Database.php new file mode 100644 index 0000000..153f620 --- /dev/null +++ b/Classes/SysLog/Action/Database.php @@ -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\Core\SysLog\Action; + +/** + * A class defining possible Database actions + */ +class Database +{ + public const INSERT = 1; + public const UPDATE = 2; + public const DELETE = 3; + public const MOVE = 4; + public const CHECK = 5; + public const LOCALIZE = 6; + public const VERSIONIZE = 7; + public const PUBLISH = 8; + public const DISCARD = 9; +} diff --git a/Classes/SysLog/Action/File.php b/Classes/SysLog/Action/File.php new file mode 100644 index 0000000..737dd39 --- /dev/null +++ b/Classes/SysLog/Action/File.php @@ -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\Core\SysLog\Action; + +/** + * A class defining possible File actions + */ +class File +{ + public const UPLOAD = 1; + public const COPY = 2; + public const MOVE = 3; + public const DELETE = 4; + public const RENAME = 5; + public const NEW_FOLDER = 6; + + /* + * The constant is not in use but the xlf file tells that 7 represents unzip + * @see https://github.com/typo3/typo3/blob/master/typo3/sysext/belog/Resources/Private/Language/locallang.xlf#L267 + */ + public const UNZIP = 7; + public const NEW_FILE = 8; + public const EDIT = 9; +} diff --git a/Classes/SysLog/Action/Login.php b/Classes/SysLog/Action/Login.php new file mode 100644 index 0000000..50e34f1 --- /dev/null +++ b/Classes/SysLog/Action/Login.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SysLog\Action; + +/** + * A class defining possible Login actions + */ +class Login +{ + public const LOGIN = 1; + public const LOGOUT = 2; + public const ATTEMPT = 3; + public const SEND_FAILURE_WARNING_EMAIL = 4; + public const PASSWORD_RESET_REQUEST = 5; + public const PASSWORD_RESET_ACCOMPLISHED = 6; +} diff --git a/Classes/SysLog/Action/Setting.php b/Classes/SysLog/Action/Setting.php new file mode 100644 index 0000000..6f88232 --- /dev/null +++ b/Classes/SysLog/Action/Setting.php @@ -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\Core\SysLog\Action; + +/** + * A class defining possible Setting actions + */ +class Setting +{ + public const CHANGE = 1; +} diff --git a/Classes/SysLog/Action/Site.php b/Classes/SysLog/Action/Site.php new file mode 100644 index 0000000..ee7404e --- /dev/null +++ b/Classes/SysLog/Action/Site.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SysLog\Action; + +/** + * A class defining possible Site actions + */ +class Site +{ + public const CREATE = 1; + public const UPDATE = 2; + public const RENAME = 3; + public const DELETE = 4; +} diff --git a/Classes/SysLog/Error.php b/Classes/SysLog/Error.php new file mode 100644 index 0000000..c3fe010 --- /dev/null +++ b/Classes/SysLog/Error.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SysLog; + +/** + * A class defining possible error types + */ +class Error +{ + public const MESSAGE = 0; + public const USER_ERROR = 1; + public const SYSTEM_ERROR = 2; + public const SECURITY_NOTICE = 3; + public const WARNING = 4; +} diff --git a/Classes/SysLog/Repository/LogEntryRepository.php b/Classes/SysLog/Repository/LogEntryRepository.php new file mode 100644 index 0000000..85332e1 --- /dev/null +++ b/Classes/SysLog/Repository/LogEntryRepository.php @@ -0,0 +1,184 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SysLog\Repository; + +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Security\PermissionSet\PrincipalContext; +use TYPO3\CMS\Core\Security\PermissionSet\PrincipalRole; +use TYPO3\CMS\Core\Security\PermissionSet\ProcessingContext; +use TYPO3\CMS\Core\SysLog\Type; + +/** + * Repository for writing system log entries to the database. + * + * Provides methods to create log entries in the sys_log table, + * tracking user actions, errors, and system events. + * + * @internal + */ +#[Autoconfigure(public: true)] +final readonly class LogEntryRepository +{ + public function __construct(private ConnectionPool $connectionPool) {} + + /** + * Legacy wrapper for `writeLogEntry`, invoked with the current backend user. + * + * This method extracts the principal and processing context from the BackendUserAuthentication + * object and delegates to writeLogEntry(). It automatically handles user impersonation + * (switch user mode) by including the original user ID in the log data. + * + * @internal might be removed during TYPO3 v14 development + */ + public function writeLogEntryForBackendUser( + BackendUserAuthentication $backendUser, + int $type, + int $action, + int $error, + string $details, + array $data, + string $tableName = '', + int|string $recordUid = '', + int $eventPid = -1, + ): int { + if ($impersonatedBy = $backendUser->getOriginalUserIdWhenInSwitchUserMode()) { + $impersonatedBy = new PrincipalContext( + // @todo role is hardcoded here, but should be resolved + role: PrincipalRole::ADMIN, + id: $impersonatedBy, + ); + } else { + $impersonatedBy = null; + } + + $principalContext = new PrincipalContext( + role: $backendUser->getRole(), + id: $backendUser->getUserId() ?? 0, + impersonatedBy: $impersonatedBy, + ); + $processingContext = new ProcessingContext( + workspaceId: $backendUser->workspace, + ); + + return $this->writeLogEntry( + $principalContext, + $processingContext, + $type, + $action, + $error, + $details, + $data, + $tableName, + $recordUid, + $eventPid + ); + } + + /** + * Writes an entry to the system log (sys_log table). + * + * This method creates a log entry tracking user actions, errors, and system events. + * The log entry includes context about the principal (user), workspace, and optionally + * the database record affected by the action. + * + * @param PrincipalContext $principalContext The user/principal context that triggered the log entry, including user ID and role + * @param ProcessingContext $processingContext The processing context, including workspace ID where the action occurred + * @param int $type Type of action that created the log entry. Common types include: + * 1 (DB) for database operations, 2 (FILE) for file operations, + * 3 (CACHE) for cache operations, 4 (EXTENSION) for extension actions, + * 5 (ERROR) for errors, 255 (LOGIN) for login/logout events. + * See \TYPO3\CMS\Core\SysLog\Type constants. + * @param int $action Specific action ID within the type category. The meaning depends on $type. + * Use 0 when no sub-categorization applies. + * @param int $error Severity level: 0 = informational message, 1 = warning (user problem), + * 2 = system error (should not happen), 3 = security notice (for admins) + * @param string $details The log message text. May contain sprintf-style placeholders (%s, %d, etc.) + * that will be substituted with values from $data array. + * @param array $data Additional data for the log entry. If provided, the first 5 elements (keys 0-4) + * will be used to substitute placeholders in $details via sprintf. + * Special key 'originalUser' is set automatically when user impersonation is active. + * @param string $tableName Database table name of the record affected by this action (used by DataHandler). + * @param int|string $recordUid UID of the record affected by this action (used by DataHandler). + * @param int $eventPid Page UID where the event occurred. Used to filter log entries by page. + * Use -1 for global events not tied to a specific page. + * @param ServerRequestInterface|null $request The request object that triggered the log entry. + * @return int The UID of the created log entry. + */ + public function writeLogEntry( + PrincipalContext $principalContext, + ProcessingContext $processingContext, + int $type, + int $action, + int $error, + string $details, + array $data, + string $tableName = '', + int|string $recordUid = '', + int $eventPid = -1, + ?ServerRequestInterface $request = null + ): int { + $userId = $principalContext->id; + $workspaceId = $processingContext->workspaceId; + if ($principalContext->impersonatedBy !== null) { + $data['originalUser'] = $principalContext->impersonatedBy->id; + } + + $request ??= $GLOBALS['TYPO3_REQUEST'] ?? null; + $connection = $this->connectionPool->getConnectionForTable('sys_log'); + $connection->insert( + 'sys_log', + [ + 'userid' => $userId, + 'type' => $type, + 'channel' => Type::toChannel($type), + 'level' => Type::toLevel($type), + 'action' => $action, + 'error' => $error, + 'details' => $details, + 'log_data' => $data !== [] ? json_encode($data) : '', + 'tablename' => $tableName, + 'recuid' => is_int($recordUid) ? $recordUid : 0, + 'IP' => $request?->getAttribute('normalizedParams')?->getRemoteAddress() ?? '', + 'tstamp' => $GLOBALS['EXEC_TIME'] ?? time(), + 'event_pid' => $eventPid, + 'workspace' => $workspaceId, + ], + [ + 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, + ] + ); + return (int)$connection->lastInsertId(); + } +} diff --git a/Classes/SysLog/Type.php b/Classes/SysLog/Type.php new file mode 100644 index 0000000..85ce5d8 --- /dev/null +++ b/Classes/SysLog/Type.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SysLog; + +use Psr\Log\LogLevel; + +/** + * A class defining possible logging types. + * + * @internal The logging type system is moving towards PSR-3-defined log levels and channels, this class might get removed without any further notice from TYPO3 v12.0. on. + */ +class Type +{ + public const DB = 1; + public const FILE = 2; + public const CACHE = 3; + public const EXTENSION = 4; + public const ERROR = 5; + public const SITE = 6; + public const SETTING = 254; + public const LOGIN = 255; + + private static array $channelMap = [ + self::DB => 'content', + self::FILE => 'file', + self::CACHE => 'default', + self::EXTENSION => 'default', + self::ERROR => 'php', + self::SITE => 'site', + self::SETTING => 'default', + self::LOGIN => 'user', + ]; + + private static array $levelMap = [ + self::DB => LogLevel::INFO, + self::FILE => LogLevel::INFO, + self::CACHE => LogLevel::INFO, + self::EXTENSION => LogLevel::INFO, + self::ERROR => LogLevel::ERROR, + self::SITE => LogLevel::INFO, + self::SETTING => LogLevel::INFO, + self::LOGIN => LogLevel::INFO, + ]; + + /** + * @internal + */ + public static function levelMap(): array + { + return self::$levelMap; + } + + /** + * @internal + */ + public static function channelMap(): array + { + return self::$channelMap; + } + + public static function toChannel(int $type): string + { + return self::$channelMap[$type] ?? 'default'; + } + + public static function toLevel(int $type): string + { + return self::$levelMap[$type] ?? LogLevel::INFO; + } +} diff --git a/Classes/SystemResource/Exception/CanNotGenerateUriException.php b/Classes/SystemResource/Exception/CanNotGenerateUriException.php new file mode 100644 index 0000000..7133c53 --- /dev/null +++ b/Classes/SystemResource/Exception/CanNotGenerateUriException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when a URI to a public resource can not be created + */ +final class CanNotGenerateUriException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/CanNotResolvePublicResourceException.php b/Classes/SystemResource/Exception/CanNotResolvePublicResourceException.php new file mode 100644 index 0000000..2dfa483 --- /dev/null +++ b/Classes/SystemResource/Exception/CanNotResolvePublicResourceException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when a resolved resource is NOT public, but MUST be public (e.g. for URI generation) + */ +final class CanNotResolvePublicResourceException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/CanNotResolveSystemResourceException.php b/Classes/SystemResource/Exception/CanNotResolveSystemResourceException.php new file mode 100644 index 0000000..5f09b26 --- /dev/null +++ b/Classes/SystemResource/Exception/CanNotResolveSystemResourceException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when all attempts to resolve a string to a unique system resource fail + */ +class CanNotResolveSystemResourceException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/CanNotResolveSystemResourceIdentifierException.php b/Classes/SystemResource/Exception/CanNotResolveSystemResourceIdentifierException.php new file mode 100644 index 0000000..c7d0803 --- /dev/null +++ b/Classes/SystemResource/Exception/CanNotResolveSystemResourceIdentifierException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when no specific identifier type can be detected + */ +final class CanNotResolveSystemResourceIdentifierException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/InvalidSystemResourceIdentifierException.php b/Classes/SystemResource/Exception/InvalidSystemResourceIdentifierException.php new file mode 100644 index 0000000..d23cf77 --- /dev/null +++ b/Classes/SystemResource/Exception/InvalidSystemResourceIdentifierException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when an identifier type is detected, but the identifier contains errors + */ +final class InvalidSystemResourceIdentifierException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/SystemResourceDefinitionNotFoundException.php b/Classes/SystemResource/Exception/SystemResourceDefinitionNotFoundException.php new file mode 100644 index 0000000..20ab086 --- /dev/null +++ b/Classes/SystemResource/Exception/SystemResourceDefinitionNotFoundException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * Thrown when a resource is requested that has not been defined beforehand + */ +final class SystemResourceDefinitionNotFoundException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/SystemResourceDoesNotExistException.php b/Classes/SystemResource/Exception/SystemResourceDoesNotExistException.php new file mode 100644 index 0000000..5289abe --- /dev/null +++ b/Classes/SystemResource/Exception/SystemResourceDoesNotExistException.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +/** + * This exception is thrown when a referenced system resource (aka file) + * does not exist, but information about this file is requested (like contents) + */ +final class SystemResourceDoesNotExistException extends SystemResourceException {} diff --git a/Classes/SystemResource/Exception/SystemResourceException.php b/Classes/SystemResource/Exception/SystemResourceException.php new file mode 100644 index 0000000..3623778 --- /dev/null +++ b/Classes/SystemResource/Exception/SystemResourceException.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Exception; + +use TYPO3\CMS\Core\Resource\Exception; + +/** + * This is the base exception. Catch this one to + * gracefully proceed when errors happen in resource + * creation or resource URI generation + */ +class SystemResourceException extends Exception {} diff --git a/Classes/SystemResource/Http/CacheBustingUri.php b/Classes/SystemResource/Http/CacheBustingUri.php new file mode 100644 index 0000000..1925e33 --- /dev/null +++ b/Classes/SystemResource/Http/CacheBustingUri.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Http; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Resource\File; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +class CacheBustingUri extends Uri +{ + public static function fromFileSystemPath(string $absolutePathToPotentialFile, UriInterface $baseUri, ?ApplicationType $applicationType = null): UriInterface + { + try { + // The absolute path might be appended with a query string and/ or fragment + // therefore we remove it here before we check for file existence + $absolutePath = (new Uri($absolutePathToPotentialFile))->getPath(); + if (!is_file($absolutePath)) { + return $baseUri; + } + } catch (\Throwable) { + // See https://review.typo3.org/75477 for the reason of this try/catch + // likely we can remove this in the future, but it is kept for now to ensure BC + return $baseUri; + } + $configAccessor = $applicationType?->isFrontend() ? 'FE' : 'BE'; + $rewriteFileName = (bool)($GLOBALS['TYPO3_CONF_VARS'][$configAccessor]['versionNumberInFilename'] ?? false); + $fileModificationTime = filemtime($absolutePath); + if ($rewriteFileName) { + $nameParts = explode('.', $baseUri->getPath()); + $fileExtension = array_pop($nameParts); + array_push($nameParts, $fileModificationTime, $fileExtension); + $uri = $baseUri->withPath(implode('.', $nameParts)); + } else { + $query = $baseUri->getQuery(); + $uri = $baseUri->withQuery($query . ($query !== '' ? '&' : '') . $fileModificationTime); + } + + return $uri; + } + + public static function fromFile(File $file, UriInterface $baseUri): UriInterface + { + $query = $baseUri->getQuery(); + return $baseUri->withQuery($query . ($query !== '' ? '&' : '') . $file->getSha1()); + } +} diff --git a/Classes/SystemResource/Identifier/FalResourceIdentifier.php b/Classes/SystemResource/Identifier/FalResourceIdentifier.php new file mode 100644 index 0000000..951486f --- /dev/null +++ b/Classes/SystemResource/Identifier/FalResourceIdentifier.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Identifier; + +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; +use TYPO3\CMS\Core\Utility\MathUtility; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final class FalResourceIdentifier extends SystemResourceIdentifier +{ + public const string TYPE = 'FAL'; + + public function __construct(private readonly string $storageId, private readonly string $falIdentifier, string $givenIdentifier) + { + parent::__construct($givenIdentifier); + if (!MathUtility::canBeInterpretedAsInteger($storageId)) { + throw new InvalidSystemResourceIdentifierException(sprintf('Given identifier "%s" is invalid. Storage id must be integer.', $givenIdentifier), 1760433315); + } + } + + public function getIdentifier(): string + { + return sprintf('%d:%s', $this->storageId, $this->falIdentifier); + } + + public function __toString() + { + return sprintf( + '%s:%d:%s', + self::TYPE, + $this->storageId, + $this->falIdentifier, + ); + } +} diff --git a/Classes/SystemResource/Identifier/PackageResourceIdentifier.php b/Classes/SystemResource/Identifier/PackageResourceIdentifier.php new file mode 100644 index 0000000..b8be370 --- /dev/null +++ b/Classes/SystemResource/Identifier/PackageResourceIdentifier.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Identifier; + +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final class PackageResourceIdentifier extends SystemResourceIdentifier +{ + public const string LEGACY_TYPE = 'EXT'; + public const string TYPE = 'PKG'; + + public function __construct( + private readonly PackageInterface $package, + private readonly string $relativePath, + string $givenIdentifier, + ) { + parent::__construct($givenIdentifier); + } + + public function getPackage(): PackageInterface + { + return $this->package; + } + + public function getRelativePath(): string + { + return $this->relativePath; + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + public function withRelativePath(string $newPath): self + { + return new self( + $this->package, + $newPath, + $this->givenIdentifier, + ); + } + + public function __toString() + { + return sprintf( + '%s:%s:%s', + self::TYPE, + $this->package->getValueFromComposerManifest('name') ?? $this->package->getPackageKey(), + $this->relativePath, + ); + } +} diff --git a/Classes/SystemResource/Identifier/SystemResourceIdentifier.php b/Classes/SystemResource/Identifier/SystemResourceIdentifier.php new file mode 100644 index 0000000..1ae464d --- /dev/null +++ b/Classes/SystemResource/Identifier/SystemResourceIdentifier.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Identifier; + +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +abstract class SystemResourceIdentifier implements \Stringable +{ + /** + * @throws InvalidSystemResourceIdentifierException + */ + public function __construct(public readonly string $givenIdentifier) {} + + public function __toString() + { + return $this->givenIdentifier; + } +} diff --git a/Classes/SystemResource/Identifier/SystemResourceIdentifierFactory.php b/Classes/SystemResource/Identifier/SystemResourceIdentifierFactory.php new file mode 100644 index 0000000..317df45 --- /dev/null +++ b/Classes/SystemResource/Identifier/SystemResourceIdentifierFactory.php @@ -0,0 +1,150 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Identifier; + +use TYPO3\CMS\Core\Package\Exception\UnknownPackageException; +use TYPO3\CMS\Core\Package\Exception\UnknownPackagePathException; +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace, + * with four exceptions due to deprecated legacy code, see Uri\ResourceViewHelper, PageRendererBackendSetupTrait, + * AbstractItemProvider and ExtensionManagementUtility::resolvePackagePath() + */ +final readonly class SystemResourceIdentifierFactory +{ + public function __construct(private PackageManager $packageManager) {} + + /** + * @throws InvalidSystemResourceIdentifierException + * @throws CanNotResolveSystemResourceIdentifierException + */ + public function create(string $resourceIdentifier): SystemResourceIdentifier + { + $givenIdentifier = $resourceIdentifier; + if (PathUtility::hasProtocolAndScheme($resourceIdentifier)) { + $resourceIdentifier = sprintf('%s:%s', UriResourceIdentifier::TYPE, $resourceIdentifier); + } + [$identifierType] = explode(':', $resourceIdentifier, 2); + return match ($identifierType) { + PackageResourceIdentifier::LEGACY_TYPE => $this->createPackageResourceIdentifier($this->convertExtensionPathToPackageResourceIdentifier($resourceIdentifier), $resourceIdentifier), + PackageResourceIdentifier::TYPE => $this->createPackageResourceIdentifier($resourceIdentifier), + FalResourceIdentifier::TYPE => $this->createFalResourceIdentifier($resourceIdentifier), + UriResourceIdentifier::TYPE => $this->createUriResourceIdentifier($givenIdentifier), + default => throw new CanNotResolveSystemResourceIdentifierException(sprintf('Can not resolve system resource identifier "%s".', $resourceIdentifier), 1758700314), + }; + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + public function createFromPackagePath(string $packageKey, string $relativePath, string $givenIdentifier): PackageResourceIdentifier + { + return new PackageResourceIdentifier( + $this->getPackageAndValidatePath($packageKey, $relativePath, $givenIdentifier), + $relativePath, + $givenIdentifier + ); + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + private function createPackageResourceIdentifier(string $resourceIdentifier, ?string $originalIdentifier = null): PackageResourceIdentifier + { + [,$packageKey, $relativePath] = $this->parseIdentifier($resourceIdentifier); + return $this->createFromPackagePath($packageKey, $relativePath, $originalIdentifier ?? $resourceIdentifier); + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + private function createFalResourceIdentifier(string $resourceIdentifier): FalResourceIdentifier + { + [,$storageId, $falIdentifier] = $this->parseIdentifier($resourceIdentifier); + return new FalResourceIdentifier($storageId, $falIdentifier, $resourceIdentifier); + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + private function createUriResourceIdentifier(string $resourceIdentifier): UriResourceIdentifier + { + try { + return new UriResourceIdentifier($resourceIdentifier); + } catch (\Throwable $e) { + throw new InvalidSystemResourceIdentifierException(sprintf('Can not resolve system resource identifier "%s". Invalid URI.', $resourceIdentifier), 1761732010, $e); + } + } + + /** + * @return array{string, string, string} + * @throws InvalidSystemResourceIdentifierException + */ + private function parseIdentifier(string $resourceIdentifier): array + { + $identifierParts = explode(':', $resourceIdentifier); + if (count($identifierParts) !== 3) { + throw new InvalidSystemResourceIdentifierException(sprintf('Given system resource identifier "%s" is invalid. An identifier consists of three parts, separated by a colon (":").', $resourceIdentifier), 1760386146); + } + return $identifierParts; + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + private function convertExtensionPathToPackageResourceIdentifier(string $extensionPath): string + { + try { + $packageKey = $this->packageManager->extractPackageKeyFromPackagePath($extensionPath); + } catch (UnknownPackageException|UnknownPackagePathException $e) { + throw new InvalidSystemResourceIdentifierException(sprintf('Can not create system resource identifier from "%s".', $extensionPath), 1758884297, $e); + } + return sprintf( + '%s:%s:%s', + PackageResourceIdentifier::TYPE, + $packageKey, + substr($extensionPath, strlen($packageKey) + 5), + ); + } + + /** + * @throws InvalidSystemResourceIdentifierException + */ + private function getPackageAndValidatePath(string $packageKey, string $relativePath, string $givenIdentifier): PackageInterface + { + if ($relativePath === '' + || str_starts_with($relativePath, '/') + || !GeneralUtility::validPathStr($relativePath) + ) { + throw new InvalidSystemResourceIdentifierException(sprintf('Relative package path "%s" must not be empty, must not start with a slash ("/") and must not contain invalid characters (e.g. ../ back path). (Given identifier "%s")', $relativePath, $givenIdentifier), 1763381514); + } + try { + $package = $this->packageManager->getPackage($packageKey); + } catch (UnknownPackageException $e) { + throw new InvalidSystemResourceIdentifierException(sprintf('Package with key "%s" does not exist. (Given identifier "%s")', $packageKey, $givenIdentifier), 1763381504, $e); + } + return $package; + } +} diff --git a/Classes/SystemResource/Identifier/UriResourceIdentifier.php b/Classes/SystemResource/Identifier/UriResourceIdentifier.php new file mode 100644 index 0000000..765689a --- /dev/null +++ b/Classes/SystemResource/Identifier/UriResourceIdentifier.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Identifier; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Http\Uri; + +/** + * This is subject to change during v14 development. Do not use. + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final class UriResourceIdentifier extends SystemResourceIdentifier +{ + public const string TYPE = 'URI'; + private readonly UriInterface $uri; + + public function __construct(string $givenIdentifier) + { + parent::__construct($givenIdentifier); + if (str_starts_with($givenIdentifier, self::TYPE)) { + $uri = substr($givenIdentifier, strlen(self::TYPE) + 1); + } + $this->uri = new Uri($uri ?? $givenIdentifier); + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function __toString() + { + if ($this->isRelative()) { + return $this->givenIdentifier; + } + return (string)$this->uri; + } + + private function isRelative(): bool + { + return $this->uri->getAuthority() === ''; + } +} diff --git a/Classes/SystemResource/Publishing/DefaultSystemResourcePublisher.php b/Classes/SystemResource/Publishing/DefaultSystemResourcePublisher.php new file mode 100644 index 0000000..d7d32c0 --- /dev/null +++ b/Classes/SystemResource/Publishing/DefaultSystemResourcePublisher.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotGenerateUriException; +use TYPO3\CMS\Core\SystemResource\Publishing\FileSystem\FileSystemPublisherInterface; +use TYPO3\CMS\Core\SystemResource\Type\PublicResourceInterface; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This implementation publishes (when implemented) public assets from extension + * packages to the public _assets directory using a hash as directory name. + * Subsequently, it can also generate URIs to public resource objects within that _assets folder + * + * @internal Never use or reference it directly, use SystemResourcePublisherInterface to inject it (or a proper replacement). + */ +#[Autoconfigure(public: true), AsAlias(SystemResourcePublisherInterface::class, public: true)] +final readonly class DefaultSystemResourcePublisher implements SystemResourcePublisherInterface +{ + private const string PUBLISHING_DIRECTORY = '_assets/'; + private const string PUBLISHING_DIRECTORY_INSTALL = '_assets_install/'; + + /** + * @var FileSystemPublisherInterface[] + */ + private array $fileSystemPublishers; + + private string $publishingDirectory; + + public function __construct( + array $fileSystemPublishers = [], + bool $failsafe = false, + ) { + $this->fileSystemPublishers = $fileSystemPublishers; + $this->publishingDirectory = $failsafe ? self::PUBLISHING_DIRECTORY_INSTALL : self::PUBLISHING_DIRECTORY; + } + + public function publishResources( + PackageInterface $package, + ): FlashMessageQueue { + $queue = new FlashMessageQueue('asset:publish'); + $resourceDefinitions = $package + ->getResources() + ->getPublicResourceDefinitions(); + + foreach ($resourceDefinitions as $definition) { + $publishingContext = new ResourcePublishingContext( + package: $package, + definition: $definition, + ); + if ($publishingContext->isSourcePublic) { + continue; + } + $publicResourcesPath = Environment::getPublicPath() . '/' . $this->publishingDirectory . $publishingContext->prefix; + GeneralUtility::mkdir_deep(dirname($publicResourcesPath)); + try { + foreach ($this->fileSystemPublishers as $publisher) { + if (!$publisher->canPublish($publishingContext->filesystemPath, $publicResourcesPath)) { + continue; + } + if (is_file($publishingContext->filesystemPath)) { + $publisher->publishFile($publishingContext->filesystemPath, $publicResourcesPath); + } else { + if (!is_dir($publishingContext->filesystemPath)) { + $queue->addMessage(new FlashMessage( + sprintf( + 'Did not publish public resource for extension "%s".' + . chr(10) + . 'The source file/directory "%s" does not exist.', + $package->getPackageKey(), + substr($publishingContext->filesystemPath, strlen(Environment::getProjectPath())), + ), + $package->getPackageKey(), + ContextualFeedbackSeverity::INFO, + )); + break; + } + $publisher->publishFolder($publishingContext->filesystemPath, $publicResourcesPath); + } + break; + } + } catch (PackageAssetsPublishingFailedException $e) { + $queue->addMessage(new FlashMessage( + sprintf( + 'Could not publish public resources for extension "%s" by using the "%s" strategy.' + . chr(10) + . 'Check whether the target directory "%s" already exists' + . chr(10) + . 'and TYPO3 has permissions to write inside the "_assets" directory.', + $package->getPackageKey(), + $e->publishingStrategy, + '.' . substr($publicResourcesPath, strlen(Environment::getProjectPath())), + ), + $package->getPackageKey(), + ContextualFeedbackSeverity::ERROR, + )); + } + } + return $queue; + } + + /** + * @throws CanNotGenerateUriException + */ + public function generateUri(PublicResourceInterface $publicResource, ?ServerRequestInterface $request, ?UriGenerationOptions $options = null): UriInterface + { + if (!$publicResource->isPublished()) { + throw new CanNotGenerateUriException(sprintf('Can not generate Uri for an unpublished resource %s', $publicResource), 1761211273); + } + $request ??= $GLOBALS['TYPO3_REQUEST'] ?? null; + $options ??= new UriGenerationOptions(); + return $publicResource->getPublicUri( + new DefaultSystemResourceUriGenerator( + $this->publishingDirectory, + $this->extractPublicPrefixFromRequest($request, $options->uriPrefix), + $request, + $options, + ) + ); + } + + private function extractPublicPrefixFromRequest(?ServerRequestInterface $request, ?string $publicPrefix): string + { + if ($publicPrefix !== null) { + return $publicPrefix; + } + if ($request === null) { + return '/'; + } + $normalizedParams = $request->getAttribute('normalizedParams'); + return $this->getFrontendUrlPrefix($request->getAttribute('frontend.typoscript')?->getConfigArray(), $normalizedParams) + ?? $normalizedParams->getSitePath(); + } + + private function getFrontendUrlPrefix(?array $typoScriptConfigArray, NormalizedParams $normalizedParams): ?string + { + if ($typoScriptConfigArray === null) { + return null; + } + if ($typoScriptConfigArray['forceAbsoluteUrls'] ?? false) { + return $normalizedParams->getSiteUrl(); + } + $absRefPrefix = trim($typoScriptConfigArray['absRefPrefix'] ?? ''); + return $absRefPrefix === 'auto' ? $normalizedParams->getSitePath() : $absRefPrefix; + } +} diff --git a/Classes/SystemResource/Publishing/DefaultSystemResourceUriGenerator.php b/Classes/SystemResource/Publishing/DefaultSystemResourceUriGenerator.php new file mode 100644 index 0000000..42e830b --- /dev/null +++ b/Classes/SystemResource/Publishing/DefaultSystemResourceUriGenerator.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotGenerateUriException; +use TYPO3\CMS\Core\SystemResource\Http\CacheBustingUri; + +/** + * This is tightly coupled to DefaultSystemResourcePublisher and acts + * as a helper to actually generate the URI for a public resource. + * This helper and its interface only exists to not expose the absolute + * path from the system resource objects directly. + * + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +readonly class DefaultSystemResourceUriGenerator implements SystemResourceUriGeneratorInterface +{ + public function __construct( + private string $publishingDirectory, + private string $prefix, + private ?ServerRequestInterface $request, + private UriGenerationOptions $options, + ) {} + + public function generateForPackageResource( + ResourceUriBuildingContext $context, + ): UriInterface { + $uri = $this->makeAbsolute(new Uri($this->calculateUriPath($context))); + if (!$this->options->cacheBusting) { + return $uri; + } + return CacheBustingUri::fromFileSystemPath( + $context->absoluteResourcePath, + $uri, + $this->request ? ApplicationType::fromRequest($this->request) : null + ); + } + + public function generateForFile(File $file): UriInterface + { + $publicUrl = $file->getPublicUrl(); + if ($publicUrl === null) { + throw new CanNotGenerateUriException(sprintf('Can not create a public Uri for a file %s', $file), 1758619473); + } + if (Environment::isCli()) { + // On CLI FAL public URLs are always relative to public directory, + // so we apply the prefix here, which is likely a "/" only, + // unless calling code properly faked a request. + $publicUrl = $this->prefix . $publicUrl; + } + $uri = $this->makeAbsolute(new Uri($publicUrl)); + if (!$this->options->cacheBusting) { + return $uri; + } + return CacheBustingUri::fromFile( + $file, + $uri, + ); + } + + private function makeAbsolute(UriInterface $uri): UriInterface + { + if ($this->request === null || !$this->options->absoluteUri) { + return $uri; + } + if ($uri->getHost() !== '') { + return $uri; + } + $siteUri = new Uri($this->request->getAttribute('normalizedParams')->getSiteUrl()); + return $uri->withScheme($siteUri->getScheme()) + ->withUserInfo($siteUri->getUserInfo()) + ->withHost($siteUri->getHost()) + ->withPort($siteUri->getPort()); + } + + private function calculateUriPath(ResourceUriBuildingContext $context): string + { + if ($context->isSourcePublic) { + return $this->prefix . substr($context->absoluteResourcePath, strlen(Environment::getPublicPath()) + 1); + } + return $this->prefix . $this->publishingDirectory . $context->uriPath; + } +} diff --git a/Classes/SystemResource/Publishing/FileSystem/FileSystemPublisherInterface.php b/Classes/SystemResource/Publishing/FileSystem/FileSystemPublisherInterface.php new file mode 100644 index 0000000..8483b84 --- /dev/null +++ b/Classes/SystemResource/Publishing/FileSystem/FileSystemPublisherInterface.php @@ -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\Core\SystemResource\Publishing\FileSystem; + +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +interface FileSystemPublisherInterface +{ + public function canPublish(string $source, string $target): bool; + + /** + * @throws PackageAssetsPublishingFailedException + */ + public function publishFolder(string $source, string $target): void; + + /** + * @throws PackageAssetsPublishingFailedException + */ + public function publishFile(string $source, string $target): void; +} diff --git a/Classes/SystemResource/Publishing/FileSystem/JunctionPublisher.php b/Classes/SystemResource/Publishing/FileSystem/JunctionPublisher.php new file mode 100644 index 0000000..8b39d5b --- /dev/null +++ b/Classes/SystemResource/Publishing/FileSystem/JunctionPublisher.php @@ -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\Core\SystemResource\Publishing\FileSystem; + +use Symfony\Component\Filesystem\Exception\IOException; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; +use TYPO3\CMS\Core\Utility\File\FileSystem; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class JunctionPublisher implements FileSystemPublisherInterface +{ + private PublishingConfiguration $config; + + public function __construct(private FileSystem $fileSystem) + { + $this->config = new PublishingConfiguration(); + } + + public function canPublish(string $source, string $target): bool + { + return Environment::isWindows() + && $this->config->isLinkPublishingEnabled(); + } + + /** + * @throws PackageAssetsPublishingFailedException + */ + public function publishFolder(string $source, string $target): void + { + $this->ensureJunctionExists($source, $target); + } + + /** + * @throws \LogicException + */ + public function publishFile(string $source, string $target): void + { + throw new \LogicException(self::class . ' can not be used to publish single files', 1772535297); + } + + /** + * @throws PackageAssetsPublishingFailedException + */ + private function ensureJunctionExists(string $target, string $junction): void + { + $e = null; + if (!$this->fileSystem->isJunction($junction)) { + try { + $this->fileSystem->junction($target, $junction); + } catch (IOException $e) { + } + } + + if ($e !== null || realpath($target) !== realpath($junction)) { + throw new PackageAssetsPublishingFailedException( + 'junction', + 1717488535, + $e, + ); + } + } +} diff --git a/Classes/SystemResource/Publishing/FileSystem/MirrorPublisher.php b/Classes/SystemResource/Publishing/FileSystem/MirrorPublisher.php new file mode 100644 index 0000000..53a7a9e --- /dev/null +++ b/Classes/SystemResource/Publishing/FileSystem/MirrorPublisher.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing\FileSystem; + +use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class MirrorPublisher implements FileSystemPublisherInterface +{ + private PublishingConfiguration $config; + + public function __construct() + { + $this->config = new PublishingConfiguration(); + } + + public function canPublish(string $source, string $target): bool + { + return $this->config->isMirrorPublishingEnabled(); + } + + public function publishFolder(string $source, string $target): void + { + if (realpath($source) === realpath($target)) { + throw new PackageAssetsPublishingFailedException( + 'mirror', + 1773140314, + ); + } + $symfonyFilesystem = new SymfonyFilesystem(); + $symfonyFilesystem->mirror( + $source, + $target, + null, + [ + 'delete' => true, + 'override' => true, + ], + ); + } + + public function publishFile(string $source, string $target): void + { + if (!is_file($source)) { + throw new \LogicException('Can not publish file, because source is not a file', 1772538042); + } + if (realpath($source) === realpath($target)) { + throw new PackageAssetsPublishingFailedException( + 'mirror', + 1773140294, + ); + } + $symfonyFilesystem = new SymfonyFilesystem(); + $symfonyFilesystem->copy($source, $target, true); + } +} diff --git a/Classes/SystemResource/Publishing/FileSystem/PublishingConfiguration.php b/Classes/SystemResource/Publishing/FileSystem/PublishingConfiguration.php new file mode 100644 index 0000000..e3dcf41 --- /dev/null +++ b/Classes/SystemResource/Publishing/FileSystem/PublishingConfiguration.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing\FileSystem; + +use TYPO3\CMS\Core\Core\Environment; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class PublishingConfiguration +{ + private string $publishingType; + + public function __construct(?string $publishingType = null) + { + $publishingType = $publishingType ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']['SystemResources']['filesystemPublishingType'] ?? 'auto'; + if ($publishingType === 'auto') { + $publishingType = Environment::getContext()->isDevelopment() ? 'link' : 'mirror'; + } + $this->publishingType = $publishingType; + } + + public function isLinkPublishingEnabled(): bool + { + return $this->publishingType === 'link'; + } + + public function isMirrorPublishingEnabled(): bool + { + return $this->publishingType === 'mirror'; + } + + public function hasCustomPublishingType(): bool + { + return !$this->isMirrorPublishingEnabled() && !$this->isLinkPublishingEnabled(); + } + + public function getCustomPublishingType(): string + { + if (!$this->hasCustomPublishingType()) { + throw new \LogicException('There is no custom publishing type enabled', 1773138713); + } + return $this->publishingType; + } +} diff --git a/Classes/SystemResource/Publishing/FileSystem/SymlinkPublisher.php b/Classes/SystemResource/Publishing/FileSystem/SymlinkPublisher.php new file mode 100644 index 0000000..e8267b8 --- /dev/null +++ b/Classes/SystemResource/Publishing/FileSystem/SymlinkPublisher.php @@ -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\Core\SystemResource\Publishing\FileSystem; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Package\Exception\PackageAssetsPublishingFailedException; +use TYPO3\CMS\Core\Utility\File\FileSystem; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class SymlinkPublisher implements FileSystemPublisherInterface +{ + private PublishingConfiguration $config; + + public function __construct(private FileSystem $fileSystem) + { + $this->config = new PublishingConfiguration(); + } + + public function canPublish(string $source, string $target): bool + { + return !Environment::isWindows() + && $this->config->isLinkPublishingEnabled(); + } + + public function publishFolder(string $source, string $target): void + { + $this->ensureSymlinkExists($source, $target, 'dir'); + } + + public function publishFile(string $source, string $target): void + { + $this->ensureSymlinkExists($source, $target, 'file'); + } + + /** + * @throws PackageAssetsPublishingFailedException + */ + private function ensureSymlinkExists(string $target, string $link, string $type): void + { + $success = true; + if (!$this->isSymlinked($link, $type)) { + $success = $this->fileSystem->relativeSymlink($target, $link); + } + $this->ensureIsValid($target, $link, $success); + } + + private function isSymlinked(string $link, string $type): bool + { + return match ($type) { + 'file' => $this->fileSystem->isSymlinkedFile($link), + 'dir' => $this->fileSystem->isSymlinkedDirectory($link), + default => throw new \UnexpectedValueException(sprintf('Type can only be "file" or "dir", "%s" given.', $type), 1774611766), + }; + } + + private function ensureIsValid(string $target, string $link, bool $success): void + { + if (!$success || realpath($target) !== realpath($link)) { + throw new PackageAssetsPublishingFailedException( + 'symlink', + 1717488536, + ); + } + } +} diff --git a/Classes/SystemResource/Publishing/ResourcePublishingContext.php b/Classes/SystemResource/Publishing/ResourcePublishingContext.php new file mode 100644 index 0000000..7ea6ccb --- /dev/null +++ b/Classes/SystemResource/Publishing/ResourcePublishingContext.php @@ -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\Core\SystemResource\Publishing; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\Package\Resource\Definition\DynamicPublicPrefixInterface; +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class ResourcePublishingContext +{ + public string $prefix; + public bool $isSourcePublic; + public string $filesystemPath; + + /** + * Variable names are explicitly public API + * for named variable access + */ + public function __construct( + private PackageInterface $package, + private PublicResourceDefinition $definition, + ) { + $this->prefix = $definition->getPublicPrefix() instanceof DynamicPublicPrefixInterface + ? $definition->getPublicPrefix()->calculatePrefix($package, $definition) + : $definition->getPublicPrefix(); + $this->isSourcePublic = str_starts_with($this->package->getPackagePath() . $this->definition->getRelativePath(), Environment::getPublicPath()); + $this->filesystemPath = $package->getPackagePath() . $definition->getRelativePath(); + } +} diff --git a/Classes/SystemResource/Publishing/ResourceUriBuildingContext.php b/Classes/SystemResource/Publishing/ResourceUriBuildingContext.php new file mode 100644 index 0000000..44687b5 --- /dev/null +++ b/Classes/SystemResource/Publishing/ResourceUriBuildingContext.php @@ -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\Core\SystemResource\Publishing; + +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; +use TYPO3\CMS\Core\SystemResource\Type\PublicPackageFile; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class ResourceUriBuildingContext +{ + public string $absoluteResourcePath; + public string $uriPath; + public bool $isSourcePublic; + + /** + * Variable names are explicitly public API + * for named variable access + */ + public function __construct( + public PublicPackageFile $resource, + public PackageInterface $package, + public PublicResourceDefinition $definition, + ) { + $this->absoluteResourcePath = $this->package->getPackagePath() . $this->resource->getRelativePath(); + $publishingContext = new ResourcePublishingContext( + package: $package, + definition: $definition + ); + $this->isSourcePublic = $publishingContext->isSourcePublic; + $this->uriPath = $publishingContext->prefix . substr($this->resource->getRelativePath(), strlen($this->definition->getRelativePath())); + } +} diff --git a/Classes/SystemResource/Publishing/SystemResourcePublisherInterface.php b/Classes/SystemResource/Publishing/SystemResourcePublisherInterface.php new file mode 100644 index 0000000..808c404 --- /dev/null +++ b/Classes/SystemResource/Publishing/SystemResourcePublisherInterface.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Package\PackageInterface; +use TYPO3\CMS\Core\SystemResource\Type\PublicResourceInterface; + +/** + * Implementations of this interface can publish public extension resources (once implemented) + * and therefore also generate URIs to those published resources. + * E.g. publish resources directly to a CDN and then generate CDS URIs + * to those resources. + */ +interface SystemResourcePublisherInterface +{ + public function publishResources(PackageInterface $package): FlashMessageQueue; + + public function generateUri(PublicResourceInterface $publicResource, ?ServerRequestInterface $request, ?UriGenerationOptions $options = null): UriInterface; +} diff --git a/Classes/SystemResource/Publishing/SystemResourceUriGeneratorInterface.php b/Classes/SystemResource/Publishing/SystemResourceUriGeneratorInterface.php new file mode 100644 index 0000000..04eddb5 --- /dev/null +++ b/Classes/SystemResource/Publishing/SystemResourceUriGeneratorInterface.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Publishing; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Resource\File; + +/** + * This is an implementation detail to allow not exposing the absolute file path + * to extension resources directly, but only to the resource publisher + * when generating URLs to the _assets directory. + * + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +interface SystemResourceUriGeneratorInterface +{ + public function generateForPackageResource( + ResourceUriBuildingContext $context, + ): UriInterface; + public function generateForFile(File $file): UriInterface; +} diff --git a/Classes/SystemResource/Publishing/UriGenerationOptions.php b/Classes/SystemResource/Publishing/UriGenerationOptions.php new file mode 100644 index 0000000..5b4562a --- /dev/null +++ b/Classes/SystemResource/Publishing/UriGenerationOptions.php @@ -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\Core\SystemResource\Publishing; + +/** + * Options for system resource URI generation. + * These might change, by adding more options, + * which means the variable names MUST be kept + * (or properly deprecated) as they are public API. + * Also, this object MUST be crated using named arguments. + */ +final readonly class UriGenerationOptions +{ + /** + * Variable names are explicitly public API + * for named variable access + * + * Some or all of these options might to be applicable + * to specific implementations SystemResourcePublisherInterface, + * which means, that if other resource publishing strategies + * are configured, that changing these options might not + * influence the resulting URI + */ + public function __construct( + public ?string $uriPrefix = null, + public bool $absoluteUri = false, + public bool $cacheBusting = true, + ) {} +} diff --git a/Classes/SystemResource/SystemResourceFactory.php b/Classes/SystemResource/SystemResourceFactory.php new file mode 100644 index 0000000..23c0c44 --- /dev/null +++ b/Classes/SystemResource/SystemResourceFactory.php @@ -0,0 +1,215 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; +use TYPO3\CMS\Core\Package\VirtualAppPackage; +use TYPO3\CMS\Core\Resource\Exception as FalException; +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\Resource\StorageRepository; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Identifier\FalResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Identifier\PackageResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Identifier\SystemResourceIdentifierFactory; +use TYPO3\CMS\Core\SystemResource\Identifier\UriResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Type\PackageResource; +use TYPO3\CMS\Core\SystemResource\Type\PublicPackageFile; +use TYPO3\CMS\Core\SystemResource\Type\PublicResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface; +use TYPO3\CMS\Core\SystemResource\Type\UriResource; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * This is the heart of system resource handling and + * the most important API to be used in userland code + * and throughout the core. + * + * This class should be final, but is not, because it + * needs to be mocked in tests. + */ +#[Autoconfigure(public: true)] +readonly class SystemResourceFactory +{ + public function __construct( + private SystemResourceIdentifierFactory $identifierFactory, + private ?StorageRepository $storageRepository, + private ?ResourceFactory $resourceFactory, + ) {} + + /** + * Use this method to obtain a resource that is public, + * which means, that a URI can be generated for it. + * + * Always use it when the goal is to generate a URI, + * as it checks, whether the resolved resource can/ is + * indeed published or not and throws an exception otherwise. + * + * @throws CanNotResolveSystemResourceException + * @throws CanNotResolvePublicResourceException + */ + public function createPublicResource(string $resourceString): PublicResourceInterface + { + $resource = $this->createResource($resourceString); + if (!$resource instanceof PublicResourceInterface || !$resource->isPublished()) { + throw new CanNotResolvePublicResourceException(sprintf('Resolved resource "%s" is not a public resource. Given resource identifier: "%s"', $resource, $resourceString), 1758098512); + } + return $resource; + } + + /** + * Use this method, when generating a URI is not required + * for the resource and only e.g. file contents like for templates + * is required. + * + * @throws CanNotResolveSystemResourceException + * @throws InvalidSystemResourceIdentifierException + */ + public function createResource(string $resourceString): StaticResourceInterface + { + try { + return $this->createFromIdentifier($resourceString); + } catch (CanNotResolveSystemResourceIdentifierException $e) { + if (str_starts_with($resourceString, Environment::getProjectPath()) + || (PathUtility::isAbsolutePath($resourceString) && file_exists($resourceString)) + ) { + throw new CanNotResolveSystemResourceException('Absolute paths are not allowed as resource identifiers', 1760618195); + } + $resourceString = ltrim($resourceString, '/'); + $falResource = $this->createFromLegacyFalPath($resourceString, $e); + return $falResource ?? $this->createResourceFromRelativePublicPath($resourceString); + } + } + + /** + * @throws CanNotResolveSystemResourceException + * @throws InvalidSystemResourceIdentifierException + */ + private function createResourceFromRelativePublicPath(string $relativePublicPath): SystemResourceInterface + { + $absoluteResourcePath = Environment::getPublicPath() . '/' . $relativePublicPath; + // The file must actually exist, only then we can be sure our + // following string manipulation is correct. Otherwise, it could be some unexpected + // string and the manipulation will lead to unexpected results + // Strip potentially available query string and fragment from the path before checking, though + try { + $strippedAbsolutePath = (new Uri($absoluteResourcePath))->getPath(); + } catch (\InvalidArgumentException) { + $strippedAbsolutePath = null; + } + if (!file_exists($strippedAbsolutePath ?? $absoluteResourcePath)) { + throw new CanNotResolveSystemResourceException(sprintf('Can not resolve relative public path "%s" to a system resource', $relativePublicPath), 1759740281); + } + $packageIdentifier = $this->identifierFactory->createFromPackagePath( + VirtualAppPackage::APP_PACKAGE_KEY, + substr($absoluteResourcePath, strlen(Environment::getProjectPath()) + 1), + $relativePublicPath, + ); + return $this->createFromPackageIdentifier($packageIdentifier); + } + + /** + * @throws CanNotResolveSystemResourceException + * @throws InvalidSystemResourceIdentifierException + * @throws CanNotResolveSystemResourceIdentifierException + */ + private function createFromIdentifier(string $potentialIdentifier): StaticResourceInterface + { + $identifier = $this->identifierFactory->create($potentialIdentifier); + return match (get_class($identifier)) { + UriResourceIdentifier::class => new UriResource($identifier), + PackageResourceIdentifier::class => $this->createFromPackageIdentifier($identifier), + FalResourceIdentifier::class => $this->createFromFalIdentifier($identifier), + default => throw new InvalidSystemResourceIdentifierException(sprintf('Can not resolve "%s" to a system resource. Unknown SystemResourceIdentifier', $potentialIdentifier), 1759393674), + }; + } + + /** + * @throws CanNotResolveSystemResourceException + */ + private function createFromLegacyFalPath(string $resourceString, \Throwable $e): ?StaticResourceInterface + { + if (!str_starts_with($resourceString, $GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'])) { + // For legacy resolving, we do not even try resolving any other path than + // one starting with configured $GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'] + return null; + } + try { + $potentialFalPath = $resourceString; + $storageUid = $this->storageRepository?->findBestMatchingStorageByLocalPath($potentialFalPath); + } catch (\Throwable) { + $storageUid = 0; + } + if ($storageUid <= 0) { + return null; + } + $storage = $this->storageRepository?->findByUid($storageUid); + if ($storage === null) { + throw new CanNotResolveSystemResourceException(sprintf('Can not resolve "%s" to a system resource, storage %d does not exist', $resourceString, $storageUid), 1758627596, $e); + } + $file = null; + try { + $file = $storage->getFile($potentialFalPath); + } catch (\Throwable) { + } + $this->ensureValidFalResource($file, $resourceString); + return $file; + } + + /** + * @throws CanNotResolveSystemResourceException + */ + private function createFromFalIdentifier(FalResourceIdentifier $resourceUri): PublicResourceInterface + { + try { + $file = $this->resourceFactory?->retrieveFileOrFolderObject($resourceUri->getIdentifier()); + $this->ensureValidFalResource($file, (string)$resourceUri); + return $file; + } catch (FalException $e) { + throw new CanNotResolveSystemResourceException(sprintf('Can not resolve "%s" to a system resource', $resourceUri), 1759397430, $e); + } + } + + /** + * @throws CanNotResolveSystemResourceException + */ + private function ensureValidFalResource(ProcessedFile|File|Folder|null $falResource, string $resourceUri): void + { + if (!$falResource instanceof File || $falResource->getStorage()->getUid() === 0) { + throw new CanNotResolveSystemResourceException(sprintf('Can not resolve file with URI "%s"', $resourceUri), 1758700078); + } + } + + private function createFromPackageIdentifier(PackageResourceIdentifier $packageIdentifier): SystemResourceInterface + { + $resourceDefinition = $packageIdentifier->getPackage()->getResources()->definitionForPath($packageIdentifier->getRelativePath()); + if ($resourceDefinition instanceof PublicResourceDefinition) { + return new PublicPackageFile($packageIdentifier, $resourceDefinition); + } + return new PackageResource($packageIdentifier, $resourceDefinition); + } +} diff --git a/Classes/SystemResource/Type/PackageResource.php b/Classes/SystemResource/Type/PackageResource.php new file mode 100644 index 0000000..a9f7452 --- /dev/null +++ b/Classes/SystemResource/Type/PackageResource.php @@ -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\Core\SystemResource\Type; + +use TYPO3\CMS\Core\Package\Resource\Definition\ResourceDefinitionInterface; +use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceDoesNotExistException; +use TYPO3\CMS\Core\SystemResource\Identifier\PackageResourceIdentifier; +use TYPO3\CMS\Core\Type\File\FileInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +class PackageResource implements SystemResourceInterface +{ + private ?FileInfo $fileInfo = null; + + public function __construct( + protected readonly PackageResourceIdentifier $identifier, + protected readonly ResourceDefinitionInterface $resourceDefinition, + ) {} + + public function getName(): string + { + return PathUtility::pathinfo($this->identifier->getRelativePath())['basename']; + } + + public function getNameWithoutExtension(): string + { + return PathUtility::pathinfo($this->identifier->getRelativePath())['filename']; + } + + public function getExtension(): string + { + return PathUtility::pathinfo($this->identifier->getRelativePath())['extension']; + } + + /** + * @throws SystemResourceDoesNotExistException + */ + public function getContents(): string + { + $fileInfo = $this->getValidatedFileInfo(); + $content = file_get_contents($fileInfo->getPathname()); + if ($content === false) { + throw new SystemResourceDoesNotExistException(sprintf('Can not get contents from referenced system resource "%s" (resolved as "%s")', $this->identifier->givenIdentifier, $this), 1758714587); + } + return $content; + } + + /** + * @throws SystemResourceDoesNotExistException + */ + public function getMimeType(): string + { + $fileInfo = $this->getValidatedFileInfo(); + $mimeType = $fileInfo->getMimeType(); + if ($mimeType === false) { + throw new SystemResourceDoesNotExistException(sprintf('Can not get mime type from referenced system resource "%s" (resolved as "%s")', $this->identifier->givenIdentifier, $this), 1758786841); + } + return $mimeType; + } + + /** + * @throws SystemResourceDoesNotExistException + */ + public function getHash(): string + { + $fileInfo = $this->getValidatedFileInfo(); + return md5_file($fileInfo->getPathname()); + } + + /** + * @throws SystemResourceDoesNotExistException + */ + private function getValidatedFileInfo(): FileInfo + { + if ($this->fileInfo !== null) { + return $this->fileInfo; + } + $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $this->identifier->getPackage()->getPackagePath() . $this->identifier->getRelativePath()); + if (!$fileInfo->isFile()) { + throw new SystemResourceDoesNotExistException(sprintf('Referenced system resource "%s" (resolved as "%s") does not exist, or is not a file', $this->identifier->givenIdentifier, $this), 1758785343); + } + return $this->fileInfo = $fileInfo; + } + + public function getResourceIdentifier(): string + { + return (string)$this->identifier; + } + + public function __toString(): string + { + return $this->getResourceIdentifier(); + } +} diff --git a/Classes/SystemResource/Type/PublicPackageFile.php b/Classes/SystemResource/Type/PublicPackageFile.php new file mode 100644 index 0000000..955de15 --- /dev/null +++ b/Classes/SystemResource/Type/PublicPackageFile.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\SystemResource\Type; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; +use TYPO3\CMS\Core\SystemResource\Identifier\PackageResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Publishing\ResourceUriBuildingContext; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourceUriGeneratorInterface; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final class PublicPackageFile extends PackageResource implements PublicResourceInterface +{ + public function __construct( + PackageResourceIdentifier $identifier, + PublicResourceDefinition $resourceDefinition, + ) { + parent::__construct($identifier, $resourceDefinition); + } + + public function getPublicUri(SystemResourceUriGeneratorInterface $uriGenerator): UriInterface + { + // This is fine, as the type is enforced in constructor + assert($this->resourceDefinition instanceof PublicResourceDefinition); + return $uriGenerator->generateForPackageResource( + new ResourceUriBuildingContext( + resource: $this, + package: $this->identifier->getPackage(), + definition: $this->resourceDefinition, + ), + ); + } + + public function getRelativePath(): string + { + return $this->identifier->getRelativePath(); + } + + public function isPublished(): bool + { + return $this->identifier->getPackage()->getResources()->isPublicPath($this->identifier->getRelativePath()); + } + + /** + * @internal This API is only meant for very limited use cases, + * e.g. for building URIs for Vite dev server, where the dev server actually + * publishes (exposes) all (private) source files, so that they can be processed on the fly + * Do *not* use within TYPO3 core or other third party extensions + */ + public static function fromPackageResource(PackageResource $packageResource): self + { + if ($packageResource instanceof PublicResourceInterface) { + throw new \LogicException('It is pointless to create a public resource from an already public resource', 1761217630); + } + return new self( + $packageResource->identifier, + new PublicResourceDefinition( + $packageResource->resourceDefinition->getRelativePath() + ) + ); + } +} diff --git a/Classes/SystemResource/Type/PublicResourceInterface.php b/Classes/SystemResource/Type/PublicResourceInterface.php new file mode 100644 index 0000000..3118cf2 --- /dev/null +++ b/Classes/SystemResource/Type/PublicResourceInterface.php @@ -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\Core\SystemResource\Type; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourceUriGeneratorInterface; + +/** + * This interface is public API and can be referenced in third party code + * or throughout the TYPO3 core. + * Implementations of this interface are internal though + * and must only happen in TYPO3\CMS\Core\SystemResource namespace + */ +interface PublicResourceInterface extends StaticResourceInterface +{ + /** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ + public function getPublicUri(SystemResourceUriGeneratorInterface $uriGenerator): UriInterface; + + /** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ + public function isPublished(): bool; +} diff --git a/Classes/SystemResource/Type/StaticResourceInterface.php b/Classes/SystemResource/Type/StaticResourceInterface.php new file mode 100644 index 0000000..7c159c2 --- /dev/null +++ b/Classes/SystemResource/Type/StaticResourceInterface.php @@ -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\Core\SystemResource\Type; + +/** + * All static resources in TYPO3 have one thing in common + * and that is, that they can be identified with a unique string. + * This means they can be constructed from a string by SystemResourceFactory, + * and they can be cast to a unique string representation. + * + * Currently absolute and relative URLs (UriResource), files within an extension directory (PackageResource), + * and file abstraction layer (FAL) files serve as static resources + * to be referenced e.g. as public URL (see PublicResourceInterface) + * + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +interface StaticResourceInterface extends \Stringable +{ + public function getResourceIdentifier(): string; +} diff --git a/Classes/SystemResource/Type/SystemResourceInterface.php b/Classes/SystemResource/Type/SystemResourceInterface.php new file mode 100644 index 0000000..9c46c4c --- /dev/null +++ b/Classes/SystemResource/Type/SystemResourceInterface.php @@ -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\Core\SystemResource\Type; + +use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceDoesNotExistException; + +/** + * This interface is public API and can be referenced in third party code + * or throughout the TYPO3 core. + * Implementations of this interface are internal though + * and must only happen in TYPO3\CMS\Core\SystemResource namespace + */ +interface SystemResourceInterface extends StaticResourceInterface +{ + /** + * @throws SystemResourceDoesNotExistException + */ + public function getContents(): string; + public function getName(): string; + public function getNameWithoutExtension(): string; + public function getExtension(): string; + public function getMimeType(): string; + public function getHash(): string; +} diff --git a/Classes/SystemResource/Type/UriResource.php b/Classes/SystemResource/Type/UriResource.php new file mode 100644 index 0000000..6ae23c5 --- /dev/null +++ b/Classes/SystemResource/Type/UriResource.php @@ -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\Core\SystemResource\Type; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\SystemResource\Identifier\UriResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourceUriGeneratorInterface; + +/** + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +final readonly class UriResource implements StaticResourceInterface, PublicResourceInterface +{ + public function __construct(private UriResourceIdentifier $identifier) {} + + public function getPublicUri(SystemResourceUriGeneratorInterface $uriGenerator): UriInterface + { + return $this->identifier->getUri(); + } + + public function isPublished(): bool + { + return true; + } + + public function __toString(): string + { + return $this->getResourceIdentifier(); + } + + public function getResourceIdentifier(): string + { + return (string)$this->identifier; + } +} diff --git a/Classes/Text/TextCropper.php b/Classes/Text/TextCropper.php new file mode 100644 index 0000000..834eeec --- /dev/null +++ b/Classes/Text/TextCropper.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Text; + +readonly class TextCropper +{ + /** + * Implements "cropHTML" which is a modified "substr" function allowing to limit a string length to a certain number + * of chars (from either start or end of string) and having a pre/postfix applied if the string really was cropped. + * + * Note: Crop is done without properly respecting html tags and entities. + * + * @param string $content The string to perform the operation on + * @param int $numberOfChars Max number of chars of the string. Negative value means cropping from end of string. + * @param string $replacementForEllipsis The pre/postfix string to apply if cropping occurs. + * @param bool $cropToSpace If true then crop will be applied at nearest space. + * @return string The processed input value. + */ + public function crop(string $content, int $numberOfChars, string $replacementForEllipsis, bool $cropToSpace): string + { + if (!$numberOfChars || !(mb_strlen($content, 'utf-8') > abs($numberOfChars))) { + return $content; + } + + if ($numberOfChars < 0) { + // cropping from the right side of the content, prepanding replacementForEllipsis + $content = mb_substr($content, $numberOfChars, null, 'utf-8'); + $truncatePosition = $cropToSpace ? mb_strpos($content, ' ', 0, 'utf-8') : false; + return $truncatePosition > 0 + ? $replacementForEllipsis . mb_substr($content, $truncatePosition, null, 'utf-8') + : $replacementForEllipsis . $content; + } + + // cropping from the left side of content, appending replacementForEllipsis + $content = mb_substr($content, 0, $numberOfChars, 'utf-8'); + $truncatePosition = $cropToSpace ? mb_strrpos($content, ' ', 0, 'utf-8') : false; + return $truncatePosition > 0 + ? mb_substr($content, 0, $truncatePosition, 'utf-8') . $replacementForEllipsis + : $content . $replacementForEllipsis; + } +} diff --git a/Classes/TimeTracker/TimeTracker.php b/Classes/TimeTracker/TimeTracker.php new file mode 100644 index 0000000..2547013 --- /dev/null +++ b/Classes/TimeTracker/TimeTracker.php @@ -0,0 +1,287 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TimeTracker; + +use Psr\Log\LogLevel; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Frontend Timetracking functions + * Is used to register how much time is used with operations in TypoScript. + * + * Note: Only push() (with first argument only), pull() and setTSlogMessage() + * are considered API, everything else is internal. + */ +class TimeTracker implements SingletonInterface +{ + /** + * If set to true (see constructor) then timetracking is enabled + */ + protected bool $isEnabled = false; + + /** + * Is loaded with the millisecond time when this object is created + */ + protected int $starttime = 0; + + /** + * Is set via finish() with the millisecond time when the request handler is finished. + */ + protected float $finishtime = 0; + + /** + * Log Rendering flag. If set, ->push() and ->pull() is called from the cObj->cObjGetSingle(). + * This determines whether the TypoScript parsing activity is logged. But it also slows down the rendering. + * + * @internal + */ + public bool $LR = true; + + protected array $wrapError = [ + LogLevel::INFO => ['', ''], + LogLevel::NOTICE => ['<strong>', '</strong>'], + LogLevel::WARNING => ['<strong style="color:#ff6600;">', '</strong>'], + LogLevel::ERROR => ['<strong style="color:#ff0000;">', '</strong>'], + ]; + + protected array $wrapIcon = [ + LogLevel::INFO => '', + LogLevel::NOTICE => 'actions-document-info', + LogLevel::WARNING => 'status-dialog-warning', + LogLevel::ERROR => 'status-dialog-error', + ]; + + protected int $uniqueCounter = 0; + protected array $tsStack = [[]]; + protected int $tsStackLevel = 0; + protected array $tsStackLevelMax = []; + protected array $tsStackLog = []; + protected int $tsStackPointer = 0; + protected array $currentHashPointer = []; + + /** + * @internal + */ + public function __construct(bool $isEnabled = true) + { + $this->isEnabled = $isEnabled; + } + + /** + * Pushes an element to the TypoScript tracking array + * + * @param string $tslabel Label string for the entry, eg. TypoScript property name + * @param string $value Additional value (@internal, may vanish) + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::cObjGetSingle() + * @see pull() + */ + public function push(string $tslabel, string $value = ''): void + { + if (!$this->isEnabled) { + return; + } + $this->tsStack[$this->tsStackPointer][] = $tslabel; + $this->currentHashPointer[] = 'timetracker_' . $this->uniqueCounter++; + $this->tsStackLevel++; + $this->tsStackLevelMax[] = $this->tsStackLevel; + // setTSlog + $k = end($this->currentHashPointer); + $this->tsStackLog[$k] = [ + 'level' => $this->tsStackLevel, + 'tsStack' => $this->tsStack, + 'value' => $value, + 'starttime' => microtime(true), + 'stackPointer' => $this->tsStackPointer, + ]; + } + + /** + * Pulls an element from the TypoScript tracking array + * + * @param string $content The content string generated within the push/pull part. + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::cObjGetSingle() + * @see push() + */ + public function pull(string $content = ''): void + { + if (!$this->isEnabled) { + return; + } + $k = end($this->currentHashPointer); + $this->tsStackLog[$k]['endtime'] = microtime(true); + $this->tsStackLog[$k]['content'] = $content; + $this->tsStackLevel--; + array_pop($this->tsStack[$this->tsStackPointer]); + array_pop($this->currentHashPointer); + } + + /** + * Logs the TypoScript entry + * + * @param string $content The message string + * @param string $logLevel Message type: see LogLevel constants + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::CONTENT() + */ + public function setTSlogMessage(string $content, string $logLevel = LogLevel::INFO): void + { + if (!$this->isEnabled) { + return; + } + end($this->currentHashPointer); + $k = current($this->currentHashPointer); + $placeholder = ''; + // Enlarge the "details" column by adding a span + if (strlen($content) > 30) { + $placeholder = '<br /><span style="width: 300px; height: 1px; display: inline-block;"></span>'; + } + $iconFactory = GeneralUtility::makeInstance(IconFactory::class); + $this->tsStackLog[$k]['message'][] = $iconFactory->getIcon($this->wrapIcon[$logLevel], IconSize::SMALL)->render() . $this->wrapError[$logLevel][0] . htmlspecialchars($content) . $this->wrapError[$logLevel][1] . $placeholder; + } + + /** + * @internal + */ + public function setEnabled(bool $isEnabled = true): void + { + $this->isEnabled = $isEnabled; + } + + /** + * Sets the starting time + * + * @see finish() + * @internal + */ + public function start(?float $starttime = null): void + { + if (!$this->isEnabled) { + return; + } + $this->starttime = $this->getMilliseconds($starttime); + } + + /** + * Increases the stack pointer + * + * @see decStackPointer() + * @see \TYPO3\CMS\Frontend\Page\PageGenerator::renderContent() + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::cObjGetSingle() + * @internal + */ + public function incStackPointer(): void + { + if (!$this->isEnabled) { + return; + } + $this->tsStackPointer++; + $this->tsStack[$this->tsStackPointer] = []; + } + + /** + * Decreases the stack pointer + * + * @see incStackPointer() + * @see \TYPO3\CMS\Frontend\Page\PageGenerator::renderContent() + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::cObjGetSingle() + * @internal + */ + public function decStackPointer(): void + { + if (!$this->isEnabled) { + return; + } + unset($this->tsStack[$this->tsStackPointer]); + $this->tsStackPointer--; + } + + /** + * Gets a microtime value as milliseconds value. + * + * @param float|null $microtime The microtime value - if not set the current time is used + * @return int The microtime value as milliseconds value + */ + protected function getMilliseconds(?float $microtime = null): int + { + if (!$this->isEnabled) { + return 0; + } + if ($microtime === null) { + $microtime = microtime(true); + } + return (int)round($microtime * 1000); + } + + /** + * Gets the difference between a given microtime value and the starting time as milliseconds. + * + * @param float|null $microtime The microtime value - if not set the current time is used + * @return int The difference between a given microtime value and starting time as milliseconds + * @internal + */ + public function getDifferenceToStarttime(?float $microtime = null): int + { + return $this->getMilliseconds($microtime) - $this->starttime; + } + + /** + * Usually called when the page generation and output is prepared. + * + * @see start() + * @internal + */ + public function finish(): void + { + if ($this->isEnabled) { + $this->finishtime = microtime(true); + } + } + + /** + * Get total parse time in milliseconds + * @internal + */ + public function getParseTime(): int + { + if (!$this->starttime) { + $this->start(microtime(true)); + } + if (!$this->finishtime) { + $this->finish(); + } + return $this->getDifferenceToStarttime($this->finishtime); + } + + /** + * @internal + */ + public function isEnabled(): bool + { + return $this->isEnabled; + } + + /** + * @internal + */ + public function getTypoScriptLogStack(): array + { + return $this->tsStackLog; + } +} diff --git a/Classes/Tree/Event/ModifyTreeDataEvent.php b/Classes/Tree/Event/ModifyTreeDataEvent.php new file mode 100644 index 0000000..ce77f41 --- /dev/null +++ b/Classes/Tree/Event/ModifyTreeDataEvent.php @@ -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\Core\Tree\Event; + +use TYPO3\CMS\Backend\Tree\TreeNode; +use TYPO3\CMS\Core\Tree\TableConfiguration\AbstractTableConfigurationTreeDataProvider; + +/** + * Allows to modify tree data for any database tree + */ +final class ModifyTreeDataEvent +{ + public function __construct( + private TreeNode $treeData, + private readonly AbstractTableConfigurationTreeDataProvider $provider + ) {} + + public function getTreeData(): TreeNode + { + return $this->treeData; + } + + public function setTreeData(TreeNode $treeData): void + { + $this->treeData = $treeData; + } + + public function getProvider(): AbstractTableConfigurationTreeDataProvider + { + return $this->provider; + } +} diff --git a/Classes/Tree/TableConfiguration/AbstractTableConfigurationTreeDataProvider.php b/Classes/Tree/TableConfiguration/AbstractTableConfigurationTreeDataProvider.php new file mode 100644 index 0000000..5dfc9e3 --- /dev/null +++ b/Classes/Tree/TableConfiguration/AbstractTableConfigurationTreeDataProvider.php @@ -0,0 +1,262 @@ +<?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\Core\Tree\TableConfiguration; + +use TYPO3\CMS\Backend\Tree\AbstractTreeDataProvider; +use TYPO3\CMS\Backend\Tree\TreeNode; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * An abstract TCA tree data provider + */ +abstract class AbstractTableConfigurationTreeDataProvider extends AbstractTreeDataProvider +{ + /** + * @var bool + */ + protected $expandAll = false; + + /** + * @var int + */ + protected $levelMaximum = 4; + + /** + * @var TreeNode + */ + protected $treeData; + + /** + * @var string + */ + protected $treeId; + + /** + * @var string + */ + protected $nonSelectableLevelList = '0'; + + /** + * @var string + */ + protected $expandedList = ''; + + /** + * @var string + */ + protected $selectedList = ''; + + /** + * Contains all ids which may be allowed to display according to + * beUser Rights and foreign_table_where (if type db) + * + * @var array $itemWhiteList + */ + protected $itemWhiteList = []; + + /** + * Contains all ids which are not allowed to be selected + * @var mixed[] + */ + protected $itemUnselectableList = []; + + /** + * @todo: This is a hack to speed up category tree calculation. See the comments + * in TcaCategory and AbstractItemProvider FormEngine classes. + * @internal + */ + protected array $availableItems = []; + + /** + * @var int[] + */ + protected array $startingPoints = [0]; + + /** + * Sets the id of the tree + * + * @param string $treeId + */ + public function setTreeId($treeId) + { + $this->treeId = $treeId; + } + + /** + * Gets the id of the tree + * + * @return string + */ + public function getTreeId() + { + return $this->treeId; + } + + /** + * Sets the expandAll + * + * @param bool $expandAll + */ + public function setExpandAll($expandAll) + { + $this->expandAll = $expandAll; + } + + /** + * Gets the expandAll + * + * @return bool + */ + public function getExpandAll() + { + return $this->expandAll; + } + + /** + * Sets the levelMaximum + * + * @param int $levelMaximum + */ + public function setLevelMaximum($levelMaximum) + { + $this->levelMaximum = $levelMaximum; + } + + /** + * Gets the levelMaximum + * + * @return int + */ + public function getLevelMaximum() + { + return $this->levelMaximum; + } + + /** + * Gets the expanded state of a given node + * + * @return bool + */ + protected function isExpanded(TreeNode $node) + { + return $this->getExpandAll() || GeneralUtility::inList($this->expandedList, $node->getId()); + } + + /** + * Init the tree data + */ + public function initializeTreeData() {} + + /** + * Sets the list for selected nodes + * + * @param string $selectedList + */ + public function setSelectedList($selectedList) + { + $this->selectedList = $selectedList; + } + + /** + * Gets the list for selected nodes + * + * @return string + */ + public function getSelectedList() + { + return $this->selectedList; + } + + /** + * Sets the list for non selectable tree levels + * + * @param string $nonSelectableLevelList + */ + public function setNonSelectableLevelList($nonSelectableLevelList) + { + $this->nonSelectableLevelList = $nonSelectableLevelList; + } + + /** + * Gets the list for non selectable tree levels + * + * @return string + */ + public function getNonSelectableLevelList() + { + return $this->nonSelectableLevelList; + } + + /** + * Setter for the itemWhiteList + */ + public function setItemWhiteList(array $itemWhiteList) + { + $this->itemWhiteList = $itemWhiteList; + } + + /** + * Getter for the itemWhiteList + * + * @return array + */ + public function getItemWhiteList() + { + return $this->itemWhiteList; + } + + /** + * Setter for $itemUnselectableList + */ + public function setItemUnselectableList(array $itemUnselectableList) + { + $this->itemUnselectableList = $itemUnselectableList; + } + + /** + * Getter for $itemUnselectableList + * + * @return array + */ + public function getItemUnselectableList() + { + return $this->itemUnselectableList; + } + + /** + * @internal See property comment + */ + public function setAvailableItems(array $availableItems) + { + $this->availableItems = $availableItems; + } + + /** + * @param int[] $startingPoints + */ + public function setStartingPoints(array $startingPoints): void + { + $this->startingPoints = $startingPoints; + } + + /** + * @return int[] + */ + public function getStartingPoints(): array + { + return $this->startingPoints; + } +} diff --git a/Classes/Tree/TableConfiguration/ArrayTreeRenderer.php b/Classes/Tree/TableConfiguration/ArrayTreeRenderer.php new file mode 100644 index 0000000..a117fa2 --- /dev/null +++ b/Classes/Tree/TableConfiguration/ArrayTreeRenderer.php @@ -0,0 +1,128 @@ +<?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\Core\Tree\TableConfiguration; + +use TYPO3\CMS\Backend\Tree\AbstractTree; +use TYPO3\CMS\Backend\Tree\Renderer\AbstractTreeRenderer; +use TYPO3\CMS\Backend\Tree\TreeNodeCollection; +use TYPO3\CMS\Backend\Tree\TreeRepresentationNode; + +/** + * Renders a tca tree array for the SelectElementTree + */ +class ArrayTreeRenderer extends AbstractTreeRenderer +{ + /** + * recursion level + * + * @var int + */ + protected $recursionLevel = 0; + + /** + * Renders a node recursive or just a single instance + * + * @param bool $recursive + * @return array + */ + public function renderNode(TreeRepresentationNode $node, $recursive = true) + { + $nodeArray = []; + $nodeArray[] = $this->getNodeArray($node); + if ($recursive && $node->hasChildNodes()) { + $this->recursionLevel++; + $children = $this->renderNodeCollection($node->getChildNodes()); + foreach ($children as $child) { + $nodeArray[] = $child; + } + $this->recursionLevel--; + } + return $nodeArray; + } + + /** + * Get node array + * + * @param \TYPO3\CMS\Backend\Tree\TreeRepresentationNode|DatabaseTreeNode $node + * @return array + */ + protected function getNodeArray(TreeRepresentationNode $node) + { + $overlayIconName = ''; + if (is_object($node->getIcon())) { + $iconName = $node->getIcon()->getIdentifier(); + if (is_object($node->getIcon()->getOverlayIcon())) { + $overlayIconName = $node->getIcon()->getOverlayIcon()->getIdentifier(); + } + } else { + $iconName = $node->getIcon(); + } + $nodeArray = [ + 'identifier' => htmlspecialchars($node->getId()), + // No need for htmlspecialchars() here as d3 is using 'textContent' property of the HTML DOM node + 'name' => $node->getLabel(), + 'icon' => $iconName, + 'overlayIcon' => $overlayIconName, + 'depth' => $this->recursionLevel, + 'hasChildren' => (bool)$node->hasChildNodes(), + 'selectable' => true, + ]; + if ($node instanceof DatabaseTreeNode) { + $nodeArray['checked'] = (bool)$node->getSelected(); + if (!$node->getSelectable()) { + $nodeArray['checked'] = false; + $nodeArray['selectable'] = false; + } + } + return $nodeArray; + } + + /** + * Renders a node collection recursive or just a single instance + * + * @param bool $recursive + * @return array + */ + public function renderTree(AbstractTree $tree, $recursive = true) + { + $this->recursionLevel = 0; + return $this->renderNode($tree->getRoot(), $recursive); + } + + /** + * Renders a tree recursively or just a single instance + * + * @param bool $recursive + * @return array + */ + public function renderNodeCollection(TreeNodeCollection $collection, $recursive = true) + { + $treeItems = []; + foreach ($collection as $node) { + $allNodes = $this->renderNode($node, $recursive); + if ($allNodes[0]) { + $treeItems[] = $allNodes[0]; + } + $nodeCount = count($allNodes); + if ($nodeCount > 1) { + for ($i = 1; $i < $nodeCount; $i++) { + $treeItems[] = $allNodes[$i]; + } + } + } + return $treeItems; + } +} diff --git a/Classes/Tree/TableConfiguration/DatabaseTreeDataProvider.php b/Classes/Tree/TableConfiguration/DatabaseTreeDataProvider.php new file mode 100644 index 0000000..a9fdc48 --- /dev/null +++ b/Classes/Tree/TableConfiguration/DatabaseTreeDataProvider.php @@ -0,0 +1,477 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tree\TableConfiguration; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Backend\Tree\SortedTreeNodeCollection; +use TYPO3\CMS\Backend\Tree\TreeNode; +use TYPO3\CMS\Backend\Tree\TreeNodeCollection; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; +use TYPO3\CMS\Core\Database\RelationHandler; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Schema\TcaSchema; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * TCA tree data provider + */ +#[Autoconfigure(public: true, shared: false)] +class DatabaseTreeDataProvider extends AbstractTableConfigurationTreeDataProvider +{ + public const MODE_CHILDREN = 1; + public const MODE_PARENT = 2; + + protected string $tableName = ''; + protected ?TcaSchema $schema = null; + + /** + * @var string + */ + protected $treeId = ''; + + protected string $labelField = ''; + + protected string $tableWhere = ''; + + /** + * @var self::MODE_* + */ + protected int $lookupMode = self::MODE_CHILDREN; + + protected string $lookupField = ''; + + protected array $idCache = []; + + /** + * Stores TCA-Configuration of the LookUpField in tableName + * + * @var array<string, mixed> + */ + protected array $columnConfiguration; + + /** + * node sort values (the orderings from foreign_Table_where evaluation) + * + * @var array<string, mixed> + */ + protected array $nodeSortValues = []; + + public function __construct(protected EventDispatcherInterface $eventDispatcher) {} + + /** + * Sets the label field + */ + public function setLabelField(string $labelField): void + { + $this->labelField = $labelField; + } + + /** + * Gets the label field + */ + public function getLabelField(): string + { + return $this->labelField; + } + + /** + * Sets the table name + */ + public function setTableName(string $tableName): void + { + $this->tableName = $tableName; + } + + /** + * Gets the table name + */ + public function getTableName(): string + { + return $this->tableName; + } + + /** + * Sets the lookup field + */ + public function setLookupField(string $lookupField): void + { + $this->lookupField = $lookupField; + } + + /** + * Gets the lookup field + */ + public function getLookupField(): string + { + return $this->lookupField; + } + + /** + * Sets the lookup mode + * + * @param self::MODE_* $lookupMode + */ + public function setLookupMode(int $lookupMode): void + { + $this->lookupMode = $lookupMode; + } + + /** + * Gets the lookup mode + * + * @return self::MODE_* + */ + public function getLookupMode(): int + { + return $this->lookupMode; + } + + /** + * Gets the nodes + */ + public function getNodes(TreeNode $node): void {} + + /** + * Gets the root node + */ + public function getRoot(): DatabaseTreeNode + { + return $this->buildRepresentationForNode($this->treeData); + } + + /** + * Sets the tableWhere clause + */ + public function setTableWhere(string $tableWhere): void + { + $this->tableWhere = $tableWhere; + } + + /** + * Gets the tableWhere clause + */ + public function getTableWhere(): string + { + return $this->tableWhere; + } + + /** + * Builds a complete node including children + */ + protected function buildRepresentationForNode(TreeNode $basicNode, ?DatabaseTreeNode $parent = null, $level = 0): DatabaseTreeNode + { + $node = GeneralUtility::makeInstance(DatabaseTreeNode::class); + $row = []; + if ($basicNode->getId() == 0) { + $node->setSelected(false); + $node->setLabel($this->schema?->getTitle($this->getLanguageService()->sL(...))); + } else { + if ($basicNode->getAdditionalData() === []) { + $row = BackendUtility::getRecordWSOL($this->tableName, (int)$basicNode->getId(), '*', '', false) ?? []; + } else { + // @todo: This is part of the category tree performance hack + $row = $basicNode->getAdditionalData(); + } + $node->setLabel(BackendUtility::getRecordTitle($this->tableName, $row) ?: $basicNode->getId()); + $node->setSelected(GeneralUtility::inList($this->getSelectedList(), $basicNode->getId())); + } + $node->setId($basicNode->getId()); + $node->setSelectable(!GeneralUtility::inList($this->getNonSelectableLevelList(), (string)$level) && !in_array($basicNode->getId(), $this->getItemUnselectableList())); + $node->setSortValue($this->nodeSortValues[$basicNode->getId()] ?? ''); + $iconFactory = GeneralUtility::makeInstance(IconFactory::class); + $node->setIcon($iconFactory->getIconForRecord($this->tableName, $row, IconSize::SMALL)); + $node->setParentNode($parent); + if ($basicNode->hasChildNodes()) { + $node->setHasChildren(true); + $childNodes = GeneralUtility::makeInstance(SortedTreeNodeCollection::class); + $tempNodes = []; + foreach ($basicNode->getChildNodes() as $child) { + $tempNodes[] = $this->buildRepresentationForNode($child, $node, $level + 1); + } + $childNodes->exchangeArray($tempNodes); + $childNodes->asort(); + $node->setChildNodes($childNodes); + } + return $node; + } + + /** + * Init the tree data + */ + public function initializeTreeData(): void + { + $this->schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->getTableName()); + $this->nodeSortValues = array_flip($this->itemWhiteList); + if ($this->schema->hasField($this->lookupField)) { + $this->columnConfiguration = $this->schema->getField($this->lookupField)->getConfiguration(); + } else { + // Use-case here is lookupField = "pid" + $this->columnConfiguration = []; + } + if (isset($this->columnConfiguration['foreign_table']) && $this->columnConfiguration['foreign_table'] !== $this->getTableName()) { + throw new \InvalidArgumentException('TCA Tree configuration is invalid: tree for different node-Tables is not implemented yet', 1290944650); + } + $this->treeData = GeneralUtility::makeInstance(TreeNode::class); + $this->loadTreeData(); + $event = $this->eventDispatcher->dispatch(new ModifyTreeDataEvent($this->treeData, $this)); + $this->treeData = $event->getTreeData(); + } + + /** + * Loads the tree data (all possible children) + */ + protected function loadTreeData(): void + { + if ($this->getStartingPoints()) { + $startingPoints = $this->getStartingPoints(); + } else { + $startingPoints = [0]; + } + + if (count($startingPoints) === 1) { + // Only one starting point is available, grab it and set it as root node + $startingPoint = current($startingPoints); + $this->treeData->setId((string)$startingPoint); + $this->treeData->setParentNode(null); + + if ($this->levelMaximum >= 1) { + $childNodes = $this->getChildrenOf($this->treeData, 1); + if ($childNodes !== null) { + $this->treeData->setChildNodes($childNodes); + } + } + } else { + // The current tree implementation disallows multiple elements on root level, thus we have to work around + // this with a separate TreeNodeCollection that gets attached to the root node with uid 0. This has the + // nasty side effect we cannot avoid the root node being rendered. + + $treeNodeCollection = GeneralUtility::makeInstance(TreeNodeCollection::class); + foreach ($startingPoints as $startingPoint) { + $treeData = GeneralUtility::makeInstance(TreeNode::class); + $treeData->setId((string)$startingPoint); + + if ($this->levelMaximum >= 1) { + $childNodes = $this->getChildrenOf($treeData, 1); + if ($childNodes !== null) { + $treeData->setChildNodes($childNodes); + } + } + $treeNodeCollection->append($treeData); + } + $this->treeData->setId('0'); + $this->treeData->setChildNodes($treeNodeCollection); + } + } + + /** + * Gets node children + */ + protected function getChildrenOf(TreeNode $node, int $level): ?TreeNodeCollection + { + $nodeData = null; + if ($node->getId() !== 0 && $node->getId() !== '0') { + if (is_array($this->availableItems[(int)$node->getId()] ?? false)) { + // @todo: This is part of the category tree performance hack + $nodeData = $this->availableItems[(int)$node->getId()]; + } else { + $nodeData = BackendUtility::getRecord($this->tableName, $node->getId(), '*', '', false); + } + } + if (empty($nodeData)) { + $nodeData = [ + 'uid' => 0, + $this->lookupField => '', + ]; + } + $storage = null; + $children = $this->getRelatedRecords($nodeData); + if (!empty($children)) { + $storage = GeneralUtility::makeInstance(TreeNodeCollection::class); + foreach ($children as $child) { + $node = GeneralUtility::makeInstance(TreeNode::class, $this->availableItems[(int)$child] ?? []); + $node->setId($child); + if ($level < $this->levelMaximum) { + $children = $this->getChildrenOf($node, $level + 1); + if ($children !== null) { + $node->setChildNodes($children); + } + } + $storage->append($node); + } + } + return $storage; + } + + /** + * Gets related records depending on TCA configuration + */ + protected function getRelatedRecords(array $row): array + { + if ($this->getLookupMode() === self::MODE_PARENT) { + $children = $this->getChildrenUidsFromParentRelation($row); + } else { + $children = $this->getChildrenUidsFromChildrenRelation($row); + } + $allowedArray = []; + foreach ($children as $child) { + if (!in_array($child, $this->idCache, true) && in_array($child, $this->itemWhiteList, true)) { + $allowedArray[] = $child; + } + } + $this->idCache = array_merge($this->idCache, $allowedArray); + return $allowedArray; + } + + /** + * Gets related records depending on TCA configuration + */ + protected function getChildrenUidsFromParentRelation(array $row): array + { + $uid = (int)$row['uid']; + if (in_array($this->columnConfiguration['type'] ?? '', ['select', 'category', 'inline', 'file'], true)) { + if ($this->columnConfiguration['MM'] ?? null) { + $dbGroup = GeneralUtility::makeInstance(RelationHandler::class); + // Dummy field for setting "look from other site" + $this->columnConfiguration['MM_opposite_field'] = 'children'; + $dbGroup->start($row[$this->lookupField], $this->getTableName(), $this->columnConfiguration['MM'], $uid, $this->getTableName(), $this->columnConfiguration); + $relatedUids = $dbGroup->tableArray[$this->getTableName()]; + } elseif ($this->columnConfiguration['foreign_field'] ?? null) { + $relatedUids = $this->listFieldQuery($this->columnConfiguration['foreign_field'], $uid); + } else { + // Check available items + if ($this->availableItems !== [] && $this->columnConfiguration['type'] === 'category') { + // @todo: This is part of the category tree performance hack + $relatedUids = []; + foreach ($this->availableItems as $item) { + if ($item[$this->lookupField] === $uid) { + $relatedUids[$item['uid']] = $item['sorting']; + } + } + if ($relatedUids !== []) { + // Ensure sorting is kept + asort($relatedUids); + $relatedUids = array_keys($relatedUids); + } + } else { + $relatedUids = $this->listFieldQuery($this->lookupField, $uid); + } + } + } else { + $relatedUids = $this->listFieldQuery($this->lookupField, $uid); + } + + return $relatedUids; + } + + /** + * Gets related children records depending on TCA configuration + */ + protected function getChildrenUidsFromChildrenRelation(array $row): array + { + $relatedUids = []; + $uid = (int)$row['uid']; + $value = (string)$row[$this->lookupField]; + switch ((string)$this->columnConfiguration['type']) { + case 'inline': + case 'file': + // Intentional fall-through + case 'select': + case 'category': + if ($this->columnConfiguration['MM'] ?? false) { + $dbGroup = GeneralUtility::makeInstance(RelationHandler::class); + $dbGroup->start( + $value, + $this->getTableName(), + $this->columnConfiguration['MM'], + $uid, + $this->getTableName(), + $this->columnConfiguration + ); + $relatedUids = $dbGroup->tableArray[$this->getTableName()]; + } elseif ($this->columnConfiguration['foreign_field'] ?? false) { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->getTableName()); + $queryBuilder->getRestrictions()->removeAll(); + $records = $queryBuilder->select('uid') + ->from($this->getTableName()) + ->where( + $queryBuilder->expr()->eq( + $this->columnConfiguration['foreign_field'], + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + + if (!empty($records)) { + $relatedUids = array_column($records, 'uid'); + } + } else { + $relatedUids = GeneralUtility::intExplode(',', $value, true); + } + break; + default: + $relatedUids = GeneralUtility::intExplode(',', $value, true); + } + return $relatedUids; + } + + /** + * Queries the table for a field which might contain a list. + * + * @param string $fieldName the name of the field to be queried + * @param int $queryId the uid to search for + * @return int[] all uids found + */ + protected function listFieldQuery(string $fieldName, int $queryId): array + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($this->getTableName()); + $queryBuilder->getRestrictions()->removeAll(); + + $queryBuilder->select('uid') + ->from($this->getTableName()) + ->where($queryBuilder->expr()->inSet($fieldName, $queryBuilder->quote((string)$queryId))); + + if ($queryId === 0) { + $queryBuilder->orWhere( + $queryBuilder->expr()->comparison( + 'CAST(' . $queryBuilder->quoteIdentifier($fieldName) . ' AS CHAR)', + ExpressionBuilder::EQ, + $queryBuilder->quote('') + ) + ); + } + + $records = $queryBuilder->executeQuery()->fetchAllAssociative(); + return array_column($records, 'uid'); + } + + protected function getLanguageService(): ?LanguageService + { + return $GLOBALS['LANG'] ?? null; + } +} diff --git a/Classes/Tree/TableConfiguration/DatabaseTreeNode.php b/Classes/Tree/TableConfiguration/DatabaseTreeNode.php new file mode 100644 index 0000000..8aaba13 --- /dev/null +++ b/Classes/Tree/TableConfiguration/DatabaseTreeNode.php @@ -0,0 +1,146 @@ +<?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\Core\Tree\TableConfiguration; + +use TYPO3\CMS\Backend\Tree\TreeRepresentationNode; + +/** + * Represents a node in a TCA database setup + */ +class DatabaseTreeNode extends TreeRepresentationNode +{ + /** + * @var bool + */ + protected $selectable; + + /** + * @var bool + */ + protected $selected = false; + + /** + * @var bool + */ + protected $hasChildren = false; + + /** + * @var mixed + */ + private $sortValue; + + /** + * Sets the selectable property + * + * @param bool $selectable + */ + public function setSelectable($selectable) + { + $this->selectable = $selectable; + } + + /** + * Gets the selectable property + * + * @return bool + */ + public function getSelectable() + { + return $this->selectable; + } + + /** + * Sets the select state + * + * @param bool $selected + */ + public function setSelected($selected) + { + $this->selected = $selected; + } + + /** + * Gets the select state + * + * @return bool + */ + public function getSelected() + { + return $this->selected; + } + + /** + * Gets the hasChildren property + * + * @return bool + */ + public function hasChildren() + { + return $this->hasChildren; + } + + /** + * Sets the hasChildren property + * + * @param bool $value + */ + public function setHasChildren($value) + { + $this->hasChildren = (bool)$value; + } + + /** + * Compares a node to another one. + * + * Returns: + * 1 if its greater than the other one + * -1 if its smaller than the other one + * 0 if its equal + * + * @param \TYPO3\CMS\Backend\Tree\TreeNode $other + * @return int see description above + */ + public function compareTo($other) + { + if ($this->equals($other)) { + return 0; + } + if ($other instanceof self) { + return $this->sortValue > $other->getSortValue() ? 1 : -1; + } + return parent::compareTo($other); + } + + /** + * Gets the sort value + * + * @return mixed + */ + public function getSortValue() + { + return $this->sortValue; + } + + /** + * Sets the sort value + * + * @param mixed $sortValue + */ + public function setSortValue($sortValue) + { + $this->sortValue = $sortValue; + } +} diff --git a/Classes/Tree/TableConfiguration/TableConfigurationTree.php b/Classes/Tree/TableConfiguration/TableConfigurationTree.php new file mode 100644 index 0000000..6215c9a --- /dev/null +++ b/Classes/Tree/TableConfiguration/TableConfigurationTree.php @@ -0,0 +1,44 @@ +<?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\Core\Tree\TableConfiguration; + +use TYPO3\CMS\Backend\Tree\AbstractTree; + +/** + * Class for tca tree + */ +class TableConfigurationTree extends AbstractTree +{ + /** + * Returns the root node + * + * @return \TYPO3\CMS\Backend\Tree\TreeNode + */ + public function getRoot() + { + return $this->dataProvider->getRoot(); + } + + /** + * Renders a tree + * + * @return mixed + */ + public function render() + { + return $this->nodeRenderer->renderTree($this); + } +} diff --git a/Classes/Tree/TableConfiguration/TreeDataProviderFactory.php b/Classes/Tree/TableConfiguration/TreeDataProviderFactory.php new file mode 100644 index 0000000..c82a46d --- /dev/null +++ b/Classes/Tree/TableConfiguration/TreeDataProviderFactory.php @@ -0,0 +1,121 @@ +<?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\Core\Tree\TableConfiguration; + +use Psr\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Builds a \TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider + * object based on some TCA configuration + */ +class TreeDataProviderFactory +{ + /** + * Gets the data provider, depending on TCA configuration + * + * @param array $currentValue The current database row, handing over 'uid' is enough + * @return DatabaseTreeDataProvider + */ + public static function getDataProvider(array $tcaConfiguration, string $table, string $field, array $currentValue) + { + $dataProvider = null; + if (!isset($tcaConfiguration['treeConfig']) || !is_array($tcaConfiguration['treeConfig'])) { + throw new \InvalidArgumentException('TCA Tree configuration is invalid: "treeConfig" array is missing', 1288215890); + } + + if (!empty($tcaConfiguration['treeConfig']['dataProvider'])) { + // This is a hack since TYPO3 v10 we use this to inject the EventDispatcher in the first argument + // For TYPO3 Core, but this is only possible if the dataProvider is extending from the DatabaseTreeDataProvider + // but did NOT use a custom constructor. This way, the original constructor receives the EventDispatcher properly + // as first argument. It is encouraged to use a custom constructor that also receives the EventDispatcher + // separately. + $reflectionClass = new \ReflectionClass($tcaConfiguration['treeConfig']['dataProvider']); + if ($reflectionClass->getConstructor()->getDeclaringClass()->getName() === DatabaseTreeDataProvider::class) { + $dataProvider = GeneralUtility::makeInstance( + $tcaConfiguration['treeConfig']['dataProvider'], + GeneralUtility::makeInstance(EventDispatcherInterface::class) + ); + } else { + $dataProvider = GeneralUtility::makeInstance( + $tcaConfiguration['treeConfig']['dataProvider'], + $tcaConfiguration, + $table, + $field, + $currentValue, + GeneralUtility::makeInstance(EventDispatcherInterface::class) + ); + } + } + if (($tcaConfiguration['type'] ?? '') !== 'folder') { + if ($dataProvider === null) { + $dataProvider = GeneralUtility::makeInstance(DatabaseTreeDataProvider::class); + } + if (isset($tcaConfiguration['foreign_table'])) { + $tableName = $tcaConfiguration['foreign_table']; + $dataProvider->setTableName($tableName); + if ($tableName == $table) { + // The uid of the currently opened row cannot be selected in a table relation to "self" + $unselectableUids = [$currentValue['uid']]; + $dataProvider->setItemUnselectableList($unselectableUids); + } + } else { + throw new \InvalidArgumentException('TCA Tree configuration is invalid: "foreign_table" not set', 1288215888); + } + if (isset($tcaConfiguration['foreign_label'])) { + $dataProvider->setLabelField($tcaConfiguration['foreign_label']); + } else { + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + if ($schemaFactory->has($tableName)) { + $labelField = $schemaFactory->get($tableName)->getCapability(TcaSchemaCapability::Label); + $dataProvider->setLabelField($labelField->getPrimaryFieldName() ?? ''); + } + } + $dataProvider->setTreeId(md5($table . '|' . $field)); + + $treeConfiguration = $tcaConfiguration['treeConfig']; + if (isset($treeConfiguration['startingPoints'])) { + $dataProvider->setStartingPoints(array_unique(GeneralUtility::intExplode(',', (string)$treeConfiguration['startingPoints']))); + } + if (isset($treeConfiguration['appearance']['expandAll'])) { + $dataProvider->setExpandAll((bool)$treeConfiguration['appearance']['expandAll']); + } + if (isset($treeConfiguration['appearance']['maxLevels'])) { + $dataProvider->setLevelMaximum((int)$treeConfiguration['appearance']['maxLevels']); + } + if (isset($treeConfiguration['appearance']['nonSelectableLevels'])) { + $dataProvider->setNonSelectableLevelList($treeConfiguration['appearance']['nonSelectableLevels']); + } elseif (isset($treeConfiguration['startingPoints'])) { + // If there are more than 1 starting points, disable the first level. See description in DatabaseTreeProvider::loadTreeData() + $dataProvider->setNonSelectableLevelList(substr_count($treeConfiguration['startingPoints'], ',') > 0 ? '0' : ''); + } + if (isset($treeConfiguration['childrenField'])) { + $dataProvider->setLookupMode(DatabaseTreeDataProvider::MODE_CHILDREN); + $dataProvider->setLookupField($treeConfiguration['childrenField']); + } elseif (isset($treeConfiguration['parentField'])) { + $dataProvider->setLookupMode(DatabaseTreeDataProvider::MODE_PARENT); + $dataProvider->setLookupField($treeConfiguration['parentField']); + } else { + throw new \InvalidArgumentException('TCA Tree configuration is invalid: neither "childrenField" nor "parentField" is set', 1288215889); + } + } elseif ($dataProvider === null) { + throw new \InvalidArgumentException('TCA Tree configuration is invalid: tree for "type=folder" not implemented yet', 1288215892); + } + return $dataProvider; + } +} diff --git a/Classes/Type/BitSet.php b/Classes/Type/BitSet.php new file mode 100644 index 0000000..2b757d2 --- /dev/null +++ b/Classes/Type/BitSet.php @@ -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\Core\Type; + +/** + * The BitSet class is a helper class to manage bit sets. It eases the work with bits and bitwise + * operations by providing a reliable and tested API. + * + * The class can be used standalone or as a parent for more verbose classes that handle bit sets. + * + * + * The functionality is best described by an example: + * + * ``` + * define('PERMISSIONS_NONE', 0b0); // 0 + * define('PERMISSIONS_PAGE_SHOW', 0b1); // 1 + * define('PERMISSIONS_PAGE_EDIT', 0b10); // 2 + * define('PERMISSIONS_PAGE_DELETE', 0b100); // 4 + * + * $bitSet = new \TYPO3\CMS\Core\Type\BitSet(PERMISSIONS_PAGE_SHOW | PERMISSIONS_PAGE_EDIT); + * $bitSet->get(PERMISSIONS_PAGE_SHOW); // true + * $bitSet->get(PERMISSIONS_PAGE_DELETE); // false + * ``` + * + * Another example shows how to possibly extend the class: + * + * ``` + * class Permissions extends \TYPO3\CMS\Core\Type\BitSet + * { + * public const NONE = 0b0; // 0 + * public const PAGE_SHOW = 0b1; // 1 + * + * public function isGranted(int $permission): bool + * { + * return $this->get($permission); + * } + * + * public function grant(int $permission): void + * { + * $this->set($permission); + * } + * } + * + * $permissions = new Permissions(); + * $permissions->isGranted(Permissions::PAGE_SHOW); // false + * $permissions->grant(Permissions::PAGE_SHOW); + * $permissions->isGranted(Permissions::PAGE_SHOW); // true + * ``` + */ +class BitSet +{ + /** + * @var int + */ + protected $set; + + public function __construct(int $set = 0) + { + $this->set = $set; + } + + /** + * Performs the same operation as {@see or()} without the need to create a BitSet instance from + * an integer value. + */ + public function set(int $bitIndex): void + { + $this->set |= $bitIndex; + } + + public function setValue(int $bitIndex, bool $value): void + { + if ($value) { + $this->set($bitIndex); + } else { + $this->unset($bitIndex); + } + } + + /** + * Performs the same operation as {@see andNot()} without the need to create a BitSet instance from + * an integer value. + */ + public function unset(int $bitIndex): void + { + $this->set &= ~$bitIndex; + } + + public function get(int $bitIndex): bool + { + return ($bitIndex & $this->set) === $bitIndex; + } + + /** + * Sets all of the bits in this BitSet to false. + */ + public function clear(): void + { + $this->set = 0; + } + + /** + * Performs a logical AND of this target bit set with the argument bit set. This bit set is + * modified so that each bit in it has the value true if and only if it both initially had the + * value true and the corresponding bit in the bit set argument also had the value true. + */ + public function and(BitSet $set): void + { + $this->set &= $set->__toInt(); + } + + /** + * Performs a logical OR of this bit set with the bit set argument. This bit set is modified so + * that a bit in it has the value true if and only if it either already had the value true or + * the corresponding bit in the bit set argument has the value true. + */ + public function or(BitSet $set): void + { + $this->set |= $set->__toInt(); + } + + /** + * Performs a logical XOR of this bit set with the bit set argument. This bit set is modified so + * that a bit in it has the value true if and only if one of the following statements holds: + * + * - The bit initially has the value true, and the corresponding bit + * in the argument has the value false. + * - The bit initially has the value false, and the corresponding bit + * in the argument has the value true. + * + * @param BitSet $set + */ + public function xor(BitSet $set): void + { + $this->set ^= $set->__toInt(); + } + + /** + * Clears all of the bits in this BitSet whose corresponding bit is set in the specified BitSet. + */ + public function andNot(BitSet $set): void + { + $this->set &= ~$set->__toInt(); + } + + /** + * Returns the integer representation of the internal set. + * (As PHP does not know a byte type, the internal set is already handled as an integer and can + * therefore directly be returned) + */ + public function __toInt(): int + { + return $this->set; + } + + /** + * Returns the (binary) string representation of the internal (integer) set. + */ + public function __toString(): string + { + return '0b' . decbin($this->set); + } +} diff --git a/Classes/Type/Bitmask/BackendGroupMountOption.php b/Classes/Type/Bitmask/BackendGroupMountOption.php new file mode 100644 index 0000000..01b8497 --- /dev/null +++ b/Classes/Type/Bitmask/BackendGroupMountOption.php @@ -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\Core\Type\Bitmask; + +use TYPO3\CMS\Core\Type\BitSet; + +/** + * A class providing constants for bitwise operations on whether backend users + * should add / inherit the Page Tree Entry Points / File Mounts from + */ +final class BackendGroupMountOption extends BitSet +{ + private const int INCLUDE_PAGE_MOUNTS = 1; + private const int INCLUDE_FILE_MOUNTS = 2; + + public function shouldUserIncludePageMountsFromAssociatedGroups(): bool + { + return $this->get(self::INCLUDE_PAGE_MOUNTS); + } + public function shouldUserIncludeFileMountsFromAssociatedGroups(): bool + { + return $this->get(self::INCLUDE_FILE_MOUNTS); + } +} diff --git a/Classes/Type/Bitmask/PageTranslationVisibility.php b/Classes/Type/Bitmask/PageTranslationVisibility.php new file mode 100644 index 0000000..9678188 --- /dev/null +++ b/Classes/Type/Bitmask/PageTranslationVisibility.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type\Bitmask; + +use TYPO3\CMS\Core\Type\BitSet; + +/** + * A class providing constants for bitwise operations on page translation handling + * from $GLOBALS[TYPO3_CONF_VARS][FE][hidePagesIfNotTranslatedByDefault] and pages.l18n_cfg + * + * Side note: The DB field pages.l18n_cfg (bitmask) has l10n_mode=exclude meaning that it + * can only be set on the default language and is automatically mirrored to all translated pages. + */ +final class PageTranslationVisibility extends BitSet +{ + private const int HIDE_DEFAULT_LANGUAGE = 1; + private const int HIDE_TRANSLATION_IF_NO_TRANSLATED_RECORD_EXISTS = 2; + + /** + * Due to the nature of the pages table, where you always have to have a page in the default + * language, sometimes a page should be hidden in the default language (e.g. english), but + * only visible in the created and available language=6 (e.g. polish). + * This can be configured via pages.l18n_cfg=1 on a per-page basis. + * + * @return bool whether the page has the flag set + */ + public function shouldBeHiddenInDefaultLanguage(): bool + { + return $this->get(self::HIDE_DEFAULT_LANGUAGE); + } + + /** + * Response on input location setting value whether the + * page should be hidden if no translation exists. + * + * Imagine this: + * - You link to page 23 in language=5 (e.g. italian) + * - The page was never translated to language=5 (no pages record with sys_language_uid=5 created) + * => Should the fallback kick in or not? + * + * The answer depends on your use-case (e.g. fallback of italian to english etc) and can + * be tuned via the global configuration option and the pages.l18n_cfg=2 flag. + * + * @return bool true if the page should be hidden + */ + public function shouldHideTranslationIfNoTranslatedRecordExists(): bool + { + return $GLOBALS['TYPO3_CONF_VARS']['FE']['hidePagesIfNotTranslatedByDefault'] xor ($this->get(self::HIDE_TRANSLATION_IF_NO_TRANSLATED_RECORD_EXISTS)); + } +} diff --git a/Classes/Type/Bitmask/Permission.php b/Classes/Type/Bitmask/Permission.php new file mode 100644 index 0000000..7655062 --- /dev/null +++ b/Classes/Type/Bitmask/Permission.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type\Bitmask; + +use TYPO3\CMS\Core\Type\BitSet; + +/** + * A class providing constants for bitwise operations on page access check + */ +final class Permission extends BitSet +{ + public const int NOTHING = 0; + + public const int PAGE_SHOW = 1; + + public const int PAGE_EDIT = 2; + + public const int PAGE_DELETE = 4; + + public const int PAGE_NEW = 8; + + public const int CONTENT_EDIT = 16; + + public const int ALL = 31; + + /** + * Permission mapping + * Used for instance in PageTS + * + * @internal + */ + public static function getMap(): array + { + return [ + 'show' => static::PAGE_SHOW, + // 1st bit + 'edit' => static::PAGE_EDIT, + // 2nd bit + 'delete' => static::PAGE_DELETE, + // 3rd bit + 'new' => static::PAGE_NEW, + // 4th bit + 'editcontent' => static::CONTENT_EDIT, + ]; + } + + public function isGranted(int $permission): bool + { + return $this->get($permission); + } + + public function nothingIsGranted(): bool + { + return $this->set === self::NOTHING; + } + + public function showPagePermissionIsGranted(): bool + { + return $this->get(self::PAGE_SHOW); + } + + public function editPagePermissionIsGranted(): bool + { + return $this->get(self::PAGE_EDIT); + } + + public function createPagePermissionIsGranted(): bool + { + return $this->get(self::PAGE_NEW); + } + + public function deletePagePermissionIsGranted(): bool + { + return $this->get(self::PAGE_DELETE); + } + + public function editContentPermissionIsGranted(): bool + { + return $this->get(self::CONTENT_EDIT); + } +} diff --git a/Classes/Type/ContextualFeedbackSeverity.php b/Classes/Type/ContextualFeedbackSeverity.php new file mode 100644 index 0000000..b7a02c7 --- /dev/null +++ b/Classes/Type/ContextualFeedbackSeverity.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type; + +/** + * Enum that contains message severities. It is backed by integer values to keep backwards compatibility to the previous + * AbstractMessage constants. + */ +enum ContextualFeedbackSeverity: int +{ + case NOTICE = -2; + case INFO = -1; + case OK = 0; + case WARNING = 1; + case ERROR = 2; + + /** + * @return non-empty-string + */ + public function getCssClass(): string + { + return match ($this) { + self::NOTICE => 'notice', + self::INFO => 'info', + self::OK => 'success', + self::WARNING => 'warning', + self::ERROR => 'danger', + }; + } + + /** + * @return non-empty-string + */ + public function getIconIdentifier(): string + { + return match ($this) { + self::NOTICE => 'actions-lightbulb', + self::INFO => 'actions-info', + self::OK => 'actions-check', + self::WARNING => 'actions-exclamation', + self::ERROR => 'actions-close', + }; + } +} diff --git a/Classes/Type/DocType.php b/Classes/Type/DocType.php new file mode 100644 index 0000000..78b12f4 --- /dev/null +++ b/Classes/Type/DocType.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Enum defining all kinds of values needed to decide on how render HTML or XML-compliant code + * parts of the Page Rendering. + * + * Set to HTML5 by default in Frontend and Backend. + */ +enum DocType +{ + case html5; + // XHTML 1.0 Strict doctype + case xhtmlStrict; + // XHTML 1.1 doctype + case xhtml11; + // XHTML 1.0 Transitional doctype + case xhtmlTransitional; + // XHTML basic doctype + case xhtmlBasic; + // XHTML+RDFa 1.0 doctype + case xhtmlRdfa10; + case none; + + /** + * @return bool true if the specified doctype requires XML Compliance (needed for e.g. self-closing tags, + * or for the xml:ns attribute). + */ + public function isXmlCompliant(): bool + { + return match ($this) { + self::xhtmlRdfa10, self::xhtml11, self::xhtmlStrict, self::xhtmlBasic, self::xhtmlTransitional => true, + default => false, + }; + } + + public function getDoctypeDeclaration(): string + { + return match ($this) { + self::xhtmlTransitional => '<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">', + self::xhtmlStrict => '<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">', + self::xhtmlBasic => '<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML Basic 1.0//EN" + "http://www.w3.org/TR/xhtml-basic/xhtml-basic10.dtd">', + self::xhtml11 => '<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.1//EN" + "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">', + self::xhtmlRdfa10 => '<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN" + "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">', + self::html5 => '<!DOCTYPE html>', + default => '' + }; + } + + public function getXmlPrologue(): string + { + if ($this->getXhtmlVersion() === 110) { + return '<?xml version="1.1" encoding="utf-8"?>'; + } + if ($this->getXhtmlVersion()) { + return '<?xml version="1.0" encoding="utf-8"?>'; + } + return ''; + } + + private function getXhtmlVersion(): ?int + { + return match ($this) { + self::xhtmlTransitional, self::xhtmlStrict => 100, + self::xhtmlBasic => 105, + self::xhtml11, self::xhtmlRdfa10 => 110, + default => null + }; + } + + public function getMetaCharsetTag(): string + { + if ($this->isXmlCompliant()) { + return '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />'; + } + if ($this === DocType::html5) { + // see https://www.w3.org/International/questions/qa-html-encoding-declarations.en.html + return '<meta charset="utf-8">'; + } + return '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">'; + } + + /** + * HTML5 deprecated the "frameborder" attribute as everything should be done via styling. + */ + public function shouldIncludeFrameBorderAttribute(): bool + { + return $this !== self::html5; + } + + public static function createFromConfigurationKey(?string $key): self + { + return match ($key) { + // config.doctype options + 'xhtml_trans' => self::xhtmlTransitional, + 'xhtml_strict' => self::xhtmlStrict, + 'xhtml_basic' => self::xhtmlBasic, + 'xhtml_11' => self::xhtml11, + 'xhtml+rdfa_10' => self::xhtmlRdfa10, + 'html5' => self::html5, + 'none' => self::none, + default => self::html5, + }; + } + + public static function createFromRequest(?ServerRequestInterface $request): DocType + { + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray(); + return DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? null); + } +} diff --git a/Classes/Type/Exception.php b/Classes/Type/Exception.php new file mode 100644 index 0000000..0f19642 --- /dev/null +++ b/Classes/Type/Exception.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type; + +/** + * A generic Type exception + */ +class Exception extends \TYPO3\CMS\Core\Exception {} diff --git a/Classes/Type/Exception/InvalidEnumerationDefinitionException.php b/Classes/Type/Exception/InvalidEnumerationDefinitionException.php new file mode 100644 index 0000000..b56cb19 --- /dev/null +++ b/Classes/Type/Exception/InvalidEnumerationDefinitionException.php @@ -0,0 +1,23 @@ +<?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\Core\Type\Exception; + +use TYPO3\CMS\Core\Type\Exception; + +/** + * Exception for an invalid enumeration definition + */ +class InvalidEnumerationDefinitionException extends Exception {} diff --git a/Classes/Type/Exception/InvalidEnumerationValueException.php b/Classes/Type/Exception/InvalidEnumerationValueException.php new file mode 100644 index 0000000..df5d746 --- /dev/null +++ b/Classes/Type/Exception/InvalidEnumerationValueException.php @@ -0,0 +1,23 @@ +<?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\Core\Type\Exception; + +use TYPO3\CMS\Core\Type\Exception; + +/** + * Exception for an invalid enumeration value + */ +class InvalidEnumerationValueException extends Exception implements InvalidValueExceptionInterface {} diff --git a/Classes/Type/Exception/InvalidValueExceptionInterface.php b/Classes/Type/Exception/InvalidValueExceptionInterface.php new file mode 100644 index 0000000..89c61e8 --- /dev/null +++ b/Classes/Type/Exception/InvalidValueExceptionInterface.php @@ -0,0 +1,21 @@ +<?php + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Type\Exception; + +/** + * Interface for Invalid value exception + */ +interface InvalidValueExceptionInterface {} diff --git a/Classes/Type/File/FileInfo.php b/Classes/Type/File/FileInfo.php new file mode 100644 index 0000000..6758ea0 --- /dev/null +++ b/Classes/Type/File/FileInfo.php @@ -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\Core\Type\File; + +use TYPO3\CMS\Core\Type\TypeInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A SPL FileInfo class providing general information related to a file. + */ +class FileInfo extends \SplFileInfo implements TypeInterface +{ + /** + * Return the mime type of a file. + * + * TYPO3 specific settings in $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'] take + * precedence over native resolving. + * + * @param string $targetFileName supplied during uploads (where the concrete file is a temporary extension-less file) + * and may be used by mimeTypeGuessers to match based on desired file name + * @return string|false Returns the mime type or FALSE if the mime type could not be discovered + */ + public function getMimeType(string $targetFileName = '') + { + $mimeType = false; + if ($this->isFile()) { + $fileExtensionToMimeTypeMapping = $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType']; + $lowercaseFileExtension = strtolower($this->getExtension()); + if (!empty($fileExtensionToMimeTypeMapping[$lowercaseFileExtension])) { + $mimeType = $fileExtensionToMimeTypeMapping[$lowercaseFileExtension]; + } else { + if (function_exists('finfo_file')) { + $fileInfo = new \finfo(); + $mimeType = $fileInfo->file($this->getPathname(), FILEINFO_MIME_TYPE); + } elseif (function_exists('mime_content_type')) { + $mimeType = mime_content_type($this->getPathname()); + } + } + } + + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][FileInfo::class]['mimeTypeGuessers'] ?? [] as $mimeTypeGuesser) { + $hookParameters = [ + 'mimeType' => &$mimeType, + 'targetFileName' => $targetFileName === '' ? null : $targetFileName, + ]; + + GeneralUtility::callUserFunction( + $mimeTypeGuesser, + $hookParameters, + $this + ); + } + + return $mimeType; + } + + /** + * Returns the file extensions appropriate for a the MIME type detected in the file. For types that commonly have + * multiple file extensions, such as JPEG images, then the return value is multiple extensions, for instance that + * could be ['jpeg', 'jpg', 'jpe', 'jiff']. For unknown types not available in the magic.mime database + * (/etc/magic.mime, /etc/mime.types, ...), then return value is an empty array. + * + * TYPO3 specific settings in $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'] take + * precedence over native resolving. + * + * @return string[] + */ + public function getMimeExtensions(): array + { + $mimeExtensions = []; + if ($this->isFile()) { + $fileExtensionToMimeTypeMapping = $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType']; + $mimeType = $this->getMimeType(); + if (in_array($mimeType, $fileExtensionToMimeTypeMapping, true)) { + $mimeExtensions = array_keys($fileExtensionToMimeTypeMapping, $mimeType, true); + } elseif (function_exists('finfo_file')) { + $fileInfo = new \finfo(); + $mimeExtensions = array_filter( + GeneralUtility::trimExplode( + '/', + (string)$fileInfo->file($this->getPathname(), FILEINFO_EXTENSION) + ), + static function ($item) { + // filter invalid items ('???' is used if not found in magic.mime database) + return $item !== '' && $item !== '???'; + } + ); + } + } + return $mimeExtensions; + } +} diff --git a/Classes/Type/File/ImageInfo.php b/Classes/Type/File/ImageInfo.php new file mode 100644 index 0000000..dd438b1 --- /dev/null +++ b/Classes/Type/File/ImageInfo.php @@ -0,0 +1,158 @@ +<?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\Core\Type\File; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use TYPO3\CMS\Core\Imaging\Exception\InvalidSvgException; +use TYPO3\CMS\Core\Imaging\Exception\UnsupportedFileException; +use TYPO3\CMS\Core\Imaging\GraphicalFunctions; +use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentFactory; +use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentService; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * An SPL FileInfo class providing information related to an image. + * + * @todo: This is a broken construct. ImageInfo (via FileInfo) extends + * \SplFileInfo, a pure data object whose only state is the file path + * passed to its constructor. FileInfo and ImageInfo then bolt service + * aspects (mime detection, image size extraction, SVG/graphics + * processing pulled in via makeInstance) onto that data object, which + * is why those collaborators cannot be injected and have to be fetched + * via GeneralUtility::makeInstance(). This should be refactored by + * splitting the service aspect away from the data object: a slim + * path/metadata value object, plus a separate injectable service that + * resolves image information for a given file. + */ +class ImageInfo extends FileInfo implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + /** + * @var array{0: int<0, max>|string, 1: int<0, max>|string, 2?: string, 3?: string, 4?: string}|false|null + */ + protected $imageSizes; + + /** + * Returns the width of the image. + * + * @return int<0, max> + */ + public function getWidth() + { + $imageSizes = $this->getImageSizes(); + return (int)$imageSizes[0]; + } + + /** + * Returns the height of the image. + * + * @return int<0, max> + */ + public function getHeight() + { + $imageSizes = $this->getImageSizes(); + return (int)$imageSizes[1]; + } + + /** + * Gets the image size, considering the exif-rotation present in the file + * + * @param string $imageFile The image filepath + * @return array{0: int<0, max>, 1: int<0, max>}|false an array where [0]/[1] is w/h + */ + protected function getExifAwareImageSize(string $imageFile) + { + $size = false; + if (function_exists('getimagesize')) { + $size = @getimagesize($imageFile); + } + if ($size === false) { + return false; + } + [$width, $height] = $size; + + if (function_exists('exif_read_data')) { + $exif = @exif_read_data($imageFile); + // see: http://sylvana.net/jpegcrop/exif_orientation.html + if (isset($exif['Orientation']) && $exif['Orientation'] >= 5 && $exif['Orientation'] <= 8) { + return [$height, $width]; + } + } + + return [$width, $height]; + } + + /** + * @return array{0: int<0, max>|string, 1: int<0, max>|string, 2?: string, 3?: string, 4?: string} + */ + protected function getImageSizes() + { + if ($this->imageSizes === null) { + $this->imageSizes = $this->getExifAwareImageSize($this->getPathname()); + + // Try SVG first as SVG size detection with IM/GM leads to an error output + if ($this->imageSizes === false && $this->getMimeType() === 'image/svg+xml') { + $this->imageSizes = $this->extractSvgImageSizes(); + } + // Fallback to IM/GM identify + if ($this->imageSizes === false) { + $this->imageSizes = $this->getImageSizesFromImageMagick(); + } + + // In case the image size could not be retrieved, log the incident as a warning. + if (empty($this->imageSizes)) { + $this->logger->warning('I could not retrieve the image size for file {file}', ['file' => $this->getPathname()]); + $this->imageSizes = [0, 0]; + } + } + return $this->imageSizes; + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string, 4: string}|null + */ + protected function getImageSizesFromImageMagick(): ?array + { + try { + $graphicalFunctions = GeneralUtility::makeInstance(GraphicalFunctions::class); + return $graphicalFunctions->imageMagickIdentify($this->getPathname()); + } catch (UnsupportedFileException $e) { + $this->logger->error( + 'Error resolving image sizes with ImageMagick: ' . $this->getPathname(), + ['exception' => $e] + ); + return null; + } + } + + /** + * Tries to read SVG as XML file and find width and height. + * + * @return array{0: int<0, max>, 1: int<0, max>}|false + */ + protected function extractSvgImageSizes() + { + try { + $document = GeneralUtility::makeInstance(SvgDocumentFactory::class)->fromFile($this->getPathname()); + $dimensions = GeneralUtility::makeInstance(SvgDocumentService::class)->getDimensions($document); + } catch (InvalidSvgException) { + return false; + } + return [$dimensions->getWidth(), $dimensions->getHeight()]; + } +} diff --git a/Classes/Type/Map.php b/Classes/Type/Map.php new file mode 100644 index 0000000..00e0cec --- /dev/null +++ b/Classes/Type/Map.php @@ -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\Core\Type; + +/** + * Map implementation that supports objects as keys, as well as scalar values. + * + * PHP's \WeakMap is not an option in case object keys are created and assigned + * in an encapsulated scope (like passing a map to a function to enrich it). In + * case the original object is not referenced anymore, it also will vanish from + * a \WeakMap, when used as key (see https://www.php.net/manual/class.weakmap.php). + * + * PHP's \SplObjectStorage has a strange behavior when using an iteration like + * `foreach ($map as $key => $value)` - the `$value` is actually the `$key` for + * BC reasons (see https://bugs.php.net/bug.php?id=49967). + * + * This individual implementation works around the "weak" behavior of \WeakMap + * and the iteration issue with `foreach` of `\SplObjectStorage` by maintaining + * its own internal state. + * + * Example: + * ``` + * $map = new \TYPO3\CMS\Core\Type\Map(); + * $key = new \stdClass(); + * $value = new \stdClass(); + * $map[$key] = $value; + * + * foreach ($map as $key => $value) { ... } + * ``` + */ +final class Map implements \ArrayAccess, \Countable, \Iterator +{ + private array $keys = []; + private array $values = []; + private int $index = 0; + private int $length = 0; + /** + * Whether the internal index exceeded the end (either the map is empty, + * or previous call to `next()` exceeded the amount of available entries) + */ + private bool $end = true; + + /** + * @template E array{0:mixed, 1:mixed} + * @param list<E> $entries + */ + public static function fromEntries(array ...$entries): self + { + $map = new self(); + foreach ($entries as $entry) { + $map[$entry[0]] = $entry[1]; + } + return $map; + } + + public function key(): mixed + { + return $this->valid() ? $this->keys[$this->index] : null; + } + + public function current(): mixed + { + return $this->valid() ? $this->values[$this->index] : null; + } + + public function next(): void + { + if (!$this->valid()) { + return; + } + if ($this->index + 1 < $this->length) { + $this->index++; + } else { + $this->end = true; + } + } + + public function rewind(): void + { + $this->index = 0; + $this->updateState(); + } + + public function valid(): bool + { + return !$this->end; + } + + public function offsetExists(mixed $offset): bool + { + return in_array($offset, $this->keys(), true); + } + + public function offsetGet(mixed $offset): mixed + { + $index = array_search($offset, $this->keys(), true); + return $index === false ? null : $this->values[$index]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $index = array_search($offset, $this->keys, true); + if ($index !== false) { + $this->values[$index] = $value; + return; + } + $this->keys[] = $offset; + $this->values[] = $value; + $this->length++; + if ($this->end) { + $this->index = $this->length - 1; + } + $this->updateState(); + } + + public function offsetUnset(mixed $offset): void + { + $index = array_search($offset, $this->keys, true); + if ($index === false) { + return; + } + unset($this->keys[$index], $this->values[$index]); + $this->keys = array_values($this->keys); + $this->values = array_values($this->values); + // key indexes `[0 => A, 1 => B, 2 => C]` + // | unset | $this-> | $this-> | + // | $index | index cur | index new | + // +--------+-----------+-----------+ + // | 1 (B) | 2 (C) | 1 (C) | + // | 1 (B) | 1 (B) | 1 (C) | + // | 2 (C) | 1 (B) | 1 (B) | + // | 2 (C) | 2 (C) | 2 (ø) | + if ($index < $this->index) { + $this->index--; + } + $this->length--; + $this->updateState(); + } + + public function assign(self $source): void + { + if (count($source) === 0) { + return; + } + foreach ($source as $key => $value) { + $this[$key] = $value; + } + } + + public function keys(): array + { + return $this->keys; + } + + public function values(): array + { + return $this->values; + } + + /** + * @return list<array{0:mixed, 1:mixed}> + */ + public function entries(): array + { + return array_map( + static fn(mixed $key, mixed $value): array => [$key, $value], + $this->keys(), + $this->values() + ); + } + + public function count(): int + { + return $this->length; + } + + private function updateState(): void + { + $this->end = !($this->index < $this->length); + } +} diff --git a/Classes/Type/TypeInterface.php b/Classes/Type/TypeInterface.php new file mode 100644 index 0000000..a93581d --- /dev/null +++ b/Classes/Type/TypeInterface.php @@ -0,0 +1,32 @@ +<?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\Core\Type; + +/** + * This is an interface that has to be used by all Core Types. + * All of them have to implement a __toString() method that is + * used to get a flatten string for the persistence of the object. + */ +interface TypeInterface +{ + /** + * Core types must implement the __toString function in order to be + * serialized to the database; + * + * @return string + */ + public function __toString(); +} diff --git a/Classes/Type/VirtualRecord.php b/Classes/Type/VirtualRecord.php new file mode 100644 index 0000000..6997997 --- /dev/null +++ b/Classes/Type/VirtualRecord.php @@ -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\Core\Type; + +/** + * Classifies records that don't have an actual persisted counter-part and only + * exist virtually for semantic reasons. New cases may be added in the future to + * represent entities that are not persisted as a database row at all. + * + * @internal + */ +enum VirtualRecord +{ + /** + * A pages record that is itself at the root of the page tree (pid = 0). + * The record acts as its own page context. + */ + case RootPage; +} diff --git a/Classes/TypoScript/AST/AbstractAstBuilder.php b/Classes/TypoScript/AST/AbstractAstBuilder.php new file mode 100644 index 0000000..01a275d --- /dev/null +++ b/Classes/TypoScript/AST/AbstractAstBuilder.php @@ -0,0 +1,276 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST; + +use Psr\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent; +use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNode; +use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\ReferenceChildNode; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Common methods of both AST builders. + * + * @internal: Internal AST structure. + */ +abstract class AbstractAstBuilder +{ + /** + * @var array<string, string> + */ + protected array $flatConstants = []; + protected EventDispatcherInterface $eventDispatcher; + + protected function handleIdentifierUnsetLine(IdentifierUnsetLine $line, CurrentObjectPath $currentObjectPath): void + { + $node = $currentObjectPath->getFirst(); + $identifierStream = $line->getIdentifierTokenStream()->reset(); + while ($identifierToken = $identifierStream->getNext()) { + if (!$foundNode = $node->getChildByName($identifierToken->getValue())) { + break; + } + $nextIdentifierToken = $identifierStream->peekNext(); + if ($nextIdentifierToken) { + $node = $foundNode; + continue; + } + $node->removeChildByName($identifierToken->getValue()); + break; + } + } + + protected function handleIdentifierCopyLine(IdentifierCopyLine $line, RootNode $rootNode, CurrentObjectPath $currentObjectPath): ?NodeInterface + { + $sourceIdentifierStream = $line->getValueTokenStream()->reset(); + $sourceNode = $rootNode; + if ($sourceIdentifierStream->isRelative()) { + // Entry node is current node from current object path if relative, otherwise RootNode. + $sourceNode = $currentObjectPath->getLast(); + } + while ($identifierToken = $sourceIdentifierStream->getNext()) { + // Go through source token stream and locate the sourceNode to copy from. + if (!$sourceNode = $sourceNode->getChildByName($identifierToken->getValue())) { + // Source node not found - nothing to do for this line + return null; + } + } + $isSourceNodeValueNull = true; + if ($sourceNode->getValue() !== null) { + // When the source node value is not null, it will override the target node value if that exists. + $isSourceNodeValueNull = false; + } + + // Locate/create the targets parent node the copied source should be added as child to, + // and get the name of the node we're dealing with. + $targetIdentifierTokenStream = $line->getIdentifierTokenStream()->reset(); + $targetParentNode = $currentObjectPath->getFirst(); + $targetTokenName = null; + while ($targetToken = $targetIdentifierTokenStream->getNext()) { + $targetTokenName = $targetToken->getValue(); + if (!($targetIdentifierTokenStream->peekNext() ?? false)) { + break; + } + if (!$foundNode = $targetParentNode->getChildByName($targetTokenName)) { + // Add new node as new child of current last element in path + $foundNode = new ChildNode($targetTokenName); + $targetParentNode->addChild($foundNode); + } + $targetParentNode = $foundNode; + } + + $existingTarget = null; + if ($isSourceNodeValueNull) { + // When the node to copy has no value, but the existing target has, + // the value from the existing target is kept. Also, if the existing + // node is a ReferenceChildNode and the source does not override this, + // source children are added to the existing reference instead of + // dropping the existing target. + $existingTarget = $targetParentNode->getChildByName($targetTokenName); + $existingTargetNodeValue = $existingTarget?->getValue(); + } else { + // Blindly remove existing target node if exists and the value is not overridden by source. + $targetParentNode->removeChildByName($targetTokenName); + } + if ($existingTarget instanceof ReferenceChildNode) { + // When existing target is a ReferenceChildNode, keep it and + // copy children from source into existing target. + $targetNode = $existingTarget; + foreach ($sourceNode->getNextChild() as $sourceChild) { + $targetNode->addChild(clone $sourceChild); + } + } else { + // Clone full source node tree, update name and add as child to parent node. + /** @var ChildNodeInterface $targetNode */ + $targetNode = clone $sourceNode; + $targetNode->updateName($targetTokenName); + $targetParentNode->addChild($targetNode); + } + if ($isSourceNodeValueNull && $existingTargetNodeValue) { + // If value of old existing target should be kept, set in now. + $targetNode->setValue($existingTargetNodeValue); + } + + return $targetNode; + } + + /** + * "foo =< bar": Prepare a reference resolving. + * Note this does *not* resolve "=<" itself at this point since this operator can only be + * evaluated after the full AST has been established. Also, having a full AST-traverser run + * that does this is *very* expensive and "=<" is only done for "tt_content.myElement" and + * "lib.parseFunc" anyways. As such, "=<" is NOT a language construct itself and the AST-parser + * only marks nodes that use it by using the special node "ObjectReference". + * Resolving then happens "lazy" and "on demand" in ContentObjectRenderer cObjGetSingle() + * and mergeTSRef() for frontend "setup" TypoScript. + */ + protected function handleIdentifierReferenceLine(IdentifierReferenceLine $line, CurrentObjectPath $currentObjectPath): NodeInterface + { + $tokenStream = $line->getIdentifierTokenStream(); + $node = $currentObjectPath->getFirst(); + $identifierStream = $tokenStream->reset(); + while ($identifierToken = $identifierStream->getNext()) { + $nextIdentifier = $identifierStream->peekNext(); + $identifierTokenValue = $identifierToken->getValue(); + if (!($node->getChildByName($identifierTokenValue)) && $nextIdentifier) { + // Add new node as new child of current last element in path + $foundNode = new ChildNode($identifierTokenValue); + $node->addChild($foundNode); + } elseif (!$node->getChildByName($identifierTokenValue) && $nextIdentifier === null) { + // Parent of target node exists, but target node does not. Add new reference child. + $foundNode = new ReferenceChildNode($identifierTokenValue); + $foundNode->setReferenceSourceStream($line->getValueTokenStream()); + $node->addChild($foundNode); + } elseif (($foundNode = $node->getChildByName($identifierTokenValue)) && $nextIdentifier === null) { + // Target node exists already. We create a new one, remove old, but transfer existing children from old to new. + $newNode = new ReferenceChildNode($identifierTokenValue); + $newNode->setReferenceSourceStream($line->getValueTokenStream()); + foreach ($foundNode->getNextChild() as $existingNodeChild) { + $newNode->addChild($existingNodeChild); + } + $node->removeChildByName($identifierTokenValue); + $node->addChild($newNode); + $foundNode = $newNode; + } + $node = $foundNode; + } + return $node; + } + + protected function getOrAddNodeFromIdentifierStream(CurrentObjectPath $currentObjectPath, IdentifierTokenStream $tokenStream): NodeInterface + { + $node = $currentObjectPath->getFirst(); + $identifierStream = $tokenStream->reset(); + while ($identifierToken = $identifierStream->getNext()) { + $identifierTokenValue = $identifierToken->getValue(); + if (!$foundNode = $node->getChildByName($identifierTokenValue)) { + // Add new node as new child of current last element in path + $foundNode = new ChildNode($identifierTokenValue); + $node->addChild($foundNode); + } + $node = $foundNode; + } + return $node; + } + + /** + * Evaluate operator functions, example TypoScript: + * "page.10.value := appendString(foo)" + */ + protected function evaluateValueModifier(Token $functionNameToken, TokenStreamInterface $functionArgumentTokenStream, ?string $originalValue): ?string + { + $functionName = $functionNameToken->getValue(); + // Constants are evaluated via __toString() of ConstantAwareTokenStream and thus need current constants. + // This implements constants in function arguments: "foo := addToList({$my.constant})" + if ($functionArgumentTokenStream instanceof ConstantAwareTokenStream) { + $functionArgumentTokenStream->setFlatConstants($this->flatConstants); + } + $functionArgument = (string)$functionArgumentTokenStream; + switch ($functionName) { + case 'prependString': + return $functionArgument . $originalValue; + case 'appendString': + return $originalValue . $functionArgument; + case 'removeString': + return str_replace($functionArgument, '', $originalValue); + case 'replaceString': + $functionValueArray = explode('|', $functionArgument, 2); + $fromStr = $functionValueArray[0]; + $toStr = $functionValueArray[1] ?? ''; + return str_replace($fromStr, $toStr, $originalValue); + case 'addToList': + return ($originalValue !== null ? $originalValue . ',' : '') . $functionArgument; + case 'removeFromList': + $existingElements = GeneralUtility::trimExplode(',', $originalValue ?? ''); + $removeElements = GeneralUtility::trimExplode(',', $functionArgument); + if (!empty($removeElements)) { + return implode(',', array_diff($existingElements, $removeElements)); + } + return $originalValue; + case 'uniqueList': + $elements = GeneralUtility::trimExplode(',', $originalValue ?? ''); + return implode(',', array_unique($elements)); + case 'reverseList': + $elements = GeneralUtility::trimExplode(',', $originalValue ?? ''); + return implode(',', array_reverse($elements)); + case 'sortList': + $elements = GeneralUtility::trimExplode(',', $originalValue ?? ''); + $arguments = GeneralUtility::trimExplode(',', $functionArgument); + $arguments = array_map('strtolower', $arguments); + $sortFlags = SORT_REGULAR; + if (in_array('numeric', $arguments)) { + $sortFlags = SORT_NUMERIC; + // If the sorting modifier "numeric" is given, all values + // are checked and an exception is thrown if a non-numeric value is given + // otherwise there is a different behaviour between PHP 7 and PHP 5.x + // See also the warning on http://us.php.net/manual/en/function.sort.php + foreach ($elements as $element) { + if (!is_numeric($element)) { + throw new \InvalidArgumentException( + 'The list "' . $originalValue . '" should be sorted numerically but contains a non-numeric value', + 1650893781 + ); + } + } + } + sort($elements, $sortFlags); + if (in_array('descending', $arguments)) { + $elements = array_reverse($elements); + } + return implode(',', $elements); + case 'getEnv': + $environmentValue = getenv(trim($functionArgument)); + if ($environmentValue !== false) { + return $environmentValue; + } + return $originalValue; + default: + return $this->eventDispatcher->dispatch(new EvaluateModifierFunctionEvent($functionName, $functionArgument, $originalValue))->getValue() ?? $originalValue; + } + } +} diff --git a/Classes/TypoScript/AST/AstBuilder.php b/Classes/TypoScript/AST/AstBuilder.php new file mode 100644 index 0000000..82eec4e --- /dev/null +++ b/Classes/TypoScript/AST/AstBuilder.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPathStack; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\BlockCloseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierAssignmentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierBlockOpenLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierFunctionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream; + +/** + * The main TypoScript AST builder. + * + * This creates a tree of Nodes, starting with the root node. Each node can have + * children. The implementation basically iterates a LineStream created by the + * tokenizers, and creates AST depending on the line type. It handles all the + * different operator lines like "=", "<" and so on. + * + * @internal: Internal AST structure. + */ +#[Autoconfigure(public: true), AsAlias(AstBuilderInterface::class)] +final class AstBuilder extends AbstractAstBuilder implements AstBuilderInterface +{ + public function __construct( + EventDispatcherInterface $eventDispatcher, + ) { + $this->eventDispatcher = $eventDispatcher; + } + + /** + * @param array<string, string> $flatConstants + */ + public function build(LineStream $lineStream, RootNode $ast, array $flatConstants = []): RootNode + { + $this->flatConstants = $flatConstants; + + $currentObjectPath = new CurrentObjectPath($ast); + $currentObjectPathStack = new CurrentObjectPathStack(); + $currentObjectPathStack->push($currentObjectPath); + + foreach ($lineStream->getNextLine() as $line) { + if ($line instanceof IdentifierAssignmentLine) { + // "foo = bar" and "foo ( bar )": Single and multi line assignments + $this->handleIdentifierAssignmentLine($line, $currentObjectPath); + } elseif ($line instanceof IdentifierBlockOpenLine) { + // "foo {": Opening a block - push to object path stack + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + $currentObjectPath = (new CurrentObjectPath($node)); + $currentObjectPathStack->push($currentObjectPath); + } elseif ($line instanceof BlockCloseLine) { + // "}": Closing a block - pop from object path stack + $currentObjectPath = $currentObjectPathStack->pop(); + } elseif ($line instanceof IdentifierUnsetLine) { + // "foo >": Remove a path + $this->handleIdentifierUnsetLine($line, $currentObjectPath); + } elseif ($line instanceof IdentifierCopyLine) { + // "foo < bar": Copy a node source path to a target path + $this->handleIdentifierCopyLine($line, $ast, $currentObjectPath); + } elseif ($line instanceof IdentifierFunctionLine) { + // "foo := addToList(42)": Evaluate functions + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + $node->setValue($this->evaluateValueModifier($line->getFunctionNameToken(), $line->getFunctionValueTokenStream(), $node->getValue())); + } elseif ($line instanceof IdentifierReferenceLine) { + // "foo =< bar": Prepare a reference resolving + $this->handleIdentifierReferenceLine($line, $currentObjectPath); + } + } + + return $ast; + } + + private function handleIdentifierAssignmentLine(IdentifierAssignmentLine $line, CurrentObjectPath $currentObjectPath): void + { + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + $valueTokenStream = $line->getValueTokenStream(); + if ($valueTokenStream instanceof ConstantAwareTokenStream) { + $valueTokenStream = clone $valueTokenStream; + $valueTokenStream->setFlatConstants($this->flatConstants); + $node->setValue((string)$valueTokenStream); + return; + } + $node->setValue((string)$valueTokenStream); + } +} diff --git a/Classes/TypoScript/AST/AstBuilderInterface.php b/Classes/TypoScript/AST/AstBuilderInterface.php new file mode 100644 index 0000000..42587fa --- /dev/null +++ b/Classes/TypoScript/AST/AstBuilderInterface.php @@ -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\Core\TypoScript\AST; + +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; + +/** + * The main TypoScript AST builder. + * + * This creates a tree of Nodes, starting with the root node. Each node can have + * children. The implementation basically iterates a LineStream created by the + * tokenizers, and creates AST depending on the line type. It handles all the + * different operator lines like "=", "<" and so on. + * + * @internal: Internal AST structure. + */ +interface AstBuilderInterface +{ + /** + * @param array<string, string> $flatConstants + */ + public function build(LineStream $lineStream, RootNode $ast, array $flatConstants = []): RootNode; +} diff --git a/Classes/TypoScript/AST/CommentAwareAstBuilder.php b/Classes/TypoScript/AST/CommentAwareAstBuilder.php new file mode 100644 index 0000000..f904a21 --- /dev/null +++ b/Classes/TypoScript/AST/CommentAwareAstBuilder.php @@ -0,0 +1,187 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST; + +use Psr\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPathStack; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\BlockCloseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\CommentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\EmptyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierAssignmentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierBlockOpenLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierFunctionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream; + +/** + * Secondary TypoScript AST builder. + * + * This creates a tree of Nodes, starting with the root node. Each node can have + * children. The implementation basically iterates a LineStream created by the + * tokenizers, and creates AST depending on the line type. It handles all the + * different operator lines like "=", "<" and so on. + * + * This AST builder is comment aware: Comments are assigned to nodes. This is used + * in ext:tstemplate and page TSconfig backend modules to add the comment related + * TypoScript functionality. + * + * This AST builder variant adds runtime overhead and is slower than the main + * AstBuilder class. + * + * @internal: Internal AST structure. + */ +final class CommentAwareAstBuilder extends AbstractAstBuilder implements AstBuilderInterface +{ + public function __construct( + EventDispatcherInterface $eventDispatcher, + ) { + $this->eventDispatcher = $eventDispatcher; + } + + /** + * @param array<string, string> $flatConstants + */ + public function build(LineStream $lineStream, RootNode $ast, array $flatConstants = []): RootNode + { + $this->flatConstants = $flatConstants; + + $currentObjectPath = new CurrentObjectPath($ast); + $currentObjectPathStack = new CurrentObjectPathStack(); + $currentObjectPathStack->push($currentObjectPath); + + $previousLineComments = []; + while ($line = $lineStream->getNext()) { + $node = null; + if ($line instanceof IdentifierAssignmentLine) { + // "foo = bar" and "foo ( bar )": Single and multi line assignments + $node = $this->handleIdentifierAssignmentLine($line, $currentObjectPath); + if ($previousLineComments) { + foreach ($previousLineComments as $previousLineComment) { + $node->addComment($previousLineComment); + } + $previousLineComments = []; + } + } elseif ($line instanceof IdentifierBlockOpenLine) { + // "foo {": Opening a block - push to object path stack + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + if ($previousLineComments) { + foreach ($previousLineComments as $previousLineComment) { + $node->addComment($previousLineComment); + } + $previousLineComments = []; + } + $currentObjectPath = (new CurrentObjectPath($node)); + $currentObjectPathStack->push($currentObjectPath); + } elseif ($line instanceof BlockCloseLine) { + // "}": Closing a block - pop from object path stack + $currentObjectPath = $currentObjectPathStack->pop(); + } elseif ($line instanceof IdentifierUnsetLine) { + // "foo >": Remove a path + $this->handleIdentifierUnsetLine($line, $currentObjectPath); + } elseif ($line instanceof IdentifierCopyLine) { + // "foo < bar": Copy a node source path to a target path + $node = $this->handleIdentifierCopyLine($line, $ast, $currentObjectPath); + if ($node && $previousLineComments) { + foreach ($previousLineComments as $previousLineComment) { + $node->addComment($previousLineComment); + } + $previousLineComments = []; + } + } elseif ($line instanceof IdentifierFunctionLine) { + // "foo := addToList(42)": Evaluate functions + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + $functionValueTokenStream = $line->getFunctionValueTokenStream(); + $node->setValue($this->evaluateValueModifier($line->getFunctionNameToken(), clone $functionValueTokenStream, $node->getValue())); + if ($functionValueTokenStream instanceof ConstantAwareTokenStream) { + // @todo: This is a bit unfortunate. When multiple functions manipulate a value after each other, + // only the stream of the last one is preserved, previous ones are lost. This way, the BE modules + // can not reflect when previous functions used constants. One idea to solve this is to turn existing + // nodes into special "function" nodes that park single operations, which are then executed lazy when + // a node value is string'ified. The BE modules could then render full lists of value manipulations and + // show how values evolve. Another idea is to change setOriginalValueTokenStream() to gather multiple + // streams - probably together with the current value at this point in time, which would also allow + // rendering how a value evolves over time as well. + $node->setOriginalValueTokenStream($functionValueTokenStream); + } + if ($previousLineComments) { + foreach ($previousLineComments as $previousLineComment) { + $node->addComment($previousLineComment); + } + $previousLineComments = []; + } + } elseif ($line instanceof IdentifierReferenceLine) { + // "foo =< bar": Prepare a reference resolving + $node = $this->handleIdentifierReferenceLine($line, $currentObjectPath); + if ($previousLineComments) { + foreach ($previousLineComments as $previousLineComment) { + $node->addComment($previousLineComment); + } + $previousLineComments = []; + } + } elseif ($line instanceof CommentLine) { + $nextLine = $lineStream->peekNext(); + if ($currentObjectPath->getLast() instanceof RootNode && ($nextLine === null || $nextLine instanceof EmptyLine)) { + $previousLineComments[] = $line->getTokenStream(); + foreach ($previousLineComments as $commentLineTokenStream) { + $ast->addComment($commentLineTokenStream); + } + $previousLineComments = []; + } + if ($nextLine instanceof CommentLine) { + $previousLineComments[] = $line->getTokenStream(); + } + if ($nextLine instanceof IdentifierAssignmentLine + || $nextLine instanceof IdentifierBlockOpenLine + || $nextLine instanceof IdentifierCopyLine + || $nextLine instanceof IdentifierFunctionLine + || $nextLine instanceof IdentifierReferenceLine + ) { + $previousLineComments[] = $line->getTokenStream(); + } + } + } + + return $ast; + } + + /** + * Slightly different from AstBuilder since it sets 'previousValue' + */ + private function handleIdentifierAssignmentLine(IdentifierAssignmentLine $line, CurrentObjectPath $currentObjectPath): NodeInterface + { + $node = $this->getOrAddNodeFromIdentifierStream($currentObjectPath, $line->getIdentifierTokenStream()); + $valueTokenStream = $line->getValueTokenStream(); + if ($valueTokenStream instanceof ConstantAwareTokenStream) { + $node->setOriginalValueTokenStream($valueTokenStream); + $valueTokenStream = clone $valueTokenStream; + $valueTokenStream->setFlatConstants($this->flatConstants); + $node->setPreviousValue($node->getValue()); + $node->setValue((string)$valueTokenStream); + return $node; + } + $node->setPreviousValue($node->getValue()); + $node->setValue((string)$valueTokenStream); + return $node; + } +} diff --git a/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPath.php b/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPath.php new file mode 100644 index 0000000..8a62d7e --- /dev/null +++ b/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPath.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath; + +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * Internal state class to track the current hierarchy in tree. + * This is important in combination with block open "{" and block + * close "}" brackets. + * Also used in BE Template Object Browser tree rendering. + * + * @internal: Internal AST structure. + */ +final class CurrentObjectPath +{ + /** + * @var NodeInterface[] + */ + private array $path; + + public function __construct(NodeInterface ...$path) + { + $this->path = $path; + } + + public function append(NodeInterface $node): void + { + $this->path[] = $node; + } + + /** + * @return NodeInterface[] + */ + public function getAll(): array + { + return $this->path; + } + + /** + * Turn current object path into a string. Quote dots in keys. + * Used in BE Template Object Browser tree, expand and search handling. + * Not implementing __toString() here since Fluid can't call this. + * + * Example: + * page.10.foo\.bar.baz + */ + public function getPathAsString(): string + { + $flatArray = []; + foreach ($this->getAll() as $pathNode) { + if ($pathNode instanceof RootNode) { + continue; + } + $name = $pathNode->getName(); + if ($name === '') { + throw new \RuntimeException('Node names must not be empty string', 1658578645); + } + $flatArray[] = addcslashes($name, '.'); + } + return implode('.', $flatArray); + } + + public function getFirst(): NodeInterface + { + return reset($this->path); + } + + public function getLast(): NodeInterface + { + return array_last($this->path); + } + + public function getSecondLast(): NodeInterface + { + return array_slice($this->path, -2, 1)[0]; + } + + public function removeLast(): void + { + array_pop($this->path); + } +} diff --git a/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPathStack.php b/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPathStack.php new file mode 100644 index 0000000..c27bd72 --- /dev/null +++ b/Classes/TypoScript/AST/CurrentObjectPath/CurrentObjectPathStack.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath; + +/** + * A stack for CurrentObjectPath: When opening a block "{", + * CurrentObjectPath is pushed, when closing a block "}", it + * is popped from this stack. + * + * @internal: Internal AST structure. + */ +final class CurrentObjectPathStack +{ + /** + * @var CurrentObjectPath[] + */ + private array $stack = []; + private int $stackSize = 0; + + public function push(CurrentObjectPath $path): void + { + $this->stack[] = $path; + $this->stackSize++; + } + + public function pop(): CurrentObjectPath + { + if ($this->stackSize === 1) { + // Never pop the very last element off from the stack. This is the + // RootNode. This prevents errors when TypoScript has a closing + // curly bracket '}' too much. + return $this->getCurrent(); + } + array_pop($this->stack); + $this->stackSize--; + return $this->getCurrent(); + } + + public function getCurrent(): CurrentObjectPath + { + return array_last($this->stack); + } + + public function getFirst(): CurrentObjectPath + { + return reset($this->stack); + } +} diff --git a/Classes/TypoScript/AST/Event/EvaluateModifierFunctionEvent.php b/Classes/TypoScript/AST/Event/EvaluateModifierFunctionEvent.php new file mode 100644 index 0000000..67adb3c --- /dev/null +++ b/Classes/TypoScript/AST/Event/EvaluateModifierFunctionEvent.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Event; + +/** + * Listeners to this event are able to implement own ":=" TypoScript modifier functions, example: + * + * foo = myOriginalValue + * foo := myNewFunction(myFunctionArgument) + * + * Listeners should take care function names can not overlap with function names + * from other extensions and should thus namespace, example naming: "extNewsSortFunction()" + */ +final class EvaluateModifierFunctionEvent +{ + private ?string $value = null; + + public function __construct( + private readonly string $functionName, + private readonly string $functionArgument, + private readonly ?string $originalValue, + ) {} + + /** + * The function name, for example "extNewsSortFunction" when using "foo := extNewsSortFunction()" + */ + public function getFunctionName(): string + { + return $this->functionName; + } + + /** + * Optional function argument, for example "myArgument" when using "foo := extNewsSortFunction(myArgument)" + * If the argument contained constants, those have been resolved at this point. + */ + public function getFunctionArgument(): string + { + return $this->functionArgument; + } + + /** + * Original / current value, for example "fooValue" when using: + * foo = fooValue + * foo := extNewsSortFunction(myArgument) + */ + public function getOriginalValue(): ?string + { + return $this->originalValue; + } + + /** + * Set the updated value calculated by a listener. + * Note you can not set to null to "unset", since getValue() falls back to + * originalValue in this case. Set to empty string instead for this edge case. + */ + public function setValue(string $value): void + { + $this->value = $value; + } + + /** + * Used by AstBuilder to fetch the updated value, falls back to given original value. + * Can be used by Listeners to see if a previous listener changed the value already + * by comparing with getOriginalValue(). + */ + public function getValue(): ?string + { + return $this->value; + } +} diff --git a/Classes/TypoScript/AST/Merger/SetupConfigMerger.php b/Classes/TypoScript/AST/Merger/SetupConfigMerger.php new file mode 100644 index 0000000..886b2c6 --- /dev/null +++ b/Classes/TypoScript/AST/Merger/SetupConfigMerger.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Merger; + +use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * Frontend TypoScript 'setup' has the main 'config' section for global config, + * plus a per type / typeNum specific PAGE 'config' (often page.config) that can + * override global 'config' per type / typeNum. + * + * This class merges both into the final 'config', later available in Request + * attribute 'frontend.typoscript' getConfigTree() and getConfigArray(). + * + * @internal: Internal AST structure. + */ +final readonly class SetupConfigMerger +{ + public function merge(?ChildNodeInterface $config, ?ChildNodeInterface $pageConfig): RootNode + { + $configResult = new RootNode(); + if ($config) { + foreach ($config->getNextChild() as $child) { + $configResult->addChild($child); + } + } + if (!$pageConfig) { + return $configResult; + } + $this->mergeRecursive($pageConfig, $configResult); + return $configResult; + } + + private function mergeRecursive(ChildNodeInterface $mergeFrom, NodeInterface $mergeTo): void + { + foreach ($mergeFrom->getNextChild() as $mergeFromChild) { + $mergeToChild = $mergeTo->getChildByName($mergeFromChild->getName()); + if (!$mergeToChild) { + $mergeTo->addChild($mergeFromChild); + continue; + } + $mergeFromChildValue = $mergeFromChild->getValue(); + if ($mergeFromChildValue !== null && $mergeFromChildValue !== $mergeToChild->getValue()) { + $mergeToChild->setValue($mergeFromChildValue); + } + $this->mergeRecursive($mergeFromChild, $mergeToChild); + } + } +} diff --git a/Classes/TypoScript/AST/Node/AbstractChildNode.php b/Classes/TypoScript/AST/Node/AbstractChildNode.php new file mode 100644 index 0000000..992bf48 --- /dev/null +++ b/Classes/TypoScript/AST/Node/AbstractChildNode.php @@ -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\Core\TypoScript\AST\Node; + +/** + * Generic child node. Implements common methods of NodeInterface used + * in all Node classes. + * + * @internal: Internal AST structure. + */ +abstract class AbstractChildNode extends AbstractNode implements ChildNodeInterface +{ + public function __construct(protected string $name) {} + + /** + * Dereference children on clone(). + * Used with '<' operator to create a deep-copy of the tree to copy. + */ + public function __clone(): void + { + foreach ($this->children as $childName => $child) { + $this->children[$childName] = clone $child; + } + } + + public function getName(): string + { + return $this->name; + } + + public function toArray(): ?array + { + if (!$this->hasChildren()) { + return null; + } + $result = []; + foreach ($this->getNextChild() as $child) { + $childName = $child->getName(); + $childValue = $child->getValue(); + if ($child instanceof ReferenceChildNode) { + // Hack for b/w compat parsing of `=<` operator. See ContentObjectRenderer cObjGetSingle() and mergeTSRef() + // @todo: adding the whitespace after '<' is another bit of a hack here ... maybe solve in tokenizer? + // compare this for what happens when doing 'foo = bar' in old parser: Is the whitespace kept for + // value to not trigger the ref lookup to often if doing 'foo = <div...' ? + // @todo: same situation in RootNode! + $childValue = '< ' . $child->getReferenceSourceStream(); + } + if ($childValue !== null) { + $result[$child->getName()] = $childValue; + } + $grandChildren = $child->toArray(); + if ($grandChildren !== null) { + $result[$childName . '.'] = $grandChildren; + } + } + return $result; + } + + public function flatten(string $prefix = ''): array + { + $flatArray = []; + $prefixedQuotedNodeName = $prefix . addcslashes($this->getName(), '.'); + if (!$this->isValueNull()) { + $flatArray[$prefixedQuotedNodeName] = $this->getValue(); + } + foreach ($this->getNextChild() as $child) { + $flatArray = array_merge($flatArray, $child->flatten($prefixedQuotedNodeName . '.')); + } + return $flatArray; + } +} diff --git a/Classes/TypoScript/AST/Node/AbstractNode.php b/Classes/TypoScript/AST/Node/AbstractNode.php new file mode 100644 index 0000000..281396e --- /dev/null +++ b/Classes/TypoScript/AST/Node/AbstractNode.php @@ -0,0 +1,185 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Node; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; + +/** + * Generic node. Implements common methods of NodeInterface used + * in all Node classes. + * + * @internal: Internal AST structure. + */ +abstract class AbstractNode implements NodeInterface +{ + private ?string $identifier = null; + protected string $name; + private ?string $value = null; + private ?string $previousValue = null; + + /** + * @var array<string, ChildNodeInterface> + */ + protected array $children = []; + private ?TokenStreamInterface $originalValueTokenStream = null; + private array $comments = []; + + /** + * When storing to cache, we only store FE relevant properties and skip + * various BE related properties which then default to class defaults when + * unserialized. This is done to create smaller php cache files. + */ + final public function __serialize(): array + { + return $this->serialize(); + } + + protected function serialize(): array + { + $result = [ + 'name' => $this->name, + 'children' => $this->children, + ]; + if ($this->value !== null) { + $result['value'] = $this->value; + } + return $result; + } + + public function setIdentifier(string $identifier): void + { + $this->identifier = hash('xxh3', $identifier); + $childCounter = 0; + foreach ($this->getNextChild() as $child) { + $child->setIdentifier($this->identifier . $childCounter); + $childCounter++; + } + } + + /** + * This forces $this->name NOT to be readonly. + * Used with '<' operator on tree root to copy: + * foo = value + * bar < foo + * The 'foo' object node is copied, but added to AST as name 'bar' + */ + public function updateName(string $name): void + { + $this->name = $name; + } + + public function getIdentifier(): string + { + if ($this->identifier === null) { + throw new \RuntimeException( + 'Identifier has not been initialized. This happens when getIdentifier() is called on' + . ' trees retrieved from cache. The identifier is not supposed to be used in this context.', + 1674620169 + ); + } + return $this->identifier; + } + + public function addChild(ChildNodeInterface $node): void + { + $this->children[$node->getName()] = $node; + } + + public function getChildByName(string $name): ?ChildNodeInterface + { + return $this->children[$name] ?? null; + } + + /** + * Note this does *not* choke if that child does not exist, so we can "blindly" remove without error. + */ + public function removeChildByName(string $name): void + { + unset($this->children[$name]); + } + + public function hasChildren(): bool + { + return !empty($this->children); + } + + public function getNextChild(): iterable + { + foreach ($this->children as $child) { + yield $child; + } + } + + public function sortChildren(): void + { + ksort($this->children, SORT_FLAG_CASE | SORT_STRING); + } + + public function setValue(?string $value): void + { + $this->value = $value; + } + + public function appendValue(string $value): void + { + $this->value .= $value; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function isValueNull(): bool + { + return $this->value === null; + } + + public function setPreviousValue(?string $value): void + { + $this->previousValue = $value; + } + + public function getPreviousValue(): ?string + { + return $this->previousValue; + } + + public function setOriginalValueTokenStream(?TokenStreamInterface $tokenStream): void + { + $this->originalValueTokenStream = $tokenStream; + } + + public function getOriginalValueTokenStream(): ?TokenStreamInterface + { + return $this->originalValueTokenStream; + } + + public function addComment(TokenStreamInterface $tokenStream): void + { + $this->comments[] = $tokenStream; + } + + /** + * @return TokenStreamInterface[] + */ + public function getComments(): array + { + return $this->comments; + } +} diff --git a/Classes/TypoScript/AST/Node/ChildNode.php b/Classes/TypoScript/AST/Node/ChildNode.php new file mode 100644 index 0000000..6b2cf1b --- /dev/null +++ b/Classes/TypoScript/AST/Node/ChildNode.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Node; + +/** + * A generic child node that is not the node root. + */ +final class ChildNode extends AbstractChildNode {} diff --git a/Classes/TypoScript/AST/Node/ChildNodeInterface.php b/Classes/TypoScript/AST/Node/ChildNodeInterface.php new file mode 100644 index 0000000..3215100 --- /dev/null +++ b/Classes/TypoScript/AST/Node/ChildNodeInterface.php @@ -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\Core\TypoScript\AST\Node; + +/** + * The created AST consists of a NodeRoot object with nested NodeObject children. + * This is the interface implemented by all children nodes (not RootNode). + * + * @internal: Internal AST structure. + */ +interface ChildNodeInterface extends NodeInterface {} diff --git a/Classes/TypoScript/AST/Node/NodeInterface.php b/Classes/TypoScript/AST/Node/NodeInterface.php new file mode 100644 index 0000000..8df296e --- /dev/null +++ b/Classes/TypoScript/AST/Node/NodeInterface.php @@ -0,0 +1,125 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Node; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; + +/** + * The created AST consists of a NodeRoot object with nested NodeObject children. + * This is the main interface to any node type. + * + * Example TypoScript: + * "foo = fooValue" + * "foo.bar = barValue" + * This creates a RootNode with one ChildNode name "foo" and value "fooValue", + * that has a child ChildNode name "bar" and value "barValue". + * + * @internal: Internal AST structure. + */ +interface NodeInterface +{ + /** + * An identifier for this node. Typically, a hash of some kind. This identifier + * is unique within the tree, by being created from the parent identifier plus + * the name. This identifier is used in the backend, when referencing single nodes. + * Calculating identifiers is initiated by calling setIdentifier() on RootNode, which + * will recurse the tree. Call this on the final tree, after AST calculation finished, + * so AST building itself does not need to fiddle with identifier updates when for + * instance tree parts are cloned using '<' operator. + * Note this value is skipped when persisting to caches since it's a Backend related + * thing that does not use cached context: When retrieving nodes from cache (e.g. in Frontend), + * the identifier is null and calling the getter will throw an exception. + */ + public function setIdentifier(string $identifier): void; + public function getIdentifier(): string; + + /** + * Helper methods for node name. + */ + public function getName(): ?string; + public function updateName(string $name): void; + + /** + * Helper methods to operate on children. + */ + public function addChild(ChildNodeInterface $node): void; + public function getChildByName(string $name): ?ChildNodeInterface; + public function removeChildByName(string $name): void; + public function hasChildren(): bool; + /** + * @return iterable<ChildNodeInterface> + */ + public function getNextChild(): iterable; + public function sortChildren(): void; + + /** + * Helper methods for value. + */ + public function setValue(?string $value): void; + public function appendValue(string $value): void; + public function getValue(): ?string; + public function isValueNull(): bool; + + /** + * Previous value is only set by comment aware ast builder. It is used in + * constant editor to see if a value has been changed. + */ + public function setPreviousValue(?string $value): void; + public function getPreviousValue(): ?string; + + /** + * Helper method for backend object browser to retrieve the original + * stream when a constant substitution happened, only set by CommentAwareAstBuilder. + */ + public function setOriginalValueTokenStream(?TokenStreamInterface $tokenStream): void; + public function getOriginalValueTokenStream(): ?TokenStreamInterface; + + /** + * Helper methods to attach TypoScript tokens to a node. + * This is used in ext:tstemplate "Constant Editor" and "Object Browser" and handled + * by CommentAwareAstBuilder. + */ + public function addComment(TokenStreamInterface $tokenStream): void; + /** + * @return TokenStreamInterface[] + */ + public function getComments(): array; + + /** + * b/w compat method to turn AST into an array. + * Note we're NOT using magic __toArray() here to avoid calling array-cast of AST by + * accident: toArray() should be called explicitly if needed, which makes it much easier + * to drop this b/w compat method when we later want to drop that layer. + * + * Note RootNode *always* returns an array, while ObjectNode's may return null. + */ + public function toArray(): ?array; + + /** + * Flatten the tree. A RootNode with a ChildNode "foo" and value "fooValue", with this + * ChildNode again having a ChildNode "bar" and value "barValue" becomes: + * [ + * 'foo' => 'fooValue', + * 'foo.bar' => 'barValue', + * ] + * + * Flattening a TypoScript tree is especially used for constants to quickly look + * up constants when parsing setup node value streams that use T_CONSTANT tokens. + */ + public function flatten(string $prefix = ''): array; +} diff --git a/Classes/TypoScript/AST/Node/ReferenceChildNode.php b/Classes/TypoScript/AST/Node/ReferenceChildNode.php new file mode 100644 index 0000000..ea56607 --- /dev/null +++ b/Classes/TypoScript/AST/Node/ReferenceChildNode.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Node; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; + +/** + * A node object created for LineIdentifierReference lines which use the T_OPERATOR_REFERENCE + * operator and have a TokenStreamIdentifier stream for "the right side" of the expression. + * + * The reference operator is nasty, since it's no "true" reference / pointer: + * foo.bar = barValue1 + * baz =< foo + * baz.bar = barValue2 + * This ends up with "barValue1" for "foo.bar", and "barValue2" for "baz.bar". "barValue1" + * for "foo.bar" is kept! + * + * Note the reference operator *only* works for TS "setup" code, not for "constants", and it + * is only resolved in these cases. See ContentObjectRenderer->cObjGetSingle() for details. + * + * @internal: Internal AST structure. + */ +final class ReferenceChildNode extends AbstractChildNode +{ + private ?IdentifierTokenStream $referenceSourceStream; + + protected function serialize(): array + { + $result = parent::serialize(); + if ($this->referenceSourceStream !== null) { + $result['referenceSourceStream'] = $this->referenceSourceStream; + } + return $result; + } + + public function setReferenceSourceStream(?IdentifierTokenStream $referenceSourceStream): void + { + $this->referenceSourceStream = $referenceSourceStream; + } + + public function getReferenceSourceStream(): IdentifierTokenStream + { + return $this->referenceSourceStream; + } +} diff --git a/Classes/TypoScript/AST/Node/RootNode.php b/Classes/TypoScript/AST/Node/RootNode.php new file mode 100644 index 0000000..0277eac --- /dev/null +++ b/Classes/TypoScript/AST/Node/RootNode.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Node; + +/** + * AST entry node. + * + * @internal: Internal AST structure. + */ +final class RootNode extends AbstractNode +{ + /** + * Attempting to clone the RootNode indicates a bug in AstBuilder. + * It should never happen. + */ + public function __clone(): void + { + throw new \LogicException('Can not clone RootNode', 1655988945); + } + + /** + * RootNode has no properties to cache, just children. + */ + protected function serialize(): array + { + return [ + 'children' => $this->children, + ]; + } + + public function getName(): ?string + { + return null; + } + + public function updateName(string $name): void + { + throw new \RuntimeException('RootNode has no name. Don\'t call updateName().', 1653743453); + } + + public function setValue(?string $value): void + { + throw new \RuntimeException('RootNode has no value. Don\'t call setValue().', 1653743454); + } + + public function appendValue(string $value): void + { + throw new \RuntimeException('RootNode has no value. Don\'t call appendValue().', 1653743455); + } + + public function getValue(): ?string + { + return null; + } + + public function isValueNull(): bool + { + return true; + } + + /** + * @return array<string, mixed> + */ + public function toArray(): array + { + $result = []; + foreach ($this->getNextChild() as $child) { + $childName = $child->getName(); + if ($child instanceof ReferenceChildNode) { + // Hack for b/w compat parsing of `=<` operator. See ContentObjectRenderer cObjGetSingle() and mergeTSRef() + $childValue = '< ' . $child->getReferenceSourceStream(); + } else { + $childValue = $child->getValue(); + } + if ($childValue !== null) { + $result[$childName] = $childValue; + } + $grandChildren = $child->toArray(); + if ($grandChildren !== null) { + $result[$childName . '.'] = $grandChildren; + } + } + return $result; + } + + public function flatten(string $prefix = ''): array + { + $flatArray = []; + foreach ($this->getNextChild() as $child) { + $flatArray = array_merge($flatArray, $child->flatten('')); + } + return $flatArray; + } +} diff --git a/Classes/TypoScript/AST/Traverser/AstTraverser.php b/Classes/TypoScript/AST/Traverser/AstTraverser.php new file mode 100644 index 0000000..386dc7a --- /dev/null +++ b/Classes/TypoScript/AST/Traverser/AstTraverser.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Traverser; + +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\AST\Visitor\AstVisitorInterface; + +/** + * Traverse the entire AST. + * + * @internal: Internal AST structure. + */ +final class AstTraverser +{ + /** + * @param AstVisitorInterface[] $visitors + */ + public function traverse(RootNode $rootNode, array $visitors): void + { + foreach ($visitors as $visitor) { + if (!$visitor instanceof AstVisitorInterface) { + throw new \RuntimeException( + 'Visitors must implement AstTreeVisitorInterface', + 1689244842 + ); + } + } + $currentObjectPath = new CurrentObjectPath(); + $this->traverseRecursive($visitors, $rootNode, $rootNode, $currentObjectPath, 0); + } + + /** + * @param AstVisitorInterface[] $visitors + */ + private function traverseRecursive(array $visitors, RootNode $nodeRoot, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + $currentObjectPath->append($node); + foreach ($visitors as $visitor) { + $visitor->visitBeforeChildren($nodeRoot, $node, $currentObjectPath, $currentDepth); + } + foreach ($node->getNextChild() as $child) { + $this->traverseRecursive($visitors, $nodeRoot, $child, $currentObjectPath, $currentDepth + 1); + foreach ($visitors as $visitor) { + $visitor->visit($nodeRoot, $child, $currentObjectPath, $currentDepth); + } + } + foreach ($visitors as $visitor) { + $visitor->visitAfterChildren($nodeRoot, $node, $currentObjectPath, $currentDepth); + } + $currentObjectPath->removeLast(); + } +} diff --git a/Classes/TypoScript/AST/Visitor/AstConstantCommentVisitor.php b/Classes/TypoScript/AST/Visitor/AstConstantCommentVisitor.php new file mode 100644 index 0000000..ca90e0f --- /dev/null +++ b/Classes/TypoScript/AST/Visitor/AstConstantCommentVisitor.php @@ -0,0 +1,506 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Visitor; + +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Gather comments suitable for constant editor. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class AstConstantCommentVisitor implements AstVisitorInterface +{ + private array $categories = [ + 'basic' => [ + 'label' => 'Basic', + 'usageCount' => 0, + ], + 'menu' => [ + 'label' => 'Menu', + 'usageCount' => 0, + ], + 'content' => [ + 'label' => 'Content', + 'usageCount' => 0, + ], + 'page' => [ + 'label' => 'Page', + 'usageCount' => 0, + ], + 'advanced' => [ + 'label' => 'Advanced', + 'usageCount' => 0, + ], + 'all' => [ + 'label' => 'All', + 'usageCount' => 0, + ], + ]; + + private array $subCategories = [ + 'enable' => [ + 'label' => 'Enable features', + 'sorting' => 'a', + ], + 'dims' => [ + 'label' => 'Dimensions, widths, heights, pixels', + 'sorting' => 'b', + ], + 'file' => [ + 'label' => 'Files', + 'sorting' => 'c', + ], + 'typo' => [ + 'label' => 'Typography', + 'sorting' => 'd', + ], + 'color' => [ + 'label' => 'Colors', + 'sorting' => 'e', + ], + 'links' => [ + 'label' => 'Links and targets', + 'sorting' => 'f', + ], + 'language' => [ + 'label' => 'Language specific constants', + 'sorting' => 'g', + ], + 'cheader' => [ + 'label' => 'Content: \'Header\'', + 'sorting' => 'ma', + ], + 'cheader_g' => [ + 'label' => 'Content: \'Header\', Graphical', + 'sorting' => 'ma', + ], + 'ctext' => [ + 'label' => 'Content: \'Text\'', + 'sorting' => 'mb', + ], + 'cimage' => [ + 'label' => 'Content: \'Image\'', + 'sorting' => 'md', + ], + 'ctextmedia' => [ + 'label' => 'Content: \'Textmedia\'', + 'sorting' => 'ml', + ], + 'cbullets' => [ + 'label' => 'Content: \'Bullet list\'', + 'sorting' => 'me', + ], + 'ctable' => [ + 'label' => 'Content: \'Table\'', + 'sorting' => 'mf', + ], + 'cuploads' => [ + 'label' => 'Content: \'Filelinks\'', + 'sorting' => 'mg', + ], + 'cmultimedia' => [ + 'label' => 'Content: \'Multimedia\'', + 'sorting' => 'mh', + ], + 'cmedia' => [ + 'label' => 'Content: \'Media\'', + 'sorting' => 'mr', + ], + 'cmailform' => [ + 'label' => 'Content: \'Form\'', + 'sorting' => 'mi', + ], + 'csearch' => [ + 'label' => 'Content: \'Search\'', + 'sorting' => 'mj', + ], + 'clogin' => [ + 'label' => 'Content: \'Login\'', + 'sorting' => 'mk', + ], + 'cmenu' => [ + 'label' => 'Content: \'Menu/Sitemap\'', + 'sorting' => 'mm', + ], + 'cshortcut' => [ + 'label' => 'Content: \'Insert records\'', + 'sorting' => 'mn', + ], + 'clist' => [ + 'label' => 'Content: \'List of records\'', + 'sorting' => 'mo', + ], + 'chtml' => [ + 'label' => 'Content: \'HTML\'', + 'sorting' => 'mq', + ], + ]; + + /** + * Helper hack variable to have a unique sub category order if no sub category is given. + */ + private int $subCategoryCounter = 0; + + private array $currentTemplateFlatConstants = []; + + private array $constants = []; + + public function visitBeforeChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + if ($node instanceof RootNode) { + $rootNodeComments = $rootNode->getComments(); + foreach ($rootNodeComments as $comment) { + $this->subCategoryCounter++; + // Additional custom categories are attached as comments to root node + $this->parseCustomCategoryAndSubCategories($comment); + } + } else { + $nodeComments = $node->getComments(); + foreach ($nodeComments as $comment) { + $this->subCategoryCounter++; + $parsedCommentArray = $this->parseNodeComment($comment, $node->getName(), $node->getValue()); + if (empty($parsedCommentArray)) { + continue; + } + $currentDottedPath = $currentObjectPath->getPathAsString(); + if (array_key_exists($currentDottedPath, $this->constants)) { + // A constant definition can be defined only once. Stop when trying to override. + continue; + } + $parsedCommentArray['name'] = $currentDottedPath; + $parsedCommentArray['idName'] = str_replace('.', '-', $currentDottedPath); + $parsedCommentArray['value'] = $node->getValue(); + $parsedCommentArray['default_value'] = $node->getPreviousValue() ?? $node->getValue() ?? '[Empty]'; + $parsedCommentArray['isInCurrentTemplate'] = false; + if (array_key_exists($currentDottedPath, $this->currentTemplateFlatConstants)) { + $parsedCommentArray['isInCurrentTemplate'] = true; + } + $this->constants[$currentDottedPath] = $parsedCommentArray; + } + } + } + + public function setCurrentTemplateFlatConstants(array $currentTemplateFlatConstants) + { + $this->currentTemplateFlatConstants = $currentTemplateFlatConstants; + } + + public function getConstants(): array + { + return $this->constants; + } + + public function getCategories(): array + { + return $this->categories; + } + + private function parseNodeComment(TokenStreamInterface $commentTokenStream, string $nodeName, ?string $currentValue = null): array + { + $languageService = $this->getLanguageService(); + $parsedCommentArray = []; + $commentTokenStream->reset(); + $trimmedTokenStream = new TokenStream(); + while ($token = $commentTokenStream->getNext()) { + if ($token->getType() !== TokenType::T_BLANK) { + $trimmedTokenStream->append($token); + } + } + $firstTokenType = $trimmedTokenStream->peekNext()->getType(); + if ($firstTokenType !== TokenType::T_COMMENT_ONELINE_HASH && $firstTokenType !== TokenType::T_COMMENT_ONELINE_DOUBLESLASH) { + // Ignore multiline comments, only '#' and '//' allowed here + return $parsedCommentArray; + } + $commentString = trim((string)$trimmedTokenStream); + // Get rid of '#' and '//' + $commentString = trim(preg_replace('/^[#\\/]*/', '', $commentString)); + if (empty($commentString)) { + return $parsedCommentArray; + } + // "# cat=my custom: custom1/customsub1; type=string; label=custom1 customsub1 test1" + $commentParts = explode(';', $commentString); + foreach ($commentParts as $commentPart) { + if (!str_contains($commentPart, '=')) { + // Whatever it is, we ignore it. + continue; + } + $partArray = explode('=', $commentPart, 2); + $partKey = strtolower(trim($partArray[0])); + $partValue = trim($partArray[1] ?? ''); + if (empty($partKey) || empty($partValue)) { + continue; + } + if ($partKey === 'type') { + if (str_starts_with($partValue, 'int+')) { + $parsedCommentArray['type'] = 'int+'; + $parsedCommentArray['typeIntPlusMin'] = 0; + preg_match('/int\+\[(.*)\]/is', $partValue, $typeMatches); + if (!empty($typeMatches[1]) && str_contains($typeMatches[1], '-')) { + $intPlusExplodedRange = GeneralUtility::intExplode('-', $typeMatches[1]); + $parsedCommentArray['typeIntPlusMin'] = $intPlusExplodedRange[0]; + $parsedCommentArray['typeHint'] = 'Greater than ' . $intPlusExplodedRange[0]; + if ($intPlusExplodedRange[1] > 0) { + $parsedCommentArray['typeIntPlusMax'] = $intPlusExplodedRange[1]; + $parsedCommentArray['typeHint'] = 'Range ' . $intPlusExplodedRange[0] . ' - ' . $intPlusExplodedRange[1]; + } + } + } elseif (str_starts_with($partValue, 'int')) { + preg_match('/int\[(.*)\]/is', $partValue, $typeMatches); + $parsedCommentArray['type'] = 'int'; + if (!empty($typeMatches[1]) && str_contains($typeMatches[1], '-')) { + $rangeArray = mb_str_split($typeMatches[1]); + $negativeStart = false; + $negativeStop = false; + $gotSeparatorDash = false; + $start = null; + $stop = null; + foreach ($rangeArray as $index => $char) { + if ($index === 0 && $char === '-') { + $negativeStart = true; + } elseif ($char === '-' && !$gotSeparatorDash) { + $gotSeparatorDash = true; + } elseif (!$gotSeparatorDash) { + $start .= $char; + } elseif ($stop === null && $char === '-') { + $negativeStop = true; + } else { + $stop .= $char; + } + } + if ($start !== null) { + if ($negativeStart) { + $start = (int)$start * -1; + } + $parsedCommentArray['typeIntMin'] = (string)$start; + $parsedCommentArray['typeHint'] = 'Greater than ' . $start; + } + if ($stop !== null) { + if ($negativeStop) { + $stop = (int)$stop * -1; + } + $parsedCommentArray['typeIntMax'] = (string)$stop; + $parsedCommentArray['typeHint'] = 'Range ' . $start . ' - ' . $stop; + } + } + } elseif ($partValue === 'wrap') { + $parsedCommentArray['type'] = 'wrap'; + $splitValue = explode('|', $currentValue ?? ''); + $parsedCommentArray['wrapStart'] = $splitValue[0]; + $parsedCommentArray['wrapEnd'] = $splitValue[1] ?? ''; + } elseif (str_starts_with($partValue, 'offset')) { + $parsedCommentArray['type'] = 'offset'; + preg_match('/offset\[(.*)\]/is', $partValue, $typeMatches); + $labelArray = explode(',', $typeMatches[1] ?? ''); + $valueArray = explode(',', $currentValue ?? ''); + $parsedCommentArray['labelValueArray'] = [ + [ + 'label' => (!empty($labelArray[0])) ? $labelArray[0] : 'x', + 'value' => (!empty($valueArray[0])) ? $valueArray[0] : '', + ], + [ + 'label' => $labelArray[1] ?? 'y', + 'value' => $valueArray[1] ?? '', + ], + ]; + for ($i = 2; $i <= 5; $i++) { + if (!($labelArray[$i] ?? false)) { + break; + } + $parsedCommentArray['labelValueArray'][] = [ + 'label' => $labelArray[$i], + 'value' => $valueArray[$i] ?? '', + ]; + } + } elseif (str_starts_with($partValue, 'options')) { + preg_match('/options\\s*\[(.*)\]/is', $partValue, $typeMatches); + if (!empty($typeMatches[1] ?? '')) { + $parsedCommentArray['type'] = 'options'; + $labelValueStringArray = GeneralUtility::trimExplode(',', $typeMatches[1], true); + foreach ($labelValueStringArray as $labelValueString) { + $labelValueArray = explode('=', $labelValueString, 2); + $label = $labelValueArray[0]; + $value = $labelValueArray[1] ?? $labelValueArray[0]; + $selected = false; + if ($value === $currentValue) { + $selected = true; + } + $parsedCommentArray['labelValueArray'][] = [ + 'label' => $languageService->sL($label), + 'value' => $value, + 'selected' => $selected, + ]; + } + } + } elseif (str_starts_with($partValue, 'boolean')) { + $parsedCommentArray['type'] = 'boolean'; + preg_match('/boolean\\s*\[(.*)\]/is', $partValue, $typeMatches); + $parsedCommentArray['trueValue'] = '1'; + if (!empty($typeMatches[1] ?? '')) { + $parsedCommentArray['trueValue'] = $typeMatches[1]; + } + } elseif (str_starts_with($partValue, 'user')) { + preg_match('/user\\s*\[(.*)\]/is', $partValue, $typeMatches); + if (!empty($typeMatches[1] ?? '')) { + $parsedCommentArray['type'] = 'user'; + $userFunction = $typeMatches[1]; + $userFunctionParams = [ + 'fieldName' => $nodeName, + 'fieldValue' => $currentValue, + ]; + $parsedCommentArray['html'] = (string)GeneralUtility::callUserFunction( + $userFunction, + $userFunctionParams + ); + } + } elseif ($partValue === 'comment') { + $parsedCommentArray['type'] = 'comment'; + } elseif ($partValue === 'color') { + $parsedCommentArray['type'] = 'color'; + } else { + $parsedCommentArray['type'] = 'string'; + } + } elseif ($partKey === 'cat') { + $categorySplitArray = explode('/', strtolower($partValue)); + $mainCategory = strtolower(trim($categorySplitArray[0])); + if (empty($mainCategory)) { + return []; + } + if (isset($this->categories[$mainCategory])) { + $this->categories[$mainCategory]['usageCount']++; + } else { + $this->categories[$mainCategory] = [ + 'usageCount' => 1, + 'label' => $mainCategory, + ]; + } + $parsedCommentArray['cat'] = $mainCategory; + $subCategory = trim($categorySplitArray[1] ?? ''); + $subCategoryOrder = trim($categorySplitArray[2] ?? ''); + if ($subCategory && array_key_exists($subCategory, $this->subCategories)) { + $parsedCommentArray['subcat_name'] = $subCategory; + $parsedCommentArray['subcat_label'] = $languageService->sL($this->subCategories[$subCategory]['label']); + $sortIdentifier = empty($subCategoryOrder) ? $this->subCategoryCounter : $subCategoryOrder; + $parsedCommentArray['subcat_sorting_first'] = $this->subCategories[$subCategory]['sorting']; + $parsedCommentArray['subcat_sorting_second'] = $sortIdentifier . 'z'; + } elseif ($subCategoryOrder !== '') { + // "0" is a valid key for an assignment like "# cat=foo//0; type=boolean; label=some config" + $parsedCommentArray['subcat_name'] = 'other'; + $parsedCommentArray['subcat_label'] = 'Other'; + $parsedCommentArray['subcat_sorting_first'] = 'o'; + $parsedCommentArray['subcat_sorting_second'] = $subCategoryOrder . 'z'; + } else { + $parsedCommentArray['subcat_name'] = 'other'; + $parsedCommentArray['subcat_label'] = 'Other'; + $parsedCommentArray['subcat_sorting_first'] = 'o'; + $parsedCommentArray['subcat_sorting_second'] = $this->subCategoryCounter . 'z'; + } + } elseif ($partKey === 'label') { + $fullLabel = $languageService->sL($partValue); + $splitLabelArray = explode(':', $fullLabel, 2); + $parsedCommentArray['label'] = $splitLabelArray[0]; + $parsedCommentArray['description'] = $splitLabelArray[1] ?? ''; + } + } + if (!array_key_exists('cat', $parsedCommentArray)) { + // At least 'category' must be there, everything else is optional. + return []; + } + $parsedCommentArray['type'] ??= 'string'; + return $parsedCommentArray; + } + + /** + * Parse RootNode comments for additional custom categories and sub categories + * and register them in $this properties. + */ + private function parseCustomCategoryAndSubCategories(TokenStreamInterface $commentTokenStream): void + { + $languageService = $this->getLanguageService(); + $firstTokenType = $commentTokenStream->peekNext()->getType(); + if ($firstTokenType !== TokenType::T_COMMENT_ONELINE_HASH && $firstTokenType !== TokenType::T_COMMENT_ONELINE_DOUBLESLASH) { + // Ignore multiline comments, only '#' and '//' allowed here + return; + } + $commentString = trim((string)$commentTokenStream); + // Get rid of '#' and '//' + $commentString = trim(preg_replace('/^[#\\/]*/', '', $commentString)); + if (empty($commentString)) { + return; + } + // "# customcategory=myCustomCategoryKey=My custom category label" + if (str_contains($commentString, '=') && str_starts_with(strtolower($commentString), 'customcategory')) { + $customCategoryArray = explode('=', $commentString, 3); + if (strtolower(trim($customCategoryArray[0])) !== 'customcategory' + || empty(trim($customCategoryArray[1])) + || empty(trim($customCategoryArray[2])) + ) { + return; + } + $categoryKey = strtolower($customCategoryArray[1]); + $categoryLabel = $customCategoryArray[2]; + if (!isset($this->categories[$categoryKey])) { + $this->categories[$categoryKey] = [ + 'usageCount' => 0, + 'label' => $languageService->sL($categoryLabel), + ]; + } + return; + } + // "customsubcategory=120=My custom sub category label" + if (str_contains($commentString, '=') && str_starts_with(strtolower($commentString), 'customsubcategory')) { + $customSubCategoryArray = explode('=', $commentString, 3); + if (strtolower(trim($customSubCategoryArray[0])) !== 'customsubcategory' + || empty(trim($customSubCategoryArray[1])) + || empty(trim($customSubCategoryArray[2])) + ) { + return; + } + $subCategoryKey = strtolower($customSubCategoryArray[1]); + $subCategoryLabel = $customSubCategoryArray[2]; + if (!isset($this->subCategories[$subCategoryKey])) { + $this->subCategories[$subCategoryKey] = [ + 'label' => $languageService->sL($subCategoryLabel), + 'sorting' => $this->subCategoryCounter, + ]; + } + } + } + + public function visit(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } + + public function visitAfterChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } + + private function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/TypoScript/AST/Visitor/AstNodeFinderVisitor.php b/Classes/TypoScript/AST/Visitor/AstNodeFinderVisitor.php new file mode 100644 index 0000000..91a89b5 --- /dev/null +++ b/Classes/TypoScript/AST/Visitor/AstNodeFinderVisitor.php @@ -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\Core\TypoScript\AST\Visitor; + +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * Find a single node in tree identified by node identifier. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class AstNodeFinderVisitor implements AstVisitorInterface +{ + private string $nodeIdentifier; + private ?NodeInterface $foundNode = null; + private ?CurrentObjectPath $foundNodeCurrentObjectPath = null; + + public function setNodeIdentifier(string $nodeIdentifier) + { + $this->nodeIdentifier = $nodeIdentifier; + } + + public function getFoundNode(): ?NodeInterface + { + return $this->foundNode; + } + + public function getFoundNodeCurrentObjectPath(): ?CurrentObjectPath + { + return $this->foundNodeCurrentObjectPath; + } + + public function visitBeforeChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + if ($node->getIdentifier() === $this->nodeIdentifier) { + $this->foundNode = $node; + $this->foundNodeCurrentObjectPath = clone $currentObjectPath; + } + } + + public function visit(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } + + public function visitAfterChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } +} diff --git a/Classes/TypoScript/AST/Visitor/AstSortChildrenVisitor.php b/Classes/TypoScript/AST/Visitor/AstSortChildrenVisitor.php new file mode 100644 index 0000000..2a7d1b9 --- /dev/null +++ b/Classes/TypoScript/AST/Visitor/AstSortChildrenVisitor.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Visitor; + +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * Sort all children alphabetically. Used in backend Object Browser. + * + * @internal: Internal AST structure. + */ +final class AstSortChildrenVisitor implements AstVisitorInterface +{ + public function visitBeforeChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + $node->sortChildren(); + } + + public function visit(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } + + public function visitAfterChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void + { + // Implement interface + } +} diff --git a/Classes/TypoScript/AST/Visitor/AstVisitorInterface.php b/Classes/TypoScript/AST/Visitor/AstVisitorInterface.php new file mode 100644 index 0000000..d4d80b4 --- /dev/null +++ b/Classes/TypoScript/AST/Visitor/AstVisitorInterface.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\AST\Visitor; + +use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath; +use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * An interface implemented by all visitors of AstTraverser. + * + * @internal: Internal AST structure. + */ +interface AstVisitorInterface +{ + public function visitBeforeChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void; + + public function visit(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void; + + public function visitAfterChildren(RootNode $rootNode, NodeInterface $node, CurrentObjectPath $currentObjectPath, int $currentDepth): void; +} diff --git a/Classes/TypoScript/FrontendTypoScript.php b/Classes/TypoScript/FrontendTypoScript.php new file mode 100644 index 0000000..1466ec9 --- /dev/null +++ b/Classes/TypoScript/FrontendTypoScript.php @@ -0,0 +1,308 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; + +/** + * This class contains the TypoScript set up by the PrepareTypoScriptFrontendRendering + * Frontend middleware. It can be accessed in content objects: + * + * $frontendTypoScript = $request->getAttribute('frontend.typoscript'); + */ +final class FrontendTypoScript +{ + private ?RootInclude $setupIncludeTree = null; + private ?RootNode $setupTree = null; + private ?array $setupArray = null; + private ?RootNode $configTree = null; + private ?array $configArray = null; + private ?RootNode $pageTree = null; + private ?array $pageArray = null; + + public function __construct( + private readonly RootNode $settingsTree, + private readonly array $settingsConditionList, + private readonly array $flatSettings, + private readonly array $setupConditionList, + ) {} + + /** + * The settings ("constants") AST. + * + * @internal Internal for now until the AST API stabilized. + */ + public function getSettingsTree(): RootNode + { + return $this->settingsTree; + } + + /** + * List of settings conditions with verdicts. Used internally for + * page cache identifier calculation. + * + * @internal + */ + public function getSettingsConditionList(): array + { + return $this->settingsConditionList; + } + + /** + * This is *always* set up by the middleware / factory: Current settings ("constants") + * are needed for page cache identifier calculation. + * This is a "flattened" array of all settings, as example, consider these settings TypoScript: + * + * ``` + * mySettings { + * foo = fooValue + * bar = barValue + * } + * ``` + * + * This will result in this array: + * + * ``` + * $flatSettings = [ + * 'mySettings.foo' => 'fooValue', + * 'mySettings.bar' => 'barValue', + * ]; + * ``` + */ + public function getFlatSettings(): array + { + return $this->flatSettings; + } + + /** + * List of setup conditions with verdicts. Used internally for + * page cache identifier calculation. + * + * @internal + */ + public function getSetupConditionList(): array + { + return $this->setupConditionList; + } + + /** + * @internal + */ + public function setSetupIncludeTree(RootInclude $setupIncludeTree): void + { + $this->setupIncludeTree = $setupIncludeTree; + } + + /** + * A tree of all TypoScript setup includes. Used internally within + * FrontendTypoScriptFactory to suppress calculating the include tree + * twice. + * + * @internal + */ + public function getSetupIncludeTree(): ?RootInclude + { + return $this->setupIncludeTree; + } + + /** + * @internal + */ + public function setSetupTree(RootNode $setupTree): void + { + $this->setupTree = $setupTree; + } + + /** + * When a page is retrieved from cache and does not contain COA_INT or USER_INT objects, + * Frontend TypoScript setup is not calculated, AST and the array representation aren't set. + * Calling getSetupTree() or getSetupArray() will then throw an exception. + * + * To avoid the exception, consumers can call hasSetup() beforehand. + * + * Note casual content objects do not need to do this, since setup TypoScript is always + * set up when content objects need to be calculated. + * + * @internal + */ + public function hasSetup(): bool + { + return $this->setupTree !== null; + } + + /** + * @internal Internal for now until the AST API stabilized. + */ + public function getSetupTree(): RootNode + { + if ($this->setupTree === null) { + throw new \RuntimeException( + 'Setup tree has not been initialized. This happens in cached Frontend scope where full TypoScript' + . ' is not needed by the system.', + 1666513644 + ); + } + return $this->setupTree; + } + + /** + * @internal + */ + public function setSetupArray(array $setupArray): void + { + $this->setupArray = $setupArray; + } + + /** + * The full Frontend TypoScript array. + * + * This is always set up as soon as the Frontend rendering needs to actually render something and + * can not get the *full* content from page cache. This is the case when a page cache entry does + * not exist, or when the page contains COA_INT or USER_INT objects. + */ + public function getSetupArray(): array + { + if ($this->setupArray === null) { + throw new \RuntimeException( + 'Setup array has not been initialized. This happens in cached Frontend scope where full TypoScript' + . ' is not needed by the system.', + 1666513645 + ); + } + return $this->setupArray; + } + + /** + * @internal + */ + public function setConfigTree(RootNode $setupConfig): void + { + $this->configTree = $setupConfig; + } + + /** + * The merged TypoScript 'config.'. + * + * This is the result of the "global" TypoScript 'config' section, merged with + * the 'config' section of the determined PAGE object which can override + * "global" 'config' per type / typeNum. + * + * This is *always* needed within casual Frontend rendering by FrontendTypoScriptFactory and + * has a dedicated cache layer to be quick to retrieve. It is needed even in fully cached pages + * context to for instance know if debug headers should be added ("config.debug=1") to a response. + * + * @internal Internal for now until the AST API stabilized. + */ + public function getConfigTree(): RootNode + { + if ($this->configTree === null) { + throw new \RuntimeException( + 'Setup "config." not initialized. FrontendTypoScriptFactory->createSetupConfigOrFullSetup() not called?', + 1710666154 + ); + } + return $this->configTree; + } + + /** + * @internal + */ + public function setConfigArray(array $configArray): void + { + $this->configArray = $configArray; + } + + /** + * Array representation of getConfigTree(). + */ + public function getConfigArray(): array + { + if ($this->configArray === null) { + throw new \RuntimeException( + 'Setup "config." not initialized. FrontendTypoScriptFactory->createSetupConfigOrFullSetup() not called?', + 1710666123 + ); + } + return $this->configArray; + } + + /** + * @internal + */ + public function setPageTree(RootNode $pageTree): void + { + $this->pageTree = $pageTree; + } + + /** + * The determined PAGE object from main TypoScript 'setup' that depends + * on type / typeNum. + * + * This is used internally by RequestHandler for page generation. + * It is *not* set in full cached page scenarios without _INT object. + * + * @internal + */ + public function getPageTree(): RootNode + { + if ($this->pageTree === null) { + throw new \RuntimeException( + 'PAGE node has not been initialized. This happens in cached Frontend scope where full TypoScript' + . ' is not needed by the system, and if a PAGE object for given type could not be determined.' + . ' Test with hasPage().', + 1710399966 + ); + } + return $this->pageTree; + } + + /** + * @internal + */ + public function hasPage(): bool + { + return $this->pageTree !== null; + } + + /** + * @internal + */ + public function setPageArray(array $pageArray): void + { + $this->pageArray = $pageArray; + } + + /** + * Array representation of getPageTree(). + * + * @internal + */ + public function getPageArray(): array + { + if ($this->pageArray === null) { + throw new \RuntimeException( + 'PAGE array has not been initialized. This happens in cached Frontend scope where full TypoScript' + . ' is not needed by the system, and if a PAGE object for given type could not be determined.' + . ' Test with hasPage().', + 1710399967 + ); + } + return $this->pageArray; + } +} diff --git a/Classes/TypoScript/FrontendTypoScriptFactory.php b/Classes/TypoScript/FrontendTypoScriptFactory.php new file mode 100644 index 0000000..3efaaa4 --- /dev/null +++ b/Classes/TypoScript/FrontendTypoScriptFactory.php @@ -0,0 +1,484 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\TypoScript\AST\Merger\SetupConfigMerger; +use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNode; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateTreeBuilder; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\ConditionVerdictAwareIncludeTreeTraverser; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionIncludeListAccumulatorVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionMatcherVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeSetupConditionConstantSubstitutionVisitor; +use TYPO3\CMS\Core\TypoScript\Tokenizer\LossyTokenizer; +use TYPO3\CMS\Frontend\Event\ModifyTypoScriptConfigEvent; +use TYPO3\CMS\Frontend\Event\ModifyTypoScriptConstantsEvent; + +/** + * Create FrontendTypoScript with its details. This is typically used by a Frontend middleware + * to calculate the TypoScript needed to satisfy rendering details of the specific Request. + * + * @internal Methods signatures and detail implementations are still subject to change. + */ +final readonly class FrontendTypoScriptFactory +{ + public function __construct( + private ContainerInterface $container, + private EventDispatcherInterface $eventDispatcher, + private SysTemplateTreeBuilder $treeBuilder, + private LossyTokenizer $tokenizer, + private IncludeTreeTraverser $includeTreeTraverser, + private ConditionVerdictAwareIncludeTreeTraverser $includeTreeTraverserConditionVerdictAware, + ) {} + + /** + * First step of TypoScript calculations. + * This is *always* called, even in FE fully cached pages context since the page + * cache entry depends on setup condition verdicts, which depends on settings. + * + * Returns the FrontendTypoScript object with these parameters set: + * * settingsTree: The full settings ("constants") AST + * * flatSettings: Flattened list of settings, derived from settings tree + * * settingsConditionList: Settings conditions with verdicts of this Request + * * setupConditionList: Setup conditions with verdicts of this Request + * * (sometimes) setupIncludeTree: The setup include tree *if* it had to be calculated + */ + public function createSettingsAndSetupConditions( + SiteInterface $site, + array $sysTemplateRows, + array $expressionMatcherVariables, + ?PhpFrontend $typoScriptCache, + ): FrontendTypoScript { + $settingsDetails = $this->createSettings( + $site, + $sysTemplateRows, + $expressionMatcherVariables, + $typoScriptCache + ); + $setupDetails = $this->createSetupConditionList( + $site, + $sysTemplateRows, + $expressionMatcherVariables, + $typoScriptCache, + $settingsDetails['flatSettings'], + $settingsDetails['settingsConditionList'], + ); + $frontendTypoScript = new FrontendTypoScript( + $settingsDetails['settingsTree'], + $settingsDetails['settingsConditionList'], + $settingsDetails['flatSettings'], + $setupDetails['setupConditionList'], + ); + if ($setupDetails['setupIncludeTree']) { + $frontendTypoScript->setSetupIncludeTree($setupDetails['setupIncludeTree']); + } + return $frontendTypoScript; + } + + /** + * Calculate settings (formerly "constants"). + * + * The page cache entry identifier depends on setup TypoScript: A single page with two different + * setup TypoScript AST will probably render different results, thus two page-cache entries. + * Setup TypoScript can be different when setup conditions match differently. + * Setup conditions can use settings "[{$foo} = 42]". + * + * All FE requests thus need the current list of settings, and settings can have conditions, too. + * We thus *always* need the current list of settings, even in fully cached pages context. + * + * The method calculates settings and uses caches as much as possible: + * * settingsTree: The full settings AST + * * flatSettings: Flattened list of settings, derived from settings AST + * * settingsConditionList: Settings conditions with verdicts of this Request + * + * @return array{settingsTree: RootNode, flatSettings: array, settingsConditionList: array} + */ + private function createSettings( + SiteInterface $site, + array $sysTemplateRows, + array $expressionMatcherVariables, + ?PhpFrontend $typoScriptCache, + ): array { + $cacheCriteria = [ + 'sysTemplateRows' => $sysTemplateRows, + ]; + if ($site instanceof Site && $site->isTypoScriptRoot()) { + $cacheCriteria['siteIdentifier'] = $site->getIdentifier(); + } + $conditionTreeCacheIdentifier = 'settings-condition-tree-' . hash('xxh3', json_encode($cacheCriteria, JSON_THROW_ON_ERROR)); + + if ($conditionTree = $typoScriptCache?->require($conditionTreeCacheIdentifier)) { + // Got the (flat) include tree of all settings conditions for this TypoScript combination from cache. + // Good. Traverse this list to calculate "current" condition verdicts. Hash this list together with a + // hash of the TypoScript sys_templates, and try to retrieve the full settings TypoScript AST from cache. + // Note: Working with the derived condition tree that *only* contains conditions, but not the full + // include tree is a trick: We only need the condition verdicts to know the AST cache identifier, + // and traversing the flat condition tree is quicker than traversing the entire settings include tree, + // since it only scales with the number of settings conditions and not with the full amount of TypoScript + // settings. The same trick is used for the setup AST cache later. + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + // It does not matter if we use IncludeTreeTraverser or ConditionVerdictAwareIncludeTreeTraverser here: + // Conditions list is flat, not nested. IncludeTreeTraverser has an if() less, so we use that one. + $this->includeTreeTraverser->traverse($conditionTree, [$conditionMatcherVisitor]); + $conditionList = $conditionMatcherVisitor->getConditionListWithVerdicts(); + $settings = $typoScriptCache->require( + 'settings-' . hash('xxh3', $conditionTreeCacheIdentifier . json_encode($conditionList, JSON_THROW_ON_ERROR)) + ); + if (is_array($settings)) { + return [ + 'settingsTree' => $settings['ast'], + 'flatSettings' => $settings['flatSettings'], + 'settingsConditionList' => $conditionList, + ]; + } + } + + // We did not get settings from cache, or are not allowed to use cache. Build settings from scratch. + // We fetch the full settings include tree (from cache if possible), register the condition + // matcher and register the AST builder and traverse include tree to retrieve settings AST and derive + // 'flat settings' from it. Both are cached if allowed afterward for the above 'if' to kick in next time. + $includeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('constants', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + $visitors = []; + $visitors[] = $conditionMatcherVisitor; + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $visitors[] = $astBuilderVisitor; + // We must use ConditionVerdictAwareIncludeTreeTraverser here: This one does not walk into + // children for not matching conditions, which is important to create the correct AST. + $this->includeTreeTraverserConditionVerdictAware->traverse($includeTree, $visitors); + $tree = $astBuilderVisitor->getAst(); + // @internal Dispatch an experimental event allowing listeners to still change the settings AST, + // to for instance implement nested constants if really needed. Note this event may change + // or vanish later without further notice. + $tree = $this->eventDispatcher->dispatch(new ModifyTypoScriptConstantsEvent($tree))->getConstantsAst(); + $flatSettings = $tree->flatten(); + + // Prepare the full list of settings conditions in order to cache this list, avoiding the + // settings AST building next time. We need all conditions of the entire include tree, but the + // above ConditionVerdictAwareIncludeTreeTraverser did not find nested conditions if an upper + // condition did not match. We thus have to traverse include tree a second time with the + // IncludeTreeTraverser. This one does traverse into not matching conditions. + $visitors = []; + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + $visitors[] = $conditionMatcherVisitor; + $conditionTreeAccumulatorVisitor = null; + if (!$conditionTree && $typoScriptCache) { + // If the settingsConditionTree did not come from cache above and if we are allowed to cache, + // register the visitor that creates the settings condition include tree, to cache it. + $conditionTreeAccumulatorVisitor = $this->container->get(IncludeTreeConditionIncludeListAccumulatorVisitor::class); + $visitors[] = $conditionTreeAccumulatorVisitor; + } + $this->includeTreeTraverser->traverse($includeTree, $visitors); + $conditionList = $conditionMatcherVisitor->getConditionListWithVerdicts(); + + if ($conditionTreeAccumulatorVisitor) { + // Cache the flat condition include tree for next run. + $conditionTree = $conditionTreeAccumulatorVisitor->getConditionIncludes(); + $typoScriptCache?->set( + $conditionTreeCacheIdentifier, + 'return unserialize(\'' . addcslashes(serialize($conditionTree), '\'\\') . '\');' + ); + } + $typoScriptCache?->set( + // Cache full AST and the derived 'flattened' variant for next run, which will kick in if + // the sys_templates and condition verdicts are identical with another Request. + 'settings-' . hash('xxh3', $conditionTreeCacheIdentifier . json_encode($conditionList, JSON_THROW_ON_ERROR)), + 'return unserialize(\'' . addcslashes(serialize(['ast' => $tree, 'flatSettings' => $flatSettings]), '\'\\') . '\');' + ); + + return [ + 'settingsTree' => $tree, + 'flatSettings' => $flatSettings, + 'settingsConditionList' => $conditionList, + ]; + } + + /** + * Calculate setup condition verdicts. + * + * With settings being done, the list of matching setup condition verdicts is calculated, + * which depend on settings. Setup conditions with their verdicts are part of the page + * cache identifier, they are *always* needed in the FE rendering chain. + * + * The cached variant uses a similar trick as with the settings calculation above: We + * calculate a flat tree of all conditions and cache this, so the traverser only needs + * to iterate the conditions to calculate their verdicts, but not the entire include + * tree next time. + * + * The method returns: + * * 'setupConditionList': Setup conditions with verdicts of this Request + * * (sometimes) setupIncludeTree: The setup include tree *if* it had to be calculated. Used internally + * to suppress a second calculation in createSetupConfigOrFullSetup(). + * + * @return array{setupConditionList: array, setupIncludeTree: RootInclude|null} + */ + private function createSetupConditionList( + SiteInterface $site, + array $sysTemplateRows, + array $expressionMatcherVariables, + ?PhpFrontend $typoScriptCache, + array $flatSettings, + array $settingsConditionList, + ): array { + $conditionTreeCacheIdentifier = 'setup-condition-tree-' . hash( + 'xxh3', + json_encode($sysTemplateRows, JSON_THROW_ON_ERROR) + . json_encode($site instanceof Site && $site->isTypoScriptRoot() ? $site->getSets() : '', JSON_THROW_ON_ERROR) + . json_encode($settingsConditionList, JSON_THROW_ON_ERROR) + ); + + if ($conditionTree = $typoScriptCache?->require($conditionTreeCacheIdentifier)) { + // We got the flat list of all setup conditions for this TypoScript combination from cache. Good. We traverse + // this list to calculate "current" condition verdicts, which we need as hash to be part of page cache identifier. + // We're done and return. Note 'setupIncludeTree' is *not* returned in this case since it is not needed and + // may or may not be needed later, depending on if we can get a page cache entry later and if it has _INT objects. + $visitors = []; + $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); + $conditionConstantSubstitutionVisitor->setFlattenedConstants($flatSettings); + $visitors[] = $conditionConstantSubstitutionVisitor; + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + $visitors[] = $conditionMatcherVisitor; + // It does not matter if we use IncludeTreeTraverser or ConditionVerdictAwareIncludeTreeTraverser here: + // Condition list is flat, not nested. IncludeTreeTraverser has an if() less, so we use that one. + $this->includeTreeTraverser->traverse($conditionTree, $visitors); + return [ + 'setupConditionList' => $conditionMatcherVisitor->getConditionListWithVerdicts(), + 'setupIncludeTree' => null, + ]; + } + + // We did not get setup condition list from cache, or are not allowed to use cache. We have to build setup + // condition list from scratch. This means we'll fetch the full setup include tree (from cache if possible), + // register the constant substitution visitor, the condition matcher and the condition accumulator visitor. + $includeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); + $visitors = []; + $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); + $conditionConstantSubstitutionVisitor->setFlattenedConstants($flatSettings); + $visitors[] = $conditionConstantSubstitutionVisitor; + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + $visitors[] = $conditionMatcherVisitor; + $conditionTreeAccumulatorVisitor = $this->container->get(IncludeTreeConditionIncludeListAccumulatorVisitor::class); + $visitors[] = $conditionTreeAccumulatorVisitor; + // It is important to use IncludeTreeTraverser here: We need the condition verdicts of *all* conditions, and + // we want to accumulate all of them. The ConditionVerdictAwareIncludeTreeTraverser wouldn't walk into nested + // conditions if an upper one does not match, which defeats cache identifier calculations. + $this->includeTreeTraverser->traverse($includeTree, $visitors); + + $typoScriptCache?->set( + $conditionTreeCacheIdentifier, + 'return unserialize(\'' . addcslashes(serialize($conditionTreeAccumulatorVisitor->getConditionIncludes()), '\'\\') . '\');' + ); + + return [ + 'setupConditionList' => $conditionMatcherVisitor->getConditionListWithVerdicts(), + 'setupIncludeTree' => $includeTree, + ]; + } + + /** + * Enrich the given FrontendTypoScript object with TypoScript 'setup' relevant data. + * + * The method is called in FE after an attempt to retrieve page content from cache has + * been done. There are three possible outcomes: + * * The page has been retrieved from cache and the content *does not* contain uncached "_INT" objects + * * The page has been retrieved from cache and the content *does* contain uncached "_INT" objects + * * The page could not be retrieved from cache + * + * If the page could not be retrieved from cache, or if the cached page content contains "_INT" objects, + * flag $needsFullSetup is given true, and the full TypoScript is calculated since at least parts of + * the page content has to be rendered, which then needs full TypoScript. + * If the page could be retrieved from cache, and contains no "_INT" objects, $needsFullSetup in false, the + * rendering chain only needs the "config." part of TypoScript to satisfy the remaining middlewares. + * + * The method implements these variants and tries to add as little overhead as possible. + * + * Returns the FrontendTypoScript object: + * * configTree: Always set. Global TypoScript 'config.' merged with overrides from given type/typeNum "page.config.". + * * configArray: Always set. Array representation of configTree. + * * setupTree: Not set if $needsFullSetup=false and configTree could be retrieved from cache. Full TypoScript setup. + * * setupArray: Not set if $needsFullSetup=false and configTree could be retrieved from cache. + * Array representation of setupTree. + * * pageTree: Not set if $needsFullSetup=false and configTree could be retrieved from cache, or if no PAGE object + * could be determined. The 'PAGE' object tree for given type/typeNum. + * * pageArray: Not set if $needsFullSetup=false and configTree could be retrieved from cache, or if no PAGE object + * could be determined. Array representation of PageTree. + */ + public function createSetupConfigOrFullSetup( + bool $needsFullSetup, + FrontendTypoScript $frontendTypoScript, + SiteInterface $site, + array $sysTemplateRows, + array $expressionMatcherVariables, + string $type, + ?PhpFrontend $typoScriptCache, + ?ServerRequestInterface $request, + ): FrontendTypoScript { + $setupTypoScriptCacheIdentifier = 'setup-' . hash( + 'xxh3', + json_encode($sysTemplateRows, JSON_THROW_ON_ERROR) + . ($site instanceof Site && $site->isTypoScriptRoot() ? $site->getIdentifier() : '') + . json_encode($frontendTypoScript->getSettingsConditionList(), JSON_THROW_ON_ERROR) + . json_encode($frontendTypoScript->getSetupConditionList(), JSON_THROW_ON_ERROR) + ); + $setupConfigTypoScriptCacheIdentifier = 'setup-config-' . hash('xxh3', $setupTypoScriptCacheIdentifier . $type); + + $gotSetupConfigFromCache = false; + if ($setupConfigTypoScriptCache = $typoScriptCache?->require($setupConfigTypoScriptCacheIdentifier)) { + $frontendTypoScript->setConfigTree($setupConfigTypoScriptCache['ast']); + $frontendTypoScript->setConfigArray($setupConfigTypoScriptCache['array']); + if (!$needsFullSetup) { + // Fully cached page context without _INT - only 'config' is needed. Return early. + return $frontendTypoScript; + } + $gotSetupConfigFromCache = true; + } + + $setupRawConfigAst = null; + if (!$typoScriptCache || $needsFullSetup || !$gotSetupConfigFromCache) { + // If caching is not allowed, if no page cache entry could be loaded or if the page cache entry has _INT + // object, we need the full setup AST. Try to use a cache entry for setup AST, which especially up _INT + // parsing. In unavailable, calculate full setup AST and cache it if allowed. + $gotSetupFromCache = false; + if ($setupTypoScriptCache = $typoScriptCache?->require($setupTypoScriptCacheIdentifier)) { + // We need AST, and we got it from cache. + $frontendTypoScript->setSetupTree($setupTypoScriptCache['ast']); + $frontendTypoScript->setSetupArray($setupTypoScriptCache['array']); + $setupRawConfigAst = $setupTypoScriptCache['ast']->getChildByName('config'); + $gotSetupFromCache = true; + } + if (!$typoScriptCache || !$gotSetupFromCache) { + // We need AST and couldn't get it from cache or are now allowed to. We thus need the full setup + // IncludeTree, which we can get from cache again if allowed, or is calculated a-new if not. + $setupIncludeTree = $frontendTypoScript->getSetupIncludeTree(); + if (!$typoScriptCache || $setupIncludeTree === null) { + // A previous method *may* have calculated setup include tree already. Calculate now if not. + $setupIncludeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->tokenizer, $site, $typoScriptCache); + } + $visitors = []; + $conditionConstantSubstitutionVisitor = $this->container->get(IncludeTreeSetupConditionConstantSubstitutionVisitor::class); + $conditionConstantSubstitutionVisitor->setFlattenedConstants($frontendTypoScript->getFlatSettings()); + $visitors[] = $conditionConstantSubstitutionVisitor; + $conditionMatcherVisitor = $this->container->get(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($expressionMatcherVariables); + $visitors[] = $conditionMatcherVisitor; + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $astBuilderVisitor->setFlatConstants($frontendTypoScript->getFlatSettings()); + $visitors[] = $astBuilderVisitor; + $this->includeTreeTraverserConditionVerdictAware->traverse($setupIncludeTree, $visitors); + $setupAst = $astBuilderVisitor->getAst(); + // @todo: It would be good to actively remove 'config' from AST and array here + // to prevent people from using the unmerged variant. The same + // is already done for the determined PAGE 'config' below. This works, but + // is currently blocked by functional tests that assert details? + // Also, we need to still cache with full 'config' to handle multiple types. + $setupRawConfigAst = $setupAst->getChildByName('config'); + $frontendTypoScript->setSetupTree($setupAst); + $frontendTypoScript->setSetupArray($setupAst->toArray()); + + // Write cache entry for AST and its array representation. + $typoScriptCache?->set( + $setupTypoScriptCacheIdentifier, + 'return unserialize(\'' . addcslashes(serialize(['ast' => $setupAst, 'array' => $setupAst->toArray()]), '\'\\') . '\');' + ); + } + + $setupAst = $frontendTypoScript->getSetupTree(); + $rawSetupPageNodeFromType = null; + $pageNodeFoundByType = false; + foreach ($setupAst->getNextChild() as $potentialPageNode) { + // Find the PAGE object that matches given type/typeNum + if ($potentialPageNode->getValue() === 'PAGE') { + // @todo: We could potentially remove *all* PAGE objects from setup here. This prevents people + // from accessing other ones than the determined one in $frontendTypoScript->getSetupArray(). + $typeNumChild = $potentialPageNode->getChildByName('typeNum'); + if ($typeNumChild && $type === $typeNumChild->getValue()) { + $rawSetupPageNodeFromType = $potentialPageNode; + $pageNodeFoundByType = true; + break; + } + if (!$typeNumChild && $type === '0') { + // The first PAGE node that has no typeNum is considered '0' automatically. + $rawSetupPageNodeFromType = $potentialPageNode; + $pageNodeFoundByType = true; + break; + } + } + } + if (!$pageNodeFoundByType) { + $rawSetupPageNodeFromType = new RootNode(); + } + $setupPageAst = new RootNode(); + foreach ($rawSetupPageNodeFromType->getNextChild() as $child) { + $setupPageAst->addChild($child); + } + + if (!$gotSetupConfigFromCache) { + // If we did not get merged 'config.' from cache above, create it now and cache it. + $mergedSetupConfigAst = (new SetupConfigMerger())->merge($setupRawConfigAst, $setupPageAst->getChildByName('config')); + if ($mergedSetupConfigAst->getChildByName('absRefPrefix') === null) { + // Make sure config.absRefPrefix is set, fallback to 'auto'. + $absRefPrefixNode = new ChildNode('absRefPrefix'); + $absRefPrefixNode->setValue('auto'); + $mergedSetupConfigAst->addChild($absRefPrefixNode); + } + if ($mergedSetupConfigAst->getChildByName('doctype') === null) { + // Make sure config.doctype is set, fallback to 'html5'. + $doctypeNode = new ChildNode('doctype'); + $doctypeNode->setValue('html5'); + $mergedSetupConfigAst->addChild($doctypeNode); + } + if ($request) { + // Dispatch ModifyTypoScriptConfigEvent before config is cached and if Request is given. + $mergedSetupConfigAst = $this->eventDispatcher + ->dispatch(new ModifyTypoScriptConfigEvent($request, $setupAst, $mergedSetupConfigAst))->getConfigTree(); + } + $frontendTypoScript->setConfigTree($mergedSetupConfigAst); + $setupConfigArray = $mergedSetupConfigAst->toArray(); + $frontendTypoScript->setConfigArray($setupConfigArray); + $typoScriptCache?->set( + $setupConfigTypoScriptCacheIdentifier, + 'return unserialize(\'' . addcslashes(serialize(['ast' => $mergedSetupConfigAst, 'array' => $setupConfigArray]), '\'\\') . '\');' + ); + } + + if ($pageNodeFoundByType) { + // Remove "page.config" to prevent people from working with the not merged variant. + // We do *not* set page if it could not be determined (important for hasPage() later + // to return an early "no PAGE for type found" Response. + $setupPageAst->removeChildByName('config'); + $frontendTypoScript->setPageTree($setupPageAst); + $frontendTypoScript->setPageArray($setupPageAst->toArray()); + } + } + return $frontendTypoScript; + } +} diff --git a/Classes/TypoScript/IncludeTree/Event/AfterTemplatesHaveBeenDeterminedEvent.php b/Classes/TypoScript/IncludeTree/Event/AfterTemplatesHaveBeenDeterminedEvent.php new file mode 100644 index 0000000..af1e087 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Event/AfterTemplatesHaveBeenDeterminedEvent.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Event; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; + +/** + * A PSR-14 event fired when sys_template rows have been fetched. + * + * This event is intended to add own rows based on given rows or site resolution. + */ +final class AfterTemplatesHaveBeenDeterminedEvent +{ + public function __construct( + private readonly array $rootline, + private readonly ?ServerRequestInterface $request, + private array $templateRows, + ) {} + + public function getRootline(): array + { + return $this->rootline; + } + + public function getRequest(): ?ServerRequestInterface + { + return $this->request; + } + + /** + * Convenience method to directly retrieve the Site. May be null though! + */ + public function getSite(): ?SiteInterface + { + return $this->request?->getAttribute('site'); + } + + public function getTemplateRows(): array + { + return $this->templateRows; + } + + public function setTemplateRows(array $templateRows): void + { + $this->templateRows = $templateRows; + } +} diff --git a/Classes/TypoScript/IncludeTree/Event/BeforeLoadedPageTsConfigEvent.php b/Classes/TypoScript/IncludeTree/Event/BeforeLoadedPageTsConfigEvent.php new file mode 100644 index 0000000..29ce27b --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Event/BeforeLoadedPageTsConfigEvent.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Event; + +/** + * Extensions can add global page TSconfig right before they are loaded from other sources + * like the global page.tsconfig file. + * + * Note: The added config should not depend on runtime / request. This is considered static + * config and thus should be identical on every request. + */ +final class BeforeLoadedPageTsConfigEvent +{ + public function __construct(private array $tsConfig = []) {} + + public function getTsConfig(): array + { + return $this->tsConfig; + } + + public function addTsConfig(string $tsConfig): void + { + $this->tsConfig[] = $tsConfig; + } + + public function setTsConfig(array $tsConfig): void + { + $this->tsConfig = $tsConfig; + } +} diff --git a/Classes/TypoScript/IncludeTree/Event/BeforeLoadedUserTsConfigEvent.php b/Classes/TypoScript/IncludeTree/Event/BeforeLoadedUserTsConfigEvent.php new file mode 100644 index 0000000..449f1b5 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Event/BeforeLoadedUserTsConfigEvent.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Event; + +/** + * Extensions can add global user TSconfig right before they are loaded from other sources + * like the global user.tsconfig file. + * + * Note: The added config should not depend on runtime / request. This is considered static + * config and thus should be identical on every request. + */ +final class BeforeLoadedUserTsConfigEvent +{ + public function __construct(private array $tsConfig = []) {} + + public function getTsConfig(): array + { + return $this->tsConfig; + } + + public function addTsConfig(string $tsConfig): void + { + $this->tsConfig[] = $tsConfig; + } + + public function setTsConfig(array $tsConfig): void + { + $this->tsConfig = $tsConfig; + } +} diff --git a/Classes/TypoScript/IncludeTree/Event/ModifyLoadedPageTsConfigEvent.php b/Classes/TypoScript/IncludeTree/Event/ModifyLoadedPageTsConfigEvent.php new file mode 100644 index 0000000..6588925 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Event/ModifyLoadedPageTsConfigEvent.php @@ -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\Core\TypoScript\IncludeTree\Event; + +/** + * Extensions can modify page TSconfig entries that can be overridden or added, based on the root line + */ +final class ModifyLoadedPageTsConfigEvent +{ + public function __construct(private array $tsConfig, private readonly array $rootLine) {} + + public function getTsConfig(): array + { + return $this->tsConfig; + } + + public function addTsConfig(string $tsConfig): void + { + $this->tsConfig[] = $tsConfig; + } + + public function setTsConfig(array $tsConfig): void + { + $this->tsConfig = $tsConfig; + } + + public function getRootLine(): array + { + return $this->rootLine; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/AbstractConditionInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/AbstractConditionInclude.php new file mode 100644 index 0000000..dfd427a --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/AbstractConditionInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * Base implementation of condition nodes. + * + * @internal: Internal tree structure. + */ +abstract class AbstractConditionInclude extends AbstractInclude implements IncludeConditionInterface +{ + protected Token $conditionValueToken; + protected ?Token $originalConditionValueToken = null; + protected bool $verdict; + + /** + * Add the condition token to cache when serialized. See __serialize() of AbstractInclude. + */ + protected function serialize(): array + { + $result = parent::serialize(); + $result['conditionValueToken'] = $this->conditionValueToken; + return $result; + } + + public function setConditionToken(Token $token): void + { + if ($token->getType() !== TokenType::T_VALUE) { + throw new \LogicException('Token must be of type T_VALUE', 1655977210); + } + $this->conditionValueToken = $token; + } + + public function getConditionToken(): Token + { + return $this->conditionValueToken; + } + + public function setOriginalConditionToken(Token $token): void + { + if ($token->getType() !== TokenType::T_VALUE) { + throw new \LogicException('Token must be of type T_VALUE', 1655977211); + } + $this->originalConditionValueToken = $token; + } + + public function getOriginalConditionToken(): ?Token + { + return $this->originalConditionValueToken; + } + + public function isConditionNegated(): bool + { + return false; + } + + public function setConditionVerdict(bool $verdict): void + { + $this->verdict = $verdict; + } + + public function getConditionVerdict(): bool + { + return $this->verdict; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/AbstractInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/AbstractInclude.php new file mode 100644 index 0000000..159ee11 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/AbstractInclude.php @@ -0,0 +1,203 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; + +/** + * Base implementation of IncludeInterface. + * + * @internal: Internal tree structure. + */ +abstract class AbstractInclude implements IncludeInterface +{ + private ?string $identifier = null; + protected string $name = ''; + protected string $path = ''; + + /** + * @var array<int, IncludeInterface> + */ + protected array $children = []; + protected ?LineStream $lineStream = null; + protected ?LineInterface $originalTokenLine = null; + protected bool $isSplit = false; + protected bool $root = false; + protected bool $clear = false; + protected ?int $pid = null; + + /** + * When storing to cache, we only store FE relevant properties and skip + * things like "name", "identifier" and friends. We also don't need the + * LineStream when a node is split. + */ + final public function __serialize(): array + { + return $this->serialize(); + } + + protected function serialize(): array + { + $result['children'] = $this->children; + if ($this->isSplit()) { + $result['isSplit'] = true; + } + if (!$this->isSplit()) { + $result['lineStream'] = $this->lineStream; + } + if ($this->isRoot()) { + $result['root'] = true; + } + if ($this->isClear()) { + $result['clear'] = true; + } + return $result; + } + + public function getType(): string + { + $classWithNamespace = static::class; + $lastBackslash = strrpos($classWithNamespace, '\\'); + return substr($classWithNamespace, $lastBackslash + 1, -7); + } + + public function setIdentifier(string $identifier): void + { + $this->identifier = hash('xxh3', $identifier); + $childCounter = 0; + foreach ($this->getNextChild() as $child) { + $child->setIdentifier($this->identifier . $childCounter); + $childCounter++; + } + } + + public function getIdentifier(): string + { + if ($this->identifier === null) { + throw new \RuntimeException( + 'Identifier has not been initialized. This happens when getIdentifier() is called on' + . ' trees retrieved from cache. The identifier is not supposed to be used in this context.', + 1673634853 + ); + } + return $this->identifier; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setPath(string $path): void + { + $this->path = $path; + } + + public function getPath(): string + { + return $this->path; + } + + public function addChild(IncludeInterface $node): void + { + $this->children[] = $node; + } + + public function hasChildren(): bool + { + return !empty($this->children); + } + + public function getNextChild(): iterable + { + foreach ($this->children as $child) { + yield $child; + } + } + + public function isSysTemplateRecord(): bool + { + return false; + } + + public function setLineStream(?LineStream $lineStream): void + { + $this->lineStream = $lineStream; + } + + public function getLineStream(): ?LineStream + { + return $this->lineStream; + } + + public function setOriginalLine(LineInterface $line): void + { + $this->originalTokenLine = $line; + } + + public function getOriginalLine(): ?LineInterface + { + return $this->originalTokenLine; + } + + public function setSplit(): void + { + $this->isSplit = true; + } + + public function isSplit(): bool + { + return $this->isSplit; + } + + public function setRoot(bool $root): void + { + $this->root = $root; + } + + public function isRoot(): bool + { + return $this->root; + } + + public function setClear(bool $clear): void + { + $this->clear = $clear; + } + + public function isClear(): bool + { + return $this->clear; + } + + public function setPid(int $pid): void + { + $this->pid = $pid; + } + + public function getPid(): ?int + { + return $this->pid; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/AtImportInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/AtImportInclude.php new file mode 100644 index 0000000..d8a3b00 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/AtImportInclude.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node representing an "@import" include. The LineStream is set + * to the content of the included source, which can be split again + * if that source contains further conditions or includes. + * + * @internal: Internal tree structure. + */ +final class AtImportInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/ConditionElseInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionElseInclude.php new file mode 100644 index 0000000..9119712 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionElseInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node representing the [ELSE] body of a condition: + * + * [foo = bar] + * ... + * [ELSE] + * baz = bazValue + * + * The LineStream is the body of the else block, the condition token + * is set to the token of the condition "[foo = bar]". + * + * @internal: Internal tree structure. + */ +final class ConditionElseInclude extends AbstractConditionInclude +{ + public function isConditionNegated(): bool + { + return true; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/ConditionInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionInclude.php new file mode 100644 index 0000000..79ee376 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionInclude.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node representing a condition and its body. + * + * [foo = bar] + * baz = bazValue + * [END] + * + * @internal: Internal tree structure. + */ +final class ConditionInclude extends AbstractConditionInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/ConditionStopInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionStopInclude.php new file mode 100644 index 0000000..e21f00e --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/ConditionStopInclude.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A simple include representing [END] and [GLOBAL] lines. + * + * @internal: Internal tree structure. + */ +final class ConditionStopInclude extends AbstractInclude +{ + public function addChild(IncludeInterface $node): void + { + throw new \LogicException('ConditionStopInclude can not have children', 1717691734); + } + + public function hasChildren(): bool + { + return false; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptInclude.php new file mode 100644 index 0000000..04cbea3 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node created for "default TypoScript" from globals, content from: + * $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_[constants|setup]']. + * + * @internal: Internal tree structure. + */ +final class DefaultTypoScriptInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptMagicKeyInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptMagicKeyInclude.php new file mode 100644 index 0000000..1eef65a --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/DefaultTypoScriptMagicKeyInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node created for "magic" include from globals, when processing + * $GLOBALS['TYPO3_CONF_VARS ']['FE']['defaultTypoScript_[constants|setup]'] + * + * @internal: Internal tree structure. + */ +final class DefaultTypoScriptMagicKeyInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/ExtensionStaticInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/ExtensionStaticInclude.php new file mode 100644 index 0000000..bc2a4b5 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/ExtensionStaticInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A node created for "extension static" TypoScript auto-include files: + * EXT:my_extension/ext_typoscript_[constants|setup].typoscript + * + * @internal: Internal tree structure. + */ +final class ExtensionStaticInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/FileInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/FileInclude.php new file mode 100644 index 0000000..e5cab53 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/FileInclude.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A classic include from sys_template "include_static_file": + * EXT:/My/Path/[constants|setup].[typoscript|ts|txt] + * + * This is always a child of an IncludeStaticFileDatabaseInclude. + * + * @internal: Internal tree structure. + */ +final class FileInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/IncludeConditionInterface.php b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeConditionInterface.php new file mode 100644 index 0000000..1aa3455 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeConditionInterface.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; + +/** + * Source streams that contain conditions are split smaller parts + * and each condition creates a Condition node. + * + * This interface is implemented by all conditions nodes. It allows + * "parking" the main condition token to be evaluated during AST building. + * + * @internal: Internal tree structure. + */ +interface IncludeConditionInterface +{ + /** + * Set and get the condition token: "[foo = bar]" + */ + public function setConditionToken(Token $token): void; + public function getConditionToken(): Token; + + /** + * Conditions may use constants: "[foo = {$bar}]". This getter/setter + * allows storing the original condition token string. + * This is set in backend only in case a constant substitution has taken + * place. Otherwise, the "vanilla" condition token is identical, + * getOriginalConditionToken() returns null and the condition token should + * be fetched from getConditionToken(). + */ + public function setOriginalConditionToken(Token $token): void; + public function getOriginalConditionToken(): ?Token; + + /** + * True for ConditionElseInclude: The [ELSE] node of a condition. + */ + public function isConditionNegated(): bool; + + /** + * When a condition is evaluated, this is set to true of false + * depending on the condition result. + */ + public function setConditionVerdict(bool $verdict): void; + public function getConditionVerdict(): bool; +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/IncludeInterface.php b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeInterface.php new file mode 100644 index 0000000..ba236f4 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeInterface.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; + +/** + * General interface of IncludeTree tree nodes. + * + * The TreeBuilder classes return a tree of these nodes, with the root node being + * a RootInclude. Each "include type" is represented by an own class: There + * is for instance "SysTemplateInclude" for a node that represents a sys_template + * row, and DefaultTypoScriptInclude for the default TypoScript string included from + * TYPO3_CONF_VARS. + * + * Nodes may have children, and a single stream of lines from the tokenizer + * may be split into multiple children: Each @import creates an own child node, + * and conditions trigger splitting as well. + * + * @internal: Internal tree structure. + */ +interface IncludeInterface +{ + /** + * A human-readable string derived from class name - Used in BE template analyzer + */ + public function getType(): string; + + /** + * An identifier for this include. Typically, a hash of some kind. This identifier + * is unique within the tree, by being created from the parent identifier plus + * something unique for this level like a counter. This identifier is used in the backend, + * when referencing single includes to be rendered. + * Calculating identifiers is initiated by calling setIdentifier() on RootNode, which + * will recurse the tree. Call this on the final tree, after include calculation finished, + * so include building itself does not need to fiddle with identifier updates. + * Note this value is skipped when persisting to caches since it's a Backend related + * thing that does not use cached context: When retrieving includes from cache + * (e.g. in Frontend), the identifier is null and calling the getter will throw an exception. + */ + public function setIdentifier(string $identifier): void; + public function getIdentifier(): string; + + /** + * A human-readable version of the identifier: Used in backend tree rendering. + */ + public function setName(string $name): void; + public function getName(): string; + + /** + * This is set to a non-empty string for includes that represent files. The file location + * is stored here, typically something like "EXT:my_extension/path/to/foo.typoscript". + * This is used when resolving file includes relative to a parent include, so a + * potential child node knows where to look relative to its parent path. + * Note this value is skipped when persisting to caches: The parent path + * information is no longer needed when a tree is fetched from cache since + * all children were attached already and don't need to be recalculated + * depending on their parent path value. + */ + public function setPath(string $path): void; + public function getPath(): string; + + /** + * Child maintenance methods. + */ + public function addChild(IncludeInterface $node): void; + public function hasChildren(): bool; + + /** + * @return iterable<IncludeInterface> + */ + public function getNextChild(): iterable; + + /** + * True for IncludeTypoScriptInclude - this node represents a sys_template record. + * When true, methods like isRoot() and isClear() are relevant. + */ + public function isSysTemplateRecord(): bool; + + /** + * The source split into single lines by a tokenizer. + */ + public function setLineStream(?LineStream $lineStream): void; + public function getLineStream(): ?LineStream; + + /** + * When an imports are handled, such a line is substituted by the included + * content. To be able to still output the original line, it is parked here. + * Relevant in backend tree and source display only. + */ + public function setOriginalLine(LineInterface $line): void; + public function getOriginalLine(): ?LineInterface; + + /** + * When included line streams contain conditions or imports, the node is split into + * children that contain single segments of the source. The node itself is then just + * a container and the LineStream attached is irrelevant for further processing. + * This flag is set when a line stream is split and the children fully represent the source. + */ + public function setSplit(): void; + public function isSplit(): bool; + + /** + * Set to true for IncludeTypoScriptInclude's (sys_template records) when "root" flag is set. + */ + public function setRoot(bool $root): void; + public function isRoot(): bool; + + /** + * Set to true for IncludeTypoScriptInclude's (sys_template records) when "clear constants" + * or "clear setup" is set. Depends on context if currently constants or setup are parsed. + */ + public function setClear(bool $clear): void; + public function isClear(): bool; + + /** + * Set to the pid of IncludeTypoScriptInclude's (sys_template records). Relevant in backend + * tree rendering only. + */ + public function setPid(int $pid): void; + public function getPid(): ?int; +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileDatabaseInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileDatabaseInclude.php new file mode 100644 index 0000000..502c24f --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileDatabaseInclude.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * Main node created for sys_template "include_static_file": + * This has FileInclude or IncludeStaticFileFileInclude children, + * depending on specific string. + * + * @internal: Internal tree structure. + */ +final class IncludeStaticFileDatabaseInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileFileInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileFileInclude.php new file mode 100644 index 0000000..075a165 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/IncludeStaticFileFileInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * Created when a sys_template "static_file_include" includes "include_static_file.txt" files: + * EXT:/My/Path/include_static_file.txt + * + * @internal: Internal tree structure. + */ +final class IncludeStaticFileFileInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/RootInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/RootInclude.php new file mode 100644 index 0000000..a6d09d4 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/RootInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * Root of the IncludeTree. Does not contain LineStreams itself, + * only children do. + * + * @internal: Internal tree structure. + */ +final class RootInclude extends AbstractInclude +{ + protected string $name = 'ROOT'; + + public function setName(string $name): void + { + throw new \LogicException('Can not set name on RootNode', 1656668001); + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/SegmentInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/SegmentInclude.php new file mode 100644 index 0000000..6946334 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/SegmentInclude.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * When a source stream is split into children because the LineStream contains + * conditions or imports, this node represents TypoScript that is not within + * condition or import context, the "baz = bazValue" part in the example below: + * + * [foo=bar] + * ... + * [END] + * baz = bazValue + * + * @internal: Internal tree structure. + */ +final class SegmentInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/SiteInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/SiteInclude.php new file mode 100644 index 0000000..e1d6001 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/SiteInclude.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * Include node created for includes from Site objects. Only relevant for constants. + * + * @internal: Internal tree structure. + */ +final class SiteInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/SiteTemplateInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/SiteTemplateInclude.php new file mode 100644 index 0000000..8f61240 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/SiteTemplateInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * The main node created for TypoScript from site sets + * and %configPath/sites/{constants,setup}.typoscript. + * + * @internal: Internal tree structure. + */ +final class SiteTemplateInclude extends AbstractInclude +{ + protected bool $root = true; + protected bool $clear = true; + + public function isRoot(): bool + { + return true; + } + + public function isClear(): bool + { + return true; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/StringInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/StringInclude.php new file mode 100644 index 0000000..2335fe7 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/StringInclude.php @@ -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\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * A simple include type used by StringTreeBuilder when a single entry + * TypoScript snipped is parsed. + * + * @internal: Internal tree structure. + */ +final class StringInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/SysTemplateInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/SysTemplateInclude.php new file mode 100644 index 0000000..a818005 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/SysTemplateInclude.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * The main node created for sys_template rows. + * + * @internal: Internal tree structure. + */ +final class SysTemplateInclude extends AbstractInclude +{ + public function isSysTemplateRecord(): bool + { + return true; + } +} diff --git a/Classes/TypoScript/IncludeTree/IncludeNode/TsConfigInclude.php b/Classes/TypoScript/IncludeTree/IncludeNode/TsConfigInclude.php new file mode 100644 index 0000000..c4965be --- /dev/null +++ b/Classes/TypoScript/IncludeTree/IncludeNode/TsConfigInclude.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode; + +/** + * An include type used by user and pages TsConfig for single TsConfig snippets. + * + * @internal: Internal tree structure. + */ +final class TsConfigInclude extends AbstractInclude {} diff --git a/Classes/TypoScript/IncludeTree/StringTreeBuilder.php b/Classes/TypoScript/IncludeTree/StringTreeBuilder.php new file mode 100644 index 0000000..533d22b --- /dev/null +++ b/Classes/TypoScript/IncludeTree/StringTreeBuilder.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\StringInclude; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; + +/** + * Parse a single TypoScript string, supporting imports and conditions. + * + * This is a relatively simple "tree" builder: It gets a single TypoScript string + * snippet, tokenizes it and creates a RootInclude "tree". The string is scanned + * for imports and conditions: Those create sub includes, just like the other + * TreeBuilder classes do. + * + * @internal + */ +#[Autoconfigure(public: true)] +final readonly class StringTreeBuilder +{ + public function __construct( + private TreeFromLineStreamBuilder $treeFromTokenStreamBuilder, + ) {} + + /** + * Create tree, ready to be traversed. Will cache if $cache is not null. + * + * @param non-empty-string $name A name used as cache identifier, [a-z,A-Z,-] only + */ + public function getTreeFromString( + string $name, + string $typoScriptString, + TokenizerInterface $tokenizer, + ?PhpFrontend $cache = null, + ): RootInclude { + $lowerCaseName = mb_strtolower($name); + $identifier = 'string-' . $lowerCaseName . '-' . hash('xxh3', $typoScriptString); + if ($cache) { + $includeTree = $cache->require($identifier); + if ($includeTree instanceof RootInclude) { + return $includeTree; + } + } + $includeTree = new RootInclude(); + $includeNode = new StringInclude(); + $includeNode->setName('[string] ' . $name); + $includeNode->setLineStream($tokenizer->tokenize($typoScriptString)); + $this->treeFromTokenStreamBuilder->buildTree($includeNode, 'other', $tokenizer); + $includeTree->addChild($includeNode); + $cache?->set($identifier, $this->prepareTreeForCache($includeTree)); + return $includeTree; + } + + private function prepareTreeForCache(RootInclude $node): string + { + return 'return unserialize(\'' . addcslashes(serialize($node), '\'\\') . '\');'; + } +} diff --git a/Classes/TypoScript/IncludeTree/SysTemplateRepository.php b/Classes/TypoScript/IncludeTree/SysTemplateRepository.php new file mode 100644 index 0000000..b2d0be8 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/SysTemplateRepository.php @@ -0,0 +1,220 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\VisibilityAspect; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Event\AfterTemplatesHaveBeenDeterminedEvent; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Fetch relevant sys_template records from database by given page rootline. + * + * The result sys_template rows are fed to the SysTemplateTreeBuilder for processing. + * + * @internal: Internal structure. There is optimization potential and especially getSysTemplateRowsByRootline() will probably vanish later. + */ +#[Autoconfigure(public: true)] +final readonly class SysTemplateRepository +{ + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private ConnectionPool $connectionPool, + private Context $context, + ) {} + + /** + * To calculate the TS include tree, we have to find sys_template rows attached to all rootline pages. + * When there are multiple active sys_template rows on a page, we pick the one with the lower sorting + * value. + * + * The query implementation below does that with *one* query for all rootline pages at once, not + * one query per page. To handle the capabilities mentioned above, the query is a bit nifty, but + * the implementation should scale nearly O(1) instead of O(n) with the rootline depth. + * + * @param ServerRequestInterface|null $request Nullable since Request is not a hard dependency ond just convenient for the Event + * + * @todo: It's potentially possible to get rid of this method in the frontend by joining sys_template + * into the Page rootline resolving as soon as it uses a CTE: This would save one query in *all* FE + * requests, even for fully-cached page requests. + */ + public function getSysTemplateRowsByRootline(array $rootline, ?ServerRequestInterface $request = null, ?VisibilityAspect $visibility = null): array + { + if ($rootline === []) { + return []; + } + + // Site-root node first! + $rootLinePageIds = array_reverse(array_column($rootline, 'uid')); + $sysTemplateRows = []; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_template'); + $queryBuilder->setRestrictions($this->getSysTemplateQueryRestrictionContainer($visibility)); + $queryBuilder->select('sys_template.*')->from('sys_template'); + // Build a value list as joined table to have sorting based on list sorting + $valueList = []; + foreach ($rootLinePageIds as $sorting => $rootLinePageId) { + $valueList[] = sprintf( + '%s, %s', + $queryBuilder->expr()->castInt( + $queryBuilder->createNamedParameter($rootLinePageId, Connection::PARAM_INT), + 'uid', + ), + $queryBuilder->expr()->castInt( + $queryBuilder->createNamedParameter($sorting, Connection::PARAM_INT), + 'sorting', + ) + ); + } + $valueList = 'SELECT ' . implode(' UNION ALL SELECT ', $valueList); + $queryBuilder->getConcreteQueryBuilder()->innerJoin( + $queryBuilder->quoteIdentifier('sys_template'), + sprintf('(%s)', $valueList), + $queryBuilder->quoteIdentifier('pidlist'), + '(' . $queryBuilder->expr()->eq( + 'sys_template.pid', + $queryBuilder->quoteIdentifier('pidlist.uid') + ) . ')' + ); + // Sort by rootline determined depth as sort criteria + $queryBuilder->orderBy('pidlist.sorting', 'ASC') + ->addOrderBy('sys_template.root', 'DESC') + ->addOrderBy('sys_template.sorting', 'ASC'); + $lastPid = null; + $queryResult = $queryBuilder->executeQuery(); + while ($sysTemplateRow = $queryResult->fetchAssociative()) { + // We're retrieving *all* templates per pid, but need the first one only. The + // order restriction above at least takes care they're after-each-other per pid. + if ($lastPid === (int)$sysTemplateRow['pid']) { + continue; + } + $lastPid = (int)$sysTemplateRow['pid']; + $sysTemplateRows[] = $sysTemplateRow; + } + $event = new AfterTemplatesHaveBeenDeterminedEvent($rootline, $request, $sysTemplateRows); + $this->eventDispatcher->dispatch($event); + return $event->getTemplateRows(); + } + + /** + * To calculate the TS include tree, we have to find sys_template rows attached to all rootline pages. + * When there are multiple active sys_template rows on a page, we pick the one with the lower sorting + * value. + * + * This variant is tailored for ext:tstemplate use. It allows "overriding" the sys_template uid of + * the deepest page, which is used when multiple sys_template records on one page are managed in the Backend. + * + * The query implementation below does that with *one* query for all rootline pages at once, not + * one query per page. To handle the capabilities mentioned above, the query is a bit nifty, but + * the implementation should scale nearly O(1) instead of O(n) with the rootline depth. + */ + public function getSysTemplateRowsByRootlineWithUidOverride(array $rootline, ?ServerRequestInterface $request, int $templateUidOnDeepestRootline, ?VisibilityAspect $visibility = null): array + { + // Site-root node first! + $rootLinePageIds = array_reverse(array_column($rootline, 'uid')); + $templatePidOnDeepestRootline = array_first($rootline)['uid']; + $sysTemplateRows = []; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_template'); + $queryBuilder->setRestrictions($this->getSysTemplateQueryRestrictionContainer($visibility)); + $queryBuilder->select('sys_template.*')->from('sys_template'); + if ($templateUidOnDeepestRootline && $templatePidOnDeepestRootline) { + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->neq('sys_template.pid', $queryBuilder->createNamedParameter($templatePidOnDeepestRootline, Connection::PARAM_INT)), + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('sys_template.pid', $queryBuilder->createNamedParameter($templatePidOnDeepestRootline, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('sys_template.uid', $queryBuilder->createNamedParameter($templateUidOnDeepestRootline, Connection::PARAM_INT)), + ), + ), + ); + } + // Build a value list as joined table to have sorting based on list sorting + $valueList = []; + foreach ($rootLinePageIds as $sorting => $rootLinePageId) { + $valueList[] = sprintf( + '%s, %s', + $queryBuilder->expr()->castInt( + $queryBuilder->createNamedParameter($rootLinePageId, Connection::PARAM_INT), + 'uid', + ), + $queryBuilder->expr()->castInt( + $queryBuilder->createNamedParameter($sorting, Connection::PARAM_INT), + 'sorting', + ), + ); + } + $valueList = 'SELECT ' . implode(' UNION ALL SELECT ', $valueList); + $queryBuilder->getConcreteQueryBuilder()->innerJoin( + $queryBuilder->quoteIdentifier('sys_template'), + sprintf('(%s)', $valueList), + $queryBuilder->quoteIdentifier('pidlist'), + '(' . $queryBuilder->expr()->eq( + 'sys_template.pid', + $queryBuilder->quoteIdentifier('pidlist.uid') + ) . ')' + ); + // Sort by rootline determined depth as sort criteria + $queryBuilder->orderBy('pidlist.sorting', 'ASC') + ->addOrderBy('sys_template.root', 'DESC') + ->addOrderBy('sys_template.sorting', 'ASC'); + $lastPid = null; + $queryResult = $queryBuilder->executeQuery(); + while ($sysTemplateRow = $queryResult->fetchAssociative()) { + // We're retrieving *all* templates per pid, but need the first one only. The + // order restriction above at least takes care they're after-each-other per pid. + if ($lastPid === (int)$sysTemplateRow['pid']) { + continue; + } + $lastPid = (int)$sysTemplateRow['pid']; + $sysTemplateRows[] = $sysTemplateRow; + } + // @todo: This event should be able to be fired even if the sys_template resolving is + // merged into an early middleware like "SiteResolver" which could join / sub-select + // pages together with sys_template directly, which would be possible if we manage + // to switch away from RootlineUtility usage in SiteResolver by using a CTE instead. + $event = new AfterTemplatesHaveBeenDeterminedEvent($rootline, $request, $sysTemplateRows); + $this->eventDispatcher->dispatch($event); + return $event->getTemplateRows(); + } + + /** + * Get sys_template record query builder restrictions. + * Allows hidden records if enabled in context. + */ + private function getSysTemplateQueryRestrictionContainer(?VisibilityAspect $visibility = null): DefaultRestrictionContainer + { + $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class); + $visibility ??= $this->context->getAspect('visibility'); + if ($visibility->includeHiddenContent()) { + $restrictionContainer->removeByType(HiddenRestriction::class); + } + if ($visibility->includeScheduledRecords()) { + $restrictionContainer->removeByType(StartTimeRestriction::class); + $restrictionContainer->removeByType(EndTimeRestriction::class); + } + return $restrictionContainer; + } +} diff --git a/Classes/TypoScript/IncludeTree/SysTemplateTreeBuilder.php b/Classes/TypoScript/IncludeTree/SysTemplateTreeBuilder.php new file mode 100644 index 0000000..991cff6 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/SysTemplateTreeBuilder.php @@ -0,0 +1,659 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\DefaultTypoScriptInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\DefaultTypoScriptMagicKeyInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ExtensionStaticInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\FileInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeStaticFileDatabaseInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeStaticFileFileInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SiteInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SiteTemplateInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SysTemplateInclude; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; +use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Create a tree representing all TypoScript includes. + * + * This is the 'middle' part of the TypoScript parsing process: The tokenizers as "lowest" + * structure create line streams from TypoScript, the AST builder as "highest" structure create + * the TypoScript object tree. + * + * This structure gathers all TypoScript snippets that have to be tokenized, and creates a + * tree with include nodes and sub include nodes. + * + * It is called in frontend (and backend "Template" module) with the page rootline, gets all + * attached sys_template records, gets their content and various sub includes and takes care + * of correct include order. + * + * This class together with TreeFromLineStreamBuilder also takes care of conditions and + * imports ("@import"): Those create child nodes in the tree. To evaluate conditions, the + * tree is later traversed, condition verdicts (true / false) are determined, to see if + * condition's child nodes should be considered in AST. + * + * The IncludeTree is "runtime stateless": Constants values and conditions are *not* evaluated + * here, so the tree is always the same for a given rootline. This makes this structure cache-able: + * In frontend, the tree (or sub parts of it) is cached and fetched from cache for next + * call. This means the entire tree-building and tokenizing is suppressed. After that runtime + * information is added: Conditions are evaluated, and the AST is built from given IncludeTree. + * + * @internal: Internal tree structure. + */ +#[Autoconfigure(public: true)] +final class SysTemplateTreeBuilder +{ + /** + * Used in 'basedOn' includes to prevent endless loop: Each sys_template row can + * be included only once in 'basedOn'. + * + * @var array<int, int> + */ + private array $includedSysTemplateUids = []; + + /** @var 'constants'|'setup' */ + private string $type; + + private TokenizerInterface $tokenizer; + private ?PhpFrontend $cache = null; + + private bool $enableStaticMagicIncludes = false; + + public function __construct( + private readonly ConnectionPool $connectionPool, + private readonly PackageManager $packageManager, + private readonly Context $context, + private readonly TreeFromLineStreamBuilder $treeFromTokenStreamBuilder, + private readonly SetRegistry $setRegistry, + ) {} + + /** + * @param 'constants'|'setup' $type + */ + public function getTreeBySysTemplateRowsAndSite( + string $type, + array $sysTemplateRows, + TokenizerInterface $tokenizer, + ?SiteInterface $site = null, + ?PhpFrontend $cache = null + ): RootInclude { + if (!in_array($type, ['constants', 'setup'], true)) { + throw new \RuntimeException('type must be either constants or setup', 1653737656); + } + $this->tokenizer = $tokenizer; + $this->cache = $cache; + $this->type = $type; + $this->includedSysTemplateUids = []; + + $rootNode = new RootInclude(); + + $siteIsTypoScriptRoot = $site instanceof Site ? $site->isTypoScriptRoot() : false; + if ($siteIsTypoScriptRoot) { + $this->enableStaticMagicIncludes = false; + $cacheIdentifier = 'site-template-' . $this->type . '-' . $site->getIdentifier(); + $includeNode = $this->cache?->require($cacheIdentifier) ?: null; + $includeNode ??= $this->createSiteTemplateInclude($site, $cacheIdentifier); + $rootNode->addChild($includeNode); + } + + if (empty($sysTemplateRows)) { + return $rootNode; + } + + $this->enableStaticMagicIncludes = true; + // Convenience code: Usually, at least one sys_template records needs to have 'clear' set. This resets + // the AST and triggers inclusion of "globals" TypoScript. When integrators missed to set the clear flags, + // important globals TypoScript is not loaded, leading to pretty hard to find issues in Frontend + // rendering. Since the details of the 'clear' flags are rather complex anyway, this code scans the given + // sys_template records if the flag is set somewhere and if not, actively sets it dynamically for the + // first templates. As a result, integrators do not need to think about the 'clear' flags at all for + // simple instances, it 'just works'. + $atLeastOneSysTemplateRowHasClearFlag = $siteIsTypoScriptRoot; + if (!$atLeastOneSysTemplateRowHasClearFlag) { + foreach ($sysTemplateRows as $sysTemplateRow) { + if (($this->type === 'constants' && $sysTemplateRow['clear'] & 1) || ($this->type === 'setup' && $sysTemplateRow['clear'] & 2)) { + $atLeastOneSysTemplateRowHasClearFlag = true; + break; + } + } + $firstRow = reset($sysTemplateRows); + $firstRow['clear'] = $this->type === 'constants' ? 1 : 2; + $sysTemplateRows[array_key_first($sysTemplateRows)] = $firstRow; + } + + foreach ($sysTemplateRows as $sysTemplateRow) { + $cacheIdentifier = 'sys-template-' . $this->type . '-' . $this->getSysTemplateRowIdentifier($sysTemplateRow, $site); + if ($this->cache) { + // Get from cache if possible + $includeNode = $this->cache->require($cacheIdentifier); + if ($includeNode) { + $rootNode->addChild($includeNode); + continue; + } + } + $includeNode = new SysTemplateInclude(); + $name = '[sys_template:' . $sysTemplateRow['uid'] . '] ' . $sysTemplateRow['title']; + $includeNode->setName($name); + $includeNode->setPid((int)$sysTemplateRow['pid']); + if ($this->type === 'constants') { + $includeNode->setLineStream($this->tokenizer->tokenize($sysTemplateRow['constants'] ?? '')); + } else { + $includeNode->setLineStream($this->tokenizer->tokenize($sysTemplateRow['config'] ?? '')); + } + if ($sysTemplateRow['root']) { + $includeNode->setRoot(true); + } + $clear = $sysTemplateRow['clear']; + if (($this->type === 'constants' && $clear & 1) || ($this->type === 'setup' && $clear & 2)) { + $includeNode->setClear(true); + } + $this->handleSysTemplateRecordInclude($includeNode, $sysTemplateRow, $site); + $this->treeFromTokenStreamBuilder->buildTree($includeNode, $this->type, $this->tokenizer); + $this->cache?->set($cacheIdentifier, $this->prepareNodeForCache($includeNode)); + $rootNode->addChild($includeNode); + } + + return $rootNode; + } + + private function createSiteTemplateInclude( + Site $site, + string $cacheIdentifier + ): SiteTemplateInclude { + $includeNode = new SiteTemplateInclude(); + $includeNode->setRoot(true); + $includeNode->setClear(true); + + $this->addScopedStaticsFromGlobals($includeNode, 'siteSets'); + $this->addContentRenderingFromGlobals($includeNode, 'TYPO3_CONF_VARS defaultContentRendering'); + + $sets = $this->setRegistry->getSets(...$site->getSets()); + if (count($sets) > 0) { + $includeSetInclude = new IncludeStaticFileFileInclude(); + $includeSetInclude->setName('site:' . $site->getIdentifier() . ':sets'); + $includeSetInclude->setPath('site:' . $site->getIdentifier() . '/'); + foreach ($sets as $set) { + if ($set->typoscript === null) { + continue; + } + $this->handleSetInclude($includeSetInclude, rtrim($set->typoscript, '/') . '/', 'set:' . $set->name); + } + $includeNode->addChild($includeSetInclude); + } + + if ($this->type === 'constants') { + $this->addDefaultTypoScriptConstantsFromSite($includeNode, $site); + } + + $siteTypoScript = $site->getTypoScript(); + $content = $this->type === 'constants' ? $siteTypoScript?->constants : $siteTypoScript?->setup; + if ($content !== null) { + $includeNode->setLineStream($this->tokenizer->tokenize($content)); + $this->treeFromTokenStreamBuilder->buildTree($includeNode, $this->type, $this->tokenizer, false); + } + + $includeNode->setName(sprintf( + '[site:%s%s] %s', + $site->getIdentifier(), + $content === null ? '' : '/' . $this->type . '.typoscript', + $site->getConfiguration()['websiteTitle'] ?? '' + )); + + $this->cache?->set($cacheIdentifier, $this->prepareNodeForCache($includeNode)); + + return $includeNode; + } + + private function handleSetInclude(IncludeInterface $parentNode, string $path, string $label): void + { + $path = GeneralUtility::getFileAbsFileName($path); + + // '/.../my_extension/Configuration/TypoScript/MyStaticInclude/include_static_file.txt' + $includeStaticFileFileIncludePath = $path . 'include_static_file.txt'; + if (file_exists($path . 'include_static_file.txt')) { + $includeStaticFileFileInclude = new IncludeStaticFileFileInclude(); + $includeStaticFileFileInclude->setName($label . ':include_static_file.txt'); + $includeStaticFileFileInclude->setPath($path . 'include_static_file.txt'); + $parentNode->addChild($includeStaticFileFileInclude); + $includeStaticFileFileIncludeContent = (string)file_get_contents($includeStaticFileFileIncludePath); + // @todo: There is no array_unique() for DB based include_static_file content?! + $includeStaticFileFileIncludeArray = array_unique(GeneralUtility::trimExplode(',', $includeStaticFileFileIncludeContent, true)); + foreach ($includeStaticFileFileIncludeArray as $includeStaticFileFileIncludeString) { + $this->handleSingleIncludeStaticFile($includeStaticFileFileInclude, $includeStaticFileFileIncludeString); + } + } + + $fileName = $path . $this->type . '.typoscript'; + if (file_exists($fileName)) { + $fileContent = file_get_contents($fileName); + $fileNode = new FileInclude(); + $fileNode->setName($label . ':' . $this->type . '.typoscript'); + $fileNode->setPath($fileName); + $fileNode->setLineStream($this->tokenizer->tokenize($fileContent)); + $this->treeFromTokenStreamBuilder->buildTree($fileNode, $this->type, $this->tokenizer, false); + $parentNode->addChild($fileNode); + } + } + + /** + * Add includes defined in a sys_template record. + */ + private function handleSysTemplateRecordInclude(IncludeInterface $parentNode, array $row, ?SiteInterface $site): void + { + $this->includedSysTemplateUids[] = (int)$row['uid']; + + $isRoot = (bool)$row['root']; + $clearConstants = (int)$row['clear'] & 1; + $clearSetup = (int)$row['clear'] & 2; + $staticFileMode = (int)($row['static_file_mode']); + $includeStaticAfterBasedOn = (bool)$row['includeStaticAfterBasedOn']; + + if ($this->type === 'constants' && $clearConstants) { + $this->addDefaultTypoScriptFromGlobals($parentNode); + $this->addDefaultTypoScriptConstantsFromSite($parentNode, $site); + } + if ($this->type === 'setup' && $clearSetup) { + $this->addDefaultTypoScriptFromGlobals($parentNode); + } + if ($staticFileMode === 3 && $isRoot) { + $this->addExtensionStatics($parentNode); + } + if (!$includeStaticAfterBasedOn) { + $this->handleIncludeStaticFileArray($parentNode, (string)$row['include_static_file']); + } + if (!empty($row['basedOn'])) { + $this->handleIncludeBasedOnTemplates($parentNode, (string)$row['basedOn'], $site); + } + if ($includeStaticAfterBasedOn) { + $this->handleIncludeStaticFileArray($parentNode, (string)$row['include_static_file']); + } + if ($staticFileMode === 1 || ($staticFileMode === 0 && $isRoot)) { + $this->addExtensionStatics($parentNode); + } + } + + /** + * Handle includes defined in a sys_template['include_static_file'] row. Extracted as + * methods since it depends on 'includeStaticAfterBasedOn' field if this is included + * *before* or *after* other 'basedOn' includes. + * + * The cache implemented here *does not* take the *content* of files into account. + * This means changing a file *does not* automatically void the cache since that would + * lead to lots of file_exists() and file_get_contents() calls in production. + * Instances in development context should thus set the typoscript-cache to NullFrontend. + * Note this cache-usage is the main-cache that kicks in whenever different sys_template + * records include the same file. For instance, when multiple sites include ext:seo XmlSitemap, + * the cache implementation here takes care the ext:seo subtree is calculated only once. + */ + private function handleIncludeStaticFileArray(IncludeInterface $parentNode, string $includeStaticFileString): void + { + $includeStaticFileIncludeArray = GeneralUtility::trimExplode(',', $includeStaticFileString, true); + foreach ($includeStaticFileIncludeArray as $includeStaticFile) { + $cacheIdentifier = preg_replace('/[^[:alnum:]]/u', '-', mb_strtolower($includeStaticFile)) . '-' . $this->type; + if ($this->cache) { + $node = $this->cache->require($cacheIdentifier); + if ($node) { + $parentNode->addChild($node); + continue; + } + } + $node = new IncludeStaticFileDatabaseInclude(); + $node->setName($includeStaticFile); + $this->handleSingleIncludeStaticFile($node, $includeStaticFile); + $this->cache?->set($cacheIdentifier, $this->prepareNodeForCache($node)); + $parentNode->addChild($node); + } + } + + /** + * Handle includes defined in a sys_template['basedOn'] row. + * Warning: Calls handleSysTemplateRecordInclude() recursive when another basedOn templates + * record includes things again! + */ + private function handleIncludeBasedOnTemplates(IncludeInterface $parentNode, string $basedOnList, ?SiteInterface $site): void + { + $basedOnTemplateUids = GeneralUtility::intExplode(',', $basedOnList, true); + // Filter uids that have been handled already. + $basedOnTemplateUids = array_diff($basedOnTemplateUids, $this->includedSysTemplateUids); + if (empty($basedOnTemplateUids)) { + return; + } + + $basedOnTemplateRows = $this->getBasedOnSysTemplateRowsFromDatabase($basedOnTemplateUids); + + foreach ($basedOnTemplateUids as $basedOnTemplateUid) { + if (is_array($basedOnTemplateRows[$basedOnTemplateUid] ?? false)) { + $sysTemplateRow = $basedOnTemplateRows[$basedOnTemplateUid]; + $this->includedSysTemplateUids[] = (int)$sysTemplateRow['uid']; + $includeNode = new SysTemplateInclude(); + $name = '[sys_template:' . $sysTemplateRow['uid'] . '] ' . $sysTemplateRow['title']; + $includeNode->setName($name); + $includeNode->setPid((int)$sysTemplateRow['pid']); + if ($this->type === 'constants') { + $includeNode->setLineStream($this->tokenizer->tokenize($sysTemplateRow['constants'] ?? '')); + } else { + $includeNode->setLineStream($this->tokenizer->tokenize($sysTemplateRow['config'] ?? '')); + } + $this->treeFromTokenStreamBuilder->buildTree($includeNode, $this->type, $this->tokenizer); + if ($sysTemplateRow['root']) { + $includeNode->setRoot(true); + } + $clear = $sysTemplateRow['clear']; + if (($this->type === 'constants' && $clear & 1) + || ($this->type === 'setup' && $clear & 2) + ) { + $includeNode->setClear(true); + } + $parentNode->addChild($includeNode); + $this->handleSysTemplateRecordInclude($includeNode, $sysTemplateRow, $site); + } + } + } + + /** + * Handle a single sys_template ['include_static_file'] include. + * Looks up file "EXT:/My/Path/include_static_file.txt' in an extension and includes this. + * Also loads "EXT:/My/Path/[constants|setup].[typoscript|ts|txt]. + * Warning: Recursive since an include_static_file.txt file can include other extension's include_static_file.txt again. + * This method has no cache-layer usage on its own: handleSingleIncludeStaticFile() which calls this + * method is the cache layer here. + */ + private function handleSingleIncludeStaticFile(IncludeInterface $parentNode, $includeStaticFileString): void + { + if (!PathUtility::isExtensionPath($includeStaticFileString)) { + // Must start with 'EXT:' + throw new \RuntimeException( + 'Single include_static_file does not start with "EXT:": ' . $includeStaticFileString, + 1651137904 + ); + } + + // Cut off 'EXT:' + $includeStaticFileWithoutExt = substr($includeStaticFileString, 4); + $includeStaticFileExtKeyAndPath = GeneralUtility::trimExplode('/', $includeStaticFileWithoutExt, true, 2); + if (empty($includeStaticFileExtKeyAndPath[0]) || empty($includeStaticFileExtKeyAndPath[1])) { + throw new \RuntimeException( + 'Syntax of static includes is "EXT:extension_key/Path". Usually enforced as such by ExtensionManagementUtility::addStaticFile', + 1651138603 + ); + } + $extensionKey = $includeStaticFileExtKeyAndPath[0]; + if (!ExtensionManagementUtility::isLoaded($extensionKey)) { + return; + } + // example: '/.../my_extension/Configuration/TypoScript/MyStaticInclude/' + $pathSegmentWithAppendedSlash = rtrim($includeStaticFileExtKeyAndPath[1]) . '/'; + $path = ExtensionManagementUtility::extPath($extensionKey, $pathSegmentWithAppendedSlash); + + // '/.../my_extension/Configuration/TypoScript/MyStaticInclude/include_static_file.txt' + $includeStaticFileFileIncludePath = $path . 'include_static_file.txt'; + if (file_exists($path . 'include_static_file.txt')) { + $includeStaticFileFileInclude = new IncludeStaticFileFileInclude(); + $name = 'EXT:' . $extensionKey . '/' . $pathSegmentWithAppendedSlash . 'include_static_file.txt'; + $includeStaticFileFileInclude->setName($name); + $includeStaticFileFileInclude->setPath($includeStaticFileString); + $parentNode->addChild($includeStaticFileFileInclude); + $includeStaticFileFileIncludeContent = (string)file_get_contents($includeStaticFileFileIncludePath); + // @todo: There is no array_unique() for DB based include_static_file content?! + $includeStaticFileFileIncludeArray = array_unique(GeneralUtility::trimExplode(',', $includeStaticFileFileIncludeContent, true)); + foreach ($includeStaticFileFileIncludeArray as $includeStaticFileFileIncludeString) { + $this->handleSingleIncludeStaticFile($includeStaticFileFileInclude, $includeStaticFileFileIncludeString); + } + } + + $extensions = ['.typoscript', '.ts', '.txt']; + foreach ($extensions as $extension) { + // '/.../my_extension/Configuration/TypoScript/MyStaticInclude/[constants|setup]' plus one of the allowed extensions like '.typoscript' + $fileName = $path . $this->type . $extension; + if (file_exists($fileName)) { + $fileContent = file_get_contents($fileName); + $fileNode = new FileInclude(); + $name = 'EXT:' . $extensionKey . '/' . $pathSegmentWithAppendedSlash . $this->type . $extension; + $fileNode->setName($name); + $fileNode->setPath($name); + $fileNode->setLineStream($this->tokenizer->tokenize($fileContent)); + $this->treeFromTokenStreamBuilder->buildTree($fileNode, $this->type, $this->tokenizer); + $parentNode->addChild($fileNode); + } + } + + if ($this->enableStaticMagicIncludes) { + $extensionKeyWithoutUnderscores = str_replace('_', '', $extensionKey); + $this->addStaticMagicFromGlobals($parentNode, $extensionKeyWithoutUnderscores . '/' . $pathSegmentWithAppendedSlash); + } + } + + /** + * Load 'EXT:my_extension/ext_typoscript_[constants|setup].typoscript' + * of *all* loaded extensions if they exist. + */ + private function addExtensionStatics(IncludeInterface $parentNode): void + { + foreach ($this->packageManager->getActivePackages() as $package) { + $extensionKey = $package->getPackageKey(); + $extensionKeyWithoutUnderscores = str_replace('_', '', $extensionKey); + $file = $package->getPackagePath() . 'ext_typoscript_' . $this->type . '.typoscript'; + if (file_exists($file)) { + $identifier = preg_replace('/[^[:alnum:]]/u', '-', 'ext-' . $extensionKey . '-ext-typoscript-' . $this->type . '-typoscript'); + if ($this->cache) { + $node = $this->cache->require($identifier); + if ($node) { + $parentNode->addChild($node); + continue; + } + } + $fileContent = file_get_contents($file); + $this->addStaticMagicFromGlobals($parentNode, $extensionKeyWithoutUnderscores); + $node = new ExtensionStaticInclude(); + $node->setName('EXT:' . $extensionKey . '/ext_typoscript_' . $this->type . '.typoscript'); + $node->setPath('EXT:' . $extensionKey . '/ext_typoscript_' . $this->type . '.typoscript'); + $node->setLineStream($this->tokenizer->tokenize($fileContent)); + $this->treeFromTokenStreamBuilder->buildTree($node, $this->type, $this->tokenizer); + $this->cache?->set($identifier, $this->prepareNodeForCache($node)); + $parentNode->addChild($node); + } + } + } + + /** + * Load default constants TS from $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_[constants|setup]'] + * whenever 'root=1' is set for a sys_template. + */ + private function addDefaultTypoScriptFromGlobals(IncludeInterface $parentConstantNode): void + { + $defaultTypoScriptConstants = $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $this->type] ?? ''; + if (!empty($defaultTypoScriptConstants)) { + $cacheIdentifier = 'globals-defaulttyposcript-' . $this->type . '-' . hash('xxh3', $defaultTypoScriptConstants); + if ($this->cache) { + $node = $this->cache->require($cacheIdentifier); + if ($node) { + $parentConstantNode->addChild($node); + return; + } + } + $node = new DefaultTypoScriptInclude(); + $node->setName('TYPO3_CONF_VARS[\'FE\'][\'defaultTypoScript_' . $this->type . '\']'); + $node->setLineStream($this->tokenizer->tokenize($defaultTypoScriptConstants)); + $this->treeFromTokenStreamBuilder->buildTree($node, $this->type, $this->tokenizer); + $this->cache?->set($cacheIdentifier, $this->prepareNodeForCache($node)); + $parentConstantNode->addChild($node); + } + } + + /** + * Load default TS constants from site configuration if that page has a site in rootline. + */ + private function addDefaultTypoScriptConstantsFromSite(IncludeInterface $parentConstantNode, ?SiteInterface $site): void + { + if (!$site instanceof Site) { + return; + } + $siteConstants = ''; + $siteSettings = $site->getSettings(); + if ($siteSettings->isEmpty()) { + return; + } + $cacheIdentifier = 'site-constants-' . hash('xxh3', json_encode($siteSettings, JSON_THROW_ON_ERROR)); + if ($this->cache) { + $node = $this->cache->require($cacheIdentifier); + if ($node) { + $parentConstantNode->addChild($node); + return; + } + } + $siteSettings = $siteSettings->getAllFlat(); + foreach ($siteSettings as $nodeIdentifier => $value) { + $siteConstants .= $nodeIdentifier . ' = ' . $value . LF; + } + $node = new SiteInclude(); + $node->setName('Site constants settings of site "' . $site->getIdentifier() . '"'); + $node->setLineStream($this->tokenizer->tokenize($siteConstants)); + $this->cache?->set($cacheIdentifier, $this->prepareNodeForCache($node)); + $parentConstantNode->addChild($node); + } + + private function addScopedStaticsFromGlobals(IncludeInterface $parentNode, string $identifier): void + { + // defaultTypoScript_constants.' or defaultTypoScript_setup.' + $source = $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $this->type . '.'][$identifier] ?? null; + if (!empty($source)) { + $node = new DefaultTypoScriptMagicKeyInclude(); + $node->setName('TYPO3_CONF_VARS globals_defaultTypoScript_' . $this->type . '.' . $identifier); + $node->setLineStream($this->tokenizer->tokenize($source)); + $this->treeFromTokenStreamBuilder->buildTree($node, $this->type, $this->tokenizer); + $parentNode->addChild($node); + } + } + + private function addContentRenderingFromGlobals(IncludeInterface $parentNode, string $name): void + { + $source = $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $this->type . '.']['defaultContentRendering'] ?? null; + if (!empty($source)) { + $node = new DefaultTypoScriptMagicKeyInclude(); + $node->setName($name); + $node->setLineStream($this->tokenizer->tokenize($source)); + $this->treeFromTokenStreamBuilder->buildTree($node, $this->type, $this->tokenizer); + $parentNode->addChild($node); + } + } + + /** + * A rather weird lookup in $GLOBALS['TYPO3_CONF_VARS']['FE'] for magic includes. + * See ExtensionManagementUtility::addTypoScript() for more details on this. + */ + private function addStaticMagicFromGlobals(IncludeInterface $parentNode, string $identifier): void + { + $this->addScopedStaticsFromGlobals($parentNode, $identifier); + // If this is a template of type "default content rendering", see if other extensions have added their TypoScript that should be included. + if (in_array($identifier, $GLOBALS['TYPO3_CONF_VARS']['FE']['contentRenderingTemplates'], true)) { + $this->addContentRenderingFromGlobals($parentNode, 'TYPO3_CONF_VARS defaultContentRendering ' . $this->type . ' for ' . $identifier); + } + } + + /** + * Get 'basedOn' sys_template sub-rows of sys_templates that use this. + * Note the 'IN()' query implementation below delivers rows in *any* order. To preserve + * basedOn list order, we re-index result rows by uid and then iterate on the original + * order of $basedOnTemplateUids in handleIncludeBasedOnTemplates(). + */ + private function getBasedOnSysTemplateRowsFromDatabase(array $basedOnTemplateUids): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_template'); + $queryBuilder->setRestrictions($this->getSysTemplateQueryRestrictionContainer()); + $basedOnTemplateRows = $queryBuilder + ->select('*') + ->from('sys_template') + ->where( + $queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($basedOnTemplateUids, Connection::PARAM_INT_ARRAY) + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + return array_combine(array_column($basedOnTemplateRows, 'uid'), $basedOnTemplateRows); + } + + /** + * Calculate a cache identifier for a sys_template row. + * This is a bit nifty: There are instances in the wild that add the same TypoScript + * sys_template over and over again in a page tree to for instance toggle a single value. + * Those content-identical template rows create only one cache entry: We create a hash + * from the relevant row fields like 'constants' and 'config', but we do NOT include + * the sys_template row 'uid' and 'pid'. So different sys_template rows with the same content + * lead to the same identifier, and we cache that just once. + * + * One additional dependency influences the identifier as well: If the 'clear constants' + * flag is set, this row will later trigger loading of constants from given site settings. + * When two "first" template rows have the exact same field content in different sites, the + * site identifier needs to be added to the hash to still create two different cache entries. + */ + private function getSysTemplateRowIdentifier(array $sysTemplateRow, ?SiteInterface $site): string + { + $siteIdentifier = 'dummy'; + if ($this->type === 'constants' && ((int)$sysTemplateRow['clear'] & 1) && $site !== null) { + $siteIdentifier = $site->getIdentifier(); + } + $cacheRelevantSysTemplateRowValues = [ + 'root' => (int)$sysTemplateRow['root'], + 'clear' => (int)$sysTemplateRow['clear'], + 'include_static_file' => (string)$sysTemplateRow['include_static_file'], + 'constants' => (string)$sysTemplateRow['constants'], + 'config' => (string)$sysTemplateRow['config'], + 'basedOn' => (string)$sysTemplateRow['basedOn'], + 'includeStaticAfterBasedOn' => (int)$sysTemplateRow['includeStaticAfterBasedOn'], + 'static_file_mode' => (int)$sysTemplateRow['static_file_mode'], + 'siteIdentifier' => $siteIdentifier, + ]; + return hash('xxh3', json_encode($cacheRelevantSysTemplateRowValues, JSON_THROW_ON_ERROR)); + } + + private function prepareNodeForCache(IncludeInterface $node): string + { + return 'return unserialize(\'' . addcslashes(serialize($node), '\'\\') . '\');'; + } + + /** + * Get sys_template record query builder restrictions. + * Allows hidden records if enabled in context. + */ + private function getSysTemplateQueryRestrictionContainer(): DefaultRestrictionContainer + { + $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class); + if ($this->context->getPropertyFromAspect('visibility', 'includeHiddenContent', false)) { + $restrictionContainer->removeByType(HiddenRestriction::class); + } + return $restrictionContainer; + } +} diff --git a/Classes/TypoScript/IncludeTree/Traverser/ConditionVerdictAwareIncludeTreeTraverser.php b/Classes/TypoScript/IncludeTree/Traverser/ConditionVerdictAwareIncludeTreeTraverser.php new file mode 100644 index 0000000..314ab52 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Traverser/ConditionVerdictAwareIncludeTreeTraverser.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeVisitorInterface; + +/** + * An optimized traverser that does not traverse children when a node is + * a condition node that evaluate false. + * + * This is pretty clever: When adding the ConditionMatcherVisitor as first visitor, it + * sets the condition verdict of a ConditionInterface node in visitBeforeChildren(). + * Adding the AstBuilderVisitor as second visitor, the AstBuilderVisitor will not be + * called for ConditionInterface children that did not evaluate to true. + * This way, we can both evaluate conditions and build the AST in only one traversing round. + * + * @internal: Internal tree structure. + */ +final class ConditionVerdictAwareIncludeTreeTraverser implements IncludeTreeTraverserInterface +{ + public function traverse(RootInclude $rootInclude, array $visitors): void + { + foreach ($visitors as $visitor) { + if (!$visitor instanceof IncludeTreeVisitorInterface) { + throw new \RuntimeException( + 'Visitors must implement IncludeTreeVisitorInterface', + 1689244840 + ); + } + } + $this->traverseRecursive($rootInclude, $visitors, 0); + } + + private function traverseRecursive(IncludeInterface $include, array $visitors, int $currentDepth): void + { + foreach ($visitors as $visitor) { + $visitor->visitBeforeChildren($include, $currentDepth); + } + if ($include instanceof IncludeConditionInterface && !$include->getConditionVerdict()) { + // Don't traverse children if condition did not match. + return; + } + foreach ($include->getNextChild() as $child) { + $this->traverseRecursive($child, $visitors, $currentDepth + 1); + foreach ($visitors as $visitor) { + $visitor->visit($child, $currentDepth); + } + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverser.php b/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverser.php new file mode 100644 index 0000000..0dddcb1 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverser.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeVisitorInterface; + +/** + * Traverse all nodes of a RootInclude. Used mostly in backend "Template" module. + * + * @internal: Internal tree structure. + */ +final class IncludeTreeTraverser implements IncludeTreeTraverserInterface +{ + public function traverse(RootInclude $rootInclude, array $visitors): void + { + foreach ($visitors as $visitor) { + if (!$visitor instanceof IncludeTreeVisitorInterface) { + throw new \RuntimeException( + 'Visitors must implement IncludeTreeVisitorInterface', + 1689244841 + ); + } + } + $this->traverseRecursive($rootInclude, $visitors, 0); + } + + private function traverseRecursive(IncludeInterface $include, array $visitors, int $currentDepth): void + { + foreach ($visitors as $visitor) { + $visitor->visitBeforeChildren($include, $currentDepth); + } + foreach ($include->getNextChild() as $child) { + $this->traverseRecursive($child, $visitors, $currentDepth + 1); + foreach ($visitors as $visitor) { + $visitor->visit($child, $currentDepth); + } + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverserInterface.php b/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverserInterface.php new file mode 100644 index 0000000..53e6844 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Traverser/IncludeTreeTraverserInterface.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeVisitorInterface; + +/** + * Interface implemented by include tree traversers. + * + * Visitors can be attached and are called for each traversed node. + * + * @internal: Internal tree structure. + */ +interface IncludeTreeTraverserInterface +{ + /** + * @param IncludeTreeVisitorInterface[] $visitors + */ + public function traverse(RootInclude $rootInclude, array $visitors): void; +} diff --git a/Classes/TypoScript/IncludeTree/TreeFromLineStreamBuilder.php b/Classes/TypoScript/IncludeTree/TreeFromLineStreamBuilder.php new file mode 100644 index 0000000..7472927 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/TreeFromLineStreamBuilder.php @@ -0,0 +1,421 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree; + +use TYPO3\CMS\Core\Resource\Security\FileNameValidator; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\AtImportInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionElseInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionStopInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\DefaultTypoScriptMagicKeyInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SegmentInclude; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionElseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionStopLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ImportLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; +use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Helper class of TreeBuilder classes: This class gets a node with a LineStream - a node + * created from a sys_template 'constants' or 'setup' field, or created from a + * file import or a string. It then looks for conditions and imports in the attached LineStream + * and splits the node into child nodes if needed. + * + * So while SysTemplateTreeBuilder is all about creating includes from sys_template records + * in correct order, this class takes care of conditions and @import within single + * source streams. + * + * This class has no cache-implementation itself: The higher level class caches + * include trees of token streams. + * + * @internal: Internal tree structure. + */ +final class TreeFromLineStreamBuilder +{ + /** @var 'constants'|'setup'|'other' */ + private string $type; + private TokenizerInterface $tokenizer; + private bool $enableMagicIncludes = false; + + /** + * Using "@import" with wildcards, the file ending depends on the given type: + * With Frontend TypoScript, .typoscript is allowed, with TsConfig, .tsconfig + * and .typoscript is allowed. This property maps types to their file suffixes. + * + * @var array<string, array<int, string>> + */ + private array $atImportTypeToSuffixMap = [ + 'constants' => ['typoscript'], + 'setup' => ['typoscript'], + 'other' => ['typoscript'], + 'tsconfig' => ['typoscript', 'tsconfig'], + ]; + + public function __construct( + private readonly FileNameValidator $fileNameValidator, + ) {} + + public function buildTree(IncludeInterface $node, string $type, TokenizerInterface $tokenizer, bool $enableMagicIncludes = true): void + { + if (!in_array($type, ['constants', 'setup', 'tsconfig', 'other'], true)) { + // Type "constants" and "setup" trigger the weird addStaticMagicFromGlobals() resolving, while "other" ignores it. + throw new \RuntimeException('type must be either "constants", "setup", "tsconfig" or "other"', 1652741356); + } + $this->type = $type; + $this->tokenizer = $tokenizer; + $this->enableMagicIncludes = $enableMagicIncludes; + $this->buildTreeInternal($node); + } + + /** + * This method is a bit tricky and not too easy to follow: It loops over + * a given source stream of lines exactly once, but creates a two-level + * include node structure from it: + * + * For instance, when a condition is encountered, it creates a node for the + * condition, and the "body" lines of the condition are child nodes of the + * condition node. The $previousNode <-> $node juggling handles this: When + * the condition body ends (new condition, or [end] or similar), the + * next include needs to be attached to the former parent node again. + * + * Essentially, a single source stream is split into multiple child nodes + * when there are conditions or imports. A node that is "split" into + * child nodes gets the "split" toggle set, indicating that the entire + * source stream is represented by its child nodes. + * + * A condition body may have more than one child: When there are multiple + * file includes, each one creates an own node, which may have children + * again. This also means the method is called recursive, since the source + * stream of an included file may need to be split into segments again, so + * it calls this method again with itself as entry node. + */ + private function buildTreeInternal(IncludeInterface $node): void + { + $parentNode = $node; + $givenTokenLineStream = $node->getLineStream(); + $lineStream = new LineStream(); + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + + foreach ($givenTokenLineStream->getNextLine() as $line) { + if ($line instanceof ConditionLine && $node instanceof ConditionInclude) { + // Finish current condition when this line is another condition + $node->setSplit(); + if (!$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $lineStream = new LineStream(); + } + $node = $parentNode; + } + + if ($line instanceof ConditionLine) { + // A new condition not yet in condition context + $node->setSplit(); + $conditionValueToken = $line->getTokenValue(); + if (!$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $lineStream = new LineStream(); + } + $childNode = new ConditionInclude(); + $childNode->setSplit(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $childNode->setConditionToken($conditionValueToken); + $lineStream->append($line); + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $parentNode = $node; + $node = $childNode; + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $lineStream = new LineStream(); + continue; + } + + if (($node instanceof ConditionInclude || $node instanceof ConditionElseInclude) + && $line instanceof ConditionStopLine + ) { + // Finish condition segment due to [end] or [global] line + $node->setSplit(); + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $node = $parentNode; + $childNode = new ConditionStopInclude(); + $childNode->setName($node->getName()); + $childNode->setLineStream((new LineStream())->append($line)); + $node->addChild($childNode); + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $lineStream = new LineStream(); + continue; + } + + if ($line instanceof ConditionStopLine) { + // [end] or [global] not within open condition context. Fishy. Still finish current + // segment, mark node split, add new ConditionStopInclude(), open a new segment. + $node->setSplit(); + if (!$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + } + $childNode = new ConditionStopInclude(); + $childNode->setName($node->getName()); + $childNode->setLineStream((new LineStream())->append($line)); + $node->addChild($childNode); + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $lineStream = new LineStream(); + continue; + } + + if ($node instanceof ConditionInclude && $line instanceof ConditionElseLine) { + // Active condition into [else] condition + $node->setSplit(); + if (!$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + } + $conditionToken = $node->getConditionToken(); + $node = $parentNode; + $childNode = new ConditionElseInclude(); + $childNode->setSplit(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $childNode->setConditionToken($conditionToken); + $lineStream = new LineStream(); + $lineStream->append($line); + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $parentNode = $node; + $node = $childNode; + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $lineStream = new LineStream(); + continue; + } + + if ($line instanceof ImportLine) { + $node->setSplit(); + $atImportValueToken = $line->getValueToken(); + if (!$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + $lineStream = new LineStream(); + } + $childNode = new SegmentInclude(); + $childNode->setName($node->getName()); + $childNode->setPath($node->getPath()); + $allowedSuffixes = $this->atImportTypeToSuffixMap[$this->type]; + foreach ($allowedSuffixes as $allowedSuffix) { + $this->processAtImport($allowedSuffix, $node, $atImportValueToken, $line); + } + continue; + } + + $lineStream->append($line); + } + + if ($node->isSplit() && !$lineStream->isEmpty()) { + $childNode->setLineStream($lineStream); + $node->addChild($childNode); + } + } + + /** + * Process a single '@import'. May add multiple children when '*' wildcards are involved. + * Warning: Calls buildTree() recursive for each included file. + * Warning: Calls itself recursive for 'relative' lookups. + */ + private function processAtImport(string $fileSuffix, IncludeInterface $node, Token $atImportValueToken, LineInterface $atImportLine, bool $tryRelative = false): void + { + $atImportValue = $atImportValueToken->getValue(); + $atImportName = $atImportValue; + if ($tryRelative) { + if (empty($node->getPath())) { + return; + } + $parentPath = rtrim(dirname($node->getPath()), '/') . '/'; + $atImportValue = ltrim($atImportValue, './'); + $atImportName = preg_replace('#([:/])[^:/]+$#', '$1', $node->getName()) . $atImportValue; + $atImportValue = $parentPath . $atImportValue; + } + $absoluteFileName = rtrim(GeneralUtility::getFileAbsFileName($atImportValue), '/'); + if ($absoluteFileName === '') { + return; + } + if (str_ends_with($absoluteFileName, '.' . $fileSuffix) && is_file($absoluteFileName)) { + // Simple file with allowed file suffix + if ($this->fileNameValidator->isValid($absoluteFileName)) { + $this->addSingleAtImportFile($node, $absoluteFileName, $atImportValue, $atImportName, $atImportLine); + $this->addStaticMagicFromGlobals($node, $atImportValue); + } + } elseif (is_dir($absoluteFileName)) { + // Directories with and without ending / + $filesAndDirs = scandir($absoluteFileName); + foreach ($filesAndDirs as $potentialInclude) { + if (!str_ends_with($potentialInclude, '.' . $fileSuffix) + || is_dir($absoluteFileName . '/' . $potentialInclude) + || !$this->fileNameValidator->isValid($absoluteFileName . '/' . $potentialInclude) + ) { + continue; + } + $singleAbsoluteFileName = $absoluteFileName . '/' . $potentialInclude; + $identifier = rtrim($atImportValue, '/') . '/' . $potentialInclude; + $this->addSingleAtImportFile($node, $singleAbsoluteFileName, $identifier, $identifier, $atImportLine); + $this->addStaticMagicFromGlobals($node, $identifier); + } + } elseif (is_file($absoluteFileName . '.' . $fileSuffix)) { + // File without .typoscript / .tsconfig suffix, but exists when suffix is added + if ($this->fileNameValidator->isValid($absoluteFileName . '.' . $fileSuffix)) { + $singleAbsoluteFileName = $absoluteFileName . '.' . $fileSuffix; + $identifier = $atImportValue . '.' . $fileSuffix; + $this->addSingleAtImportFile($node, $singleAbsoluteFileName, $identifier, $identifier, $atImportLine); + $this->addStaticMagicFromGlobals($node, $identifier); + } + } elseif (str_contains($absoluteFileName, '*')) { + // Something with * + $directory = rtrim(dirname($absoluteFileName) . '/'); + $directoryExists = is_dir($directory); + if (!$directoryExists && str_starts_with($atImportValue, './') && !$tryRelative) { + // See if we can import some relative wildcard like "./Setup/*" or "./Setup/*.typoscript" + $this->processAtImport($fileSuffix, $node, $atImportValueToken, $atImportLine, true); + return; + } + if (!$directoryExists) { + // Absolute directory. There is nothing to import if the directory does not exist. + return; + } + $filePattern = basename($absoluteFileName); + if (!str_contains($filePattern, '*')) { + // The * wildcard must occur in the filename, wildcards in directories are not handled. + return; + } + if (mb_substr_count($filePattern, '*') > 1) { + // Only one wildcard character is allowed, foo*.bar*.typoscript is considered an invalid pattern. + return; + } + // Normalize right side, making sure it always ends with $fileSuffix ".typoscript" / ".tsconfig" + if (str_ends_with($filePattern, $fileSuffix)) { + $filePattern = mb_substr($filePattern, 0, -1 * strlen($fileSuffix)); + $filePattern = rtrim($filePattern, '.'); + } + $filePattern = $filePattern . '.' . $fileSuffix; + $wildcardPosition = mb_strpos($filePattern, '*'); + $leftPrefix = mb_substr($filePattern, 0, $wildcardPosition); + $rightPrefix = mb_substr($filePattern, $wildcardPosition + 1); + $filesAndDirs = scandir($directory); + foreach ($filesAndDirs as $potentialInclude) { + if ($potentialInclude === '.' + || $potentialInclude === '..' + || !str_starts_with($potentialInclude, $leftPrefix) + || !str_ends_with($potentialInclude, $rightPrefix) + || is_dir($directory . $potentialInclude) + || !$this->fileNameValidator->isValid($directory . $potentialInclude) + ) { + continue; + } + $singleAbsoluteFileName = $directory . $potentialInclude; + $identifier = rtrim(dirname($atImportValue), '/') . '/' . $potentialInclude; + $this->addSingleAtImportFile($node, $singleAbsoluteFileName, $identifier, $identifier, $atImportLine); + $this->addStaticMagicFromGlobals($node, $identifier); + } + } elseif (!$tryRelative) { + // See if we can import relative "./foo.typoscript" or "foo.typoscript" + $this->processAtImport($fileSuffix, $node, $atImportValueToken, $atImportLine, true); + } + } + + /** + * Get content of a single @import file and add to current node as child. + * + * Warning: Recursively calls buildTree() to process includes of included content. + */ + private function addSingleAtImportFile( + IncludeInterface $parentNode, + string $absoluteFileName, + string $path, + string $name, + LineInterface $atImportLine + ): void { + $content = file_get_contents($absoluteFileName); + $newNode = new AtImportInclude(); + $newNode->setName($name); + $newNode->setPath($path); + $newNode->setLineStream($this->tokenizer->tokenize($content)); + $newNode->setOriginalLine($atImportLine); + $this->buildTreeInternal($newNode); + $parentNode->addChild($newNode); + } + + /** + * A rather weird lookup in $GLOBALS['TYPO3_CONF_VARS']['FE'] for magic includes. + * See ExtensionManagementUtility::addTypoScript() for more details on this. + * Warning: Yes, this is recursive again. + */ + private function addStaticMagicFromGlobals(IncludeInterface $parentNode, string $path): void + { + if (!in_array($this->type, ['constants', 'setup'], true) || !str_starts_with($path, 'EXT:')) { + // This magic method is relevant for Frontend TypoScript only, indicated by + // $this->type being either "constants" or "setup". + return; + } + $includeStaticFileWithoutExt = substr($path, 4); + $includeStaticFileExtKeyAndPath = GeneralUtility::trimExplode('/', $includeStaticFileWithoutExt, true, 2); + $extensionKey = $includeStaticFileExtKeyAndPath[0]; + $extensionKeyWithoutUnderscores = str_replace('_', '', $extensionKey); + if (!$extensionKeyWithoutUnderscores || !ExtensionManagementUtility::isLoaded($extensionKey)) { + return; + } + // example: 'Configuration/TypoScript/MyStaticInclude/' + $pathSegmentWithAppendedSlash = rtrim(dirname($includeStaticFileExtKeyAndPath[1])) . '/'; + $file = basename($path); + $type = GeneralUtility::trimExplode('.', $file, false, 2)[0] ?? ''; + if ($type !== $this->type) { + return; + } + $globalsLookup = $extensionKeyWithoutUnderscores . '/' . $pathSegmentWithAppendedSlash; + + if (!$this->enableMagicIncludes) { + return; + } + // If this is a template of type "default content rendering", see if other extensions have added their TypoScript that should be included. + if (in_array($globalsLookup, $GLOBALS['TYPO3_CONF_VARS']['FE']['contentRenderingTemplates'], true)) { + $source = $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.']['defaultContentRendering'] ?? null; + if (!empty($source)) { + $node = new DefaultTypoScriptMagicKeyInclude(); + $node->setName('TYPO3_CONF_VARS defaultContentRendering for ' . $path); + $node->setLineStream($this->tokenizer->tokenize($source)); + $this->buildTreeInternal($node); + $parentNode->addChild($node); + } + } + } +} diff --git a/Classes/TypoScript/IncludeTree/TsConfigTreeBuilder.php b/Classes/TypoScript/IncludeTree/TsConfigTreeBuilder.php new file mode 100644 index 0000000..17b31ac --- /dev/null +++ b/Classes/TypoScript/IncludeTree/TsConfigTreeBuilder.php @@ -0,0 +1,342 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\EventDispatcher\EventDispatcher; +use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Event\BeforeLoadedPageTsConfigEvent; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Event\BeforeLoadedUserTsConfigEvent; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Event\ModifyLoadedPageTsConfigEvent; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\TsConfigInclude; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; +use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Build include tree for user TSconfig and page TSconfig. This is typically used only by + * UserTsConfigFactory and PageTsConfigFactory. + * + * @internal + */ +#[Autoconfigure(public: true)] +final readonly class TsConfigTreeBuilder +{ + public function __construct( + private TreeFromLineStreamBuilder $treeFromTokenStreamBuilder, + private PackageManager $packageManager, + private EventDispatcher $eventDispatcher, + private SiteFinder $siteFinder, + private SetRegistry $setRegistry, + ) {} + + public function getUserTsConfigTree( + BackendUserAuthentication $backendUser, + TokenizerInterface $tokenizer, + ?PhpFrontend $cache = null + ): RootInclude { + $includeTree = new RootInclude(); + + $collectedUserTsConfigArray = []; + $gotPackagesUserTsConfigFromCache = false; + $cacheIdentifier = (new PackageDependentCacheIdentifier($this->packageManager)) + ->withPrefix('usertsconfig-packages-strings') + ->toString(); + if ($cache) { + $collectedUserTsConfigArrayFromCache = $cache->require($cacheIdentifier); + if ($collectedUserTsConfigArrayFromCache) { + $gotPackagesUserTsConfigFromCache = true; + $collectedUserTsConfigArray = $collectedUserTsConfigArrayFromCache; + } + } + if (!$gotPackagesUserTsConfigFromCache) { + $event = $this->eventDispatcher->dispatch(new BeforeLoadedUserTsConfigEvent()); + $collectedUserTsConfigArray = $event->getTsConfig(); + foreach ($this->packageManager->getActivePackages() as $package) { + $packagePath = $package->getPackagePath(); + $tsConfigFile = null; + if (file_exists($packagePath . 'Configuration/user.tsconfig')) { + $tsConfigFile = $packagePath . 'Configuration/user.tsconfig'; + } elseif (file_exists($packagePath . 'Configuration/User.tsconfig')) { + $tsConfigFile = $packagePath . 'Configuration/User.tsconfig'; + } + if ($tsConfigFile) { + $typoScriptString = @file_get_contents($tsConfigFile); + if (!empty($typoScriptString)) { + $collectedUserTsConfigArray['userTsConfig-package-' . $package->getPackageKey()] = $typoScriptString; + } + } + } + $cache?->set($cacheIdentifier, 'return unserialize(\'' . addcslashes(serialize($collectedUserTsConfigArray), '\'\\') . '\');'); + } + foreach ($collectedUserTsConfigArray as $key => $typoScriptString) { + $includeTree->addChild($this->getTreeFromString((string)$key, $typoScriptString, $tokenizer, $cache)); + } + + foreach ($backendUser->userGroupsUID as $groupId) { + // Loop through all groups and add their 'TSconfig' fields + if (!empty($backendUser->userGroups[$groupId]['TSconfig'] ?? '')) { + $includeTree->addChild($this->getTreeFromString('userTsConfig-group-' . $groupId, $backendUser->userGroups[$groupId]['TSconfig'], $tokenizer, $cache)); + } + if (trim($backendUser->userGroups[$groupId]['tsconfig_includes'] ?? '')) { + $includeTsConfigFileList = GeneralUtility::trimExplode(',', $backendUser->userGroups[$groupId]['tsconfig_includes'], true); + foreach ($includeTsConfigFileList as $key => $includeTsConfigFile) { + $content = $this->getContentOfTsconfigFile($includeTsConfigFile); + if (!empty($content)) { + $includeTree->addChild($this->getTreeFromString('userTsConfig-include-group' . $key, $content, $tokenizer, $cache)); + } + } + } + } + if (!empty($backendUser->user['TSconfig'] ?? '')) { + $includeTree->addChild($this->getTreeFromString('userTsConfig-user', $backendUser->user['TSconfig'], $tokenizer, $cache)); + } + if (trim($backendUser->user['tsconfig_includes'] ?? '')) { + $includeTsConfigFileList = GeneralUtility::trimExplode(',', $backendUser->user['tsconfig_includes'], true); + foreach ($includeTsConfigFileList as $key => $includeTsConfigFile) { + $content = $this->getContentOfTsconfigFile($includeTsConfigFile); + if (!empty($content)) { + $includeTree->addChild($this->getTreeFromString('userTsConfig-include-user' . $key, $content, $tokenizer, $cache)); + } + } + } + + return $includeTree; + } + + public function getPagesTsConfigTree( + array $rootLine, + TokenizerInterface $tokenizer, + ?PhpFrontend $cache = null + ): RootInclude { + $collectedPagesTsConfigArray = []; + + $collectedPagesTsConfigArray += $this->getPackagePageTsConfigTree($cache); + + // HEADS up: rootLine may be modified by getSitePagesTsConfigTree + $collectedPagesTsConfigArray += $this->getSitePageTsConfigTree($rootLine, $cache); + + $collectedPagesTsConfigArray += $this->getRootlinePageTsConfigTree($rootLine, $cache); + + $event = $this->eventDispatcher->dispatch(new ModifyLoadedPageTsConfigEvent( + array_map(static fn(array $descriptor): string => $descriptor['content'], $collectedPagesTsConfigArray), + $rootLine + )); + $collectedPagesTsConfigContentArray = $event->getTsConfig(); + foreach ($collectedPagesTsConfigContentArray as $key => $content) { + $collectedPagesTsConfigArray[$key]['content'] = $content; + } + + $includeTree = new RootInclude(); + foreach ($collectedPagesTsConfigArray as $key => $descriptor) { + $typoScriptString = $descriptor['content']; + $filename = $descriptor['filename'] ?? null; + $includeTree->addChild($this->getTreeFromString((string)$key, $typoScriptString, $tokenizer, $cache, $filename)); + } + return $includeTree; + } + + private function getPackagePageTsConfigTree( + ?PhpFrontend $cache = null + ): array { + $collectedPagesTsConfigArray = []; + $gotPackagesPagesTsConfigFromCache = false; + $cacheIdentifier = (new PackageDependentCacheIdentifier($this->packageManager)) + ->withPrefix('pagestsconfig-packages-strings') + ->toString(); + if ($cache) { + $collectedPagesTsConfigArrayFromCache = $cache->require($cacheIdentifier); + if ($collectedPagesTsConfigArrayFromCache) { + $gotPackagesPagesTsConfigFromCache = true; + $collectedPagesTsConfigArray = $collectedPagesTsConfigArrayFromCache; + } + } + if (!$gotPackagesPagesTsConfigFromCache) { + $event = $this->eventDispatcher->dispatch(new BeforeLoadedPageTsConfigEvent()); + $collectedPagesTsConfigArray = array_map(static fn(string $config): array => ['content' => $config, 'filename' => null], $event->getTsConfig()); + foreach ($this->packageManager->getActivePackages() as $package) { + $packagePath = $package->getPackagePath(); + $tsConfigFile = null; + if (file_exists($packagePath . 'Configuration/page.tsconfig')) { + $tsConfigFile = $packagePath . 'Configuration/page.tsconfig'; + } elseif (file_exists($packagePath . 'Configuration/Page.tsconfig')) { + $tsConfigFile = $packagePath . 'Configuration/Page.tsconfig'; + } + if ($tsConfigFile) { + $typoScriptString = @file_get_contents($tsConfigFile); + if (!empty($typoScriptString)) { + $collectedPagesTsConfigArray['pagesTsConfig-package-' . $package->getPackageKey()] = [ + 'filename' => $tsConfigFile, + 'content' => $typoScriptString, + ]; + } + } + } + $cache?->set($cacheIdentifier, 'return unserialize(\'' . addcslashes(serialize($collectedPagesTsConfigArray), '\'\\') . '\');'); + } + return $collectedPagesTsConfigArray; + } + + private function getSitePageTsConfigTree( + array &$rootLine, + ?PhpFrontend $cache = null + ): array { + $reverseRootLine = array_reverse($rootLine); + $rootlineUntilSite = []; + $rootSite = null; + foreach ($reverseRootLine as $rootLineEntry) { + array_unshift($rootlineUntilSite, $rootLineEntry); + $uid = (int)($rootLineEntry['uid'] ?? 0); + if ($uid === 0) { + continue; + } + try { + $site = $this->siteFinder->getSiteByRootPageId($uid); + } catch (SiteNotFoundException) { + continue; + } + if ($site->isTypoScriptRoot()) { + $rootSite = $site; + $rootLine = $rootlineUntilSite; + break; + } + } + + if ($rootSite === null) { + return []; + } + + $cacheIdentifier = (new PackageDependentCacheIdentifier($this->packageManager)) + ->withPrefix('pagestsconfig-site') + ->withAdditionalHashedIdentifier($rootSite->getIdentifier()) + ->toString(); + $pageTsConfig = $cache?->require($cacheIdentifier) ?: null; + + if ($pageTsConfig === null) { + $pageTsConfig = []; + $sets = $this->setRegistry->getSets(...$rootSite->getSets()); + foreach ($sets as $set) { + if ($set->pagets === null) { + continue; + } + $filename = GeneralUtility::getFileAbsFileName($set->pagets); + if (!file_exists($filename)) { + continue; + } + $content = @file_get_contents($filename); + if (!empty($content)) { + $pageTsConfig['pageTsConfig-set-' . str_replace('/', '-', $set->name)] = [ + 'filename' => $filename, + 'content' => $content, + ]; + } + } + + $pageTsConfig['pageTsConfig-site-' . $rootSite->getIdentifier()] = [ + 'filename' => GeneralUtility::getFileAbsFileName(Environment::getConfigPath() . '/sites/' . $rootSite->getIdentifier() . '/page.tsconfig'), + 'content' => $rootSite->getTSconfig()->pageTSconfig ?? '', + ]; + $cache?->set($cacheIdentifier, 'return ' . var_export($pageTsConfig, true) . ';'); + } + return $pageTsConfig; + } + + private function getRootlinePageTsConfigTree( + array $rootLine, + ?PhpFrontend $cache = null + ): array { + $collectedPagesTsConfigArray = []; + foreach ($rootLine as $page) { + if (empty($page['uid'])) { + // Page 0 can happen when the rootline is given from BE context. It has not TSconfig. Skip this. + continue; + } + if (trim($page['tsconfig_includes'] ?? '')) { + $includeTsConfigFileList = GeneralUtility::trimExplode(',', $page['tsconfig_includes'], true); + foreach ($includeTsConfigFileList as $key => $includeTsConfigFile) { + $content = $this->getContentOfTsconfigFile($includeTsConfigFile); + if (!empty($content)) { + $collectedPagesTsConfigArray['pagesTsConfig-page-' . $page['uid'] . '-includes-' . $key] = [ + 'content' => $content, + ]; + } + } + } + if (!empty($page['TSconfig'])) { + $collectedPagesTsConfigArray['pagesTsConfig-page-' . $page['uid'] . '-tsConfig'] = ['content' => $page['TSconfig']]; + } + } + return $collectedPagesTsConfigArray; + } + + private function getContentOfTsconfigFile(string $path): string + { + if (PathUtility::isExtensionPath($path)) { + [$includeTsConfigFileExtensionKey, $includeTsConfigFilename] = explode('/', substr($path, 4), 2); + if ($includeTsConfigFilename !== '' + && $includeTsConfigFileExtensionKey !== '' + && ExtensionManagementUtility::isLoaded($includeTsConfigFileExtensionKey) + ) { + $extensionPath = ExtensionManagementUtility::extPath($includeTsConfigFileExtensionKey); + $includeTsConfigFileAndPath = PathUtility::getCanonicalPath($extensionPath . $includeTsConfigFilename); + if (str_starts_with($includeTsConfigFileAndPath, $extensionPath) && file_exists($includeTsConfigFileAndPath)) { + return (string)file_get_contents($includeTsConfigFileAndPath); + } + } + } + return ''; + } + + private function getTreeFromString( + string $name, + string $typoScriptString, + TokenizerInterface $tokenizer, + ?PhpFrontend $cache = null, + ?string $filename = null, + ): TsConfigInclude { + $lowercaseName = mb_strtolower($name); + $identifier = (new PackageDependentCacheIdentifier($this->packageManager)) + ->withPrefix($lowercaseName) + ->withAdditionalHashedIdentifier($typoScriptString) + ->toString(); + if ($cache) { + $includeNode = $cache->require($identifier); + if ($includeNode instanceof TsConfigInclude) { + return $includeNode; + } + } + $includeNode = new TsConfigInclude(); + $includeNode->setName($name); + if ($filename !== null) { + $includeNode->setPath($filename); + } + $includeNode->setLineStream($tokenizer->tokenize($typoScriptString)); + $this->treeFromTokenStreamBuilder->buildTree($includeNode, 'tsconfig', $tokenizer); + $cache?->set($identifier, 'return unserialize(\'' . addcslashes(serialize($includeNode), '\'\\') . '\');'); + return $includeNode; + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeAstBuilderVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeAstBuilderVisitor.php new file mode 100644 index 0000000..b79e8b8 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeAstBuilderVisitor.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\TypoScript\AST\AstBuilderInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SysTemplateInclude; + +/** + * Main visitor that creates the TypoScript AST: When adding this visitor + * and traversing the IncludeTree, the final AST can be fetched using getAst(). + * + * This visitor is usually only used together with ConditionVerdictAwareIncludeTreeTraverser, + * and the IncludeTreeConditionMatcherVisitor is added *before* this visitor to determine + * condition verdicts, so AST is only extended for conditions with "true" verdict. + * + * When parsing "setup", "flattened" constants should be assigned to this visitor, so + * the AstBuilder can resolve constants. + * + * @internal: Internal tree structure. + */ + +// Ast builder visitor creates state and should not be re-used +#[Autoconfigure(public: true, shared: false)] +final class IncludeTreeAstBuilderVisitor implements IncludeTreeVisitorInterface +{ + private RootNode $ast; + + /** + * @var array<string, string> + */ + private array $flatConstants = []; + + public function __construct(private readonly AstBuilderInterface $astBuilder) + { + $this->ast = new RootNode(); + } + + /** + * When 'setup' is parsed, setting resolved flat constants here will make + * the AST builder substitute these constants. + * + * @param array<string, string> $flatConstants + */ + public function setFlatConstants(array $flatConstants): void + { + $this->flatConstants = $flatConstants; + } + + public function getAst(): RootNode + { + return $this->ast; + } + + /** + * Reset AST if "clear" flag is set. That's a sys_template record specific thing + * to restart with a new RootNode and drop any AST calculated already. + */ + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if ($include instanceof SysTemplateInclude && $include->isClear()) { + // Reset any given AST if this sys_template row has clear flag (constants or setup clear) set. + $this->ast = new RootNode(); + } + } + + /** + * Extend current AST with given LineStream of include node. + */ + public function visit(IncludeInterface $include, int $currentDepth): void + { + $lineStream = $include->getLineStream(); + if ($lineStream && !$include->isSplit()) { + // A "split" include means that the entire TypoScript is split into child includes. The + // TokenStream of the split include itself must not be parsed, so it's excluded here. + $this->ast = $this->astBuilder->build($lineStream, $this->ast, $this->flatConstants); + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeCommentAwareAstBuilderVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeCommentAwareAstBuilderVisitor.php new file mode 100644 index 0000000..de32600 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeCommentAwareAstBuilderVisitor.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\TypoScript\AST\CommentAwareAstBuilder; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SysTemplateInclude; + +/** + * Secondary visitor that creates the TypoScript AST: When adding this visitor + * and traversing the IncludeTree, the final AST can be fetched using getAst(). + * This is an "extended" version of IncludeTreeAstBuilderVisitor that uses + * the CommentAwareAstBuilder instead of the AstBuilder to build the AST: This special + * AST builder is comment aware and adds TypoScript comments to nodes. + * + * This visitor is used in ext:tstemplate TypoScript modules and ext:backend page TSconfig + * to allow implementation of the "comment" related functionality. + * + * When parsing "setup", "flattened" constants should be assigned to this visitor, so + * the AstBuilder can resolve constants. + * + * @internal: Internal tree structure. + */ + +// This Ast builder visitor creates state and should not be re-used +#[Autoconfigure(public: true, shared: false)] +final class IncludeTreeCommentAwareAstBuilderVisitor implements IncludeTreeVisitorInterface +{ + private RootNode $ast; + + /** + * @var array<string, string> + */ + private array $flatConstants = []; + + public function __construct(private readonly CommentAwareAstBuilder $astBuilder) + { + $this->ast = new RootNode(); + } + + /** + * When 'setup' is parsed, setting resolved flat constants here will make + * the AST builder substitute these constants. + * + * @param array<string, string> $flatConstants + */ + public function setFlatConstants(array $flatConstants): void + { + $this->flatConstants = $flatConstants; + } + + public function getAst(): RootNode + { + return $this->ast; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if ($include instanceof SysTemplateInclude && $include->isClear()) { + // Reset any given AST if this sys_template row has clear flag (constants or setup clear) set. + $this->ast = new RootNode(); + } + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + $tokenStream = $include->getLineStream(); + if ($tokenStream && !$include->isSplit()) { + $this->ast = $this->astBuilder->build($tokenStream, $this->ast, $this->flatConstants); + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionAggregatorVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionAggregatorVisitor.php new file mode 100644 index 0000000..6483ef1 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionAggregatorVisitor.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * Gather conditions in an IncludeTree. + * + * This visitor is used in ext:tstemplate TypoScript modules and ext:backend page TSconfig + * backend modules to find available conditions and make them toggleable. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class IncludeTreeConditionAggregatorVisitor implements IncludeTreeVisitorInterface +{ + /** + * @var array<int, array<string, string>> + */ + private array $conditions = []; + + /** + * Get accumulated conditions gathered by visit(). + */ + public function getConditions(): array + { + return $this->conditions; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + // No-op. Magic happens in visit() + } + + /** + * If the given include is an IncludeConditionInterface, grab it's original (unchanged by constants) + * condition token. + */ + public function visit(IncludeInterface $include, int $currentDepth): void + { + if (!$include instanceof IncludeConditionInterface) { + return; + } + $condition = $include->getConditionToken()->getValue(); + if (!in_array($condition, array_column($this->conditions, 'value'))) { + $this->conditions[] = [ + 'value' => $condition, + 'originalValue' => $include->getOriginalConditionToken()?->getValue(), + ]; + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionEnforcerVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionEnforcerVisitor.php new file mode 100644 index 0000000..60cd47f --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionEnforcerVisitor.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * Force condition verdicts. + * + * This visitor is used in ext:tstemplate TypoScript modules and ext:backend page TSconfig + * backend modules to toggle on/off selected conditions. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class IncludeTreeConditionEnforcerVisitor implements IncludeTreeVisitorInterface +{ + /** + * @var array<int, string> + */ + private array $enabledConditions; + + public function setEnabledConditions(array $enabledConditions): void + { + $this->enabledConditions = $enabledConditions; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if (!$include instanceof IncludeConditionInterface) { + return; + } + $conditionValue = $include->getConditionToken()->getValue(); + if (in_array($conditionValue, $this->enabledConditions) && !$include->isConditionNegated() + || !in_array($conditionValue, $this->enabledConditions) && $include->isConditionNegated() + ) { + $include->setConditionVerdict(true); + } else { + $include->setConditionVerdict(false); + } + } + + public function visit(IncludeInterface $include, int $currentDepth): void {} +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionIncludeListAccumulatorVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionIncludeListAccumulatorVisitor.php new file mode 100644 index 0000000..16a2fcd --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionIncludeListAccumulatorVisitor.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; + +/** + * This is used in FE to "gather" condition nodes as a flat tree (root + condition nodes). + * The FE uses this optimized tree to quickly determine condition verdicts without loading + * the full tree. + * + * @internal: Internal tree structure. + */ + +// This visitor creates state and should not be re-used +#[Autoconfigure(public: true, shared: false)] +final class IncludeTreeConditionIncludeListAccumulatorVisitor implements IncludeTreeVisitorInterface +{ + private RootInclude $rootInclude; + + public function __construct() + { + $this->rootInclude = new RootInclude(); + } + + public function getConditionIncludes(): RootInclude + { + return $this->rootInclude; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if (!$include instanceof IncludeConditionInterface) { + return; + } + /** @var IncludeConditionInterface&IncludeInterface $newConditionInclude */ + $newConditionInclude = (new ($include::class)); + $newConditionInclude->setConditionToken($include->getConditionToken()); + $this->rootInclude->addChild($newConditionInclude); + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + // Noop, just implement interface. + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionMatcherVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionMatcherVisitor.php new file mode 100644 index 0000000..cdbcd22 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeConditionMatcherVisitor.php @@ -0,0 +1,200 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\UserAspect; +use TYPO3\CMS\Core\Context\WorkspaceAspect; +use TYPO3\CMS\Core\ExpressionLanguage\RequestWrapper; +use TYPO3\CMS\Core\ExpressionLanguage\Resolver; +use TYPO3\CMS\Core\Page\PageLayoutResolver; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * A visitor that looks at IncludeConditionInterface nodes and + * evaluates their conditions. + * + * Condition matching is done in visitBeforeChildren() to be used in combination with + * ConditionVerdictAwareIncludeTreeTraverser, so children are only traversed for + * conditions that evaluated true. + * + * @internal: Internal tree structure. + */ + +// This visitor creates state and should not be re-used +#[Autoconfigure(public: true, shared: false)] +final class IncludeTreeConditionMatcherVisitor implements IncludeTreeVisitorInterface +{ + private Resolver $resolver; + private array $conditionList = []; + + public function __construct( + private readonly Context $context, + private readonly PageLayoutResolver $pageLayoutResolver, + private readonly LoggerInterface $logger, + ) {} + + /** + * Prepare the core expression language Resolver class - our API to symfony + * expression language - for typoscript context usage. + * + * The method gets a series of variables hand over coming from caller scope + * like rootline, page array and eventually a request object. These vars are + * munged around a bit and enriched with a series of semi-static state variables: + * Things that can be injected like derived from context, for example + * frontend / backend user, workspace and similar. + * This ensures all typoscript 'conditions' receive similar structured data. + */ + public function initializeExpressionMatcherWithVariables(array $variables): void + { + $context = $this->context; + $enrichedVariables = [ + 'context' => $context, + ]; + // Variables derived directly from context are set if context provides according aspects. + $frontendUserAspect = $this->context->getAspect('frontend.user'); + if ($frontendUserAspect instanceof UserAspect) { + $frontend = new \stdClass(); + $frontend->user = new \stdClass(); + $frontend->user->isLoggedIn = $frontendUserAspect->get('isLoggedIn'); + $frontend->user->userId = $frontendUserAspect->get('id'); + $frontend->user->userGroupList = implode(',', $frontendUserAspect->get('groupIds')); + $frontend->user->userGroupIds = $frontendUserAspect->get('groupIds'); + $enrichedVariables['frontend'] = $frontend; + } + $backendUserAspect = $this->context->getAspect('backend.user'); + if ($backendUserAspect instanceof UserAspect) { + $backend = new \stdClass(); + $backend->user = new \stdClass(); + $backend->user->isAdmin = $backendUserAspect->get('isAdmin'); + $backend->user->isLoggedIn = $backendUserAspect->get('isLoggedIn'); + $backend->user->userId = $backendUserAspect->get('id'); + $backend->user->userGroupList = implode(',', $backendUserAspect->get('groupIds')); + $backend->user->userGroupIds = $backendUserAspect->get('groupIds'); + $enrichedVariables['backend'] = $backend; + } + $workspaceAspect = $this->context->getAspect('workspace'); + if ($workspaceAspect instanceof WorkspaceAspect) { + $workspace = new \stdClass(); + $workspace->workspaceId = $workspaceAspect->get('id'); + $workspace->isLive = $workspaceAspect->get('isLive'); + $workspace->isOffline = $workspaceAspect->get('isOffline'); + $enrichedVariables['workspace'] = $workspace; + } + + $pageId = $variables['pageId'] ?? 0; + + // If rootLine is given, create an object that contains some prepared values. + $fullRootLine = $variables['fullRootLine'] ?? null; + if ($fullRootLine === null && $pageId > 0) { + $fullRootLine = BackendUtility::BEgetRootLine($pageId, '', true); + ksort($fullRootLine); + } + // 'tree' is always exposed to the expression language, even when no rootline could be + // determined (e.g. DataHandler CLI operations on orphaned records with a pid pointing to + // a non-existing page). Conditions like '[123 in tree.rootLineIds]' must then evaluate + // to false instead of raising a SyntaxError for an unknown 'tree' variable. + $localRootLine = $variables['localRootLine'] ?? $fullRootLine ?? []; + $tree = new \stdClass(); + $tree->level = count($localRootLine) - 1; + $tree->rootLine = $localRootLine; + $tree->fullRootLine = $fullRootLine ?? []; + $tree->rootLineIds = array_column($localRootLine, 'uid'); + $tree->rootLineParentIds = array_slice(array_column($localRootLine, 'pid'), 1); + $tree->pagelayout = null; + if ($localRootLine !== []) { + // We're feeding the "full" RootLine here, not the "local" one that stops at sys_template record having 'root' set. + // This is to be in-line with backend here: A 'backend_layout_next_level' on a page above sys_template 'root' page should + // still be considered. Normally, $fullRootLine is "deepest page first, then up". This is needed for getLayoutForPage() to find + // the 'nearest' parent. However, here it is always passed sorted, so it is a top-down rootLine. Hence, this needs to be once + // again reversed at this point. + $bottomUpFullRootLine = array_reverse($fullRootLine); + $tree->pagelayout = $this->pageLayoutResolver->getLayoutIdentifierForPage($variables['page'], $bottomUpFullRootLine); + } + $enrichedVariables['tree'] = $tree; + + // If a request is given, make sure it is an instance of RequestWrapper, + // if not, create an instance from ServerRequestInterface and set it. + if (isset($variables['request']) && !($variables['request'] instanceof RequestWrapper)) { + $variables['request'] = new RequestWrapper($variables['request']); + } elseif (!isset($variables['request'])) { + $variables['request'] = new RequestWrapper(null); + } + + // We do not expose pageId, rootLine and fullRootLine to conditions directly. + unset($variables['pageId'], $variables['localRootLine'], $variables['fullRootLine']); + + $enrichedVariables = array_replace($enrichedVariables, $variables); + + $this->resolver = new Resolver('typoscript', $enrichedVariables); + } + + /** + * A list of all handled conditions with their verdicts. + * This is used in FE since condition verdicts influence page caches. + */ + public function getConditionListWithVerdicts(): array + { + return $this->conditionList; + } + + /** + * Let symfony expression language handle the expression, gather expressions + * that have been handled since they influence page caching, negate expression + * verdicts if they're a [else] expression. + */ + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if (!$include instanceof IncludeConditionInterface) { + return; + } + $conditionExpression = $include->getConditionToken()->getValue(); + try { + $verdict = (bool)$this->resolver->evaluate($conditionExpression); + } catch (SyntaxError $e) { + $this->logger->error('TypoScript condition [{expression}] could not be parsed: {error}', [ + 'expression' => $conditionExpression, + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + $verdict = false; + } catch (\RuntimeException $e) { + throw new \RuntimeException( + sprintf('TypoScript condition [%s] could not be evaluated: %s', $conditionExpression, $e->getMessage()), + 1731486757, + $e + ); + } + if ($include->isConditionNegated()) { + // Honor ConditionElseInclude "[ELSE]" which negates the verdict of the main condition. + $verdict = !$verdict; + } + $this->conditionList[$conditionExpression] = $verdict; + $include->setConditionVerdict($verdict); + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + // Noop, just implement interface + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeNodeFinderVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeNodeFinderVisitor.php new file mode 100644 index 0000000..9270f3e --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeNodeFinderVisitor.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * Find a single node in tree identified by node identifier. + * + * This visitor is used in ext:tstemplate TypoScript modules and ext:backend page TSconfig + * backend modules to find single nodes, for instance when their source should be rendered. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class IncludeTreeNodeFinderVisitor implements IncludeTreeVisitorInterface +{ + private ?IncludeInterface $foundNode = null; + private string $nodeIdentifier; + + public function setNodeIdentifier(string $nodeIdentifier) + { + $this->nodeIdentifier = $nodeIdentifier; + } + + public function getFoundNode(): ?IncludeInterface + { + return $this->foundNode; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if ($include->getIdentifier() === $this->nodeIdentifier) { + $this->foundNode = $include; + } + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + // Implement interface + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSetupConditionConstantSubstitutionVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSetupConditionConstantSubstitutionVisitor.php new file mode 100644 index 0000000..3885edc --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSetupConditionConstantSubstitutionVisitor.php @@ -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\Core\TypoScript\IncludeTree\Visitor; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeConditionInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * Handle constants within (TS setup) conditions: + * When a conditional include is like this: '["{$foo.bar}" == "4711"]', this visitor looks + * up 'foo.bar in given (flattened) constants and substitutes it with the constant value. + * The 'include' object then contains the substituted condition token for 'getConditionToken()', + * while the original token without the substitution is parked in 'getOriginalConditionToken()'. + * The latter is done to have the original token available in the backend to show, it is irrelevant in frontend. + * + * @internal: Internal tree structure. + */ + +// This visitor creates state and should not be re-used +#[Autoconfigure(public: true, shared: false)] +final class IncludeTreeSetupConditionConstantSubstitutionVisitor implements IncludeTreeVisitorInterface +{ + /** + * @var array<string, string> + */ + private array $flattenedConstants; + + /** + * Must be set when adding this visitor, to an empty array at least. + * Will fatal otherwise, and that's fine, since if not setting this, + * this visitor is useless and shouldn't be added at all. + * + * @param array<string, string> $flattenedConstants + */ + public function setFlattenedConstants(array $flattenedConstants): void + { + $this->flattenedConstants = $flattenedConstants; + } + + /** + * Do the magic, see tests for details. + * Implementation within 'visitBeforeChildren()' since this allows running *both* this + * visitor first, and then IncludeTreeConditionMatcherVisitor directly afterward in the same + * traverser cycle! + */ + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if (!$include instanceof IncludeConditionInterface) { + return; + } + $conditionToken = $include->getConditionToken(); + $conditionValue = $conditionToken->getValue(); + $flattenedConstants = $this->flattenedConstants; + $hadSubstitution = false; + $newConditionValue = preg_replace_callback( + '/{\$(.[^}]*)}/', + static function ($match) use ($flattenedConstants, &$hadSubstitution) { + // Replace {$someConstant} if found, else leave unchanged + if (array_key_exists($match[1], $flattenedConstants)) { + $hadSubstitution = true; + return $flattenedConstants[$match[1]]; + } + return $match[0]; + }, + $conditionValue + ); + if ($hadSubstitution) { + $include->setOriginalConditionToken($conditionToken); + $include->setConditionToken(new Token(TokenType::T_VALUE, $newConditionValue, $conditionToken->getLine(), $conditionToken->getColumn())); + } + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + // Noop, just implement interface + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSourceAggregatorVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSourceAggregatorVisitor.php new file mode 100644 index 0000000..8c8d667 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSourceAggregatorVisitor.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\AtImportInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionElseInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * Create a TypoScript source back from an IncludeTree. Inline source from + * "@import" and friends. + * + * This visitor is used in ext:tstemplate TypoScript modules and ext:backend page TSconfig + * backend modules to show code of single includes with their resolved imports. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class IncludeTreeSourceAggregatorVisitor implements IncludeTreeVisitorInterface +{ + /** + * The accumulated source. + */ + private string $source = ''; + + /** + * Restrict source rendering to specific includes. Used in BE template analyzer + * to output source of a single include and its sub includes. Since a single include + * could be included multiple times, we track if source for it has been build to + * suppress outputting it multiple times. + */ + private string $startNodeIdentifier = ''; + private bool $startNodeHandled = false; + private int $startNodeDepth = 0; + private bool $isWithinStartNode = false; + + public function setStartNodeIdentifier(string $startNodeIdentifier) + { + $this->startNodeIdentifier = $startNodeIdentifier; + } + + public function getSource(): string + { + return $this->source; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void + { + if ($this->startNodeHandled && $currentDepth <= $this->startNodeDepth) { + $this->isWithinStartNode = false; + } + if ($this->startNodeIdentifier === $include->getIdentifier() && !$this->startNodeHandled) { + $this->startNodeDepth = $currentDepth; + $this->isWithinStartNode = true; + $this->startNodeHandled = true; + } + if (empty($this->startNodeIdentifier) || $this->isWithinStartNode) { + $lineStream = $include->getLineStream(); + if ($lineStream !== null + && !$lineStream->isEmpty() + && ($include instanceof ConditionInclude || $include instanceof ConditionElseInclude) + ) { + $this->source .= "\n#\n# Condition from '" . $include->getName() . '\' Line ' . $include->getConditionToken()->getLine() . "\n#\n"; + $this->source .= $lineStream; + } + if ($include instanceof AtImportInclude) { + $this->source .= "\n#\n# Include from definition '" . trim((string)($include->getOriginalLine()->getTokenStream())) . "'\n#\n"; + } + } + } + + public function visit(IncludeInterface $include, int $currentDepth): void + { + if (empty($this->startNodeIdentifier) || $this->isWithinStartNode) { + $lineStream = $include->getLineStream(); + if ($lineStream === null + || $lineStream->isEmpty() + || ($include->isSplit()) + ) { + return; + } + $this->source .= "\n#\n# Content from '" . $include->getName() . "'\n#\n"; + $this->source .= $lineStream; + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSyntaxScannerVisitor.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSyntaxScannerVisitor.php new file mode 100644 index 0000000..b0b31c7 --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeSyntaxScannerVisitor.php @@ -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\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\AtImportInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionElseInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\ConditionInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\BlockCloseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierBlockOpenLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ImportLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\InvalidLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineInterface; + +/** + * This implements a simple TypoScript syntax scanner. It is used in page TSconfig + * and TypoScript "include" submodules to find and show broken syntax. + * + * @internal This is a specific Backend implementation and is not considered part of the Public TYPO3 API. + */ +final class IncludeTreeSyntaxScannerVisitor implements IncludeTreeVisitorInterface +{ + /** + * @var list<array{type: string, include: IncludeInterface, line: LineInterface, lineNumber: int}> + */ + private array $errors = []; + + /** + * @return list<array{type: string, include: IncludeInterface, line: LineInterface, lineNumber: int}> + */ + public function getErrors(): array + { + return $this->errors; + } + + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void {} + + public function visit(IncludeInterface $include, int $currentDepth): void + { + $this->brokenLinesAndBraces($include); + $this->emptyImports($include); + + // Add the line number of the first token of the line object to the error array. + // Not strictly needed, but more convenient in Fluid template to render. + foreach ($this->errors as &$error) { + /** @var LineInterface $line */ + $line = $error['line']; + $error['lineNumber'] = $line->getTokenStream()->reset()->peekNext()->getLine(); + } + + // Sort array by line number to list them top->bottom in view. + usort($this->errors, fn($a, $b) => $a['lineNumber'] <=> $b['lineNumber']); + } + + /** + * Scan for invalid lines ("foo.bar <" is invalid since there must be something after "<"), + * and scan for "too many" and "not enough" "}" braces. + */ + private function brokenLinesAndBraces(IncludeInterface $include): void + { + if ($include->isSplit()) { + // If this node is split, don't check for syntax errors, this is + // done for child nodes. + return; + } + $lineStream = $include->getLineStream(); + if (!$lineStream) { + return; + } + $braceCount = 0; + $lastLine = null; + foreach ($lineStream->getNextLine() as $line) { + $lastLine = $line; + if ($line instanceof InvalidLine) { + $this->errors[] = [ + 'type' => 'line.invalid', + 'include' => $include, + 'line' => $line, + ]; + } + if ($line instanceof IdentifierBlockOpenLine) { + $braceCount++; + } + if ($line instanceof BlockCloseLine) { + $braceCount--; + if ($braceCount < 0) { + $braceCount = 0; + $this->errors[] = [ + 'type' => 'brace.excess', + 'include' => $include, + 'line' => $line, + ]; + } + } + } + if ($braceCount !== 0) { + $this->errors[] = [ + 'type' => 'brace.missing', + 'include' => $include, + 'line' => $lastLine, + ]; + } + } + + /** + * Look for @import that don't find to-include file(s). + * + * @todo: This code is far more complex than it could be. See #102102 and #102103 for + * changes we should apply to the include tree structure to simplify this. + */ + private function emptyImports(IncludeInterface $include): void + { + if (!$include->isSplit()) { + // Nodes containing @import are always split + return; + } + $lineStream = $include->getLineStream(); + if (!$lineStream) { + // A node that is split should never have an empty line stream, + // this may be obsolete, but does not hurt much. + return; + } + // Find @import lines in this include, index by + // combination of line number and column position. + $allImportLines = []; + foreach ($lineStream->getNextLine() as $line) { + if ($line instanceof ImportLine) { + $valueToken = $line->getValueToken(); + $allImportLines[$valueToken->getLine() . '-' . $valueToken->getColumn()] = $line; + } + } + // Now iterate children to exclude valid allImportLines, those that included something. + foreach ($include->getNextChild() as $child) { + if ($child instanceof AtImportInclude) { + /** @var ImportLine $originalLine */ + $originalLine = $child->getOriginalLine(); + $valueToken = $originalLine->getValueToken(); + unset($allImportLines[$valueToken->getLine() . '-' . $valueToken->getColumn()]); + } + // Condition includes don't have the "body" lines itself (or a "body" sub node). This may change, + // but until then we'll have to scan the parent node and loop condition includes here to find out + // which of them resolved to child nodes. + if ($child instanceof ConditionInclude || $child instanceof ConditionElseInclude) { + foreach ($child->getNextChild() as $conditionChild) { + if ($conditionChild instanceof AtImportInclude) { + /** @var ImportLine $originalLine */ + $originalLine = $conditionChild->getOriginalLine(); + $valueToken = $originalLine->getValueToken(); + unset($allImportLines[$valueToken->getLine() . '-' . $valueToken->getColumn()]); + } + } + } + } + // Everything left are invalid includes + foreach ($allImportLines as $importLine) { + $this->errors[] = [ + 'type' => 'import.empty', + 'include' => $include, + 'line' => $importLine, + ]; + } + } +} diff --git a/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeVisitorInterface.php b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeVisitorInterface.php new file mode 100644 index 0000000..d5d32dc --- /dev/null +++ b/Classes/TypoScript/IncludeTree/Visitor/IncludeTreeVisitorInterface.php @@ -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\Core\TypoScript\IncludeTree\Visitor; + +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\IncludeInterface; + +/** + * A visitor that can be attached to IncludeTreeTraverser's. + * + * @internal: Internal tree structure. + */ +interface IncludeTreeVisitorInterface +{ + /** + * Gets called by the traversers *before* children are traversed. Useful for + * instance for the IncludeTreeConditionMatcherVisitor to evaluate a condition + * verdict *before* children are traversed (or not). + */ + public function visitBeforeChildren(IncludeInterface $include, int $currentDepth): void; + + /** + * Main visit method called for each node. + */ + public function visit(IncludeInterface $include, int $currentDepth): void; +} diff --git a/Classes/TypoScript/PageTsConfig.php b/Classes/TypoScript/PageTsConfig.php new file mode 100644 index 0000000..075de1a --- /dev/null +++ b/Classes/TypoScript/PageTsConfig.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * A data object that carries the final page TSconfig. This is created by PageTsConfigFactory. + * + * @internal Internal for now until API stabilized. Use BackendUtility::getPagesTSconfig(). + */ +final readonly class PageTsConfig +{ + private array $pageTsConfigArray; + + public function __construct( + private RootNode $pageTsConfigTree, + private array $conditionListWithVerdicts, + ) { + $this->pageTsConfigArray = $pageTsConfigTree->toArray(); + } + + public function getPageTsConfigTree(): RootNode + { + return $this->pageTsConfigTree; + } + + public function getPageTsConfigArray(): array + { + return $this->pageTsConfigArray; + } + + public function getConditionListWithVerdicts(): array + { + return $this->conditionListWithVerdicts; + } +} diff --git a/Classes/TypoScript/PageTsConfigFactory.php b/Classes/TypoScript/PageTsConfigFactory.php new file mode 100644 index 0000000..0171fe0 --- /dev/null +++ b/Classes/TypoScript/PageTsConfigFactory.php @@ -0,0 +1,144 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SiteInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\TsConfigInclude; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\ConditionVerdictAwareIncludeTreeTraverser; +use TYPO3\CMS\Core\TypoScript\IncludeTree\TsConfigTreeBuilder; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionMatcherVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeSetupConditionConstantSubstitutionVisitor; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Calculate page TSconfig. This does the heavy lifting additionally supported by + * TsConfigTreeBuilder: Load basic page TSconfig tree, overload with user TSconfig, parse + * site settings ("constants"), then build the page TSconfig AST and return page TSconfig DTO. + * + * @internal Internal for now until API stabilized. Use BackendUtility::getPagesTSconfig(). + */ +#[Autoconfigure(public: true)] +final readonly class PageTsConfigFactory +{ + public function __construct( + private ContainerInterface $container, + private TokenizerInterface $tokenizer, + private TsConfigTreeBuilder $tsConfigTreeBuilder, + #[Autowire(service: 'cache.typoscript')] + private PhpFrontend $cache, + ) {} + + public function create( + array $fullRootLine, + SiteInterface $site, + ?UserTsConfig $userTsConfig = null + ): PageTsConfig { + $pagesTsConfigTree = $this->tsConfigTreeBuilder->getPagesTsConfigTree($fullRootLine, $this->tokenizer, $this->cache); + + // Overloading with user TSconfig if hand over + if ($userTsConfig !== null) { + $userTsConfigAst = $userTsConfig->getUserTsConfigTree(); + $userTsConfigPageOverrides = ''; + // @todo: This is ugly and expensive. There should be a better way to do this. Similar in BE page TSconfig controllers. + $userTsConfigFlat = $userTsConfigAst->flatten(); + foreach ($userTsConfigFlat as $userTsConfigIdentifier => $userTsConfigValue) { + if (str_starts_with($userTsConfigIdentifier, 'page.')) { + $userTsConfigPageOverrides .= substr($userTsConfigIdentifier, 5) . ' = ' . $userTsConfigValue . chr(10); + } + } + if (!empty($userTsConfigPageOverrides)) { + $includeNode = new TsConfigInclude(); + $includeNode->setName('pageTsConfig-overrides-by-userTsConfig'); + $includeNode->setLineStream($this->tokenizer->tokenize($userTsConfigPageOverrides)); + $pagesTsConfigTree->addChild($includeNode); + } + } + + // Prepare site constants to be substituted + $includeTreeTraverserConditionVerdictAware = new ConditionVerdictAwareIncludeTreeTraverser(); + $siteSettingsFlat = []; + if ($site instanceof Site) { + $siteSettings = $site->getSettings(); + if (!$siteSettings->isEmpty()) { + $siteSettingsCacheIdentifier = 'site-settings-flat-' . hash('xxh3', json_encode($siteSettings, JSON_THROW_ON_ERROR)); + $siteSettingsCacheArray = $this->cache->require($siteSettingsCacheIdentifier); + if (isset($siteSettingsCacheArray['flatConstants'])) { + $siteSettingsFlat = $siteSettingsCacheArray['flatConstants']; + } else { + $siteConstants = ''; + $siteSettings = $siteSettings->getAllFlat(); + foreach ($siteSettings as $nodeIdentifier => $value) { + $siteConstants .= $nodeIdentifier . ' = ' . $value . LF; + } + $siteSettingsNode = new SiteInclude(); + $siteSettingsNode->setName('Site constants settings of site "' . $site->getIdentifier() . '"'); + $siteSettingsNode->setLineStream($this->tokenizer->tokenize($siteConstants)); + $siteSettingsTreeRoot = new RootInclude(); + $siteSettingsTreeRoot->addChild($siteSettingsNode); + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $includeTreeTraverserConditionVerdictAware->traverse($siteSettingsTreeRoot, [$astBuilderVisitor]); + $siteSettingsFlat = $astBuilderVisitor->getAst()->flatten(); + $this->cache->set($siteSettingsCacheIdentifier, 'return unserialize(\'' . addcslashes(serialize(['flatConstants' => $siteSettingsFlat]), '\'\\') . '\');'); + } + } + } + + // Create AST with constants from site and conditions + $includeTreeTraverserConditionVerdictAwareVisitors = []; + if (!empty($siteSettingsFlat)) { + $setupConditionConstantSubstitutionVisitor = new IncludeTreeSetupConditionConstantSubstitutionVisitor(); + $setupConditionConstantSubstitutionVisitor->setFlattenedConstants($siteSettingsFlat); + $includeTreeTraverserConditionVerdictAwareVisitors[] = $setupConditionConstantSubstitutionVisitor; + } + $lastPageFullRecord = []; + $pageId = 0; + if (!empty($fullRootLine)) { + $lastPage = array_last($fullRootLine); + $pageId = $lastPage['uid']; + $lastPageFullRecord = BackendUtility::getRecord('pages', $pageId) ?: []; + } + $conditionMatcherVariables = [ + 'fullRootLine' => $fullRootLine, + 'site' => $site, + // @todo We're using the full page row here to provide all necessary fields (e.g. "backend_layout"), + // which are currently not included in the rows, RootlineUtility provides by default. We might + // want to switch to array_last($fullRootLine) as soon as it contains all fields. + 'page' => $lastPageFullRecord, + 'pageId' => $pageId, + ]; + $conditionMatcherVisitor = GeneralUtility::makeInstance(IncludeTreeConditionMatcherVisitor::class); + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables($conditionMatcherVariables); + $includeTreeTraverserConditionVerdictAwareVisitors[] = $conditionMatcherVisitor; + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $astBuilderVisitor->setFlatConstants($siteSettingsFlat); + $includeTreeTraverserConditionVerdictAwareVisitors[] = $astBuilderVisitor; + $includeTreeTraverserConditionVerdictAware->traverse($pagesTsConfigTree, $includeTreeTraverserConditionVerdictAwareVisitors); + + return new PageTsConfig($astBuilderVisitor->getAst(), $conditionMatcherVisitor->getConditionListWithVerdicts()); + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/AbstractLine.php b/Classes/TypoScript/Tokenizer/Line/AbstractLine.php new file mode 100644 index 0000000..757cb8d --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/AbstractLine.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; + +/** + * Implement main LineInterface methods. + * + * @internal: Internal tokenizer structure. + */ +abstract class AbstractLine implements LineInterface +{ + protected TokenStreamInterface $tokenStream; + + public function setTokenStream(TokenStreamInterface $tokenStream): static + { + $this->tokenStream = $tokenStream; + return $this; + } + + public function getTokenStream(): TokenStreamInterface + { + return $this->tokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/BlockCloseLine.php b/Classes/TypoScript/Tokenizer/Line/BlockCloseLine.php new file mode 100644 index 0000000..28d6ecf --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/BlockCloseLine.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * A block close line, essentially "}". + * + * @internal: Internal tokenizer structure. + */ +final class BlockCloseLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/CommentLine.php b/Classes/TypoScript/Tokenizer/Line/CommentLine.php new file mode 100644 index 0000000..634296d --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/CommentLine.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * A commented TypoScript line: Lines that start with "#", "//" and multiline comments "/* ... *\/" + * + * Note multiline comments often represent multiple source lines: An opening "/*" as + * first source line, then the comment body with one or more source lines, then finally + * the closing "*\/". These still create only one "CommentLine". + * + * @internal: Internal tokenizer structure. + */ +final class CommentLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/ConditionElseLine.php b/Classes/TypoScript/Tokenizer/Line/ConditionElseLine.php new file mode 100644 index 0000000..0659968 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/ConditionElseLine.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * "[ELSE]" / "[else]": An else block after a starting ConditionLine. + * + * @internal: Internal tokenizer structure. + */ +final class ConditionElseLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/ConditionLine.php b/Classes/TypoScript/Tokenizer/Line/ConditionLine.php new file mode 100644 index 0000000..c911a6a --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/ConditionLine.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * A condition line: "[foo == bar]". + * + * @internal: Internal tokenizer structure. + */ +final class ConditionLine extends AbstractLine +{ + private Token $valueToken; + + public function setValueToken(Token $token): static + { + if ($token->getType() !== TokenType::T_VALUE) { + throw new \LogicException('Token must be of type T_VALUE', 1655823705); + } + $this->valueToken = $token; + return $this; + } + + public function getTokenValue(): Token + { + return $this->valueToken; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/ConditionStopLine.php b/Classes/TypoScript/Tokenizer/Line/ConditionStopLine.php new file mode 100644 index 0000000..97d8640 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/ConditionStopLine.php @@ -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\Core\TypoScript\Tokenizer\Line; + +/** + * A line stopping current ConditionLine context: + * "[END]" / "[end]" / "[GLOBAL]" / "[global]". + * + * @internal: Internal tokenizer structure. + */ +final class ConditionStopLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/EmptyLine.php b/Classes/TypoScript/Tokenizer/Line/EmptyLine.php new file mode 100644 index 0000000..4651213 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/EmptyLine.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * A completely empty line, or a line consisting of tabs or whitespaces only. + * + * This is not created when the TypoScript source line is within multiline "(" + * assignments and multiline "/*" comments: The T_BLANK and T_NEWLINE tokens + * are part of the value steram in these contexts. + * + * Note the LossyTokenizers does not create these and just skips them since + * they have no semantic meaning for the resulting TypoScript tree. + * + * @internal: Internal tokenizer structure. + */ +final class EmptyLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierAssignmentLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierAssignmentLine.php new file mode 100644 index 0000000..02e1596 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierAssignmentLine.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; + +/** + * Simple "=" assignments and multiline "(" assignments: "foo.bar = barValue". + * + * Each line has two additional token streams: $identifierTokenStream for the + * left side ("foo" and "bar" tokens) and $valueTokenStream for the right side + * ("barValue" token). Right side is often a single token only, but can be many + * tokens when constants and multiline assignments are involved. + * + * Neither the left, nor the right side streams can be empty: Even with "foo.bar =" + * a T_VALUE token with empty value is created for the right side. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierAssignmentLine extends AbstractLine +{ + private IdentifierTokenStream $identifierTokenStream; + private TokenStreamInterface $valueTokenStream; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655824257); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + return $this->identifierTokenStream; + } + + public function setValueTokenStream(TokenStreamInterface $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Value token stream must not be empty', 1655824258); + } + $this->valueTokenStream = $tokenStream; + return $this; + } + + public function getValueTokenStream(): TokenStreamInterface + { + return $this->valueTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierBlockOpenLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierBlockOpenLine.php new file mode 100644 index 0000000..3e59fac --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierBlockOpenLine.php @@ -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\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; + +/** + * A block open line: "foo.bar {". + * + * $identifierTokenStream is a stream of tokens on the left side, "foo" + * and "bar" token in the example above. That stream must not be empty. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierBlockOpenLine extends AbstractLine +{ + private IdentifierTokenStream $identifierTokenStream; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655824621); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + return $this->identifierTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierCopyLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierCopyLine.php new file mode 100644 index 0000000..d988214 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierCopyLine.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; + +/** + * A line using the copy operator: "foo.bar < lib.myLib". + * + * Contains a stream of tokens for the left side ("foo" and "bar" tokens) and + * a stream of tokens for the right side ("lib" and "myLib"). None of these + * token streams can be empty, it's an InvalidLine otherwise. + * + * Note the right side TokenStreamIdentifier can be relative: "foo.bar < .baz". + * Flag $relative in TokenStreamIdentifier represents this start dot on the right side. + * + * None of the two streams can be empty. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierCopyLine extends AbstractLine +{ + private IdentifierTokenStream $identifierTokenStream; + private IdentifierTokenStream $valueTokenStream; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655824946); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + return $this->identifierTokenStream; + } + + public function setValueTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Value token stream must not be empty', 1655824947); + } + $this->valueTokenStream = $tokenStream; + return $this; + } + + public function getValueTokenStream(): IdentifierTokenStream + { + return $this->valueTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierFunctionLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierFunctionLine.php new file mode 100644 index 0000000..de62600 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierFunctionLine.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * A line with a function assignment using the ":=" operator: "foo.bar := addToList(42)". + * + * Contains $identifierTokenStream for the left side ("foo" and "bar" token), a single + * token for the function name ("addToList"), and an optional token for the value ("42"). + * Note the value token is optional since there are functions without values (eg. "uniqueList()"). + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierFunctionLine extends AbstractLine +{ + private ?IdentifierTokenStream $identifierTokenStream = null; + private ?Token $functionNameToken = null; + private ?TokenStreamInterface $functionValueTokenStream = null; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): IdentifierFunctionLine + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655825120); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + if ($this->identifierTokenStream === null) { + throw new \RuntimeException('Identifier token stream has not been set', 1717495444); + } + return $this->identifierTokenStream; + } + + public function setFunctionNameToken(Token $token): IdentifierFunctionLine + { + if ($token->getType() !== TokenType::T_FUNCTION_NAME) { + throw new \LogicException('Function name token must be of type T_FUNCTION_NAME', 1655825121); + } + $this->functionNameToken = $token; + return $this; + } + + public function getFunctionNameToken(): Token + { + if ($this->functionNameToken === null) { + throw new \RuntimeException('Function name token has not been set', 1717495576); + } + return $this->functionNameToken; + } + + public function setFunctionValueTokenStream(TokenStreamInterface $tokenStream): IdentifierFunctionLine + { + $this->functionValueTokenStream = $tokenStream; + return $this; + } + + public function getFunctionValueTokenStream(): TokenStreamInterface + { + if ($this->functionValueTokenStream === null) { + throw new \RuntimeException('Function value token stream has not been set', 1717495996); + } + return $this->functionValueTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierReferenceLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierReferenceLine.php new file mode 100644 index 0000000..a753fcb --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierReferenceLine.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; + +/** + * A line using the reference ("=<") operator: "foo.bar =< lib.myLib". + * + * Contains two non-empty token streams: One for the left side ("foo" and "bar" tokens), + * and one for the right side ("lib" and "myLib"). Both streams must not be empty. + * + * Note the AstBuilder does not directly resolve "=<" operators. This is + * not a language construct itself and is only resolved in some special cases + * in frontend. See ContentObjectRenderer->cObjGetSingle() for more details. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierReferenceLine extends AbstractLine +{ + private IdentifierTokenStream $identifierTokenStream; + private IdentifierTokenStream $valueTokenStream; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655825891); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + return $this->identifierTokenStream; + } + + public function setValueTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Value token stream must not be empty', 1655825892); + } + $this->valueTokenStream = $tokenStream; + return $this; + } + + public function getValueTokenStream(): IdentifierTokenStream + { + return $this->valueTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/IdentifierUnsetLine.php b/Classes/TypoScript/Tokenizer/Line/IdentifierUnsetLine.php new file mode 100644 index 0000000..5bf39eb --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/IdentifierUnsetLine.php @@ -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\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; + +/** + * A line using the unset (">") operator: "foo.bar >". + * + * Has $identifierTokenStream for the stream of tokens on the left + * side ("foo" and "bar" tokens). + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierUnsetLine extends AbstractLine +{ + private IdentifierTokenStream $identifierTokenStream; + + public function setIdentifierTokenStream(IdentifierTokenStream $tokenStream): static + { + if ($tokenStream->isEmpty()) { + throw new \LogicException('Identifier token stream must not be empty', 1655826025); + } + $this->identifierTokenStream = $tokenStream; + return $this; + } + + public function getIdentifierTokenStream(): IdentifierTokenStream + { + return $this->identifierTokenStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/ImportLine.php b/Classes/TypoScript/Tokenizer/Line/ImportLine.php new file mode 100644 index 0000000..ce27a85 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/ImportLine.php @@ -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\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * A line using the "@import" keyword: "@import 'EXT:my_extension/Configuration/TypoScript/randomfile.typoscript'" + * + * Contains the $valueToken ("EXT:my_extension/Configuration/TypoScript/randomfile.typoscript"), without the + * surrounding tick (') or doubletick ("). The value itself is not parsed further at this point, this + * is done by the IncludeTree classes. + * + * @internal: Internal tokenizer structure. + */ +final class ImportLine extends AbstractLine +{ + private Token $valueToken; + + public function setValueToken(Token $token): static + { + if ($token->getType() !== TokenType::T_VALUE) { + throw new \LogicException('Value token must be of type T_VALUE', 1655826193); + } + $this->valueToken = $token; + return $this; + } + + public function getValueToken(): Token + { + return $this->valueToken; + } +} diff --git a/Classes/TypoScript/Tokenizer/Line/InvalidLine.php b/Classes/TypoScript/Tokenizer/Line/InvalidLine.php new file mode 100644 index 0000000..7e88218 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/InvalidLine.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * A line that is syntactically invalid. + * + * This is created by LosslessTokenizer whenever a line does not make sense. + * Examples: + * "foo.bar" - no operator + * "foo.bar <" - right side empty + * "@import ''" - no import value + * + * Note only LosslessTokenizer creates these lines, LossyTokenizer just skips them. + * + * @internal: Internal tokenizer structure. + */ +final class InvalidLine extends AbstractLine {} diff --git a/Classes/TypoScript/Tokenizer/Line/LineInterface.php b/Classes/TypoScript/Tokenizer/Line/LineInterface.php new file mode 100644 index 0000000..3b2b447 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/LineInterface.php @@ -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\Core\TypoScript\Tokenizer\Line; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; + +/** + * The TypoScript tokenizers deliver streams of lines. This is the main line interface. + * + * Each line is represented by a specific line type. For instance, "foo.bar {" creates + * an IdentifierBlockOpenLine and has the additional method getIdentifierTokenStream() + * to retrieve the "foo" and "bar" identifier tokens. + * + * @internal: Internal tokenizer structure. + */ +interface LineInterface +{ + /** + * Set and get the token stream that represents the full line. This is mostly used + * in backend to for instance create a TypoScript string back from tokenized lines. + * + * Note: Only the LosslessTokenizer fills this 'full line' stream, LossyTokenizer + * does not for performance reasons. + */ + public function setTokenStream(TokenStreamInterface $tokenStream): static; + public function getTokenStream(): TokenStreamInterface; +} diff --git a/Classes/TypoScript/Tokenizer/Line/LineStream.php b/Classes/TypoScript/Tokenizer/Line/LineStream.php new file mode 100644 index 0000000..0ec8cc6 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Line/LineStream.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Line; + +/** + * Each TypoScript snippet is turned by the tokenizers into a + * stream of lines. Tokenizers return instances of this class. + * + * Iterate line streams in a foreach loop using getNextLine(). + * + * @internal: Internal tokenizer structure. + */ +final class LineStream +{ + /** + * @var LineInterface[] + */ + private array $lines = []; + private int $currentIndex = -1; + + /** + * Create a source string from given token lines. This is used in backend + * to turn the "full" token streams of lines into strings for output. + */ + public function __toString(): string + { + $source = ''; + foreach ($this->getNextLine() as $line) { + // We do *not* implement __toString() on lines since this is a + // backend thing only, and we do not want to accidentally stringify + // lines based on the full stream anywhere. + $source .= $line->getTokenStream()->reset(); + } + return $source; + } + + /** + * When storing to cache, we only store FE relevant properties and skip + * irrelevant things. In particular, $currentIndex should always initialize + * to -1 and does not need to be stored. + */ + final public function __serialize(): array + { + return [ + 'lines' => $this->lines, + ]; + } + + /** + * Stream creation. + */ + public function append(LineInterface $line): self + { + $this->lines[] = $line; + return $this; + } + + /** + * We sometimes create a line stream but don't add lines. + * This method returns true if lines have been added. + */ + public function isEmpty(): bool + { + return empty($this->lines); + } + + /** + * @return iterable<LineInterface> + */ + public function getNextLine(): iterable + { + foreach ($this->lines as $child) { + yield $child; + } + } + + /** + * Reset current pointer. Typically, call this before iterating with getNext(). + */ + public function reset(): self + { + $this->currentIndex = -1; + return $this; + } + + /** + * Get next line and raise pointer. + * + * Methods getNext(), peekNext() and reset() are an alternative to + * getNextLine() which allow peek of the next line, which getNextLine() + * does not. The disadvantage is that these methods create internal + * state in $this->currentIndex, which getNextLine() does not. Use + * getNext() iteration only if peekNext() is needed to avoid creating + * useless state. + */ + public function getNext(): ?LineInterface + { + $this->currentIndex++; + return $this->lines[$this->currentIndex] ?? null; + } + + /** + * Get next line but do not raise pointer. + */ + public function peekNext(): ?LineInterface + { + return $this->lines[$this->currentIndex + 1] ?? null; + } +} diff --git a/Classes/TypoScript/Tokenizer/LosslessTokenizer.php b/Classes/TypoScript/Tokenizer/LosslessTokenizer.php new file mode 100644 index 0000000..137eeac --- /dev/null +++ b/Classes/TypoScript/Tokenizer/LosslessTokenizer.php @@ -0,0 +1,879 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\BlockCloseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\CommentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionElseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionStopLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\EmptyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierAssignmentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierBlockOpenLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierFunctionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ImportLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\InvalidLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierToken; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * A lossless tokenizer for TypoScript syntax. + * + * tokenize() creates a flat stream of tokens from a TypoScript string. It is lossless + * and never "looses" characters to allow syntax linting and creating linter-fixed source + * strings: tokenize() to create a TokenStream and using string cast (__toString()) on + * that stream creates *the same* source string again. + * + * The tokenizer *does not* parse conditions or includes itself (no file / db lookups), + * this is part of the IncludeTree parser. + * + * This class is unit test covered by TokenizerInterfaceTest and paired with LossyTokenizer. + * Never change anything in this class without additional test coverage! + * + * @internal: Internal tokenizer structure. + */ +final class LosslessTokenizer implements TokenizerInterface +{ + private LineStream $lineStream; + + private TokenStreamInterface $tokenStream; + private IdentifierTokenStream $identifierStream; + private TokenStreamInterface $valueStream; + + private array $lines; + private int $currentLineNumber; + private string $currentLineString; + private \closure $currentLinebreakCallback; + private int $currentColumnInLine = 0; + + public function tokenize(string $source): LineStream + { + $this->lineStream = new LineStream(); + $this->currentLineNumber = -1; + $this->lines = $this->splitLines($source); + + while (true) { + $this->tokenStream = new TokenStream(); + $this->currentLineNumber++; + if (!array_key_exists($this->currentLineNumber, $this->lines)) { + break; + } + $this->currentColumnInLine = 0; + $this->currentLineString = $this->lines[$this->currentLineNumber]['line']; + $this->currentLinebreakCallback = $this->lines[$this->currentLineNumber]['linebreakCallback']; + $this->parseTabsAndWhitespaces(); + $nextChar = substr($this->currentLineString, 0, 1); + if ($nextChar === '') { + ($this->currentLinebreakCallback)(); + if (!$this->tokenStream->isEmpty()) { + $this->createEmptyLine(); + } + continue; + } + $nextTwoChars = substr($this->currentLineString, 0, 2); + if ($nextChar === '#') { + $this->createHashCommentLine(); + } elseif ($nextTwoChars === '//') { + $this->createDoubleSlashCommentLine(); + } elseif ($nextTwoChars === '/*') { + $this->createMultilineCommentLine(); + } elseif ($nextChar === '[') { + $this->createConditionLine(); + } elseif ($nextChar === '}') { + $this->createBlockStopLine(); + } elseif (str_starts_with($this->currentLineString, '@import')) { + $this->parseImportLine(); + } elseif (str_starts_with($this->currentLineString, '<INCLUDE_TYPOSCRIPT:')) { + // @todo: Could be relocated elsewhere. This is just to make sure this + // old language construct is detected as InvalidLine. + $this->tokenStream->append(new Token(TokenType::T_VALUE, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine)); + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + } else { + $this->parseIdentifier(); + } + } + + return $this->lineStream; + } + + private function splitLines($source): array + { + $vanillaLines = explode(chr(10), $source); + $lines = array_map( + fn(int $lineNumber, string $vanillaLine): array => [ + 'line' => rtrim($vanillaLine, "\r"), + 'linebreakCallback' => str_ends_with($vanillaLine, "\r") + ? fn() => $this->tokenStream->append(new Token(TokenType::T_NEWLINE, "\r\n", $lineNumber, mb_strlen($vanillaLine) - 1)) + : fn() => $this->tokenStream->append(new Token(TokenType::T_NEWLINE, "\n", $lineNumber, mb_strlen($vanillaLine))), + ], + array_keys($vanillaLines), + $vanillaLines + ); + // Set the linebreak callback of last line to empty to suppress dangling linebreak tokens + $lines[count($vanillaLines) - 1]['linebreakCallback'] = function () {}; + return $lines; + } + + private function createEmptyLine(): void + { + $this->lineStream->append((new EmptyLine())->setTokenStream($this->tokenStream)); + } + + /** + * Add tabs and whitespaces until some different char appears. + */ + private function parseTabsAndWhitespaces(): void + { + $matches = []; + if (preg_match('#^(\s+)(.*)$#', $this->currentLineString, $matches)) { + $this->tokenStream->append(new Token(TokenType::T_BLANK, $matches[1], $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentLineString = $matches[2]; + $this->currentColumnInLine = $this->currentColumnInLine + strlen($matches[1]); + } + } + + private function makeComment(): void + { + $nextChar = substr($this->currentLineString, 0, 1); + if ($nextChar === '') { + ($this->currentLinebreakCallback)(); + return; + } + $nextTwoChars = substr($this->currentLineString, 0, 2); + if ($nextChar === '#') { + $this->parseHashComment(); + } elseif ($nextTwoChars === '//') { + $this->parseDoubleSlashComment(); + } elseif ($nextTwoChars === '/*') { + $this->parseMultilineComment(); + } else { + $this->parseHashComment(); + } + } + + private function createHashCommentLine(): void + { + $this->parseHashComment(); + $this->lineStream->append((new CommentLine())->setTokenStream($this->tokenStream)); + } + + private function parseHashComment(): void + { + $this->tokenStream->append(new Token(TokenType::T_COMMENT_ONELINE_HASH, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine)); + ($this->currentLinebreakCallback)(); + } + + private function createDoubleSlashCommentLine(): void + { + $this->parseDoubleSlashComment(); + $this->lineStream->append((new CommentLine())->setTokenStream($this->tokenStream)); + } + + private function parseDoubleSlashComment(): void + { + $this->tokenStream->append(new Token(TokenType::T_COMMENT_ONELINE_DOUBLESLASH, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine)); + ($this->currentLinebreakCallback)(); + } + + private function createMultilineCommentLine(): void + { + $this->parseMultilineComment(); + $this->lineStream->append((new CommentLine())->setTokenStream($this->tokenStream)); + } + + private function parseMultilineComment(): void + { + $this->tokenStream->append(new Token(TokenType::T_COMMENT_MULTILINE_START, '/*', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine += 2; + $this->currentLineString = substr($this->currentLineString, 2); + while (true) { + if (str_ends_with($this->currentLineString, '*/')) { + if (strlen($this->currentLineString) > 2) { + $this->tokenStream->append(new Token(TokenType::T_VALUE, substr($this->currentLineString, 0, -2), $this->currentLineNumber, $this->currentColumnInLine)); + } + $this->tokenStream->append(new Token(TokenType::T_COMMENT_MULTILINE_STOP, '*/', $this->currentLineNumber, $this->currentColumnInLine + mb_strlen($this->currentLineString) - 2)); + ($this->currentLinebreakCallback)(); + return; + } + if (strlen($this->currentLineString)) { + $this->tokenStream->append(new Token(TokenType::T_VALUE, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine)); + } + ($this->currentLinebreakCallback)(); + if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { + return; + } + $this->currentLineNumber++; + $this->currentColumnInLine = 0; + $this->currentLineString = $this->lines[$this->currentLineNumber]['line']; + $this->currentLinebreakCallback = $this->lines[$this->currentLineNumber]['linebreakCallback']; + } + } + + /** + * Create a condition line from token stream of this line. + */ + private function createConditionLine(): void + { + $upperCaseLine = strtoupper($this->currentLineString); + $this->tokenStream->append(new Token(TokenType::T_CONDITION_START, '[', $this->currentLineNumber, $this->currentColumnInLine)); + if (str_starts_with($upperCaseLine, '[ELSE]')) { + $this->tokenStream->append(new Token(TokenType::T_CONDITION_ELSE, substr($this->currentLineString, 1, 4), $this->currentLineNumber, $this->currentColumnInLine + 1)); + $this->tokenStream->append(new Token(TokenType::T_CONDITION_STOP, ']', $this->currentLineNumber, $this->currentColumnInLine + 5)); + $this->currentLineString = substr($this->currentLineString, 6); + $this->currentColumnInLine += 6; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new ConditionElseLine())->setTokenStream($this->tokenStream)); + return; + } + if (str_starts_with($upperCaseLine, '[END]')) { + $this->tokenStream->append(new Token(TokenType::T_CONDITION_END, substr($this->currentLineString, 1, 3), $this->currentLineNumber, $this->currentColumnInLine + 1)); + $this->tokenStream->append(new Token(TokenType::T_CONDITION_STOP, ']', $this->currentLineNumber, $this->currentColumnInLine + 4)); + $this->currentLineString = substr($this->currentLineString, 5); + $this->currentColumnInLine += 5; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new ConditionStopLine())->setTokenStream($this->tokenStream)); + return; + } + if (str_starts_with($upperCaseLine, '[GLOBAL]')) { + $this->tokenStream->append(new Token(TokenType::T_CONDITION_GLOBAL, substr($this->currentLineString, 1, 6), $this->currentLineNumber, $this->currentColumnInLine + 1)); + $this->tokenStream->append(new Token(TokenType::T_CONDITION_STOP, ']', $this->currentLineNumber, $this->currentColumnInLine + 7)); + $this->currentLineString = substr($this->currentLineString, 8); + $this->currentColumnInLine += 8; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new ConditionStopLine())->setTokenStream($this->tokenStream)); + return; + } + $conditionBody = ''; + $conditionBodyStartPosition = $this->currentColumnInLine + 1; + $conditionBodyCharCount = 0; + $conditionBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); + $bracketCount = 1; + while (true) { + $nextChar = $conditionBodyChars[$conditionBodyCharCount] ?? null; + if ($nextChar === null) { + // end of chars + if ($conditionBodyCharCount) { + $this->tokenStream->append(new Token(TokenType::T_VALUE, $conditionBody, $this->currentLineNumber, $conditionBodyStartPosition)); + } + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextChar === '[') { + $bracketCount++; + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + continue; + } + if ($nextChar === ']') { + $bracketCount--; + if ($bracketCount === 0) { + if ($conditionBodyCharCount) { + $conditionBodyToken = new Token(TokenType::T_VALUE, $conditionBody, $this->currentLineNumber, $conditionBodyStartPosition); + $this->tokenStream->append($conditionBodyToken); + $this->tokenStream->append(new Token(TokenType::T_CONDITION_STOP, ']', $this->currentLineNumber, $this->currentColumnInLine + $conditionBodyCharCount + 1)); + $this->currentLineString = mb_substr($this->currentLineString, $conditionBodyCharCount + 2); + $this->currentColumnInLine = $this->currentColumnInLine + $conditionBodyCharCount + 2; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new ConditionLine())->setTokenStream($this->tokenStream)->setValueToken($conditionBodyToken)); + return; + } + $this->tokenStream->append(new Token(TokenType::T_CONDITION_STOP, ']', $this->currentLineNumber, $this->currentColumnInLine + $conditionBodyCharCount + 1)); + $this->currentLineString = mb_substr($this->currentLineString, $conditionBodyCharCount + 2); + $this->currentColumnInLine = $this->currentColumnInLine + $conditionBodyCharCount + 2; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + continue; + } + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + } + } + + private function createBlockStopLine(): void + { + $this->tokenStream->append(new Token(TokenType::T_BLOCK_STOP, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + $this->makeComment(); + $this->lineStream->append((new BlockCloseLine())->setTokenStream($this->tokenStream)); + } + + private function parseBlockStart(): void + { + $this->tokenStream->append(new Token(TokenType::T_BLOCK_START, '{', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + $this->parseTabsAndWhitespaces(); + if (str_starts_with($this->currentLineString, '}')) { + // Edge case: foo = { } in one line. Note content within {} is not parsed, everything behind { ends up as comment. + $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)->setTokenStream($this->tokenStream)); + $this->tokenStream = new TokenStream(); + $this->tokenStream->append(new Token(TokenType::T_BLOCK_STOP, '}', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentLineString = substr($this->currentLineString, 1); + $this->currentColumnInLine++; + $this->makeComment(); + $this->lineStream->append((new BlockCloseLine())->setTokenStream($this->tokenStream)); + return; + } + $this->makeComment(); + $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)->setTokenStream($this->tokenStream)); + } + + private function parseImportLine(): void + { + $this->tokenStream->append(new Token(TokenType::T_IMPORT_KEYWORD, '@import', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine += 7; + $this->currentLineString = substr($this->currentLineString, 7); + $this->parseTabsAndWhitespaces(); + + // Next char should be the opening tick or doubletick, otherwise we create a comment until end of line + $nextChar = substr($this->currentLineString, 0, 1); + if ($nextChar !== '\'' && $nextChar !== '"') { + $this->makeComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $this->tokenStream->append(new Token(TokenType::T_IMPORT_START, $nextChar, $this->currentLineNumber, $this->currentColumnInLine)); + + $importBody = ''; + $importBodyStartPosition = $this->currentColumnInLine + 1; + $importBodyCharCount = 0; + $importBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); + while (true) { + $nextChar = $importBodyChars[$importBodyCharCount] ?? null; + if ($nextChar === null) { + // end of chars + if ($importBodyCharCount) { + $importBodyToken = (new Token(TokenType::T_VALUE, $importBody, $this->currentLineNumber, $importBodyStartPosition)); + $this->tokenStream->append($importBodyToken); + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new ImportLine())->setTokenStream($this->tokenStream)->setValueToken($importBodyToken)); + return; + } + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextChar === '\'' || $nextChar === '"') { + if ($importBodyCharCount) { + $importBodyToken = new Token(TokenType::T_VALUE, $importBody, $this->currentLineNumber, $importBodyStartPosition); + $this->tokenStream->append($importBodyToken); + $this->tokenStream->append(new Token(TokenType::T_IMPORT_STOP, $nextChar, $this->currentLineNumber, $this->currentColumnInLine + $importBodyCharCount + 1)); + $this->currentLineString = mb_substr($this->currentLineString, $importBodyCharCount + 2); + $this->currentColumnInLine = $this->currentColumnInLine + $importBodyCharCount + 2; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new ImportLine())->setTokenStream($this->tokenStream)->setValueToken($importBodyToken)); + return; + } + $this->tokenStream->append(new Token(TokenType::T_IMPORT_STOP, $nextChar, $this->currentLineNumber, $this->currentColumnInLine + $importBodyCharCount + 1)); + $this->currentLineString = mb_substr($this->currentLineString, $importBodyCharCount + 2); + $this->currentColumnInLine = $this->currentColumnInLine + $importBodyCharCount + 2; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $importBody .= $nextChar; + $importBodyCharCount++; + } + } + + private function parseIdentifier(): void + { + $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); + $currentPosition = $this->parseIdentifierUntilStopChar($splitLine); + if (!$currentPosition) { + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $this->currentLineString = mb_substr($this->currentLineString, $currentPosition); + $this->currentColumnInLine = $this->currentColumnInLine + $currentPosition; + $currentColumnInLineBefore = $this->currentColumnInLine; + $this->parseTabsAndWhitespaces(); + $currentPosition = $currentPosition + $this->currentColumnInLine - $currentColumnInLineBefore; + $nextChar = $splitLine[$currentPosition] ?? null; + $nextTwoChars = $nextChar . ($splitLine[$currentPosition + 1] ?? ''); + if ($nextTwoChars === '=<') { + $this->parseOperatorReference(); + return; + } + if ($nextChar === '=') { + $this->parseOperatorAssignment(); + return; + } + if ($nextChar === '{') { + $this->parseBlockStart(); + return; + } + if ($nextChar === '>') { + $this->parseOperatorUnset(); + return; + } + if ($nextChar === '<') { + $this->parseOperatorCopy(); + return; + } + if ($nextChar === '(') { + $this->parseOperatorMultilineAssignment(); + return; + } + if ($nextTwoChars === ':=') { + $this->parseOperatorFunction(); + return; + } + if ($nextChar === '#') { + $this->parseHashComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextTwoChars === '//') { + $this->parseDoubleSlashComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextTwoChars === '/*') { + $this->parseMultilineComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextChar === null) { + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + } + } + + private function parseOperatorAssignment(): void + { + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_ASSIGNMENT, '=', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + $this->parseTabsAndWhitespaces(); + $this->valueStream = new TokenStream(); + [$this->valueStream, $this->tokenStream] = $this->parseValueForConstants($this->valueStream, $this->tokenStream, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine); + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new IdentifierAssignmentLine())->setTokenStream($this->tokenStream)->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); + } + + private function parseOperatorMultilineAssignment(): void + { + $this->valueStream = new TokenStream(); + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_ASSIGNMENT_MULTILINE_START, '(', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + // True if we're currently in the line with the opening '(' + $isFirstLine = true; + // True if the first line has a first value token: "foo ( thisIsTheFirstValueToken" + $valueOnFirstLine = false; + // True if the line after '(' is parsed + $isSecondLine = false; + $previousLineCallback = function () {}; + while (true) { + if (str_starts_with(ltrim($this->currentLineString), ')')) { + $this->parseTabsAndWhitespaces(); + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_ASSIGNMENT_MULTILINE_STOP, ')', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentLineString = substr($this->currentLineString, 1); + $this->currentColumnInLine++; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + if ($this->valueStream->isEmpty()) { + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + } else { + $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)->setTokenStream($this->tokenStream)); + } + return; + } + if ($isFirstLine && str_ends_with($this->currentLineString, ')')) { + // Special case if the ')' is on same line as the opening '(' + $this->currentLineString = substr($this->currentLineString, 0, -1); + if (strlen($this->currentLineString) > 1) { + [$this->valueStream, $this->tokenStream] = $this->parseValueForConstants($this->valueStream, $this->tokenStream, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine); + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_ASSIGNMENT_MULTILINE_STOP, ')', $this->currentLineNumber, $this->currentColumnInLine + mb_strlen($this->currentLineString))); + // Tricky to swap the streams here, but that's the most effective solution I could come up with for the line endings here. + ($this->currentLinebreakCallback)(); + $tempStream = $this->tokenStream; + $this->tokenStream = $this->valueStream; + ($this->currentLinebreakCallback)(); + $this->tokenStream = $tempStream; + $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)->setTokenStream($this->tokenStream)); + return; + } + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_ASSIGNMENT_MULTILINE_STOP, ')', $this->currentLineNumber, $this->currentColumnInLine + strlen($this->currentLineString))); + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($isFirstLine && strlen($this->currentLineString)) { + [$this->valueStream, $this->tokenStream] = $this->parseValueForConstants($this->valueStream, $this->tokenStream, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine); + $valueOnFirstLine = true; + $previousLineCallback = $this->currentLinebreakCallback; + } + if (($isFirstLine && $valueOnFirstLine) + || (!$isFirstLine && !$isSecondLine) + ) { + $tempStream = $this->tokenStream; + $this->tokenStream = $this->valueStream; + $previousLineCallback(); + $this->tokenStream = $tempStream; + } + if (!$isFirstLine && strlen($this->currentLineString)) { + [$this->valueStream, $this->tokenStream] = $this->parseValueForConstants($this->valueStream, $this->tokenStream, $this->currentLineString, $this->currentLineNumber, $this->currentColumnInLine); + } + $previousLineCallback = $this->currentLinebreakCallback; + ($this->currentLinebreakCallback)(); + if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($isFirstLine) { + $isSecondLine = true; + } else { + $isSecondLine = false; + } + $isFirstLine = false; + $valueOnFirstLine = false; + $this->currentLineNumber++; + $this->currentColumnInLine = 0; + $this->currentLineString = $this->lines[$this->currentLineNumber]['line']; + $this->currentLinebreakCallback = $this->lines[$this->currentLineNumber]['linebreakCallback']; + } + } + + private function parseOperatorUnset(): void + { + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_UNSET, '>', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append((new IdentifierUnsetLine())->setTokenStream($this->tokenStream)->setIdentifierTokenStream($this->identifierStream)); + } + + private function parseOperatorCopy(): void + { + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_COPY, '<', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + $this->parseTabsAndWhitespaces(); + $identifierStream = $this->identifierStream; + $this->parseIdentifierAtEndOfLine(); + $referenceStream = $this->identifierStream; + if ($referenceStream->isEmpty()) { + // @todo: ($this->currentLinebreakCallback)(); is missing here?! + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $this->lineStream->append( + (new IdentifierCopyLine()) + ->setIdentifierTokenStream($identifierStream) + ->setValueTokenStream($referenceStream) + ->setTokenStream($this->tokenStream) + ); + } + + private function parseOperatorReference(): void + { + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_REFERENCE, '=<', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine += 2; + $this->currentLineString = substr($this->currentLineString, 2); + $this->parseTabsAndWhitespaces(); + $identifierStream = $this->identifierStream; + $this->parseIdentifierAtEndOfLine(); + $referenceStream = $this->identifierStream; + if ($referenceStream->isEmpty()) { + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $this->lineStream->append( + (new IdentifierReferenceLine()) + ->setIdentifierTokenStream($identifierStream) + ->setValueTokenStream($referenceStream) + ->setTokenStream($this->tokenStream) + ); + } + + private function parseIdentifierAtEndOfLine(): void + { + $this->identifierStream = new IdentifierTokenStream(); + $isRelative = false; + $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); + $char = $splitLine[0] ?? null; + if ($char === null) { + return; + } + $nextTwoChars = $char . ($splitLine[1] ?? ''); + if ($char === '.') { + // A relative right side: foo.bar < .foo (note the dot!). we identifierStream->setRelative() and + // get rid of the dot for the rest of the processing. + $isRelative = true; + $this->tokenStream->append((new Token(TokenType::T_DOT, '.', 0, $this->currentColumnInLine))); + array_shift($splitLine); + $this->currentColumnInLine++; + $this->currentLineString = substr($this->currentLineString, 1); + } + if ($char === '#') { + $this->parseHashComment(); + return; + } + if ($nextTwoChars === '//') { + $this->parseDoubleSlashComment(); + return; + } + if ($nextTwoChars === '/*') { + $this->parseMultilineComment(); + return; + } + $currentPosition = $this->parseIdentifierUntilStopChar($splitLine, $isRelative); + if (!$currentPosition) { + return; + } + $this->currentLineString = mb_substr($this->currentLineString, $currentPosition); + $this->currentColumnInLine = $this->currentColumnInLine + $currentPosition; + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + } + + private function parseIdentifierUntilStopChar(array $splitLine, bool $isRelative = false): ?int + { + $this->identifierStream = new IdentifierTokenStream(); + if ($isRelative) { + $this->identifierStream->setRelative(); + } + $currentPosition = 0; + $currentIdentifierStartPosition = $this->currentColumnInLine; + $currentIdentifierBody = ''; + $currentIdentifierCharCount = 0; + while (true) { + $nextChar = $splitLine[$currentPosition] ?? null; + if ($nextChar === null) { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody, $this->currentLineNumber, $currentIdentifierStartPosition); + $this->tokenStream->append($identifierToken); + $this->identifierStream->append($identifierToken); + } + ($this->currentLinebreakCallback)(); + return null; + } + $nextTwoChars = $nextChar . ($splitLine[$currentPosition + 1] ?? null); + if ($currentPosition > 0 + && ($nextChar === ' ' || $nextChar === "\t" || $nextChar === '=' || $nextChar === '<' || $nextChar === '>' || $nextChar === '{' || $nextTwoChars === ':=' || $nextChar === '(') + ) { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody, $this->currentLineNumber, $currentIdentifierStartPosition); + $this->tokenStream->append($identifierToken); + $this->identifierStream->append($identifierToken); + } + break; + } + if ($nextTwoChars === '\\.') { + // A quoted dot is part of *this* identifier + $currentIdentifierBody .= '.'; + $currentPosition += 2; + $currentIdentifierCharCount++; + } elseif ($nextChar === '.') { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody, $this->currentLineNumber, $currentIdentifierStartPosition); + $this->tokenStream->append($identifierToken); + $this->identifierStream->append($identifierToken); + $currentIdentifierCharCount = 0; + $currentIdentifierBody = ''; + } + $this->tokenStream->append(new Token(TokenType::T_DOT, '.', $this->currentLineNumber, $this->currentColumnInLine + $currentPosition)); + $currentPosition++; + $currentIdentifierStartPosition = $this->currentColumnInLine + $currentPosition; + } else { + $currentIdentifierBody .= $nextChar; + $currentIdentifierCharCount++; + $currentPosition++; + } + } + return $currentPosition; + } + + private function parseOperatorFunction(): void + { + $this->tokenStream->append(new Token(TokenType::T_OPERATOR_FUNCTION, ':=', $this->currentLineNumber, $this->currentColumnInLine)); + $this->currentColumnInLine += 2; + $this->currentLineString = substr($this->currentLineString, 2); + $this->parseTabsAndWhitespaces(); + if ($this->currentLineString === '') { + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $functionName = ''; + $functionNameStartPosition = $this->currentColumnInLine; + $functionNameCharCount = 0; + $functionChars = mb_str_split($this->currentLineString, 1, 'UTF-8'); + while (true) { + $nextChar = $functionChars[$functionNameCharCount] ?? null; + if ($nextChar === null) { + // end of chars + if ($functionNameCharCount) { + $this->tokenStream->append(new Token(TokenType::T_FUNCTION_NAME, $functionName, $this->currentLineNumber, $functionNameStartPosition)); + } + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextChar === '(') { + if ($functionNameCharCount) { + $functionNameToken = new Token(TokenType::T_FUNCTION_NAME, $functionName, $this->currentLineNumber, $functionNameStartPosition); + $this->tokenStream->append($functionNameToken); + $this->tokenStream->append(new Token(TokenType::T_FUNCTION_VALUE_START, '(', $this->currentLineNumber, $this->currentColumnInLine + $functionNameCharCount)); + $functionNameCharCount++; + break; + } + $this->makeComment(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + $functionName .= $nextChar; + $functionNameCharCount++; + } + $functionBodyStartPosition = $functionNameCharCount; + $functionBodyPart = ''; + $functionBodyCharCount = 0; + $functionValueStream = new TokenStream(); + $parenthesesLevel = 0; + while (true) { + $nextChar = $functionChars[$functionBodyStartPosition + $functionBodyCharCount] ?? null; + if ($nextChar === null) { + if ($functionBodyCharCount) { + $this->tokenStream->append(new Token(TokenType::T_VALUE, $functionBodyPart, $this->currentLineNumber, $functionBodyStartPosition)); + } + ($this->currentLinebreakCallback)(); + $this->lineStream->append((new InvalidLine())->setTokenStream($this->tokenStream)); + return; + } + if ($nextChar === '(') { + // In case of a function call like "appendString(something(somethingelse))" + // we shall only stop processing when the last bracket was evaluated. + $parenthesesLevel++; + } + if ($nextChar === ')') { + if ($parenthesesLevel > 0) { + $parenthesesLevel--; + // Continue collecting characters from the (...) argument stream. + // Also, ")" will be appended, thus intentionally no "break" occurs. + } else { + if ($functionBodyCharCount) { + [$functionValueStream, $this->tokenStream] = $this->parseValueForConstants($functionValueStream, $this->tokenStream, $functionBodyPart, $this->currentLineNumber, $this->currentColumnInLine, $functionBodyStartPosition); + } + $this->tokenStream->append(new Token(TokenType::T_FUNCTION_VALUE_STOP, ')', $this->currentLineNumber, $this->currentColumnInLine + $functionNameCharCount + $functionBodyCharCount)); + $functionBodyCharCount++; + break; + } + } + $functionBodyPart .= $nextChar; + $functionBodyCharCount++; + } + $this->currentColumnInLine = $this->currentColumnInLine + $functionNameCharCount + $functionBodyCharCount; + $this->currentLineString = mb_substr($this->currentLineString, $functionNameCharCount + $functionBodyCharCount); + $this->parseTabsAndWhitespaces(); + $this->makeComment(); + $this->lineStream->append( + (new IdentifierFunctionLine()) + ->setIdentifierTokenStream($this->identifierStream) + ->setFunctionNameToken($functionNameToken) + ->setTokenStream($this->tokenStream) + ->setFunctionValueTokenStream($functionValueStream) + ); + } + + /** + * @return array{0: TokenStreamInterface, 1: TokenStreamInterface} + */ + private function parseValueForConstants(TokenStreamInterface $valueStream, TokenStreamInterface $tokenStream, string $value, int $line, int $column, int $tokenOffsetPosition = 0): array + { + if (!str_contains($value, '{$')) { + $valueToken = new Token(TokenType::T_VALUE, $value, $line, $column + $tokenOffsetPosition); + $valueStream->append($valueToken); + $tokenStream->append($valueToken); + return [$valueStream, $tokenStream]; + } + $splitLine = mb_str_split($value, 1, 'UTF-8'); + $isInConstant = false; + $currentPosition = 0; + $currentString = ''; + $currentStringLength = 0; + $lastTokenEndPosition = 0; + while (true) { + $char = $splitLine[$currentPosition] ?? null; + if ($char === null) { + if ($currentStringLength) { + $valueToken = new Token(TokenType::T_VALUE, $currentString, $line, $column + $lastTokenEndPosition + $tokenOffsetPosition); + $valueStream->append($valueToken); + $tokenStream->append($valueToken); + } + break; + } + $nextTwoChars = $char . ($splitLine[$currentPosition + 1] ?? ''); + if ($nextTwoChars === '{$') { + $isInConstant = true; + if ($currentStringLength) { + $valueToken = new Token(TokenType::T_VALUE, $currentString, $line, $column + $lastTokenEndPosition + $tokenOffsetPosition); + $valueStream->append($valueToken); + $tokenStream->append($valueToken); + $lastTokenEndPosition = $currentPosition; + } + $currentString = '{$'; + $currentPosition += 2; + continue; + } + if ($isInConstant && $char === '}') { + $valueToken = new Token(TokenType::T_CONSTANT, $currentString . '}', $line, $column + $lastTokenEndPosition + $tokenOffsetPosition); + if (!$valueStream instanceof ConstantAwareTokenStream) { + $valueStream = (new ConstantAwareTokenStream())->setAll($valueStream->getAll()); + } + $valueStream->append($valueToken); + $tokenStream->append($valueToken); + $currentPosition++; + $currentString = ''; + $currentStringLength = 0; + $lastTokenEndPosition = $currentPosition; + $isInConstant = false; + continue; + } + $currentPosition++; + $currentStringLength++; + $currentString .= $char; + } + return [$valueStream, $tokenStream]; + } +} diff --git a/Classes/TypoScript/Tokenizer/LossyTokenizer.php b/Classes/TypoScript/Tokenizer/LossyTokenizer.php new file mode 100644 index 0000000..43861b5 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/LossyTokenizer.php @@ -0,0 +1,624 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer; + +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\BlockCloseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionElseLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ConditionStopLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierAssignmentLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierBlockOpenLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierFunctionLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\ImportLine; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierToken; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStream; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface; +use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenType; + +/** + * A lossy tokenizer implementation: Do not create invalid lines, do not create empty lines, + * do not create token line and column positions. + * + * This tokenizer creates a much smaller streams of only relevant lines. All information + * not essential for the AstBuilder is skipped. This tokenizer is used in frontend rendering + * for quicker AST building. + * + * An instance of this tokenizer is injected by DI when injecting TokenizerInterface. + * + * This class is unit test covered by TokenizerInterfaceTest and paired with LossyTokenizer. + * Never change anything in this class without additional test coverage! + * + * @internal: Internal tokenizer structure. + */ +#[AsAlias(TokenizerInterface::class)] +final class LossyTokenizer implements TokenizerInterface +{ + private LineStream $lineStream; + + private IdentifierTokenStream $identifierStream; + private TokenStreamInterface $valueStream; + + private array $lines; + private int $currentLineNumber; + private string $currentLineString; + + public function tokenize(string $source): LineStream + { + $this->lineStream = new LineStream(); + $this->currentLineNumber = -1; + $this->lines = $this->splitLines($source); + + while (true) { + $this->currentLineNumber++; + if (!array_key_exists($this->currentLineNumber, $this->lines)) { + break; + } + $this->currentLineString = trim($this->lines[$this->currentLineNumber]['line']); + $nextChar = substr($this->currentLineString, 0, 1); + if ($nextChar === '') { + continue; + } + $nextTwoChars = substr($this->currentLineString, 0, 2); + if ($nextChar === '#' || $nextTwoChars === '//') { + continue; + } + if ($nextTwoChars === '/*') { + // @todo: This is one of multiple places where multiline "/*" comments are parsed in this tokenizer. Other + // places are cluttered in detail methods. It might be more straight to have an early scanning + // phase through all lines to remove comments up front, to not wire especially the multiline comment + // parsing to single places, and throw away commented lines early. This isn't trivial though, since + // for instance "foo = bar /* not a comment */" then needs to be sorted out, too. Having an early + // "kick comments" loop however might be quicker in the end and would make the main parsing + // methods more concise and probably more bullet proof. + // Also note there are currently not-unit-tested edge cases, that will currently not parse as + // (maybe) expected. In the example below, "foo2 = bar2" is ignored. This is an issue with the + // LosslessTokenizer as well, probably, and we may rather want to declare this as invalid syntax?! + // foo = bar /* comment start + // comment end */ foo2 = bar2 + $this->ignoreUntilEndOfMultilineComment(); + continue; + } + if ($nextChar === '[') { + $this->createConditionLine(); + } elseif ($nextChar === '}') { + $this->lineStream->append((new BlockCloseLine())); + } elseif (str_starts_with($this->currentLineString, '@import')) { + $this->parseImportLine(); + } elseif (str_starts_with($this->currentLineString, '<INCLUDE_TYPOSCRIPT:')) { + // @todo: Do nothing. This creates an InvalidLine in LossyTokenizer. + } else { + $this->parseIdentifier(); + } + } + + return $this->lineStream; + } + + private function splitLines($source): array + { + $vanillaLines = explode(chr(10), $source); + return array_map( + fn(int $lineNumber, string $vanillaLine): array => [ + 'line' => rtrim($vanillaLine, "\r"), + ], + array_keys($vanillaLines), + $vanillaLines + ); + } + + private function ignoreUntilEndOfMultilineComment(): void + { + while (true) { + if (str_contains($this->currentLineString, '*/')) { + return; + } + if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { + return; + } + $this->currentLineNumber++; + $this->currentLineString = trim($this->lines[$this->currentLineNumber]['line']); + } + } + + /** + * Create a condition line from token stream of this line. + */ + private function createConditionLine(): void + { + $upperCaseLine = strtoupper($this->currentLineString); + if (str_starts_with($upperCaseLine, '[ELSE]')) { + $this->lineStream->append((new ConditionElseLine())); + $this->currentLineString = trim(substr($this->currentLineString, 6)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + return; + } + if (str_starts_with($upperCaseLine, '[END]')) { + $this->lineStream->append((new ConditionStopLine())); + $this->currentLineString = trim(substr($this->currentLineString, 5)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + return; + } + if (str_starts_with($upperCaseLine, '[GLOBAL]')) { + $this->lineStream->append((new ConditionStopLine())); + $this->currentLineString = trim(substr($this->currentLineString, 8)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + return; + } + $conditionBody = ''; + $conditionBodyCharCount = 0; + $conditionBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); + $bracketCount = 1; + while (true) { + $nextChar = $conditionBodyChars[$conditionBodyCharCount] ?? null; + if ($nextChar === null) { + // end of chars + return; + } + if ($nextChar === '[') { + $bracketCount++; + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + continue; + } + if ($nextChar === ']') { + $bracketCount--; + if ($bracketCount === 0) { + if ($conditionBodyCharCount) { + $conditionBodyToken = new Token(TokenType::T_VALUE, $conditionBody); + $this->lineStream->append((new ConditionLine())->setValueToken($conditionBodyToken)); + $conditionBodyCharCount++; + break; + } + $conditionBodyCharCount++; + break; + } + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + continue; + } + $conditionBody .= $nextChar; + $conditionBodyCharCount++; + } + $this->currentLineString = trim(mb_substr($this->currentLineString, $conditionBodyCharCount + 1)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseBlockStart(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 1)); + if (str_starts_with($this->currentLineString, '}')) { + // Edge case: foo = { } in one line. Note content within {} is not parsed, everything behind { ends up as comment. + $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)); + $this->lineStream->append((new BlockCloseLine())); + return; + } + $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)); + } + + private function parseImportLine(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 7)); + + // Next char should be the opening tick or doubletick, otherwise treat it as ignored comment + $nextChar = substr($this->currentLineString, 0, 1); + if ($nextChar !== '\'' && $nextChar !== '"') { + return; + } + + $importBody = ''; + $importBodyCharCount = 0; + $importBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); + while (true) { + $nextChar = $importBodyChars[$importBodyCharCount] ?? null; + if ($nextChar === null) { + // end of chars + if ($importBodyCharCount) { + $importBodyToken = (new Token(TokenType::T_VALUE, $importBody)); + $this->lineStream->append((new ImportLine())->setValueToken($importBodyToken)); + return; + } + return; + } + if ($nextChar === '\'' || $nextChar === '"') { + if ($importBodyCharCount) { + $importBodyToken = new Token(TokenType::T_VALUE, $importBody); + $this->lineStream->append((new ImportLine())->setValueToken($importBodyToken)); + break; + } + break; + } + $importBody .= $nextChar; + $importBodyCharCount++; + } + $this->currentLineString = trim(mb_substr($this->currentLineString, $importBodyCharCount + 2)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseIdentifier(): void + { + $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); + $currentPosition = $this->parseIdentifierUntilStopChar($splitLine); + if (!$currentPosition) { + return; + } + $this->currentLineString = trim(mb_substr($this->currentLineString, $currentPosition)); + $nextChar = substr($this->currentLineString, 0, 1); + $nextTwoChars = $nextChar . substr($this->currentLineString, 1, 1); + if ($nextTwoChars === '=<') { + $this->parseOperatorReference(); + return; + } + if ($nextChar === '=') { + $this->parseOperatorAssignment(); + return; + } + if ($nextChar === '{') { + $this->parseBlockStart(); + return; + } + if ($nextChar === '>') { + $this->parseOperatorUnset(); + return; + } + if ($nextChar === '<') { + $this->parseOperatorCopy(); + return; + } + if ($nextChar === '(') { + $this->parseOperatorMultilineAssignment(); + return; + } + if ($nextTwoChars === ':=') { + $this->parseOperatorFunction(); + } + if ($nextTwoChars === '/*') { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseOperatorUnset(): void + { + $this->lineStream->append((new IdentifierUnsetLine())->setIdentifierTokenStream($this->identifierStream)); + $this->currentLineString = trim(trim(trim($this->currentLineString), '>')); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseOperatorAssignment(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 1)); + $this->valueStream = $this->parseValueForConstants(new TokenStream(), $this->currentLineString); + $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); + } + + private function parseOperatorMultilineAssignment(): void + { + $this->valueStream = new TokenStream(); + $this->currentLineString = substr($this->currentLineString, 1); + // True if we're currently in the line with the opening '(' + $isFirstLine = true; + // True if the first line has a first value token: "foo ( thisIsTheFirstValueToken" + $valueOnFirstLine = false; + // True if the line after '(' is parsed + $isSecondLine = false; + while (true) { + if (str_starts_with(ltrim($this->currentLineString), ')')) { + $this->currentLineString = trim(substr($this->currentLineString, 1)); + if (!$this->valueStream->isEmpty()) { + $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); + } + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + return; + } + if ($isFirstLine && str_ends_with($this->currentLineString, ')')) { + $this->currentLineString = substr($this->currentLineString, 0, -1); + if (strlen($this->currentLineString) > 1) { + $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); + $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); + return; + } + return; + } + if ($isFirstLine && strlen($this->currentLineString)) { + $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); + $valueOnFirstLine = true; + } + if (($isFirstLine && $valueOnFirstLine) + || (!$isFirstLine && !$isSecondLine) + ) { + $this->valueStream->append(new Token(TokenType::T_NEWLINE, "\n")); + } + if (!$isFirstLine && strlen($this->currentLineString)) { + $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); + } + if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { + return; + } + if ($isFirstLine) { + $isSecondLine = true; + } else { + $isSecondLine = false; + } + $isFirstLine = false; + $valueOnFirstLine = false; + $this->currentLineNumber++; + $this->currentLineString = $this->lines[$this->currentLineNumber]['line']; + } + } + + private function parseOperatorCopy(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 1)); + $identifierStream = $this->identifierStream; + $charsHandled = $this->parseIdentifierAtEndOfLine(); + $referenceStream = $this->identifierStream; + if ($referenceStream->isEmpty()) { + return; + } + $this->lineStream->append( + (new IdentifierCopyLine()) + ->setIdentifierTokenStream($identifierStream) + ->setValueTokenStream($referenceStream) + ); + $this->currentLineString = trim(mb_substr($this->currentLineString, $charsHandled)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseOperatorReference(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 2)); + $identifierStream = $this->identifierStream; + $charsHandled = $this->parseIdentifierAtEndOfLine(); + $referenceStream = $this->identifierStream; + if ($referenceStream->isEmpty()) { + return; + } + $this->lineStream->append( + (new IdentifierReferenceLine()) + ->setIdentifierTokenStream($identifierStream) + ->setValueTokenStream($referenceStream) + ); + $this->currentLineString = trim(mb_substr($this->currentLineString, $charsHandled)); + if (str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseIdentifierAtEndOfLine(): int + { + $this->identifierStream = new IdentifierTokenStream(); + $isRelative = false; + $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); + $char = $splitLine[0] ?? null; + if ($char === null) { + return 0; + } + $nextTwoChars = $char . ($splitLine[1] ?? ''); + if ($char === '.') { + // A relative right side: foo.bar < .foo (note the dot!). we identifierStream->setRelative() and + // get rid of the dot for the rest of the processing. + $isRelative = true; + array_shift($splitLine); + $this->currentLineString = substr($this->currentLineString, 1); + } + if ($char === '#') { + return 1; + } + if ($nextTwoChars === '//') { + return 2; + } + if ($nextTwoChars === '/*') { + $this->ignoreUntilEndOfMultilineComment(); + return 0; + } + return $this->parseIdentifierUntilStopChar($splitLine, $isRelative); + } + + private function parseIdentifierUntilStopChar(array $splitLine, bool $isRelative = false): int + { + $this->identifierStream = new IdentifierTokenStream(); + if ($isRelative) { + $this->identifierStream->setRelative(); + } + $currentPosition = 0; + $currentIdentifierBody = ''; + $currentIdentifierCharCount = 0; + while (true) { + $nextChar = $splitLine[$currentPosition] ?? null; + if ($nextChar === null) { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); + $this->identifierStream->append($identifierToken); + } + return $currentPosition; + } + $nextTwoChars = $nextChar . ($splitLine[$currentPosition + 1] ?? null); + if ($currentPosition > 0 + && ($nextChar === ' ' || $nextChar === "\t" || $nextChar === '=' || $nextChar === '<' || $nextChar === '>' || $nextChar === '{' || $nextTwoChars === ':=' || $nextChar === '(') + ) { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); + $this->identifierStream->append($identifierToken); + } + break; + } + if ($nextTwoChars === '\\.') { + // A quoted dot is part of *this* identifier + $currentIdentifierBody .= '.'; + $currentPosition += 2; + $currentIdentifierCharCount++; + } elseif ($nextChar === '.') { + if ($currentIdentifierCharCount) { + $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); + $this->identifierStream->append($identifierToken); + $currentIdentifierCharCount = 0; + $currentIdentifierBody = ''; + } + $currentPosition++; + } else { + $currentIdentifierBody .= $nextChar; + $currentIdentifierCharCount++; + $currentPosition++; + } + } + return $currentPosition; + } + + private function parseOperatorFunction(): void + { + $this->currentLineString = trim(substr($this->currentLineString, 2)); + if ($this->currentLineString === '') { + return; + } + $functionName = ''; + $functionNameCharCount = 0; + $functionChars = mb_str_split($this->currentLineString, 1, 'UTF-8'); + while (true) { + $nextChar = $functionChars[$functionNameCharCount] ?? null; + if ($nextChar === null) { + // end of chars + return; + } + if ($nextChar === '(') { + if ($functionNameCharCount) { + $functionNameToken = new Token(TokenType::T_FUNCTION_NAME, $functionName); + $functionNameCharCount++; + break; + } + return; + } + $functionName .= $nextChar; + $functionNameCharCount++; + } + $functionBodyStartPosition = $functionNameCharCount; + $functionBodyPart = ''; + $functionBodyCharCount = 0; + $functionValueStream = new TokenStream(); + $parenthesesLevel = 0; + while (true) { + $nextChar = $functionChars[$functionBodyStartPosition + $functionBodyCharCount] ?? null; + if ($nextChar === null) { + return; + } + if ($nextChar === '(') { + // In case of a function call like "appendString(something(somethingelse))" + // we shall only stop processing when the last bracket was evaluated. + $parenthesesLevel++; + } + if ($nextChar === ')') { + if ($parenthesesLevel > 0) { + $parenthesesLevel--; + // Continue collecting characters from the (...) argument stream. + // Also, ")" will be appended, thus intentionally no "break" occurs. + } else { + if ($functionBodyCharCount) { + $functionValueStream = $this->parseValueForConstants($functionValueStream, $functionBodyPart); + $functionBodyCharCount++; + } + break; + } + } + $functionBodyPart .= $nextChar; + $functionBodyCharCount++; + } + $this->lineStream->append( + (new IdentifierFunctionLine()) + ->setIdentifierTokenStream($this->identifierStream) + ->setFunctionNameToken($functionNameToken) + ->setFunctionValueTokenStream($functionValueStream) + ); + // Check for multiline comment + $this->currentLineString = implode('', array_slice($functionChars, $functionBodyStartPosition + $functionBodyCharCount + 1)); + if (mb_strlen($this->currentLineString) >= 1 && str_starts_with($this->currentLineString, '/*')) { + $this->ignoreUntilEndOfMultilineComment(); + } + } + + private function parseValueForConstants(TokenStreamInterface $valueStream, string $value): TokenStreamInterface + { + if (!str_contains($value, '{$')) { + $valueStream->append(new Token(TokenType::T_VALUE, $value)); + return $valueStream; + } + $splitLine = mb_str_split($value, 1, 'UTF-8'); + $isInConstant = false; + $currentPosition = 0; + $currentString = ''; + $currentStringLength = 0; + while (true) { + $char = $splitLine[$currentPosition] ?? null; + if ($char === null) { + if ($currentStringLength) { + $valueToken = new Token(TokenType::T_VALUE, $currentString); + $valueStream->append($valueToken); + } + break; + } + $nextTwoChars = $char . ($splitLine[$currentPosition + 1] ?? ''); + if ($nextTwoChars === '{$') { + $isInConstant = true; + if ($currentStringLength) { + $valueToken = new Token(TokenType::T_VALUE, $currentString); + $valueStream->append($valueToken); + } + $currentString = '{$'; + $currentPosition += 2; + continue; + } + if ($isInConstant && $char === '}') { + $valueToken = new Token(TokenType::T_CONSTANT, $currentString . '}'); + if (!$valueStream instanceof ConstantAwareTokenStream) { + $valueStream = (new ConstantAwareTokenStream())->setAll($valueStream->getAll()); + } + $valueStream->append($valueToken); + $currentPosition++; + $currentString = ''; + $currentStringLength = 0; + $isInConstant = false; + continue; + } + $currentPosition++; + $currentStringLength++; + $currentString .= $char; + } + return $valueStream; + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/AbstractToken.php b/Classes/TypoScript/Tokenizer/Token/AbstractToken.php new file mode 100644 index 0000000..1cbb52a --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/AbstractToken.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * Main implementation of a TokenInterface. + * + * @internal: Internal tokenizer structure. + */ +abstract class AbstractToken implements TokenInterface +{ + protected int $line = 0; + protected int $column = 0; + + public function __construct( + private readonly TokenType $type, + protected readonly string $value, + int $line = 0, + int $column = 0 + ) { + // No constructor property promotion for $line and $column: We don't serialize + // these two and want to still default them to 0 (zero) when unserialized. + $this->line = $line; + $this->column = $column; + } + + public function __toString(): string + { + return $this->value; + } + + /** + * Do not store line and column when structure is serialized to cache. + * Not storing $line and $column reduces the cache size by about 1/3 since + * we're typically storing *a lot* of tokens. + */ + public function __serialize(): array + { + return [ + 'type' => $this->type, + 'value' => $this->value, + ]; + } + + public function getType(): TokenType + { + return $this->type; + } + + public function getValue(): string + { + return $this->value; + } + + public function getLine(): int + { + return $this->line; + } + + public function getColumn(): int + { + return $this->column; + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/AbstractTokenStream.php b/Classes/TypoScript/Tokenizer/Token/AbstractTokenStream.php new file mode 100644 index 0000000..38d50df --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/AbstractTokenStream.php @@ -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\Core\TypoScript\Tokenizer\Token; + +/** + * A generic implementation of TokenStreamInterface. + * + * @internal: Internal tokenizer structure. + */ +abstract class AbstractTokenStream implements TokenStreamInterface +{ + /** + * @var TokenInterface[] + */ + protected array $tokens = []; + protected int $currentIndex = -1; + + /** + * Create a source string from given tokens. + */ + public function __toString(): string + { + $source = ''; + $this->reset(); + while ($token = $this->getNext()) { + $source .= $token; + } + return $source; + } + + /** + * When storing to cache, we only store FE relevant properties and skip + * irrelevant things. For instance $currentIndex should always initialize + * to -1 and does not need to be stored. + */ + final public function __serialize(): array + { + return $this->serialize(); + } + + protected function serialize(): array + { + $result['tokens'] = $this->tokens; + return $result; + } + + /** + * Stream creation. + */ + public function append(TokenInterface $token): self + { + $this->tokens[] = $token; + return $this; + } + + /** + * We sometimes create a stream but don't add tokens. + * This method returns true if tokens have been added. + */ + public function isEmpty(): bool + { + return empty($this->tokens); + } + + /** + * Reset current pointer. Typically, call this before iterating with getNext(). + */ + public function reset(): static + { + $this->currentIndex = -1; + return $this; + } + + /** + * Get next token and raise pointer. + */ + public function getNext(): ?TokenInterface + { + $this->currentIndex++; + return $this->tokens[$this->currentIndex] ?? null; + } + + public function peekNext(): ?TokenInterface + { + return $this->tokens[$this->currentIndex + 1] ?? null; + } + + public function getAll(): array + { + return $this->tokens; + } + + public function setAll(array $tokens): self + { + $this->tokens = $tokens; + return $this; + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/ConstantAwareTokenStream.php b/Classes/TypoScript/Tokenizer/Token/ConstantAwareTokenStream.php new file mode 100644 index 0000000..4e74679 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/ConstantAwareTokenStream.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A list of single T_VALUE, T_NEWLINE and T_CONSTANT tokens. This is only created for + * LineIdentifierAssignment lines if there is at least one T_CONSTANT token + * in the assignment that needs to be evaluated when string'ified by the + * AST-builder. + * + * @internal: Internal tokenizer structure. + */ +final class ConstantAwareTokenStream extends AbstractTokenStream +{ + private ?array $flatConstants = null; + + /** + * Set by the AstBuilder to resolve constant values. Never cached. + */ + public function setFlatConstants(array $flatConstants): void + { + $this->flatConstants = $flatConstants; + } + + /** + * Create a source string from given tokens. + * This resolves T_CONSTANT tokens to their value if they exist in $this->flatConstants. + */ + public function __toString(): string + { + $source = ''; + $this->reset(); + while ($token = $this->getNext()) { + if ($token->getType() === TokenType::T_CONSTANT) { + $token = $this->getConstantValue($this->parseConstantExpression($token->getValue())) ?? $token; + } + $source .= $token; + } + $this->reset(); + return $source; + } + + private function getConstantValue(?array $constantNames): ?string + { + if ($this->flatConstants === null || $constantNames === null) { + return null; + } + foreach ($constantNames as $constantName) { + $value = $this->flatConstants[$constantName] ?? null; + if ($value !== null) { + return (string)$value; + } + } + return null; + } + + /** + * Parse constant expression, including null coalescing operator into an + * array of constant names to look up in order. + * + * @todo: The tokenization of this constant expression should ideally be moved + * into the TypoScript Tokenizer in order to produce a list of multiple tokens + * instead of just a T_CONSTANT for the entire body. + * This would allow early static syntax analysis of the construct and maybe + * detection of invalid and fallback to T_CONSTANT_INVALID that is treated + * like T_VALUE and can be detected. Maybe something like this: + * TokenType::T_CONSTANT_START "{" + * TokenType::T_CONSTANT_END "}" + * TokenType::T_CONSTANT_NAME "$foo.bar" + * TokenType::T_CONSTANT_OPERATOR_NULL_COALESCE " ?? " + * TokenType::T_CONSTANT_INVALID "{$foo ?? bar}" (missing $ before bar) + */ + private function parseConstantExpression(string $constantExpression): ?array + { + $innerExpression = ltrim(rtrim($constantExpression, '}'), '{'); + $tokenValues = GeneralUtility::trimExplode(' ?? ', $innerExpression, true); + if ($tokenValues === []) { + return null; + } + $tokenValueNames = []; + foreach ($tokenValues as $tokenValue) { + if (!str_starts_with($tokenValue, '$')) { + return null; + } + $tokenValueNames[] = substr($tokenValue, 1); + } + return $tokenValueNames; + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/IdentifierToken.php b/Classes/TypoScript/Tokenizer/Token/IdentifierToken.php new file mode 100644 index 0000000..3c5d3f5 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/IdentifierToken.php @@ -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\Core\TypoScript\Tokenizer\Token; + +/** + * A special token if this token is a T_IDENTIFIER token: + * With a line like "foo = bar", "foo" is created as TokenIdentifier TokenInterface + * (as opposed to Token) having a TokenType::T_IDENTIFIER token. + * The only difference to all other tokens is that TokenIdentifier tokens + * quote any "." (dots) in their value with a backslash when output. This is + * mostly used in backend when rendering source of TokenLine's. + * + * Note we do *not* explicitly check if TokenType::T_IDENTIFIER is given in + * __construct() at the moment for performance reasons and inheritance considerations. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierToken extends AbstractToken +{ + public function __toString(): string + { + return str_replace('.', '\.', $this->value); + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/IdentifierTokenStream.php b/Classes/TypoScript/Tokenizer/Token/IdentifierTokenStream.php new file mode 100644 index 0000000..dd11acb --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/IdentifierTokenStream.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * A list of single identifier (!) tokens: TokenType::T_IDENTIFIER, and only of those. + * + * This is used in TS lines that know certain parts have to be lists of identifier tokens only. + * For instance a LineIdentifierAssignment "foo.bar = barValue" return this stream for getIdentifierTokenStream(): + * The left side of an assignment line is a list of identifier tokens. + * + * Identifiers can be "relative" on the right side for "<" (LineIdentifierCopy) and "=<" (LineIdentifierReference). + * Examples are "foo.bar < .baz" and "foo.bar =< .baz". These are identified by having a "." (dot) at the beginning + * on the right side. For these places, the toggle "relative" is set to true for the AST-builder to look for relative + * copy and copy-reference. The generic example are "relative" references in TS menus: 'RO < .NO' + * + * For example, with "foo.bar < baz", the Tokenizer creates a LineIdentifierCopy line, having a TokenStreamIdentifier + * list of the T_IDENTIFIER tokens for 'foo' and 'bar' for getIdentifierTokenStream(), plus a TokenStreamIdentifier list + * of T_IDENTIFIER tokens for 'baz' for getValueTokenStream(). + * + * Note identifier streams on the left side (foo.bar = ...) are never relative, this toggle is true for "<" and "=<" only. + * + * Lines that know they can only return TokenStreamIdentifier's - they are more specific than just TokenStream, are + * type-hinted as such. For instance getIdentifierTokenStream() type hints TokenStreamIdentifier. + * + * @internal: Internal tokenizer structure. + */ +final class IdentifierTokenStream extends AbstractTokenStream +{ + private bool $relative = false; + + /** + * When rendering a source string from multiple identifiers, dots between single identifiers need to be added again. + * This is used in RootNode->toArray() to create that insane '< lib.whatever' as value when using the + * reference operator: "foo =< lib.whatever". See ContentObjectRenderer cObjGetSingle() and mergeTSRef(). + */ + public function __toString(): string + { + $source = []; + $this->reset(); + while ($token = $this->getNext()) { + $source[] = (string)$token; + } + $source = implode('.', $source); + if ($this->relative) { + $source = '.' . $source; + } + return $source; + } + + protected function serialize(): array + { + $result = parent::serialize(); + if ($this->isRelative()) { + $result['relative'] = true; + } + return $result; + } + + /** + * Append a token to the stream. + */ + public function append(TokenInterface $token): self + { + if ($token->getType() !== TokenType::T_IDENTIFIER) { + throw new \LogicException( + 'Trying to add a token of type TokenType::' . $token->getType()->name . ' to class TokenStreamIdentifier, but only TokenType::T_IDENTIFIERS are allowed.', + 1655138907 + ); + } + $this->tokens[] = $token; + return $this; + } + + /** + * This identifier token stream is relative! There is a dot on the right side of something like "foo.bar < .baz" + */ + public function setRelative(): self + { + $this->relative = true; + return $this; + } + + /** + * True if this identifier stream is relative to given context. + */ + public function isRelative(): bool + { + return $this->relative; + } +} diff --git a/Classes/TypoScript/Tokenizer/Token/Token.php b/Classes/TypoScript/Tokenizer/Token/Token.php new file mode 100644 index 0000000..a664ae0 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/Token.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * A casual token created from TypoScript source: + * When having a TypoScript line like "# a comment", then a LineComment + * is created having a token "T_COMMENT_ONELINE_HASH" and value "# a comment" as + * assigned TokenStream. + * See TokenType for on overview on which TokenTypes can exist. + * + * @internal: Internal tokenizer structure. + */ +final class Token extends AbstractToken {} diff --git a/Classes/TypoScript/Tokenizer/Token/TokenInterface.php b/Classes/TypoScript/Tokenizer/Token/TokenInterface.php new file mode 100644 index 0000000..3cbcfde --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/TokenInterface.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * A readonly token: Each line of TypoScript is split into a list of lines consisting of + * tokens by the tokenizers. + * + * As example, a "foo.bar = baz" line creates a LineIdentifierAssignment line, having + * TokenType::T_IDENTIFIER 'foo', plus TokenType::T_IDENTIFIER 'bar' as TokenStream for + * LineIdentifierAssignment->getIdentifierTokenStream(), plus a TokenType::T_VALUE 'baz' + * as LineIdentifierAssignment->getValueTokenStream(). + * + * We have two different Token implementations: The casual "Token" class for everything, plus + * the "TokenIdentifier" class for identifier tokens. Identifier tokens are those "left" of + * for instance an assignment like "foo.bar = baz" ("foo" and "bar" are TokenIdentifier instances), + * and also on the right side when using expression with "<" and "=<" operator: Example "foo.bar < baz": + * "baz" is an instance of a TokenIdentifier ("foo" and "bar" as well). + * + * The reason to have two implementations is that TokenIdentifier needs to be handled slightly + * different when cast to string: For identifiers, all "." (dots) within a single identifier token + * need to be quoted with "\" (backslash), to not confuse the parser. The classic use-case is having dots in + * FlexForm identifiers for PageTS: + * "foo.bar\.baz.foobar = value" - three identifier tokens (not four!): "foo", "bar.baz" and "foobar". + * So the difference between "TokenIdentifier" and "Token" is just that "TokenIdentifier" quotes dots + * in its value when string'ified, while Token does not and __toString() on Token simply says ->getValue(). + * + * Multiple tokens are encapsulated in TokenStreamInterface. TokenStreamInterface has a __toString() + * method as well, which calls __toString() on all assigned tokens. This way, a TokenIdentifier will + * do its quoting magic, and casual Token instances return their value. + * + * The idea is here that TokenStreams are cast to string quite often. For instance, an assignment line + * like "foo = bar" creates a token stream of one token for the right side (things after "="): + * A T_VALUE Token instance with value "bar". The AST builder then at some point needs to resolve this + * TokenStream to string. This will directly call __toString on token "bar", and does not deal with quoting, + * since its no TokenIdentifier and just a Token. + * + * Note on getLine() and getColumn(): These two represent the position of a token in the source file: + * We start counting at 0 (zero): The first token on the first line is line 0, column 0. + * Only the LosslessTokenizer sets these, it's too expensive and of no relevance for the LossyTokenizer + * that is used for instance in FE TS tokenizing. That's why these two properties are optional + * and 0 (zero) by default. + * + * @internal: Internal tokenizer structure. + */ +interface TokenInterface +{ + public function __toString(): string; + public function getType(): TokenType; + public function getValue(): string; + public function getLine(): int; + public function getColumn(): int; +} diff --git a/Classes/TypoScript/Tokenizer/Token/TokenStream.php b/Classes/TypoScript/Tokenizer/Token/TokenStream.php new file mode 100644 index 0000000..f6be524 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/TokenStream.php @@ -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\Core\TypoScript\Tokenizer\Token; + +/** + * A list of single tokens. These are typically used in TokenLines: A TypoScript + * line consists of one or more streams of tokens, depending on the line type. + * + * @internal: Internal tokenizer structure. + */ +final class TokenStream extends AbstractTokenStream {} diff --git a/Classes/TypoScript/Tokenizer/Token/TokenStreamInterface.php b/Classes/TypoScript/Tokenizer/Token/TokenStreamInterface.php new file mode 100644 index 0000000..19d5aca --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/TokenStreamInterface.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * A generic stream of tokens used in single LineInterface lines. + * + * The tokenizers create these streams for various lists of tokens, the generic + * implementation is class TokenStream. For lists of identifier tokens the special + * class TokenStreamIdentifier is created. + * + * @internal: Internal tokenizer structure. + */ +interface TokenStreamInterface +{ + /** + * Create a source string from given tokens. + */ + public function __toString(): string; + + /** + * Stream creation. + */ + public function append(TokenInterface $token): self; + + /** + * We sometimes create a stream but don't add tokens. + * This method returns true if tokens have been added. + */ + public function isEmpty(): bool; + + /** + * Reset current pointer. Typically, call this before iterating with getNext(). + */ + public function reset(): self; + + /** + * Get next token and raise pointer. + */ + public function getNext(): ?TokenInterface; + + /** + * Get next token but do not raise pointer. + */ + public function peekNext(): ?TokenInterface; + + /** + * Only used internally when one Stream is transferred to another, + * in particular when a TokenStream is turned into TokenStreamConstantAware. + * + * @return TokenInterface[] + */ + public function getAll(): array; + + /** + * Only used internally when one Stream is transferred to another, + * in particular when a TokenStream is turned into TokenStreamConstantAware. + * + * @param TokenInterface[] $tokens + */ + public function setAll(array $tokens): self; +} diff --git a/Classes/TypoScript/Tokenizer/Token/TokenType.php b/Classes/TypoScript/Tokenizer/Token/TokenType.php new file mode 100644 index 0000000..51d6594 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/Token/TokenType.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript\Tokenizer\Token; + +/** + * Each TokenInterface instance is a type of this Enum. + * + * @internal: Internal tokenizer structure. + */ +enum TokenType: int +{ + case T_NONE = 0; // tokenizer internal handling + + case T_IDENTIFIER = 100; // single word left of an operator. 'foo.bar' are two identifiers + case T_VALUE = 200; // right side of an assignment, does not contain line breaks, also used as 'comment' body + + case T_OPERATOR_ASSIGNMENT = 300; // '=' + case T_OPERATOR_REFERENCE = 301; // '=<' + case T_OPERATOR_COPY = 302; // '<' + case T_OPERATOR_UNSET = 303; // '>' + case T_OPERATOR_FUNCTION = 304; // ':=' + case T_OPERATOR_ASSIGNMENT_MULTILINE_START = 310; // '(' + case T_OPERATOR_ASSIGNMENT_MULTILINE_STOP = 311; // ')' + + case T_BLOCK_START = 400; // '{' + case T_BLOCK_STOP = 401; // '}' + + case T_DOT = 500; // '.' identifier separator + + case T_BLANK = 600; // list of ' ' and "\t" + + case T_NEWLINE = 700; // "\n" or "\r\n" + + case T_COMMENT_ONELINE_HASH = 800; // '#...' + case T_COMMENT_ONELINE_DOUBLESLASH = 801; // '//' + case T_COMMENT_MULTILINE_START = 802; // '/*' + case T_COMMENT_MULTILINE_STOP = 803; // '*/' + + case T_FUNCTION_NAME = 900; // 'addToList' and others + case T_FUNCTION_VALUE_START = 901; // '(' after T_FUNCTION_NAME + case T_FUNCTION_VALUE_STOP = 902; // ')' after T_FUNCTION_NAME + + case T_CONDITION_START = 1000; // '[' at start of line + case T_CONDITION_STOP = 1001; // ']' after '[' in same line, body is a T_VALUE + case T_CONDITION_ELSE = 1002; // 'ELSE' surrounded by '[' and ']' + case T_CONDITION_END = 1003; // 'END' surrounded by '[' and ']' + case T_CONDITION_GLOBAL = 1004; // 'GLOBAL' surrounded by '[' and ']' + + case T_CONSTANT = 1100; // '{$...}' + + case T_IMPORT_KEYWORD = 1200; // '@import' + case T_IMPORT_START = 1201; // ''' (tick) or '"' (doubletick) after @import + case T_IMPORT_STOP = 1202; // ''' (tick) or '"' (doubletick) after T_IMPORT_START +} diff --git a/Classes/TypoScript/Tokenizer/TokenizerInterface.php b/Classes/TypoScript/Tokenizer/TokenizerInterface.php new file mode 100644 index 0000000..3da94e9 --- /dev/null +++ b/Classes/TypoScript/Tokenizer/TokenizerInterface.php @@ -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\Core\TypoScript\Tokenizer; + +use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\LineStream; + +/** + * A lossless tokenizer for TypoScript syntax. + * + * tokenize() creates a stream of LineInterface objects from a TypoScript string, each line + * contains the important streams or tokens of a single line. + * + * There are two tokenizer implementations: + * - LossyTokenizer: This one skip all invalid lines and comments and everything that is + * not needed for AST building. + * - LosslessTokenizer: This one creates a stream of lines useful for backend template module + * to elaborate on details and failures in TypoScript. + * + * The tokenizer *does not* parse conditions or includes itself (no file / db lookups), + * this is part of the IncludeTree parser. + * + * @internal: Internal tokenizer structure. + */ +interface TokenizerInterface +{ + public function tokenize(string $source): LineStream; +} diff --git a/Classes/TypoScript/TypoScriptService.php b/Classes/TypoScript/TypoScriptService.php new file mode 100644 index 0000000..a1db7a9 --- /dev/null +++ b/Classes/TypoScript/TypoScriptService.php @@ -0,0 +1,193 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +/** + * Helper class to manage and convert TypoScript into differently shaped arrays. + * Also contains the functionality in TypoScript called "optionSplit". + * + * @internal + */ +readonly class TypoScriptService +{ + /** + * Removes all trailing dots recursively from TS settings array + * + * Extbase converts the "classical" TypoScript (with trailing dot) to a format without trailing dot, + * to be more future-proof and not to have any conflicts with Fluid object accessor syntax. + * + * @param array<string|int, mixed> $typoScriptArray for example `['foo' => 'TEXT', 'foo.' => ['bar' => 'baz']]` + * @return array<string|int, mixed> for example `['foo' => ['_typoScriptNodeValue' => 'TEXT', 'bar' => 'baz']]` + * @internal Avoid using this method. This has been invented for Extbase, which decided to move TypoScript + * arrays around in just another different way. + */ + public function convertTypoScriptArrayToPlainArray(array $typoScriptArray): array + { + foreach ($typoScriptArray as $key => $value) { + if (str_ends_with((string)$key, '.')) { + $keyWithoutDot = substr((string)$key, 0, -1); + $typoScriptNodeValue = $typoScriptArray[$keyWithoutDot] ?? null; + if (is_array($value)) { + $typoScriptArray[$keyWithoutDot] = $this->convertTypoScriptArrayToPlainArray($value); + if ($typoScriptNodeValue !== null) { + $typoScriptArray[$keyWithoutDot]['_typoScriptNodeValue'] = $typoScriptNodeValue; + } + unset($typoScriptArray[$key]); + } else { + $typoScriptArray[$keyWithoutDot] = null; + } + } + } + return $typoScriptArray; + } + + /** + * Returns an array with Typoscript the old way (with dot). + * + * Extbase converts the "classical" TypoScript (with trailing dot) to a format without trailing dot, + * to be more future-proof and not to have any conflicts with Fluid object accessor syntax. + * However, if you want to call legacy TypoScript objects, you somehow need the "old" syntax (because this is what TYPO3 is used to). + * With this method, you can convert the extbase TypoScript to classical TYPO3 TypoScript which is understood by the rest of TYPO3. + * + * @param array $plainArray A TypoScript Array with Extbase Syntax (without dot but with _typoScriptNodeValue) + * @return array Array with TypoScript as usual (with dot) + * @internal Avoid using this method. This has been invented for Extbase, which decided to move TypoScript + * arrays around in just another different way. + */ + public function convertPlainArrayToTypoScriptArray(array $plainArray): array + { + $typoScriptArray = []; + foreach ($plainArray as $key => $value) { + if (is_array($value)) { + if (isset($value['_typoScriptNodeValue'])) { + $typoScriptArray[$key] = $value['_typoScriptNodeValue']; + unset($value['_typoScriptNodeValue']); + } + $typoScriptArray[$key . '.'] = $this->convertPlainArrayToTypoScriptArray($value); + } else { + $typoScriptArray[$key] = $value ?? ''; + } + } + return $typoScriptArray; + } + + /** + * Implementation of the "optionSplit" feature in TypoScript (used eg. for MENU objects) + * What it does is to split the incoming TypoScript array so that the values are exploded by certain + * strings ("||" and "|*|") and each part distributed into individual TypoScript arrays with a similar structure, + * but individualized values. + * The concept is known as "optionSplit" and is rather advanced to handle but quite powerful, in particular + * for creating menus in TYPO3. + * + * @param array $originalConfiguration A TypoScript array + * @param int $splitCount The number of items for which to generate individual TypoScript arrays + * @return array The individualized TypoScript array. + */ + public function explodeConfigurationForOptionSplit(array $originalConfiguration, int $splitCount): array + { + $finalConfiguration = []; + if (!$splitCount) { + return $finalConfiguration; + } + // Initialize output to carry at least the keys + for ($aKey = 0; $aKey < $splitCount; $aKey++) { + $finalConfiguration[$aKey] = []; + } + // Recursive processing of array keys + foreach ($originalConfiguration as $cKey => $val) { + if (is_array($val)) { + $tempConf = $this->explodeConfigurationForOptionSplit($val, $splitCount); + foreach ($tempConf as $aKey => $val2) { + $finalConfiguration[$aKey][$cKey] = $val2; + } + } elseif (is_string($val)) { + // Splitting of all values on this level of the TypoScript object tree: + if ($cKey === 'noTrimWrap' || (!str_contains($val, '|*|') && !str_contains($val, '||'))) { + for ($aKey = 0; $aKey < $splitCount; $aKey++) { + $finalConfiguration[$aKey][$cKey] = $val; + } + } else { + $main = explode('|*|', $val); + $lastC = 0; + $middleC = 0; + $firstC = 0; + if ($main[0]) { + $first = explode('||', $main[0]); + $firstC = count($first); + } + $middle = []; + if (!empty($main[1])) { + $middle = explode('||', $main[1]); + $middleC = count($middle); + } + $last = []; + $value = ''; + if (!empty($main[2])) { + $last = explode('||', $main[2]); + $lastC = count($last); + $value = $last[0]; + } + for ($aKey = 0; $aKey < $splitCount; $aKey++) { + if ($firstC && isset($first[$aKey])) { + $value = $first[$aKey]; + } elseif ($middleC) { + $value = $middle[($aKey - $firstC) % $middleC]; + } + if ($lastC && $lastC >= $splitCount - $aKey) { + $value = $last[$lastC - ($splitCount - $aKey)]; + } + $finalConfiguration[$aKey][$cKey] = trim($value); + } + } + } + } + return $finalConfiguration; + } + + /** + * Flatten TypoScript label array; converting a hierarchical array into a flat + * array with the keys separated by dots. + * + * Example Input: array('k1' => array('subkey1' => 'val1')) + * Example Output: array('k1.subkey1' => 'val1') + * + * @param array $labelValues Hierarchical array of labels + * @param string $parentKey the name of the parent key in the recursion; is only needed for recursion. + * @return array flattened array of labels. + */ + public function flattenTypoScriptLabelArray(array $labelValues, string $parentKey = ''): array + { + $result = []; + foreach ($labelValues as $key => $labelValue) { + if (!empty($parentKey)) { + if ($key === '_typoScriptNodeValue') { + $key = $parentKey; + } else { + $key = $parentKey . '.' . $key; + } + } + if (is_array($labelValue)) { + $labelValue = $this->flattenTypoScriptLabelArray($labelValue, $key); + $result = array_merge($result, $labelValue); + } else { + $result[$key] = $labelValue; + } + } + return $result; + } +} diff --git a/Classes/TypoScript/TypoScriptStringFactory.php b/Classes/TypoScript/TypoScriptStringFactory.php new file mode 100644 index 0000000..f939121 --- /dev/null +++ b/Classes/TypoScript/TypoScriptStringFactory.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use Psr\Container\ContainerInterface; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\TypoScript\AST\AstBuilderInterface; +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; +use TYPO3\CMS\Core\TypoScript\IncludeTree\StringTreeBuilder; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; + +/** + * A factory to create the AST object tree for a given TypoScript snippet. + * + * This is used by some consumers in the core that parse a TypoScript a-like + * syntax that is not Frontend TypoScript and TsConfig directly. + */ +final readonly class TypoScriptStringFactory +{ + public function __construct( + private ContainerInterface $container, + private TokenizerInterface $tokenizer, + ) {} + + /** + * Parse a single string and support imports and conditions, cache optionally. + * + * @param non-empty-string $name A name used as cache identifier, [a-z,A-Z,-] only + */ + public function parseFromStringWithIncludes(string $name, string $typoScript): RootNode + { + $cacheManager = $this->container->get(CacheManager::class); + /** @var PhpFrontend $cache */ + $cache = $cacheManager->getCache('typoscript'); + $stringTreeBuilder = $this->container->get(StringTreeBuilder::class); + $includeTree = $stringTreeBuilder->getTreeFromString($name, $typoScript, $this->tokenizer, $cache); + $includeTreeTraverserConditionVerdictAware = new IncludeTreeTraverser(); + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $includeTreeTraverserConditionVerdictAware->traverse($includeTree, [$astBuilderVisitor]); + return $astBuilderVisitor->getAst(); + } + + /** + * Parse a single string *not* supporting imports, conditions and caching. + * Detail method used in install tool and in a couple of other special cases. + * + * @internal + */ + public function parseFromString(string $typoScript, AstBuilderInterface $astBuilder): RootNode + { + $lineStream = $this->tokenizer->tokenize($typoScript); + return $astBuilder->build($lineStream, new RootNode()); + } +} diff --git a/Classes/TypoScript/UserTsConfig.php b/Classes/TypoScript/UserTsConfig.php new file mode 100644 index 0000000..8f1d198 --- /dev/null +++ b/Classes/TypoScript/UserTsConfig.php @@ -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\Core\TypoScript; + +use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode; + +/** + * A data object that carries the final user TSconfig. This is created by UserTsConfigFactory. + * + * @internal Internal for now until API stabilized. Use backendUser->getTSConfig(). + */ +final readonly class UserTsConfig +{ + private array $userTsConfigArray; + + public function __construct( + private RootNode $userTsConfigTree + ) { + $this->userTsConfigArray = $userTsConfigTree->toArray(); + } + + public function getUserTsConfigTree(): RootNode + { + return $this->userTsConfigTree; + } + + public function getUserTsConfigArray(): array + { + return $this->userTsConfigArray; + } +} diff --git a/Classes/TypoScript/UserTsConfigFactory.php b/Classes/TypoScript/UserTsConfigFactory.php new file mode 100644 index 0000000..bc92cc0 --- /dev/null +++ b/Classes/TypoScript/UserTsConfigFactory.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\TypoScript; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\ConditionVerdictAwareIncludeTreeTraverser; +use TYPO3\CMS\Core\TypoScript\IncludeTree\TsConfigTreeBuilder; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor; +use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionMatcherVisitor; +use TYPO3\CMS\Core\TypoScript\Tokenizer\TokenizerInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Calculate user TSconfig. This does the heavy lifting additionally supported by + * TsConfigTreeBuilder: Load basic user TSconfig tree, then build the user TSconfig AST + * and return user TSconfig DTO. + * + * @internal Internal for now until API stabilized. Use backendUser->getTSConfig(). + */ +#[Autoconfigure(public: true)] +final readonly class UserTsConfigFactory +{ + public function __construct( + private ContainerInterface $container, + private TokenizerInterface $tokenizer, + private TsConfigTreeBuilder $tsConfigTreeBuilder, + #[Autowire(service: 'cache.typoscript')] + private PhpFrontend $cache, + ) {} + + public function create(BackendUserAuthentication $backendUser): UserTsConfig + { + $includeTreeTraverserConditionVerdictAware = new ConditionVerdictAwareIncludeTreeTraverser(); + $includeTreeTraverserConditionVerdictAwareVisitors = []; + $userTsConfigTree = $this->tsConfigTreeBuilder->getUserTsConfigTree($backendUser, $this->tokenizer, $this->cache); + $conditionMatcherVisitor = GeneralUtility::makeInstance(IncludeTreeConditionMatcherVisitor::class); + // User TSconfig is not within page context, that what page TSconfig is for, so 'page', 'pageId', + // 'rootLine' and 'tree' can not be used in user TSconfig conditions. There is no request, either. + $conditionMatcherVisitor->initializeExpressionMatcherWithVariables([ + 'page' => [], + 'pageId' => 0, + ]); + $includeTreeTraverserConditionVerdictAwareVisitors[] = $conditionMatcherVisitor; + $astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class); + $includeTreeTraverserConditionVerdictAwareVisitors[] = $astBuilderVisitor; + $includeTreeTraverserConditionVerdictAware->traverse($userTsConfigTree, $includeTreeTraverserConditionVerdictAwareVisitors); + return new UserTsConfig($astBuilderVisitor->getAst()); + } +} diff --git a/Classes/Upgrades/BackendUserLanguageMigration.php b/Classes/Upgrades/BackendUserLanguageMigration.php new file mode 100644 index 0000000..d8cc904 --- /dev/null +++ b/Classes/Upgrades/BackendUserLanguageMigration.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; + +/** + * Migrates backend user language from "default" to "en". + * + * @since 14.2 + * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. + */ +#[UpgradeWizard('backendUserLanguageMigration')] +final readonly class BackendUserLanguageMigration implements UpgradeWizardInterface +{ + private const string TABLE_NAME = 'be_users'; + + public function __construct( + private ConnectionPool $connectionPool + ) {} + + public function getTitle(): string + { + return 'Migrate backend user language from "default" to "en"'; + } + + public function getDescription(): string + { + $count = $this->getRecordsToUpdateCount(); + return sprintf( + 'The language key "default" for backend users has been replaced with "en". ' + . 'This wizard migrates %d backend user record(s) to use "en" instead of "default".', + $count + ); + } + + public function updateNecessary(): bool + { + return $this->getRecordsToUpdateCount() > 0; + } + + public function getPrerequisites(): array + { + return [ + DatabaseUpdatedPrerequisite::class, + ]; + } + + public function executeUpdate(): bool + { + $connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME); + $connection->update( + self::TABLE_NAME, + ['lang' => 'en'], + ['lang' => 'default'] + ); + return true; + } + + private function getRecordsToUpdateCount(): int + { + $queryBuilder = $this->getPreparedQueryBuilder(); + return (int)$queryBuilder + ->count('uid') + ->from(self::TABLE_NAME) + ->where( + $queryBuilder->expr()->eq( + 'lang', + $queryBuilder->createNamedParameter('default') + ) + ) + ->executeQuery() + ->fetchOne(); + } + + private function getPreparedQueryBuilder(): QueryBuilder + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME); + $queryBuilder->getRestrictions()->removeAll(); + return $queryBuilder; + } +} diff --git a/Classes/Upgrades/ChattyInterface.php b/Classes/Upgrades/ChattyInterface.php new file mode 100644 index 0000000..ab5741b --- /dev/null +++ b/Classes/Upgrades/ChattyInterface.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Is this upgradeWizard chatty aka does it need to output things? + */ +interface ChattyInterface +{ + /** + * Setter injection for output into upgrade wizards + */ + public function setOutput(OutputInterface $output): void; +} diff --git a/Classes/Upgrades/ConfirmableInterface.php b/Classes/Upgrades/ConfirmableInterface.php new file mode 100644 index 0000000..8b780ea --- /dev/null +++ b/Classes/Upgrades/ConfirmableInterface.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +/** + * Use if upgrade wizard needs confirmation + */ +interface ConfirmableInterface +{ + /** + * Return a confirmation message instance + */ + public function getConfirmation(): Confirmation; +} diff --git a/Classes/Upgrades/Confirmation.php b/Classes/Upgrades/Confirmation.php new file mode 100644 index 0000000..726a878 --- /dev/null +++ b/Classes/Upgrades/Confirmation.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +readonly class Confirmation +{ + public function __construct( + protected string $title, + protected string $message, + protected bool $defaultValue = false, + protected string $confirm = 'Yes, execute', + protected string $deny = 'No, do not execute', + protected bool $required = false + ) {} + + public function getConfirm(): string + { + return $this->confirm; + } + + public function getDeny(): string + { + return $this->deny; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function getDefaultValue(): bool + { + return $this->defaultValue; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/Classes/Upgrades/DatabaseRowsUpdateWizard.php b/Classes/Upgrades/DatabaseRowsUpdateWizard.php new file mode 100644 index 0000000..75de08b --- /dev/null +++ b/Classes/Upgrades/DatabaseRowsUpdateWizard.php @@ -0,0 +1,336 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Database\Connection; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Upgrades\RowUpdater\RowUpdaterInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * This is a generic updater to migrate content of TCA rows. + * + * Multiple classes implementing interface "RowUpdaterInterface" can be + * registered here, each for a specific update purpose. + * + * The updater fetches each row of all TCA registered tables and + * visits the client classes who may modify the row content. + * + * The updater remembers for each class if it run through, so the updater + * will be shown again if a new updater class is registered that has not + * been run yet. + * + * A start position pointer is stored in the registry that is updated during + * the run process, so if for instance the PHP process runs into a timeout, + * the job can restart at the position it stopped. + * + * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. + */ +#[UpgradeWizard('databaseRowsUpdateWizard')] +class DatabaseRowsUpdateWizard implements UpgradeWizardInterface, RepeatableInterface +{ + /** + * @var array Single classes that may update rows + * @todo No RowUpdater remaining allowing us to move the registration to attribute/interface based locator. + */ + protected $rowUpdater = []; + + /** + * @internal + * @return string[] + */ + public function getAvailableRowUpdater(): array + { + return $this->rowUpdater; + } + + /** + * @return string Title of this updater + */ + public function getTitle(): string + { + return 'Execute database migrations on single rows'; + } + + /** + * @return string Longer description of this updater + * @throws \RuntimeException + */ + public function getDescription(): string + { + $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute(); + $description = 'Row updaters that have not been executed:'; + foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) { + $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName); + if (!$rowUpdater instanceof RowUpdaterInterface) { + throw new \RuntimeException( + 'Row updater must implement RowUpdaterInterface', + 1484066647 + ); + } + $description .= LF . $rowUpdater->getTitle(); + } + return $description; + } + + /** + * @return bool True if at least one row updater is not marked done + */ + public function updateNecessary(): bool + { + return !empty($this->getRowUpdatersToExecute()); + } + + /** + * @return string[] All new fields and tables must exist + */ + public function getPrerequisites(): array + { + return [ + DatabaseUpdatedPrerequisite::class, + ]; + } + + /** + * Performs the configuration update. + * + * @throws \Doctrine\DBAL\ConnectionException + * @throws \Exception + */ + public function executeUpdate(): bool + { + $registry = GeneralUtility::makeInstance(Registry::class); + + // If rows from the target table that is updated and the sys_registry table are on the + // same connection, the row update statement and sys_registry position update will be + // handled in a transaction to have an atomic operation in case of errors during execution. + $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); + $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry'); + + /** @var RowUpdaterInterface[] $rowUpdaterInstances */ + $rowUpdaterInstances = []; + // Single row updater instances are created only once for this method giving + // them a chance to set up local properties during hasPotentialUpdateForTable() + // and using that in updateTableRow() + foreach ($this->getRowUpdatersToExecute() as $rowUpdater) { + $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater); + if (!$rowUpdaterInstance instanceof RowUpdaterInterface) { + throw new \RuntimeException( + 'Row updater must implement RowUpdaterInterface', + 1484071612 + ); + } + $rowUpdaterInstances[] = $rowUpdaterInstance; + } + + // Scope of the row updater is to update all rows that have TCA, + // our list of tables is just the list of loaded TCA tables. + /** @var string[] $listOfAllTables */ + $listOfAllTables = array_keys($GLOBALS['TCA']); + + // In case the PHP ended for whatever reason, fetch the last position from registry + // and throw away all tables before that start point. + sort($listOfAllTables); + reset($listOfAllTables); + $firstTable = current($listOfAllTables) ?: ''; + $startPosition = $this->getStartPosition($firstTable); + foreach ($listOfAllTables as $key => $table) { + if ($table === $startPosition['table']) { + break; + } + unset($listOfAllTables[$key]); + } + + // Ask each row updater if it potentially has field updates for rows of a table + $tableToUpdaterList = []; + foreach ($listOfAllTables as $table) { + foreach ($rowUpdaterInstances as $updater) { + if ($updater->hasPotentialUpdateForTable($table)) { + $tableToUpdaterList[$table] ??= []; + $tableToUpdaterList[$table][] = $updater; + } + } + } + + // Iterate through all rows of all tables that have potential row updaters attached, + // feed each single row to each updater and finally update each row in database if + // a row updater changed a fields + foreach ($tableToUpdaterList as $table => $updaters) { + /** @var RowUpdaterInterface[] $updaters */ + $connectionForTable = $connectionPool->getConnectionForTable($table); + $queryBuilder = $connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*') + ->from($table) + ->orderBy('uid'); + if ($table === $startPosition['table']) { + $queryBuilder->where( + $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid'])) + ); + } + $statement = $queryBuilder->executeQuery(); + $rowCountWithoutUpdate = 0; + while ($row = $statement->fetchAssociative()) { + $rowBefore = $row; + foreach ($updaters as $updater) { + $row = $updater->updateTableRow($table, $row); + } + $updatedFields = array_diff_assoc($row, $rowBefore); + if (empty($updatedFields)) { + // Updaters changed no field of that row + $rowCountWithoutUpdate++; + if ($rowCountWithoutUpdate >= 200) { + // Update startPosition if there were many rows without data change + $startPosition = [ + 'table' => $table, + 'uid' => $row['uid'], + ]; + $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); + $rowCountWithoutUpdate = 0; + } + } else { + $rowCountWithoutUpdate = 0; + $startPosition = [ + 'table' => $table, + 'uid' => $rowBefore['uid'], + ]; + if ($connectionForSysRegistry === $connectionForTable) { + // Target table and sys_registry table are on the same connection, use a transaction + $connectionForTable->beginTransaction(); + try { + $this->updateOrDeleteRow( + $connectionForTable, + $connectionForTable, + $table, + (int)$rowBefore['uid'], + $updatedFields, + $startPosition + ); + $connectionForTable->commit(); + } catch (\Exception $up) { + $connectionForTable->rollBack(); + throw $up; + } + } else { + // Different connections for table and sys_registry. + // So, execute two distinct queries and hope for the best. + $this->updateOrDeleteRow( + $connectionForTable, + $connectionForSysRegistry, + $table, + (int)$rowBefore['uid'], + $updatedFields, + $startPosition + ); + } + } + } + } + + // Ready with updates, remove position information from sys_registry + $registry->remove('installUpdateRows', 'rowUpdatePosition'); + // Mark row updaters that were executed as done + foreach ($rowUpdaterInstances as $updater) { + $this->setRowUpdaterExecuted($updater); + } + + return true; + } + + /** + * Return an array of class names that are not yet marked as done. + * + * @return array Class names + */ + protected function getRowUpdatersToExecute(): array + { + $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []); + return array_diff($this->rowUpdater, $doneRowUpdater); + } + + /** + * Mark a single updater as done + */ + protected function setRowUpdaterExecuted(RowUpdaterInterface $updater) + { + $registry = GeneralUtility::makeInstance(Registry::class); + $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []); + $doneRowUpdater[] = get_class($updater); + $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater); + } + + /** + * Return an array with table / uid combination that specifies the start position the + * update row process should start with. + * + * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized + * @return array New start position + */ + protected function getStartPosition(string $firstTable): array + { + $registry = GeneralUtility::makeInstance(Registry::class); + $startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []); + if (empty($startPosition)) { + $startPosition = [ + 'table' => $firstTable, + 'uid' => 0, + ]; + $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); + } + return $startPosition; + } + + protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void + { + $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null; + if ($deleteField === null && isset($updatedFields['deleted']) && $updatedFields['deleted'] === 1) { + $connectionForTable->delete( + $table, + [ + 'uid' => $uid, + ] + ); + } else { + $connectionForTable->update( + $table, + $updatedFields, + [ + 'uid' => $uid, + ] + ); + } + $connectionForSysRegistry->update( + 'sys_registry', + [ + 'entry_value' => serialize($startPosition), + ], + [ + 'entry_namespace' => 'installUpdateRows', + 'entry_key' => 'rowUpdatePosition', + ], + [ + // Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary) + 'entry_value' => Connection::PARAM_LOB, + 'entry_namespace' => Connection::PARAM_STR, + 'entry_key' => Connection::PARAM_STR, + ] + ); + } +} diff --git a/Classes/Upgrades/DatabaseUpdatedPrerequisite.php b/Classes/Upgrades/DatabaseUpdatedPrerequisite.php new file mode 100644 index 0000000..0b69f73 --- /dev/null +++ b/Classes/Upgrades/DatabaseUpdatedPrerequisite.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Service\DatabaseUpgradeWizardsService; + +/** + * Prerequisite for upgrade wizards to ensure the database is up-to-date + */ +#[Autoconfigure(public: true)] +final class DatabaseUpdatedPrerequisite implements PrerequisiteInterface, ChattyInterface +{ + private OutputInterface $output; + + public function __construct( + private readonly DatabaseUpgradeWizardsService $databaseUpgradeWizardsService, + ) {} + + public function getTitle(): string + { + return 'Database Up-to-Date'; + } + + public function ensure(): bool + { + $adds = $this->databaseUpgradeWizardsService->getBlockingDatabaseAdds(); + // Nothing to add, early return + if ($adds === []) { + return true; + } + + $this->output->writeln('Performing ' . count($adds) . ' database operations.'); + // remove potentially empty error messages + $errorMessages = array_filter($this->databaseUpgradeWizardsService->addMissingTablesAndFields()); + + return $errorMessages === []; + } + + public function isFulfilled(): bool + { + $adds = $this->databaseUpgradeWizardsService->getBlockingDatabaseAdds(); + return count($adds) === 0; + } + + public function setOutput(OutputInterface $output): void + { + $this->output = $output; + } +} diff --git a/Classes/Upgrades/MigrateExtensionDataImportRegistryKeysUpdate.php b/Classes/Upgrades/MigrateExtensionDataImportRegistryKeysUpdate.php new file mode 100644 index 0000000..05f783e --- /dev/null +++ b/Classes/Upgrades/MigrateExtensionDataImportRegistryKeysUpdate.php @@ -0,0 +1,168 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite as CoreDatabaseUpdatedPrerequisite; +use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface as CoreUpgradeWizardInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Migrate extension data import registry keys from path-based to extension key-based format + * + * This wizard updates sys_registry entries that were stored with file paths to use + * extension keys as prefix instead, making them independent of file path changes. + * + * @since 14.0 + * @internal This class is only meant to be used within `EXT:core` and is not part of the TYPO3 Core API. + */ +#[UpgradeWizard('migrateExtensionDataImportRegistryKeys')] +readonly class MigrateExtensionDataImportRegistryKeysUpdate implements CoreUpgradeWizardInterface +{ + public function getTitle(): string + { + return 'Migrate extension data import registry keys'; + } + + public function getDescription(): string + { + return 'Updates sys_registry entries for extension data imports from path-based keys to extension key-based keys. ' + . 'This makes the registry entries independent of file path changes and follows the new format introduced ' + . 'in the extension data import system.'; + } + + public function executeUpdate(): bool + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_registry'); + + // Get all extensionDataImport registry entries + $queryBuilder = $connection->createQueryBuilder(); + $result = $queryBuilder + ->select('entry_key', 'entry_value') + ->from('sys_registry') + ->where( + $queryBuilder->expr()->eq( + 'entry_namespace', + $queryBuilder->createNamedParameter('extensionDataImport') + ) + ) + ->orderBy('uid') + ->executeQuery(); + + $registry = GeneralUtility::makeInstance(Registry::class); + while ($row = $result->fetchAssociative()) { + $oldKey = $row['entry_key']; + $value = $row['entry_value']; + + if ($oldKey === '') { + continue; + } + // Skip entries that already use the new format (contain ":") + if (str_contains($oldKey, ':') && !str_starts_with($oldKey, 'EXT:')) { + continue; + } + + $newKey = $this->convertPathToExtensionKey($oldKey); + if ($newKey !== null && $newKey !== $oldKey) { + // Set the new key with the same value + $registry->set('extensionDataImport', $newKey, unserialize($value, ['allowed_classes' => false])); + // Remove the old key + $registry->remove('extensionDataImport', $oldKey); + } + } + + return true; + } + + public function updateNecessary(): bool + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_registry'); + + $queryBuilder = $connection->createQueryBuilder(); + $count = $queryBuilder + ->count('*') + ->from('sys_registry') + ->where( + $queryBuilder->expr()->eq( + 'entry_namespace', + $queryBuilder->createNamedParameter('extensionDataImport') + ), + $queryBuilder->expr()->notLike( + 'entry_key', + $queryBuilder->createNamedParameter('%:%') + ) + ) + ->executeQuery() + ->fetchOne(); + + return $count > 0; + } + + public function getPrerequisites(): array + { + return [ + CoreDatabaseUpdatedPrerequisite::class, + ]; + } + + /** + * Convert a path-based registry key to an extension key-based format + */ + protected function convertPathToExtensionKey(string $pathKey): ?string + { + // Pattern 1: typo3conf/ext/... or typo3/sysext/... + if (preg_match('#^(?:typo3conf/ext|typo3/sysext)/([^/]+)/(.+)$#', $pathKey, $matches)) { + $extensionKey = $matches[1]; + $filePart = $matches[2]; + return $extensionKey . ':' . $filePart; + } + + // Pattern 2: EXT:extension_name/... + if (preg_match('#^EXT:([^/]+)/(.+)$#', $pathKey, $matches)) { + $extensionKey = $matches[1]; + $filePart = $matches[2]; + return $extensionKey . ':' . $filePart; + } + + // Pattern 3: composer-based mode (vendor/...), in this case we take the last path part before + // "Initialisation/Files" or "ext_tables_static+adt.sql" + $pathSegments = GeneralUtility::revExplode('/', $pathKey, 2); + if (count($pathSegments) !== 2) { + return null; + } + if ($pathSegments[1] === 'Files' || $pathSegments[1] === 'dataImported') { + $pathSegments[0] = GeneralUtility::revExplode('/', $pathSegments[0], 2)[0]; + $pathSegments[1] = 'Initialisation/' . $pathSegments[1]; + } + if (preg_match('#^([^/]+)/(.+)$#', $pathSegments[0], $matches) && in_array($pathSegments[1], ['Initialisation/Files', 'Initialisation/dataImported', 'ext_tables_static+adt.sql'])) { + $extensionKey = (GeneralUtility::revExplode('/', $matches[0], 2)[1] ?? ''); + $extensionKey = str_replace('-', '_', $extensionKey); // Normalize dashes to underscores + if (str_starts_with($extensionKey, 'cms_')) { + $extensionKey = substr($extensionKey, strlen('cms_')); + } + $filePart = $pathSegments[1]; + return $extensionKey . ':' . $filePart; + } + + return null; + } +} diff --git a/Classes/Upgrades/PageDoktypeLinkMigration.php b/Classes/Upgrades/PageDoktypeLinkMigration.php new file mode 100644 index 0000000..da1a1fe --- /dev/null +++ b/Classes/Upgrades/PageDoktypeLinkMigration.php @@ -0,0 +1,200 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Schema\Table; +use Symfony\Component\Console\Output\OutputInterface; +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Migrates `pages.url` field values for `pages.doktype = 3 (Link)` to + * TypoLink notation suitable for the `pages.link` field and displays + * failed pages uid. + * + * @since 14.0 + * @internal This class is only meant to be used within EXT:core and is not part of the TYPO3 Core API. + * @todo Remove in 16.0 as breaking change. + */ +#[UpgradeWizard('pageDoktypeLinkMigration')] +class PageDoktypeLinkMigration implements UpgradeWizardInterface, ChattyInterface +{ + protected ?OutputInterface $output = null; + + public function __construct( + private readonly ConnectionPool $connectionPool, + ) {} + + public function setOutput(OutputInterface $output): void + { + $this->output = $output; + } + + public function getTitle(): string + { + return 'Migrate field "pages.url" to "pages.link" for pages of type Link.'; + } + + public function getDescription(): string + { + return 'Migrates "pages.url" to "pages.link", preserving former behaviour of the page type "External Link".'; + } + + public function getPrerequisites(): array + { + return [ + DatabaseUpdatedPrerequisite::class, + ]; + } + + public function updateNecessary(): bool + { + $tableSchema = $this->getPagesTableSchema(); + return $tableSchema !== null + && $tableSchema->hasColumn('url') + && $tableSchema->hasColumn('target') + && $tableSchema->hasColumn('link') + && $this->hasRecordsToUpdate(); + } + + public function executeUpdate(): bool + { + if (!$this->updateNecessary()) { + return true; + } + $connection = $this->connectionPool->getConnectionForTable('pages'); + $migratableItemsQueryBuilder = $connection->createQueryBuilder(); + $result = $migratableItemsQueryBuilder + ->select('uid', 'url', 'target', 'deleted') + ->from('pages') + ->where( + $migratableItemsQueryBuilder->expr()->and( + $migratableItemsQueryBuilder->expr()->eq('link', $migratableItemsQueryBuilder->createNamedParameter('')), + $migratableItemsQueryBuilder->expr()->neq('url', $migratableItemsQueryBuilder->createNamedParameter('')), + $migratableItemsQueryBuilder->expr()->eq('doktype', $migratableItemsQueryBuilder->createNamedParameter(PageRepository::DOKTYPE_LINK, ParameterType::INTEGER)), + ), + ) + ->executeQuery(); + $scheme = $GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme'] ?? 'http'; + $failedMigrations = []; + try { + while ($row = $result->fetchAssociative()) { + $url = $this->migrateExternalUrlToTypoLink($row['url'], $row['target'], $scheme); + if ($url === '') { + if ($row['deleted'] !== 1) { + $failedMigrations[] = $row['uid']; + } + continue; + } + $updateQueryBuilder = $connection->createQueryBuilder(); + $updateQueryBuilder->getRestrictions()->removeAll(); + $expression = $updateQueryBuilder->expr(); + $updateQueryBuilder->update('pages') + ->set('link', $url) + // Empty url field to flag already migrated record. + ->set('url', '') + // Empty target field + ->set('target', '') + ->where($expression->eq('uid', ($row['uid']))) + ->executeStatement(); + } + } finally { + // Ensure to buffer is freed in case any exception occurred to avoid follow issues. + $result->free(); + } + if ($failedMigrations !== []) { + $this->output?->writeln(sprintf( + 'The following pages of type "Link" could not be migrated: %s', + implode(', ', $failedMigrations), + )); + } + return true; + } + + protected function hasRecordsToUpdate(): bool + { + $connection = $this->connectionPool->getConnectionForTable('pages'); + $migratableItemsQueryBuilder = $connection->createQueryBuilder(); + return (bool)$migratableItemsQueryBuilder + ->count('*') + ->from('pages') + ->where( + $migratableItemsQueryBuilder->expr()->and( + $migratableItemsQueryBuilder->expr()->eq('link', $migratableItemsQueryBuilder->createNamedParameter('')), + $migratableItemsQueryBuilder->expr()->neq('url', $migratableItemsQueryBuilder->createNamedParameter('')), + $migratableItemsQueryBuilder->expr()->eq('doktype', $migratableItemsQueryBuilder->createNamedParameter(PageRepository::DOKTYPE_LINK, ParameterType::INTEGER)), + ), + )->executeQuery()->fetchOne(); + } + + protected function migrateExternalUrlToTypoLink(string $urlString, string $target, string $sitePrefix): string + { + $urlTargetSuffix = $target !== '' ? ' ' . $target : ''; + if ($urlString === '') { + return ''; + } + // Old ExternalUrl field allowed to simply define query parameters appended to the current page, + // which TypoScript still supports. Simply keep/copy the option. + if (str_starts_with($urlString, '?')) { + return $urlString . $urlTargetSuffix; + } + $parsedUrl = parse_url($urlString); + if (str_starts_with($urlString, 'mailto:')) { + if (GeneralUtility::validEmail(substr($urlString, 7))) { + // valid mailto: URL + return $urlString . $urlTargetSuffix; + } + return ''; + + } + if (!($parsedUrl['scheme'] ?? false)) { + if (GeneralUtility::validEmail($urlString)) { + // Email Address without mailto prefix + return 'mailto:' . $urlString; + } + if (str_starts_with($urlString, '/')) { + // Relative Url on site base + return $urlString . $urlTargetSuffix; + } + // domain without https prefix + $urlString = $sitePrefix . '://' . $urlString; + } + if (!GeneralUtility::isValidUrl($urlString)) { + return ''; + } + // Reject any non-http(s) schemes (mailto: already handled above) + // This rejects javascript: ftp: and other potentially harmful prefixes + $scheme = strtolower((string)(parse_url($urlString, PHP_URL_SCHEME) ?? '')); + if ($scheme !== '' && $scheme !== 'http' && $scheme !== 'https') { + return ''; + } + return $urlString . $urlTargetSuffix; + } + + protected function getPagesTableSchema(): ?Table + { + $schemaManager = $this->connectionPool->getConnectionForTable('pages')->createSchemaManager(); + if (!$schemaManager->tablesExist(['pages'])) { + return null; + } + return $schemaManager->introspectTable('pages'); + } +} diff --git a/Classes/Upgrades/PrerequisiteCollection.php b/Classes/Upgrades/PrerequisiteCollection.php new file mode 100644 index 0000000..1c5dac8 --- /dev/null +++ b/Classes/Upgrades/PrerequisiteCollection.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Collection of prerequisites used internally in upgrade wizard commands. + * + * @internal for use in upgrade wizard command only and not part of public API. + */ +final class PrerequisiteCollection implements \IteratorAggregate +{ + private \ArrayObject $prerequisites; + + public function __construct() + { + $this->prerequisites = new \ArrayObject(); + } + + public function add(string $prerequisiteClass): void + { + if ( + !($this->prerequisites[$prerequisiteClass] ?? false) + && is_a($prerequisiteClass, PrerequisiteInterface::class, true) + ) { + $this->prerequisites[$prerequisiteClass] = GeneralUtility::makeInstance( + $prerequisiteClass + ); + } + } + + public function getIterator(): \Traversable + { + return $this->prerequisites; + } +} diff --git a/Classes/Upgrades/PrerequisiteInterface.php b/Classes/Upgrades/PrerequisiteInterface.php new file mode 100644 index 0000000..56d7382 --- /dev/null +++ b/Classes/Upgrades/PrerequisiteInterface.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +/** + * UpgradeWizard Prerequisites + */ +interface PrerequisiteInterface +{ + /** + * Get speaking name of this prerequisite + */ + public function getTitle(): string; + + /** + * Ensure this prerequisite is fulfilled + * + * Gets called if "isFulfilled" returns false + * and should ensure the prerequisite + * + * Returns true on success, false on error + * + * @see isFulfilled + */ + public function ensure(): bool; + + /** + * Is this prerequisite met? + * + * Checks whether this prerequisite is fulfilled. If it is not, + * ensure should be called to fulfill it. + * + * @see ensure + */ + public function isFulfilled(): bool; +} diff --git a/Classes/Upgrades/ReferenceIndexUpdatedPrerequisite.php b/Classes/Upgrades/ReferenceIndexUpdatedPrerequisite.php new file mode 100644 index 0000000..df4721f --- /dev/null +++ b/Classes/Upgrades/ReferenceIndexUpdatedPrerequisite.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Backend\Command\ProgressListener\ReferenceIndexProgressListener; +use TYPO3\CMS\Core\Database\ReferenceIndex; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * ReferenceIndex Prerequisite + * + * Defines that the reference index needs to be up-to-date before an upgrade wizard may be run + */ +#[Autoconfigure(public: true)] +final class ReferenceIndexUpdatedPrerequisite implements PrerequisiteInterface, ChattyInterface +{ + private OutputInterface $output; + + public function __construct( + private readonly ReferenceIndex $referenceIndex, + ) {} + + public function getTitle(): string + { + return 'Reference Index Up-to-Date'; + } + + /** + * Updates the reference index + */ + public function ensure(): bool + { + $this->output->writeln('Reference Index is being updated'); + $progressListener = GeneralUtility::makeInstance(ReferenceIndexProgressListener::class); + $progressListener->initialize(new SymfonyStyle(new ArrayInput([]), $this->output)); + $result = $this->referenceIndex->updateIndex(false, $progressListener); + return empty($result['errors']); + } + + /** + * Checks whether there are reference index updates to be done + */ + public function isFulfilled(): bool + { + $result = $this->referenceIndex->updateIndex(true); + return empty($result['errors']); + } + + public function setOutput(OutputInterface $output): void + { + $this->output = $output; + } +} diff --git a/Classes/Upgrades/RepeatableInterface.php b/Classes/Upgrades/RepeatableInterface.php new file mode 100644 index 0000000..273c692 --- /dev/null +++ b/Classes/Upgrades/RepeatableInterface.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +/** + * Use if wizard may be run multiple times (and should not be disabled after one run) + * + * Semantic/Marker interface only + */ +interface RepeatableInterface {} diff --git a/Classes/Upgrades/RowUpdater/RowUpdaterInterface.php b/Classes/Upgrades/RowUpdater/RowUpdaterInterface.php new file mode 100644 index 0000000..a0b7db6 --- /dev/null +++ b/Classes/Upgrades/RowUpdater/RowUpdaterInterface.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades\RowUpdater; + +/** + * Interface each single row updater must implement. + */ +interface RowUpdaterInterface +{ + /** + * Get a description of this single row updater + */ + public function getTitle(): string; + + /** + * Return true if this row updater may have updates for given table rows. + * + * @param string $tableName Given table + */ + public function hasPotentialUpdateForTable(string $tableName): bool; + + /** + * Update a single row from a table. + * + * @param string $tableName Given table + * @param array $row Given row + * @return array Potentially modified row + */ + public function updateTableRow(string $tableName, array $row): array; +} diff --git a/Classes/Upgrades/UpgradeWizardInterface.php b/Classes/Upgrades/UpgradeWizardInterface.php new file mode 100644 index 0000000..a3f63dd --- /dev/null +++ b/Classes/Upgrades/UpgradeWizardInterface.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +/** + * Interface UpgradeWizardInterface + */ +interface UpgradeWizardInterface +{ + /** + * Return the speaking name of this wizard + */ + public function getTitle(): string; + + /** + * Return the description for this wizard + */ + public function getDescription(): string; + + /** + * Execute the update + * + * Called when a wizard reports that an update is necessary + */ + public function executeUpdate(): bool; + + /** + * Is an update necessary? + * + * Is used to determine whether a wizard needs to be run. + * Check if data for migration exists. + */ + public function updateNecessary(): bool; + + /** + * Returns an array of class names of Prerequisite classes + * + * This way a wizard can define dependencies like "database up-to-date" or + * "reference index updated" + * + * @return string[] + */ + public function getPrerequisites(): array; +} diff --git a/Classes/Upgrades/UpgradeWizardRegistry.php b/Classes/Upgrades/UpgradeWizardRegistry.php new file mode 100644 index 0000000..29ba9bc --- /dev/null +++ b/Classes/Upgrades/UpgradeWizardRegistry.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * Registry for upgrade wizards. The registry receives all services, tagged with "install.upgradewizard". + * The tagging of upgrade wizards is automatically done based on the PHP Attribute UpgradeWizard. + * + * @internal + */ +readonly class UpgradeWizardRegistry +{ + public function __construct( + #[AutowireLocator(services: 'install.upgradewizard', indexAttribute: 'identifier')] + private ServiceLocator $upgradeWizards + ) {} + + /** + * Whether a registered upgrade wizard exists for the given identifier + */ + public function hasUpgradeWizard(string $identifier): bool + { + return $this->upgradeWizards->has($identifier); + } + + /** + * Get registered upgrade wizard by identifier + */ + public function getUpgradeWizard(string $identifier): UpgradeWizardInterface + { + if (!$this->hasUpgradeWizard($identifier)) { + throw new \UnexpectedValueException('Upgrade wizard with identifier ' . $identifier . ' is not registered.', 1673964964); + } + + return $this->upgradeWizards->get($identifier); + } + + /** + * Get all registered upgrade wizards + * + * @return array + */ + public function getUpgradeWizards(): array + { + return $this->upgradeWizards->getProvidedServices(); + } +} diff --git a/Classes/Upgrades/UserPermissionsForRenamedModulesMigration.php b/Classes/Upgrades/UserPermissionsForRenamedModulesMigration.php new file mode 100644 index 0000000..e2cef50 --- /dev/null +++ b/Classes/Upgrades/UserPermissionsForRenamedModulesMigration.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Upgrades; + +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @since 14.0 + * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. + */ +#[UpgradeWizard('userPermissionsForRenamedModulesMigration')] +class UserPermissionsForRenamedModulesMigration implements UpgradeWizardInterface +{ + protected array $tables = [ + 'be_groups' => 'groupMods', + 'be_users' => 'userMods', + ]; + + /** + * @var array <string, string> an array with the old module identifier as key and the new one as value + */ + protected array $moduleRenaming = [ + 'web_list' => 'records', + 'web_info' => 'content_status', + 'workspaces_admin' => 'workspaces_publish', + 'site_redirects' => 'redirects', + 'web_linkvalidator' => 'linkvalidator_checklinks', + ]; + + /** + * Modules that require a parent module to be accessible. + * + * @var array<string, string> Key: the new module identifier, Value: required parent module + */ + protected array $requiredParentModules = [ + 'redirects' => 'link_management', + 'linkvalidator_checklinks' => 'link_management', + ]; + + public function getTitle(): string + { + return 'Migrate module permissions'; + } + + public function getDescription(): string + { + return 'Migrate permissions for renamed modules in user and group module permissions. ' + . 'Also adds required parent modules when a module has been moved to a new location in the module hierarchy.'; + } + + public function getPrerequisites(): array + { + return [ + DatabaseUpdatedPrerequisite::class, + ]; + } + + public function updateNecessary(): bool + { + return $this->migrate(true); + } + + public function executeUpdate(): bool + { + return $this->migrate(false); + } + + private function migrate(bool $dryRun): bool + { + $migrated = false; + foreach ($this->tables as $table => $field) { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable($table); + $connection = $this->getConnectionPool()->getConnectionForTable($table); + $queryBuilder->select('uid', $field) + ->from($table) + ->executeQuery(); + + foreach ($queryBuilder->fetchAllAssociative() as $record) { + $originalModules = (string)($record[$field] ?? ''); + $modules = explode(',', $originalModules); + $parentModulesToAdd = []; + $updatedModules = array_map(function ($module) use (&$parentModulesToAdd) { + $trimmedModule = trim($module); + if (isset($this->moduleRenaming[$trimmedModule])) { + $newModule = $this->moduleRenaming[$trimmedModule]; + if (isset($this->requiredParentModules[$newModule])) { + $parentModulesToAdd[] = $this->requiredParentModules[$newModule]; + } + return $newModule; + } + return $module; + }, $modules); + + $trimmedModules = array_filter(array_map('trim', $updatedModules)); + $deduplicatedModules = array_unique($trimmedModules); + + // Check if existing modules (already renamed in a previous migration) need parent modules + // This handles the case where the wizard ran in v14.0 before $requiredParentModules existed + foreach ($deduplicatedModules as $module) { + if (isset($this->requiredParentModules[$module])) { + $parentModulesToAdd[] = $this->requiredParentModules[$module]; + } + } + + // Add parent modules if not already present + foreach (array_unique($parentModulesToAdd) as $parentModule) { + if (!in_array($parentModule, $deduplicatedModules, true)) { + $deduplicatedModules[] = $parentModule; + } + } + + $newModules = implode(',', $deduplicatedModules); + if ($originalModules !== $newModules) { + if ($dryRun) { + return true; + } + $migrated = $connection->update( + $table, + [ + $field => $newModules, + ], + ['uid' => (int)$record['uid']] + ) > 0 || $migrated; + } + } + } + return $migrated; + } + + protected function getConnectionPool(): ConnectionPool + { + return GeneralUtility::makeInstance(ConnectionPool::class); + } +} diff --git a/Classes/Utility/ArrayUtility.php b/Classes/Utility/ArrayUtility.php new file mode 100644 index 0000000..332b690 --- /dev/null +++ b/Classes/Utility/ArrayUtility.php @@ -0,0 +1,1017 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException; + +/** + * Class with helper functions for array handling + */ +readonly class ArrayUtility +{ + /** + * Validates the given $arrayToTest by checking if an element is not in $allowedArrayKeys. + * + * @throws \InvalidArgumentException if an element in $arrayToTest is not in $allowedArrayKeys + * @internal + */ + public static function assertAllArrayKeysAreValid(array $arrayToTest, array $allowedArrayKeys): void + { + $notAllowedArrayKeys = array_keys(array_diff_key($arrayToTest, array_flip($allowedArrayKeys))); + if (count($notAllowedArrayKeys) !== 0) { + throw new \InvalidArgumentException( + sprintf( + 'The options "%s" were not allowed (allowed were: "%s")', + implode(', ', $notAllowedArrayKeys), + implode(', ', $allowedArrayKeys) + ), + 1325697085 + ); + } + } + + /** + * Recursively convert 'true' and 'false' strings to boolean values. + */ + public static function convertBooleanStringsToBooleanRecursive(array $array): array + { + $result = $array; + foreach ($result as $key => $value) { + if (is_array($value)) { + $result[$key] = self::convertBooleanStringsToBooleanRecursive($value); + } else { + if ($value === 'true') { + $result[$key] = true; + } elseif ($value === 'false') { + $result[$key] = false; + } + } + } + return $result; + } + + /** + * Reduce an array by a search value and keep the array structure. + * + * Comparison is type strict: + * - For a given needle of type string, integer, array or boolean, + * value and value type must match to occur in result array + * - For a given object, an object within the array must be a reference to + * the same object to match (not just different instance of same class) + * + * Example: + * - Needle: 'findMe' + * - Given array: + * array( + * 'foo' => 'noMatch', + * 'bar' => 'findMe', + * 'foobar => array( + * 'foo' => 'findMe', + * ), + * ); + * - Result: + * array( + * 'bar' => 'findMe', + * 'foobar' => array( + * 'foo' => findMe', + * ), + * ); + * + * See the unit tests for more examples and expected behaviour + * + * @param mixed $needle The value to search for + * @param array $haystack The array in which to search + * @return array $haystack array reduced matching $needle values + */ + public static function filterByValueRecursive(mixed $needle = '', array $haystack = []): array + { + $resultArray = []; + // Define a lambda function to be applied to all members of this array dimension + // Call recursive if current value is of type array + // Write to $resultArray (by reference!) if types and value match + $callback = static function (&$value, $key) use ($needle, &$resultArray) { + if ($value === $needle) { + $resultArray[$key] = $value; + } elseif (is_array($value)) { + $subArrayMatches = static::filterByValueRecursive($needle, $value); + if (!empty($subArrayMatches)) { + $resultArray[$key] = $subArrayMatches; + } + } + }; + // array_walk() is not affected by the internal pointers, no need to reset + array_walk($haystack, $callback); + // Pointers to result array are reset internally + return $resultArray; + } + + /** + * Checks if a given path exists in array + * + * Example: + * - array: + * array( + * 'foo' => array( + * 'bar' = 'test', + * ) + * ); + * - path: 'foo/bar' + * - return: TRUE + * + * @param array $array Given array + * @param array|string $path Path to test within the array + * @param string $delimiter Delimiter for path, default / + * @return bool TRUE if path exists in array + */ + public static function isValidPath(array $array, array|string $path, string $delimiter = '/'): bool + { + $isValid = true; + try { + static::getValueByPath($array, $path, $delimiter); + } catch (MissingArrayPathException) { + $isValid = false; + } + return $isValid; + } + + /** + * Returns a value by given path + * + * Example + * - array: + * array( + * 'foo' => array( + * 'bar' => array( + * 'baz' => 42 + * ) + * ) + * ); + * - path: foo/bar/baz + * - return: 42 + * + * If a path segments contains a delimiter character, the path segment + * must be enclosed by " (double quote), see unit tests for details + * + * @param array $array Input array + * @param array|string $path Path within the array + * @param string $delimiter Defined path delimiter, default / + * @throws \RuntimeException if the path is empty + * @throws MissingArrayPathException if a configured path segment does not exist in the array + */ + public static function getValueByPath(array $array, array|string $path, string $delimiter = '/'): mixed + { + // Upcast a string to an array if necessary + if (is_string($path)) { + if ($path === '') { + // Programming error has to be sanitized before calling the method -> global exception + throw new \RuntimeException('Path must not be empty', 1341397767); + } + $path = str_getcsv($path, $delimiter, '"', '\\'); + } + // Loop through each part and extract its value + $value = $array; + foreach ($path as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + // Replace current value with child + $value = $value[$segment]; + } else { + // Throw specific exception if there is no such path + throw new MissingArrayPathException('Segment ' . $segment . ' of path ' . implode($delimiter, $path) . ' does not exist in array', 1341397869); + } + } + return $value; + } + + /** + * Reindex keys from the current nesting level if all keys within + * the current nesting level are integers. + */ + public static function reIndexNumericArrayKeysRecursive(array $array): array + { + // Can't use array_is_list() because an all-integers but non-sequential + // array is not a list, but should be reindexed. + if (count(array_filter(array_keys($array), is_string(...))) === 0) { + $array = array_values($array); + } + foreach ($array as $key => $value) { + if (is_array($value) && !empty($value)) { + $array[$key] = self::reIndexNumericArrayKeysRecursive($value); + } + } + return $array; + } + + /** + * Recursively remove keys if their value are NULL. + */ + public static function removeNullValuesRecursive(array $array): array + { + $result = $array; + foreach ($result as $key => $value) { + if (is_array($value)) { + $result[$key] = self::removeNullValuesRecursive($value); + } elseif ($value === null) { + unset($result[$key]); + } + } + return $result; + } + + /** + * Modifies or sets a new value in an array by given path + * + * Example: + * - array: + * array( + * 'foo' => array( + * 'bar' => 42, + * ), + * ); + * - path: foo/bar + * - value: 23 + * - return: + * array( + * 'foo' => array( + * 'bar' => 23, + * ), + * ); + * + * @param array $array Input array to manipulate + * @param string|array|\ArrayAccess $path Path in array to search for + * @param mixed $value Value to set at path location in array + * @param string $delimiter Path delimiter + * @return array Modified array + * @throws \RuntimeException + */ + public static function setValueByPath(array $array, string|array|\ArrayAccess $path, mixed $value, string $delimiter = '/'): array + { + if (is_string($path)) { + if ($path === '') { + throw new \RuntimeException('Path must not be empty', 1341406194); + } + // Extract parts of the path + $path = str_getcsv($path, $delimiter, '"', '\\'); + } + // Point to the root of the array + $pointer = &$array; + // Find path in given array + foreach ($path as $segment) { + // Fail if the part is empty + if ($segment === '') { + throw new \RuntimeException('Invalid path segment specified', 1341406846); + } + // Create cell if it doesn't exist + if (is_array($pointer) && !array_key_exists($segment, $pointer)) { + $pointer[$segment] = []; + } + // Make it array if it was something else before + if (!is_array($pointer)) { + $pointer = []; + } + // Set pointer to new cell + $pointer = &$pointer[$segment]; + } + // Set value of target cell + $pointer = $value; + return $array; + } + + /** + * Remove a sub part from an array specified by path + * + * @param array $array Input array to manipulate + * @param string $path Path to remove from array + * @param string $delimiter Path delimiter + * @return array Modified array + * @throws \RuntimeException + */ + public static function removeByPath(array $array, string $path, string $delimiter = '/'): array + { + if ($path === '') { + throw new \RuntimeException('Path must not be empty', 1371757718); + } + // Extract parts of the path + $pathSegments = str_getcsv($path, $delimiter, '"', '\\'); + $pathDepth = count($pathSegments); + $currentDepth = 0; + $pointer = &$array; + // Find path in given array + foreach ($pathSegments as $segment) { + $currentDepth++; + // Fail if the part is empty + if ($segment === '') { + throw new \RuntimeException('Invalid path segment specified', 1371757720); + } + if (!array_key_exists($segment, $pointer)) { + throw new MissingArrayPathException('Segment ' . $segment . ' of path ' . implode($delimiter, $pathSegments) . ' does not exist in array', 1371758436); + } + if ($currentDepth === $pathDepth) { + unset($pointer[$segment]); + } else { + $pointer = &$pointer[$segment]; + } + } + return $array; + } + + /** + * Sorts an array recursively by key + * + * @param array $array Array to sort recursively by key + * @return array Sorted array + */ + public static function sortByKeyRecursive(array $array): array + { + ksort($array); + foreach ($array as $key => $value) { + if (is_array($value) && !empty($value)) { + $array[$key] = self::sortByKeyRecursive($value); + } + } + return $array; + } + + /** + * Sort an array of arrays by a given key using uasort + * + * @param array $arrays Array of arrays to sort + * @param string $key Key to sort after + * @param bool $ascending Set to TRUE for ascending order, FALSE for descending order + * @return array Array of sorted arrays + * @throws \RuntimeException + */ + public static function sortArraysByKey(array $arrays, string $key, bool $ascending = true): array + { + if (empty($arrays)) { + return $arrays; + } + uasort($arrays, static function (array $a, array $b) use ($key, $ascending) { + if (!isset($a[$key], $b[$key])) { + throw new \RuntimeException('The specified sorting key "' . $key . '" is not available in the given array.', 1373727309); + } + if (!is_scalar($a[$key])) { + throw new \RuntimeException(sprintf('The specified sorting key "%s" is not a scalar value, given "%s".', $key, gettype($a[$key])), 1373727310); + } + if (!is_scalar($b[$key])) { + throw new \RuntimeException(sprintf('The specified sorting key "%s" is not a scalar value, given "%s".', $key, gettype($b[$key])), 1373727311); + } + return $ascending ? strcasecmp((string)$a[$key], (string)$b[$key]) : strcasecmp((string)$b[$key], (string)$a[$key]); + }); + return $arrays; + } + + /** + * Exports an array as string. + * Similar to var_export(), but representation follows the PSR-2 and TYPO3 core CGL. + * + * See unit tests for detailed examples + * + * @param array $array Array to export + * @param int $level Internal level used for recursion, do *not* set from outside! + * @return string String representation of array + * @throws \RuntimeException + */ + public static function arrayExport(array $array = [], int $level = 0): string + { + $lines = "[\n"; + $level++; + $writeKeyIndex = false; + $expectedKeyIndex = 0; + foreach ($array as $key => $value) { + if ($key === $expectedKeyIndex) { + $expectedKeyIndex++; + } else { + // Found a non-integer or non-consecutive key, so we can break here + $writeKeyIndex = true; + break; + } + } + foreach ($array as $key => $value) { + // Indention + $lines .= str_repeat(' ', $level); + if ($writeKeyIndex) { + // Numeric / string keys + $lines .= is_int($key) ? $key . ' => ' : '\'' . $key . '\' => '; + } + if (is_array($value)) { + if (!empty($value)) { + $lines .= self::arrayExport($value, $level); + } else { + $lines .= "[],\n"; + } + } elseif (is_int($value) || is_float($value)) { + $lines .= $value . ",\n"; + } elseif ($value === null) { + $lines .= "null,\n"; + } elseif (is_bool($value)) { + $lines .= $value ? 'true' : 'false'; + $lines .= ",\n"; + } elseif (is_string($value)) { + // Quote \ to \\ + // Quote ' to \' + $stringContent = str_replace(['\\', '\''], ['\\\\', '\\\''], $value); + $lines .= '\'' . $stringContent . "',\n"; + } else { + throw new \RuntimeException('Objects are not supported', 1342294987); + } + } + $lines .= str_repeat(' ', $level - 1) . ']' . ($level - 1 == 0 ? '' : ",\n"); + return $lines; + } + + /** + * Converts a multidimensional array to a flat representation. + * @todo: The current implementation isn't a generic array flatten method, but tailored for TypoScript flattening + * @todo: It should be deprecated and removed and the required specialities should be put under the domain of TypoScript parsing + * + * See unit tests for more details + * + * Example: + * - array: + * array( + * 'first.' => array( + * 'second' => 1 + * ) + * ) + * - result: + * array( + * 'first.second' => 1 + * ) + * + * Example: + * - array: + * array( + * 'first' => array( + * 'second' => 1 + * ) + * ) + * - result: + * array( + * 'first.second' => 1 + * ) + * + * @param array $array The (relative) array to be converted + * @param string $prefix The (relative) prefix to be used (e.g. 'section.') + * @param bool $keepDots + */ + public static function flatten(array $array, string $prefix = '', bool $keepDots = false): array + { + $flatArray = []; + foreach ($array as $key => $value) { + if ($keepDots === false) { + // Ensure there is no trailing dot: + $key = rtrim((string)$key, '.'); + } + if (!is_array($value)) { + $flatArray[$prefix . $key] = $value; + } else { + $newPrefix = $prefix . $key; + if ($keepDots === false) { + $newPrefix = $prefix . $key . '.'; + } + $flatArray = array_merge($flatArray, self::flatten($value, $newPrefix, $keepDots)); + } + } + return $flatArray; + } + + /** + * Just like flatten, but not tailored for TypoScript but for plain simple arrays + * It is internal for now, as it needs to be decided how to deprecate/ rename flatten + * + * @internal + */ + public static function flattenPlain(array $array): array + { + $flattenRecursive = static function (array $array, string $prefix = '') use (&$flattenRecursive) { + $flatArray = []; + foreach ($array as $key => $value) { + $key = addcslashes((string)$key, '.'); + if (!is_array($value)) { + $flatArray[] = [$prefix . $key => $value]; + } else { + $flatArray[] = $flattenRecursive($value, $prefix . $key . '.'); + } + } + + return array_merge(...$flatArray); + }; + + return $flattenRecursive($array); + } + + /** + * Converts a flat representation of an array to a multidimensional array. + * + * Example: + * - array: + * array( + * 'first.second' => 1 + * ) + * + * - result: + * array( + * 'first.' => array( + * 'second' => 1 + * ) + * ) + * + * @param array<string, mixed> $input + * @param string $delimiter + * @return array<string, mixed> + */ + public static function unflatten(array $input, string $delimiter = '.'): array + { + $output = []; + foreach ($input as $key => $value) { + $parts = StringUtility::explodeEscaped($delimiter, $key); + $nested = &$output; + while (count($parts) > 1) { + $nested = &$nested[array_shift($parts)]; + if (!is_array($nested)) { + $nested = []; + } + } + $nested[array_shift($parts)] = $value; + } + return $output; + } + + /** + * Determine the intersections between two arrays, recursively comparing keys + * A complete sub array of $source will be preserved, if the key exists in $mask. + * + * See unit tests for more examples and edge cases. + * + * Example: + * - source: + * array( + * 'key1' => 'bar', + * 'key2' => array( + * 'subkey1' => 'sub1', + * 'subkey2' => 'sub2', + * ), + * 'key3' => 'baz', + * ) + * - mask: + * array( + * 'key1' => NULL, + * 'key2' => array( + * 'subkey1' => exists', + * ), + * ) + * - return: + * array( + * 'key1' => 'bar', + * 'key2' => array( + * 'subkey1' => 'sub1', + * ), + * ) + * + * @param array $source Source array + * @param array $mask Array that has the keys which should be kept in the source array + * @return array Keys which are present in both arrays with values of the source array + */ + public static function intersectRecursive(array $source, array $mask = []): array + { + $intersection = []; + foreach ($source as $key => $_) { + if (!array_key_exists($key, $mask)) { + continue; + } + if (is_array($source[$key]) && is_array($mask[$key])) { + $value = self::intersectRecursive($source[$key], $mask[$key]); + if (!empty($value)) { + $intersection[$key] = $value; + } + } else { + $intersection[$key] = $source[$key]; + } + } + return $intersection; + } + + /** + * Renumber the keys of an array to avoid leaps if keys are all numeric. + * + * Is called recursively for nested arrays. + * + * Example: + * + * Given + * array(0 => 'Zero' 1 => 'One', 2 => 'Two', 4 => 'Three') + * as input, it will return + * array(0 => 'Zero' 1 => 'One', 2 => 'Two', 3 => 'Three') + * + * Will treat keys string representations of number (ie. '1') equal to the + * numeric value (ie. 1). + * + * Example: + * Given + * array('0' => 'Zero', '1' => 'One' ) + * it will return + * array(0 => 'Zero', 1 => 'One') + * + * @param array $array Input array + * @param int $level Internal level used for recursion, do *not* set from outside! + */ + public static function renumberKeysToAvoidLeapsIfKeysAreAllNumeric(array $array = [], int $level = 0): array + { + $level++; + $allKeysAreNumeric = true; + foreach ($array as $key => $_) { + if (is_int($key) === false) { + $allKeysAreNumeric = false; + break; + } + } + $renumberedArray = $array; + if ($allKeysAreNumeric === true) { + $renumberedArray = array_values($array); + } + foreach ($renumberedArray as $key => $value) { + if (is_array($value)) { + $renumberedArray[$key] = self::renumberKeysToAvoidLeapsIfKeysAreAllNumeric($value, $level); + } + } + return $renumberedArray; + } + + /** + * Merges two arrays recursively and "binary safe" (integer keys are + * overridden as well), overruling similar values in the original array + * with the values of the overrule array. + * In case of identical keys, ie. keeping the values of the overrule array. + * + * This method takes the original array by reference for speed optimization with large arrays + * + * The differences to the existing PHP function array_merge_recursive() are: + * * Keys of the original array can be unset via the overrule array. ($enableUnsetFeature) + * * Much more control over what is actually merged. ($addKeys, $includeEmptyValues) + * * Elements or the original array get overwritten if the same key is present in the overrule array. + * + * @param array $original Original array. It will be *modified* by this method and contains the result afterwards! + * @param array $overrule Overrule array, overruling the original array + * @param bool $addKeys If set to FALSE, keys that are NOT found in $original will not be set. Thus only existing value can/will be overruled from overrule array. + * @param bool $includeEmptyValues If set, values from $overrule will overrule if they are empty or zero. + * @param bool $enableUnsetFeature If set, special values "__UNSET" can be used in the overrule array in order to unset array keys in the original array. + */ + public static function mergeRecursiveWithOverrule(array &$original, array $overrule, bool $addKeys = true, bool $includeEmptyValues = true, bool $enableUnsetFeature = true): void + { + foreach ($overrule as $key => $_) { + if ($enableUnsetFeature && $overrule[$key] === '__UNSET') { + unset($original[$key]); + continue; + } + if (isset($original[$key]) && is_array($original[$key])) { + if (is_array($overrule[$key])) { + self::mergeRecursiveWithOverrule($original[$key], $overrule[$key], $addKeys, $includeEmptyValues, $enableUnsetFeature); + } + } elseif ( + ($addKeys || isset($original[$key])) + && ($includeEmptyValues || $overrule[$key]) + ) { + $original[$key] = $overrule[$key]; + } + } + // This line is kept for backward compatibility reasons. + reset($original); + } + + /** + * Removes the value $cmpValue from the $array if found there. Returns the modified array + * + * @param array $array Array containing the values + * @param string $cmpValue Value to search for and if found remove array entry where found. + * @return array Output array with entries removed if search string is found + */ + public static function removeArrayEntryByValue(array $array, string $cmpValue): array + { + foreach ($array as $k => $v) { + if (is_array($v)) { + $array[$k] = self::removeArrayEntryByValue($v, $cmpValue); + } elseif ((string)$v === $cmpValue) { + unset($array[$k]); + } + } + return $array; + } + + /** + * Filters an array to reduce its elements to match the condition. + * The values in $keepItems can be optionally evaluated by a custom callback function. + * + * Example (arguments used to call this function): + * + * ``` + * $array = array( + * array('aa' => array('first', 'second'), + * array('bb' => array('third', 'fourth'), + * array('cc' => array('fifth', 'sixth'), + * ); + * $keepItems = array('third'); + * $getValueFunc = function($value) { return $value[0]; } + * ``` + * + * Returns: + * + * ``` + * array( + * array('bb' => array('third', 'fourth'), + * ) + * ``` + * + * @param array $array $array The initial array to be filtered/reduced + * @param array|string|null $keepItems The items which are allowed/kept in the array - accepts array or csv string + * @param callable|null $getValueFunc (optional) Callback function used to get the value to keep + * @return array The filtered/reduced array with the kept items + */ + public static function keepItemsInArray(array $array, array|string|null $keepItems, ?callable $getValueFunc = null): array + { + if (empty($array)) { + return $array; + } + + // Convert strings to arrays: + if (is_string($keepItems)) { + $keepItems = GeneralUtility::trimExplode(',', $keepItems); + } + + if (empty($keepItems)) { + return $array; + } + + // Check if valueFunc can be executed: + if (!is_callable($getValueFunc)) { + $getValueFunc = null; + } + // Do the filtering: + $keepItems = array_flip($keepItems); + foreach ($array as $key => $value) { + // Get the value to compare by using the callback function: + $keepValue = isset($getValueFunc) ? $getValueFunc($value) : $value; + if (!isset($keepItems[$keepValue])) { + unset($array[$key]); + } + } + + return $array; + } + + /** + * Rename Array keys with a given mapping table + * + * @param array $array Array by reference which should be remapped + * @param array $mappingTable Array with remap information, array/$oldKey => $newKey) + */ + public static function remapArrayKeys(array &$array, array $mappingTable): void + { + foreach ($mappingTable as $old => $new) { + if ($new && isset($array[$old])) { + $array[$new] = $array[$old]; + unset($array[$old]); + } + } + } + + /** + * Filters keys off from first array that also exist in second array. Comparison is done by keys. + * This method is a recursive version of php array_diff_key() + * + * @param array $array1 Source array + * @param array $array2 Reduce source array by this array + * @return array Source array reduced by keys also present in second array + */ + public static function arrayDiffKeyRecursive(array $array1, array $array2): array + { + $differenceArray = []; + foreach ($array1 as $key => $value) { + if (!array_key_exists($key, $array2)) { + $differenceArray[$key] = $value; + } elseif (is_array($value)) { + if (is_array($array2[$key])) { + $recursiveResult = self::arrayDiffKeyRecursive($value, $array2[$key]); + if (!empty($recursiveResult)) { + $differenceArray[$key] = $recursiveResult; + } + } + } + } + return $differenceArray; + } + + /** + * Filters values off from first array that also exist in second array. Comparison is done by keys. + * This method is a recursive version of php array_diff_assoc() + * + * @param array $array1 Source array + * @param array $array2 Reduce source array by this array + * @return array Source array reduced by values also present in second array, indexed by key + */ + public static function arrayDiffAssocRecursive(array $array1, array $array2): array + { + $differenceArray = []; + foreach ($array1 as $key => $value) { + if (!array_key_exists($key, $array2) || (!is_array($value) && $value !== $array2[$key])) { + $differenceArray[$key] = $value; + } elseif (is_array($value)) { + if (is_array($array2[$key])) { + $recursiveResult = self::arrayDiffAssocRecursive($value, $array2[$key]); + if (!empty($recursiveResult)) { + $differenceArray[$key] = $recursiveResult; + } + } + } + } + return $differenceArray; + } + + /** + * Sorts an array by key recursive - uses natural sort order (aAbB-zZ) + * + * @param array $array array to be sorted recursively, passed by reference + * @return bool always TRUE + */ + public static function naturalKeySortRecursive(array &$array): bool + { + uksort($array, 'strnatcasecmp'); + foreach ($array as &$value) { + if (is_array($value)) { + self::naturalKeySortRecursive($value); + } + } + + return true; + } + + /** + * Takes a TypoScript array as input and returns an array which contains all integer properties found which had a value (not only properties). The output array will be sorted numerically. + * + * @param array $setupArr TypoScript array with numerical array in + * @param bool $acceptAnyKeys If set, then a value is not required - the properties alone will be enough. + * @return array An array with all integer properties listed in numeric order. + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::cObjGet() + * @see \TYPO3\CMS\Frontend\Imaging\GifBuilder + */ + public static function filterAndSortByNumericKeys(array $setupArr, bool $acceptAnyKeys = false): array + { + $filteredKeys = []; + $keys = array_keys($setupArr); + foreach ($keys as $key) { + if ($acceptAnyKeys || MathUtility::canBeInterpretedAsInteger($key)) { + $filteredKeys[] = (int)$key; + } + } + $filteredKeys = array_unique($filteredKeys); + sort($filteredKeys); + return $filteredKeys; + } + + /** + * If the array contains numerical keys only, sort it in ascending order + */ + public static function sortArrayWithIntegerKeys(array $array): array + { + // Can't use array_is_list() because an all-integers but non-sequential + // array is not a list, but can still be numerically sorted. + if (count(array_filter(array_keys($array), is_string(...))) === 0) { + ksort($array); + } + return $array; + } + + /** + * Sort keys from the current nesting level if all keys within the + * current nesting level are integers. + */ + public static function sortArrayWithIntegerKeysRecursive(array $array): array + { + $array = static::sortArrayWithIntegerKeys($array); + foreach ($array as $key => $value) { + if (is_array($value) && !empty($value)) { + $array[$key] = self::sortArrayWithIntegerKeysRecursive($value); + } + } + return $array; + } + + /** + * Recursively translate values. + */ + public static function stripTagsFromValuesRecursive(array $array): array + { + $result = $array; + foreach ($result as $key => $value) { + if (is_array($value)) { + $result[$key] = self::stripTagsFromValuesRecursive($value); + } elseif (is_string($value) || (is_object($value) && method_exists($value, '__toString'))) { + $result[$key] = strip_tags((string)$value); + } + } + return $result; + } + + /** + * Recursively filter an array + * + * Example: + * filterRecursive( + * ['a' => ['b' => null]], + * static fn ($item) => $item !== null, + * ARRAY_FILTER_USE_BOTH + * ) + * + * @param 0|ARRAY_FILTER_USE_KEY|ARRAY_FILTER_USE_BOTH $mode + * @see https://www.php.net/manual/en/function.array-filter.php + */ + public static function filterRecursive(array $array, ?callable $callback = null, int $mode = 0): array + { + $callback ??= static fn($value) => (bool)$value; + + foreach ($array as $key => $value) { + if (is_array($value)) { + $array[$key] = self::filterRecursive($value, $callback, $mode); + } + } + return array_filter($array, $callback, $mode); + } + + /** + * Check whether the array has non-integer keys. If there is at least one string key, $array will be + * regarded as an associative array. + * + * @return bool True in case a string key was found. + * @internal + */ + public static function isAssociative(array $array): bool + { + return !array_is_list($array); + } + + /** + * Same as array_replace_recursive except that when in simple arrays (= YAML lists), the entries are + * appended (array_merge). The second array takes precedence in case of equal sub arrays. + * + * @internal + */ + public static function replaceAndAppendScalarValuesRecursive(array $array1, array $array2): array + { + // Simple lists get merged / added up + if (array_is_list($array1)) { + return array_merge($array1, $array2); + } + foreach ($array1 as $k => $v) { + // The key also exists in second array, if it is a simple value + // then $array2 will override the value, where an array is calling + // replaceAndAppendScalarValuesRecursive() recursively. + if (isset($array2[$k])) { + if (is_array($v) && is_array($array2[$k])) { + $array1[$k] = self::replaceAndAppendScalarValuesRecursive($v, $array2[$k]); + } else { + $array1[$k] = $array2[$k]; + } + unset($array2[$k]); + } + } + // If there are properties in the second array left, they are added up + if (!empty($array2)) { + foreach ($array2 as $k => $v) { + $array1[$k] = $v; + } + } + + return $array1; + } + + /** + * Determines whether all (nested) array values are scalar values or `null`. + */ + public static function containsOnlyScalarValues(array $array): bool + { + foreach ($array as $value) { + if (is_array($value)) { + if (!self::containsOnlyScalarValues($value)) { + return false; + } + } elseif (!is_scalar($value) && $value !== null) { + return false; + } + } + return true; + } +} diff --git a/Classes/Utility/ClassNamingUtility.php b/Classes/Utility/ClassNamingUtility.php new file mode 100644 index 0000000..a377a52 --- /dev/null +++ b/Classes/Utility/ClassNamingUtility.php @@ -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\Core\Utility; + +use TYPO3\CMS\Extbase\Persistence\RepositoryInterface; + +/** + * Several functions related to naming and conversions of names + * such as translation between Repository and Model names or + * exploding an objectControllerName into pieces + */ +class ClassNamingUtility +{ + /** + * Translates a model name to an appropriate repository name + * e.g. Tx_Extbase_Domain_Model_Foo to Tx_Extbase_Domain_Repository_FooRepository + * or \TYPO3\CMS\Extbase\Domain\Model\Foo to \TYPO3\CMS\Extbase\Domain\Repository\FooRepository + */ + public static function translateModelNameToRepositoryName(string $modelName): string + { + return str_replace( + '\\Domain\\Model', + '\\Domain\\Repository', + $modelName + ) . 'Repository'; + } + + /** + * Translates a repository name to an appropriate model name + * e.g. Tx_Extbase_Domain_Repository_FooRepository to Tx_Extbase_Domain_Model_Foo + * or \TYPO3\CMS\Extbase\Domain\Repository\FooRepository to \TYPO3\CMS\Extbase\Domain\Model\Foo + * + * @param class-string<RepositoryInterface> $repositoryName + * + * @return class-string + */ + public static function translateRepositoryNameToModelName(string $repositoryName): string + { + return preg_replace( + ['/\\\\Domain\\\\Repository/', '/Repository$/'], + ['\\Domain\\Model', ''], + $repositoryName + ); + } + + /** + * Explodes a controllerObjectName like \Vendor\Ext\Controller\FooController + * into several pieces like vendorName, extensionName, subpackageKey and controllerName + * + * @param string $controllerObjectName The controller name to be exploded + * @return array<string> An array of controllerObjectName pieces + */ + public static function explodeObjectControllerName(string $controllerObjectName): array + { + $matches = []; + $extensionName = str_starts_with($controllerObjectName, 'TYPO3\\CMS') + ? '^(?P<vendorName>[^\\\\]+\\\[^\\\\]+)\\\(?P<extensionName>[^\\\\]+)' + : '^(?P<vendorName>[^\\\\]+)\\\\(?P<extensionName>[^\\\\]+)'; + preg_match( + '/' . $extensionName . '\\\\(Controller|Command|(?P<subpackageKey>.+)\\\\Controller)\\\\(?P<controllerName>[a-z\\\\]+)Controller$/ix', + $controllerObjectName, + $matches + ); + return array_filter($matches, is_string(...), ARRAY_FILTER_USE_KEY); + } +} diff --git a/Classes/Utility/CommandUtility.php b/Classes/Utility/CommandUtility.php new file mode 100644 index 0000000..fd9ee73 --- /dev/null +++ b/Classes/Utility/CommandUtility.php @@ -0,0 +1,549 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Exception\RuntimeException; +use Symfony\Component\Process\Process; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Log\LogManager; + +/** + * Class to handle system commands. + * finds executables (programs) on Unix and Windows without knowing where they are + * + * returns exec command for a program + * or FALSE + * + * This class is meant to be used without instance: + * + * ``` + * $cmd = CommandUtility::getCommand ('awstats','perl'); + * ``` + * + * The data of this class is cached. + * That means if a program is found once it don't have to be searched again. + * + * user functions: + * + * addPaths() could be used to extend the search paths + * getCommand() get a command string + * checkCommand() returns TRUE if a command is available + * + * Search paths that are included: + * $TYPO3_CONF_VARS['GFX']['processor_path'] + * $TYPO3_CONF_VARS['SYS']['binPath'] + * $GLOBALS['_SERVER']['PATH'] + * '/usr/bin/,/usr/local/bin/' on Unix + * + * binaries can be preconfigured with + * $TYPO3_CONF_VARS['SYS']['binSetup'] + */ +class CommandUtility +{ + /** + * Tells if object is already initialized + */ + protected static bool $initialized = false; + + /** + * Contains application list. This is an array with the following structure: + * - app => file name to the application (like 'tar' or 'bzip2') + * - path => full path to the application without application name (like '/usr/bin/' for '/usr/bin/tar') + * - valid => TRUE or FALSE + * Array key is identical to 'app'. + * + * @var array<string, array{app: string, path: string, valid: bool}> + */ + protected static array $applications = []; + + /** + * Paths where to search for applications + * + * The key is a path. The value is either the same path, or false if the path is not valid. + * + * @var array<string, string|false>|null + */ + protected static ?array $paths = null; + + /** + * Execute a shell command. + * + * Needs to be central to have better control and possible fix for issues. Is a wrapper for Symfony's Process + * component. + * + * @see Process + */ + public static function exec(string|array $command, ?array &$output = null, int &$returnValue = 0, ?float $timeout = 60): string|false + { + if (is_string($command)) { + $process = Process::fromShellCommandline($command, null, null, null, $timeout); + } else { + $process = new Process($command, null, null, null, $timeout); + } + + try { + $returnValue = $process->run(); + } catch (RuntimeException $runtimeException) { + self::getLogger()->warning('Executing command "{command}" failed.', [ + 'command' => $command, + 'exception' => $runtimeException, + ]); + + return false; + } + + $processOutput = $process->getOutput(); + if (str_ends_with($processOutput, PHP_EOL)) { + // Last \n is ignored by PHP exec(): https://github.com/php/php-src/blob/b675db4c56dd0de4ea1f5195d587ed90f0096ed8/ext/standard/exec.c#L148 + $processOutput = substr($processOutput, 0, -1); + } + $output = explode(PHP_EOL, $processOutput); + + return rtrim(strrchr($processOutput, PHP_EOL) ?: $processOutput); + + } + + /** + * Compile the command for running ImageMagick/GraphicsMagick. + * + * @param string $command Command to be run: identify, convert or combine/composite + * @param string $parameters The parameters string + * @param string $path Override the default path (e.g. used by the install tool) + * @return string Compiled command that deals with ImageMagick & GraphicsMagick + */ + public static function imageMagickCommand(string $command, string $parameters, string $path = ''): string + { + $gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX']; + $isExt = Environment::isWindows() ? '.exe' : ''; + if (!$path) { + $path = (string)($gfxConf['processor_path'] ?? ''); + } + $path = GeneralUtility::fixWindowsFilePath($path); + // This is only used internally, has no effect outside + if ($command === 'combine') { + $command = 'composite'; + } + // Compile the path & command + if ($gfxConf['processor'] === 'GraphicsMagick') { + $path = self::escapeShellArgument($path . 'gm' . $isExt) . ' ' . self::escapeShellArgument($command); + } else { + if (Environment::isWindows() && !@is_file($path . $command . $isExt)) { + $path = self::escapeShellArgument($path . 'magick' . $isExt) . ' ' . self::escapeShellArgument($command); + } else { + $path = self::escapeShellArgument($path . $command . $isExt); + } + } + // strip profile information for thumbnails and reduce their size + if ($parameters && $command !== 'identify') { + // Use legacy processor_stripColorProfileCommand setting if defined, otherwise + // use the preferred configuration option processor_stripColorProfileParameters + $stripColorProfileCommand = $gfxConf['processor_stripColorProfileCommand'] + ?? implode(' ', array_map(CommandUtility::escapeShellArgument(...), $gfxConf['processor_stripColorProfileParameters'] ?? [])); + // Determine whether the strip profile action has be disabled by TypoScript: + if ($gfxConf['processor_stripColorProfileByDefault'] + && $stripColorProfileCommand !== '' + && $parameters !== '-version' + && !str_contains($parameters, $stripColorProfileCommand) + && !str_contains($parameters, '###SkipStripProfile###') + ) { + $parameters = $stripColorProfileCommand . ' ' . $parameters; + } else { + $parameters = str_replace('###SkipStripProfile###', '', $parameters); + } + + // When converting images that have background transparency, this needs to be not filled, + // but preserved, so that e.g. conversion from SVG into PNG/JPG contains transparency info. + // Without this option, the default background color for conversions is white (https://imagemagick.org/script/command-line-options.php#background) + $parameters = '-background none ' . $parameters; + } + // Add -auto-orient on convert so IM/GM respects the image orient + if ($parameters && $command === 'convert') { + $parameters = '-auto-orient ' . $parameters; + } + // set interlace parameter for convert command + if ($command !== 'identify' && $gfxConf['processor_interlace']) { + $parameters = '-interlace ' . CommandUtility::escapeShellArgument($gfxConf['processor_interlace']) . ' ' . $parameters; + } + $cmdLine = $path . ' ' . $parameters; + // It is needed to change the parameters order when a mask image has been specified + if ($command === 'composite') { + $paramsArr = self::unQuoteFilenames($parameters); + $paramsArrCount = count($paramsArr); + if ($paramsArrCount > 5) { + $tmp = $paramsArr[$paramsArrCount - 3]; + $paramsArr[$paramsArrCount - 3] = $paramsArr[$paramsArrCount - 4]; + $paramsArr[$paramsArrCount - 4] = $tmp; + } + $cmdLine = $path . ' ' . implode(' ', $paramsArr); + } + return $cmdLine; + } + + /** + * Checks if a command is valid or not, updates global variables + * + * @param string $cmd The command that should be executed. eg: "convert" + * @param string $handler Executor for the command. eg: "perl" + * @return bool|int True if the command is valid; False if cmd is not found; -1 if the handler is not found + */ + public static function checkCommand(string $cmd, string $handler = ''): bool|int + { + if (!self::init()) { + return false; + } + + if ($handler !== '' && !self::checkCommand($handler)) { + return -1; + } + // Already checked and valid + if (self::$applications[$cmd]['valid'] ?? false) { + return true; + } + // Is set but was (above) not TRUE + if (isset(self::$applications[$cmd]['valid'])) { + return false; + } + + foreach (self::$paths as $path => $validPath) { + // Ignore invalid (FALSE) paths + if ($validPath) { + if (Environment::isWindows()) { + // Windows OS + // @todo Why is_executable() is not called here? + if (@is_file($path . $cmd)) { + self::$applications[$cmd]['app'] = $cmd; + self::$applications[$cmd]['path'] = $path; + self::$applications[$cmd]['valid'] = true; + return true; + } + if (@is_file($path . $cmd . '.exe')) { + self::$applications[$cmd]['app'] = $cmd . '.exe'; + self::$applications[$cmd]['path'] = $path; + self::$applications[$cmd]['valid'] = true; + return true; + } + } else { + // Unix-like OS + $filePath = realpath($path . $cmd); + if ($filePath && @is_executable($filePath)) { + self::$applications[$cmd]['app'] = $cmd; + self::$applications[$cmd]['path'] = $path; + self::$applications[$cmd]['valid'] = true; + return true; + } + } + } + } + + // Try to get the executable with the command 'which'. + // It does the same like already done, but maybe on other paths + if (!Environment::isWindows()) { + $output = null; + $returnValue = 0; + $cmd = @self::exec('which ' . self::escapeShellArgument($cmd), $output, $returnValue); + + if ($returnValue === 0) { + self::$applications[$cmd]['app'] = $cmd; + self::$applications[$cmd]['path'] = PathUtility::dirname($cmd) . '/'; + self::$applications[$cmd]['valid'] = true; + return true; + } + } + + return false; + } + + /** + * Returns a command string for exec(), system() + * + * @param string $cmd The command that should be executed. eg: "convert" + * @param string $handler Handler (executor) for the command. eg: "perl" + * @param string $handlerOpt Options for the handler, like '-w' for "perl" + * @return string|bool|int Returns command string, or FALSE if cmd is not found, or -1 if the handler is not found + */ + public static function getCommand(string $cmd, string $handler = '', string $handlerOpt = ''): string|bool|int + { + if (!self::init()) { + return false; + } + + // Handler + if ($handler) { + $handler = self::getCommand($handler); + + if (!$handler) { + return -1; + } + $handler .= ' ' . escapeshellcmd($handlerOpt) . ' '; + } + + // Command + if (!self::checkCommand($cmd)) { + return false; + } + $cmd = self::$applications[$cmd]['path'] . self::$applications[$cmd]['app'] . ' '; + + return trim($handler . $cmd); + } + + /** + * Extend the preset paths. This way an extension can install an executable and provide the path to \TYPO3\CMS\Core\Utility\CommandUtility + * + * @param string $paths Comma separated list of extra paths where a command should be searched. Relative paths (without leading "/") are prepend with public web path + */ + public static function addPaths(string $paths): void + { + self::initPaths($paths); + } + + /** + * Returns an array of search paths + * + * @param bool $addInvalid If set the array contains invalid path too. Then the key is the path and the value is empty + * @return array<string, string|false> Array of search paths (empty if exec is disabled) + */ + public static function getPaths(bool $addInvalid = false): array + { + if (!self::init()) { + return []; + } + + return $addInvalid + ? self::$paths + : array_filter(self::$paths); + } + + /** + * Initializes this class + */ + protected static function init(): bool + { + if ($GLOBALS['TYPO3_CONF_VARS']['BE']['disable_exec_function']) { + return false; + } + if (!self::$initialized) { + self::initPaths(); + self::$applications = self::getConfiguredApps(); + self::$initialized = true; + } + return true; + } + + /** + * Initializes and extends the preset paths with own + * + * @param string $paths Comma separated list of extra paths where a command should be searched. Relative paths (without leading "/") are prepend with public web path + */ + protected static function initPaths(string $paths = ''): void + { + $doCheck = false; + + // Init global paths array if not already done + if (!is_array(self::$paths)) { + self::$paths = self::getPathsInternal(); + $doCheck = true; + } + // Merge the submitted paths array to the global + if ($paths) { + $paths = GeneralUtility::trimExplode(',', $paths, true); + foreach ($paths as $path) { + // Make absolute path of relative + if (!str_starts_with($path, '/')) { + $path = Environment::getProjectPath() . '/' . $path; + } + if (!isset(self::$paths[$path])) { + if (@is_dir($path)) { + self::$paths[$path] = $path; + } else { + self::$paths[$path] = false; + } + } + } + } + // Check if new paths are invalid + if ($doCheck) { + foreach (self::$paths as $path => $valid) { + // Ignore invalid (FALSE) paths + if ($valid && !@is_dir($path)) { + self::$paths[$path] = false; + } + } + } + } + + /** + * Processes and returns the paths from $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup'] + * + * @return array<string, array{app: string, path: string, valid: bool}> Array of commands and path + */ + protected static function getConfiguredApps(): array + { + $cmdArr = []; + + if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']) { + $binSetup = str_replace(['\'.chr(10).\'', '\' . LF . \''], LF, $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']); + $pathSetup = preg_split('/[\n,]+/', $binSetup); + foreach ($pathSetup as $val) { + if (trim($val) === '') { + continue; + } + [$cmd, $cmdPath] = GeneralUtility::trimExplode('=', $val, true, 2); + $cmdArr[$cmd]['app'] = PathUtility::basename($cmdPath); + $cmdArr[$cmd]['path'] = PathUtility::dirname($cmdPath) . '/'; + $cmdArr[$cmd]['valid'] = true; + } + } + + return $cmdArr; + } + + /** + * Sets the search paths from different sources, internal + * + * @return array<string, string> Array of absolute paths (keys and values are equal) + */ + protected static function getPathsInternal(): array + { + $pathsArr = []; + $sysPathArr = []; + + // Image magick paths first + if ($imPath = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']) { + $imPath = self::fixPath($imPath); + $pathsArr[$imPath] = $imPath; + } + + // Add configured paths + if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath']) { + $sysPath = GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath'], true); + foreach ($sysPath as $val) { + $val = self::fixPath($val); + $sysPathArr[$val] = $val; + } + } + + // Add path from environment + if (!empty($GLOBALS['_SERVER']['PATH']) || !empty($GLOBALS['_SERVER']['Path'])) { + $sep = Environment::isWindows() ? ';' : ':'; + $serverPath = $GLOBALS['_SERVER']['PATH'] ?? $GLOBALS['_SERVER']['Path']; + $envPath = GeneralUtility::trimExplode($sep, $serverPath, true); + foreach ($envPath as $val) { + $val = self::fixPath($val); + $sysPathArr[$val] = $val; + } + } + + // Set common paths for Unix (only) + if (!Environment::isWindows()) { + $sysPathArr = array_merge($sysPathArr, [ + '/usr/bin/' => '/usr/bin/', + '/usr/local/bin/' => '/usr/local/bin/', + ]); + } + + return array_merge($pathsArr, $sysPathArr); + } + + /** + * Set a path to the right format + * + * @param string $path Input path + * @return string Output path + */ + protected static function fixPath(string $path): string + { + return str_replace('//', '/', $path . '/'); + } + + /** + * Escape shell arguments (for example filenames) to be used on the local system. + * + * The setting UTF8filesystem will be taken into account. + * + * @param string[] $input Input arguments to be escaped + * @return string[] Escaped shell arguments + */ + public static function escapeShellArguments(array $input): array + { + $isUTF8Filesystem = !empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']); + $currentLocale = false; + if ($isUTF8Filesystem) { + if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? false) { + $currentLocale = setlocale(LC_CTYPE, '0'); + setlocale(LC_CTYPE, $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale']); + } + } + + $output = array_map('escapeshellarg', $input); + + if ($isUTF8Filesystem && $currentLocale !== false) { + setlocale(LC_CTYPE, $currentLocale); + } + + return $output; + } + + /** + * Explode a string (normally a list of filenames) with whitespaces by considering quotes in that string. + * + * @param string $parameters The whole parameters string + * @return array Exploded parameters + */ + protected static function unQuoteFilenames(string $parameters): array + { + $paramsArr = explode(' ', trim($parameters)); + // Whenever a quote character (") is found, $quoteActive is set to the element number inside of $params. + // A value of -1 means that there are not open quotes at the current position. + $quoteActive = -1; + foreach ($paramsArr as $k => $v) { + if ($quoteActive > -1) { + $paramsArr[$quoteActive] .= ' ' . $v; + unset($paramsArr[$k]); + if (substr($v, -1) === $paramsArr[$quoteActive][0]) { + $quoteActive = -1; + } + } elseif (!trim($v)) { + // Remove empty elements + unset($paramsArr[$k]); + } elseif (preg_match('/^(["\'])/', $v) && substr($v, -1) !== $v[0]) { + $quoteActive = $k; + } + } + // Return re-indexed array + return array_values($paramsArr); + } + + /** + * Escape a shell argument (for example a filename) to be used on the local system. + * + * The setting UTF8filesystem will be taken into account. + * + * @param string $input Input-argument to be escaped + * @return string Escaped shell argument + */ + public static function escapeShellArgument(string $input): string + { + return self::escapeShellArguments([$input])[0]; + } + + protected static function getLogger(): LoggerInterface + { + return GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); + } +} diff --git a/Classes/Utility/CsvUtility.php b/Classes/Utility/CsvUtility.php new file mode 100644 index 0000000..6b97ac8 --- /dev/null +++ b/Classes/Utility/CsvUtility.php @@ -0,0 +1,175 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use TYPO3\CMS\Core\IO\CsvStreamFilter; + +/** + * Class with helper functions for CSV handling + */ +class CsvUtility +{ + /** + * whether to passthrough data as is, without any modification + */ + public const TYPE_PASSTHROUGH = 0; + + /** + * whether to remove control characters like `=`, `+`, ... + */ + public const TYPE_REMOVE_CONTROLS = 1; + + /** + * whether to prefix control characters like `=`, `+`, ... + * to become `'=`, `'+`, ... + */ + public const TYPE_PREFIX_CONTROLS = 2; + + /** + * Convert a string, formatted as CSV, into a multidimensional array + * + * This cannot be done by str_getcsv, since it's impossible to handle enclosed cells with a line feed in it + * + * @param string $input The CSV input + * @param string $fieldDelimiter The field delimiter + * @param string $fieldEnclosure The field enclosure + * @param int $maximumColumns The maximum amount of columns + */ + public static function csvToArray(string $input, string $fieldDelimiter = ',', string $fieldEnclosure = '"', int $maximumColumns = 0): array + { + $multiArray = []; + $maximumCellCount = 0; + + if (($handle = fopen('php://memory', 'r+')) !== false) { + fwrite($handle, $input); + rewind($handle); + while (($cells = fgetcsv($handle, 0, $fieldDelimiter, $fieldEnclosure, '\\')) !== false) { + $maximumCellCount = max(count($cells), $maximumCellCount); + $multiArray[] = preg_replace('|<br */?>|i', LF, $cells); + } + fclose($handle); + } + + if ($maximumColumns > $maximumCellCount) { + $maximumCellCount = $maximumColumns; + } + + foreach ($multiArray as &$row) { + for ($key = 0; $key < $maximumCellCount; $key++) { + if ( + $maximumColumns > 0 + && $maximumColumns < $maximumCellCount + && $key >= $maximumColumns + ) { + if (isset($row[$key])) { + unset($row[$key]); + } + } elseif (!isset($row[$key])) { + $row[$key] = ''; + } + } + } + + return $multiArray; + } + + /** + * Takes a row and returns a CSV string of the values with $delim (default is ,) and $quote (default is ") as separator chars. + * + * @param string[] $row Input array of values + * @param string $delim Delimited, default is comma + * @param string $quote Quote-character to wrap around the values. + * @param int $type Output behaviour concerning potentially harmful control literals + * @return string A single line of CSV + */ + public static function csvValues(array $row, string $delim = ',', string $quote = '"', int $type = self::TYPE_REMOVE_CONTROLS): string + { + $resource = fopen('php://temp', 'w'); + if (!is_resource($resource)) { + throw new \RuntimeException('Cannot open temporary data stream for writing', 1625556521); + } + $modifier = CsvStreamFilter::applyStreamFilter($resource, false); + array_map(self::assertCellValueType(...), $row); + if ($type === self::TYPE_REMOVE_CONTROLS) { + $row = array_map(self::removeControlLiterals(...), $row); + } elseif ($type === self::TYPE_PREFIX_CONTROLS) { + $row = array_map(self::prefixControlLiterals(...), $row); + } + fputcsv($resource, $modifier($row), $delim, $quote, '\\'); + fseek($resource, 0); + return stream_get_contents($resource); + } + + /** + * Prefixes control literals at the beginning of a cell value with a single quote + * + * (e.g. `=+value` --> `'=+value`) + */ + protected static function prefixControlLiterals(bool|int|float|string|null $cellValue): bool|int|float|string|null + { + if (!self::shallFilterValue($cellValue)) { + return $cellValue; + } + $cellValue = (string)$cellValue; + return preg_replace('#^([\t\v=+*%/@-])#', '\'${1}', $cellValue); + } + + /** + * Removes control literals from the beginning of a cell value + * + * (e.g. `=+value` --> `value`) + */ + protected static function removeControlLiterals(bool|int|float|string|null $cellValue): bool|int|float|string|null + { + if (!self::shallFilterValue($cellValue)) { + return $cellValue; + } + $cellValue = (string)$cellValue; + return preg_replace('#^([\t\v=+*%/@-]+)+#', '', $cellValue); + } + + /** + * Asserts scalar or null types for given cell value. + */ + protected static function assertCellValueType(mixed $cellValue): void + { + // int, float, string, bool, null + if ($cellValue === null || is_scalar($cellValue)) { + return; + } + throw new \RuntimeException( + sprintf('Unexpected type %s for cell value', gettype($cellValue)), + 1625562833 + ); + } + + /** + * Whether cell value shall be filtered. + * + * This applies to everything that is not or cannot be represented + * as boolean, integer or float. + */ + protected static function shallFilterValue(bool|int|float|string|null $cellValue): bool + { + return $cellValue !== null + && !is_bool($cellValue) + && !is_numeric($cellValue) + && !MathUtility::canBeInterpretedAsInteger($cellValue) + && !MathUtility::canBeInterpretedAsFloat($cellValue); + } +} diff --git a/Classes/Utility/DebugUtility.php b/Classes/Utility/DebugUtility.php new file mode 100644 index 0000000..5d9b4d3 --- /dev/null +++ b/Classes/Utility/DebugUtility.php @@ -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\Core\Utility; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Extbase\Utility\DebuggerUtility; + +/** + * Class to handle debug + */ +class DebugUtility +{ + protected static bool $plainTextOutput = true; + + protected static bool $ansiColorUsage = true; + + /** + * Debug + * + * Directly echos out debug information as HTML (or plain in CLI context) + */ + public static function debug(mixed $var = '', string $header = 'Debug'): void + { + // buffer the output of debug if no buffering started before + if (ob_get_level() === 0) { + ob_start(); + } + + echo self::renderDump($var, $header); + } + + /** + * Converts a variable to a string + * + * @return string Plain, not HTML encoded string + */ + public static function convertVariableToString(mixed $variable): string + { + $string = self::renderDump($variable, '', true, false); + return $string === '' ? '| debug |' : $string; + } + + /** + * Displays the "path" of the function call stack in a string, using debug_backtrace + * + * @param bool $prependFileNames If set to true file names are added to the output + * @return string Plain, not HTML encoded string + */ + public static function debugTrail(bool $prependFileNames = false): string + { + $trail = debug_backtrace(0); + $trail = array_reverse($trail); + array_pop($trail); + $path = []; + foreach ($trail as $dat) { + $fileInformation = $prependFileNames && !empty($dat['file']) ? $dat['file'] . ':' : ''; + $pathFragment = $fileInformation . ($dat['class'] ?? '') . ($dat['type'] ?? '') . $dat['function']; + // add the path of the included file + if (in_array($dat['function'], ['require', 'include', 'require_once', 'include_once'])) { + $pathFragment .= '(' . PathUtility::stripPathSitePrefix($dat['args'][0]) . '),' . PathUtility::stripPathSitePrefix($dat['file']); + } + if (array_key_exists('line', $dat)) { + $path[] = $pathFragment . '#' . $dat['line']; + } else { + $path[] = $pathFragment; + } + } + return implode(' // ', $path); + } + + /** + * Returns a string with a list of ascii-values for the first $characters characters in $string + * + * @param string $string String to show ASCII value for + * @param int $characters Number of characters to show + * @return string The string with ASCII values in separated by a space char. + */ + public static function ordinalValue(string $string, int $characters = 100): string + { + if (strlen($string) < $characters) { + $characters = strlen($string); + } + $valuestring = ''; + for ($i = 0; $i < $characters; $i++) { + $valuestring .= ' ' . ord($string[$i]); + } + return trim($valuestring); + } + + /** + * Returns HTML-code, which is a visual representation of a multidimensional array + * use \TYPO3\CMS\Core\Utility\GeneralUtility::print_array() in order to print an array + * Returns FALSE if $array_in is not an array + * + * @param mixed $array_in Array to view + * @return string HTML output + */ + public static function viewArray(mixed $array_in): string + { + return self::renderDump($array_in); + } + + /** + * Renders the dump according to the context, either for command line or as HTML output + * + * @param bool|null $plainText Omit or pass null to use the current default. + * @param bool|null $ansiColors Omit or pass null to use the current default. + */ + protected static function renderDump(mixed $variable, string $title = '', ?bool $plainText = null, ?bool $ansiColors = null): string + { + $plainText = $plainText ?? Environment::isCli() && self::$plainTextOutput; + $ansiColors = $ansiColors ?? Environment::isCli() && self::$ansiColorUsage; + return trim(DebuggerUtility::var_dump($variable, $title, 8, $plainText, $ansiColors, true)); + } + + /** + * Preset plaintext output + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method is usually only used in tests to preset the output behaviour + * + * @internal + */ + public static function usePlainTextOutput(bool $plainTextOutput): void + { + static::$plainTextOutput = $plainTextOutput; + } + + /** + * Preset ansi color usage + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method is usually only used in tests to preset the ansi color usage + * + * @internal + */ + public static function useAnsiColor(bool $ansiColorUsage): void + { + static::$ansiColorUsage = $ansiColorUsage; + } +} diff --git a/Classes/Utility/DiffGranularity.php b/Classes/Utility/DiffGranularity.php new file mode 100644 index 0000000..1da9ee6 --- /dev/null +++ b/Classes/Utility/DiffGranularity.php @@ -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\Core\Utility; + +enum DiffGranularity +{ + // Show the diff "by word" + case WORD; + // Show any diff (like a patch) based on a single character + case CHARACTER; +} diff --git a/Classes/Utility/DiffUtility.php b/Classes/Utility/DiffUtility.php new file mode 100644 index 0000000..692e6f9 --- /dev/null +++ b/Classes/Utility/DiffUtility.php @@ -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\Core\Utility; + +use cogpowered\FineDiff\Diff; +use cogpowered\FineDiff\Granularity\Character; +use cogpowered\FineDiff\Granularity\Word; + +/** + * Helper service to create a diff HTML of two strings. + * It is currently a facade for lolli42/finediff. + */ +readonly class DiffUtility +{ + public function diff(string $from, string $to, DiffGranularity $granularity = DiffGranularity::WORD): string + { + return (new Diff($granularity === DiffGranularity::WORD ? new Word() : new Character()))->render($from, $to); + } +} diff --git a/Classes/Utility/Exception/MissingArrayPathException.php b/Classes/Utility/Exception/MissingArrayPathException.php new file mode 100644 index 0000000..3d0ceb1 --- /dev/null +++ b/Classes/Utility/Exception/MissingArrayPathException.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\Exception; + +/** + * Exception thrown if ArrayUtility::getValueByPath() and + * ArrayUtility::removeByPath() don't find the target path in given array. + * + * Note this extends from \RuntimeException to be backwards compatible with the + * formerly thrown \RuntimeException in the method. + */ +class MissingArrayPathException extends \RuntimeException {} diff --git a/Classes/Utility/Exception/NotImplementedMethodException.php b/Classes/Utility/Exception/NotImplementedMethodException.php new file mode 100644 index 0000000..75685c7 --- /dev/null +++ b/Classes/Utility/Exception/NotImplementedMethodException.php @@ -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\Core\Utility\Exception; + +/** + * Exception thrown if a method is not implemented yet. + * + * Note this extends from \RuntimeException to be backwards compatible with the + * formerly thrown \RuntimeException in the methods. + */ +class NotImplementedMethodException extends \RuntimeException {} diff --git a/Classes/Utility/ExtensionManagementUtility.php b/Classes/Utility/ExtensionManagementUtility.php new file mode 100644 index 0000000..107df16 --- /dev/null +++ b/Classes/Utility/ExtensionManagementUtility.php @@ -0,0 +1,1187 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Package\Exception as PackageException; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Schema\Struct\SelectItem; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Identifier\PackageResourceIdentifier; +use TYPO3\CMS\Core\SystemResource\Identifier\SystemResourceIdentifierFactory; + +/** + * Extension Management functions + * + * This class is never instantiated, rather the methods inside is called as functions like + * \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded('my_extension'); + */ +class ExtensionManagementUtility +{ + protected static PackageManager $packageManager; + private static SystemResourceIdentifierFactory $resourceIdentifierFactory; + + /** + * Sets the package manager for all that backwards compatibility stuff, + * so it doesn't have to be fetched through the bootstrap. + * + * @internal + */ + public static function setPackageManager(PackageManager $packageManager): void + { + static::$packageManager = $packageManager; + self::$resourceIdentifierFactory = new SystemResourceIdentifierFactory($packageManager); + } + + /************************************** + * + * PATHS and other evaluation + * + ***************************************/ + + /** + * Returns TRUE if the extension with extension key $key is loaded. + */ + public static function isLoaded(string $key): bool + { + return static::$packageManager->isPackageActive($key); + } + + /** + * Temporary helper method to resolve system resource paths. + * + * The PackageManager is statically injected to this class already. This + * method will be removed without substitution in TYPO3 15 once + * GeneralUtility::getFileAbsFileName() is removed and usages of it replaced + * using the system resource API. + * + * @throws CanNotResolveSystemResourceIdentifierException + * @throws InvalidSystemResourceIdentifierException + * @internal This method is only allowed to be called from GeneralUtility::getFileAbsFileName()! DONT'T introduce other usages! + */ + public static function resolvePackagePath(string $path): string + { + if (!PathUtility::isExtensionPath($path, true)) { + throw new CanNotResolveSystemResourceIdentifierException(sprintf('"%s" is not a package resource identifier', $path), 1763402850); + } + $packageIdentifier = self::$resourceIdentifierFactory->create($path); + if (!$packageIdentifier instanceof PackageResourceIdentifier) { + // Identifier is of type URI or FAL, which is invalid for path resolving + throw new InvalidSystemResourceIdentifierException(sprintf('"%s" can not be resolved to a valid package resource', $path), 1763402808); + } + if (str_starts_with($packageIdentifier->givenIdentifier, 'PKG:')) { + trigger_error(sprintf('Resolving absolute file system path from a package resource is deprecated and will be removed in TYPO3 v14 LTS (identifier: "%s")', $packageIdentifier->givenIdentifier), E_USER_DEPRECATED); + } + // validity of path is evaluated on resource identifier creation already + return $packageIdentifier->getPackage()->getPackagePath() . $packageIdentifier->getRelativePath(); + } + + /** + * Returns the absolute path to the extension with extension key $key. + * + * @param string $key Extension key + * @param string $script $script is appended to the output if set. + * @throws \BadFunctionCallException + */ + public static function extPath(string $key, string $script = ''): string + { + if (!static::$packageManager->isPackageActive($key)) { + throw new \BadFunctionCallException('TYPO3 Fatal Error: Extension key "' . $key . '" is NOT loaded!', 1365429656); + } + return static::$packageManager->getPackage($key)->getPackagePath() . $script; + } + + /** + * Returns the correct class name prefix for the extension key $key + * + * @param string $key Extension key + * @internal + */ + public static function getCN(string $key): string + { + return str_starts_with($key, 'user_') + ? 'user_' . str_replace('_', '', substr($key, 5)) + : 'tx_' . str_replace('_', '', $key); + } + + /** + * Retrieves the version of an installed extension. + * If the extension is not installed, this function returns an empty string. + * + * @param string $key The key of the extension to look up; must not be empty. + * + * @throws \InvalidArgumentException + * @throws \TYPO3\CMS\Core\Package\Exception + * @return string The extension version as a string in the format "x.y.z", + */ + public static function getExtensionVersion(string $key): string + { + if (empty($key)) { + throw new \InvalidArgumentException('Extension key must be a non-empty string.', 1294586096); + } + if (!static::isLoaded($key)) { + return ''; + } + $version = static::$packageManager->getPackage($key)->getPackageMetaData()->getVersion(); + if (empty($version)) { + throw new PackageException('Version number in composer manifest of package "' . $key . '" is missing or invalid', 1395614959); + } + return $version; + } + + /************************************** + * + * Adding BACKEND features + * (related to core features) + * + ***************************************/ + + /** + * Adding fields to an existing table definition in $GLOBALS['TCA'] + * Adds an array with $GLOBALS['TCA'] column-configuration to the $GLOBALS['TCA']-entry for that table. + * This function adds the configuration needed for rendering of the field in TCEFORMS - but it does NOT add the field names to the types lists! + * So to have the fields displayed you must also call fx. addToAllTCAtypes or manually add the fields to the types list. + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param string $table The table name of a table already present in $GLOBALS['TCA'] with a columns section + * @param array $columnArray The array with the additional columns (typical some fields an extension wants to add) + */ + public static function addTCAcolumns(string $table, array $columnArray): void + { + if (is_array($GLOBALS['TCA'][$table]['columns'] ?? false)) { + // Candidate for array_merge() if integer-keys will some day make trouble... + $GLOBALS['TCA'][$table]['columns'] = array_merge($GLOBALS['TCA'][$table]['columns'], $columnArray); + } + } + + /** + * Makes fields visible in the TCEforms, adding them to the end of (all) "types"-configurations + * + * Adds a string $string (comma separated list of field names) to all ["types"][xxx]["showitem"] entries for table $table (unless limited by $typeList) + * This is needed to have new fields shown automatically in the TCEFORMS of a record from $table. + * Typically this function is called after having added new columns (database fields) with the addTCAcolumns function + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param string $table Table name + * @param string $newFieldsString Field list to add. + * @param string $typeList Comma-separated list of specific types to add the field list to. (If empty, all type entries are affected) + * @param string $position Insert fields before (default) or after one, or replace a field + */ + public static function addToAllTCAtypes(string $table, string $newFieldsString, string $typeList = '', string $position = ''): void + { + $newFieldsString = trim($newFieldsString); + if ($newFieldsString === '' || !is_array($GLOBALS['TCA'][$table]['types'] ?? false)) { + return; + } + if ($position !== '') { + [$positionIdentifier, $entityName] = GeneralUtility::trimExplode(':', $position, false, 2); + } else { + $positionIdentifier = ''; + $entityName = ''; + } + $palettesChanged = []; + + foreach ($GLOBALS['TCA'][$table]['types'] as $type => &$typeDetails) { + // skip if we don't want to add the field for this type + if ($typeList !== '' && !GeneralUtility::inList($typeList, $type)) { + continue; + } + // skip if fields were already added + if (!isset($typeDetails['showitem'])) { + continue; + } + + $fieldArray = GeneralUtility::trimExplode(',', $typeDetails['showitem'], true); + if (in_array($newFieldsString, $fieldArray, true)) { + continue; + } + + $fieldExists = false; + $newPosition = ''; + if (is_array($GLOBALS['TCA'][$table]['palettes'] ?? false)) { + // Get the palette names used in current showitem + $paletteCount = preg_match_all('/(?:^|,) # Line start or a comma + (?: + \\s*\\-\\-palette\\-\\-;[^;]*;([^,$]*)| # --palette--;label;paletteName + \\s*\\b[^;,]+\\b(?:;[^;]*;([^;,]+))?[^,]* # field;label;paletteName + )/x', $typeDetails['showitem'], $paletteMatches); + if ($paletteCount > 0) { + $paletteNames = array_filter(array_merge($paletteMatches[1], $paletteMatches[2])); + if (!empty($paletteNames)) { + foreach ($paletteNames as $paletteName) { + if (!isset($GLOBALS['TCA'][$table]['palettes'][$paletteName])) { + continue; + } + $palette = $GLOBALS['TCA'][$table]['palettes'][$paletteName]; + switch ($positionIdentifier) { + case 'after': + case 'before': + if (preg_match('/\\b' . preg_quote($entityName, '/') . '\\b/', $palette['showitem']) > 0 || $entityName === 'palette:' . $paletteName) { + $newPosition = $positionIdentifier . ':--palette--;;' . $paletteName; + } + break; + case 'replace': + // check if fields have been added to palette before + if (isset($palettesChanged[$paletteName])) { + $fieldExists = true; + continue 2; + } + if (preg_match('/\\b' . preg_quote($entityName, '/') . '\\b/', $palette['showitem']) > 0) { + self::addFieldsToPalette($table, $paletteName, $newFieldsString, $position); + // Memorize that we already changed this palette, in case other types also use it + $palettesChanged[$paletteName] = true; + $fieldExists = true; + continue 2; + } + break; + default: + // Intentionally left blank + } + } + } + } + } + if ($fieldExists === false) { + $typeDetails['showitem'] = self::executePositionedStringInsertion( + $typeDetails['showitem'], + $newFieldsString, + $newPosition !== '' ? $newPosition : $position + ); + } + } + unset($typeDetails); + } + + /** + * Adds new fields to all palettes that is defined after an existing field. + * If the field does not have a following palette yet, it's created automatically + * and gets called "generatedFor-$field". + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * See unit tests for more examples and edge cases. + * + * Example: + * + * 'aTable' => array( + * 'types' => array( + * 'aType' => array( + * 'showitem' => 'aField, --palette--;;aPalette', + * ), + * ), + * 'palettes' => array( + * 'aPalette' => array( + * 'showitem' => 'fieldB, fieldC', + * ), + * ), + * ), + * + * Calling addFieldsToAllPalettesOfField('aTable', 'aField', 'newA', 'before: fieldC') results in: + * + * 'aTable' => array( + * 'types' => array( + * 'aType' => array( + * 'showitem' => 'aField, --palette--;;aPalette', + * ), + * ), + * 'palettes' => array( + * 'aPalette' => array( + * 'showitem' => 'fieldB, newA, fieldC', + * ), + * ), + * ), + * + * @param string $table Name of the table + * @param string $field Name of the field that has the palette to be extended + * @param string $addFields Comma-separated list of fields to be added to the palette + * @param string $insertionPosition Insert fields before (default) or after one + */ + public static function addFieldsToAllPalettesOfField(string $table, string $field, string $addFields, string $insertionPosition = ''): void + { + if (!isset($GLOBALS['TCA'][$table]['columns'][$field])) { + return; + } + if (!is_array($GLOBALS['TCA'][$table]['types'])) { + return; + } + + // Iterate through all types and search for the field that defines the palette to be extended + foreach ($GLOBALS['TCA'][$table]['types'] as $typeName => $typeArray) { + // Continue if types has no showitem at all or if requested field is not in it + if (!isset($typeArray['showitem']) || !str_contains($typeArray['showitem'], $field)) { + continue; + } + $fieldArrayWithOptions = GeneralUtility::trimExplode(',', $typeArray['showitem']); + // Find the field we're handling + $newFieldStringArray = []; + foreach ($fieldArrayWithOptions as $fieldNumber => $fieldString) { + $newFieldStringArray[] = $fieldString; + $fieldArray = GeneralUtility::trimExplode(';', $fieldString); + if ($fieldArray[0] !== $field) { + continue; + } + if ( + isset($fieldArrayWithOptions[$fieldNumber + 1]) + && str_starts_with($fieldArrayWithOptions[$fieldNumber + 1], '--palette--') + ) { + // Match for $field and next field is a palette - add fields to this one + $paletteName = GeneralUtility::trimExplode(';', $fieldArrayWithOptions[$fieldNumber + 1]); + $paletteName = $paletteName[2]; + self::addFieldsToPalette($table, $paletteName, $addFields, $insertionPosition); + } else { + // Match for $field but next field is no palette - create a new one + $newPaletteName = 'generatedFor-' . $field; + self::addFieldsToPalette($table, 'generatedFor-' . $field, $addFields, $insertionPosition); + $newFieldStringArray[] = '--palette--;;' . $newPaletteName; + } + } + $GLOBALS['TCA'][$table]['types'][$typeName]['showitem'] = implode(', ', $newFieldStringArray); + } + } + + /** + * Adds new fields to a palette. + * If the palette does not exist yet, it's created automatically. + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param string $table Name of the table + * @param string $palette Name of the palette to be extended + * @param string $addFields Comma-separated list of fields to be added to the palette + * @param string $insertionPosition Insert fields before (default) or after one + */ + public static function addFieldsToPalette(string $table, string $palette, string $addFields, string $insertionPosition = ''): void + { + if (isset($GLOBALS['TCA'][$table])) { + $paletteData = &$GLOBALS['TCA'][$table]['palettes'][$palette]; + // If palette already exists, merge the data: + if (is_array($paletteData)) { + $paletteData['showitem'] = self::executePositionedStringInsertion($paletteData['showitem'], $addFields, $insertionPosition); + } else { + $paletteData['showitem'] = self::removeDuplicatesForInsertion($addFields); + } + } + } + + /** + * Add an item to a select field item list. + * + * Warning: Do not use this method for radio or check types, especially not + * with $relativeToField and $relativePosition parameters. This would shift + * existing database data 'off by one'. + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * As an example, this can be used to add an item to tt_content CType select + * drop-down after the existing 'mailform' field with these parameters: + * - $table = 'tt_content' + * - $field = 'CType' + * - $item = array( + * 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.I.10', + * 'login', + * 'i/imagename.gif', + * ), + * - $relativeToField = mailform + * - $relativePosition = after + * + * $item has an optional fourth parameter for the groupId (string), to attach the + * new item to. The groupname is defined when a group is added with addTcaSelectItemGroup + * + * @throws \InvalidArgumentException If given parameters are not of correct + * @throws \RuntimeException If reference to related position fields can not + * @param string $table Name of TCA table + * @param string $field Name of TCA field + * @param array|SelectItem $item New item to add + * @param string $relativeToField Add item relative to existing field + * @param string $relativePosition Valid keywords: 'before', 'after' + */ + public static function addTcaSelectItem(string $table, string $field, array|SelectItem $item, string $relativeToField = '', string $relativePosition = ''): void + { + $item = $item instanceof SelectItem ? $item->toArray() : $item; + if ($table === 'tt_content' && $field === 'CType' && ($item['group'] ?? null) === null) { + $item['group'] = 'default'; + } + if ($relativePosition !== '' && $relativePosition !== 'before' && $relativePosition !== 'after' && $relativePosition !== 'replace') { + throw new \InvalidArgumentException('Relative position must be either empty or one of "before", "after", "replace".', 1303236967); + } + if (!isset($GLOBALS['TCA'][$table]['columns'][$field]['config']['items']) + || !is_array($GLOBALS['TCA'][$table]['columns'][$field]['config']['items']) + ) { + throw new \RuntimeException('Given select field item list was not found.', 1303237468); + } + // Make sure item keys are integers + $GLOBALS['TCA'][$table]['columns'][$field]['config']['items'] = array_values($GLOBALS['TCA'][$table]['columns'][$field]['config']['items']); + if ($relativePosition !== '') { + // Insert at specified position + $matchedPosition = ArrayUtility::filterByValueRecursive($relativeToField, $GLOBALS['TCA'][$table]['columns'][$field]['config']['items']); + if (!empty($matchedPosition)) { + $relativeItemKey = key($matchedPosition); + if ($relativePosition === 'replace') { + $GLOBALS['TCA'][$table]['columns'][$field]['config']['items'][$relativeItemKey] = $item; + } else { + if ($relativePosition === 'before') { + $offset = $relativeItemKey; + } else { + $offset = $relativeItemKey + 1; + } + array_splice($GLOBALS['TCA'][$table]['columns'][$field]['config']['items'], $offset, 0, [0 => $item]); + } + } else { + // Insert at new item at the end of the array if relative position was not found + $GLOBALS['TCA'][$table]['columns'][$field]['config']['items'][] = $item; + } + } else { + // Insert at new item at the end of the array + $GLOBALS['TCA'][$table]['columns'][$field]['config']['items'][] = $item; + } + } + + /** + * Adds an item group to a TCA select field. Allows to add a group so addTcaSelectItem() + * can add a groupId with a label and its position within other groups. + * + * @param string $table the table name in TCA - e.g. tt_content + * @param string $field the field name in TCA - e.g. CType + * @param string $groupId the unique identifier for a group, where all items from addTcaSelectItem() with a group ID are connected + * @param string $groupLabel the label e.g. LLL:EXT:my_extension/Resources/Private/Language/locallang_tca.xlf:group.mygroupId + * @param string|null $position e.g. "before:special", "after:default" (where the part after the colon is an existing groupId) or "top" or "bottom" + */ + public static function addTcaSelectItemGroup(string $table, string $field, string $groupId, string $groupLabel, ?string $position = 'bottom'): void + { + if (!is_array($GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null)) { + throw new \RuntimeException('Given select field item list was not found.', 1586728563); + } + $itemGroups = $GLOBALS['TCA'][$table]['columns'][$field]['config']['itemGroups'] ?? []; + // Group has been defined already, nothing to do + if (isset($itemGroups[$groupId])) { + return; + } + $position = (string)$position; + $positionGroupId = ''; + if (str_contains($position, ':')) { + [$position, $positionGroupId] = explode(':', $position, 2); + } + // Referenced group was not found, just append to the bottom + if (($position === 'before' || $position === 'after') && !isset($itemGroups[$positionGroupId])) { + $position = 'bottom'; + } + switch ($position) { + case 'after': + $newItemGroups = []; + foreach ($itemGroups as $existingGroupId => $existingGroupLabel) { + $newItemGroups[$existingGroupId] = $existingGroupLabel; + if ($positionGroupId === $existingGroupId) { + $newItemGroups[$groupId] = $groupLabel; + } + } + $itemGroups = $newItemGroups; + break; + case 'before': + $newItemGroups = []; + foreach ($itemGroups as $existingGroupId => $existingGroupLabel) { + if ($positionGroupId === $existingGroupId) { + $newItemGroups[$groupId] = $groupLabel; + } + $newItemGroups[$existingGroupId] = $existingGroupLabel; + } + $itemGroups = $newItemGroups; + break; + case 'top': + $itemGroups = array_merge([$groupId => $groupLabel], $itemGroups); + break; + case 'bottom': + default: + $itemGroups[$groupId] = $groupLabel; + } + $GLOBALS['TCA'][$table]['columns'][$field]['config']['itemGroups'] = $itemGroups; + } + + /** + * Adds a new field to the backend user settings configuration. + * + * The field configuration is stored in TCA at: + * $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'][$fieldName] + * + * FOR USE IN Configuration/TCA/Overrides/be_users.php FILES + * + * Example: + * ExtensionManagementUtility::addUserSetting( + * 'myCustomSetting', + * [ + * 'label' => 'LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:myCustomSetting', + * 'config' => [ + * 'type' => 'check', + * 'renderType' => 'checkboxToggle', + * ], + * ], + * 'after:emailMeAtLogin' + * ); + * + * @param string $fieldName The name of the field to add + * @param array $fieldConfiguration The TCA-style field configuration (label, config, etc.) + * @param string $insertionPosition Insert field before (default) or after an existing field (e.g., 'after:email') + */ + public static function addUserSetting(string $fieldName, array $fieldConfiguration, string $insertionPosition = ''): void + { + if (!isset($GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'])) { + $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] = []; + } + $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'][$fieldName] = $fieldConfiguration; + + // Add to showitem + $currentShowitem = $GLOBALS['TCA']['be_users']['columns']['user_settings']['showitem'] ?? ''; + $GLOBALS['TCA']['be_users']['columns']['user_settings']['showitem'] = self::executePositionedStringInsertion( + $currentShowitem, + $fieldName, + $insertionPosition + ); + } + + /** + * Inserts as list of data into an existing list. + * The insertion position can be defined accordant before of after existing list items. + * + * Example: + * + list: 'field_a, field_b, field_c' + * + insertionList: 'field_d, field_e' + * + insertionPosition: 'after:field_b' + * -> 'field_a, field_b, field_d, field_e, field_c' + * + * $insertPosition may contain ; and - characters: after:--palette--;;title + * + * @param string $list The list of items to be extended + * @param string $insertionList The list of items to inserted + * @param string $insertionPosition Insert fields before (default) or after one + * @return string The extended list + */ + protected static function executePositionedStringInsertion(string $list, string $insertionList, string $insertionPosition = ''): string + { + $list = trim($list, ", \t\n\r\0\x0B"); + + if ($insertionPosition !== '') { + [$location, $positionName] = GeneralUtility::trimExplode(':', $insertionPosition, false, 2); + } else { + $location = ''; + $positionName = ''; + } + + if ($location !== 'replace') { + $insertionList = self::removeDuplicatesForInsertion($insertionList, $list); + } + + if ($insertionList === '') { + return $list; + } + if ($list === '') { + return $insertionList; + } + if ($insertionPosition === '') { + return $list . ', ' . $insertionList; + } + + // The $insertPosition may be a palette: after:--palette--;;title + // In the $list the palette may contain a LLL string in between the ;; + // Adjust the regex to match that + $positionName = preg_quote($positionName, '/'); + if (str_contains($positionName, ';;')) { + $positionName = str_replace(';;', ';[^;]*;', $positionName); + } + + $pattern = ('/(^|,\\s*)(' . $positionName . ')(;[^,$]+)?(,|$)/'); + $newList = match ($location) { + 'after' => preg_replace($pattern, '$1$2$3, ' . $insertionList . '$4', $list), + 'before' => preg_replace($pattern, '$1' . $insertionList . ', $2$3$4', $list), + 'replace' => preg_replace($pattern, '$1' . $insertionList . '$4', $list), + default => $list, + }; + + // When preg_replace did not replace anything; append the $insertionList. + if ($newList === $list) { + return $list . ', ' . $insertionList; + } + return $newList; + } + + /** + * Compares an existing list of items and a list of items to be inserted + * and returns a duplicate-free variant of that insertion list. + * + * Example: + * + list: 'field_a, field_b, field_c' + * + insertion: 'field_b, field_d, field_c' + * -> new insertion: 'field_d' + * + * Duplicate values in $insertionList are removed. + * + * @param string $insertionList The comma-separated list of items to inserted + * @param string $list The comma-separated list of items to be extended + * @return string Duplicate-free list of items to be inserted + */ + protected static function removeDuplicatesForInsertion(string $insertionList, string $list = ''): string + { + $insertionListParts = preg_split('/\\s*,\\s*/', $insertionList); + $listMatches = []; + if ($list !== '') { + preg_match_all('/(?:^|,)\\s*\\b([^;,]+)\\b[^,]*/', $list, $listMatches); + $listMatches = $listMatches[1]; + } + + $cleanInsertionListParts = []; + foreach ($insertionListParts as $fieldName) { + $fieldNameParts = explode(';', $fieldName, 2); + $cleanFieldName = $fieldNameParts[0]; + if ( + $cleanFieldName === '--linebreak--' + || ( + !in_array($cleanFieldName, $cleanInsertionListParts, true) + && !in_array($cleanFieldName, $listMatches, true) + ) + ) { + $cleanInsertionListParts[] = $fieldName; + } + } + return implode(', ', $cleanInsertionListParts); + } + + /************************************** + * + * Adding SERVICES features + * + ***************************************/ + /** + * Adds a service to the global services array + * + * @param string $extKey Extension key + * @param string $serviceType Service type, must not be prefixed "tx_" or "Tx_" + * @param string $serviceKey Service key, must be prefixed "tx_", "Tx_" or "user_" + * @param array $info Service description array + */ + public static function addService(string $extKey, string $serviceType, string $serviceKey, array $info): void + { + if (!$serviceType) { + throw new \InvalidArgumentException('No serviceType given.', 1507321535); + } + $info['priority'] = max(0, min(100, $info['priority'])); + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey] = $info; + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['extKey'] = $extKey; + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['serviceKey'] = $serviceKey; + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['serviceType'] = $serviceType; + // Change the priority (and other values) from $GLOBALS['TYPO3_CONF_VARS'] + // $GLOBALS['TYPO3_CONF_VARS']['T3_SERVICES'][$serviceType][$serviceKey]['priority'] + // even the activation is possible (a unix service might be possible on windows for some reasons) + if (is_array($GLOBALS['TYPO3_CONF_VARS']['T3_SERVICES'][$serviceType][$serviceKey] ?? false)) { + // No check is done here - there might be configuration values only the service type knows about, so + // we pass everything + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey] = array_merge($GLOBALS['T3_SERVICES'][$serviceType][$serviceKey], $GLOBALS['TYPO3_CONF_VARS']['T3_SERVICES'][$serviceType][$serviceKey]); + } + // OS check + // Empty $os means 'not limited to one OS', therefore a check is not needed + if (!empty($GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['available']) && ($GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['os'] ?? '') != '') { + $os_type = Environment::isWindows() ? 'WIN' : 'UNIX'; + $os = GeneralUtility::trimExplode(',', strtoupper($GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['os'])); + if (!in_array($os_type, $os, true)) { + self::deactivateService($serviceType, $serviceKey); + } + } + // Convert subtype list to array for quicker access + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['serviceSubTypes'] = []; + $serviceSubTypes = GeneralUtility::trimExplode(',', $info['subtype']); + foreach ($serviceSubTypes as $subtype) { + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['serviceSubTypes'][$subtype] = $subtype; + } + } + + /** + * Find the available service with highest priority + * + * @param string $serviceType Service type + * @param string $serviceSubType Service sub type + * @param array $excludeServiceKeys Service keys that should be excluded in the search for a service. + * @return array|false Service info array if a service was found, FALSE otherwise + */ + public static function findService(string $serviceType, string $serviceSubType = '', array $excludeServiceKeys = []): array|false + { + $serviceKey = false; + $serviceInfo = false; + $priority = 0; + $quality = 0; + if (is_array($GLOBALS['T3_SERVICES'][$serviceType])) { + foreach ($GLOBALS['T3_SERVICES'][$serviceType] as $key => $info) { + if (in_array($key, $excludeServiceKeys)) { + continue; + } + // Select a subtype randomly + // Useful to start a service by service key without knowing his subtypes - for testing purposes + if ($serviceSubType === '*') { + $serviceSubType = key($info['serviceSubTypes']); + } + // This matches empty subtype too + if (($info['available'] ?? false) + && (($info['subtype'] ?? null) == $serviceSubType || ($info['serviceSubTypes'][$serviceSubType] ?? false)) + && ($info['priority'] ?? 0) >= $priority + ) { + // Has a lower quality than the already found, therefore we skip this service + if ($info['priority'] == $priority && $info['quality'] < $quality) { + continue; + } + // Check if the service is available + $info['available'] = self::isServiceAvailable($serviceType, $key, $info); + // Still available after exec check? + if ($info['available']) { + $serviceKey = $key; + $priority = $info['priority']; + $quality = $info['quality']; + } + } + } + } + if ($serviceKey) { + $serviceInfo = $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]; + } + return $serviceInfo; + } + + /** + * Find a specific service identified by its key + * Note that this completely bypasses the notions of priority and quality + * + * @param string $serviceKey Service key + * @return array Service info array if a service was found + * @throws \TYPO3\CMS\Core\Exception + */ + public static function findServiceByKey(string $serviceKey): array + { + if (is_array($GLOBALS['T3_SERVICES'])) { + // Loop on all service types + // NOTE: we don't care about the actual type, we are looking for a specific key + foreach ($GLOBALS['T3_SERVICES'] as $serviceType => $servicesPerType) { + if (isset($servicesPerType[$serviceKey])) { + $serviceDetails = $servicesPerType[$serviceKey]; + // Test if service is available + if (self::isServiceAvailable($serviceType, $serviceKey, $serviceDetails)) { + // We have found the right service, return its information + return $serviceDetails; + } + } + } + } + throw new \TYPO3\CMS\Core\Exception('Service not found for key: ' . $serviceKey, 1319217244); + } + + /** + * Check if a given service is available, based on the executable files it depends on + * + * @param string $serviceType Type of service + * @param string $serviceKey Specific key of the service + * @param array $serviceDetails Information about the service + * @return bool Service availability + */ + public static function isServiceAvailable(string $serviceType, string $serviceKey, array $serviceDetails): bool + { + // If the service depends on external programs - check if they exists + if (trim($serviceDetails['exec'] ?? '')) { + $executables = GeneralUtility::trimExplode(',', $serviceDetails['exec'], true); + foreach ($executables as $executable) { + // If at least one executable file is not available, exit early returning FALSE + if (!CommandUtility::checkCommand($executable)) { + self::deactivateService($serviceType, $serviceKey); + return false; + } + } + } + // The service is available + return true; + } + + /** + * Deactivate a service + * + * @param string $serviceType Service type + * @param string $serviceKey Service key + */ + public static function deactivateService(string $serviceType, string $serviceKey): void + { + // ... maybe it's better to move non-available services to a different array?? + $GLOBALS['T3_SERVICES'][$serviceType][$serviceKey]['available'] = false; + } + + /************************************** + * + * Adding FRONTEND features + * + ***************************************/ + + /** + * Convenience method so you don't have to deal with strings and arrays and $GLOBALS[TCA] directly that much. + * + * Adds a new entry to an existing TCA DB table that has a type field configured (via $TCA[$table][ctrl][type]) + * such as "tt_content" or "pages" tables. + * + * Takes the $item (label, value[, icon] etc.) and adds the item to the items-array of $TCA[$table] + * of the "type" field. The position in the list can be chosen via the $position argument. + * + * In addition, a type-icon gets registered, and, based on the $item[value], the record type is also added + * to $TCA[$table]['types'][$newType], where $showItemList is added as 'showitem' key, as well as $additionalTypeInformation + * such as 'columnsOverride' or 'creationOptions'. + * + * In addition, the $showItemList will receive a 'extended' tab at the very end, so other extensions + * that add additional fields, will receive this at the extended tab automatically. + * + * Can be used in favor of addPlugin() and addTcaSelectItem(). + * + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param array|SelectItem $item The item to add to the select field + * @param string $showItemList A string containing all fields to be used / displayed in this type + * @param array $additionalTypeInformation Additional type information to be added to the type in $TCA[$table]['types'] + * @param string $position The position in the list where the new item should be added, something like "after:textpic" + * @param string $table The table name, defaults to 'tt_content' + */ + public static function addRecordType(array|SelectItem $item, string $showItemList, array $additionalTypeInformation = [], string $position = '', string $table = 'tt_content'): void + { + $selectItem = is_array($item) ? SelectItem::fromTcaItemArray($item) : $item; + $typeField = $GLOBALS['TCA'][$table]['ctrl']['type'] ?? null; + // Throw exception if no type is set + if ($typeField === null) { + throw new \RuntimeException('Cannot add record type "' . $selectItem->getValue() . '" for TCA table "' . $table . '" without type field defined.', 1725997543); + } + // Set the type icon as well + if ($selectItem->getIcon()) { + $GLOBALS['TCA'][$table]['ctrl']['typeicon_classes'][$selectItem->getValue()] = $selectItem->getIcon(); + } + if (!$selectItem->hasGroup()) { + $selectItem = $selectItem->withGroup('default'); + } + + $relativeInformation = GeneralUtility::trimExplode(':', $position, true, 2); + self::addTcaSelectItem($table, $typeField, $selectItem, $relativeInformation[1] ?? '', $relativeInformation[0] ?? ''); + + $showItemList = trim($showItemList, ', '); + // Add the extended tab if not already added manually at the very end. + if ($showItemList !== '' && !str_contains($showItemList, '--div--;core.form.tabs:extended') && !str_contains($showItemList, '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:extended')) { + $showItemList .= ',--div--;core.form.tabs:extended'; + } + if ($showItemList !== '') { + $showItemList .= ','; + } + + $additionalTypeInformation['showitem'] = $showItemList; + $GLOBALS['TCA'][$table]['types'][$selectItem->getValue()] = $additionalTypeInformation; + } + + /** + * This is a helper method to add a new "frontend plugin". It therefore takes the $itemArray (label, value[,icon]) and + * adds to the items-array of $GLOBALS['TCA']['tt_content']['columns']['CType'|. So basically, this method adds + * a new "select item" to the tt_content record type column ("CType"). + * + * Additionally, this registers a given icon for the new record type and adds the plugin to the "plugin" group, + * in case no group is manually specified in the items array. If the value (array pos. 1) is already found in + * that items-array, the entry is substituted, otherwise the input array is added to the bottom. + * + * Finally a basic "showitem" configuration is added for the plugin. However, this should be adjusted by either + * manually defining $GLOBALS['TCA']['tt_content']['types']['my_plugin'|['showitem'] or by calling further + * helper methods, such as {@see ExtensionManagementUtility::addToAllTCAtypes()}. + * + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param array|SelectItem $itemArray Numerical or assoc array: [0 or 'label'] => Plugin label, [1 or 'value'] => Plugin identifier / plugin key, ideally prefixed with an extension-specific name (e.g. "events2_list"), [2 or 'icon'] => Icon identifier or path to plugin icon, [3 or 'group'] => an optional "group" ID, falls back to "plugins" + * @param string $flexForm The flex form (data structure) to be used for the plugin. Either a reference to a flex-form XML file (eg. "FILE:EXT:newloginbox/flexform_ds.xml") or the XML directly. + */ + public static function addPlugin(array|SelectItem $itemArray, string $flexForm = ''): void + { + $selectItem = is_array($itemArray) ? SelectItem::fromTcaItemArray($itemArray) : $itemArray; + if ($selectItem->getIcon() && !isset($GLOBALS['TCA']['tt_content']['ctrl']['typeicon_classes'][$selectItem->getValue()])) { + // Set the type icon as well + $GLOBALS['TCA']['tt_content']['ctrl']['typeicon_classes'][$selectItem->getValue()] = $selectItem->getIcon(); + } + if (!$selectItem->hasGroup()) { + $selectItem = $selectItem->withGroup('plugins'); + } + // Override possible existing entries. + foreach ($GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'] ?? [] as $index => $item) { + if ((string)($item['value'] ?? '') === (string)$selectItem->getValue()) { + $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'][$index] = $selectItem->toArray(); + return; + } + } + $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'][] = $selectItem->toArray(); + + // Ensure to have at least some basic information available when editing the new type in FormEngine + if (!isset($GLOBALS['TCA']['tt_content']['types'][$selectItem->getValue()]) + && isset($GLOBALS['TCA']['tt_content']['types']['header']) + ) { + $GLOBALS['TCA']['tt_content']['types'][$selectItem->getValue()] = $GLOBALS['TCA']['tt_content']['types']['header']; + } + + // Add data structure for the plugin + if ($flexForm !== '') { + $GLOBALS['TCA']['tt_content']['types'][$selectItem->getValue()]['columnsOverrides']['pi_flexform']['config']['ds'] = $flexForm; + // Add flexform to showitem list + self::addToAllTCAtypes( + 'tt_content', + '--div--;core.form.tabs:plugin, pi_flexform', + $selectItem->getValue(), + 'after:palette:headers' + ); + } + } + + /** + * Adds the $table tablename to the list of tables allowed to be includes by content element type "Insert records" + * By using $content_table and $content_field you can also use the function for other tables. + * FOR USE IN files in Configuration/TCA/Overrides/*.php. + * + * @param string $table Table name to allow for "insert record + * @param string $content_table Table name TO WHICH the $table name is applied. See $content_field as well. + * @param string $content_field Field name in the database $content_table in which $table is allowed to be added as a reference ("Insert Record") + */ + public static function addToInsertRecords(string $table, string $content_table = 'tt_content', string $content_field = 'records'): void + { + if (is_array($GLOBALS['TCA'][$content_table]['columns']) && isset($GLOBALS['TCA'][$content_table]['columns'][$content_field]['config']['allowed'])) { + $GLOBALS['TCA'][$content_table]['columns'][$content_field]['config']['allowed'] .= ',' . $table; + } + } + + /** + * Call this method to add an entry in the static template list found in sys_templates + * FOR USE IN Configuration/TCA/Overrides/sys_template.php. + * + * @param string $extKey Is of course the extension key + * @param string $path Is the path where the template files "constants.typoscript", "setup.typoscript", and "include_static_file.txt" + * are found (relative to extPath, eg. "Configuration/TypoScript/Static/"). The file "include_static_file.txt", + * allows including other static templates defined in files, from your static template, and thus corresponds + * to the field 'include_static_file' in the sys_template table. The syntax for this is a comma separated list + * of static templates to include, example: + * EXT:fluid_styled_content/Configuration/TypoScript/,EXT:other_extension/Configuration/TypoScript/ + * @param string $title Is the title in the selector box. + * @throws \InvalidArgumentException + * @see addTypoScript() + */ + public static function addStaticFile(string $extKey, string $path, string $title): void + { + if (!$extKey) { + throw new \InvalidArgumentException('No extension key given.', 1507321291); + } + if (!$path) { + throw new \InvalidArgumentException('No file path given.', 1507321297); + } + if (is_array($GLOBALS['TCA']['sys_template']['columns'])) { + $value = str_replace(',', '', 'EXT:' . $extKey . '/' . $path); + $itemArray = ['label' => trim($title . ' (' . $extKey . ')'), 'value' => $value]; + $GLOBALS['TCA']['sys_template']['columns']['include_static_file']['config']['items'][] = $itemArray; + } + } + + /** + * Call this method to add an entry in the page TSconfig list found in pages + * FOR USE in Configuration/TCA/Overrides/pages.php + * + * @param string $extKey The extension key + * @param string $filePath The path where the TSconfig file is located + * @param string $title The title in the selector box + * @throws \InvalidArgumentException + */ + public static function registerPageTSConfigFile(string $extKey, string $filePath, string $title): void + { + self::registerTsConfig('pages', $extKey, $filePath, $title); + } + + /** + * Call this method to add an entry in the User TSconfig list found in be_users + * FOR USE in Configuration/TCA/Overrides/be_users.php + * + * @param string $extKey The extension key + * @param string $filePath The path where the TSconfig file is located + * @param string $title The title in the selector box + * @throws \InvalidArgumentException + */ + public static function registerUserTSConfigFile(string $extKey, string $filePath, string $title): void + { + self::registerTsConfig('be_users', $extKey, $filePath, $title); + } + + /** + * Call this method to add an entry in the Usergroup TSconfig list found in be_groups + * FOR USE in Configuration/TCA/Overrides/be_groups.php + * + * @param string $extKey The extension key + * @param string $filePath The path where the TSconfig file is located + * @param string $title The title in the selector box + * @throws \InvalidArgumentException + */ + public static function registerUserGroupTSConfigFile(string $extKey, string $filePath, string $title): void + { + self::registerTsConfig('be_groups', $extKey, $filePath, $title); + } + + /** + * Adds $content to the default TypoScript setup code as set in $GLOBALS['TYPO3_CONF_VARS'][FE]['defaultTypoScript_setup']. + * NOT prefixed with a [GLOBAL] line, other calls MUST properly close their conditions! + * FOR USE IN ext_localconf.php FILES + * + * @param string $content TypoScript Setup string + * @param bool $includeInSiteSets + */ + public static function addTypoScriptSetup(string $content, bool $includeInSiteSets = true): void + { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup'] ??= ''; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup'])) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup'] .= LF; + } + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup'] .= $content; + + if ($includeInSiteSets) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup.']['siteSets'] ??= ''; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup.']['siteSets'])) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup.']['siteSets'] .= LF; + } + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_setup.']['siteSets'] .= $content; + } + } + + /** + * Adds $content to the default TypoScript constants code as set in $GLOBALS['TYPO3_CONF_VARS'][FE]['defaultTypoScript_constants'] + * NOT prefixed with a [GLOBAL] line, other calls MUST properly close their conditions! + * FOR USE IN ext_localconf.php FILES + * + * @param string $content TypoScript Constants string + * @param bool $includeInSiteSets + */ + public static function addTypoScriptConstants(string $content, bool $includeInSiteSets = true): void + { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants'] ??= ''; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants'])) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants'] .= LF; + } + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants'] .= $content; + if ($includeInSiteSets) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants.']['siteSets'] ??= ''; + if (!empty($GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants.']['siteSets'])) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants.']['siteSets'] .= LF; + } + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_constants.']['siteSets'] .= $content; + } + } + + /** + * Adds $content to the default TypoScript code for either setup or constants as set in $GLOBALS['TYPO3_CONF_VARS'][FE]['defaultTypoScript_*'] + * (Basically this function can do the same as addTypoScriptSetup and addTypoScriptConstants - just with a little more hazzle, but also with some more options!) + * FOR USE IN ext_localconf.php FILES + * Note: As of TYPO3 CMS 6.2, static template #43 (content: default) was replaced with "defaultContentRendering" which makes it + * possible that a first extension like fluid_styled_content registers a "contentRendering" template (= a template that defines default content rendering TypoScript) + * by adding itself to $TYPO3_CONF_VARS[FE][contentRenderingTemplates][] = 'myext/Configuration/TypoScript'. + * An extension calling addTypoScript('myext', 'setup', $typoScript, 'defaultContentRendering') will add its TypoScript directly after; + * For now, "43" and "defaultContentRendering" can be used, but "defaultContentRendering" is more descriptive and + * should be used in the future. + * + * @param string $key Is the extension key (informative only). + * @param string $type Is either "setup" or "constants" and obviously determines which kind of TypoScript code we are adding. + * @param string $content Is the TS content, will be prefixed with a [GLOBAL] line and a comment-header. + * @param int|string $afterStaticUid string pointing to the "key" of a static_file template ([reduced extension_key]/[local path]). The points is that the TypoScript you add is included only IF that static template is included (and in that case, right after). So effectively the TypoScript you set can specifically overrule settings from those static templates. + * @throws \InvalidArgumentException + */ + public static function addTypoScript(string $key, string $type, string $content, int|string $afterStaticUid = 0, bool $includeInSiteSets = true): void + { + if ($type !== 'setup' && $type !== 'constants') { + throw new \InvalidArgumentException('Argument $type must be set to either "setup" or "constants" when calling addTypoScript from extension "' . $key . '"', 1507321200); + } + $content = ' + +[GLOBAL] +############################################# +## TypoScript added by extension "' . $key . '" +############################################# + +' . $content; + if ($afterStaticUid) { + // If 'defaultContentRendering' is targeted (formerly static uid 43), + // the content is added after TypoScript of type contentRendering, e.g. fluid_styled_content, see + // EXT:core/Classes/TypoScript/IncludeTree/SysTemplateTreeBuilder.php for more information on how the code is parsed. + if ($afterStaticUid === 'defaultContentRendering' || $afterStaticUid == 43) { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.']['defaultContentRendering'] ??= ''; + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.']['defaultContentRendering'] .= $content; + } else { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.'][$afterStaticUid] ??= ''; + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.'][$afterStaticUid] .= $content; + } + } else { + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type] ??= ''; + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type] .= $content; + if ($includeInSiteSets) { + // 'siteSets' is an @internal identifier + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.']['siteSets'] ??= ''; + $GLOBALS['TYPO3_CONF_VARS']['FE']['defaultTypoScript_' . $type . '.']['siteSets'] .= $content; + } + } + } + + /*************************************** + * + * Internal extension management methods + * + ***************************************/ + /** + * Gets an array of loaded extension keys + */ + public static function getLoadedExtensionListArray(): array + { + return array_keys(static::$packageManager->getActivePackages()); + } + + /** + * Loads given extension + * + * @param string $extensionKey Extension key to load + * @throws \RuntimeException + */ + public static function loadExtension(string $extensionKey): void + { + if (static::$packageManager->isPackageActive($extensionKey)) { + throw new \RuntimeException('Extension already loaded', 1342345486); + } + static::$packageManager->activatePackage($extensionKey); + } + + /** + * Unloads given extension + * + * @throws \RuntimeException + */ + public static function unloadExtension(string $extensionKey): void + { + if (!static::$packageManager->isPackageActive($extensionKey)) { + throw new \RuntimeException('Extension not loaded', 1342345487); + } + static::$packageManager->deactivatePackage($extensionKey); + } + + protected static function registerTsConfig(string $tableName, string $extKey, string $filePath, string $title): void + { + if (!$extKey) { + throw new \InvalidArgumentException('No extension key given.', 1447789490); + } + if (!$filePath) { + throw new \InvalidArgumentException('No file path given.', 1447789491); + } + if (!is_array($GLOBALS['TCA'][$tableName]['columns'] ?? null)) { + throw new \InvalidArgumentException(sprintf('No TCA definition for table "%s".', $tableName), 1447789492); + } + + $value = str_replace(',', '', 'EXT:' . $extKey . '/' . $filePath); + $itemArray = ['label' => trim($title . ' (' . $extKey . ')'), 'value' => $value]; + $GLOBALS['TCA'][$tableName]['columns']['tsconfig_includes']['config']['items'][] = $itemArray; + } +} diff --git a/Classes/Utility/File/BasicFileUtility.php b/Classes/Utility/File/BasicFileUtility.php new file mode 100644 index 0000000..2f08605 --- /dev/null +++ b/Classes/Utility/File/BasicFileUtility.php @@ -0,0 +1,150 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\File; + +use TYPO3\CMS\Core\Charset\CharsetConverter; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Contains class with basic file management functions + * + * Contains functions for management, validation etc of files in TYPO3. + * + * @internal All methods in this class should not be used anymore since TYPO3 6.0, this class is therefore marked + * as internal. + * Please use corresponding \TYPO3\CMS\Core\Resource\ResourceStorage + * (fetched via BE_USERS->getFileStorages()), as all functions should be + * found there (in a cleaner manner). + */ +class BasicFileUtility +{ + /** + * @var string + */ + public const UNSAFE_FILENAME_CHARACTER_EXPRESSION = '\\x00-\\x2C\\/\\x3A-\\x3F\\x5B-\\x60\\x7B-\\xBF'; + + /** + * This number decides the highest allowed appended number used on a filename before we use naming with unique strings + * + * @var int + */ + public $maxNumber = 99; + + /** + * This number decides how many characters out of a unique MD5-hash that is appended to a filename if getUniqueName is asked to find an available filename. + * + * @var int + */ + public $uniquePrecision = 6; + + /** + * Cleans $theDir for slashes in the end of the string and returns the new path, if it exists on the server. + * + * @param string $theDir Directory path to check + * @return bool|string Returns the cleaned up directory name if OK, otherwise FALSE. + * @todo: should go into the LocalDriver in a protected way (not important to the outside world) + */ + protected function sanitizeFolderPath($theDir) + { + if (!GeneralUtility::validPathStr($theDir)) { + return false; + } + $theDir = PathUtility::getCanonicalPath($theDir); + if (@is_dir($theDir)) { + return $theDir; + } + return false; + } + + /** + * Returns the destination path/filename of a unique filename/foldername in that path. + * If $theFile exists in $theDest (directory) the file have numbers appended up to $this->maxNumber. Hereafter a unique string will be appended. + * This function is used by fx. DataHandler when files are attached to records and needs to be uniquely named in the uploads/* folders + * + * @param string $theFile The input filename to check + * @param string $theDest The directory for which to return a unique filename for $theFile. $theDest MUST be a valid directory. Should be absolute. + * @param bool $dontCheckForUnique If set the filename is returned with the path prepended without checking whether it already existed! + * @return string|null The destination absolute filepath (not just the name!) of a unique filename/foldername in that path. + * @internal May be removed without further notice. Method has been marked as deprecated for various versions but is still used in core. + * @todo: should go into the LocalDriver in a protected way (not important to the outside world) + */ + public function getUniqueName($theFile, $theDest, $dontCheckForUnique = false) + { + // $theDest is cleaned up + $theDest = $this->sanitizeFolderPath($theDest); + if ($theDest) { + // Fetches info about path, name, extension of $theFile + $origFileInfo = GeneralUtility::split_fileref($theFile); + // Check if the file exists and if not - return the filename... + $fileInfo = $origFileInfo; + $theDestFile = $theDest . '/' . $fileInfo['file']; + // The destinations file + if (!file_exists($theDestFile) || $dontCheckForUnique) { + // If the file does NOT exist we return this filename + return $theDestFile; + } + // Well the filename in its pure form existed. Now we try to append numbers / unique-strings and see if we can find an available filename... + $theTempFileBody = preg_replace('/_[0-9][0-9]$/', '', $origFileInfo['filebody']); + // This removes _xx if appended to the file + $theOrigExt = $origFileInfo['realFileext'] ? '.' . $origFileInfo['realFileext'] : ''; + for ($a = 1; $a <= $this->maxNumber + 1; $a++) { + if ($a <= $this->maxNumber) { + // First we try to append numbers + $insert = '_' . sprintf('%02d', $a); + } else { + // .. then we try unique-strings... + $insert = '_' . substr(md5(StringUtility::getUniqueId()), 0, $this->uniquePrecision); + } + $theTestFile = $theTempFileBody . $insert . $theOrigExt; + $theDestFile = $theDest . '/' . $theTestFile; + // The destinations file + if (!file_exists($theDestFile)) { + // If the file does NOT exist we return this filename + return $theDestFile; + } + } + } + + return null; + } + + /** + * Returns a string where any character not matching [.a-zA-Z0-9_-] is substituted by '_' + * Trailing dots are removed + * + * @param string $fileName Input string, typically the body of a filename + * @return string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed + * @internal May be removed without further notice. Method has been marked as deprecated for various versions but is still used in core. + */ + public function cleanFileName($fileName) + { + // Handle UTF-8 characters + if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) { + // allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave) + $cleanFileName = preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName)) ?? ''; + } else { + $fileName = GeneralUtility::makeInstance(CharsetConverter::class)->utf8_char_mapping($fileName); + // Replace unwanted characters by underscores + $cleanFileName = preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName)) ?? ''; + } + // Strip trailing dots and return + return rtrim($cleanFileName, '.'); + } +} diff --git a/Classes/Utility/File/ExtendedFileUtility.php b/Classes/Utility/File/ExtendedFileUtility.php new file mode 100644 index 0000000..bb671d5 --- /dev/null +++ b/Classes/Utility/File/ExtendedFileUtility.php @@ -0,0 +1,1182 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\File; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UploadedFileInterface; +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\Http\ApplicationType; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; +use TYPO3\CMS\Core\Resource\Event\AfterFileCommandProcessedEvent; +use TYPO3\CMS\Core\Resource\Exception; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFileNameException; +use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFolderException; +use TYPO3\CMS\Core\Resource\Exception\FileOperationErrorException; +use TYPO3\CMS\Core\Resource\Exception\IllegalFileExtensionException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFileWritePermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderWritePermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InsufficientUserPermissionsException; +use TYPO3\CMS\Core\Resource\Exception\InvalidFileException; +use TYPO3\CMS\Core\Resource\Exception\InvalidFileNameException; +use TYPO3\CMS\Core\Resource\Exception\InvalidTargetFolderException; +use TYPO3\CMS\Core\Resource\Exception\NotInMountPointException; +use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException; +use TYPO3\CMS\Core\Resource\Exception\UploadException; +use TYPO3\CMS\Core\Resource\Exception\UploadSizeException; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\Folder; +use TYPO3\CMS\Core\Resource\Index\Indexer; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\SysLog\Action\File as SystemLogFileAction; +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification; +use TYPO3\CMS\Core\SysLog\Type as SystemLogType; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\Exception\NotImplementedMethodException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Validation\ResultException; + +/** + * Contains functions for performing file operations like copying, pasting, uploading, moving, + * deleting etc. through the TCE + * + * See document "TYPO3 Core API" for syntax + * + * This class contains functions primarily used by tce_file.php (TYPO3 Core Engine for file manipulation) + * Functions include copying, moving, deleting, uploading and so on... + * + * All fileoperations must be within the file mount paths of the user. + * + * @internal Since TYPO3 v10, this class should not be used anymore outside of TYPO3 Core, and is considered internal, + * as the FAL API should be used instead. + */ +class ExtendedFileUtility extends BasicFileUtility +{ + /** + * Defines behaviour when uploading files with names that already exist; + */ + protected DuplicationBehavior $existingFilesConflictMode = DuplicationBehavior::CANCEL; + + /** + * This array is self-explaining (look in the class below). + * It grants access to the functions. This could be set from outside in order to enabled functions to users. + * See also the function setActionPermissions() which takes input directly from the user-record + */ + public array $actionPerms = [ + // File permissions + 'addFile' => false, + 'readFile' => false, + 'writeFile' => false, + 'copyFile' => false, + 'moveFile' => false, + 'renameFile' => false, + 'deleteFile' => false, + // Folder permissions + 'addFolder' => false, + 'readFolder' => false, + 'writeFolder' => false, + 'copyFolder' => false, + 'moveFolder' => false, + 'renameFolder' => false, + 'deleteFolder' => false, + 'recursivedeleteFolder' => false, + ]; + + /** + * Will contain map between upload ID and the final filename + */ + public array $internalUploadMap = []; + + /** + * Container for FlashMessages so they can be localized + * + * @var FlashMessage[] + */ + protected array $flashMessages = []; + + protected array $fileCmdMap = []; + + /** + * @var array<string, UploadedFileInterface|list<UploadedFileInterface>> + */ + protected array $uploadedFiles = []; + + protected ResourceFactory $fileFactory; + + /** + * Get existingFilesConflictMode + */ + public function getExistingFilesConflictMode(): string + { + return $this->existingFilesConflictMode->value; + } + + /** + * Set existingFilesConflictMode + */ + public function setExistingFilesConflictMode(DuplicationBehavior $existingFilesConflictMode): void + { + $this->existingFilesConflictMode = $existingFilesConflictMode; + } + + /** + * Initialization of the class + * + * @param array $fileCmds Array with the commands to execute. See "TYPO3 Core API" document + */ + public function start(array $fileCmds, array $uploadedFiles): void + { + // Initialize Object Factory + $this->fileFactory = GeneralUtility::makeInstance(ResourceFactory::class); + // Initializing file processing commands: + $this->fileCmdMap = $fileCmds; + $this->uploadedFiles = $uploadedFiles; + } + + /** + * Sets the file action permissions. + * If no argument is given, permissions of the currently logged in backend user are taken into account. + * + * @param array $permissions File Permissions. + */ + public function setActionPermissions(array $permissions = []) + { + if (empty($permissions)) { + $permissions = $this->getBackendUser()->getFilePermissions(); + } + $this->actionPerms = $permissions; + } + + /** + * Processing the command array in $this->fileCmdMap + * + * @return mixed FALSE, if the file functions were not initialized + * @throws \UnexpectedValueException + */ + public function processData() + { + $result = []; + if ($this->fileCmdMap !== []) { + // Check if there were uploads expected, but no one made + if ($this->fileCmdMap['upload'] ?? false) { + $uploads = $this->fileCmdMap['upload']; + foreach ($uploads as $upload) { + $uploadedFileIndex = 'upload_' . $upload['data']; + if (!$this->uploadedFileHasClientName($this->uploadedFiles[$uploadedFileIndex] ?? null)) { + unset($this->fileCmdMap['upload'][$upload['data']]); + } + } + if (empty($this->fileCmdMap['upload'])) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'No file was uploaded'); + $this->addMessageToFlashMessageQueue('FileUtility.NoFileWasUploaded'); + } + } + + // Check if there were new folder names expected, but non given + if ($this->fileCmdMap['newfolder'] ?? false) { + foreach ($this->fileCmdMap['newfolder'] as $key => $cmdArr) { + if ((string)($cmdArr['data'] ?? '') === '') { + unset($this->fileCmdMap['newfolder'][$key]); + } + } + if (empty($this->fileCmdMap['newfolder'])) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'No name was provided for the new folder'); + $this->addMessageToFlashMessageQueue('FileUtility.NoNameForNewFolderGiven'); + } + } + + // Traverse each set of actions + foreach ($this->fileCmdMap as $action => $actionData) { + // Traverse all action data. More than one file might be affected at the same time. + if (is_array($actionData)) { + $result[$action] = []; + // We reset the array keys of $actionData to keep track of the corresponding + // result, while not changing the previous behaviour of $result[$action][]. + foreach (array_values($actionData) as $key => $cmdArr) { + // Clear file stats + clearstatcache(); + // Branch out based on command: + switch ($action) { + case 'delete': + $result[$action][$key] = $this->func_delete($cmdArr); + break; + case 'copy': + $result[$action][$key] = $this->func_copy($cmdArr); + break; + case 'move': + $result[$action][$key] = $this->func_move($cmdArr); + break; + case 'rename': + $result[$action][$key] = $this->func_rename($cmdArr); + break; + case 'newfolder': + $result[$action][$key] = $this->func_newfolder($cmdArr); + break; + case 'newfile': + $result[$action][$key] = $this->func_newfile($cmdArr); + break; + case 'editfile': + $result[$action][$key] = $this->func_edit($cmdArr); + break; + case 'upload': + $result[$action][$key] = $this->func_upload($cmdArr); + break; + case 'replace': + $result[$action][$key] = $this->replaceFile($cmdArr); + break; + } + + GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch( + new AfterFileCommandProcessedEvent([$action => $cmdArr], $result[$action][$key], $this->existingFilesConflictMode->value) + ); + } + } + } + } + return $result; + } + + /** + * @param int $action The action number. See the functions in the class for a hint. Eg. edit is '9', upload is '1' ... + * @param int $severity The severity: 0 = message, 1 = error, 2 = System Error, 3 = security notice (admin) + * @param string $message This is the default, raw error message in english + * @param array $context Additional information when the log is shown + */ + protected function writeLog(int $action, int $severity, string $message, array $context = []): void + { + $this->getBackendUser()->writelog(SystemLogType::FILE, $action, $severity, null, $message, $context); + } + + /** + * Adds a localized FlashMessage to the message queue + * + * @param string $localizationKey + * @param ContextualFeedbackSeverity $severity + * @throws \InvalidArgumentException + */ + protected function addMessageToFlashMessageQueue($localizationKey, array $replaceMarkers = [], ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::ERROR) + { + if ($this->isBackendScope()) { + $label = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/fileMessages.xlf:' . $localizationKey); + $message = vsprintf($label, $replaceMarkers); + $flashMessage = new FlashMessage( + $message, + '', + $severity, + true + ); + $this->addFlashMessage($flashMessage); + } + } + + protected function addEvaluationResultHintsToFlashMessageQueue( + ResultException $exception, + ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::ERROR, + ): void { + if (!$this->isBackendScope()) { + return; + } + foreach ($exception->messages as $messageItem) { + $message = $messageItem->labelBag?->compile($this->getLanguageService()) ?? $messageItem->message; + $flashMessage = new FlashMessage( + $message, + '', + $severity, + true + ); + $this->addFlashMessage($flashMessage); + } + } + + /************************************* + * + * File operation functions + * + **************************************/ + /** + * Deleting files and folders (action=4) + * + * @param array $cmds $cmds['data'] is the file/folder to delete + * @return bool Returns TRUE upon success + */ + public function func_delete(array $cmds) + { + $result = false; + // Example identifier for $cmds['data'] => "4:mypath/tomyfolder/myfile.jpg" + // for backwards compatibility: the combined file identifier was the path+filename + try { + $fileObject = $this->getFileObject($cmds['data']); + } catch (ResourceDoesNotExistException $e) { + $flashMessage = new FlashMessage( + sprintf( + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.fileNotFound'), + $cmds['data'] + ), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.fileNotFound'), + ContextualFeedbackSeverity::ERROR, + true + ); + $this->addFlashMessage($flashMessage); + + return false; + } + // checks to delete the file + if ($fileObject instanceof File) { + // check if the file still has references + // Exclude sys_file_metadata records as these are no use references + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex'); + $refIndexRecords = $queryBuilder + ->select('tablename', 'recuid', 'ref_uid') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq( + 'ref_table', + $queryBuilder->createNamedParameter('sys_file') + ), + $queryBuilder->expr()->eq( + 'ref_uid', + $queryBuilder->createNamedParameter($fileObject->getUid(), Connection::PARAM_INT) + ), + $queryBuilder->expr()->neq( + 'tablename', + $queryBuilder->createNamedParameter('sys_file_metadata') + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + $deleteFile = true; + if (!empty($refIndexRecords)) { + $shortcutContent = []; + $brokenReferences = []; + + foreach ($refIndexRecords as $fileReferenceRow) { + if ($fileReferenceRow['tablename'] === 'sys_file_reference') { + $row = $this->transformFileReferenceToRecordReference($fileReferenceRow); + if ($row === null) { + $brokenReferences[] = $fileReferenceRow['ref_uid']; + continue; + } + $shortcutRecord = BackendUtility::getRecord($row['tablename'], $row['recuid']); + + if ($shortcutRecord) { + $shortcutContent[] = '[record:' . $row['tablename'] . ':' . $row['recuid'] . ']'; + } else { + $brokenReferences[] = $fileReferenceRow['ref_uid']; + } + } else { + $shortcutContent[] = '[record:' . $fileReferenceRow['tablename'] . ':' . $fileReferenceRow['recuid'] . ']'; + } + } + if (!empty($brokenReferences)) { + // render a message that the file has broken references + $flashMessage = new FlashMessage( + sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.fileHasBrokenReferences'), count($brokenReferences)), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.fileHasBrokenReferences'), + ContextualFeedbackSeverity::INFO, + true + ); + $this->addFlashMessage($flashMessage); + } + if (!empty($shortcutContent)) { + // render a message that the file could not be deleted + $flashMessage = new FlashMessage( + sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.fileNotDeletedHasReferences'), $fileObject->getName()) . ' ' . implode(', ', $shortcutContent), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.fileNotDeletedHasReferences'), + ContextualFeedbackSeverity::WARNING, + true + ); + $this->addFlashMessage($flashMessage); + $deleteFile = false; + } + } + + if ($deleteFile) { + try { + $result = $fileObject->delete(); + + // show the user that the file was deleted + $flashMessage = new FlashMessage( + sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.fileDeleted'), $fileObject->getName()), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.fileDeleted'), + ContextualFeedbackSeverity::OK, + true + ); + $this->addFlashMessage($flashMessage); + // Log success + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" deleted', ['identifier' => $fileObject->getIdentifier()]); + } catch (InsufficientFileAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'Access denied for file "{identifier}" due to insufficient permissions', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToAccessTheFile', [$fileObject->getIdentifier()]); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'The file or folder {destination} was not accessible within permitted mountpoints', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.TargetWasNotWithinYourMountpoints', [$fileObject->getIdentifier()]); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'Delete failed for file "{identifier}": insufficient write permissions', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotDeleteFile', [$fileObject->getIdentifier()]); + } + } + } else { + if ($fileObject instanceof Folder && !$this->folderHasFilesInUse($fileObject)) { + try { + $result = $fileObject->delete(true); + if ($result) { + // notify the user that the folder was deleted + $flashMessage = new FlashMessage( + sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.folderDeleted'), $fileObject->getName()), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.folderDeleted'), + ContextualFeedbackSeverity::OK, + true + ); + $this->addFlashMessage($flashMessage); + // Log success + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::MESSAGE, 'Directory "{identifier}" deleted', ['identifier' => $fileObject->getIdentifier()]); + } + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'Delete failed for directory "{identifier}": recursive deletion is not supported', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotDeleteDirectory', [$fileObject->getIdentifier()]); + } catch (InsufficientFolderAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'Access denied for directory "{identifier}" due to insufficient permissions', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToAccessTheDirectory', [$fileObject->getIdentifier()]); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'The file or folder {destination} was not accessible within permitted mountpoints', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.TargetWasNotWithinYourMountpoints', [$fileObject->getIdentifier()]); + } catch (FileOperationErrorException $e) { + $this->writeLog(SystemLogFileAction::DELETE, SystemLogErrorClassification::USER_ERROR, 'Delete failed for directory "{identifier}": insufficient write permissions', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotDeleteDirectory', [$fileObject->getIdentifier()]); + } + } + } + + return $result; + } + + /** + * Checks files in given folder recursively for for existing references. + * + * Creates a flash message if there are references. + * + * @param Folder $folder + * @return bool TRUE if folder has files in use, FALSE otherwise + */ + public function folderHasFilesInUse(Folder $folder) + { + $files = $folder->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, true); + if (empty($files)) { + return false; + } + + /** @var int[] $fileUids */ + $fileUids = []; + foreach ($files as $file) { + $fileUids[] = $file->getUid(); + } + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex'); + $numberOfReferences = $queryBuilder + ->count('hash') + ->from('sys_refindex') + ->where( + $queryBuilder->expr()->eq( + 'ref_table', + $queryBuilder->createNamedParameter('sys_file') + ), + $queryBuilder->expr()->in( + 'ref_uid', + $queryBuilder->createNamedParameter($fileUids, Connection::PARAM_INT_ARRAY) + ), + $queryBuilder->expr()->neq( + 'tablename', + $queryBuilder->createNamedParameter('sys_file_metadata') + ) + )->executeQuery()->fetchOne(); + + $hasReferences = $numberOfReferences > 0; + if ($hasReferences) { + $flashMessage = new FlashMessage( + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.description.folderNotDeletedHasFilesWithReferences'), + $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:message.header.folderNotDeletedHasFilesWithReferences'), + ContextualFeedbackSeverity::WARNING, + true + ); + $this->addFlashMessage($flashMessage); + } + + return $hasReferences; + } + + /** + * Maps results from the fal file reference table on the + * structure of the normal reference index table. + */ + protected function transformFileReferenceToRecordReference(array $referenceRecord): ?array + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex'); + $queryBuilder->getRestrictions()->removeAll(); + $fileReference = $queryBuilder + ->select('uid_foreign', 'tablenames', 'fieldname', 'sorting_foreign') + ->from('sys_file_reference') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($referenceRecord['recuid'], Connection::PARAM_INT) + ) + ) + ->executeQuery() + ->fetchAssociative(); + + if ($fileReference === false) { + return null; + } + + return [ + 'recuid' => $fileReference['uid_foreign'], + 'tablename' => $fileReference['tablenames'], + 'field' => $fileReference['fieldname'], + 'flexpointer' => '', + 'softref_key' => '', + 'sorting' => $fileReference['sorting_foreign'], + ]; + } + + /** + * Gets a File or a Folder object from an identifier [storage]:[fileId] + */ + protected function getFileObject(string $identifier) + { + $object = $this->fileFactory->retrieveFileOrFolderObject($identifier); + if ($object === null) { + throw new InvalidFileException('The item ' . $identifier . ' was not a file or directory', 1320122453); + } + if ($object->getStorage()->isFallbackStorage()) { + throw new InsufficientFileAccessPermissionsException('You are not allowed to access files outside your storages', 1375889830); + } + return $object; + } + + /** + * Copying files and folders (action=2) + * + * $cmds['data'] (string): The file/folder to copy + * + example "4:mypath/tomyfolder/myfile.jpg") + * + for backwards compatibility: the identifier was the path+filename + * $cmds['target'] (string): The path where to copy to. + * + example "2:targetpath/targetfolder/" + * $cmds['altName'] (string): Use an alternative name if the target already exists + * + * @param array $cmds Command details as described above + * @return \TYPO3\CMS\Core\Resource\File|false + */ + protected function func_copy($cmds) + { + $sourceFileObject = $this->getFileObject($cmds['data']); + $targetFolderObject = $this->getFileObject($cmds['target']); + // Basic check + if (!$targetFolderObject instanceof Folder) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::SYSTEM_ERROR, 'Destination "{identifier}" was not a directory', ['identifier' => $cmds['target']]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationWasNotADirectory', [$cmds['target']]); + return false; + } + // If this is TRUE, we append _XX to the file name if + $appendSuffixOnConflict = (string)($cmds['altName'] ?? ''); + $resultObject = null; + $conflictMode = $appendSuffixOnConflict !== '' ? DuplicationBehavior::RENAME : DuplicationBehavior::CANCEL; + // Copying the file + if ($sourceFileObject instanceof File) { + try { + $resultObject = $sourceFileObject->copyTo($targetFolderObject, null, $conflictMode); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'File copy denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToCopyFiles'); + } catch (InsufficientFileAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Resource access failed: source "{identifier}" or destination {"destination}" file is outside the configured mountpoints', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotAccessAllNecessaryResources', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Extension of file name "{identifier}" is not allowed in "{destination}"', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileNameIsNotAllowedIn', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'File "{identifier}" already exists in directory "{destination}"', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileAlreadyExistsInFolder', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (NotImplementedMethodException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'The function to copy a file between storages is not yet implemented'); + $this->addMessageToFlashMessageQueue('FileUtility.TheFunctionToCopyAFileBetweenStoragesIsNotYetImplemented'); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::SYSTEM_ERROR, 'Copy failed for file "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotCopiedTo', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } + if ($resultObject) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" copied to "{destination}"', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $resultObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileCopiedTo', [$sourceFileObject->getIdentifier(), $resultObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } + } else { + // Else means this is a Folder + $sourceFolderObject = $sourceFileObject; + try { + $resultObject = $sourceFolderObject->copyTo($targetFolderObject, null, $conflictMode); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Directory copy denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToCopyDirectories'); + } catch (InsufficientFileAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Resource access failed: source "{identifier}" or destination {"destination}" file is outside the configured mountpoints', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotAccessAllNecessaryResources', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (InsufficientFolderAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Access denied: insufficient permissions for destination directory "{destination}"', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouDontHaveFullAccessToTheDestinationDirectory', [$targetFolderObject->getIdentifier()]); + } catch (InvalidTargetFolderException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Copy failed: destination "{destination}" already contains a file or directory with the same name "{name}"', ['name' => $sourceFolderObject->getName(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CannotCopyFolderIntoTargetFolderBecauseTheTargetFolderIsAlreadyWithinTheFolderToBeCopied', [$sourceFolderObject->getName(), $targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFolderException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::USER_ERROR, 'Target "{destination}" already exists', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.TargetAlreadyExists', [$targetFolderObject->getIdentifier()]); + } catch (NotImplementedMethodException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'The function to copy a folder between storages is not yet implemented'); + $this->addMessageToFlashMessageQueue('FileUtility.TheFunctionToCopyAFolderBetweenStoragesIsNotYetImplemented'); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::SYSTEM_ERROR, 'Copy failed for directory "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryWasNotCopiedTo', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } + if ($resultObject) { + $this->writeLog(SystemLogFileAction::COPY, SystemLogErrorClassification::MESSAGE, 'Directory "{identifier}" copied to "{destination}"', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryCopiedTo', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } + } + return $resultObject; + } + + /** + * Moving files and folders (action=3) + * + * $cmds['data'] (string): The file/folder to move + * + example "4:mypath/tomyfolder/myfile.jpg") + * + for backwards compatibility: the identifier was the path+filename + * $cmds['target'] (string): The path where to move to. + * + example "2:targetpath/targetfolder/" + * $cmds['altName'] (string): Use an alternative name if the target already exists + * + * @param array $cmds Command details as described above + * @return \TYPO3\CMS\Core\Resource\File|false + */ + protected function func_move($cmds) + { + $sourceFileObject = $this->getFileObject($cmds['data']); + $targetFolderObject = $this->getFileObject($cmds['target']); + // Basic check + if (!$targetFolderObject instanceof Folder) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::SYSTEM_ERROR, 'Destination "{destination}" was not a directory', ['destination' => $cmds['target']]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationWasNotADirectory', [$cmds['target']]); + return false; + } + $alternativeName = (string)($cmds['altName'] ?? ''); + $resultObject = null; + // Moving the file + if ($sourceFileObject instanceof File) { + try { + $sourcePath = $sourceFileObject->getIdentifier(); + if ($alternativeName !== '') { + // Don't allow overwriting existing files, but find a new name + $resultObject = $sourceFileObject->moveTo($targetFolderObject, $alternativeName, DuplicationBehavior::RENAME); + } else { + // Don't allow overwriting existing files + $resultObject = $sourceFileObject->moveTo($targetFolderObject, null, DuplicationBehavior::CANCEL); + } + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" moved to "{destination}"', ['identifier' => $sourcePath, 'destination' => $resultObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileMovedTo', [$sourcePath, $resultObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'File moving denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToMoveFiles'); + } catch (InsufficientFileAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Resource access failed: source "{identifier}" or destination {"destination}" file is outside the configured mountpoints', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotAccessAllNecessaryResources', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Extension of file name "{identifier}" is not allowed in "{destination}"', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileNameIsNotAllowedIn', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'File "{identifier}" already exists in directory "{destination}"', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileAlreadyExistsInFolder', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (NotImplementedMethodException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'The function to move a file between storages is not yet implemented'); + $this->addMessageToFlashMessageQueue('FileUtility.TheFunctionToMoveAFileBetweenStoragesIsNotYetImplemented'); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::SYSTEM_ERROR, 'Copy failed for file "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFileObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotCopiedTo', [$sourceFileObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } + } else { + // Else means this is a Folder + $sourceFolderObject = $sourceFileObject; + try { + if ($alternativeName !== '') { + // Don't allow overwriting existing files, but find a new name + $resultObject = $sourceFolderObject->moveTo($targetFolderObject, $alternativeName, DuplicationBehavior::RENAME); + } else { + // Don't allow overwriting existing files + $resultObject = $sourceFolderObject->moveTo($targetFolderObject, null, DuplicationBehavior::RENAME); + } + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::MESSAGE, 'Directory "{identifier}" moved to "{destination}"', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryMovedTo', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Directory moving denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToMoveDirectories'); + } catch (InsufficientFileAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Resource access failed: source folder "{identifier}" or destination {"destination}" directory is outside the configured mountpoints', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.CouldNotAccessAllNecessaryResources', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } catch (InsufficientFolderAccessPermissionsException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'You don\'t have full access to the destination directory "{destination}"', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouDontHaveFullAccessToTheDestinationDirectory', [$targetFolderObject->getIdentifier()]); + } catch (InvalidTargetFolderException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Move failed: destination "{destination}" is located within the source directory "{identifier}"', ['identifier' => $sourceFolderObject->getName(), 'destination' => $targetFolderObject->getName()]); + $this->addMessageToFlashMessageQueue('FileUtility.CannotMoveFolderIntoTargetFolderBecauseTheTargetFolderIsAlreadyWithinTheFolderToBeMoved', [$sourceFolderObject->getName(), $targetFolderObject->getName()]); + } catch (ExistingTargetFolderException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'Target "{destination}" already exists', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.TargetAlreadyExists', [$targetFolderObject->getIdentifier()]); + } catch (NotImplementedMethodException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::USER_ERROR, 'The function to move a folder between storages is not yet implemented'); + $this->addMessageToFlashMessageQueue('FileUtility.TheFunctionToMoveAFolderBetweenStoragesIsNotYetImplemented'); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::MOVE, SystemLogErrorClassification::SYSTEM_ERROR, 'Move failed for directory "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFolderObject->getIdentifier(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryWasNotMovedTo', [$sourceFolderObject->getIdentifier(), $targetFolderObject->getIdentifier()]); + } + } + return $resultObject; + } + + /** + * Renaming files or folders (action=5) + * + * $cmds['data'] (string): The file/folder to copy + * + example "4:mypath/tomyfolder/myfile.jpg") + * + for backwards compatibility: the identifier was the path+filename + * $cmds['target'] (string): New name of the file/folder + * + * @param array $cmds Command details as described above + * @return \TYPO3\CMS\Core\Resource\File Returns the new file upon success + */ + public function func_rename($cmds) + { + $sourceFileObject = $this->getFileObject($cmds['data']); + $sourceFile = $sourceFileObject->getName(); + $targetFile = $cmds['target']; + $resultObject = null; + if ($sourceFileObject instanceof File) { + try { + // Try to rename the File + $resultObject = $sourceFileObject->rename($targetFile, $this->existingFilesConflictMode); + if ($resultObject->getName() !== $targetFile) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'File renamed from "{identifier}" to "{destination}": unsupported characters were replaced', ['identifier' => $sourceFile, 'destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.FileNameSanitized', [$targetFile, $resultObject->getName()], ContextualFeedbackSeverity::WARNING); + } else { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::MESSAGE, 'File renamed from "{identifier}" to "{destination}"', ['identifier' => $sourceFile, 'destination' => $targetFile]); + } + if ($sourceFile === $resultObject->getName()) { + $this->addMessageToFlashMessageQueue('FileUtility.FileRenamedSameName', [$sourceFile], ContextualFeedbackSeverity::INFO); + } else { + $this->addMessageToFlashMessageQueue('FileUtility.FileRenamedFromTo', [$sourceFile, $resultObject->getName()], ContextualFeedbackSeverity::OK); + } + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'File renaming denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToRenameFiles'); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Operation failed due to illegal file extension on "{identifier}" or "{destination}"', ['identifier' => $sourceFileObject->getName(), 'destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileNameOrWasNotAllowed', [$sourceFileObject->getName(), $targetFile]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Rename failed because the destination file "{destination}" already exists', ['destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationExistedAlready', [$targetFile]); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$targetFile]); + } catch (ResultException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'File {identifier} was not renamed to {destination}', ['identifier' => $sourceFileObject->getName(), 'destination' => $targetFile]); + $this->addEvaluationResultHintsToFlashMessageQueue($e); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Rename failed for file "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFileObject->getName(), 'destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotRenamed', [$sourceFileObject->getName(), $targetFile]); + } + } else { + // Else means this is a Folder + try { + // Try to rename the Folder + $resultObject = $sourceFileObject->rename($targetFile); + $newFolderName = $resultObject->getName(); + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::MESSAGE, 'Directory renamed from "{identifier}" to "{destination}"', ['identifier' => $sourceFile, 'destination' => $targetFile]); + if ($sourceFile === $newFolderName) { + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryRenamedSameName', [$sourceFile], ContextualFeedbackSeverity::INFO); + } else { + if ($newFolderName === $targetFile) { + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryRenamedFromTo', [$sourceFile, $newFolderName], ContextualFeedbackSeverity::OK); + } else { + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryRenamedFromToCharReplaced', [$sourceFile, $newFolderName], ContextualFeedbackSeverity::WARNING); + } + } + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Directory renaming denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToRenameDirectories'); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Rename failed because the destination folder "{destination}" already exists', ['destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationExistedAlready', [$targetFile]); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$targetFile]); + } catch (ResultException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'File {identifier} was not renamed to {destination}', ['identifier' => $sourceFileObject->getName(), 'destination' => $targetFile]); + $this->addEvaluationResultHintsToFlashMessageQueue($e); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::RENAME, SystemLogErrorClassification::USER_ERROR, 'Rename failed for directory "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $sourceFileObject->getName(), 'destination' => $targetFile]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryWasNotRenamed', [$sourceFileObject->getName(), $targetFile]); + } + } + return $resultObject; + } + + /** + * This creates a new folder. (action=6) + * + * $cmds['data'] (string): The new folder name + * $cmds['target'] (string): The path where to copy to. + * + example "2:targetpath/targetfolder/" + * + * @param array $cmds Command details as described above + * @return Folder|false Returns the new foldername upon success + */ + public function func_newfolder($cmds) + { + $resultObject = false; + $targetFolderObject = $this->getFileObject($cmds['target']); + if (!$targetFolderObject instanceof Folder) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::SYSTEM_ERROR, 'Destination "{destination}" was not a directory', ['destination' => $cmds['target']]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationWasNotADirectory', [$cmds['target']]); + return false; + } + $folderName = $cmds['data']; + try { + $resultObject = $targetFolderObject->createFolder($folderName); + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::MESSAGE, 'Directory "{identifier}" created in "{destination}"', ['identifier' => $folderName, 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryCreatedIn', [$folderName, $targetFolderObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } catch (InvalidFileNameException $e) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'Invalid folder name "{identifier}"', ['identifier' => $folderName]); + $this->addMessageToFlashMessageQueue('FileUtility.InvalidFolderName', [$folderName]); + } catch (InsufficientFolderWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'Directory creation denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToCreateDirectories'); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFolderException $e) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'File or directory "{identifier}" already exists', ['identifier' => $folderName]); + $this->addMessageToFlashMessageQueue('FileUtility.FileOrDirectoryExistedAlready', [$folderName]); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::NEW_FOLDER, SystemLogErrorClassification::USER_ERROR, 'Creation failed for directory "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $folderName, 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DirectoryNotCreated', [$folderName, $targetFolderObject->getIdentifier()]); + } + return $resultObject; + } + + /** + * This creates a new file. (action=8) + * $cmds['data'] (string): The new file name + * $cmds['target'] (string): The path where to create it. + * + example "2:targetpath/targetfolder/" + * + * @param array $cmds Command details as described above + */ + public function func_newfile($cmds): File|false|null + { + $targetFolderObject = $this->getFileObject($cmds['target']); + if (!$targetFolderObject instanceof Folder) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::SYSTEM_ERROR, 'Destination "{destination}" was not a directory', ['destination' => $cmds['target']]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationWasNotADirectory', [$cmds['target']]); + return false; + } + $resultObject = null; + $fileName = $cmds['data']; + try { + $resultObject = $targetFolderObject->createFile($fileName); + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" created', ['identifier' => $fileName]); + if ($resultObject->getName() !== $fileName) { + $this->addMessageToFlashMessageQueue('FileUtility.FileNameSanitized', [$fileName, $resultObject->getName()], ContextualFeedbackSeverity::WARNING); + } + $this->addMessageToFlashMessageQueue('FileUtility.FileCreated', [$resultObject->getName()], ContextualFeedbackSeverity::OK); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'Extension of file "{identifier}" was not allowed', ['identifier' => $fileName]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileWasNotAllowed', [$fileName]); + } catch (InsufficientFolderWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'File creation denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToCreateFiles'); + } catch (NotInMountPointException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'File existed already in "{destination}"', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileExistedAlreadyIn', [$targetFolderObject->getIdentifier()]); + } catch (InvalidFileNameException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'File name "{identifier}" was not allowed', ['identifier' => $fileName]); + $this->addMessageToFlashMessageQueue('FileUtility.FileNameWasNotAllowed', [$fileName]); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::NEW_FILE, SystemLogErrorClassification::USER_ERROR, 'Creation failed for file "{identifier}" in "{destination}": insufficient write permissions', ['identifier' => $fileName, 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotCreated', [$fileName, $targetFolderObject->getIdentifier()]); + } + return $resultObject; + } + + /** + * Editing textfiles or folders (action=9) + * + * @param array $cmds $cmds['data'] is the new content. $cmds['target'] is the target (file or dir) + * @return bool Returns TRUE on success + */ + public function func_edit($cmds) + { + // Example identifier for $cmds['target'] => "4:mypath/tomyfolder/myfile.jpg" + // for backwards compatibility: the combined file identifier was the path+filename + $fileIdentifier = $cmds['target']; + $fileObject = $this->getFileObject($fileIdentifier); + if (!$fileObject instanceof File) { + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::SYSTEM_ERROR, 'Target "{destination}" was not a file', ['destination' => $fileIdentifier]); + $this->addMessageToFlashMessageQueue('FileUtility.TargetWasNotAFile', [$fileIdentifier]); + return false; + } + if (!$fileObject->isTextFile()) { + $extList = $GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext']; + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::USER_ERROR, 'Unsupported text file extension "{extension}" (allowed: {allowedExtensions})', ['extension' => $fileObject->getExtension(), 'allowedExtensions' => $extList]); + $this->addMessageToFlashMessageQueue('FileUtility.FileExtensionIsNotATextfileFormat', [$fileObject->getExtension(), $extList]); + return false; + } + try { + // Example identifier for $cmds['target'] => "2:targetpath/targetfolder/" + $content = $cmds['data']; + $fileObject->setContents($content); + clearstatcache(); + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::MESSAGE, 'File saved to "{identifier}", bytes: {size}', ['identifier' => $fileObject->getIdentifier(), 'size' => $fileObject->getSize()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileSavedTo', [$fileObject->getIdentifier()], ContextualFeedbackSeverity::OK); + return true; + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::USER_ERROR, 'File editing denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToEditFiles'); + return false; + } catch (InsufficientFileWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::USER_ERROR, 'Save failed for file "{identifier}": insufficient write permissions', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotSaved', [$fileObject->getIdentifier()]); + return false; + } catch (IllegalFileExtensionException|\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::EDIT, SystemLogErrorClassification::USER_ERROR, 'Save failed for file "{identifier}": file extension rejected', ['identifier' => $fileObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotSaved', [$fileObject->getIdentifier()]); + return false; + } + } + + /** + * Upload of files (action=1) + * in HTML you'd need sth like this: <input type="file" name="upload_1[]" multiple="true" /> + * + * @param array $cmds $cmds['data'] is the ID-number (points to the global var that holds the filename-ref + * ($this->uploadedFiles['upload_' . $id]['name']) . $cmds['target'] is the target directory, $cmds['charset'] + * is the the character set of the file name (utf-8 is needed for JS-interaction) + * @return File[]|bool Returns an array of new file objects upon success. False otherwise + */ + public function func_upload($cmds) + { + $uploadPosition = $cmds['data']; + $uploadedFileData = $this->uploadedFiles['upload_' . $uploadPosition] ?? null; + if (!$this->uploadedFileHasClientName($uploadedFileData)) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::SYSTEM_ERROR, 'No file was uploaded'); + $this->addMessageToFlashMessageQueue('FileUtility.NoFileWasUploaded'); + return false; + } + // Example identifier for $cmds['target'] => "2:targetpath/targetfolder/" + $targetFolderObject = $this->getFileObject($cmds['target']); + // Uploading with non HTML-5-style, thus, make an array out of it, so we can loop over it + if (!is_array($uploadedFileData)) { + $uploadedFileData = [$uploadedFileData]; + } + $resultObjects = []; + // Loop through all uploaded files + foreach ($uploadedFileData as $uploadedFile) { + try { + $fileObject = $targetFolderObject->addUploadedFile($uploadedFile, $this->existingFilesConflictMode); + if ($this->existingFilesConflictMode === DuplicationBehavior::REPLACE) { + $this->getIndexer($fileObject->getStorage())->updateIndexEntry($fileObject); + } + $resultObjects[] = $fileObject; + $this->internalUploadMap[$uploadPosition] = $fileObject->getCombinedIdentifier(); + if ($fileObject->getName() !== $uploadedFile->getClientFilename()) { + $this->addMessageToFlashMessageQueue('FileUtility.FileNameSanitized', [$uploadedFile->getClientFilename(), $fileObject->getName()], ContextualFeedbackSeverity::WARNING); + } + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" uploaded to "{destination}"', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.UploadingFileTo', [$uploadedFile->getClientFilename(), $targetFolderObject->getIdentifier()], ContextualFeedbackSeverity::OK); + } catch (InsufficientFileWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Overwrite denied for "{identifier}" due to insufficient permissions', ['identifier' => $uploadedFile->getClientFilename()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToOverride', [$uploadedFile->getClientFilename()]); + } catch (UploadException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::SYSTEM_ERROR, 'Upload failed because no file was provided'); + $this->addMessageToFlashMessageQueue('FileUtility.TheUploadHasFailedNoUploadedFileFound'); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'File uploading denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToUploadFiles'); + } catch (UploadSizeException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Upload failed: file "{identifier}" exceeds the configured size limit', ['identifier' => $uploadedFile->getClientFilename()]); + $this->addMessageToFlashMessageQueue('FileUtility.TheUploadedFileExceedsTheSize-limit', [$uploadedFile->getClientFilename()]); + } catch (InsufficientFolderWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$targetFolderObject->getIdentifier()]); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Extension of file name "{identifier}" is not allowed in "{destination}"', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileNameIsNotAllowedIn', [$uploadedFile->getClientFilename(), $targetFolderObject->getIdentifier()]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'No unique filename available in "{destination}"', ['destination' => $targetFolderObject->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.NoUniqueFilenameAvailableIn', [$targetFolderObject->getIdentifier()]); + } catch (ResultException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Uploading file "{identifier}" to "{destination}" failed', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $targetFolderObject->getIdentifier()]); + $this->addEvaluationResultHintsToFlashMessageQueue($e); + } catch (\RuntimeException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Move failed for the uploaded file in "{destination}": insufficient write permissions. Error: {error}', ['destination' => $targetFolderObject->getIdentifier(), 'error' => $e->getMessage()]); + $this->addMessageToFlashMessageQueue('FileUtility.UploadedFileCouldNotBeMoved', [$targetFolderObject->getIdentifier()]); + } + } + + return $resultObjects; + } + + /** + * Replaces a file on the filesystem and changes the identifier of the persisted file object in sys_file if + * keepFilename is not checked. If keepFilename is checked, only the file content will be replaced. + * + * @return array|bool + * @throws Exception\InsufficientFileAccessPermissionsException + * @throws Exception\InvalidFileException + * @throws \RuntimeException + */ + protected function replaceFile(array $cmdArr) + { + $fileObjectToReplace = null; + $uploadPosition = $cmdArr['data']; + $uploadedFile = $this->uploadedFiles['replace_' . $uploadPosition]; + if (empty($uploadedFile->getClientFilename())) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::SYSTEM_ERROR, 'No file was uploaded for replacement'); + $this->addMessageToFlashMessageQueue('FileUtility.NoFileWasUploadedForReplacing'); + return false; + } + + $keepFileName = (bool)($cmdArr['keepFilename'] ?? false); + $resultObjects = []; + + try { + $fileObjectToReplace = $this->getFileObject($cmdArr['uid']); + $folder = $fileObjectToReplace->getParentFolder(); + $resourceStorage = $fileObjectToReplace->getStorage(); + $uploadedFileExtension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION); + + if (!$keepFileName) { + $fileObject = $resourceStorage->replaceAndRenameUploadedFile($uploadedFile, $fileObjectToReplace); + } elseif ($uploadedFileExtension !== $fileObjectToReplace->getExtension()) { + // `keepFileName` would cause a failing consistency check, for instance, when adding `image/png` contents to an existing `file.pdf`. + // This step ensures the file is renamed from `file.pdf` to `file.png`. + $targetFileName = pathinfo($fileObjectToReplace->getName(), PATHINFO_FILENAME) . '.' . $uploadedFileExtension; + $fileObject = $resourceStorage->replaceAndRenameUploadedFile($uploadedFile, $fileObjectToReplace, $targetFileName); + } else { + $fileObject = $resourceStorage->addUploadedFile($uploadedFile, $folder, $fileObjectToReplace->getName(), DuplicationBehavior::REPLACE); + } + + $resultObjects[] = $fileObject; + $this->internalUploadMap[$uploadPosition] = $fileObject->getCombinedIdentifier(); + + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::MESSAGE, 'File "{identifier}" replaced with "{destination}"', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $fileObjectToReplace->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.ReplacingFileTo', [$uploadedFile->getClientFilename(), $fileObjectToReplace->getIdentifier()], ContextualFeedbackSeverity::OK); + } catch (InsufficientFileWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Overwrite denied for "{destination}" due to insufficient permissions', ['destination' => $uploadedFile->getClientFilename()]); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToOverride', [$uploadedFile->getClientFilename()]); + } catch (UploadException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::SYSTEM_ERROR, 'Upload failed because no file was provided'); + $this->addMessageToFlashMessageQueue('FileUtility.TheUploadHasFailedNoUploadedFileFound'); + } catch (InsufficientUserPermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'File uploading denied due to insufficient permissions'); + $this->addMessageToFlashMessageQueue('FileUtility.YouAreNotAllowedToUploadFiles'); + } catch (UploadSizeException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Upload failed: file "{identifier}" exceeds the configured size limit', ['identifier' => $uploadedFile->getClientFilename()]); + $this->addMessageToFlashMessageQueue('FileUtility.TheUploadedFileExceedsTheSize-limit', [$uploadedFile->getClientFilename()]); + } catch (InsufficientFolderWritePermissionsException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Destination path "{destination}" is outside the configured mountpoints', ['destination' => $fileObjectToReplace->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.DestinationPathWasNotWithinYourMountpoints', [$fileObjectToReplace->getIdentifier()]); + } catch (IllegalFileExtensionException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Extension of file name "{identifier}" is not allowed in "{destination}"', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $fileObjectToReplace->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.ExtensionOfFileNameIsNotAllowedIn', [$uploadedFile->getClientFilename(), $fileObjectToReplace->getIdentifier()]); + } catch (ExistingTargetFileNameException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'No unique filename available in "{destination}"', ['destination' => $fileObjectToReplace->getIdentifier()]); + $this->addMessageToFlashMessageQueue('FileUtility.NoUniqueFilenameAvailableIn', [$fileObjectToReplace->getIdentifier()]); + } catch (ResultException $e) { + $this->writeLog(SystemLogFileAction::UPLOAD, SystemLogErrorClassification::USER_ERROR, 'Replacing file "{identifier}" to "{destination}" failed', ['identifier' => $uploadedFile->getClientFilename(), 'destination' => $fileObjectToReplace->getIdentifier()]); + $this->addEvaluationResultHintsToFlashMessageQueue($e); + } catch (\RuntimeException $e) { + throw $e; + } + return $resultObjects; + } + + /** + * Add flash message to message queue + */ + protected function addFlashMessage(FlashMessage $flashMessage) + { + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + + $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + protected function isBackendScope(): bool + { + return ($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend(); + } + + protected function uploadedFileHasClientName(array|UploadedFileInterface|null $file): bool + { + if ($file instanceof UploadedFileInterface) { + return !empty($file->getClientFilename()); + } + if (isset($file[0]) && $file[0] instanceof UploadedFileInterface) { + return !empty($file[0]->getClientFilename()); + } + return false; + } + + /** + * Gets Indexer + * + * @return \TYPO3\CMS\Core\Resource\Index\Indexer + */ + protected function getIndexer(ResourceStorage $storage) + { + return GeneralUtility::makeInstance(Indexer::class, $storage); + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Utility/File/FileSystem.php b/Classes/Utility/File/FileSystem.php new file mode 100644 index 0000000..0273b48 --- /dev/null +++ b/Classes/Utility/File/FileSystem.php @@ -0,0 +1,220 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\File; + +use Symfony\Component\Filesystem\Exception\IOException; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Utility\CommandUtility; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Most of this code is thankfully taken from \Composer\Util\Filesystem + * + * @internal Only to be used in TYPO3\CMS\Core\SystemResource namespace + */ +readonly class FileSystem +{ + /** + * Returns the shortest path from $from to $to + * + * @param bool $directories If true, the source/target are considered to be directories + * @throws \InvalidArgumentException + */ + public function findShortestPath(string $from, string $to, bool $directories = false): string + { + if (!PathUtility::isAbsolutePath($from) || !PathUtility::isAbsolutePath($to)) { + throw new \InvalidArgumentException(sprintf('$from (%s) and $to (%s) must be absolute paths.', $from, $to), 1765283155); + } + + $from = PathUtility::getCanonicalPath($from); + $to = PathUtility::getCanonicalPath($to); + + if ($directories) { + $from = rtrim($from, '/') . '/dummy_file'; + } + + if (dirname($from) === dirname($to)) { + return './' . basename($to); + } + + $commonPath = $to; + while (!str_starts_with($from . '/', $commonPath . '/') && $commonPath !== '/' && preg_match('{^[A-Z]:/?$}i', $commonPath) === 0) { + $commonPath = str_replace('\\', '/', dirname($commonPath)); + } + + // no commonality at all + if (!str_starts_with($from, $commonPath)) { + return $to; + } + + $commonPath = rtrim($commonPath, '/') . '/'; + $sourcePathDepth = substr_count((string)substr($from, strlen($commonPath)), '/'); + $commonPathCode = str_repeat('../', $sourcePathDepth); + + $result = $commonPathCode . substr($to, strlen($commonPath)); + if ($result === '') { + return './'; + } + return $result; + } + + /** + * Creates a relative symlink from $link to $target + * + * @param string $target The path of the binary file to be symlinked + * @param string $link The path where the symlink should be created + */ + public function relativeSymlink(string $target, string $link): bool + { + if (!function_exists('symlink')) { + return false; + } + + $cwd = $this->getCwd(); + + $relativePath = $this->findShortestPath($link, $target); + chdir(dirname($link)); + $result = @symlink($relativePath, $link); + + chdir($cwd); + + return $result; + } + + /** + * Return true if that directory is a symlink. + */ + public function isSymlinkedDirectory(string $directory): bool + { + if (!is_dir($directory)) { + return false; + } + + $resolved = $this->resolveSymlinkedDirectorySymlink($directory); + + return is_link($resolved); + } + + /** + * Return true if that file is a symlink. + */ + public function isSymlinkedFile(string $file): bool + { + if (!is_file($file)) { + return false; + } + return is_link($file); + } + + /** + * Creates an NTFS junction. + */ + public function junction(string $target, string $junction): void + { + if (!Environment::isWindows()) { + throw new \LogicException(sprintf('Function %s is not available on non-Windows platform', __CLASS__), 1765283168); + } + if (!is_dir($target)) { + throw new IOException(sprintf('Cannot junction to "%s" as it is not a directory.', $target), 1765283131, null, $target); + } + + // Removing any previously junction to ensure clean execution. + if (!is_dir($junction) || $this->isJunction($junction)) { + @rmdir($junction); + } + $commandLine = [ + 'mklink', + '/J', + ]; + $commandLine[] = str_replace('/', DIRECTORY_SEPARATOR, $junction); + $commandLine[] = realpath($target); + CommandUtility::exec($commandLine); + + if (CommandUtility::exec($commandLine) === false) { + throw new IOException(sprintf('Failed to create junction to "%s" at "%s".', $target, $junction), 1763664408, null, $target); + } + clearstatcache(true, $junction); + } + + /** + * Returns whether the target directory is a Windows NTFS Junction. + * + * We test if the path is a directory and not an ordinary link, then check + * that the mode value returned from lstat (which gives the status of the + * link itself) is not a directory, by replicating the POSIX S_ISDIR test. + * + * @param string $junction Path to check. + */ + public function isJunction(string $junction): bool + { + if (!Environment::isWindows()) { + return false; + } + + // Important to clear all caches first + clearstatcache(true, $junction); + + if (!is_dir($junction) || is_link($junction)) { + return false; + } + + $stat = lstat($junction); + + // S_ISDIR test (S_IFDIR is 0x4000, S_IFMT is 0xF000 bitmask) + return is_array($stat) && ($stat['mode'] & 0xF000) !== 0x4000; + } + + /** + * Resolve pathname to symbolic link of a directory + * + * @param string $pathname Directory path to resolve + */ + private function resolveSymlinkedDirectorySymlink(string $pathname): string + { + if (!is_dir($pathname)) { + return $pathname; + } + + $resolved = rtrim($pathname, '/'); + + if ($resolved === '') { + return $pathname; + } + + return $resolved; + } + + /** + * getcwd() equivalent which always returns a string + * + * @throws \RuntimeException + */ + private function getCwd(): string + { + $cwd = getcwd(); + // fallback to realpath('') just in case this works but odds are it would break as well if we are in a case where getcwd fails + if ($cwd === false) { + $cwd = realpath(''); + } + // crappy state, assume '' and hopefully relative paths allow things to continue + if ($cwd === false) { + throw new \RuntimeException('Could not determine the current working directory', 1765283181); + } + return $cwd; + } +} diff --git a/Classes/Utility/GeneralUtility.php b/Classes/Utility/GeneralUtility.php new file mode 100644 index 0000000..71e7440 --- /dev/null +++ b/Classes/Utility/GeneralUtility.php @@ -0,0 +1,2869 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use Egulias\EmailValidator\EmailValidator; +use Egulias\EmailValidator\Validation\EmailValidation; +use Egulias\EmailValidator\Validation\MultipleValidationWithAnd; +use Egulias\EmailValidator\Validation\RFCValidation; +use Egulias\EmailValidator\Warning\CFWSNearAt; +use GuzzleHttp\Exception\TransferException; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Authentication\AbstractAuthenticationService; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Core\ClassLoadingInformation; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\RequestFactory; +use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Security\AllowedCallableAssertion; +use TYPO3\CMS\Core\Security\RawValue; +use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException; +use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceException; + +/** + * The legendary "t3lib_div" class - Miscellaneous functions for general purpose. + * Most of the functions do not relate specifically to TYPO3 + * However a section of functions requires certain TYPO3 features available + * See comments in the source. + * You are encouraged to use this library in your own scripts! + * + * USE: + * All methods in this class are meant to be called statically. + * So use \TYPO3\CMS\Core\Utility\GeneralUtility::[method-name] to refer to the functions, eg. '\TYPO3\CMS\Core\Utility\GeneralUtility::milliseconds()' + */ +class GeneralUtility +{ + protected static ?ContainerInterface $container = null; + + /** + * Singleton instances returned by `makeInstance`, using the class names as array keys + * + * @var array<class-string, SingletonInterface> + */ + protected static array $singletonInstances = []; + + /** + * Instances returned by `makeInstance`, using the class names as array keys + * + * @var array<class-string, array<int, object>> + */ + protected static array $nonSingletonInstances = []; + + /** + * Cache for `makeInstance` with given class name and final class names to reduce number of + * `self::getClassName()` calls + * + * @var array<class-string, class-string> Given class name => final class name + */ + protected static array $finalClassNameCache = []; + + final private function __construct() {} + + /** + * Truncates a string with appended/prepended "..." and takes current character set into consideration. + * + * @param string $string String to truncate + * @param int $chars Must be an integer with an absolute value of at least 4. if negative the string is cropped from the right end. + * @param string $appendString Appendix to the truncated string + * @return string Cropped string + */ + public static function fixed_lgd_cs(string $string, int $chars, string $appendString = '...'): string + { + if ($chars === 0 || mb_strlen($string, 'utf-8') <= abs($chars)) { + return $string; + } + if ($chars > 0) { + $string = mb_substr($string, 0, $chars, 'utf-8') . $appendString; + } else { + $string = $appendString . mb_substr($string, $chars, mb_strlen($string, 'utf-8'), 'utf-8'); + } + return $string; + } + + /** + * Match IP number with list of numbers with wildcard + * Dispatcher method for switching into specialised IPv4 and IPv6 methods. + * + * @param string $baseIP Is the current remote IP address for instance, typ. REMOTE_ADDR + * @param string $list Is a comma-list of IP-addresses to match with. CIDR-notation should be used. For IPv4 addresses only, the *-wildcard is also allowed instead of number, plus leaving out parts in the IP number is accepted as wildcard (eg. 192.168.*.* equals 192.168). If list is "*" no check is done and the function returns TRUE immediately. An empty list always returns FALSE. + * @return bool TRUE if an IP-mask from $list matches $baseIP + */ + public static function cmpIP(string $baseIP, string $list): bool + { + $list = trim($list); + if ($list === '') { + return false; + } + if ($list === '*') { + return true; + } + if (str_contains($baseIP, ':') && self::validIPv6($baseIP)) { + return self::cmpIPv6($baseIP, $list); + } + return self::cmpIPv4($baseIP, $list); + } + + /** + * Match IPv4 number with list of numbers with wildcard + * + * @param string $baseIP Is the current remote IP address for instance, typ. REMOTE_ADDR + * @param string $list Is a comma-list of IP-addresses to match with. CIDR-notation, *-wildcard allowed instead of number, plus leaving out parts in the IP number is accepted as wildcard (eg. 192.168.0.0/16 equals 192.168.*.* equals 192.168), could also contain IPv6 addresses + * @return bool TRUE if an IP-mask from $list matches $baseIP + */ + public static function cmpIPv4(string $baseIP, string $list): bool + { + $IPpartsReq = explode('.', $baseIP); + if (count($IPpartsReq) === 4) { + $values = self::trimExplode(',', $list, true); + foreach ($values as $test) { + $testList = explode('/', $test); + if (count($testList) === 2) { + [$test, $mask] = $testList; + } else { + $mask = false; + } + if ((int)$mask) { + $mask = (int)$mask; + // "192.168.3.0/24" + $lnet = (int)ip2long($test); + $lip = (int)ip2long($baseIP); + $binnet = str_pad(decbin($lnet), 32, '0', STR_PAD_LEFT); + $firstpart = substr($binnet, 0, $mask); + $binip = str_pad(decbin($lip), 32, '0', STR_PAD_LEFT); + $firstip = substr($binip, 0, $mask); + $yes = $firstpart === $firstip; + } else { + // "192.168.*.*" + $IPparts = explode('.', $test); + $yes = 1; + foreach ($IPparts as $index => $val) { + $val = trim($val); + if ($val !== '*' && $IPpartsReq[$index] !== $val) { + $yes = 0; + } + } + } + if ($yes) { + return true; + } + } + } + return false; + } + + /** + * Match IPv6 address with a list of IPv6 prefixes + * + * @param string $baseIP Is the current remote IP address for instance + * @param string $list Is a comma-list of IPv6 prefixes, could also contain IPv4 addresses. IPv6 addresses + * must be specified in CIDR-notation, not with * wildcard, otherwise self::validIPv6() will fail. + * @return bool TRUE If a baseIP matches any prefix + */ + public static function cmpIPv6(string $baseIP, string $list): bool + { + // Policy default: Deny connection + $success = false; + $baseIP = self::normalizeIPv6($baseIP); + $values = self::trimExplode(',', $list, true); + foreach ($values as $test) { + $testList = explode('/', $test); + if (count($testList) === 2) { + [$test, $mask] = $testList; + } else { + $mask = false; + } + if (self::validIPv6($test)) { + $test = self::normalizeIPv6($test); + $maskInt = (int)$mask ?: 128; + // Special case; /0 is an allowed mask - equals a wildcard + if ($mask === '0') { + $success = true; + } elseif ($maskInt == 128) { + $success = $test === $baseIP; + } else { + $testBin = (string)inet_pton($test); + $baseIPBin = (string)inet_pton($baseIP); + + $success = true; + // Modulo is 0 if this is a 8-bit-boundary + $maskIntModulo = $maskInt % 8; + $numFullCharactersUntilBoundary = (int)($maskInt / 8); + $substring = (string)substr($baseIPBin, 0, $numFullCharactersUntilBoundary); + if (!str_starts_with($testBin, $substring)) { + $success = false; + } elseif ($maskIntModulo > 0) { + // If not an 8-bit-boundary, check bits of last character + $testLastBits = str_pad(decbin(ord(substr($testBin, $numFullCharactersUntilBoundary, 1))), 8, '0', STR_PAD_LEFT); + $baseIPLastBits = str_pad(decbin(ord(substr($baseIPBin, $numFullCharactersUntilBoundary, 1))), 8, '0', STR_PAD_LEFT); + if (strncmp($testLastBits, $baseIPLastBits, $maskIntModulo) != 0) { + $success = false; + } + } + } + } + if ($success) { + return true; + } + } + return false; + } + + /** + * Normalize an IPv6 address to full length + * + * @param string $address Given IPv6 address + * @return string Normalized address + */ + public static function normalizeIPv6(string $address): string + { + $normalizedAddress = ''; + // According to RFC lowercase-representation is recommended + $address = strtolower($address); + // Normalized representation has 39 characters (0000:0000:0000:0000:0000:0000:0000:0000) + if (strlen($address) === 39) { + // Already in full expanded form + return $address; + } + // Count 2 if if address has hidden zero blocks + $chunks = explode('::', $address); + if (count($chunks) === 2) { + $chunksLeft = explode(':', $chunks[0]); + $chunksRight = explode(':', $chunks[1]); + $left = count($chunksLeft); + $right = count($chunksRight); + // Special case: leading zero-only blocks count to 1, should be 0 + if ($left === 1 && strlen($chunksLeft[0]) === 0) { + $left = 0; + } + $hiddenBlocks = 8 - ($left + $right); + $hiddenPart = ''; + $h = 0; + while ($h < $hiddenBlocks) { + $hiddenPart .= '0000:'; + $h++; + } + if ($left === 0) { + $stageOneAddress = $hiddenPart . $chunks[1]; + } else { + $stageOneAddress = $chunks[0] . ':' . $hiddenPart . $chunks[1]; + } + } else { + $stageOneAddress = $address; + } + // Normalize the blocks: + $blocks = explode(':', $stageOneAddress); + $divCounter = 0; + foreach ($blocks as $block) { + $tmpBlock = ''; + $i = 0; + $hiddenZeros = 4 - strlen($block); + while ($i < $hiddenZeros) { + $tmpBlock .= '0'; + $i++; + } + $normalizedAddress .= $tmpBlock . $block; + if ($divCounter < 7) { + $normalizedAddress .= ':'; + $divCounter++; + } + } + return $normalizedAddress; + } + + /** + * Validate a given IP address. + * + * Possible format are IPv4 and IPv6. + * + * @param string $ip IP address to be tested + * @return bool TRUE if $ip is either of IPv4 or IPv6 format. + */ + public static function validIP(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + /** + * Validate a given IP address to the IPv4 address format. + * + * Example for possible format: 10.0.45.99 + * + * @param string $ip IP address to be tested + * @return bool TRUE if $ip is of IPv4 format. + */ + public static function validIPv4(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } + + /** + * Validate a given IP address to the IPv6 address format. + * + * Example for possible format: 43FB::BB3F:A0A0:0 | ::1 + * + * @param string $ip IP address to be tested + * @return bool TRUE if $ip is of IPv6 format. + */ + public static function validIPv6(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + + /** + * Match fully qualified domain name with list of strings with wildcard + * + * @param string $baseHost A hostname or an IPv4/IPv6-address (will by reverse-resolved; typically REMOTE_ADDR) + * @param string $list A comma-list of domain names to match with. *-wildcard allowed but cannot be part of a string, so it must match the full host name (eg. myhost.*.com => correct, myhost.*domain.com => wrong) + * @return bool TRUE if a domain name mask from $list matches $baseIP + */ + public static function cmpFQDN(string $baseHost, string $list): bool + { + $baseHost = trim($baseHost); + if (empty($baseHost)) { + return false; + } + if (self::validIPv4($baseHost) || self::validIPv6($baseHost)) { + // Resolve hostname + // Note: this is reverse-lookup and can be randomly set as soon as somebody is able to set + // the reverse-DNS for his IP (security when for example used with REMOTE_ADDR) + $baseHostName = (string)gethostbyaddr($baseHost); + if ($baseHostName === $baseHost) { + // Unable to resolve hostname + return false; + } + } else { + $baseHostName = $baseHost; + } + $baseHostNameParts = explode('.', $baseHostName); + $values = self::trimExplode(',', $list, true); + foreach ($values as $test) { + $hostNameParts = explode('.', $test); + // To match hostNameParts can only be shorter (in case of wildcards) or equal + $hostNamePartsCount = count($hostNameParts); + $baseHostNamePartsCount = count($baseHostNameParts); + if ($hostNamePartsCount > $baseHostNamePartsCount) { + continue; + } + $yes = true; + foreach ($hostNameParts as $index => $val) { + $val = trim($val); + if ($val === '*') { + // Wildcard valid for one or more hostname-parts + $wildcardStart = $index + 1; + // Wildcard as last/only part always matches, otherwise perform recursive checks + if ($wildcardStart < $hostNamePartsCount) { + $wildcardMatched = false; + $tempHostName = implode('.', array_slice($hostNameParts, $index + 1)); + while ($wildcardStart < $baseHostNamePartsCount && !$wildcardMatched) { + $tempBaseHostName = implode('.', array_slice($baseHostNameParts, $wildcardStart)); + $wildcardMatched = self::cmpFQDN($tempBaseHostName, $tempHostName); + $wildcardStart++; + } + if ($wildcardMatched) { + // Match found by recursive compare + return true; + } + $yes = false; + } + } elseif ($baseHostNameParts[$index] !== $val) { + // In case of no match + $yes = false; + } + } + if ($yes) { + return true; + } + } + return false; + } + + /** + * Checks if a given URL matches the host that currently handles this HTTP request. + * Scheme, hostname and (optional) port of the given URL are compared. + * + * @param string $url URL to compare with the TYPO3 request host + * @param ServerRequestInterface $request PSR-7 request including normalizedParams attribute + * @return bool Whether the URL matches the TYPO3 request host + */ + public static function isOnCurrentHost(string $url, ServerRequestInterface $request): bool + { + $normalizedParams = $request->getAttribute('normalizedParams'); + if ($normalizedParams === null) { + throw new \RuntimeException('GeneralUtility::isOnCurrentHost() requires the request to have a normalizedParams attribute.', 1775679512); + } + return stripos($url . '/', $normalizedParams->getRequestHost() . '/') === 0; + } + + /** + * Check for item in list + * Check if an item exists in a comma-separated list of items. + * + * @param string $list Comma-separated list of items (string) + * @param string $item Item to check for + * @return bool TRUE if $item is in $list + */ + public static function inList($list, $item) + { + return str_contains(',' . $list . ',', ',' . $item . ','); + } + + /** + * Expand a comma-separated list of integers with ranges (eg 1,3-5,7 becomes 1,3,4,5,7). + * Ranges are limited to 1000 values per range. + * + * @param string $list Comma-separated list of integers with ranges (string) + * @return string New comma-separated list of items + */ + public static function expandList($list): string + { + $items = explode(',', $list); + $list = []; + foreach ($items as $item) { + $range = explode('-', $item); + if (isset($range[1])) { + $runAwayBrake = 1000; + for ($n = $range[0]; $n <= $range[1]; $n++) { + $list[] = $n; + $runAwayBrake--; + if ($runAwayBrake <= 0) { + break; + } + } + } else { + $list[] = $item; + } + } + return implode(',', $list); + } + + /** + * Makes a positive integer hash out of the first 7 chars from the md5 hash of the input + * + * @param string $str String to md5-hash + * @return int Returns 28bit integer-hash + */ + public static function md5int($str) + { + return hexdec(substr(md5($str), 0, 7)); + } + + /** + * Splits a reference to a file in 5 parts + * + * @param string $fileNameWithPath File name with path to be analyzed (must exist if open_basedir is set) + * @return array<string, string> Contains keys [path], [file], [filebody], [fileext], [realFileext] + */ + public static function split_fileref(string $fileNameWithPath): array + { + $info = []; + $reg = []; + if (preg_match('/(.*\\/)(.*)$/', $fileNameWithPath, $reg)) { + $info['path'] = $reg[1]; + $info['file'] = $reg[2]; + } else { + $info['path'] = ''; + $info['file'] = $fileNameWithPath; + } + $reg = ''; + // If open_basedir is set and the fileName was supplied without a path the is_dir check fails + if (!is_dir($fileNameWithPath) && preg_match('/(.*)\\.([^\\.]*$)/', $info['file'], $reg)) { + $info['filebody'] = $reg[1]; + $info['fileext'] = strtolower($reg[2]); + $info['realFileext'] = $reg[2]; + } else { + $info['filebody'] = $info['file']; + $info['fileext'] = ''; + } + return $info; + } + + /** + * Returns the directory part of a path without trailing slash + * If there is no dir-part, then an empty string is returned. + * Behaviour: + * + * '/dir1/dir2/script.php' => '/dir1/dir2' + * '/dir1/' => '/dir1' + * 'dir1/script.php' => 'dir1' + * 'd/script.php' => 'd' + * '/script.php' => '' + * '' => '' + * + * @param string $path Directory name / path + * @return string Processed input value. See function description. + */ + public static function dirname($path) + { + $p = self::revExplode('/', $path, 2); + return count($p) === 2 ? $p[0] : ''; + } + + /** + * Formats the input integer $sizeInBytes as bytes/kilobytes/megabytes (-/K/M) + * + * @param int $sizeInBytes Number of bytes to format. + * @param string $labels Binary unit name "iec", decimal unit name "si" or labels for bytes, kilo, mega, giga, and so on separated by vertical bar (|) and possibly encapsulated in "". Eg: " | K| M| G". Defaults to "iec". + * @param int $base The unit base if not using a unit name. Defaults to 1024. + * @return string Formatted representation of the byte number, for output. + */ + public static function formatSize($sizeInBytes, $labels = '', $base = 0, ?int $decimals = null) + { + $defaultFormats = [ + 'iec' => ['base' => 1024, 'labels' => [' ', ' Ki', ' Mi', ' Gi', ' Ti', ' Pi', ' Ei', ' Zi', ' Yi']], + 'si' => ['base' => 1000, 'labels' => [' ', ' k', ' M', ' G', ' T', ' P', ' E', ' Z', ' Y']], + ]; + // Set labels and base: + if (empty($labels)) { + $labels = 'iec'; + } + if (isset($defaultFormats[$labels])) { + $base = $defaultFormats[$labels]['base']; + $labelArr = $defaultFormats[$labels]['labels']; + } else { + $base = (int)$base; + if ($base !== 1000 && $base !== 1024) { + $base = 1024; + } + $labelArr = explode('|', str_replace('"', '', $labels)); + } + // This is set via Site Handling and in the Locales class via setlocale() + // LC_NUMERIC is not set because of side effects when calculating with floats + // see @\TYPO3\CMS\Core\Localization\Locales::setLocale + $currentLocale = setlocale(LC_MONETARY, '0'); + $oldLocale = setlocale(LC_NUMERIC, '0'); + setlocale(LC_NUMERIC, $currentLocale); + $localeInfo = localeconv(); + setlocale(LC_NUMERIC, $oldLocale); + + $sizeInBytes = max($sizeInBytes, 0); + $multiplier = floor(($sizeInBytes ? log($sizeInBytes) : 0) / log($base)); + $sizeInUnits = $sizeInBytes / $base ** $multiplier; + if ($sizeInUnits > ($base * .9)) { + $multiplier++; + } + $multiplier = min($multiplier, count($labelArr) - 1); + $sizeInUnits = $sizeInBytes / $base ** $multiplier; + $decimals ??= (($multiplier > 0) && ($sizeInUnits < 20)) ? 2 : 0; + return number_format($sizeInUnits, $decimals, $localeInfo['decimal_point'], '') . $labelArr[$multiplier]; + } + + /** + * This splits a string by the chars in $operators (typical /+-*) and returns an array with them in + * + * @param string $string Input string, eg "123 + 456 / 789 - 4 + * @param string $operators Operators to split by, typically "/+-* + * @return array<int, array<int, string>> Array with operators and operands separated. + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::calc() + * @see \TYPO3\CMS\Frontend\Imaging\GifBuilder::calcOffset() + */ + public static function splitCalc($string, $operators) + { + $res = []; + $sign = '+'; + while ($string) { + $valueLen = strcspn($string, $operators); + $value = substr($string, 0, $valueLen); + $res[] = [$sign, trim($value)]; + $sign = substr($string, $valueLen, 1); + $string = substr($string, $valueLen + 1); + } + reset($res); + return $res; + } + + /** + * Checking syntax of input email address + * + * @param string $email Input string to evaluate + * @return bool Returns TRUE if the $email address (input string) is valid + */ + public static function validEmail(string $email): bool + { + if (trim($email) !== $email) { + return false; + } + if (!str_contains($email, '@')) { + return false; + } + + $validators = []; + foreach ($GLOBALS['TYPO3_CONF_VARS']['MAIL']['validators'] ?? [RFCValidation::class] as $className) { + $validator = new $className(); + if ($validator instanceof EmailValidation) { + $validators[] = $validator; + } + } + + $emailValidator = new EmailValidator(); + $isValid = $emailValidator->isValid($email, new MultipleValidationWithAnd($validators, MultipleValidationWithAnd::STOP_ON_ERROR)); + + // Currently, the RFCValidation doesn't recognise "email @example.com" + // as an invalid email for historic reasons - catch it here + // see https://github.com/egulias/EmailValidator/issues/374 + if ($isValid) { + // If email is valid, check if we have CFWSNearAt warning and + // treat it as an invalid email, i.e "email @example.com" + foreach ($emailValidator->getWarnings() as $warning) { + if ($warning instanceof CFWSNearAt) { + return false; + } + } + } + + return $isValid; + } + + /** + * Returns a given string with underscores as UpperCamelCase. + * Example: Converts blog_example to BlogExample + * + * @param string $string String to be converted to camel case + * @return string UpperCamelCasedWord + */ + public static function underscoredToUpperCamelCase($string) + { + return str_replace(' ', '', ucwords(str_replace('_', ' ', strtolower($string)))); + } + + /** + * Returns a given string with underscores as lowerCamelCase. + * Example: Converts minimal_value to minimalValue + * + * @param string $string String to be converted to camel case + * @return string lowerCamelCasedWord + */ + public static function underscoredToLowerCamelCase($string) + { + return lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', strtolower($string))))); + } + + /** + * Returns a given CamelCasedString as a lowercase string with underscores. + * Example: Converts BlogExample to blog_example, and minimalValue to minimal_value + * + * @param string $string String to be converted to lowercase underscore + * @return string lowercase_and_underscored_string + */ + public static function camelCaseToLowerCaseUnderscored($string) + { + $value = preg_replace('/(?<=\\w)([A-Z])/', '_\\1', $string) ?? ''; + return mb_strtolower($value, 'utf-8'); + } + + /** + * Checks if a given string is a Uniform Resource Locator (URL). + * + * On seriously malformed URLs, parse_url may return FALSE and emit an + * E_WARNING. + * + * filter_var() requires a scheme to be present. + * + * http://www.faqs.org/rfcs/rfc2396.html + * Scheme names consist of a sequence of characters beginning with a + * lower case letter and followed by any combination of lower case letters, + * digits, plus ("+"), period ("."), or hyphen ("-"). For resiliency, + * programs interpreting URI should treat upper case letters as equivalent to + * lower case in scheme names (e.g., allow "HTTP" as well as "http"). + * scheme = alpha *( alpha | digit | "+" | "-" | "." ) + * + * Convert the domain part to punicode if it does not look like a regular + * domain name. Only the domain part because RFC3986 specifies the the rest of + * the url may not contain special characters: + * https://tools.ietf.org/html/rfc3986#appendix-A + * + * @param string $url The URL to be validated + * @return bool Whether the given URL is valid + */ + public static function isValidUrl(string $url): bool + { + $parsedUrl = parse_url($url); + if (!$parsedUrl || !isset($parsedUrl['scheme'])) { + return false; + } + // HttpUtility::buildUrl() will always build urls with <scheme>:// + // our original $url might only contain <scheme>: (e.g. mail:) + // so we convert that to the double-slashed version to ensure + // our check against the $recomposedUrl is proper + if (!str_starts_with($url, $parsedUrl['scheme'] . '://')) { + $url = str_replace($parsedUrl['scheme'] . ':', $parsedUrl['scheme'] . '://', $url); + } + $recomposedUrl = HttpUtility::buildUrl($parsedUrl); + if ($recomposedUrl !== $url) { + // The parse_url() had to modify characters, so the URL is invalid + return false; + } + if (isset($parsedUrl['host']) && !preg_match('/^[a-z0-9.\\-]*$/i', $parsedUrl['host'])) { + $host = idn_to_ascii($parsedUrl['host']); + if ($host === false) { + return false; + } + $parsedUrl['host'] = $host; + } + return filter_var(HttpUtility::buildUrl($parsedUrl), FILTER_VALIDATE_URL) !== false; + } + + /************************* + * + * ARRAY FUNCTIONS + * + *************************/ + + /** + * Explodes a $string delimited by $delimiter and casts each item in the array to (int). + * Corresponds to \TYPO3\CMS\Core\Utility\GeneralUtility::trimExplode(), but with conversion to integers for all values. + * + * @param string $delimiter Delimiter string to explode with + * @param string $string The string to explode + * @param bool $removeEmptyValues If set, all empty values (='') will NOT be set in output + * @return list<int> Exploded values, all converted to integers + */ + public static function intExplode(string $delimiter, string $string, bool $removeEmptyValues = false): array + { + $result = explode($delimiter, $string); + foreach ($result as $key => &$value) { + if ($removeEmptyValues && trim($value) === '') { + unset($result[$key]); + } else { + $value = (int)$value; + } + } + unset($value); + + /** @var array<int, int> $result */ + return array_values($result); + } + + /** + * Reverse explode which explodes the string counting from behind. + * + * Note: The delimiter has to given in the reverse order as + * it is occurring within the string. + * + * GeneralUtility::revExplode('[]', '[my][words][here]', 2) + * ==> array('[my][words', 'here]') + * + * @param string $delimiter Delimiter string to explode with + * @param string $string The string to explode + * @param int $limit Number of array entries + * + * @return list<string> Exploded values + */ + public static function revExplode(string $delimiter, string $string, int $limit = 0): array + { + // 2 is the (currently, as of 2014-02) most-used value for `$limit` in the core, therefore we check it first + if ($limit === 2) { + $position = strrpos($string, strrev($delimiter)); + if ($position !== false) { + return [substr($string, 0, $position), substr($string, $position + strlen($delimiter))]; + } + return [$string]; + } + if ($limit <= 1) { + return [$string]; + } + $explodedValues = explode($delimiter, strrev($string), $limit); + $explodedValues = array_map(strrev(...), $explodedValues); + return array_reverse($explodedValues); + } + + /** + * Explodes a string and removes whitespace-only values. + * + * If $removeEmptyValues is set, then all values that contain only whitespace are removed. + * + * Each item will have leading and trailing whitespace removed. However, if the tail items are + * returned as a single array item, their internal whitespace will not be modified. + * + * @param string $delim Delimiter string to explode with + * @param string $string The string to explode + * @param bool $removeEmptyValues If set, all empty values will be removed in output + * @param int $limit If limit is set and positive, the returned array will contain a maximum of limit elements with + * the last element containing the rest of string. If the limit parameter is negative, all components + * except the last -limit are returned. + * @return list<string> Exploded values + * @phpstan-return ($removeEmptyValues is true ? list<non-empty-string> : list<string>) Exploded values + */ + public static function trimExplode(string $delim, string $string, bool $removeEmptyValues = false, int $limit = 0): array + { + $result = explode($delim, $string); + if ($removeEmptyValues) { + // Remove items that are just whitespace, but leave whitespace intact for the rest. + $result = array_values(array_filter($result, static fn(string $item): bool => trim($item) !== '')); + } + + if ($limit === 0) { + // Return everything. + return array_map(trim(...), $result); + } + + if ($limit < 0) { + // Trim and return just the first $limit elements and ignore the rest. + return array_map(trim(...), array_slice($result, 0, $limit)); + } + + // Fold the last length - $limit elements into a single trailing item, then trim and return the result. + $tail = array_slice($result, $limit - 1); + $result = array_slice($result, 0, $limit - 1); + if ($tail) { + $result[] = implode($delim, $tail); + } + return array_map(trim(...), $result); + } + + /** + * Implodes a multidim-array into GET-parameters (eg. ¶m[key][key2]=value2¶m[key][key3]=value3) + * + * @param string $name Name prefix for entries. Set to blank if you wish none. + * @param array $theArray The (multidimensional) array to implode + * @param string $str (keep blank) + * @param bool $skipBlank If set, parameters which were blank strings would be removed. + * @param bool $rawurlencodeParamName If set, the param name itself (for example "param[key][key2]") would be rawurlencoded as well. + * @return string Imploded result, fx. ¶m[key][key2]=value2¶m[key][key3]=value3 + * @see explodeUrl2Array() + */ + public static function implodeArrayForUrl(string $name, array $theArray, string $str = '', bool $skipBlank = false, bool $rawurlencodeParamName = false): string + { + foreach ($theArray as $Akey => $AVal) { + $thisKeyName = $name ? $name . '[' . $Akey . ']' : $Akey; + if (is_array($AVal)) { + $str = self::implodeArrayForUrl($thisKeyName, $AVal, $str, $skipBlank, $rawurlencodeParamName); + } else { + $stringValue = (string)$AVal; + if (!$skipBlank || $stringValue !== '') { + $parameterName = $rawurlencodeParamName ? rawurlencode($thisKeyName) : $thisKeyName; + $parameterValue = rawurlencode($stringValue); + $str .= '&' . $parameterName . '=' . $parameterValue; + } + } + } + return $str; + } + + /** + * Explodes a string with GETvars (eg. "&id=1&type=2&ext[mykey]=3") into an array. + * + * Note! If you want to use a multi-dimensional string, consider this plain simple PHP code instead: + * + * $result = []; + * parse_str($queryParametersAsString, $result); + * + * However, if you do magic with a flat structure (e.g. keeping "ext[mykey]" as flat key in a one-dimensional array) + * then this method is for you. + * + * @param string $string GETvars string + * @return array<array-key, string> Array of values. All values AND keys are rawurldecoded() as they properly should be. But this means that any implosion of the array again must rawurlencode it! + * @see implodeArrayForUrl() + */ + public static function explodeUrl2Array(string $string): array + { + $output = []; + $p = explode('&', $string); + foreach ($p as $v) { + if ($v !== '') { + $nameAndValue = explode('=', $v, 2); + $output[rawurldecode($nameAndValue[0])] = isset($nameAndValue[1]) ? rawurldecode($nameAndValue[1]) : ''; + } + } + return $output; + } + + /** + * Removes dots "." from end of a key identifier of TypoScript styled array. + * array('key.' => array('property.' => 'value')) --> array('key' => array('property' => 'value')) + * + * @param array $ts TypoScript configuration array + * @return array TypoScript configuration array without dots at the end of all keys + */ + public static function removeDotsFromTS(array $ts): array + { + $out = []; + foreach ($ts as $key => $value) { + if (is_array($value)) { + $key = rtrim($key, '.'); + $out[$key] = self::removeDotsFromTS($value); + } else { + $out[$key] = $value; + } + } + return $out; + } + + /************************* + * + * HTML/XML PROCESSING + * + *************************/ + /** + * Returns an array with all attributes of the input HTML tag as key/value pairs. Attributes are only lowercase a-z + * $tag is either a whole tag (eg '<TAG OPTION ATTRIB=VALUE>') or the parameter list (ex ' OPTION ATTRIB=VALUE>') + * If an attribute is empty, then the value for the key is empty. You can check if it existed with isset() + * + * @param string $tag HTML-tag string (or attributes only) + * @param bool $decodeEntities Whether to decode HTML entities + * @return array<string, string> Array with the attribute values. + */ + public static function get_tag_attributes(string $tag, bool $decodeEntities = false): array + { + $components = self::split_tag_attributes($tag); + // Attribute name is stored here + $name = ''; + $valuemode = false; + $attributes = []; + foreach ($components as $val) { + // Only if $name is set (if there is an attribute, that waits for a value), that valuemode is enabled. This ensures that the attribute is assigned it's value + if ($val !== '=') { + if ($valuemode) { + if ($name) { + $attributes[$name] = $decodeEntities ? htmlspecialchars_decode($val) : $val; + $name = ''; + } + } else { + if ($key = strtolower(preg_replace('/[^[:alnum:]_\\:\\-]/', '', $val) ?? '')) { + $attributes[$key] = ''; + $name = $key; + } + } + $valuemode = false; + } else { + $valuemode = true; + } + } + return $attributes; + } + + /** + * Returns an array with the 'components' from an attribute list from an HTML tag. The result is normally analyzed by get_tag_attributes + * Removes tag-name if found + * + * @param string $tag HTML-tag string (or attributes only) + * @return string[] Array with the attribute values. + */ + public static function split_tag_attributes(string $tag): array + { + $tag_tmp = trim(preg_replace('/^<[^[:space:]]*/', '', trim($tag)) ?? ''); + // Removes any > in the end of the string + $tag_tmp = trim(rtrim($tag_tmp, '>')); + $value = []; + // Compared with empty string instead , 030102 + while ($tag_tmp !== '') { + $firstChar = $tag_tmp[0]; + if ($firstChar === '"' || $firstChar === '\'') { + $reg = explode($firstChar, $tag_tmp, 3); + $value[] = $reg[1]; + $tag_tmp = trim($reg[2] ?? ''); + } elseif ($firstChar === '=') { + $value[] = '='; + // Removes = chars. + $tag_tmp = trim(substr($tag_tmp, 1)); + } else { + // There are '' around the value. We look for the next ' ' or '>' + $reg = preg_split('/[[:space:]=]/', $tag_tmp, 2); + $value[] = trim($reg[0]); + $tag_tmp = trim(substr($tag_tmp, strlen($reg[0]), 1) . ($reg[1] ?? '')); + } + } + reset($value); + return $value; + } + + /** + * Implodes attributes in the array $arr for an attribute list in eg. and HTML tag (with quotes) + * + * @param array<string, string|int> $arr Array with attribute key/value pairs, eg. "bgcolor" => "red", "border" => 0 + * @param bool $xhtmlSafe If set the resulting attribute list will have a) all attributes in lowercase (and duplicates weeded out, first entry taking precedence) and b) all values htmlspecialchar()'ed. It is recommended to use this switch! + * @param bool $keepBlankAttributes If TRUE, don't check if values are blank. Default is to omit attributes with blank values. + * @return string Imploded attributes, eg. 'bgcolor="red" border="0"' + */ + public static function implodeAttributes(array $arr, bool $xhtmlSafe = false, bool $keepBlankAttributes = false): string + { + if ($xhtmlSafe) { + $newArr = []; + foreach ($arr as $attributeName => $attributeValue) { + $attributeName = strtolower((string)$attributeName); + if (!isset($newArr[$attributeName])) { + $newArr[$attributeName] = htmlspecialchars((string)$attributeValue); + } + } + $arr = $newArr; + } + $list = []; + foreach ($arr as $attributeName => $attributeValue) { + if ((string)$attributeValue !== '' || $keepBlankAttributes) { + $list[] = $attributeName . '="' . $attributeValue . '"'; + } + } + return implode(' ', $list); + } + + /** + * Render a textarea, taking into account whether a leading linefeed needs to be added + * + * The HTML `<textarea>` element has very specific rules for leading + * linefeed (0x0a) characters: if the first char of the content is a + * linefeed, it is to be ignored by parsers: + * https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody:~:text=A%20start%20tag%20whose%20tag%20name%20is%20%22textarea%22 + * + * To represent the exact number of leading line breaks, a supplementary + * linefeed character needs to be prepended to the textarea value, which + * will always be ignored, but ensures that subsequent linefeeds are + * respected. + * + * @param string $value textarea content + * @param array<string, string|int> $attributes Array with attribute key/value pairs, eg. "class" => "my-textarea" + * @return string Generated HTML tag, e.g. <textarea class="my-textarea">\nmyvalue</textarea> + * @internal + */ + public static function renderTextarea(string $value, array $attributes = []): string + { + return sprintf( + '<textarea%s%s>%s%s</textarea>', + $attributes === [] ? '' : ' ', + GeneralUtility::implodeAttributes($attributes, true), + $value !== '' ? LF : '', + htmlspecialchars($value), + ); + } + + /** + * Wraps JavaScript code XHTML ready with <script>-tags + * Automatic re-indenting of the JS code is done by using the first line as indent reference. + * This is nice for indenting JS code with PHP code on the same level. + * + * @param string $string JavaScript code + * @param array<string, string> $attributes (optional) script tag HTML attributes + * @return string The wrapped JS code, ready to put into a XHTML page + */ + public static function wrapJS(string $string, array $attributes = []): string + { + if (trim($string)) { + // remove nl from the beginning + $string = ltrim($string, LF); + // re-ident to one tab using the first line as reference + $match = []; + if (preg_match('/^(\\t+)/', $string, $match)) { + $string = str_replace($match[1], "\t", $string); + } + return '<script ' . GeneralUtility::implodeAttributes($attributes, true) . '> +/*<![CDATA[*/ +' . $string . ' +/*]]>*/ +</script>'; + } + return ''; + } + + /** + * Parses XML input into a PHP array with associative keys + * + * @param string $string XML data input + * @param int $depth Number of element levels to resolve the XML into an array. Any further structure will be set as XML. + * @param array $parserOptions Options that will be passed to PHP's xml_parser_set_option() + * @return array|string The array with the parsed structure unless the XML parser returns with an error in which case the error message string is returned. + */ + public static function xml2tree(string $string, int $depth = 999, array $parserOptions = []): array|string + { + $parser = xml_parser_create(); + $vals = []; + xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0); + xml_parser_set_option($parser, XML_OPTION_SKIP_WHITE, 0); + foreach ($parserOptions as $option => $value) { + xml_parser_set_option($parser, $option, $value); + } + xml_parse_into_struct($parser, $string, $vals); + if (xml_get_error_code($parser)) { + return 'Line ' . xml_get_current_line_number($parser) . ': ' . xml_error_string(xml_get_error_code($parser)); + } + $stack = [[]]; + $stacktop = 0; + $startPoint = 0; + $tagi = []; + foreach ($vals as $key => $val) { + $type = $val['type']; + // open tag: + if ($type === 'open' || $type === 'complete') { + $stack[$stacktop++] = $tagi; + if ($depth == $stacktop) { + $startPoint = $key; + } + $tagi = ['tag' => $val['tag']]; + if (isset($val['attributes'])) { + $tagi['attrs'] = $val['attributes']; + } + if (isset($val['value'])) { + $tagi['values'][] = $val['value']; + } + } + // finish tag: + if ($type === 'complete' || $type === 'close') { + $oldtagi = $tagi; + $tagi = $stack[--$stacktop]; + $oldtag = $oldtagi['tag']; + unset($oldtagi['tag']); + if ($depth == $stacktop + 1) { + if ($key - $startPoint > 0) { + $partArray = array_slice($vals, $startPoint + 1, $key - $startPoint - 1); + $oldtagi['XMLvalue'] = self::xmlRecompileFromStructValArray($partArray); + } else { + $oldtagi['XMLvalue'] = $oldtagi['values'][0]; + } + } + $tagi['ch'][$oldtag][] = $oldtagi; + unset($oldtagi); + } + // cdata + if ($type === 'cdata') { + $tagi['values'][] = $val['value']; + } + } + return $tagi['ch']; + } + + /** + * Converts a PHP array into an XML string. + * The XML output is optimized for readability since associative keys are used as tag names. + * This also means that only alphanumeric characters are allowed in the tag names AND only keys NOT starting with numbers (so watch your usage of keys!). However there are options you can set to avoid this problem. + * Numeric keys are stored with the default tag name "numIndex" but can be overridden to other formats) + * The function handles input values from the PHP array in a binary-safe way; All characters below 32 (except 9,10,13) will trigger the content to be converted to a base64-string + * The PHP variable type of the data IS preserved as long as the types are strings, arrays, integers and booleans. Strings are the default type unless the "type" attribute is set. + * The output XML has been tested with the PHP XML-parser and parses OK under all tested circumstances with 4.x versions. However, with PHP5 there seems to be the need to add an XML prologue a la <?xml version="1.0" encoding="[charset]" standalone="yes" ?> - otherwise UTF-8 is assumed! Unfortunately, many times the output from this function is used without adding that prologue meaning that non-ASCII characters will break the parsing!! This sucks of course! Effectively it means that the prologue should always be prepended setting the right characterset, alternatively the system should always run as utf-8! + * However using MSIE to read the XML output didn't always go well: One reason could be that the character encoding is not observed in the PHP data. The other reason may be if the tag-names are invalid in the eyes of MSIE. Also using the namespace feature will make MSIE break parsing. There might be more reasons... + * + * @param array $array The input PHP array with any kind of data; text, binary, integers. Not objects though. + * @param string $NSprefix tag-prefix, eg. a namespace prefix like "T3:" + * @param int $level Current recursion level. Don't change, stay at zero! + * @param string $docTag Alternative document tag. Default is "phparray". + * @param int $spaceInd If greater than zero, then the number of spaces corresponding to this number is used for indenting, if less than zero - no indentation, if zero - a single TAB is used + * @param array $options Options for the compilation. Key "useNindex" => 0/1 (boolean: whether to use "n0, n1, n2" for num. indexes); Key "useIndexTagForNum" => "[tag for numerical indexes]"; Key "useIndexTagForAssoc" => "[tag for associative indexes"; Key "parentTagMap" => array('parentTag' => 'thisLevelTag') + * @param array $stackData Stack data. Don't touch. + * @return string An XML string made from the input content in the array. + * @see xml2array() + */ + public static function array2xml(array $array, string $NSprefix = '', int $level = 0, string $docTag = 'phparray', int $spaceInd = 0, array $options = [], array $stackData = []): string + { + // The list of byte values which will trigger binary-safe storage. If any value has one of these char values in it, it will be encoded in base64 + $binaryChars = "\0" . chr(1) . chr(2) . chr(3) . chr(4) . chr(5) . chr(6) . chr(7) . chr(8) . chr(11) . chr(12) . chr(14) . chr(15) . chr(16) . chr(17) . chr(18) . chr(19) . chr(20) . chr(21) . chr(22) . chr(23) . chr(24) . chr(25) . chr(26) . chr(27) . chr(28) . chr(29) . chr(30) . chr(31); + // Set indenting mode: + $indentChar = $spaceInd ? ' ' : "\t"; + $indentN = $spaceInd > 0 ? $spaceInd : 1; + $nl = $spaceInd >= 0 ? LF : ''; + // Init output variable: + $output = ''; + // Traverse the input array + foreach ($array as $k => $v) { + $attr = ''; + $tagName = (string)$k; + // Construct the tag name. + // Use tag based on grand-parent + parent tag name + if (isset($stackData['grandParentTagName'], $stackData['parentTagName'], $options['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']])) { + $attr .= ' index="' . htmlspecialchars($tagName) . '"'; + $tagName = (string)$options['grandParentTagMap'][$stackData['grandParentTagName'] . '/' . $stackData['parentTagName']]; + } elseif (isset($stackData['parentTagName'], $options['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM']) && MathUtility::canBeInterpretedAsInteger($tagName)) { + // Use tag based on parent tag name + if current tag is numeric + $attr .= ' index="' . htmlspecialchars($tagName) . '"'; + $tagName = (string)$options['parentTagMap'][$stackData['parentTagName'] . ':_IS_NUM']; + } elseif (isset($stackData['parentTagName'], $options['parentTagMap'][$stackData['parentTagName'] . ':' . $tagName])) { + // Use tag based on parent tag name + current tag + $attr .= ' index="' . htmlspecialchars($tagName) . '"'; + $tagName = (string)$options['parentTagMap'][$stackData['parentTagName'] . ':' . $tagName]; + } elseif (isset($stackData['parentTagName'], $options['parentTagMap'][$stackData['parentTagName']])) { + // Use tag based on parent tag name: + $attr .= ' index="' . htmlspecialchars($tagName) . '"'; + $tagName = (string)$options['parentTagMap'][$stackData['parentTagName']]; + } elseif (MathUtility::canBeInterpretedAsInteger($tagName)) { + // If integer...; + if ($options['useNindex'] ?? false) { + // If numeric key, prefix "n" + $tagName = 'n' . $tagName; + } else { + // Use special tag for num. keys: + $attr .= ' index="' . $tagName . '"'; + $tagName = ($options['useIndexTagForNum'] ?? false) ?: 'numIndex'; + } + } elseif (!empty($options['useIndexTagForAssoc'])) { + // Use tag for all associative keys: + $attr .= ' index="' . htmlspecialchars($tagName) . '"'; + $tagName = $options['useIndexTagForAssoc']; + } + // The tag name is cleaned up so only alphanumeric chars (plus - and _) are in there and not longer than 100 chars either. + $tagName = substr(preg_replace('/[^[:alnum:]_-]/', '', $tagName), 0, 100); + // If the value is an array then we will call this function recursively: + if (is_array($v)) { + // Sub elements: + if (isset($options['alt_options']) && ($options['alt_options'][($stackData['path'] ?? '') . '/' . $tagName] ?? false)) { + $subOptions = $options['alt_options'][($stackData['path'] ?? '') . '/' . $tagName]; + $clearStackPath = (bool)($subOptions['clearStackPath'] ?? false); + } else { + $subOptions = $options; + $clearStackPath = false; + } + if (empty($v)) { + $content = ''; + } else { + $content = $nl . self::array2xml($v, $NSprefix, $level + 1, '', $spaceInd, $subOptions, [ + 'parentTagName' => $tagName, + 'grandParentTagName' => $stackData['parentTagName'] ?? '', + 'path' => $clearStackPath ? '' : ($stackData['path'] ?? '') . '/' . $tagName, + ]) . ($spaceInd >= 0 ? str_pad('', ($level + 1) * $indentN, $indentChar) : ''); + } + // Do not set "type = array". Makes prettier XML but means that empty arrays are not restored with xml2array + if (!isset($options['disableTypeAttrib']) || (int)$options['disableTypeAttrib'] != 2) { + $attr .= ' type="array"'; + } + } else { + $stringValue = (string)$v; + // Just a value: + // Look for binary chars: + $vLen = strlen($stringValue); + // Go for base64 encoding if the initial segment NOT matching any binary char has the same length as the whole string! + if ($vLen && strcspn($stringValue, $binaryChars) != $vLen) { + // If the value contained binary chars then we base64-encode it and set an attribute to notify this situation: + $content = $nl . chunk_split(base64_encode($stringValue)); + $attr .= ' base64="1"'; + } else { + // Otherwise, just htmlspecialchar the stuff: + $content = htmlspecialchars($stringValue); + $dType = gettype($v); + if ($dType !== 'string' && !($options['disableTypeAttrib'] ?? false)) { + $attr .= ' type="' . $dType . '"'; + } + } + } + if ($tagName !== '') { + // Add the element to the output string: + $output .= ($spaceInd >= 0 ? str_pad('', ($level + 1) * $indentN, $indentChar) : '') + . '<' . $NSprefix . $tagName . $attr . '>' . $content . '</' . $NSprefix . $tagName . '>' . $nl; + } + } + // If we are at the outer-most level, then we finally wrap it all in the document tags and return that as the value: + if (!$level) { + $output = '<' . $docTag . '>' . $nl . $output . '</' . $docTag . '>'; + } + return $output; + } + + /** + * Converts an XML string to a PHP array. + * This is the reverse function of array2xml() + * This is a wrapper for xml2arrayProcess that adds a two-level cache + * + * @param string $string XML content to convert into an array + * @param string $NSprefix The tag-prefix resolve, eg. a namespace like "T3:" + * @param bool $reportDocTag If set, the document tag will be set in the key "_DOCUMENT_TAG" of the output array + * @return array|string If the parsing had errors, a string with the error message is returned. Otherwise an array with the content. + * @see array2xml() + * @see xml2arrayProcess() + */ + public static function xml2array(string $string, string $NSprefix = '', bool $reportDocTag = false): array|string + { + $runtimeCache = static::makeInstance(CacheManager::class)->getCache('runtime'); + $firstLevelCache = $runtimeCache->get('generalUtilityXml2Array') ?: []; + $identifier = md5($string . $NSprefix . ($reportDocTag ? '1' : '0')); + // Look up in first level cache + if (empty($firstLevelCache[$identifier])) { + $firstLevelCache[$identifier] = self::xml2arrayProcess($string, $NSprefix, $reportDocTag); + $runtimeCache->set('generalUtilityXml2Array', $firstLevelCache); + } + return $firstLevelCache[$identifier]; + } + + /** + * Converts an XML string to a PHP array. + * This is the reverse function of array2xml() + * + * @param string $string XML content to convert into an array + * @param string $NSprefix The tag-prefix resolve, eg. a namespace like "T3:" + * @param bool $reportDocTag If set, the document tag will be set in the key "_DOCUMENT_TAG" of the output array + * @return array|string If the parsing had errors, a string with the error message is returned. Otherwise an array with the content. + * @see array2xml() + */ + public static function xml2arrayProcess(string $string, string $NSprefix = '', bool $reportDocTag = false): array|string + { + $string = trim((string)$string); + // Create parser: + $parser = xml_parser_create(); + $vals = []; + xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0); + xml_parser_set_option($parser, XML_OPTION_SKIP_WHITE, 0); + // Default output charset is UTF-8, only ASCII, ISO-8859-1 and UTF-8 are supported!!! + $match = []; + preg_match('/^[[:space:]]*<\\?xml[^>]*encoding[[:space:]]*=[[:space:]]*"([^"]*)"/', substr($string, 0, 200), $match); + $theCharset = $match[1] ?? 'utf-8'; + // us-ascii / utf-8 / iso-8859-1 + xml_parser_set_option($parser, XML_OPTION_TARGET_ENCODING, $theCharset); + // Parse content: + xml_parse_into_struct($parser, $string, $vals); + // If error, return error message: + if (xml_get_error_code($parser)) { + return 'Line ' . xml_get_current_line_number($parser) . ': ' . xml_error_string(xml_get_error_code($parser)); + } + // Init vars: + $stack = [[]]; + $stacktop = 0; + $current = []; + $tagName = ''; + $documentTag = ''; + // Traverse the parsed XML structure: + foreach ($vals as $val) { + // First, process the tag-name (which is used in both cases, whether "complete" or "close") + $tagName = $val['tag']; + if (!$documentTag) { + $documentTag = $tagName; + } + // Test for name space: + $tagName = $NSprefix && str_starts_with($tagName, $NSprefix) ? substr($tagName, strlen($NSprefix)) : $tagName; + // Test for numeric tag, encoded on the form "nXXX": + $testNtag = substr($tagName, 1); + // Closing tag. + $tagName = $tagName[0] === 'n' && MathUtility::canBeInterpretedAsInteger($testNtag) ? (int)$testNtag : $tagName; + // Test for alternative index value: + if ((string)($val['attributes']['index'] ?? '') !== '') { + $tagName = $val['attributes']['index']; + } + // Setting tag-values, manage stack: + switch ($val['type']) { + case 'open': + // If open tag it means there is an array stored in sub-elements. Therefore increase the stackpointer and reset the accumulation array: + // Setting blank place holder + $current[$tagName] = []; + $stack[$stacktop++] = $current; + $current = []; + break; + case 'close': + // If the tag is "close" then it is an array which is closing and we decrease the stack pointer. + $oldCurrent = $current; + $current = $stack[--$stacktop]; + // Going to the end of array to get placeholder key, key($current), and fill in array next: + end($current); + $current[key($current)] = $oldCurrent; + unset($oldCurrent); + break; + case 'complete': + // If "complete", then it's a value. If the attribute "base64" is set, then decode the value, otherwise just set it. + if (!empty($val['attributes']['base64'])) { + $current[$tagName] = base64_decode($val['value']); + } else { + // Had to cast it as a string - otherwise it would be evaluate FALSE if tested with isset()!! + $current[$tagName] = (string)($val['value'] ?? ''); + // Cast type: + switch ((string)($val['attributes']['type'] ?? '')) { + case 'integer': + $current[$tagName] = (int)$current[$tagName]; + break; + case 'double': + $current[$tagName] = (float)$current[$tagName]; + break; + case 'boolean': + $current[$tagName] = (bool)$current[$tagName]; + break; + case 'NULL': + $current[$tagName] = null; + break; + case 'array': + // MUST be an empty array since it is processed as a value; Empty arrays would end up here because they would have no tags inside... + $current[$tagName] = []; + break; + } + } + break; + } + } + if ($reportDocTag) { + $current[$tagName]['_DOCUMENT_TAG'] = $documentTag; + } + // Finally return the content of the document tag. + return $current[$tagName]; + } + + /** + * This implodes an array of XML parts (made with xml_parse_into_struct()) into XML again. + * + * @param array<int, array<string, mixed>> $vals An array of XML parts, see xml2tree + * @return string Re-compiled XML data. + */ + public static function xmlRecompileFromStructValArray(array $vals): string + { + $XMLcontent = ''; + foreach ($vals as $val) { + $type = $val['type']; + // Open tag: + if ($type === 'open' || $type === 'complete') { + $XMLcontent .= '<' . $val['tag']; + if (isset($val['attributes'])) { + foreach ($val['attributes'] as $k => $v) { + $XMLcontent .= ' ' . $k . '="' . htmlspecialchars($v) . '"'; + } + } + if ($type === 'complete') { + if (isset($val['value'])) { + $XMLcontent .= '>' . htmlspecialchars($val['value']) . '</' . $val['tag'] . '>'; + } else { + $XMLcontent .= '/>'; + } + } else { + $XMLcontent .= '>'; + } + if ($type === 'open' && isset($val['value'])) { + $XMLcontent .= htmlspecialchars($val['value']); + } + } + // Finish tag: + if ($type === 'close') { + $XMLcontent .= '</' . $val['tag'] . '>'; + } + // Cdata + if ($type === 'cdata') { + $XMLcontent .= htmlspecialchars($val['value']); + } + } + return $XMLcontent; + } + + /************************* + * + * FILES FUNCTIONS + * + *************************/ + /** + * Reads the file or url $url and returns the content + * If you are having trouble with proxies when reading URLs you can configure your way out of that with settings within $GLOBALS['TYPO3_CONF_VARS']['HTTP']. + * + * @param string $url File/URL to read + * @return string|false The content from the resource given as input. FALSE if an error has occurred. + */ + public static function getUrl(string $url): string|false + { + // Looks like it's an external file, use Guzzle by default + if (preg_match('/^(?:http|ftp)s?|s(?:ftp|cp):/', $url)) { + $requestFactory = static::makeInstance(RequestFactory::class); + try { + $response = $requestFactory->request($url); + } catch (TransferException $exception) { + return false; + } + $content = $response->getBody()->getContents(); + } else { + $content = @file_get_contents($url); + } + return $content; + } + + /** + * Writes $content to the file $file + * + * @param string $file Filepath to write to + * @param string $content Content to write + * @param bool $changePermissions If TRUE, permissions are forced to be set + * @return bool TRUE if the file was successfully opened and written to. + */ + public static function writeFile(string $file, string $content, bool $changePermissions = false): bool + { + if (!@is_file($file)) { + $changePermissions = true; + } + if ($fd = fopen($file, 'wb')) { + $res = fwrite($fd, $content); + fclose($fd); + if ($res === false) { + return false; + } + // Change the permissions only if the file has just been created + if ($changePermissions) { + static::fixPermissions($file); + } + return true; + } + return false; + } + + /** + * Sets the file system mode and group ownership of a file or a folder. + * + * @param string $path Path of file or folder, must not be escaped. Path can be absolute or relative + * @param bool $recursive If set, also fixes permissions of files and folders in the folder (if $path is a folder) + * @return bool TRUE on success, FALSE on error, always TRUE on Windows OS + */ + public static function fixPermissions(string $path, bool $recursive = false): bool + { + $targetPermissions = null; + if (Environment::isWindows()) { + return true; + } + $result = false; + // Make path absolute + if (!PathUtility::isAbsolutePath($path)) { + $path = static::getFileAbsFileName($path); + } + if (static::isAllowedAbsPath($path)) { + if (@is_file($path)) { + $targetPermissions = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['fileCreateMask'] ?? '0644'); + } elseif (@is_dir($path)) { + $targetPermissions = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['folderCreateMask'] ?? '0755'); + } + if (!empty($targetPermissions)) { + // make sure it's always 4 digits + $targetPermissions = str_pad($targetPermissions, 4, '0', STR_PAD_LEFT); + $targetPermissions = octdec($targetPermissions); + // "@" is there because file is not necessarily OWNED by the user + $result = @chmod($path, (int)$targetPermissions); + } + // Set createGroup if not empty + if ( + isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['createGroup']) + && $GLOBALS['TYPO3_CONF_VARS']['SYS']['createGroup'] !== '' + ) { + // "@" is there because file is not necessarily OWNED by the user + $changeGroupResult = @chgrp($path, $GLOBALS['TYPO3_CONF_VARS']['SYS']['createGroup']); + $result = $changeGroupResult ? $result : false; + } + // Call recursive if recursive flag if set and $path is directory + if ($recursive && @is_dir($path)) { + $handle = opendir($path); + if (is_resource($handle)) { + while (($file = readdir($handle)) !== false) { + $recursionResult = null; + if ($file !== '.' && $file !== '..') { + if (@is_file($path . '/' . $file)) { + $recursionResult = static::fixPermissions($path . '/' . $file); + } elseif (@is_dir($path . '/' . $file)) { + $recursionResult = static::fixPermissions($path . '/' . $file, true); + } + if (isset($recursionResult) && !$recursionResult) { + $result = false; + } + } + } + closedir($handle); + } + } + } + return $result; + } + + /** + * Writes $content to a filename in the typo3temp/ folder (and possibly one or two subfolders...) + * Accepts an additional subdirectory in the file path! + * + * @param string $filepath Absolute file path to write within the typo3temp/ or Environment::getVarPath() folder - the file path must be prefixed with this path + * @param string $content Content string to write + * @return string|null Returns NULL on success, otherwise an error string telling about the problem. + */ + public static function writeFileToTypo3tempDir(string $filepath, string $content): ?string + { + // Parse filepath into directory and basename: + $fI = pathinfo($filepath); + $fI['dirname'] .= '/'; + // Check parts: + if (!static::validPathStr($filepath) || !$fI['basename'] || strlen($fI['basename']) >= 60) { + return 'Input filepath "' . $filepath . '" was generally invalid!'; + } + + // Setting main temporary directory name (standard) + $allowedPathPrefixes = [ + Environment::getPublicPath() . '/typo3temp' => 'Environment::getPublicPath() + "/typo3temp/"', + ]; + // Also allow project-path + /var/ + if (Environment::getVarPath() !== Environment::getPublicPath() . '/typo3temp/var') { + $relPath = substr(Environment::getVarPath(), strlen(Environment::getProjectPath()) + 1); + $allowedPathPrefixes[Environment::getVarPath()] = 'ProjectPath + ' . $relPath; + } + + $errorMessage = null; + foreach ($allowedPathPrefixes as $pathPrefix => $prefixLabel) { + $dirName = $pathPrefix . '/'; + // Invalid file path, let's check for the other path, if it exists + if (!str_starts_with($fI['dirname'], $dirName)) { + if ($errorMessage === null) { + $errorMessage = '"' . $fI['dirname'] . '" was not within directory ' . $prefixLabel; + } + continue; + } + // This resets previous error messages from the first path + $errorMessage = null; + + if (!@is_dir($dirName)) { + $errorMessage = $prefixLabel . ' was not a directory!'; + // continue and see if the next iteration resets the errorMessage above + continue; + } + // Checking if the "subdir" is found + $subdir = substr($fI['dirname'], strlen($dirName)); + if ($subdir) { + if (preg_match('#^(?:[[:alnum:]_]+/)+$#', $subdir)) { + $dirName .= $subdir; + if (!@is_dir($dirName)) { + static::mkdir_deep($pathPrefix . '/' . $subdir); + } + } else { + $errorMessage = 'Subdir, "' . $subdir . '", was NOT on the form "[[:alnum:]_]/+"'; + break; + } + } + // Checking dir-name again (sub-dir might have been created) + if (@is_dir($dirName)) { + if ($filepath === $dirName . $fI['basename']) { + static::writeFile($filepath, $content, true); + if (!@is_file($filepath)) { + $errorMessage = 'The file was not written to the disk. Please, check that you have write permissions to the ' . $prefixLabel . ' directory.'; + } + break; + } + $errorMessage = 'Calculated file location didn\'t match input "' . $filepath . '".'; + break; + } + $errorMessage = '"' . $dirName . '" is not a directory!'; + break; + } + return $errorMessage; + } + + /** + * Wrapper function for mkdir. + * Sets folder permissions according to $GLOBALS['TYPO3_CONF_VARS']['SYS']['folderCreateMask'] + * and group ownership according to $GLOBALS['TYPO3_CONF_VARS']['SYS']['createGroup'] + * + * @param string $newFolder Absolute path to folder, see PHP mkdir() function. Removes trailing slash internally. + * @return bool TRUE if operation was successful + */ + public static function mkdir(string $newFolder): bool + { + $result = @mkdir($newFolder, (int)octdec((string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['folderCreateMask'] ?? '0'))); + if ($result) { + static::fixPermissions($newFolder); + } + return $result; + } + + /** + * Creates a directory - including parent directories if necessary and + * sets permissions on newly created directories. + * + * @param string $directory Target directory to create + * @throws \RuntimeException If directory could not be created + */ + public static function mkdir_deep(string $directory): void + { + // Ensure there is only one slash + $fullPath = rtrim($directory, '/'); + if ($fullPath !== '' && !is_dir($fullPath)) { + $firstCreatedPath = static::createDirectoryPath($fullPath . '/'); + if ($firstCreatedPath !== '') { + static::fixPermissions($firstCreatedPath, true); + } + } + } + + /** + * Creates directories for the specified paths if they do not exist. This + * functions sets proper permission mask but does not set proper user and + * group. + * + * @return string Path to the first created directory in the hierarchy + * @see \TYPO3\CMS\Core\Utility\GeneralUtility::mkdir_deep + * @throws \RuntimeException If directory could not be created + */ + protected static function createDirectoryPath(string $fullDirectoryPath): string + { + $currentPath = $fullDirectoryPath; + $firstCreatedPath = ''; + $permissionMask = (int)octdec((string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['folderCreateMask'] ?? '0')); + if (!@is_dir($currentPath)) { + do { + $firstCreatedPath = $currentPath; + $separatorPosition = (int)strrpos($currentPath, DIRECTORY_SEPARATOR); + $currentPath = substr($currentPath, 0, $separatorPosition); + } while (!is_dir($currentPath) && $separatorPosition > 0); + $result = @mkdir($fullDirectoryPath, $permissionMask, true); + // Check existence of directory again to avoid race condition. Directory could have get created by another process between previous is_dir() and mkdir() + if (!$result && !@is_dir($fullDirectoryPath)) { + throw new \RuntimeException('Could not create directory "' . $fullDirectoryPath . '"!', 1170251401); + } + } + return $firstCreatedPath; + } + + /** + * Wrapper function for rmdir, allowing recursive deletion of folders and files + * + * @param string $path Absolute path to folder, see PHP rmdir() function. Removes trailing slash internally. + * @param bool $removeNonEmpty Allow deletion of non-empty directories + * @return bool TRUE if operation was successful + */ + public static function rmdir(string $path, bool $removeNonEmpty = false): bool + { + $OK = false; + // Remove trailing slash + $path = preg_replace('|/$|', '', $path) ?? ''; + $isWindows = DIRECTORY_SEPARATOR === '\\'; + if (file_exists($path)) { + $OK = true; + if (!is_link($path) && is_dir($path)) { + if ($removeNonEmpty === true && ($handle = @opendir($path))) { + $entries = []; + + while (false !== ($file = readdir($handle))) { + if ($file === '.' || $file === '..') { + continue; + } + + $entries[] = $path . '/' . $file; + } + + closedir($handle); + + foreach ($entries as $entry) { + if (!static::rmdir($entry, $removeNonEmpty)) { + $OK = false; + } + } + } + if ($OK) { + $OK = @rmdir($path); + } + } elseif (is_link($path) && is_dir($path) && $isWindows) { + $OK = @rmdir($path); + } else { + // If $path is a file, simply remove it + $OK = @unlink($path); + } + clearstatcache(); + } elseif (is_link($path)) { + $OK = @unlink($path); + if (!$OK && $isWindows) { + // Try to delete dead folder links on Windows systems + $OK = @rmdir($path); + } + clearstatcache(); + } + return $OK; + } + + /** + * Returns an array with the names of folders in a specific path + * Will return 'error' (string) if there were an error with reading directory content. + * Will return null if provided path is false. + * + * @param string $path Path to list directories from + * @return string[]|string|null Returns an array with the directory entries as values. If no path is provided, the return value will be null. + */ + public static function get_dirs(string $path): array|string|null + { + $dirs = null; + if ($path) { + if (is_dir($path)) { + $dir = scandir($path); + $dirs = []; + foreach ($dir as $entry) { + if (is_dir($path . '/' . $entry) && $entry !== '..' && $entry !== '.') { + $dirs[] = $entry; + } + } + } else { + $dirs = 'error'; + } + } + return $dirs; + } + + /** + * Finds all files in a given path and returns them as an array. Each + * array key is a md5 hash of the full path to the file. This is done because + * 'some' extensions like the import/export extension depend on this. + * + * @param string $path The path to retrieve the files from. + * @param string $extensionList A comma-separated list of file extensions. Only files of the specified types will be retrieved. When left blank, files of any type will be retrieved. + * @param bool $prependPath If TRUE, the full path to the file is returned. If FALSE only the file name is returned. + * @param string $order The sorting order. The default sorting order is alphabetical. Setting $order to 'mtime' will sort the files by modification time. + * @param string $excludePattern A regular expression pattern of file names to exclude. For example: 'clear.gif' or '(clear.gif|.htaccess)'. The pattern will be wrapped with: '/^' and '$/'. + * @return array<string, string>|string Array of the files found, or an error message in case the path could not be opened. + */ + public static function getFilesInDir(string $path, string $extensionList = '', bool $prependPath = false, string $order = '', string $excludePattern = ''): array|string + { + $excludePattern = (string)$excludePattern; + $path = rtrim($path, '/'); + if (!@is_dir($path)) { + return []; + } + + $rawFileList = scandir($path); + if ($rawFileList === false) { + return 'error opening path: "' . $path . '"'; + } + + $pathPrefix = $path . '/'; + $allowedFileExtensionArray = self::trimExplode(',', $extensionList); + $extensionList = ',' . str_replace(' ', '', $extensionList) . ','; + $files = []; + foreach ($rawFileList as $entry) { + $completePathToEntry = $pathPrefix . $entry; + if (!@is_file($completePathToEntry)) { + continue; + } + + foreach ($allowedFileExtensionArray as $allowedFileExtension) { + if ( + ($extensionList === ',,' || str_ends_with(mb_strtolower($entry), mb_strtolower('.' . $allowedFileExtension))) + && ($excludePattern === '' || !preg_match('/^' . $excludePattern . '$/', $entry)) + ) { + if ($order !== 'mtime') { + $files[] = $entry; + } else { + // Store the value in the key so we can do a fast asort later. + $files[$entry] = filemtime($completePathToEntry); + } + } + } + } + + $valueName = 'value'; + if ($order === 'mtime') { + asort($files); + $valueName = 'key'; + } + + $valuePathPrefix = $prependPath ? $pathPrefix : ''; + $foundFiles = []; + /** @noinspection PhpUnusedLocalVariableInspection key is possibly used with "valueName */ + foreach ($files as $key => $value) { + // Don't change this ever - extensions may depend on the fact that the hash is an md5 of the path! (import/export extension) + $foundFiles[md5($pathPrefix . ${$valueName})] = $valuePathPrefix . ${$valueName}; + } + + return $foundFiles; + } + + /** + * Recursively gather all files and folders of a path. + * + * @param string[] $fileArr Empty input array (will have files added to it) + * @param string $path The path to read recursively from (absolute) (include trailing slash!) + * @param string $extList Comma list of file extensions: Only files with extensions in this list (if applicable) will be selected. + * @param bool $regDirs If set, directories are also included in output. + * @param int $recursivityLevels The number of levels to dig down... + * @param string $excludePattern regex pattern of files/directories to exclude + * @return array<string, string> An array with the found files/directories. + */ + public static function getAllFilesAndFoldersInPath(array $fileArr, string $path, string $extList = '', bool $regDirs = false, int $recursivityLevels = 99, string $excludePattern = ''): array + { + if ($regDirs) { + $fileArr[md5($path)] = $path; + } + $fileArr = array_merge($fileArr, (array)self::getFilesInDir($path, $extList, true, '', $excludePattern)); + $dirs = self::get_dirs($path); + if ($recursivityLevels > 0 && is_array($dirs)) { + foreach ($dirs as $subdirs) { + if ((string)$subdirs !== '' && ($excludePattern === '' || !preg_match('/^' . $excludePattern . '$/', $subdirs))) { + $fileArr = self::getAllFilesAndFoldersInPath($fileArr, $path . $subdirs . '/', $extList, $regDirs, $recursivityLevels - 1, $excludePattern); + } + } + } + return $fileArr; + } + + /** + * Removes the absolute part of all files/folders in fileArr + * + * @param string[] $fileArr The file array to remove the prefix from + * @param string $prefixToRemove The prefix path to remove (if found as first part of string!) + * @return string[]|string The input $fileArr processed, or a string with an error message, when an error occurred. + */ + public static function removePrefixPathFromList(array $fileArr, string $prefixToRemove): array|string + { + foreach ($fileArr as &$absFileRef) { + if (str_starts_with($absFileRef, $prefixToRemove)) { + $absFileRef = substr($absFileRef, strlen($prefixToRemove)); + } else { + return 'ERROR: One or more of the files was NOT prefixed with the prefix-path!'; + } + } + unset($absFileRef); + return $fileArr; + } + + /** + * Fixes a path for windows-backslashes and reduces double-slashes to single slashes + */ + public static function fixWindowsFilePath(string $theFile): string + { + return str_replace(['\\', '//'], '/', $theFile); + } + + /** + * Prefixes a URL used with 'header-location' with 'http://...' depending on whether it has it already. + * - If already having a scheme, nothing is prepended + * - If having REQUEST_URI slash '/', then prefixing 'http://[host]' (relative to host) + * - Otherwise prefixed with TYPO3_REQUEST_DIR (relative to current dir / TYPO3_REQUEST_DIR) + * + * @param string $path URL / path to prepend full URL addressing to. + * @return ($path is non-empty-string ? non-empty-string : string) + */ + public static function locationHeaderUrl(string $path, ServerRequestInterface $request): string + { + if (str_starts_with($path, '//')) { + return $path; + } + $normalizedParams = $request->getAttribute('normalizedParams'); + // relative to HOST + if (str_starts_with($path, '/')) { + return $normalizedParams->getRequestHost() . $path; + } + + $urlComponents = parse_url($path); + if (!($urlComponents['scheme'] ?? false)) { + // No scheme either + return $normalizedParams->getRequestDir() . $path; + } + + return $path; + } + + /** + * Returns the maximum upload size for a file that is allowed. Measured in KB. + * This might be handy to find out the real upload limit that is possible for this + * TYPO3 installation. + * + * @return int Maximum size of uploads that are allowed in KiB (divider 1024) + */ + public static function getMaxUploadFileSize(): int + { + $uploadMaxFilesize = (string)ini_get('upload_max_filesize'); + $postMaxSize = (string)ini_get('post_max_size'); + // Check for PHP restrictions of the maximum size of one of the $_FILES + $phpUploadLimit = self::getBytesFromSizeMeasurement($uploadMaxFilesize); + // Check for PHP restrictions of the maximum $_POST size + $phpPostLimit = self::getBytesFromSizeMeasurement($postMaxSize); + // If the total amount of post data is smaller (!) than the upload_max_filesize directive, + // then this is the real limit in PHP + $phpUploadLimit = $phpPostLimit > 0 && $phpPostLimit < $phpUploadLimit ? $phpPostLimit : $phpUploadLimit; + return (int)(floor($phpUploadLimit) / 1024); + } + + /** + * Gets the bytes value from a measurement string like "100k". + * + * @param string $measurement The measurement (e.g. "100k") + * @return int The bytes value (e.g. 102400) + */ + public static function getBytesFromSizeMeasurement(string $measurement): int + { + $bytes = (float)$measurement; + if (stripos($measurement, 'G')) { + $bytes *= 1024 * 1024 * 1024; + } elseif (stripos($measurement, 'M')) { + $bytes *= 1024 * 1024; + } elseif (stripos($measurement, 'K')) { + $bytes *= 1024; + } + return (int)$bytes; + } + + /** + * Writes string to a temporary file named after the md5-hash of the string + * Quite useful for extensions adding their custom built JavaScript during runtime. + * + * @param string $content JavaScript to write to file. + * @return string filename to include in the <script> tag + */ + public static function writeJavaScriptContentToTemporaryFile(string $content): string + { + $script = 'typo3temp/assets/js/' . md5($content) . '.js'; + if (!@is_file(Environment::getPublicPath() . '/' . $script)) { + self::writeFileToTypo3tempDir(Environment::getPublicPath() . '/' . $script, $content); + } + return $script; + } + + /** + * Writes string to a temporary file named after the md5-hash of the string + * Quite useful for extensions adding their custom built StyleSheet during runtime. + * + * @param string $content CSS styles to write to file. + * @return string filename to include in the <link> tag + */ + public static function writeStyleSheetContentToTemporaryFile(string $content): string + { + $script = 'typo3temp/assets/css/' . md5($content) . '.css'; + if (!@is_file(Environment::getPublicPath() . '/' . $script)) { + self::writeFileToTypo3tempDir(Environment::getPublicPath() . '/' . $script, $content); + } + return $script; + } + + /************************* + * + * TYPO3 SPECIFIC FUNCTIONS + * + *************************/ + /** + * Returns the absolute filename of a relative reference, resolves the "EXT:" prefix + * (way of referring to files inside extensions) and checks that the file is inside + * the TYPO3's base folder and implies a check with + * \TYPO3\CMS\Core\Utility\GeneralUtility::validPathStr(). + * + * @param string $fileName The input filename/filepath to evaluate + * @return string Returns the absolute filename of $filename if valid, otherwise blank string. + */ + public static function getFileAbsFileName(string $fileName): string + { + if ($fileName === '') { + return ''; + } + try { + return ExtensionManagementUtility::resolvePackagePath($fileName); + } catch (InvalidSystemResourceIdentifierException) { + return ''; + } catch (SystemResourceException) { + } + + $checkForBackPath = fn(string $fileName): string => $fileName !== '' && static::validPathStr($fileName) ? $fileName : ''; + // Absolute path, but set to blank if not inside allowed directories. + if (PathUtility::isAbsolutePath($fileName)) { + if (static::isAllowedAbsPath($fileName)) { + return $checkForBackPath($fileName); + } + return ''; + } + + // Relative path. Prepend with the public web folder. + $fileName = Environment::getPublicPath() . '/' . $fileName; + return $checkForBackPath($fileName); + } + + /** + * Checks for malicious file paths. + * + * Returns TRUE if no '//', '..', '\' or control characters are found in the $theFile. + * This should make sure that the path is not pointing 'backwards' and further doesn't contain double/back slashes. + * So it's compatible with the UNIX style path strings valid for TYPO3 internally. + * + * @param string $theFile File path to evaluate + * @return bool TRUE, $theFile is allowed path string, FALSE otherwise + * @see https://php.net/manual/en/security.filesystem.nullbytes.php + */ + public static function validPathStr(string $theFile): bool + { + return !str_contains($theFile, '//') && !str_contains($theFile, '\\') + && preg_match('#(?:^\\.\\.|/\\.\\./|[[:cntrl:]])#u', $theFile) === 0; + } + + /** + * Returns TRUE if the path is absolute, without backpath '..' and within TYPO3s project or public folder OR within the lockRootPath + * + * @param string $path File path to evaluate + */ + public static function isAllowedAbsPath(string $path): bool + { + $path = PathUtility::sanitizeTrailingSeparator($path); + return PathUtility::isAbsolutePath($path) && static::validPathStr($path) + && ( + str_starts_with($path, Environment::getProjectPath() . '/') + || PathUtility::isAllowedAdditionalPath($path) + ); + } + + /** + * Low level utility function to copy directories and content recursive + * + * @param string $source Path to source directory, relative to document root or absolute + * @param string $destination Path to destination directory, relative to document root or absolute + */ + public static function copyDirectory(string $source, string $destination): void + { + if (!str_contains($source, Environment::getProjectPath() . '/')) { + $source = Environment::getProjectPath() . '/' . $source; + } + if (!str_contains($destination, Environment::getProjectPath() . '/')) { + $destination = Environment::getProjectPath() . '/' . $destination; + } + if (static::isAllowedAbsPath($source) && static::isAllowedAbsPath($destination)) { + static::mkdir_deep($destination); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + /** @var \SplFileInfo $item */ + foreach ($iterator as $item) { + $target = $destination . '/' . static::fixWindowsFilePath((string)$iterator->getSubPathName()); + if ($item->isDir()) { + static::mkdir($target); + } else { + static::upload_copy_move(static::fixWindowsFilePath($item->getPathname()), $target); + } + } + } + } + + /** + * Checks if a given string is a valid frame URL to be loaded in the + * backend or used in redirect headers. + * + * If the given url is empty or considered to be harmless, it is returned + * as is, else the event is logged and an empty string is returned. + * + * @param string $url potential URL to check + * @return string $url or empty string + * @todo: This method needs an overhaul in v15. It still relies on the deprecated resolveBackPath() + * helper below to canonicalize relative paths. It should be reworked to no longer deal with + * relative paths at all, so the deprecated helper can be dropped. + */ + public static function sanitizeLocalUrl(string $url, ServerRequestInterface $request): string + { + $sanitizedUrl = ''; + if (!empty($url)) { + $validUrlCharacters = [ + // Percent-Encoding: https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 + '%', + + // Reserved Characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + // gen-delims + ':', '/', '?', '#', '[', ']', '@', + // sub-delims + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', + + // Unreserved Characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + '-', '.', '_', '~', + // ALPHA + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + // DIGIT + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ]; + + $hasInvalidCharacters = str_replace($validUrlCharacters, '', $url) !== ''; + if ($hasInvalidCharacters) { + static::getLogger()->notice('The URL "{url}" contains unexpected characters and was denied as local url.', ['url' => $url]); + return ''; + } + + $decodedUrl = rawurldecode($url); + if ($decodedUrl !== ltrim($decodedUrl, " \t\v")) { + static::getLogger()->notice('URL "{url}" contains unexpected whitespace and was denied as local url.', ['url' => $url]); + return ''; + } + + $parsedUrl = parse_url($decodedUrl); + $normalizedParams = $request->getAttribute('normalizedParams'); + $requestHost = $normalizedParams->getRequestHost(); + $siteUrl = $normalizedParams->getSiteUrl(); + $sitePath = $normalizedParams->getSitePath(); + $scriptName = $normalizedParams->getScriptName(); + // Pass if URL is on the current host: + if (self::isValidUrl($decodedUrl)) { + if (stripos($decodedUrl . '/', $requestHost . '/') === 0 && str_starts_with($decodedUrl, $siteUrl)) { + $sanitizedUrl = $url; + } + } elseif (PathUtility::isAbsolutePath($decodedUrl) && self::isAllowedAbsPath($decodedUrl)) { + $sanitizedUrl = $url; + } elseif ($decodedUrl[0] === '/' && !str_starts_with($decodedUrl, '//') && str_starts_with(self::resolveBackPath($decodedUrl), $sitePath)) { + $sanitizedUrl = $url; + } elseif (empty($parsedUrl['scheme']) && $decodedUrl[0] !== '/' && strpbrk($decodedUrl, '*:|"<>') === false && !str_contains($decodedUrl, '\\\\') && str_starts_with(self::resolveBackPath(self::dirname($scriptName) . '/' . $decodedUrl), $sitePath)) { + $sanitizedUrl = $url; + } + } + if (!empty($url) && empty($sanitizedUrl)) { + static::getLogger()->notice('The URL "{url}" is not considered to be local and was denied.', ['url' => $url]); + } + return $sanitizedUrl; + } + + /** + * Resolves "../" sections in the input path string. + * For example "fileadmin/directory/../other_directory/" will be resolved to "fileadmin/other_directory/" + * + * @param string $pathStr File path in which "/../" is resolved + * @deprecated The only remaining caller is sanitizeLocalUrl() above and no new callers must be + * added. This helper exists solely for the legacy relative path canonicalization in + * sanitizeLocalUrl() and is removed once that method has been reworked. + */ + private static function resolveBackPath(string $pathStr): string + { + trigger_error('GeneralUtility::resolveBackPath() will likely be removed in TYPO3 v15.0 when sanitizeLocalUrl() is reworked. Avoid working with relative paths as TYPO3 will not canonicalize them anymore.', E_USER_DEPRECATED); + if (!str_contains($pathStr, '..')) { + return $pathStr; + } + $parts = explode('/', $pathStr); + $output = []; + $c = 0; + foreach ($parts as $part) { + if ($part === '..') { + if ($c) { + array_pop($output); + --$c; + } else { + $output[] = $part; + } + } else { + ++$c; + $output[] = $part; + } + } + return implode('/', $output); + } + + /** + * Moves $source file to $destination if uploaded, otherwise try to make a copy + * + * @param string $source Source file, absolute path + * @param string $destination Destination file, absolute path + * @return bool Returns TRUE if the file was moved. + * @see upload_to_tempfile() + */ + public static function upload_copy_move(string $source, string $destination): bool + { + if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Utility\GeneralUtility::class]['moveUploadedFile'] ?? null)) { + $params = ['source' => $source, 'destination' => $destination, 'method' => 'upload_copy_move']; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Utility\GeneralUtility::class]['moveUploadedFile'] as $hookMethod) { + $fakeThis = null; + self::callUserFunction($hookMethod, $params, $fakeThis); + } + } + + $result = false; + if (is_uploaded_file($source)) { + // Return the value of move_uploaded_file, and if FALSE the temporary $source is still + // around so the user can use unlink to delete it: + $result = move_uploaded_file($source, $destination); + } else { + @copy($source, $destination); + } + // Change the permissions of the file + self::fixPermissions($destination); + // If here the file is copied and the temporary $source is still around, + // so when returning FALSE the user can try unlink to delete the $source + return $result; + } + + /** + * Will move an uploaded file (normally in "/tmp/xxxxx") to a temporary filename in Environment::getProjectPath() . "var/" from where TYPO3 can use it. + * Use this function to move uploaded files to where you can work on them. + * REMEMBER to use \TYPO3\CMS\Core\Utility\GeneralUtility::unlink_tempfile() afterwards - otherwise temp-files will build up! They are NOT automatically deleted in the temporary folder! + * + * @param string $uploadedFileName The temporary uploaded filename, eg. $_FILES['[upload field name here]']['tmp_name'] + * @return string If a new file was successfully created, return its filename, otherwise blank string. + * @see unlink_tempfile() + * @see upload_copy_move() + */ + public static function upload_to_tempfile(string $uploadedFileName): string + { + if (is_uploaded_file($uploadedFileName)) { + $tempFile = self::tempnam('upload_temp_'); + if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Utility\GeneralUtility::class]['moveUploadedFile'] ?? null)) { + $params = ['source' => $uploadedFileName, 'destination' => $tempFile, 'method' => 'upload_to_tempfile']; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Utility\GeneralUtility::class]['moveUploadedFile'] as $hookMethod) { + $fakeThis = null; + self::callUserFunction($hookMethod, $params, $fakeThis); + } + } + + move_uploaded_file($uploadedFileName, $tempFile); + return @is_file($tempFile) ? $tempFile : ''; + } + + return ''; + } + + /** + * Deletes (unlink) a temporary filename in the var/ or typo3temp folder given as input. + * The function will check that the file exists, is within TYPO3's var/ or typo3temp/ folder and does not contain back-spaces ("../") so it should be pretty safe. + * Use this after upload_to_tempfile() or tempnam() from this class! + * + * @param string $uploadedTempFileName absolute file path - must reside within var/ or typo3temp/ folder. + * @return bool|null Returns TRUE if the file was unlink()'ed + * @see upload_to_tempfile() + * @see tempnam() + */ + public static function unlink_tempfile(string $uploadedTempFileName): ?bool + { + if ($uploadedTempFileName) { + $uploadedTempFileName = self::fixWindowsFilePath((string)$uploadedTempFileName); + if ( + self::validPathStr($uploadedTempFileName) + && ( + str_starts_with($uploadedTempFileName, Environment::getPublicPath() . '/typo3temp/') + || str_starts_with($uploadedTempFileName, Environment::getVarPath() . '/') + ) + && @is_file($uploadedTempFileName) + ) { + if (unlink($uploadedTempFileName)) { + return true; + } + } + } + + return null; + } + + /** + * Create temporary filename (Create file with unique file name) + * This function should be used for getting temporary file names - will make your applications safe for open_basedir = on + * REMEMBER to delete the temporary files after use! This is done by \TYPO3\CMS\Core\Utility\GeneralUtility::unlink_tempfile() + * + * @param string $filePrefix Prefix for temporary file + * @param string $fileSuffix Suffix for temporary file, for example a special file extension + * @return non-empty-string result from PHP function `tempnam()` with the temp/var folder prefixed. + * @see unlink_tempfile() + * @see upload_to_tempfile() + */ + public static function tempnam(string $filePrefix, string $fileSuffix = ''): string + { + $temporaryPath = Environment::getVarPath() . '/transient/'; + if (!is_dir($temporaryPath)) { + self::mkdir_deep($temporaryPath); + } + if ($fileSuffix === '') { + $path = (string)tempnam($temporaryPath, $filePrefix); + $tempFileName = $temporaryPath . PathUtility::basename($path); + } else { + do { + $tempFileName = $temporaryPath . $filePrefix . random_int(1, PHP_INT_MAX) . $fileSuffix; + } while (file_exists($tempFileName)); + touch($tempFileName); + clearstatcache(false, $tempFileName); + } + return $tempFileName; + } + + /** + * Calls a user-defined function/method in class + * Such a function/method should look like this: "function proc(&$params, &$ref) {...}" + * + * @param non-empty-string|\Closure $funcName Function/Method reference or Closure. + * @param mixed $params Parameters to be pass along (typically an array) (REFERENCE!) + * @param object|null $ref Reference to be passed along (typically "$this" - being a reference to the calling object) + * @param bool $assertAllowedCallable If true, asserts the target callable has the `#[AsAllowedCallable]` PHP attribute + * @return mixed Content from method/function call + * @throws \InvalidArgumentException + */ + public static function callUserFunction(string|\Closure|RawValue $funcName, mixed &$params, ?object $ref = null, bool $assertAllowedCallable = false): mixed + { + // Check if we're using a closure and invoke it directly. + if (is_a($funcName, \Closure::class)) { + return call_user_func_array($funcName, [&$params, &$ref]); + } + if ($funcName instanceof RawValue) { + $isTrusted = $funcName->trusted; + $funcName = $funcName->value; + } else { + $isTrusted = false; + } + if ($assertAllowedCallable === false) { + $invokableAssertion = null; + } else { + $invokableAssertion = self::makeInstance(AllowedCallableAssertion::class); + } + $funcName = trim($funcName); + $parts = explode('->', $funcName); + // Call function or method + if (count($parts) === 2) { + // It's a class/method + // Check if class/method exists: + if (class_exists($parts[0])) { + // Create object + $classObj = self::makeInstance($parts[0]); + $methodName = (string)$parts[1]; + $callable = [$classObj, $methodName]; + if (is_callable($callable)) { + // Call method: + if (!$isTrusted) { + $invokableAssertion?->assertCallable($callable); + } + $content = call_user_func_array($callable, [&$params, &$ref]); + } else { + throw new \InvalidArgumentException('No method name \'' . $parts[1] . '\' in class ' . $parts[0], 1294585865); + } + } else { + throw new \InvalidArgumentException('No class named ' . $parts[0], 1294585866); + } + } elseif (function_exists($funcName) && is_callable($funcName)) { + // It's a function + if (!$isTrusted) { + $invokableAssertion?->assertCallable($funcName); + } + $content = call_user_func_array($funcName, [&$params, &$ref]); + } else { + // Usually this will be annotated by static code analysis tools, but there's no native "not empty string" type + throw new \InvalidArgumentException('No function named: ' . $funcName, 1294585867); + } + return $content; + } + + /** + * @internal + */ + public static function setContainer(ContainerInterface $container): void + { + self::$container = $container; + } + + /** + * @internal + */ + public static function getContainer(): ContainerInterface + { + if (self::$container === null) { + throw new \LogicException('PSR-11 Container is not available', 1549404144); + } + return self::$container; + } + + /** + * Creates an instance of the given class while applying TYPO3 specific mechanisms + * such as XCLASSes, singleton handling and integration with public services from + * the dependency injection container. + * + * This method is primarily intended for TYPO3 core and infrastructure code that + * must participate in these mechanisms. In extension and application code, prefer + * constructor- or method-based dependency injection and direct use of the DI + * container configuration where possible. Use this method only to support XCLASSes + * and to access public services from the DI container. + * + * Instead of: `$obj = new MyClass();` + * Use: `$obj = GeneralUtility::makeInstance(MyClass::class)` + * + * You can also pass arguments for a constructor: + * `GeneralUtility::makeInstance(MyClass::class, $arg1, $arg2, ..., $argN)` + * + * @template T of object + * @param class-string<T> $className name of the class to instantiate, must not be empty and not start with a backslash + * @return T the created instance + * @throws \InvalidArgumentException if $className is empty or starts with a backslash + */ + public static function makeInstance(string $className, mixed ...$constructorArguments): object + { + // PHPStan will complain about this check. That's okay as we're checking a contract violation here. + if ($className === '') { + throw new \InvalidArgumentException('$className must be a non empty string.', 1288965219); + } + // Never instantiate with a beginning backslash, otherwise things like singletons won't work. + if (str_starts_with($className, '\\')) { + throw new \InvalidArgumentException( + '$className "' . $className . '" must not start with a backslash.', + 1420281366 + ); + } + if (isset(static::$finalClassNameCache[$className])) { + $finalClassName = static::$finalClassNameCache[$className]; + } else { + $finalClassName = self::getClassName($className); + static::$finalClassNameCache[$className] = $finalClassName; + } + // Return singleton instance if it is already registered + if (isset(self::$singletonInstances[$finalClassName])) { + return self::$singletonInstances[$finalClassName]; + } + // Return instance if it has been injected by addInstance() + if ( + isset(self::$nonSingletonInstances[$finalClassName]) + && !empty(self::$nonSingletonInstances[$finalClassName]) + ) { + return array_shift(self::$nonSingletonInstances[$finalClassName]); + } + + // Read service and prototypes from the DI container, this is required to + // support classes that require dependency injection. + // We operate on the original class name on purpose, as class overrides + // are resolved inside the container + if (self::$container !== null && $constructorArguments === [] && self::$container->has($className)) { + return self::$container->get($className); + } + + // Create new instance and call constructor with parameters + $instance = new $finalClassName(...$constructorArguments); + // Register new singleton instance, but only if it is not a known PSR-11 container service + if ($instance instanceof SingletonInterface && !(self::$container !== null && self::$container->has($className))) { + self::$singletonInstances[$finalClassName] = $instance; + } + if ($instance instanceof LoggerAwareInterface) { + $instance->setLogger(static::makeInstance(LogManager::class)->getLogger($className)); + } + return $instance; + } + + /** + * Creates a class taking implementation settings and class aliases into account. + * + * Intended to be used to create objects by the dependency injection container. + * + * @template T of object + * @param class-string<T> $className name of the class to instantiate + * @param mixed ...$constructorArguments Arguments for the constructor + * @return T the created instance + * @internal + */ + public static function makeInstanceForDi(string $className, mixed ...$constructorArguments): object + { + $finalClassName = static::$finalClassNameCache[$className] ?? static::$finalClassNameCache[$className] = self::getClassName($className); + + // Return singleton instance if it is already registered (currently required for unit and functional tests) + if (isset(self::$singletonInstances[$finalClassName])) { + return self::$singletonInstances[$finalClassName]; + } + // Create new instance and call constructor with parameters + return new $finalClassName(...$constructorArguments); + } + + /** + * Returns the class name for a new instance, taking into account + * registered implementations for this class + * + * @param class-string $className Base class name to evaluate + * @return class-string Final class name to instantiate with `new [classname]` + * @internal This is not a public API method, do not use in own extensions. + * Public to be accessible by extbase hydration. + */ + public static function getClassName(string $className): string + { + if (class_exists($className)) { + while (static::classHasImplementation($className)) { + $className = static::getImplementationForClass($className); + } + } + return ClassLoadingInformation::getClassNameForAlias($className); + } + + /** + * Returns the configured implementation of the class + * + * @param class-string $className + * @return class-string + */ + protected static function getImplementationForClass(string $className): string + { + return $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][$className]['className']; + } + + /** + * Checks if a class has a configured implementation + * + * @param class-string $className + */ + protected static function classHasImplementation(string $className): bool + { + return !empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][$className]['className']); + } + + /** + * Sets the instance of a singleton class to be returned by `makeInstance`. + * + * If this function is called multiple times for the same $className, + * makeInstance will return the last set instance. + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method exists mostly for unit tests to inject a mock of a singleton class. + * If you use this, make sure to always combine this with `getSingletonInstances()` + * and `resetSingletonInstances()` in setUp() and `tearDown()` of the test class. + * + * @see makeInstance + * @param class-string $className + * @internal + */ + public static function setSingletonInstance(string $className, SingletonInterface $instance): void + { + self::checkInstanceClassName($className, $instance); + // Check for XCLASS registration (same is done in makeInstance() in order to store the singleton of the final class name) + $finalClassName = self::getClassName($className); + self::$singletonInstances[$finalClassName] = $instance; + } + + /** + * Removes the instance of a singleton class to be returned by `makeInstance`. + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method exists mostly for unit tests to inject a mock of a singleton class. + * If you use this, make sure to always combine this with `getSingletonInstances()` + * and `resetSingletonInstances()` in `setUp()` and `tearDown()` of the test class. + * + * @see makeInstance + * @param class-string $className + * @throws \InvalidArgumentException + * @internal + */ + public static function removeSingletonInstance(string $className, SingletonInterface $instance): void + { + self::checkInstanceClassName($className, $instance); + if (!isset(self::$singletonInstances[$className])) { + throw new \InvalidArgumentException('No Instance registered for ' . $className . '.', 1394099179); + } + if ($instance !== self::$singletonInstances[$className]) { + throw new \InvalidArgumentException('The instance you are trying to remove has not been registered before.', 1394099256); + } + unset(self::$singletonInstances[$className]); + } + + /** + * Set a group of singleton instances. Similar to `setSingletonInstance()`, + * but multiple instances can be set. + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method is usually only used in tests to restore the list of singletons in + * `tearDown()` that was backed up with `getSingletonInstances()` in `setUp()` and + * manipulated in tests with `setSingletonInstance()`. + * + * @internal + * @param array<class-string, SingletonInterface> $newSingletonInstances + */ + public static function resetSingletonInstances(array $newSingletonInstances): void + { + static::$singletonInstances = []; + foreach ($newSingletonInstances as $className => $instance) { + static::setSingletonInstance($className, $instance); + } + } + + /** + * Get all currently registered singletons + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method is usually only used in tests in `setUp()` to fetch the list of + * currently registered singletons, if this list is manipulated with + * `setSingletonInstance()` in tests. + * + * @internal + * @return array<class-string, SingletonInterface> + */ + public static function getSingletonInstances(): array + { + return static::$singletonInstances; + } + + /** + * Get all currently registered non singleton instances + * + * Warning: + * This is NOT a public API method and must not be used in own extensions! + * This method is only used in `UnitTestCase` base test `tearDown()` to verify tests + * have no left-over instances that were previously added using `addInstance()`. + * + * @internal + * @return array<class-string, array<object>> + */ + public static function getInstances(): array + { + return static::$nonSingletonInstances; + } + + /** + * Sets the instance of a non-singleton class to be returned by `makeInstance`. + * + * If this function is called multiple times for the same `$className`, + * `makeInstance` will return the instances in the order in which they have + * been added (FIFO). + * + * Warning: This is a helper method for unit tests. Do not call this directly in production code! + * + * @see makeInstance + * @param class-string $className + * @throws \InvalidArgumentException if class extends \TYPO3\CMS\Core\SingletonInterface + */ + public static function addInstance(string $className, object $instance): void + { + self::checkInstanceClassName($className, $instance); + if ($instance instanceof SingletonInterface) { + throw new \InvalidArgumentException('$instance must not be an instance of TYPO3\\CMS\\Core\\SingletonInterface. For setting singletons, please use setSingletonInstance.', 1288969325); + } + if (!isset(self::$nonSingletonInstances[$className])) { + self::$nonSingletonInstances[$className] = []; + } + self::$nonSingletonInstances[$className][] = $instance; + } + + /** + * Checks that `$className` is non-empty and that `$instance` is an instance of `$className`. + * + * @throws \InvalidArgumentException if $className is empty or if $instance is no instance of $className + */ + protected static function checkInstanceClassName(string $className, object $instance): void + { + if ($className === '') { + throw new \InvalidArgumentException('$className must not be empty.', 1288967479); + } + if (!$instance instanceof $className) { + throw new \InvalidArgumentException('$instance must be an instance of ' . $className . ', but actually is an instance of ' . get_class($instance) . '.', 1288967686); + } + } + + /** + * Purge all instances returned by makeInstance. + * + * This function is most useful when called from tearDown in a test case + * to drop any instances that have been created by the tests. + * + * Warning: This is a helper method for unit tests. Do not call this directly in production code! + * + * @see makeInstance + */ + public static function purgeInstances(): void + { + self::$container = null; + self::$singletonInstances = []; + self::$nonSingletonInstances = []; + } + + /** + * Flushes some internal runtime caches: + * - the class-name mapping used by `makeInstance()` + * + * This function is intended to be used in unit tests to keep environment changes from spilling into the next test. + * + * @internal + */ + public static function flushInternalRuntimeCaches(): void + { + self::$finalClassNameCache = []; + } + + /** + * Find the best service and check if it works. + * Returns object of the service class. + * + * This method is used for the legacy ExtensionManager:addService() mechanism, + * not with Dependency-Injected services. In practice, all remaining core uses of + * this mechanism are authentication services, which all have an info property. + * + * @param string $serviceType Type of service (service key). + * @param string $serviceSubType Sub type like file extensions or similar. Defined by the service. + * @param array $excludeServiceKeys List of service keys which should be excluded in the search for a service + * @throws \RuntimeException + * @return object|string[]|false The service object or an array with error infos, or false if no service was found. + */ + public static function makeInstanceService(string $serviceType, string $serviceSubType = '', array $excludeServiceKeys = []): array|object|false + { + $error = false; + $requestInfo = [ + 'requestedServiceType' => $serviceType, + 'requestedServiceSubType' => $serviceSubType, + 'requestedExcludeServiceKeys' => $excludeServiceKeys, + ]; + while ($info = ExtensionManagementUtility::findService($serviceType, $serviceSubType, $excludeServiceKeys)) { + // provide information about requested service to service object + $info = array_merge($info, $requestInfo); + + /** @var class-string<AbstractAuthenticationService>|null $className */ + $className = $info['className']; + /* @todo Do a (minor) breaking change in TYPO3 v15.0 and type-enforce this to only AbstractAuthenticationService objects. + (There are public extensions out there carrying around makeInstanceService() as a pre-dependency-injection methodology, + which we need to cut) + */ + /** @var AbstractAuthenticationService|null $obj */ + $obj = self::makeInstance($className); + if (is_object($obj)) { + if (!is_callable([$obj, 'init'])) { + self::getLogger()->error('Requested service {class} has no init() method.', [ + 'class' => $info['className'], + 'service' => $info, + ]); + throw new \RuntimeException('Broken service: ' . $info['className'], 1568119209); + } + $obj->info = $info; + // service available? + if ($obj->init()) { + return $obj; + } + $error = $obj->getLastErrorArray(); + unset($obj); + } + + // deactivate the service + ExtensionManagementUtility::deactivateService($info['serviceType'], $info['serviceKey']); + } + return $error; + } + + /** + * Quotes a string for usage as JS parameter. + * + * @param string $value the string to encode, may be empty + * @return string the encoded value already quoted (with single quotes), + */ + public static function quoteJSvalue(string $value): string + { + $json = (string)json_encode( + $value, + JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG + ); + + return strtr( + $json, + [ + '"' => '\'', + '\\\\' => '\\u005C', + ' ' => '\\u0020', + '!' => '\\u0021', + '\\t' => '\\u0009', + '\\n' => '\\u000A', + '\\r' => '\\u000D', + ] + ); + } + + /** + * Serializes data to JSON, to be used in HTML attribute, e.g. + * + * `<div data-value="[[JSON]]">...</div>` + * (`[[JSON]]` represents return value of this function) + */ + public static function jsonEncodeForHtmlAttribute(mixed $value, bool $useHtmlEntities = true): string + { + $json = (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG); + return $useHtmlEntities ? htmlspecialchars($json) : $json; + } + + /** + * Serializes data to JSON, to be used in JavaScript instructions, e.g. + * + * `<script>const value = JSON.parse('[[JSON]]');</script>` + * (`[[JSON]]` represents return value of this function) + */ + public static function jsonEncodeForJavaScript(mixed $value): string + { + $json = (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG); + return strtr( + $json, + [ + // comments below refer to JSON-encoded data + '\\\\' => '\\\\u005C', // `"\\Vendor\\Package"` -> `"\\u005CVendor\\u005CPackage"` + '\\t' => '\\u0009', // `"\t"` -> `"\u0009"` + '\\n' => '\\u000A', // `"\n"` -> `"\u000A"` + '\\r' => '\\u000D', // `"\r"` -> `"\u000D"` + ] + ); + } + + /** + * Very, very, very basic CSS sanitizer which removes `{`, `}`, `\n`, `\r` + * from CSS variable values and encodes potential HTML entities `<`+`>`. + */ + public static function sanitizeCssVariableValue(string $value): string + { + $value = str_replace(['{', '}', "\n", "\r"], '', $value); + // keep quotes, e.g. for `background: url("/res/background.png")` + return htmlspecialchars($value, ENT_SUBSTITUTE); + } + + protected static function getLogger(): LoggerInterface + { + return static::makeInstance(LogManager::class)->getLogger(__CLASS__); + } +} diff --git a/Classes/Utility/HttpUtility.php b/Classes/Utility/HttpUtility.php new file mode 100644 index 0000000..817d724 --- /dev/null +++ b/Classes/Utility/HttpUtility.php @@ -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\Core\Utility; + +/** + * HTTP Utility class + */ +class HttpUtility +{ + // HTTP Headers, see https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + // INFORMATIONAL CODES + public const HTTP_STATUS_100 = 'HTTP/1.1 100 Continue'; + public const HTTP_STATUS_101 = 'HTTP/1.1 101 Switching Protocols'; + public const HTTP_STATUS_102 = 'HTTP/1.1 102 Processing'; + public const HTTP_STATUS_103 = 'HTTP/1.1 103 Early Hints'; + // SUCCESS CODES + public const HTTP_STATUS_200 = 'HTTP/1.1 200 OK'; + public const HTTP_STATUS_201 = 'HTTP/1.1 201 Created'; + public const HTTP_STATUS_202 = 'HTTP/1.1 202 Accepted'; + public const HTTP_STATUS_203 = 'HTTP/1.1 203 Non-Authoritative Information'; + public const HTTP_STATUS_204 = 'HTTP/1.1 204 No Content'; + public const HTTP_STATUS_205 = 'HTTP/1.1 205 Reset Content'; + public const HTTP_STATUS_206 = 'HTTP/1.1 206 Partial Content'; + public const HTTP_STATUS_207 = 'HTTP/1.1 207 Multi-status'; + public const HTTP_STATUS_208 = 'HTTP/1.1 208 Already Reported'; + public const HTTP_STATUS_226 = 'HTTP/1.1 226 IM Used'; + // REDIRECTION CODES + public const HTTP_STATUS_300 = 'HTTP/1.1 300 Multiple Choices'; + public const HTTP_STATUS_301 = 'HTTP/1.1 301 Moved Permanently'; + public const HTTP_STATUS_302 = 'HTTP/1.1 302 Found'; + public const HTTP_STATUS_303 = 'HTTP/1.1 303 See Other'; + public const HTTP_STATUS_304 = 'HTTP/1.1 304 Not Modified'; + public const HTTP_STATUS_305 = 'HTTP/1.1 305 Use Proxy'; + public const HTTP_STATUS_306 = 'HTTP/1.1 306 Switch Proxy'; // Deprecated + public const HTTP_STATUS_307 = 'HTTP/1.1 307 Temporary Redirect'; + public const HTTP_STATUS_308 = 'HTTP/1.1 308 Permanent Redirect'; + // CLIENT ERROR + public const HTTP_STATUS_400 = 'HTTP/1.1 400 Bad Request'; + public const HTTP_STATUS_401 = 'HTTP/1.1 401 Unauthorized'; + public const HTTP_STATUS_402 = 'HTTP/1.1 402 Payment Required'; + public const HTTP_STATUS_403 = 'HTTP/1.1 403 Forbidden'; + public const HTTP_STATUS_404 = 'HTTP/1.1 404 Not Found'; + public const HTTP_STATUS_405 = 'HTTP/1.1 405 Method Not Allowed'; + public const HTTP_STATUS_406 = 'HTTP/1.1 406 Not Acceptable'; + public const HTTP_STATUS_407 = 'HTTP/1.1 407 Proxy Authentication Required'; + public const HTTP_STATUS_408 = 'HTTP/1.1 408 Request Timeout'; + public const HTTP_STATUS_409 = 'HTTP/1.1 409 Conflict'; + public const HTTP_STATUS_410 = 'HTTP/1.1 410 Gone'; + public const HTTP_STATUS_411 = 'HTTP/1.1 411 Length Required'; + public const HTTP_STATUS_412 = 'HTTP/1.1 412 Precondition Failed'; + public const HTTP_STATUS_413 = 'HTTP/1.1 413 Request Entity Too Large'; + public const HTTP_STATUS_414 = 'HTTP/1.1 414 URI Too Long'; + public const HTTP_STATUS_415 = 'HTTP/1.1 415 Unsupported Media Type'; + public const HTTP_STATUS_416 = 'HTTP/1.1 416 Requested range not satisfiable'; + public const HTTP_STATUS_417 = 'HTTP/1.1 417 Expectation Failed'; + public const HTTP_STATUS_418 = 'HTTP/1.1 418 I\'m a teapot'; + public const HTTP_STATUS_422 = 'HTTP/1.1 422 Unprocessable Entity'; + public const HTTP_STATUS_423 = 'HTTP/1.1 423 Locked'; + public const HTTP_STATUS_424 = 'HTTP/1.1 424 Failed Dependency'; + public const HTTP_STATUS_425 = 'HTTP/1.1 425 Unordered Collection'; + public const HTTP_STATUS_426 = 'HTTP/1.1 426 Upgrade Required'; + public const HTTP_STATUS_428 = 'HTTP/1.1 428 Precondition Required'; + public const HTTP_STATUS_429 = 'HTTP/1.1 429 Too Many Requests'; + public const HTTP_STATUS_431 = 'HTTP/1.1 431 Request Header Fields Too Large'; + public const HTTP_STATUS_451 = 'HTTP/1.1 451 Unavailable For Legal Reasons'; + // SERVER ERROR + public const HTTP_STATUS_500 = 'HTTP/1.1 500 Internal Server Error'; + public const HTTP_STATUS_501 = 'HTTP/1.1 501 Not Implemented'; + public const HTTP_STATUS_502 = 'HTTP/1.1 502 Bad Gateway'; + public const HTTP_STATUS_503 = 'HTTP/1.1 503 Service Unavailable'; + public const HTTP_STATUS_504 = 'HTTP/1.1 504 Gateway Time-out'; + public const HTTP_STATUS_505 = 'HTTP/1.1 505 Version not Supported'; + public const HTTP_STATUS_506 = 'HTTP/1.1 506 Variant Also Negotiates'; + public const HTTP_STATUS_507 = 'HTTP/1.1 507 Insufficient Storage'; + public const HTTP_STATUS_508 = 'HTTP/1.1 508 Loop Detected'; + public const HTTP_STATUS_509 = 'HTTP/1.1 509 Bandwidth Limit Exceeded'; + public const HTTP_STATUS_511 = 'HTTP/1.1 511 Network Authentication Required'; + // URL Schemes + public const SCHEME_HTTP = 1; + public const SCHEME_HTTPS = 2; + + /** + * Builds a URL string from an array with the URL parts, as e.g. output by parse_url(). + * + * @see http://www.php.net/parse_url + */ + public static function buildUrl(array $urlParts): string + { + return (isset($urlParts['scheme']) ? $urlParts['scheme'] . '://' : '') + . (isset($urlParts['user']) ? $urlParts['user'] + . (isset($urlParts['pass']) ? ':' . $urlParts['pass'] : '') . '@' : '') + . ($urlParts['host'] ?? '') + . (isset($urlParts['port']) ? ':' . $urlParts['port'] : '') + . ($urlParts['path'] ?? '') + . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '') + . (isset($urlParts['fragment']) ? '#' . $urlParts['fragment'] : ''); + } + + /** + * Implodes a multidimensional array of query parameters to a string of GET parameters (eg. param[key][key2]=value2¶m[key][key3]=value3) + * and properly encodes parameter names as well as values. Spaces are encoded as %20 + * + * @param array $parameters The (multidimensional) array of query parameters with values + * @param string $prependCharacter If the created query string is not empty, prepend this character "?" or "&" else no prepend + * @param bool $skipEmptyParameters If true, empty parameters (blank string, empty array, null) are removed. + * @return string Imploded result, for example param[key][key2]=value2¶m[key][key3]=value3 + * @see explodeUrl2Array() + */ + public static function buildQueryString(array $parameters, string $prependCharacter = '', bool $skipEmptyParameters = false): string + { + if (empty($parameters)) { + return ''; + } + + if ($skipEmptyParameters) { + // This callback filters empty strings, array and null but keeps zero integers + $parameters = ArrayUtility::filterRecursive( + $parameters, + static function ($item) { + return $item !== '' && $item !== [] && $item !== null; + } + ); + } + + $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + $prependCharacter = $prependCharacter === '?' || $prependCharacter === '&' ? $prependCharacter : ''; + + return $queryString && $prependCharacter ? $prependCharacter . $queryString : $queryString; + } +} diff --git a/Classes/Utility/IpAnonymizationUtility.php b/Classes/Utility/IpAnonymizationUtility.php new file mode 100644 index 0000000..d0d4107 --- /dev/null +++ b/Classes/Utility/IpAnonymizationUtility.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +/** + * Anonymize a given IP + * + * Inspired by https://github.com/geertw/php-ip-anonymizer + */ +class IpAnonymizationUtility +{ + /** + * IPv4 netmask used to anonymize IPv4 address. + * + * 1) Mask host + * 2) Mask host and subnet + * + * @var array<int, string> + */ + public const MASKV4 = [ + 1 => '255.255.255.0', + 2 => '255.255.0.0', + ]; + + /** + * IPv6 netmask used to anonymize IPv6 address. + * + * 1) Mask Interface ID + * 2) Mask Interface ID and SLA ID + * + * @var array<int, string> + */ + public const MASKV6 = [ + 1 => 'ffff:ffff:ffff:ffff:0000:0000:0000:0000', + 2 => 'ffff:ffff:ffff:0000:0000:0000:0000:0000', + ]; + + /** + * Anonymize given IP + * + * @param string $address IP address + * @param int $mask Allowed values are 0 (masking disabled), 1 (mask host), 2 (mask host and subnet) + * @throws \UnexpectedValueException + */ + public static function anonymizeIp(string $address, ?int $mask = null): string + { + if ($mask === null) { + $mask = (int)$GLOBALS['TYPO3_CONF_VARS']['SYS']['ipAnonymization']; + } + if ($mask < 0 || $mask > 2) { + throw new \UnexpectedValueException(sprintf('The provided value "%d" is not an allowed value for the IP mask.', $mask), 1519739203); + } + if ($mask === 0) { + return $address; + } + if (empty($address)) { + return ''; + } + + $packedAddress = @inet_pton($address); + if ($packedAddress === false) { + return ''; + } + $length = strlen($packedAddress); + + if ($length === 4) { + $bitMask = self::MASKV4[$mask]; + } elseif ($length === 16) { + $bitMask = self::MASKV6[$mask]; + } else { + return ''; + } + $packedBitMask = inet_pton($bitMask); + if ($packedBitMask === false) { + return ''; + } + return inet_ntop($packedAddress & $packedBitMask); + } +} diff --git a/Classes/Utility/MailUtility.php b/Classes/Utility/MailUtility.php new file mode 100644 index 0000000..b561519 --- /dev/null +++ b/Classes/Utility/MailUtility.php @@ -0,0 +1,359 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Exception\ExceptionInterface; + +/** + * Class to handle mail specific functionality + */ +class MailUtility +{ + /** + * Gets a valid "from" for mail messages (email and name). + * + * Ready to be passed to $mail->setFrom() + * + * This method can return three different variants: + * 1. An assoc. array: key => Valid email address which can be used as sender; value => Valid name which can be used as a sender + * 2. A numeric array with one entry: Valid email address which can be used as sender + * 3. Null, if no address is configured + * + * @return array<string|int, string>|null + */ + public static function getSystemFrom(): ?array + { + $address = self::getSystemFromAddress(); + $name = self::getSystemFromName(); + if (!$address) { + return null; + } + if ($name) { + return [$address => $name]; + } + return [$address]; + } + + /** + * Creates a valid "from" name for mail messages. + * + * As configured in Install Tool. + * + * @return string|null The name (unquoted, unformatted). NULL if none is set or an invalid non-string value. + */ + public static function getSystemFromName(): ?string + { + $name = $GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromName'] ?? null; + + return (!empty($name) && is_string($name)) ? $name : null; + } + + /** + * Creates a valid email address for the sender of mail messages. + * + * Uses a fallback chain: + * $TYPO3_CONF_VARS['MAIL']['defaultMailFromAddress'] -> + * no-reply@FirstDomainRecordFound -> + * no-reply@php_uname('n') -> + * no-reply@example.com + * + * Ready to be passed to $mail->setFrom() + * + * @return string An email address + */ + public static function getSystemFromAddress(): string + { + $address = $GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromAddress'] ?? null; + + if (!is_string($address) || !GeneralUtility::validEmail($address)) { + // still nothing, get host name from server + $address = 'no-reply@' . php_uname('n'); + if (!GeneralUtility::validEmail($address)) { + // if everything fails use a dummy address + $address = 'no-reply@example.com'; + } + } + return $address; + } + + /** + * Gets a default "reply-to" for mail messages (email and name). + * + * Ready to be passed to $mail->setReplyTo() + * + * This method returns a list of email addresses, but depending on the existence of "defaultMailReplyToName" + * the array can have a different shape: + * + * 1. An assoc. array: key => a valid reply-to address which can be used as sender; value => a valid reply-to name which can be used as a sender + * 2. A numeric array with one entry: a valid reply-to address which can be used as sender + * + * @return array<string|int, string> + */ + public static function getSystemReplyTo(): array + { + $mailConfiguration = $GLOBALS['TYPO3_CONF_VARS']['MAIL'] ?? []; + $replyToAddress = $mailConfiguration['defaultMailReplyToAddress'] ?? null; + if (empty($replyToAddress) || !GeneralUtility::validEmail($replyToAddress)) { + return []; + } + + if (!empty($mailConfiguration['defaultMailReplyToName'])) { + $replyTo = [$replyToAddress => $mailConfiguration['defaultMailReplyToName']]; + } else { + $replyTo = [$replyToAddress]; + } + + return $replyTo; + } + + /** + * Breaks up a single line of text for emails + * Words - longer than $lineWidth - will not be split into parts + * + * @param string $str The string to break up + * @param string $newlineChar The string to implode the broken lines with (default/typically \n) + * @param int $lineWidth The line width + * @return string Reformatted text + */ + public static function breakLinesForEmail(string $str, string $newlineChar = LF, int $lineWidth = 76): string + { + $lines = []; + $substrStart = 0; + while (strlen($str) > $substrStart) { + $substr = substr($str, $substrStart, $lineWidth); + // has line exceeded (reached) the maximum width? + if (strlen($substr) === $lineWidth) { + // find last space-char + $spacePos = strrpos(rtrim($substr), ' '); + // space-char found? + if ($spacePos !== false) { + // take everything up to last space-char + $theLine = substr($substr, 0, $spacePos); + $substrStart++; + } else { + // search for space-char in remaining text + // makes this line longer than $lineWidth! + $afterParts = explode(' ', substr($str, $lineWidth + $substrStart), 2); + $theLine = $substr . $afterParts[0]; + } + if ($theLine === '') { + // prevent endless loop because of empty line + break; + } + } else { + $theLine = $substr; + } + $lines[] = trim($theLine); + $substrStart += strlen($theLine); + if (trim(substr($str, $substrStart, $lineWidth)) === '') { + // no more text + break; + } + } + return implode($newlineChar, $lines); + } + + /** + * Parses mailbox headers and turns them into an array. + * + * Mailbox headers are a comma separated list of 'name <email@example.org>' combinations + * or plain email addresses (or a mix of these). + * The resulting array has key-value pairs where the key is either a number + * (no display name in the mailbox header) and the value is the email address, + * or the key is the email address and the value is the display name. + * + * Groups (RFC 5322 section 3.4) are flattened to their members and their display + * name is discarded, comments are removed, and invalid addresses are silently skipped. + * + * @param string $rawAddresses Comma separated list of email addresses (optionally with display name) + * @return array Parsed list of addresses. + */ + public static function parseAddresses(string $rawAddresses): array + { + $addressList = []; + foreach (self::splitAddressList($rawAddresses) as $rawMailbox) { + $address = self::parseMailbox($rawMailbox); + if ($address === null) { + continue; + } + if ($address->getName() !== '') { + // item with name found ( name <email@example.org> ) + $addressList[$address->getAddress()] = $address->getName(); + } else { + // item without name found ( email@example.org ) + $addressList[] = $address->getAddress(); + } + } + return $addressList; + } + + /** + * Splits a raw address-list header value into its individual mailboxes, while + * honoring quoted strings ( "last, first" <email@example.org> ), comments + * (which are removed), domain literals ( user@[IPv6:2001:db8::1] ) and + * angle-addr parts. Group members are flattened into the list, the display + * name of a group is discarded. + * + * @return string[] + */ + private static function splitAddressList(string $rawAddresses): array + { + $mailboxes = []; + $buffer = ''; + $inQuotes = false; + $inAngleAddr = false; + $inDomainLiteral = false; + $commentDepth = 0; + $length = strlen($rawAddresses); + for ($i = 0; $i < $length; $i++) { + $char = $rawAddresses[$i]; + if ($commentDepth > 0) { + if ($char === '\\') { + $i++; + } elseif ($char === '(') { + $commentDepth++; + } elseif ($char === ')') { + $commentDepth--; + if ($commentDepth === 0) { + // a comment is equivalent to folding white space (RFC 5322, section 3.2.2) + $buffer .= ' '; + } + } + continue; + } + if ($inQuotes) { + if ($char === '\\' && $i + 1 < $length) { + $buffer .= $char . $rawAddresses[++$i]; + continue; + } + if ($char === '"') { + $inQuotes = false; + } + $buffer .= $char; + continue; + } + switch ($char) { + case '"': + $inQuotes = true; + $buffer .= $char; + break; + case '(': + $commentDepth++; + break; + case '[': + case ']': + $inDomainLiteral = $char === '['; + $buffer .= $char; + break; + case '<': + case '>': + $inAngleAddr = $char === '<'; + $buffer .= $char; + break; + case ',': + case ';': + if ($inAngleAddr || $inDomainLiteral) { + $buffer .= $char; + break; + } + $mailboxes[] = $buffer; + $buffer = ''; + break; + case ':': + if ($inAngleAddr || $inDomainLiteral) { + $buffer .= $char; + break; + } + // a colon ends the display name of a group ( groupname: member@example.org; ) + $buffer = ''; + break; + default: + $buffer .= $char; + } + } + $mailboxes[] = $buffer; + return $mailboxes; + } + + private static function parseMailbox(string $rawMailbox): ?Address + { + $rawMailbox = trim($rawMailbox); + // NUL is invalid anywhere, even in the obsolete syntax (RFC 5322, section 4.1) + if ($rawMailbox === '' || str_contains($rawMailbox, "\0")) { + return null; + } + $displayName = ''; + $addrSpec = $rawMailbox; + $angleStart = self::findAngleAddrStart($rawMailbox); + if ($angleStart !== null) { + $angleEnd = strrpos($rawMailbox, '>'); + if ($angleEnd === false || $angleEnd < $angleStart) { + return null; + } + $displayName = self::normalizeDisplayName(substr($rawMailbox, 0, $angleStart)); + $addrSpec = substr($rawMailbox, $angleStart + 1, $angleEnd - $angleStart - 1); + if (str_starts_with($addrSpec, '@')) { + // an obsolete route ( <@relay.example.org:user@example.org> ) is ignored (RFC 5322, section 4.4) + $routeEnd = strpos($addrSpec, ':'); + if ($routeEnd !== false) { + $addrSpec = substr($addrSpec, $routeEnd + 1); + } + } + } + try { + return new Address($addrSpec, $displayName); + } catch (ExceptionInterface) { + return null; + } + } + + /** + * Finds the position of the '<' starting an angle-addr, ignoring any '<' + * inside a quoted display name ( "Contact <va@example.org>" <real@example.org> ). + */ + private static function findAngleAddrStart(string $rawMailbox): ?int + { + $inQuotes = false; + $length = strlen($rawMailbox); + for ($i = 0; $i < $length; $i++) { + $char = $rawMailbox[$i]; + if ($inQuotes && $char === '\\') { + $i++; + } elseif ($char === '"') { + $inQuotes = !$inQuotes; + } elseif ($char === '<' && !$inQuotes) { + return $i; + } + } + return null; + } + + /** + * Resolves a quoted display name ( "last, first" ) and contained + * quoted-pairs ( \" ) to the plain text it represents. + */ + private static function normalizeDisplayName(string $displayName): string + { + $displayName = trim($displayName); + if (strlen($displayName) > 1 && str_starts_with($displayName, '"') && str_ends_with($displayName, '"')) { + $displayName = stripslashes(substr($displayName, 1, -1)); + } + return $displayName; + } +} diff --git a/Classes/Utility/MathUtility.php b/Classes/Utility/MathUtility.php new file mode 100644 index 0000000..9e4cfaa --- /dev/null +++ b/Classes/Utility/MathUtility.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +/** + * Class with helper functions for mathematical calculations + */ +class MathUtility +{ + /** + * Forces the integer $theInt into the boundaries of $min and $max. If the $theInt is FALSE then the $defaultValue is applied. + * + * @param mixed $theInt Input value - will be cast to int if non-integer value is passed. + * @param int $min Lower limit + * @param int $max Higher limit + * @param int $defaultValue Default value if input is FALSE. + * @return int The input value forced into the boundaries of $min and $max + */ + public static function forceIntegerInRange(mixed $theInt, int $min, int $max = 2000000000, int $defaultValue = 0): int + { + // Returns $theInt as an integer in the integerspace from $min to $max + $theInt = (int)$theInt; + // If the input value is zero after being converted to integer, + // defaultValue may set another default value for it. + if ($defaultValue && !$theInt) { + $theInt = $defaultValue; + } + if ($theInt < $min) { + $theInt = $min; + } + if ($theInt > $max) { + $theInt = $max; + } + return $theInt; + } + + /** + * Tests if the input can be interpreted as integer. + * + * Note: "0" will return true while any other number with a leading 0 (including multiple zeroes) will be false. + * + * Note: Integer casting from objects or arrays is considered undefined and thus will return false. + * + * @see https://php.net/manual/en/language.types.integer.php#language.types.integer.casting.from-other + * @param mixed $var Any input variable to test + * @return bool Returns TRUE if string is an integer + */ + public static function canBeInterpretedAsInteger(mixed $var): bool + { + return match (gettype($var)) { + 'integer' => true, + // Due to historical reasons `TRUE` is correctly interpreted as integer + // but `FALSE` not even if a (int) cast would return `0` and keeping it + // we can simply return the boolean value to have the same behaviour and + // still avoiding type casting chain. + 'boolean' => $var, + // We use a type casting chain here to ensure that value is the same after + // casting and eliminated invalid stuff from it. The `@` silence operator + // can look weird here but is required to avoid enforced casting issues + // with PHP 8.5.0 and newer. + 'string' => (string)@(int)$var === $var, + // We use a type casting chain here to ensure that value is the same after + // casting and eliminated invalid stuff from it. The `@` silence operator + // can look weird here but is required to avoid enforced casting issues + // with PHP 8.5.0 and newer. + // gettype() returns `double` for `float values` + 'double' => !is_nan($var) && (string)@(int)$var === (string)$var, + // non-scalar like array, object, resource, NULL or unknown_type + default => false, + }; + } + + /** + * Tests if the input can be interpreted as float. + * + * Note: Float casting from objects or arrays is considered undefined and thus will return false. + * + * @see http://www.php.net/manual/en/language.types.float.php, section "Formally" for the notation + * @param mixed $var Any input variable to test + * @return bool Returns TRUE if string is a float + */ + public static function canBeInterpretedAsFloat(mixed $var): bool + { + $pattern_lnum = '[0-9]+'; + $pattern_dnum = '([0-9]*[\.]' . $pattern_lnum . ')|(' . $pattern_lnum . '[\.][0-9]*)'; + $pattern_exp_dnum = '[+-]?((' . $pattern_lnum . '|' . $pattern_dnum . ')([eE][+-]?' . $pattern_lnum . ')?)'; + + if ($var === '' || is_object($var) || is_array($var)) { + return false; + } + + $matches = preg_match('/^' . $pattern_exp_dnum . '$/', (string)$var); + return $matches === 1; + } + + /** + * Calculates the input by +,-,*,/,%,^ with priority to + and - + * + * @param string $string Input string, eg "123 + 456 / 789 - 4 + * @return float|string Calculated value. Or error string. + * @see \TYPO3\CMS\Core\Utility\MathUtility::calculateWithParentheses() + */ + public static function calculateWithPriorityToAdditionAndSubtraction(string $string): float|string + { + // Removing all whitespace + $string = preg_replace('/[[:space:]]*/', '', $string); + // Ensuring an operator for the first entrance + $string = '+' . $string; + $qm = '\\*\\/\\+-^%'; + $regex = '([' . $qm . '])([' . $qm . ']?[0-9\\.]*)'; + // Split the expression here: + $reg = []; + preg_match_all('/' . $regex . '/', $string, $reg); + reset($reg[2]); + $number = 0; + $Msign = '+'; + $err = ''; + $buffer = (float)current($reg[2]); + // Advance pointer + $regSliced = array_slice($reg[2], 1, null, true); + foreach ($regSliced as $k => $v) { + $v = (float)$v; + $sign = $reg[1][$k]; + if ($sign === '+' || $sign === '-') { + $Msign === '-' ? ($number -= $buffer) : ($number += $buffer); + $Msign = $sign; + $buffer = $v; + } else { + if ($sign === '/') { + if ($v) { + $buffer /= $v; + } else { + $err = 'dividing by zero'; + } + } + if ($sign === '%') { + if ($v) { + $buffer %= $v; + } else { + $err = 'dividing by zero'; + } + } + if ($sign === '*') { + $buffer *= $v; + } + if ($sign === '^') { + $buffer = $buffer ** $v; + } + } + } + $number = $Msign === '-' ? ($number - $buffer) : ($number + $buffer); + return $err ? 'ERROR: ' . $err : $number; + } + + /** + * Calculates the input with parenthesis levels + * + * @param string $string Input string, eg "(123 + 456) / 789 - 4 + * @return string Calculated value. Or error string. + * @see calculateWithPriorityToAdditionAndSubtraction() + * @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::stdWrap() + */ + public static function calculateWithParentheses(string $string): string + { + $securC = 100; + do { + $valueLenO = strcspn($string, '('); + $valueLenC = strcspn($string, ')'); + if ($valueLenC == strlen($string) || $valueLenC < $valueLenO) { + $value = self::calculateWithPriorityToAdditionAndSubtraction(substr($string, 0, $valueLenC)); + $string = $value . substr($string, $valueLenC + 1); + return $string; + } + $string = substr($string, 0, $valueLenO) . self::calculateWithParentheses(substr($string, $valueLenO + 1)); + + // Security: + $securC--; + if ($securC <= 0) { + break; + } + } while ($valueLenO < strlen($string)); + return $string; + } + + /** + * Checks whether the given number $value is an integer in the range [$minimum;$maximum] + * + * @param mixed $value Integer value to check. If not an integer this method always returns false. + * @param int $minimum Lower boundary of the range + * @param int $maximum Upper boundary of the range + */ + public static function isIntegerInRange(mixed $value, int $minimum, int $maximum): bool + { + $value = filter_var($value, FILTER_VALIDATE_INT, [ + 'options' => [ + 'min_range' => $minimum, + 'max_range' => $maximum, + ], + ]); + return is_int($value); + } +} diff --git a/Classes/Utility/PathUtility.php b/Classes/Utility/PathUtility.php new file mode 100644 index 0000000..921c81d --- /dev/null +++ b/Classes/Utility/PathUtility.php @@ -0,0 +1,433 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException; +use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; + +/** + * Class with helper functions for file paths. + */ +readonly class PathUtility +{ + /** + * Creates an absolute URL out of really any input path, removes '../' parts for the targetPath + * + * @todo: And this exactly is a big issue as it mixes file system paths with (relative) URLs. + * Additionally, it depends on the current request and can not do its job on CLI. + * Deprecate entirely and replace with stricter API. + * + * @param string $targetPath can be "../typo3conf/ext/myext/myfile.js" or "/myfile.js" + * @param bool $prefixWithSitePath Don't use this argument. It is only used by TYPO3 in one place, which are subject to removal. + * @return string something like "/mysite/typo3conf/ext/myext/myfile.js" + */ + public static function getAbsoluteWebPath(string $targetPath, bool $prefixWithSitePath = true): string + { + if (static::hasProtocolAndScheme($targetPath)) { + return $targetPath; + } + + $prefixWithSitePath = $prefixWithSitePath && !Environment::isCli(); + if (self::isAbsolutePath($targetPath)) { + if (str_starts_with($targetPath, Environment::getPublicPath())) { + // It is an absolute file system path with file/folder inside document root, + // therefore we can strip the full file system path to the document root to obtain the URI + $targetPath = self::stripPathSitePrefix($targetPath); + } elseif (Environment::isComposerMode() && str_contains($targetPath, 'Resources/Public') && str_starts_with($targetPath, Environment::getProjectPath())) { + // TYPO3 is in managed by Composer and it is an absolute file system path inside composer root path, + // and a public resource is referenced, therefore we can calculate the path to the published assets + // This is true for all Composer packages that are installed in vendor folder by Composer, but still recognized by TYPO3 + $relativePath = substr($targetPath, strlen(Environment::getProjectPath())); + // The $relativePath might contain multiple occurrences of 'Resources/Public', so only search for first one + [$relativePrefix, $relativeAssetPath] = explode('Resources/Public', $relativePath, 2); + $targetPath = '_assets/' . md5($relativePrefix) . $relativeAssetPath; + } else { + // At this point it can be ANY path, even an invalid or non existent and it is totally unclear, + // whether this is a mistake or accidentally working as intended. + // The only conclusion here is, that this API has to be deprecated altogether an be replaced with API + // that clearly distinguishes between creating a URL from a static resource and ensuring an URL is absolute and not relative to current script. + $prefixWithSitePath = false; + } + } else { + // Make an absolute path out of it + $targetPath = self::dirname(Environment::getCurrentScript()) . '/' . $targetPath; + $targetPath = self::stripPathSitePrefix($targetPath); + } + + if ($prefixWithSitePath) { + // @todo: Another reason this method must fall. + $targetPath = NormalizedParams::createFromServerParams($_SERVER)->getSitePath() . $targetPath; + } + + return $targetPath; + } + + /** + * @internal Will be removed (or made private) before v14 LTS release + * + * @throws CanNotResolvePublicResourceException + * @throws CanNotResolveSystemResourceException + */ + public static function getSystemResourceUri(string $resourceIdentifier, ?ServerRequestInterface $request = null, ?UriGenerationOptions $options = null): UriInterface + { + $resourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class); + $resource = $resourceFactory->createPublicResource($resourceIdentifier); + $resourcePublisher = GeneralUtility::makeInstance(SystemResourcePublisherInterface::class); + return $resourcePublisher->generateUri($resource, $request, $options); + } + + /** + * Checks whether the given path is an extension resource + */ + public static function isExtensionPath(string $path, bool $includePackagePaths = false): bool + { + return + str_starts_with($path, 'EXT:') + || ($includePackagePaths && str_starts_with($path, 'PKG:')); + } + + /** + * Gets the common path prefix out of many paths. + * + /var/www/domain.com/typo3/sysext/frontend/ + * + /var/www/domain.com/typo3/sysext/em/ + * + /var/www/domain.com/typo3/sysext/file/ + * = /var/www/domain.com/typo3/sysext/ + * + * @param array<string> $paths Paths to be processed + */ + public static function getCommonPrefix(array $paths): ?string + { + $paths = array_map(GeneralUtility::fixWindowsFilePath(...), $paths); + $commonPath = null; + if (count($paths) === 1) { + $commonPath = array_shift($paths); + } elseif (count($paths) > 1) { + $parts = explode('/', (string)array_shift($paths)); + $comparePath = ''; + $break = false; + foreach ($parts as $part) { + $comparePath .= $part . '/'; + foreach ($paths as $path) { + if (!str_starts_with($path . '/', $comparePath)) { + $break = true; + break; + } + } + if ($break) { + break; + } + $commonPath = $comparePath; + } + } + if ($commonPath !== null) { + $commonPath = self::sanitizeTrailingSeparator($commonPath, '/'); + } + return $commonPath; + } + + /** + * Normalizes a trailing separator. + * + * (e.g. 'some/path' -> 'some/path/') + * + * @param string $path The path to be sanitized + * @param string $separator The separator to be used + */ + public static function sanitizeTrailingSeparator(string $path, string $separator = '/'): string + { + return rtrim($path, $separator) . $separator; + } + + /** + * Returns trailing name component of path + * + * Since basename() is locale dependent we need to access + * the filesystem with the same locale of the system, not + * the rendering context. + * + * @see http://www.php.net/manual/en/function.basename.php + * + * @param string $path + */ + public static function basename(string $path): string + { + $targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? ''; + if (empty($targetLocale)) { + return basename($path); + } + $currentLocale = (string)setlocale(LC_CTYPE, '0'); + setlocale(LC_CTYPE, $targetLocale); + $basename = basename($path); + setlocale(LC_CTYPE, $currentLocale); + return $basename; + } + + /** + * Returns parent directory's path + * + * Since dirname() is locale dependent we need to access + * the filesystem with the same locale of the system, not + * the rendering context. + * + * @see http://www.php.net/manual/en/function.dirname.php + * + * @param string $path + */ + public static function dirname(string $path): string + { + $targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? ''; + if (empty($targetLocale)) { + return dirname($path); + } + $currentLocale = (string)setlocale(LC_CTYPE, '0'); + setlocale(LC_CTYPE, $targetLocale); + $dirname = dirname($path); + setlocale(LC_CTYPE, $currentLocale); + return $dirname; + } + + /** + * Returns parent directory's path + * + * Since pathinfo() is locale dependent we need to access + * the filesystem with the same locale of the system, not + * the rendering context. + * + * The valid flags for $options are the same as for the built-in + * phpinfo() function. + * + * @see http://www.php.net/manual/en/function.pathinfo.php + * + * @return ($options is PATHINFO_ALL ? array{dirname?: string, basename?: string, extension?: string, filename?: string} : string) + */ + public static function pathinfo(string $path, int $options = PATHINFO_ALL): string|array + { + $targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? ''; + if (empty($targetLocale)) { + return pathinfo($path, $options); + } + $currentLocale = (string)setlocale(LC_CTYPE, '0'); + setlocale(LC_CTYPE, $targetLocale); + $pathinfo = pathinfo($path, $options); + setlocale(LC_CTYPE, $currentLocale); + return $pathinfo; + } + + /** + * Checks if the $path is absolute or relative (detecting either '/' or 'x:/' as first part of string) and returns TRUE if so. + */ + public static function isAbsolutePath(string $path): bool + { + // On Windows also a path starting with a drive letter is absolute: X:/ + if (Environment::isWindows() && (substr($path, 1, 2) === ':/' || substr($path, 1, 2) === ':\\')) { + return true; + } + // Path starting with a / is always absolute, on every system + return str_starts_with($path, '/'); + } + + /** + * Gets the (absolute) path of an include file based on the (absolute) path of a base file + * + * Does NOT do any sanity checks. This is a task for the calling function, e.g. + * call GeneralUtility::getFileAbsFileName() on the result. + * @see \TYPO3\CMS\Core\Utility\GeneralUtility::getFileAbsFileName() + * + * Resolves all dots and slashes between that paths of both files. + * Whether the result is absolute or not, depends on the base file name. + * + * If the include file goes higher than a relative base file, then the result + * will contain dots as a relative part. + * <pre> + * base: abc/one.txt + * include: ../../two.txt + * result: ../two.txt + * </pre> + * The exact behavior, refer to getCanonicalPath(). + * + * @param string $baseFilenameOrPath The name of the file or a path that serves as a base; a path will need to have a '/' at the end + * @param string $includeFileName The name of the file that is included in the file + * @return string The (absolute) path of the include file + */ + public static function getAbsolutePathOfRelativeReferencedFileOrPath(string $baseFilenameOrPath, string $includeFileName): string + { + $fileName = static::basename($includeFileName); + $basePath = str_ends_with($baseFilenameOrPath, '/') ? $baseFilenameOrPath : static::dirname($baseFilenameOrPath); + $newDir = static::getCanonicalPath($basePath . '/' . static::dirname($includeFileName)); + // Avoid double slash on empty path + return (($newDir !== '/') ? $newDir : '') . '/' . $fileName; + } + + /** + * Returns parent directory's path + * Early during bootstrap there is no TYPO3_CONF_VARS yet so the setting for the system locale + * is also unavailable. The path of the parent directory is determined with a regular expression + * to avoid issues with locales. + * + * + * @return string Path without trailing slash + */ + public static function dirnameDuringBootstrap(string $path): string + { + return preg_replace('#(.*)(/|\\\\)([^\\\\/]+)$#', '$1', $path); + } + + /** + * Returns filename part of a path + * Early during bootstrap there is no TYPO3_CONF_VARS yet so the setting for the system locale + * is also unavailable. The filename part is determined with a regular expression to avoid issues + * with locales. + */ + public static function basenameDuringBootstrap(string $path): string + { + return preg_replace('#.*[/\\\\]([^\\\\/]+)$#', '$1', $path); + } + + /********************* + * + * Cleaning methods + * + *********************/ + /** + * Resolves all dots, slashes and removes spaces after or before a path... + * + * @param string $path Input string + * @return string Canonical path, always without trailing slash + */ + public static function getCanonicalPath(string $path): string + { + // Replace backslashes with slashes to work with Windows paths if given + $path = trim(str_replace('\\', '/', $path)); + + // @todo do we really need this? Probably only in testing context for vfs? + $protocol = ''; + if (str_contains($path, '://')) { + [$protocol, $path] = explode('://', $path); + $protocol .= '://'; + } + + $absolutePathPrefix = ''; + if (static::isAbsolutePath($path)) { + if (Environment::isWindows() && substr($path, 1, 2) === ':/') { + $absolutePathPrefix = substr($path, 0, 3); + $path = substr($path, 3); + } else { + $path = ltrim($path, '/'); + $absolutePathPrefix = '/'; + } + } + + $theDirParts = explode('/', $path); + $theDirPartsCount = count($theDirParts); + // This cannot use a foreach() as some steps skip ahead multiple elements. + for ($partCount = 0; $partCount < $theDirPartsCount; $partCount++) { + // double-slashes in path: remove element + if ($theDirParts[$partCount] === '') { + array_splice($theDirParts, $partCount, 1); + $partCount--; + $theDirPartsCount--; + } + // "." in path: remove element + if (($theDirParts[$partCount] ?? '') === '.') { + array_splice($theDirParts, $partCount, 1); + $partCount--; + $theDirPartsCount--; + } + // ".." in path: + if (($theDirParts[$partCount] ?? '') === '..') { + if ($partCount >= 1) { + // Remove this and previous element + array_splice($theDirParts, $partCount - 1, 2); + $partCount -= 2; + $theDirPartsCount -= 2; + } elseif ($absolutePathPrefix) { + // can't go higher than root dir + // simply remove this part and continue + array_splice($theDirParts, $partCount, 1); + $partCount--; + $theDirPartsCount--; + } + } + } + + return $protocol . $absolutePathPrefix . implode('/', $theDirParts); + } + + /** + * Strip first part of a path, equal to the length of public web path including trailing slash + * + * @internal + */ + public static function stripPathSitePrefix(string $path): string + { + return substr($path, strlen(Environment::getPublicPath() . '/')); + } + + /** + * Tries to guess whether a given URL hast protocol and (optional) scheme. + * Scheme relative URLs match as well. + * Current implementation is two simple string operations. + * + * This is just a guess. For a more detailed validation and parsing, + * use \TYPO3\CMS\Core\Utility\GeneralUtility::isValidUrl() + * + * @param string $path + * + * @internal + */ + public static function hasProtocolAndScheme(string $path): bool + { + return str_starts_with($path, '//') || strpos($path, '://') > 0; + } + + /** + * Evaluates a given path against the optional settings in `$GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath']`. + * Albeit the name `BE/lockRootPath` is misleading, this setting was and is used in general and is not limited + * to the backend-scope. The setting actually allows defining additional paths, besides the project root path. + * + * @param string $path Absolute path to a file or directory + */ + public static function isAllowedAdditionalPath(string $path): bool + { + // ensure the submitted path ends with a string, even for a file + $path = self::sanitizeTrailingSeparator($path); + $allowedPaths = $GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath'] ?? []; + if (is_string($allowedPaths)) { + // The setting was a string before and is now an array + // For compatibility reasons, we cast a string to an array here for now + $allowedPaths = [$allowedPaths]; + } + if (!is_array($allowedPaths)) { + throw new \RuntimeException('$GLOBALS[\'TYPO3_CONF_VARS\'][\'BE\'][\'lockRootPath\'] is expected to be an array.', 1707408379); + } + foreach ($allowedPaths as $allowedPath) { + $allowedPath = trim($allowedPath); + if ($allowedPath !== '' && str_starts_with($path, self::sanitizeTrailingSeparator($allowedPath))) { + return true; + } + } + return false; + } +} diff --git a/Classes/Utility/PermutationUtility.php b/Classes/Utility/PermutationUtility.php new file mode 100644 index 0000000..e9d4737 --- /dev/null +++ b/Classes/Utility/PermutationUtility.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +/** + * Class with helper functions for permuting items. + */ +class PermutationUtility +{ + /** + * Combines string items of multiple arrays as cross-product into flat items. + * + * Example: + * + meltStringItems([['a', 'b'], ['c', 'd'], ['e', 'f']]) + * + results into ['ace', 'acf', 'ade', 'adf', 'bce', 'bcf', 'bde', 'bdf'] + * + * @param array[] $payload Distinct array that should be melted + * @param string $previousResult Previous item results + * @return string[] + */ + public static function meltStringItems(array $payload, string $previousResult = ''): array + { + $results = []; + $items = static::nextItems($payload); + foreach ($items as $item) { + if (!(is_string($item) || $item instanceof \Stringable)) { + throw new \LogicException( + sprintf('Expected string, got %s', gettype($item)), + 1578164102 + ); + } + $resultItem = $previousResult . $item; + if (!empty($payload)) { + $results = array_merge( + $results, + static::meltStringItems($payload, $resultItem) + ); + continue; + } + $results[] = $resultItem; + } + return $results; + } + + /** + * Combines arbitrary items of multiple arrays as cross-product into flat items. + * + * Example: + * + meltArrayItems(['a','b'], ['c','e'], ['f','g']) + * + results into ['a', 'c', 'e'], ['a', 'c', 'f'], ['a', 'd', 'e'], ['a', 'd', 'f'], + * ['b', 'c', 'e'], ['b', 'c', 'f'], ['b', 'd', 'e'], ['b', 'd', 'f'], + * + * @param array[] $payload Distinct items that should be melted + * @param array $previousResult Previous item results + * @return array[] + */ + public static function meltArrayItems(array $payload, array $previousResult = []): array + { + $results = []; + $items = static::nextItems($payload); + foreach ($items as $item) { + $resultItems = $previousResult; + $resultItems[] = $item; + if (!empty($payload)) { + $results = array_merge( + $results, + static::meltArrayItems($payload, $resultItems) + ); + continue; + } + $results[] = $resultItems; + } + return $results; + } + + protected static function nextItems(array &$payload): iterable + { + $items = array_shift($payload); + if (is_iterable($items)) { + return $items; + } + throw new \LogicException( + sprintf('Expected iterable, got %s', gettype($items)), + 1578164101 + ); + } +} diff --git a/Classes/Utility/RootlineUtility.php b/Classes/Utility/RootlineUtility.php new file mode 100644 index 0000000..9ffc3a7 --- /dev/null +++ b/Classes/Utility/RootlineUtility.php @@ -0,0 +1,1419 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use Doctrine\DBAL\Exception as DoctrineException; +use Doctrine\DBAL\Platforms\TrimMode; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Context\Context; +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\Domain\Repository\PageRepository; +use TYPO3\CMS\Core\Exception\Page\BrokenRootLineException; +use TYPO3\CMS\Core\Exception\Page\CircularRootLineException; +use TYPO3\CMS\Core\Exception\Page\MountPointsDisabledException; +use TYPO3\CMS\Core\Exception\Page\PageNotFoundException; +use TYPO3\CMS\Core\Exception\Page\PagePropertyRelationNotFoundException; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Versioning\VersionState; + +/** + * A utility resolving and Caching the Rootline generation + */ +class RootlineUtility +{ + // Note that having a nesting depth of 100 is quite high, but defined to be more on a "safe" side here. Main goal + // is to mitigate unforeseen recursion which are not covered by the ancestor guard (checking page uid in path). + private const int MAX_CTE_TRAVERSAL_LEVELS = 100; + + /** @internal */ + public const RUNTIME_CACHE_TAG = 'rootline-utility'; + + protected int $pageUid; + + protected string $mountPointParameter; + + /** @var int[] */ + protected array $parsedMountPointParameters = []; + + protected int $languageUid = 0; + + protected int $workspaceUid = 0; + + protected FrontendInterface $cache; + protected FrontendInterface $runtimeCache; + + protected PageRepository $pageRepository; + protected Context $context; + + protected string $cacheIdentifier; + + /** + * @throws MountPointsDisabledException + */ + public function __construct(int $uid, string $mountPointParameter = '', ?Context $context = null) + { + $this->mountPointParameter = $this->sanitizeMountPointParameter($mountPointParameter); + $this->context = $context ?? GeneralUtility::makeInstance(Context::class); + $this->pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context); + $this->languageUid = $this->context->getPropertyFromAspect('language', 'id', 0); + $this->workspaceUid = (int)$this->context->getPropertyFromAspect('workspace', 'id', 0); + if ($this->mountPointParameter !== '') { + if (!($GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids'] ?? false)) { + throw new MountPointsDisabledException('Mount-Point Pages are disabled for this installation. Cannot resolve a Rootline for a page with Mount-Points', 1343462896); + } + $this->parseMountPointParameter(); + } + $this->pageUid = $this->resolvePageId($uid); + $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline'); + $this->runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'); + $this->cacheIdentifier = $this->getCacheIdentifier(); + } + + /** + * Returns the actual rootline without the tree root (uid=0), including the page with $this->pageUid + * + * @throws BrokenRootLineException + * @throws CircularRootLineException + * @throws PageNotFoundException + * @throws DoctrineException + */ + public function get(): array + { + if ($this->pageUid === 0) { + // pageUid 0 has no root line, return empty array right away + return []; + } + if (!$this->runtimeCache->has('rootline-localcache-' . $this->cacheIdentifier)) { + $entry = $this->cache->get($this->cacheIdentifier); + if (!$entry) { + $this->generateRootlineCache(); + } else { + $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]); + $depth = count($entry); + // Populate the root-lines for parent pages as well + // since they are part of the current root-line + while ($depth > 1) { + --$depth; + $parentCacheIdentifier = $this->getCacheIdentifier($entry[$depth - 1]['uid']); + // Abort if the root-line of the parent page is + // already in the local cache data + if ($this->runtimeCache->has('rootline-localcache-' . $parentCacheIdentifier)) { + break; + } + // Behaves similar to array_shift(), but preserves + // the array keys - which contain the page ids here + $entry = array_slice($entry, 1, null, true); + $this->runtimeCache->set('rootline-localcache-' . $parentCacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]); + } + } + } + return $this->runtimeCache->get('rootline-localcache-' . $this->cacheIdentifier); + } + + protected function getCacheIdentifier(?int $otherUid = null): string + { + $mountPointParameter = $this->mountPointParameter; + if ($mountPointParameter !== '' && str_contains($mountPointParameter, ',')) { + $mountPointParameter = str_replace(',', '__', $mountPointParameter); + } + return implode('_', [ + $otherUid ?? $this->pageUid, + $mountPointParameter, + $this->languageUid, + $this->workspaceUid, + $this->context->getAspect('visibility')->includeHiddenContent() ? '1' : '0', + $this->context->getAspect('visibility')->includeHiddenPages() ? '1' : '0', + ]); + } + + /** + * Queries the database for the page record and returns it. + * + * @param int $uid Page id + * @throws PageNotFoundException + * @return array<string, string|int|float|null> + */ + protected function getRecordArray(int $uid): array + { + $currentCacheIdentifier = $this->getCacheIdentifier($uid); + if (!$this->runtimeCache->has('rootline-recordcache-' . $currentCacheIdentifier)) { + $row = $this->getWorkspaceResolvedPageRecord($uid, $this->workspaceUid); + if (is_array($row)) { + $row = $this->enrichPageRecordArray($row, $uid); + $this->runtimeCache->set('rootline-recordcache-' . $currentCacheIdentifier, $row, [self::RUNTIME_CACHE_TAG]); + } + } + if (!is_array($this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier) ?? false)) { + throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $uid . '.', 1343464101); + } + return $this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier); + } + + /** + * Resolve relations as defined in TCA and add them to the provided $pageRecord array. + * + * @param int $uid page ID + * @param array<string, string|int|float|null> $pageRecord Page record (possibly overlaid) to be extended with relations + * @throws PagePropertyRelationNotFoundException + * @return array<string, string|int|float|null> $pageRecord with additional relations + */ + protected function enrichWithRelationFields(int $uid, array $pageRecord): array + { + $resultFieldUidArray = []; + $localRelationColumns = []; + $foreignRelationColumns = []; + $foreignRelationColumnTableFieldMapping = []; + $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages'); + foreach ($schema->getFields() as $column => $fieldType) { + $configuration = $fieldType->getConfiguration(); + if ($this->columnHasRelationToResolve($configuration)) { + $resultFieldUidArray[$column] = []; + if (!empty($configuration['MM']) && !empty($configuration['MM_opposite_field']) && !empty($configuration['foreign_table'])) { + $foreignRelationColumns[] = $column; + // This is a solution when multiple fields are on the foreign side in an MM relation to the same local side. + // For instance, when there are two category fields in pages. + $foreignRelationColumnTableFieldMapping[$configuration['foreign_table']][$configuration['MM_opposite_field']][$column] = 1; + } else { + $localRelationColumns[] = $column; + } + } + } + if (empty($localRelationColumns) && empty($foreignRelationColumns)) { + // Early return if there are no relations to resolve at all. Typically, this does not kick in with pages, though. + return $pageRecord; + } + + // @todo: There is a general issue with starttime & endtime restrictions: The date aspect of course always changes. + // Since the result of this operation is cached into rootline cache, resolving restrictions based on time + // may result in invalid caches. We cannot add the time restriction to the cache identifier since that + // would not match cache rows constantly. That cache may also kick in when admin panel "simulate time" + // is used and no-cache is not forced in admin panel, also leading to invalid results. + // This is currently not a *huge* issue, since timed records attached to pages are "relatively" seldom, and + // FE instances that use it most likely do something like "clearCacheAtMidnight" anyways. + // To ultimately solve the issue, we should either drop the persisted rootline_cache altogether (which should + // be do-able when the main rootline query switches to a CTE and does not need the cache anymore), OR we + // remove the starttime/endtime handling here again, and let consumers sort out timed records on their own, + // which would be a pity. + // @todo: We could potentially handle ['enablecolumns']['fe_group'] here as well. This however is more work + // since we then need two further fields in refindex to track it. Also fe_group is one of those CSV + // fields that has "virtual" db connections "-2" and "-1" that don't point to true records. refindex + // already behaves funny with those (and has no good test coverage). Also, having (negative) int uids + // is a violation for select, those should at least use non-int strings and set "allowNonIdValues". + // At best, we'd find some other way for -2 and -1 to get rid of those virtual values entirely. Everything + // in this area is probably breaking and needs upgrade wizards. + // @todo: The queries below may benefit from being prepared and then fired with values, since they are potentially + // executed often. This requires storing the prepared query in runtime cache, and requires switching from + // named parameters to positional parameters. + // @todo: Note the entire thing currently handles only non-CSV relations (there must be a TCA foreign_field or MM), + // CSV values are not "filtered" and processed regarding hidden, starttime and similar at all. This could be + // added later, but needs a careful implementation, for instance because of "allowNonIdValues", and combined + // "table_uid" in type=group, and fe_group "virtual" -2 fields. + // @todo: This operation always returns already workspace uids if they exist. It however does *not* return localization + // uids in most cases, this still needs to be done manually, when working with localized pages. Also, + // hidden, starttime and endtime of the default language record kicks in, not of the localization overlay + // row. We could potentially model this in refindex, by de-normalizing localization overlays in refindex + // as well, but this needs work and some decisions since language overlays may need to consider fallback chains. + $visibilityAspect = $this->context->getAspect('visibility'); + $includeHiddenContent = $visibilityAspect->includeHiddenContent(); + $includeScheduledRecords = $visibilityAspect->includeScheduledRecords(); + $dateTimestamp = (int)$this->context->getAspect('date')->get('timestamp'); + + if (!empty($localRelationColumns) && empty($foreignRelationColumns)) { + // We only have local side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has categories MM. + // @todo: Add at least one test that manipulates TCA to verify this code branch works. + $queryBuilder = $this->createQueryBuilder('sys_refindex'); + $result = $queryBuilder->select('tablename', 'field', 'ref_uid') + ->from('sys_refindex') + ->orderBy('sorting') + ->where( + $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter('pages')), + $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), + $queryBuilder->expr()->in('field', $queryBuilder->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)), + $queryBuilder->expr()->eq('workspace', $this->workspaceUid), + $queryBuilder->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value), + $includeHiddenContent + ? $queryBuilder->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index. + : $queryBuilder->expr()->eq('ref_hidden', 0), + $includeScheduledRecords + ? $queryBuilder->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index. + : $queryBuilder->expr()->lt('ref_starttime', $dateTimestamp), + $includeScheduledRecords + ? $queryBuilder->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index. + : $queryBuilder->expr()->gt('ref_endtime', $dateTimestamp) + ) + ->executeQuery(); + while ($row = $result->fetchAssociative()) { + $resultFieldUidArray[$row['field']][] = (int)$row['ref_uid']; + } + foreach ($resultFieldUidArray as $column => $connectedUids) { + if (empty($connectedUids)) { + $pageRecord[$column] = ''; + } else { + $pageRecord[$column] = implode(',', $connectedUids); + } + } + return $pageRecord; + } + + if (empty($localRelationColumns)) { + // We only have foreign side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has inline media. + // @todo: Add at least one test that manipulates TCA to verify this code branch works. + $queryBuilder = $this->createQueryBuilder('sys_refindex'); + $result = $queryBuilder->select('tablename', 'field', 'recuid', 'ref_field') + ->from('sys_refindex') + ->orderBy('ref_sorting') + ->where( + $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter('pages')), + // Use workspace-uid if the record is an overlay. + $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), + $queryBuilder->expr()->in('tablename', $queryBuilder->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)), + $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)), + $queryBuilder->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value), + $includeHiddenContent + ? $queryBuilder->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index. + : $queryBuilder->expr()->eq('hidden', 0), + $includeScheduledRecords + ? $queryBuilder->expr()->lte('starttime', 2147483647) + : $queryBuilder->expr()->lt('starttime', $dateTimestamp), + $includeScheduledRecords + ? $queryBuilder->expr()->gte('endtime', 0) + : $queryBuilder->expr()->gt('endtime', $dateTimestamp) + ) + ->executeQuery(); + while ($row = $result->fetchAssociative()) { + if (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) { + $resultFieldUidArray[$row['ref_field']][] = (int)$row['recuid']; + } + } + foreach ($resultFieldUidArray as $column => $connectedUids) { + if (empty($connectedUids)) { + $pageRecord[$column] = ''; + } else { + $pageRecord[$column] = implode(',', $connectedUids); + } + } + return $pageRecord; + } + + // We need rows from refindex by looking at both local side and foreign side. + // This is done using a UNION of two distinct queries. This is pretty useful + // since it saves a round trip and can use distinct indexes per "sub" query. + // Named arguments however are global for both queries, so we use a dummy query + // builder to gather them all. + // Also, postgres and sqlite don't support sorting on single queries with UNION, + // so we sort the final result set just before imploding to the final CSV per field. + $namedArgumentsQB = $this->createQueryBuilder('sys_refindex'); + $localQB = $this->createQueryBuilder('sys_refindex'); + $localQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting') + ->from('sys_refindex') + ->where( + $localQB->expr()->eq('tablename', $namedArgumentsQB->createNamedParameter('pages')), + $localQB->expr()->eq('recuid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), + $localQB->expr()->in('field', $namedArgumentsQB->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)), + $localQB->expr()->eq('workspace', $this->workspaceUid), + $localQB->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value), + $includeHiddenContent + ? $localQB->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index. + : $localQB->expr()->eq('ref_hidden', 0), + $includeScheduledRecords + ? $localQB->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index. + : $localQB->expr()->lt('ref_starttime', $dateTimestamp), + $includeScheduledRecords + ? $localQB->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index. + : $localQB->expr()->gt('ref_endtime', $dateTimestamp) + ); + $foreignQB = $this->createQueryBuilder('sys_refindex'); + $foreignQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting') + ->from('sys_refindex') + ->where( + $foreignQB->expr()->eq('ref_table', $namedArgumentsQB->createNamedParameter('pages')), + // Use workspace-uid if the record is an overlay. + $foreignQB->expr()->eq('ref_uid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), + $foreignQB->expr()->in('tablename', $namedArgumentsQB->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)), + $foreignQB->expr()->eq('workspace', $namedArgumentsQB->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)), + $foreignQB->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value), + $includeHiddenContent + ? $foreignQB->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index. + : $foreignQB->expr()->eq('hidden', 0), + $includeScheduledRecords + ? $foreignQB->expr()->lte('starttime', 2147483647) + : $foreignQB->expr()->lt('starttime', $dateTimestamp), + $includeScheduledRecords + ? $foreignQB->expr()->gte('endtime', 0) + : $foreignQB->expr()->gt('endtime', $dateTimestamp) + ); + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_refindex'); + $result = $connection->executeQuery( + $localQB->getSQL() . ' UNION ALL ' . $foreignQB->getSQL(), + $namedArgumentsQB->getParameters(), + $namedArgumentsQB->getParameterTypes() + ); + while ($row = $result->fetchAssociative()) { + if ($row['tablename'] === 'pages') { + $resultFieldUidArray[$row['field']][(int)$row['sorting']] = (int)$row['ref_uid']; + } elseif (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) { + $resultFieldUidArray[$row['ref_field']][(int)$row['ref_sorting']] = (int)$row['recuid']; + } + } + foreach ($resultFieldUidArray as $column => $connectedUids) { + if (empty($connectedUids)) { + $pageRecord[$column] = ''; + } else { + ksort($connectedUids, SORT_NUMERIC); + $pageRecord[$column] = implode(',', $connectedUids); + } + } + return $pageRecord; + } + + /** + * Checks whether the TCA Configuration array of a column + * describes a relation which is not stored as CSV in the record + * + * @param array $configuration TCA configuration to check + * @return bool TRUE, if it describes a non-CSV relation + */ + protected function columnHasRelationToResolve(array $configuration): bool + { + if (!empty($configuration['MM']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline', 'group'])) { + return true; + } + if (!empty($configuration['foreign_field']) && !empty($configuration['type']) && in_array($configuration['type'], ['inline', 'file'])) { + return true; + } + if (($configuration['type'] ?? '') === 'category' && ($configuration['relationship'] ?? '') === 'manyToMany') { + return true; + } + return false; + } + + /** + * Actual function to generate the rootline and cache it + * + * @throws BrokenRootLineException + * @throws CircularRootLineException + * @throws PageNotFoundException + * @throws DoctrineException + */ + protected function generateRootlineCache(): void + { + $pageId = $this->pageUid; + $page = $this->getRecordArray($pageId); + $parentPageId = $page['pid']; + $workspaceId = $this->workspaceUid; + if ($this->isMountedPage($pageId)) { + // If the current page is a mounted (according to the MP parameter) handle the mount-point + $page = $this->getRecordArray($pageId); + $mountPoint = $this->getRecordArray($this->parsedMountPointParameters[$pageId]); + $page = $this->processMountedPage($page, $mountPoint); + $parentPageId = $mountPoint['pid']; + // Anyhow after reaching the mount-point, we have to go up that rootline + unset($this->parsedMountPointParameters[$this->pageUid]); + } + $rootline = $this->getRootlineFromRuntimeCache($parentPageId); + if (!is_array($rootline)) { + $rootline = $this->getRootlineRecords($parentPageId, $workspaceId); + } + $rootline[] = $page; + $firstEntry = reset($rootline); + // ensure valid rootline down to virtual tree root + if (is_array($firstEntry) && $firstEntry['pid'] !== 0) { + throw new PageNotFoundException('Broken rootline. Could not resolve full rootline for uid ' . $pageId . '.', 1721913589); + } + $cacheTags = []; + foreach ($rootline as $entry) { + $cacheTags[] = 'pageId_' . $entry['uid']; + } + krsort($rootline); + $this->cache->set($this->cacheIdentifier, $rootline, $cacheTags); + $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]); + + // Reduce rootline page by page and set to runtime cache to eliminate the need to fetch rootline for a parent + // page as a performance optimization when a children already generated the rootline. + while ($rootline !== []) { + // Behaves similar to array_shift(), but preserves the array keys. + $rootline = array_slice($rootline, 1, null, true); + if ($rootline !== []) { + $cacheIdentifier = $this->getCacheIdentifier(array_first($rootline)['uid']); + $this->runtimeCache->set('rootline-localcache-' . $cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]); + } + } + } + + /** + * Checks whether the current Page is a Mounted Page + * (according to the MP-URL-Parameter) + */ + protected function isMountedPage(int $pageId): bool + { + return array_key_exists($pageId, $this->parsedMountPointParameters); + } + + /** + * Enhances with mount point information or replaces the node if needed + * + * @param array<string, string|int|float|null> $mountedPageData page record array of mounted page + * @param array<string, string|int|float|null> $mountPointPageData page record array of mount point page + * @throws BrokenRootLineException + * @return array<string, string|int|float|null> + */ + protected function processMountedPage(array $mountedPageData, array $mountPointPageData): array + { + $mountPid = $mountPointPageData['mount_pid'] ?? null; + $uid = $mountedPageData['uid'] ?? null; + if ((int)$mountPid !== (int)$uid) { + throw new BrokenRootLineException('Broken rootline. Mountpoint parameter does not match the actual rootline. mount_pid (' . $mountPid . ') does not match page uid (' . $uid . ').', 1343464100); + } + // Current page replaces the original mount-page + $mountUid = $mountPointPageData['uid'] ?? null; + if (!empty($mountPointPageData['mount_pid_ol'])) { + $mountedPageData['_MOUNT_OL'] = true; + $mountedPageData['_MOUNT_PAGE'] = [ + 'uid' => $mountUid, + 'pid' => $mountPointPageData['pid'] ?? null, + 'title' => $mountPointPageData['title'] ?? null, + ]; + } else { + // The mount-page is not replaced, the mount-page itself has to be used + $mountedPageData = $mountPointPageData; + } + $mountedPageData['_MOUNTED_FROM'] = $this->pageUid; + $mountedPageData['_MP_PARAM'] = $this->pageUid . '-' . $mountUid; + return $mountedPageData; + } + + /** + * Sanitize the MountPoint Parameter + * Splits the MP-Param via "," and removes mountpoints + * that don't have the format \d+-\d+ + */ + protected function sanitizeMountPointParameter(string $mountPointParameter): string + { + $mountPointParameter = trim($mountPointParameter); + if ($mountPointParameter === '') { + return ''; + } + $mountPoints = GeneralUtility::trimExplode(',', $mountPointParameter); + foreach ($mountPoints as $key => $mP) { + // If MP has incorrect format, discard it + if (!preg_match('/^\d+-\d+$/', $mP)) { + unset($mountPoints[$key]); + } + } + return implode(',', $mountPoints); + } + + /** + * Parse the MountPoint Parameters + * Splits the MP-Param via "," for several nested mountpoints + * and afterwords registers the mountpoint configurations + */ + protected function parseMountPointParameter(): void + { + $mountPoints = GeneralUtility::trimExplode(',', $this->mountPointParameter); + foreach ($mountPoints as $mP) { + [$mountedPageUid, $mountPageUid] = GeneralUtility::intExplode('-', $mP); + $this->parsedMountPointParameters[$mountedPageUid] = $mountPageUid; + } + } + + /** + * Fetches the UID of the page. + * + * If the page was moved in a workspace, actually returns the UID + * of the moved version in the workspace. + */ + protected function resolvePageId(int $pageId): int + { + if ($pageId === 0 || $this->workspaceUid === 0) { + return $pageId; + } + + $page = $this->resolvePageRecord($pageId); + if (!isset($page['t3ver_state']) || VersionState::tryFrom($page['t3ver_state']) !== VersionState::MOVE_POINTER) { + return $pageId; + } + + $movePointerId = $this->resolveMovePointerId((int)$page['t3ver_oid']); + return $movePointerId ?: $pageId; + } + + protected function resolvePageRecord(int $pageId): ?array + { + $queryBuilder = $this->createQueryBuilder('pages'); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $statement = $queryBuilder + ->from('pages') + ->select('uid', 't3ver_oid', 't3ver_state') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + + $record = $statement->fetchAssociative(); + return $record ?: null; + } + + /** + * Fetched the UID of the versioned record if the live record has been moved in a workspace. + */ + protected function resolveMovePointerId(int $liveId): ?int + { + $queryBuilder = $this->createQueryBuilder('pages'); + $queryBuilder->getRestrictions()->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + + $statement = $queryBuilder + ->from('pages') + ->select('uid') + ->setMaxResults(1) + ->where( + $queryBuilder->expr()->eq( + 't3ver_wsid', + $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_state', + $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + 't3ver_oid', + $queryBuilder->createNamedParameter($liveId, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + + $movePointerId = $statement->fetchOne(); + return $movePointerId ? (int)$movePointerId : null; + } + + /** + * Get enriched RootLine page records. + * + * This method uses a recursive common-table-expression (CTE) doing the first workspace overlay handling withing the + * RootLine traversal. Language overlay and record enrichment (references data) are applied on retrieved records. + * + * In case a received record is a mounted page, a new instance of `RootlineUtility` is created to retrieve the + * mounted page rootline and result spliced together. + * + * In case only live-workspace rootline is required, for example in normal frontend visitors without backend login + * and selected workspaces, the created CTE is simplified avoiding irrelevant joins and further improve performance + * in that case. + * + * Note that the created recursive CTE contains two guards against cycling rootline issues: + * + * Guard 1 - ancestor path guard: + * ------------------------------ + * + * During the recursing a uid path is created (`__CTE_PATH__`) and in case that a page is already contained in the + * parent record `__CTE_PATH__` the flag field `__CTE_IS_CYCLE__` is set to 1, otherwise it is 0. + * + * If the parent record `__CTE_IS_CYLCE__` is `1` no records are retrieved which retrieves a duplicate page record + * except of this flag. During the record retrieval {@see CircularRootLineException} is thrown to abort and state + * this crucial data corruption. + * + * This guard is designed to abort cycling recursion on the database side as early as possible while still transport + * the cycling issue information to this method. + * + * Guard 2 - max recursion level guard: + * ------------------------------------ + * + * During the recursive CTE handling recursion level info is created (`__CTE_LEVEL__`) and used as a hard recursion + * limit fence. That means, if the level reaches {@see self::MAX_CTE_TRAVERSAL_LEVELS} no more page records are + * received. In the case that neither GUARD 1, nor PID=0 abort criteria is reached and max level hit a meaningful + * {@see BrokenRootLineException} exception is thrown. + * + * Note that further improvements are possible, for example adding language overlay handling directly to the CTE + * when strategy how to deal with the three dispatched PSR-14 events in PageRepository has been made up. + * + * @todo As already mentioned above, mountpoint pages are resolved by calling a new `RootlineUtility` instance + * which prevents the detection of circular mountpoint configuration like in the prior implementation. A + * way to add mountpoint page replacements within the CTE needs to be evaluated and implemented to make a + * circular mountpoint configuration finally detectable. At least first cycling rootline revealing database + * data corruption are now included. + * + * @throws BrokenRootLineException + * @throws CircularRootLineException + * @throws DoctrineException + */ + protected function getRootlineRecords(int $pageId, int $workspaceId): array + { + if ($pageId === 0) { + return []; + } + $cte = $this->createQueryBuilder('pages'); + $cte->getRestrictions()->removeAll(); + $expr = $cte->expr(); + $pagesFields = $this->getPagesFields(); + $resolvedPagesFields = array_filter($pagesFields, static fn($value) => $value !== 'uid'); + array_walk( + $resolvedPagesFields, + static function (string &$value, int|string $key, string $prefixAlias) { + $value = sprintf('%s.%s', $prefixAlias, $value); + }, + 'finalpages', + ); + $cte + ->typo3_withRecursive( + 'cte', + // Unique rows is omitted to avoid superfluous distinct row determination by the database query executor + // due to having level based data in the traversal part they would be distinct anyway. Duplicates are + // sorted out by the implemented ancestor guard - see self::createTraversalQueryBuilder(). + false, + $this->createInitialQueryBuilder($cte, $pageId, $workspaceId), + $this->createTraversalQueryBuilder($cte, $workspaceId), + [ + // data fields + $cte->quoteIdentifier('uid'), + $cte->quoteIdentifier('pid'), + // workspace handling values + $cte->quoteIdentifier('_ORIG_pid'), + $cte->quoteIdentifier('_ORIG_uid'), + // recursive handling fields + $cte->quoteIdentifier('__CTE_JOIN_UID__'), + $cte->quoteIdentifier('__CTE_LEVEL__'), + $cte->quoteIdentifier('__CTE_PATH__'), + $cte->quoteIdentifier('__CTE_IS_CYCLE__'), + ], + ) + ->select(...[ + 'cte.uid', + ...$resolvedPagesFields, + 'cte._ORIG_uid', + 'cte._ORIG_pid', + // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by + // all supported Database vendors at all or not following the SQL standard. The guard stops cycling + // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. + // Note that these values are removed from result rows by `self::cleanWorkspaceResolvedPageRecord()` + 'cte.__CTE_PATH__', + 'cte.__CTE_IS_CYCLE__', + 'cte.__CTE_LEVEL__', + ]) + ->from('cte') + // It's important to traverse determined rootline records in the correct order, which means from the page + // record down to the rootpage. The recursive CTE builds up the CTE level starting from current record as + // level 1 and incrementing the level for each parent record, which means that we need to order by the + // level in ascending order (1, 2, 3, 4). + ->orderBy('cte.__CTE_LEVEL__', 'ASC') + ->addOrderBy('cte.uid', 'ASC') + ->innerJoin( + 'cte', + 'pages', + 'finalpages', + $expr->eq('finalpages.uid', $cte->quoteIdentifier('cte.__CTE_JOIN_UID__')) + ); + $records = []; + $result = $cte->executeQuery(); + while ($record = $result->fetchAssociative()) { + $cyclingDetected = (bool)($record['__CTE_IS_CYCLE__'] ?? false); + $recordLevel = (int)($record['__CTE_LEVEL__'] ?? 0); + $recordId = (int)$record['uid']; + $recordPid = (int)$record['pid']; + $recordCacheIdentifier = $this->getCacheIdentifier($recordId); + $record = $this->cleanWorkspaceResolvedPageRecord($record); + if ($cyclingDetected) { + // Cycling page records found by CTE path guard. + // @todo CTE does not handle mountpoint page resolving yet and calling RootlineUtility in recursive + // manner below not detecting cycling mountpoint configurations yet. CTE **must* be improved to + // handle mountpoint replacements directly and ensure cycling detection finally works - which has + // never been the case. + throw new CircularRootLineException( + 'Circular connection in rootline for page with uid ' . $this->pageUid . ' found. ' + . 'Check your mountpoint configuration and page data with pid value pointing to a sub page.', + 1343464103 + ); + } + // Throw a concrete BrokenRootLineException with explaining message in case maximal CTE traversal limit has + // been reached without ending on PID 0 - the virtual tree root node. + // @todo Find a way to test this case which is not that easy because of the MAX_CTE_TRAVERSAL_LEVEL. + if ($recordLevel >= self::MAX_CTE_TRAVERSAL_LEVELS && $recordPid !== 0) { + throw new BrokenRootLineException( + sprintf( + 'Broken rootline. Could not resolve full rootline for uid %s. ' + . 'Reached max traversal level %s without ending on pid 0.', + $pageId, + self::MAX_CTE_TRAVERSAL_LEVELS, + ), + 1722118090, + ); + } + if ($this->isMountedPage($recordId)) { + // free result, because we will not iterator further through it and instead invoke RootlineUtility. + $result->free(); + // @todo Find a way to implement mountpoint resolving directly in the CTE to remove calling and splicing + // recursive RootlineUtility call results and handle everything in one database query. + // Get rootline of (and including) parent page + $mountPointParameter = !empty($this->parsedMountPointParameters) ? $this->mountPointParameter : ''; + $rootlineUtility = GeneralUtility::makeInstance(self::class, $recordId, $mountPointParameter, $this->context); + $rootline = $rootlineUtility->get(); + foreach ($rootline as $rootlineRecord) { + $records[] = $rootlineRecord; + } + break; + } + if (!$this->runtimeCache->has('rootline-recordcache-' . $recordCacheIdentifier)) { + $record = $this->enrichPageRecordArray($record, $recordId); + $this->runtimeCache->set('rootline-recordcache-' . $recordCacheIdentifier, $record, [self::RUNTIME_CACHE_TAG]); + } + $record = $this->runtimeCache->get('rootline-recordcache-' . $recordCacheIdentifier); + if (!is_array($record)) { + throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $recordId . '.', 1721982337); + } + $records[] = $record; + + } + // `$records` are build having the current record as first item. We need to revers it here to ensure correct + // rootline completion and indexing within `RootlineUtility::generateRootlineCache()`, which expects to have + // a record with `pid=0` as first item in the returned records. Note, that keys are not preserved on purpose. + return array_reverse($records); + } + + /** + * Creates the QueryBuilder for the recursive CTE initial part within {@see self::getRootlineRecords()}. + * + * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information. + */ + protected function createInitialQueryBuilder(QueryBuilder $cte, int $pageId, int $workspaceId): QueryBuilder + { + $expr = $cte->expr(); + $initial = $this->createQueryBuilder('pages'); + $initial->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + if ($workspaceId === 0) { + // Return simplified initial expression for live workspace resolving only. + return $initial + ->selectLiteral(...[ + // data fields + $cte->quoteIdentifier('uid'), + $cte->quoteIdentifier('pid'), + // adding fake columns to be compatible with workspace aware columns + $expr->as('null', '_ORIG_pid'), + $expr->as('null', '_ORIG_uid'), + // recursive handling fields + $expr->as($cte->quoteIdentifier('uid'), '__CTE_JOIN_UID__'), + $expr->castInt('1', '__CTE_LEVEL__'), + // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by + // all supported Database vendors at all or not following the SQL standard. The guard stops cycling + // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. + $expr->castText($cte->quoteIdentifier('uid'), '__CTE_PATH__'), + // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here + $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'), + ]) + ->from('pages') + ->where(...[ + $expr->eq('uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)), + // only select live workspace + $expr->eq('t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + ]); + } + + $initial + ->selectLiteral(...[ + // data fields + $cte->quoteIdentifier('live.uid'), + $cte->quoteIdentifier('workspace_resolved.pid'), + // workspace handling + // For move pointers, store the actual live PID in the _ORIG_pid + // The only place where PID is actually different in a workspace + $expr->if( + $expr->and( + $expr->isNotNull('workspace.t3ver_state'), + $expr->eq( + 'workspace.t3ver_state', + $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('live.pid'), + 'null', + '_ORIG_pid' + ), + // For versions of single elements or page+content, preserve online UID + // (this will produce true "overlay" of element _content_, not any references) + // For new versions there is no online counterpart + $expr->if( + $expr->and( + $expr->isNotNull('workspace.t3ver_state'), + $expr->neq( + 'workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('workspace.uid'), + 'null', + '_ORIG_uid', + ), + // recursive handling fields + $expr->if( + $expr->and( + $expr->isNotNull('workspace.t3ver_state'), + $expr->neq( + 'workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('workspace_resolved.uid'), + $cte->quoteIdentifier('live.uid'), + '__CTE_JOIN_UID__', + ), + $expr->castInt('1', '__CTE_LEVEL__'), + // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by + // all supported Database vendors at all or not following the SQL standard. The guard stops cycling + // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. + $expr->castText($cte->quoteIdentifier('live.uid'), '__CTE_PATH__'), + // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here + $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'), + ]) + ->from('pages', 'source') + ->innerJoin( + 'source', + 'pages', + 'live', + $expr->eq( + 'live.uid', + $expr->if( + $expr->and( + $expr->gt('source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + $expr->eq('source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), + ), + $cte->quoteIdentifier('source.t3ver_oid'), + $cte->quoteIdentifier('source.uid'), + ) + ), + ) + ->leftJoin( + 'live', + 'pages', + 'workspace', + $expr->and( + $expr->eq( + 'workspace.t3ver_wsid', + $cte->createNamedParameter($workspaceId, Connection::PARAM_INT) + ), + $expr->or( + // t3ver_state=1 does not contain a t3ver_oid, and returns itself + $expr->and( + $expr->eq( + 'workspace.uid', + $cte->quoteIdentifier('live.uid'), + ), + $expr->eq( + 'workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $expr->eq( + 'workspace.t3ver_oid', + $cte->quoteIdentifier('live.uid'), + ) + ) + ), + ) + ->innerJoin( + 'workspace', + 'pages', + 'workspace_resolved', + (string)$expr->and( + $expr->eq( + 'workspace_resolved.uid', + $expr->if( + $expr->isNotNull('workspace.uid'), + $cte->quoteIdentifier('workspace.uid'), + $cte->quoteIdentifier('live.uid'), + ), + ), + ), + ) + ->where(...[ + $expr->eq('source.uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)), + $expr->in('source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), + $expr->or( + // retrieve live workspace if no overlays exists + $expr->isNull('workspace.uid'), + // discard(omit) record if it turned out to be deleted in workspace + $expr->and( + $expr->isNotNull('workspace.uid'), + $expr->neq( + 'workspace.t3ver_state', + $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + ), + ]); + + return $initial; + } + + /** + * Creates the QueryBuilder for the recursive CTE traversal part within {@see self::getRootlineRecords()}. + * + * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information. + */ + protected function createTraversalQueryBuilder(QueryBuilder $cte, int $workspaceId): QueryBuilder + { + $expr = $cte->expr(); + $traversal = $this->createQueryBuilder('pages'); + $traversal->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + if ($workspaceId === 0) { + $traversal + ->selectLiteral(...[ + // data fields + $cte->quoteIdentifier('p.uid'), + $cte->quoteIdentifier('p.pid'), + // adding fake columns to be compatible with workspace aware columns + $expr->as('null', '_ORIG_pid'), + $expr->as('null', '_ORIG_uid'), + // recursive handling fields + $expr->as($cte->quoteIdentifier('p.uid'), '__CTE_JOIN_UID__'), + $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('c.__CTE_LEVEL__'))), '__CTE_LEVEL__'), + // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by + // all supported Database vendors at all or not following the SQL standard. The guard stops cycling + // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. + $expr->castText( + $expr->concat( + $expr->trim('c.__CTE_PATH__', TrimMode::TRAILING, ' '), + $cte->quote(','), + $cte->quoteIdentifier('p.uid') + ), + '__CTE_PATH__' + ), + $expr->as( + sprintf( + '%s <> 0', + $expr->castInt(sprintf( + // Nesting is needed because inSet() creates a comparision expression return a boolean value + '(%s)', + $expr->inSet( + 'c.__CTE_PATH__', + $cte->quoteIdentifier('p.uid'), + true, + ), + )), + ), + '__CTE_IS_CYCLE__' + ), + ]) + ->from('cte', 'c') + ->innerJoin( + 'c', + 'pages', + 'p', + (string)$expr->and( + $expr->eq('p.uid', $cte->quoteIdentifier('c.pid')), + // only select live workspace + $expr->eq('p.t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + // ensure that resolve page is not the child page + $expr->neq('c.uid', $cte->quoteIdentifier('p.uid')), + ) + ) + ->where(...[ + // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to + // keep the cycle detection value in the result set to allow proper handling later on retrieved rows. + $expr->eq('c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)), + // do not try to fetch page with uid 0, which is the virtual tree root point + $expr->neq('c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion + $expr->lt('c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)), + ]); + + return $traversal; + } + + $traversal + ->selectLiteral(...[ + // data fields + $cte->quoteIdentifier('traversal_live.uid'), + $cte->quoteIdentifier('traversal_workspace_resolved.pid'), + // workspace handling + // For move pointers, store the actual live PID in the _ORIG_pid + // The only place where PID is actually different in a workspace + $expr->if( + $expr->and( + $expr->isNotNull('traversal_workspace.t3ver_state'), + $expr->eq( + 'traversal_workspace.t3ver_state', + $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('traversal_live.pid'), + 'null', + '_ORIG_pid' + ), + // For versions of single elements or page+content, preserve online UID + // (this will produce true "overlay" of element _content_, not any references) + // For new versions there is no online counterpart + $expr->if( + $expr->and( + $expr->isNotNull('traversal_workspace.t3ver_state'), + $expr->neq( + 'traversal_workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('traversal_workspace.uid'), + 'null', + '_ORIG_uid', + ), + // recursive handling fields + $expr->if( + $expr->and( + $expr->isNotNull('traversal_workspace.t3ver_state'), + $expr->neq( + 'traversal_workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $cte->quoteIdentifier('traversal_workspace_resolved.uid'), + $cte->quoteIdentifier('traversal_live.uid'), + '__CTE_JOIN_UID__', + ), + $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('traversal_c.__CTE_LEVEL__'))), '__CTE_LEVEL__'), + // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by + // all supported Database vendors at all or not following the SQL standard. The guard stops cycling + // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. + $expr->castText( + $expr->concat( + $expr->trim('traversal_c.__CTE_PATH__', TrimMode::TRAILING, ' '), + $cte->quote(','), + $cte->quoteIdentifier('traversal_live.uid') + ), + '__CTE_PATH__' + ), + $expr->as( + sprintf( + '%s <> 0', + $expr->castInt(sprintf( + // Nesting is needed because inSet() creates a comparision expression return a boolean value + '(%s)', + $expr->inSet( + 'traversal_c.__CTE_PATH__', + $cte->quoteIdentifier('traversal_live.uid'), + true, + ) + )), + ), + '__CTE_IS_CYCLE__' + ), + ]) + ->from('cte', 'traversal_c') + ->innerJoin( + 'traversal_c', + 'pages', + 'traversal_source', + (string)$expr->and( + $expr->eq( + 'traversal_source.uid', + $cte->quoteIdentifier('traversal_c.pid') + ), + $expr->in('traversal_source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), + ), + ) + ->innerJoin( + 'traversal_source', + 'pages', + 'traversal_live', + $expr->eq( + 'traversal_live.uid', + $expr->if( + $expr->and( + $expr->gt('traversal_source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + $expr->eq('traversal_source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), + ), + $cte->quoteIdentifier('traversal_source.t3ver_oid'), + $cte->quoteIdentifier('traversal_source.uid'), + ) + ), + ) + ->leftJoin( + 'traversal_live', + 'pages', + 'traversal_workspace', + (string)$expr->and( + $expr->eq( + 'traversal_workspace.t3ver_wsid', + $cte->createNamedParameter($workspaceId, Connection::PARAM_INT) + ), + $expr->or( + // t3ver_state=1 does not contain a t3ver_oid, and returns itself + $expr->and( + $expr->eq( + 'traversal_workspace.uid', + $cte->quoteIdentifier('traversal_live.uid'), + ), + $expr->eq( + 'traversal_workspace.t3ver_state', + $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $expr->eq( + 'traversal_workspace.t3ver_oid', + $cte->quoteIdentifier('traversal_live.uid'), + ) + ) + ), + ) + ->innerJoin( + 'traversal_workspace', + 'pages', + 'traversal_workspace_resolved', + (string)$expr->and( + $expr->eq( + 'traversal_workspace_resolved.uid', + $expr->if( + $expr->isNotNull('traversal_workspace.uid'), + $cte->quoteIdentifier('traversal_workspace.uid'), + $cte->quoteIdentifier('traversal_live.uid'), + ), + ), + ), + ) + ->where(...[ + // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to + // keep the cycle detection value in the result set to allow proper handling later on retrieved rows. + $expr->eq('traversal_c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)), + // do not try to fetch page with uid 0, which is the virtual tree root point + $expr->neq('traversal_c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)), + // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion + $expr->lt('traversal_c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)), + // workspace handling + $expr->or( + // retrieve live workspace if no overlays exists + $expr->isNull('traversal_workspace.uid'), + // discard(omit) record if it turned out to be deleted in workspace + $expr->and( + $expr->isNotNull('traversal_workspace.uid'), + $expr->neq( + 'traversal_workspace.t3ver_state', + $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + ), + ]); + + return $traversal; + } + + /** + * {@see self::getRecordArray()} used {@see PageRepository::versionOL()} to compose workspace overlayed records in + * the based, bypassing access checks (aka enableFields) resulting in multiple queries. This constellation of method + * and database query chains can be condensed down to a single database query, which this method uses to retrieve + * the equal workspace overlay database page record in one and thus reducing overall database query count. + */ + protected function getWorkspaceResolvedPageRecord(int $pageId, int $workspaceId): ?array + { + $queryBuilder = $this->createQueryBuilder('pages'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + if ($workspaceId === 0) { + // For live workspace only we can simplify this even more + $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + ); + return $queryBuilder->executeQuery()->fetchAssociative() ?: null; + } + $fields = $this->getPagesFields(); + $prefixedFields = array_filter($fields, static fn($value) => $value !== 'uid'); + array_walk( + $prefixedFields, + static function (string &$value, int|string $key, QueryBuilder $queryBuilder) { + $value = $queryBuilder->quoteIdentifier(sprintf('%s.%s', 'workspace_resolved', $value)); + }, + $queryBuilder, + ); + $queryBuilder + ->selectLiteral( + $queryBuilder->quoteIdentifier('live.uid'), + ...$prefixedFields, + ...[ + // For move pointers, store the actual live PID in the _ORIG_pid + // The only place where PID is actually different in a workspace + $queryBuilder->expr()->if( + $queryBuilder->expr()->and( + $queryBuilder->expr()->isNotNull('workspace.t3ver_state'), + $queryBuilder->expr()->eq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), + ), + $queryBuilder->quoteIdentifier('live.pid'), + 'null', + '_ORIG_pid' + ), + // For versions of single elements or page+content, preserve online UID + // (this will produce true "overlay" of element _content_, not any references) + // For new versions there is no online counterpart + $queryBuilder->expr()->if( + $queryBuilder->expr()->and( + $queryBuilder->expr()->isNotNull('workspace.t3ver_state'), + $queryBuilder->expr()->neq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT)), + ), + $queryBuilder->quoteIdentifier('workspace.uid'), + 'null', + '_ORIG_uid', + ), + ] + ) + ->from('pages', 'source') + ->innerJoin( + 'source', + 'pages', + 'live', + $queryBuilder->expr()->eq( + 'live.uid', + $queryBuilder->expr()->if( + (string)$queryBuilder->expr()->and( + $queryBuilder->expr()->gt('source.t3ver_oid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('source.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), + ), + $queryBuilder->quoteIdentifier('source.t3ver_oid'), + $queryBuilder->quoteIdentifier('source.uid'), + ) + ), + ) + ->leftJoin( + 'live', + 'pages', + 'workspace', + (string)$queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + 'workspace.t3ver_wsid', + $queryBuilder->createNamedParameter($workspaceId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->or( + // t3ver_state=1 does not contain a t3ver_oid, and returns itself + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + 'workspace.uid', + $queryBuilder->quoteIdentifier('live.uid'), + ), + $queryBuilder->expr()->eq( + 'workspace.t3ver_state', + $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + $queryBuilder->expr()->eq( + 'workspace.t3ver_oid', + $queryBuilder->quoteIdentifier('live.uid'), + ) + ) + ), + ) + ->innerJoin( + 'workspace', + 'pages', + 'workspace_resolved', + (string)$queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + 'workspace_resolved.uid', + $queryBuilder->expr()->if( + $queryBuilder->expr()->isNotNull('workspace.uid'), + $queryBuilder->quoteIdentifier('workspace.uid'), + $queryBuilder->quoteIdentifier('live.uid'), + ), + ), + ), + ) + ->where( + $queryBuilder->expr()->eq('source.uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), + $queryBuilder->expr()->in('source.t3ver_wsid', $queryBuilder->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), + $queryBuilder->expr()->or( + // retrieve live workspace if no overlays exists + $queryBuilder->expr()->isNull('workspace.uid'), + // discard(omit) record if it turned out to be deleted in workspace + $queryBuilder->expr()->and( + $queryBuilder->expr()->isNotNull('workspace.uid'), + $queryBuilder->expr()->neq( + 'workspace.t3ver_state', + $queryBuilder->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), + ), + ), + ), + ) + ->setMaxResults(1); + $row = $queryBuilder->executeQuery()->fetchAssociative() ?: null; + return $this->cleanWorkspaceResolvedPageRecord($row); + } + + protected function cleanWorkspaceResolvedPageRecord(?array $row = null): ?array + { + if ($row === null) { + return $row; + } + // Remove cycle detection fields from result row + unset( + $row['__CTE_PATH__'], + $row['__CTE_IS_CYCLE__'], + $row['__CTE_LEVEL__'], + ); + // Remove helper fields if null, keeping them only if they contain valid data to mimic the way PHP methods + // throughout the TYPO3 core added these fields. + $removeNullableFields = [ + '_ORIG_uid', + '_ORIG_pid', + ]; + foreach ($removeNullableFields as $removeNullableField) { + if (array_key_exists($removeNullableField, $row) && $row[$removeNullableField] === null) { + unset($row[$removeNullableField]); + } + } + return $row; + } + + protected function enrichPageRecordArray(array $row, int $pageId): array + { + $row = $this->pageRepository->getLanguageOverlay('pages', $row, $this->context->getAspect('language')); + $row = $this->enrichWithRelationFields($row['_LOCALIZED_UID'] ?? $pageId, $row); + return $row; + } + + /** + * Uses a two-layer cache to ensure that this check is really called VERY VERY SELDOM. + */ + protected function getPagesFields(): array + { + // SchemaInformation provides a 2-level cache (runtime and persisted), no need to cache this here in the class. + return GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('pages') + ->getSchemaInformation()->listTableColumnNames('pages'); + } + + protected function createQueryBuilder(string $tableName): QueryBuilder + { + return GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable($tableName); + } + + private function getRootlineFromRuntimeCache(int $pageId): ?array + { + $cacheIdentifier = $this->getCacheIdentifier($pageId); + if ($this->runtimeCache->has('rootline-localcache-' . $cacheIdentifier)) { + $rootline = $this->runtimeCache->get('rootline-localcache-' . $cacheIdentifier); + if (is_array($rootline)) { + return array_reverse($rootline); + } + } + return null; + } +} diff --git a/Classes/Utility/String/StringFragment.php b/Classes/Utility/String/StringFragment.php new file mode 100644 index 0000000..c1c74be --- /dev/null +++ b/Classes/Utility/String/StringFragment.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\String; + +/** + * @internal + */ +class StringFragment implements \Stringable +{ + public readonly int $length; + public readonly string $ident; + + public static function raw(string $value): self + { + return new self($value, StringFragmentSplitter::TYPE_RAW); + } + + public static function expression(string $value): self + { + return new self($value, StringFragmentSplitter::TYPE_EXPRESSION); + } + + public function __construct( + public readonly string $value, + public readonly string $type + ) { + if ($this->value === '') { + throw new \LogicException('Value must not be empty', 1651671582); + } + $this->length = strlen($this->value); + $this->ident = md5($this->type . '::' . ($this->value)); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Classes/Utility/String/StringFragmentCollection.php b/Classes/Utility/String/StringFragmentCollection.php new file mode 100644 index 0000000..969d198 --- /dev/null +++ b/Classes/Utility/String/StringFragmentCollection.php @@ -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\Core\Utility\String; + +/** + * @internal + */ +class StringFragmentCollection implements \Stringable, \Countable +{ + /** + * @var list<StringFragment> + */ + protected array $fragments; + + /** + * Length of all fragment strings + */ + protected int $length = 0; + + public function __construct(StringFragment ...$fragments) + { + $lengths = array_map(static fn(StringFragment $fragment) => $fragment->length, $fragments); + $this->length = array_sum($lengths); + $this->fragments = $fragments; + } + + public function __toString(): string + { + return implode('', array_map('strval', $this->fragments)); + } + + public function count(): int + { + return count($this->fragments); + } + + public function with(StringFragment ...$fragments): self + { + $target = clone $this; + foreach ($fragments as $fragment) { + $target->length += $fragment->length; + $target->fragments[] = $fragment; + } + return $target; + } + + public function withOnlyType(string $type): self + { + $fragments = array_filter( + $this->fragments, + static fn(StringFragment $item) => $item->type === $type + ); + return new self(...$fragments); + } + + public function withoutType(string $type): self + { + $fragments = array_filter( + $this->fragments, + static fn(StringFragment $item) => $item->type !== $type + ); + return new self(...$fragments); + } + + /** + * @return list<StringFragment> + */ + public function getFragments(): array + { + return $this->fragments; + } + + public function getLength(): int + { + return $this->length; + } + + public function diff(self $other): self + { + $otherFragmentIdents = $other->getFragmentIdents(); + $differentFragments = array_filter( + $this->fragments, + static fn(StringFragment $item) => !in_array($item->ident, $otherFragmentIdents, true) + ); + return new self(...$differentFragments); + } + + public function intersect(self $other): self + { + $otherFragmentIdents = $other->getFragmentIdents(); + $sameFragments = array_filter( + $this->fragments, + static fn(StringFragment $item) => in_array($item->ident, $otherFragmentIdents, true) + ); + return new self(...$sameFragments); + } + + /** + * @return list<string> + */ + protected function getFragmentIdents(): array + { + return array_map(static fn(StringFragment $item) => $item->ident, $this->fragments); + } +} diff --git a/Classes/Utility/String/StringFragmentPattern.php b/Classes/Utility/String/StringFragmentPattern.php new file mode 100644 index 0000000..6ea857f --- /dev/null +++ b/Classes/Utility/String/StringFragmentPattern.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\String; + +/** + * @internal + */ +readonly class StringFragmentPattern +{ + public function __construct( + public string $type, + public string $pattern + ) {} + + /** + * Compiles this string fragment pattern to a scoped PCRE pattern string. + */ + public function compilePattern(): string + { + return sprintf( + '(?P<%s>%s)', + $this->type . '_' . bin2hex(random_bytes(5)), + $this->pattern + ); + } +} diff --git a/Classes/Utility/String/StringFragmentSplitter.php b/Classes/Utility/String/StringFragmentSplitter.php new file mode 100644 index 0000000..5926fdf --- /dev/null +++ b/Classes/Utility/String/StringFragmentSplitter.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility\String; + +/** + * Splits a string into RAW and EXPRESSION fragments. + * EXPRESSION fragments are resolved by provided arbitrary regex pattern. + * + * @internal + */ +class StringFragmentSplitter +{ + /** + * Raw string literals + */ + public const TYPE_RAW = 'raw'; + + /** + * Literals used as expression + */ + public const TYPE_EXPRESSION = 'expression'; + + /** + * Returns `null` in case there have not been any pattern matches, + * if omitted an array containing only `raw` fragments is returned + */ + public const FLAG_UNMATCHED_AS_NULL = 1; + + /** + * @var list<StringFragmentPattern> + */ + protected readonly array $patterns; + + public function __construct(StringFragmentPattern ...$patterns) + { + $this->patterns = $patterns; + } + + /** + * @param string $value to be split into `raw` and `expression` fragments + * @param int $flags (optional) `FLAG_UNMATCHED_AS_NULL` + */ + public function split(string $value, int $flags = 0): ?StringFragmentCollection + { + $pattern = chr(1) . implode('|', $this->preparePatterns()) . chr(1); + $options = PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE | PREG_SET_ORDER; + if (!preg_match_all($pattern, $value, $matches, $options)) { + if (($flags & self::FLAG_UNMATCHED_AS_NULL) === self::FLAG_UNMATCHED_AS_NULL) { + return null; + } + return new StringFragmentCollection(StringFragment::raw($value)); + } + + $collection = new StringFragmentCollection(); + foreach ($matches as $match) { + // filters string keys (e.g. `expression_a1b2c3d4e5`) from matches, skips numeric indexes + $types = array_filter( + array_keys($match), + static fn(int|string $type) => is_string($type) && $type !== '' + ); + foreach ($types as $type) { + $matchOffset = $match[$type][1]; + if ($matchOffset < 0) { + continue; + } + $matchValue = $match[$type][0]; + // matches contain only pattern matches, but no raw string literals - by comparing the + // position of the collection with the current offset, missing raw literals are synchronized + if ($collection->getLength() < $matchOffset) { + $gapValue = substr($value, $collection->getLength(), $matchOffset - $collection->getLength()); + $collection = $collection->with(StringFragment::raw($gapValue)); + } + $collection = $collection->with(StringFragment::expression($matchValue)); + } + } + // synchronize missing raw string literals + // (at the end of the given value after previous expression) + if ($collection->getLength() < strlen($value)) { + $gapValue = substr($value, $collection->getLength()); + $collection = $collection->with(StringFragment::raw($gapValue)); + } + return $collection; + } + + /** + * @return list<string> + */ + protected function preparePatterns(): array + { + return array_map( + static fn(StringFragmentPattern $pattern) => $pattern->compilePattern(), + $this->patterns + ); + } +} diff --git a/Classes/Utility/StringUtility.php b/Classes/Utility/StringUtility.php new file mode 100644 index 0000000..943e180 --- /dev/null +++ b/Classes/Utility/StringUtility.php @@ -0,0 +1,207 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +/** + * Class with helper functions for string handling + */ +class StringUtility +{ + /** + * Casts applicable types (string, bool, finite numeric) to string. + * + * Any other type will be replaced by the `$default` value. + */ + public static function cast(mixed $value, ?string $default = null): ?string + { + if (is_string($value)) { + return $value; + } + + if (is_bool($value) || (is_numeric($value) && is_finite($value))) { + return (string)$value; + } + + return $default; + } + + /** + * Keeps only string types (filters out non-strings). + * + * Any other non-string type will be replaced by the `$default` value. + */ + public static function filter(mixed $value, ?string $default = null): ?string + { + return is_string($value) ? $value : $default; + } + + /** + * This function generates a unique id by using the more entropy parameter. + * Furthermore, the dots are removed so the id can be used inside HTML attributes e.g. id. + * + * @return non-empty-string + */ + public static function getUniqueId(string $prefix = ''): string + { + $uniqueId = uniqid($prefix, true); + return str_replace('.', '', $uniqueId); + } + + /** + * Escape a CSS selector to be used for DOM queries + * + * This method takes care to escape any CSS selector meta character. + * The result may be used to query the DOM like $('#' + escapedSelector) + */ + public static function escapeCssSelector(string $selector): string + { + return preg_replace('/([#:.\\[\\],=@])/', '\\\\$1', $selector); + } + + /** + * Removes the Byte Order Mark (BOM) from the input string. + * + * This method supports UTF-8 encoded strings only! + */ + public static function removeByteOrderMark(string $input): string + { + if (str_starts_with($input, "\xef\xbb\xbf")) { + $input = substr($input, 3); + } + + return $input; + } + + /** + * Matching two strings against each other, supporting a "*" wildcard (match many) or a "?" wildcard (match one= or (if wrapped in "/") PCRE regular expressions + * + * @param string $haystack The string in which to find $needle. + * @param string $needle The string to find in $haystack + * @return bool Returns TRUE if $needle matches or is found in (according to wildcards) $haystack. E.g. if $haystack is "Netscape 6.5" and $needle is "Net*" or "Net*ape" then it returns TRUE. + */ + public static function searchStringWildcard(string $haystack, string $needle): bool + { + $result = false; + if ($haystack === $needle) { + $result = true; + } elseif ($needle) { + if (preg_match('/^\\/.+\\/$/', $needle)) { + // Regular expression, only "//" is allowed as delimiter + $regex = $needle; + } else { + $needle = str_replace(['*', '?'], ['%%%MANY%%%', '%%%ONE%%%'], $needle); + $regex = '/^' . preg_quote($needle, '/') . '$/'; + // Replace the marker with .* to match anything (wildcard) + $regex = str_replace(['%%%MANY%%%', '%%%ONE%%%'], ['.*', '.'], $regex); + } + $result = (bool)preg_match($regex, $haystack); + } + return $result; + } + + /** + * Takes a comma-separated list and removes all duplicates. + * If a value in the list is trim(empty), the value is ignored. + * + * @param string $list A comma-separated list of values. + * @return string Returns the list without any duplicates of values, space around values are trimmed. + */ + public static function uniqueList(string $list): string + { + return implode(',', array_unique(GeneralUtility::trimExplode(',', $list, true))); + } + + /** + * Works the same as str_pad() except that it correctly handles strings with multibyte characters + * and takes an additional optional argument $encoding. + * + * @deprecated since TYPO3 v15.0, will be removed in TYPO3 v16.0. Use the native PHP function mb_str_pad() instead. + */ + public static function multibyteStringPad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, string $encoding = 'UTF-8'): string + { + trigger_error( + 'StringUtility::multibyteStringPad() will be removed in TYPO3 v16.0. Use the native PHP function mb_str_pad() instead.', + E_USER_DEPRECATED + ); + // An empty pad string returns the input unchanged instead of throwing a ValueError like mb_str_pad() does. + if ($pad_string === '') { + return $string; + } + return mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); + } + + /** + * Returns base64 encoded value with a URL and filename safe alphabet + * according to https://tools.ietf.org/html/rfc4648#section-5 + * + * The difference to classic base64 is, that the result + * alphabet is adjusted like shown below, padding (`=`) + * is stripped completely: + * + position #62: `+` -> `-` (minus) + * + position #63: `/` -> `_` (underscore) + * + * @param string $value raw value + * @return string base64url encoded string + */ + public static function base64urlEncode(string $value): string + { + return strtr(base64_encode($value), ['+' => '-', '/' => '_', '=' => '']); + } + + /** + * Returns base64 decoded value with a URL and filename safe alphabet + * according to https://tools.ietf.org/html/rfc4648#section-5 + * + * The difference to classic base64 is, that the result + * alphabet is adjusted like shown below, padding (`=`) + * is stripped completely: + * + position #62: `-` (minus) -> `+` + * + position #63: `_` (underscore) -> `/` + * + * @param string $value base64url decoded string + * @param bool $strict enforces to only allow characters contained in the base64(url) alphabet + * @return string|false raw value, or `false` if non-base64(url) characters were given in strict mode + */ + public static function base64urlDecode(string $value, bool $strict = false): string|false + { + return base64_decode(strtr($value, ['-' => '+', '_' => '/']), $strict); + } + + /** + * Explodes a string while respecting escape characters + * + * e.g.: delimiter: '.'; escapeCharacter: '\'; subject: 'new\.site.child' + * result: [new.site, child] + * @param string $delimiter + * @param string $subject + * @param string $escapeCharacter + */ + public static function explodeEscaped(string $delimiter, string $subject, string $escapeCharacter = '\\'): array + { + if ($delimiter !== '') { + $placeholder = '\\0\\0\\0_esc'; + $subjectEscaped = str_replace($escapeCharacter . $delimiter, $placeholder, $subject); + $escapeParts = explode($delimiter, $subjectEscaped); + foreach ($escapeParts as &$part) { + $part = str_replace($placeholder, $delimiter, $part); + } + return $escapeParts; + } + return [$subject]; + } +} diff --git a/Classes/Utility/VersionNumberUtility.php b/Classes/Utility/VersionNumberUtility.php new file mode 100644 index 0000000..070283c --- /dev/null +++ b/Classes/Utility/VersionNumberUtility.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Utility; + +use TYPO3\CMS\Core\Information\Typo3Version; + +/** + * Class with helper functions for version number handling + */ +class VersionNumberUtility +{ + /** + * Returns an integer from a three part version number, eg '4.12.3' -> 4012003 + * + * @param string $versionNumber Version number on format x.x.x + * @return int Integer version of version number (where each part can count to 999) + */ + public static function convertVersionNumberToInteger(string $versionNumber): int + { + $versionParts = explode('.', $versionNumber); + $version = $versionParts[0]; + for ($i = 1; $i < 3; $i++) { + if (!empty($versionParts[$i])) { + $version .= str_pad((string)(int)$versionParts[$i], 3, '0', STR_PAD_LEFT); + } else { + $version .= '000'; + } + } + return (int)$version; + } + + /** + * Removes -dev -alpha -beta -RC states (also without '-' prefix) from a version number + * and replaces them by .0 and normalizes to a three part version number + */ + public static function getNumericTypo3Version(): string + { + $t3version = static::getCurrentTypo3Version(); + $t3version = preg_replace('/-?(dev|alpha|beta|RC).*$/', '', $t3version); + $parts = GeneralUtility::intExplode('.', $t3version . '..'); + $t3version = MathUtility::forceIntegerInRange($parts[0], 0, 999) . '.' + . MathUtility::forceIntegerInRange($parts[1], 0, 999) . '.' + . MathUtility::forceIntegerInRange($parts[2], 0, 999); + return $t3version; + } + + /** + * Wrapper function for the static TYPO3 version to + * make functions using the constant unit testable. + */ + public static function getCurrentTypo3Version(): string + { + return (string)GeneralUtility::makeInstance(Typo3Version::class); + } + + /** + * This function converts version range strings (like '4.2.0-4.4.99') to an array + * (like array('4.2.0', '4.4.99'). It also forces each version part to be between + * 0 and 999 + * + * @param string $versionsString A string in the form 'x.x.x-y.y.y' + * @return string[] + */ + public static function convertVersionsStringToVersionNumbers(string $versionsString): array + { + $versions = GeneralUtility::trimExplode('-', $versionsString); + foreach ($versions as $i => $version) { + $cleanedVersion = GeneralUtility::trimExplode('.', $version); + foreach ($cleanedVersion as $j => $cleaned) { + $cleanedVersion[$j] = MathUtility::forceIntegerInRange((int)$cleaned, 0, 999); + } + $cleanedVersionString = implode('.', $cleanedVersion); + if (static::convertVersionNumberToInteger($cleanedVersionString) === 0) { + $cleanedVersionString = ''; + } + $versions[$i] = $cleanedVersionString; + } + return $versions; + } + + /** + * Parses the version number x.x.x and returns an array with the various parts. + * It also forces each … 0 to 999 + * + * @param string $version Version string, in the format x.x.x + * @return array<string, int|string> + */ + public static function convertVersionStringToArray(string $version): array + { + $parts = GeneralUtility::intExplode('.', $version . '..'); + $parts[0] = MathUtility::forceIntegerInRange($parts[0], 0, 999); + $parts[1] = MathUtility::forceIntegerInRange($parts[1], 0, 999); + $parts[2] = MathUtility::forceIntegerInRange($parts[2], 0, 999); + $result = []; + $result['version'] = $parts[0] . '.' . $parts[1] . '.' . $parts[2]; + $result['version_int'] = (int)($parts[0] * 1000000 + $parts[1] * 1000 + $parts[2]); + $result['version_main'] = $parts[0]; + $result['version_sub'] = $parts[1]; + $result['version_dev'] = $parts[2]; + return $result; + } +} diff --git a/Classes/Validation/ResultException.php b/Classes/Validation/ResultException.php new file mode 100644 index 0000000..53e3cd4 --- /dev/null +++ b/Classes/Validation/ResultException.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Validation; + +use TYPO3\CMS\Core\Exception; + +/** + * @internal + */ +final class ResultException extends Exception +{ + /** + * @var list<ResultMessage> + */ + public readonly array $messages; + + public function __construct(string $message = '', int $code = 0, ResultMessage ...$messages) + { + parent::__construct($message, $code); + $this->messages = $messages; + } +} diff --git a/Classes/Validation/ResultMessage.php b/Classes/Validation/ResultMessage.php new file mode 100644 index 0000000..04934b6 --- /dev/null +++ b/Classes/Validation/ResultMessage.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Validation; + +use TYPO3\CMS\Core\Localization\LabelBag; + +/** + * @internal + */ +final readonly class ResultMessage +{ + public function __construct( + public string $message, + public ?LabelBag $labelBag = null, + ) {} +} diff --git a/Classes/Validation/ResultRenderingTrait.php b/Classes/Validation/ResultRenderingTrait.php new file mode 100644 index 0000000..60c9005 --- /dev/null +++ b/Classes/Validation/ResultRenderingTrait.php @@ -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\Core\Validation; + +use TYPO3\CMS\Core\Localization\TranslatorInterface; + +trait ResultRenderingTrait +{ + public function renderResultException(ResultException $exception, ?TranslatorInterface $translator = null): string + { + return sprintf( + '%s: %s', + $exception->getMessage(), + implode( + ' | ', + $this->compileResultMessages($exception->messages, $translator) + ) + ); + } + + /** + * @param list<ResultMessage> $messages + * @return list<string> + */ + public function compileResultMessages(array $messages, ?TranslatorInterface $translator = null): array + { + return array_map( + static fn(ResultMessage $message): string => $message->labelBag !== null && $translator !== null + ? $message->labelBag->compile($translator) + : $message->message, + $messages + ); + } +} diff --git a/Classes/Versioning/VersionState.php b/Classes/Versioning/VersionState.php new file mode 100644 index 0000000..70e288c --- /dev/null +++ b/Classes/Versioning/VersionState.php @@ -0,0 +1,63 @@ +<?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\Core\Versioning; + +/** + * Enumeration object for VersionState + */ +enum VersionState: int +{ + /** + * The t3ver_state 0 is used for a live element, and any + * commonly "modified" versioned record which is then identified + * with t3ver_oid=uid of live ID + */ + case DEFAULT_STATE = 0; + + /** + * If a new record is created in a workspace a new + * record is added with t3ver_state = 1, a so-called + * "newly versioned record", which acts as a standalone + * record and has no t3ver_oid value. Publishing this record + * is done by changing the t3ver_wsid field to "0". + */ + case NEW_PLACEHOLDER = 1; + + /** + * Deleting elements is done by actually creating a + * new version of the element and setting t3ver_state=2 + * that indicates the live element must be deleted upon + * publishing the versions. + */ + case DELETE_PLACEHOLDER = 2; + + /** + * When an element is moved to a different page, a versioned + * record is created with t3ver_state=4 and the new PID. + * When the database table has a sorting field, the sorting + * on the versioned record is also updated to reflect the new position. + * + * When reading records from the DB with workspaces in mind, + * the t3ver_state=4 records should be fetched as well to + * find the new position and to do "workspace overlays" properly. + */ + case MOVE_POINTER = 4; + + public function indicatesPlaceholder(): bool + { + return $this !== self::NEW_PLACEHOLDER && $this !== self::DEFAULT_STATE; + } +} diff --git a/Classes/View/ResponsableViewInterface.php b/Classes/View/ResponsableViewInterface.php new file mode 100644 index 0000000..7b389b4 --- /dev/null +++ b/Classes/View/ResponsableViewInterface.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\View; + +use Psr\Http\Message\ResponseInterface; + +/** + * An optional addition to ViewInterface that allows getting a PSR-7 Response directly. + */ +interface ResponsableViewInterface +{ + /** + * Renders the view and creates a response from it. Optionally receives a template location. + */ + public function renderResponse(string $templateFileName = ''): ResponseInterface; +} diff --git a/Classes/View/ViewFactoryData.php b/Classes/View/ViewFactoryData.php new file mode 100644 index 0000000..68281c9 --- /dev/null +++ b/Classes/View/ViewFactoryData.php @@ -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\Core\View; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * A data object hand over to ViewFactoryInterface to create, + * configure and return a view based on this data. + * + * Best practices: + * * Hand over request if possible. + * * Use the tuple $templateRootPaths, $partialRootPaths and $layoutRootPaths if possible, using + * an array of "base" paths like 'EXT:Resources/Private/(Templates|Partials|Layouts)' + * * Avoid $templatePathAndFilename + * * Call render('path/within/templateRootPath') without file-ending on the returned ViewInterface instance. + */ +final readonly class ViewFactoryData +{ + public function __construct( + public ?array $templateRootPaths = null, + public ?array $partialRootPaths = null, + public ?array $layoutRootPaths = null, + public ?string $templatePathAndFilename = null, + public ?ServerRequestInterface $request = null, + public ?string $format = null, + ) {} +} diff --git a/Classes/View/ViewFactoryInterface.php b/Classes/View/ViewFactoryInterface.php new file mode 100644 index 0000000..6f56d69 --- /dev/null +++ b/Classes/View/ViewFactoryInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\View; + +/** + * Generic TYPO3 view factory - the V in MVC. + * + * This interface is used in TYPO3 and should be used via dependency + * injection to create a view instance in custom TYPO3 extensions + * whenever a view should be rendered. + */ +interface ViewFactoryInterface +{ + public function create(ViewFactoryData $data): ViewInterface; +} diff --git a/Classes/View/ViewInterface.php b/Classes/View/ViewInterface.php new file mode 100644 index 0000000..270bd75 --- /dev/null +++ b/Classes/View/ViewInterface.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\View; + +/** + * A generic view interface. + */ +interface ViewInterface +{ + /** + * Add a variable to the view data collection. + */ + public function assign(string $key, mixed $value): self; + + /** + * Add multiple variables to the view data collection. + * + * @param array<string, mixed> $values Array of string keys with mixed-type values. + */ + public function assignMultiple(array $values): self; + + /** + * Renders the view. Optionally receives a template location. + */ + public function render(string $templateFileName = ''): string; +} diff --git a/Classes/ViewHelpers/IconForRecordViewHelper.php b/Classes/ViewHelpers/IconForRecordViewHelper.php new file mode 100644 index 0000000..3e2276b --- /dev/null +++ b/Classes/ViewHelpers/IconForRecordViewHelper.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\ViewHelpers; + +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; + +/** + * ViewHelper to display an icon for a record. + * + * ``` + * <core:iconForRecord table="tt_content" row="{record}" /> + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-core-iconforrecord + */ +final class IconForRecordViewHelper extends AbstractViewHelper +{ + /** + * ViewHelper returns HTML, thus we need to disable output escaping + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly IconFactory $iconFactory + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('table', 'string', 'the table for the record icon', true); + $this->registerArgument('row', 'array', 'the record row', true); + $this->registerArgument('size', 'string', 'the icon size', false, IconSize::SMALL->value); + $this->registerArgument('alternativeMarkupIdentifier', 'string', 'alternative markup identifier'); + } + + public function render(): string + { + $table = $this->arguments['table']; + $size = IconSize::from($this->arguments['size']); + $row = $this->arguments['row']; + $alternativeMarkupIdentifier = $this->arguments['alternativeMarkupIdentifier']; + return $this->iconFactory->getIconForRecord($table, $row, $size)->render($alternativeMarkupIdentifier); + } +} diff --git a/Classes/ViewHelpers/IconForResourceViewHelper.php b/Classes/ViewHelpers/IconForResourceViewHelper.php new file mode 100644 index 0000000..f1d3f85 --- /dev/null +++ b/Classes/ViewHelpers/IconForResourceViewHelper.php @@ -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\Core\ViewHelpers; + +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3\CMS\Core\Resource\ResourceInterface; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; + +/** + * ViewHelper to displays an icon for a FAL resource (file or folder means a `TYPO3\CMS\Core\Resource\ResourceInterface`). + * + * ``` + * <core:iconForResource resource="{file.resource}" /> + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-core-iconforresource + * @see \TYPO3\CMS\Core\Resource\ResourceInterface + */ +final class IconForResourceViewHelper extends AbstractViewHelper +{ + /** + * ViewHelper returns HTML, thus we need to disable output escaping + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly IconFactory $iconFactory + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('resource', ResourceInterface::class, 'Resource', true); + $this->registerArgument('size', 'string', 'The icon size', false, IconSize::SMALL); + $this->registerArgument('overlay', 'string', 'Overlay identifier', false, null); + $this->registerArgument('options', 'array', 'An associative array with additional options', false, []); + $this->registerArgument('alternativeMarkupIdentifier', 'string', 'Alternative markup identifier'); + } + + public function render(): string + { + $resource = $this->arguments['resource']; + if (!($resource instanceof ResourceInterface)) { + return ''; + } + $size = $this->arguments['size']; + $overlay = $this->arguments['overlay']; + return $this->iconFactory->getIconForResource($resource, $size, $overlay, $this->arguments['options'])->render($this->arguments['alternativeMarkupIdentifier']); + } +} diff --git a/Classes/ViewHelpers/IconViewHelper.php b/Classes/ViewHelpers/IconViewHelper.php new file mode 100644 index 0000000..7c816b6 --- /dev/null +++ b/Classes/ViewHelpers/IconViewHelper.php @@ -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\Core\ViewHelpers; + +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3\CMS\Core\Imaging\IconState; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; + +/** + * ViewHelper to display an icon identified by its icon identifier. + * + * ``` + * <core:icon title="Open actions menu" identifier="actions-menu" /> + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-core-icon + */ +final class IconViewHelper extends AbstractViewHelper +{ + /** + * ViewHelper returns HTML, thus we need to disable output escaping + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly IconFactory $iconFactory + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('identifier', 'string', 'Identifier of the icon as registered in the Icon Registry.', true); + $this->registerArgument('size', 'string', 'Desired size of the icon. All values of the IconSize enum are allowed, these are: "small", "default", "medium", "large" and "mega".', false, IconSize::SMALL->value); + $this->registerArgument('overlay', 'string', 'Identifier of an overlay icon as registered in the Icon Registry.'); + $this->registerArgument('state', 'string', 'Sets the state of the icon. All values of the Icons.states enum are allowed, these are: "default" and "disabled".', false, IconState::STATE_DEFAULT->value); + $this->registerArgument('alternativeMarkupIdentifier', 'string', 'Alternative icon identifier. Takes precedence over the identifier if supported by the IconProvider.'); + $this->registerArgument('title', 'string', 'Title for the icon'); + } + + /** + * Prints icon html for $identifier key + */ + public function render(): string + { + $identifier = $this->arguments['identifier']; + $size = IconSize::from($this->arguments['size']); + $overlay = $this->arguments['overlay']; + $state = IconState::tryFrom($this->arguments['state']); + $alternativeMarkupIdentifier = $this->arguments['alternativeMarkupIdentifier']; + $icon = $this->iconFactory->getIcon($identifier, $size, $overlay, $state); + if ($this->arguments['title'] ?? false) { + $icon->setTitle($this->arguments['title']); + } + return $icon->render($alternativeMarkupIdentifier); + } +} diff --git a/Classes/ViewHelpers/NormalizedUrlViewHelper.php b/Classes/ViewHelpers/NormalizedUrlViewHelper.php new file mode 100644 index 0000000..c7a49d3 --- /dev/null +++ b/Classes/ViewHelpers/NormalizedUrlViewHelper.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\ViewHelpers; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions; +use TYPO3\CMS\Core\SystemResource\SystemResourceFactory; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; + +/** + * ViewHelper to normalize a path that uses EXT: syntax or an absolute URL to an absolute web path. + * + * ``` + * <core:normalizedUrl pathOrUrl="https://foo.bar/img.jpg" /> + * <core:normalizedUrl pathOrUrl="EXT:core/Resources/Public/Images/typo3_black.svg" /> + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-core-normalizedurl + * @internal + */ +final class NormalizedUrlViewHelper extends AbstractViewHelper +{ + public function __construct( + private readonly SystemResourceFactory $systemResourceFactory, + private readonly SystemResourcePublisherInterface $resourcePublisher, + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('pathOrUrl', 'string', 'Absolute path to file using EXT: syntax or URL.'); + } + + /** + * Output what is given as URL or extension relative path as absolute URL + */ + public function render(): string + { + $pathOrUrl = $this->renderChildren(); + $resource = $this->systemResourceFactory->createPublicResource($pathOrUrl); + return (string)$this->resourcePublisher->generateUri( + $resource, + $this->renderingContext->hasAttribute(ServerRequestInterface::class) ? $this->renderingContext->getAttribute(ServerRequestInterface::class) : null, + new UriGenerationOptions(absoluteUri: true), + ); + } + + /** + * Explicitly set argument name to be used as content. + */ + public function getContentArgumentName(): string + { + return 'pathOrUrl'; + } +} diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php new file mode 100644 index 0000000..06d197b --- /dev/null +++ b/Configuration/Backend/Modules.php @@ -0,0 +1,65 @@ +<?php + +/** + * Configuration of the main modules (having no parent and no path) + */ +return [ + 'content' => [ + 'labels' => 'core.modules.content', + 'iconIdentifier' => 'module-web', + 'navigationComponent' => '@typo3/backend/tree/page-tree-element', + 'aliases' => ['web'], + ], + 'media' => [ + 'position' => ['after' => 'content'], + 'labels' => 'core.modules.media', + 'iconIdentifier' => 'module-file', + 'navigationComponent' => '@typo3/backend/tree/file-storage-tree-container', + 'aliases' => ['file'], + 'appearance' => [ + 'promotesSingleSubmoduleToStandalone' => true, + ], + ], + 'site' => [ + 'labels' => 'core.modules.site', + 'workspaces' => 'live', + 'iconIdentifier' => 'module-site', + ], + 'user' => [ + 'labels' => 'core.modules.user', + 'iconIdentifier' => 'module-user', + 'workspaces' => '*', + 'appearance' => [ + 'renderInModuleMenu' => false, + ], + ], + 'admin' => [ + 'labels' => 'core.modules.admin', + 'iconIdentifier' => 'module-tools', + 'aliases' => ['tools'], + ], + 'system' => [ + 'labels' => 'core.modules.system', + 'iconIdentifier' => 'module-system', + ], + 'integrations' => [ + 'parent' => 'admin', + 'position' => ['after' => 'permissions_pages'], + 'access' => 'admin', + 'workspaces' => 'live', + 'path' => '/module/integrations', + 'iconIdentifier' => 'module-integrations', + 'labels' => 'core.modules.integrations', + 'appearance' => [ + 'dependsOnSubmodules' => true, + ], + 'showSubmoduleOverview' => true, + ], + 'help' => [ + 'labels' => 'core.modules.help', + 'iconIdentifier' => 'module-help', + 'appearance' => [ + 'renderInModuleMenu' => false, + ], + ], +]; diff --git a/Configuration/DefaultAppResources.php b/Configuration/DefaultAppResources.php new file mode 100644 index 0000000..a38b708 --- /dev/null +++ b/Configuration/DefaultAppResources.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; +use TYPO3\CMS\Core\Package\VirtualAppPackage; + +return static function (VirtualAppPackage $package, string $relativePublicPath) { + return [ + new PublicResourceDefinition( + relativePath: $relativePublicPath . '_assets', + ), + new PublicResourceDefinition( + relativePath: $relativePublicPath . 'uploads', + ), + new PublicResourceDefinition( + relativePath: $relativePublicPath . 'typo3temp/assets', + ), + ]; +}; diff --git a/Configuration/DefaultConfiguration.php b/Configuration/DefaultConfiguration.php new file mode 100644 index 0000000..13544f5 --- /dev/null +++ b/Configuration/DefaultConfiguration.php @@ -0,0 +1,1915 @@ +<?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! + */ + +/** + * This file contains the default array definition that is + * later populated as $GLOBALS['TYPO3_CONF_VARS'] + * + * The description of the various options is stored in the DefaultConfigurationDescription.yaml file + */ +return [ + 'DB' => [ + 'additionalQueryRestrictions' => [], + 'globalDriverMiddlewares' => [ + 'typo3/core/custom-platform-driver-middleware' => [ + 'target' => \TYPO3\CMS\Core\Database\Middleware\CustomPlatformDriverMiddleware::class, + 'before' => [ + 'typo3/core/custom-pdo-driver-result-middleware', + ], + ], + 'typo3/core/custom-pdo-driver-result-middleware' => [ + 'target' => \TYPO3\CMS\Core\Database\Middleware\CustomPdoDriverResultMiddleware::class, + 'after' => [ + 'typo3/core/custom-platform-driver-middleware', + ], + ], + ], + ], + 'GFX' => [ // Configuration of the image processing features in TYPO3. 'IM' and 'GD' are short for ImageMagick and GD library respectively. + 'thumbnails' => true, + 'imagefile_ext' => 'gif,jpg,jpeg,tif,tiff,bmp,pcx,tga,png,pdf,ai,svg,webp,avif', + 'imageFileConversionFormats' => [ + // Default "pass through" web formats + 'jpg' => 'jpg', + 'jpeg' => 'jpeg', + 'gif' => 'gif', + 'png' => 'png', + 'svg' => 'svg', + 'webp' => 'webp', + // "Everything else" (default conversion) + // A format like PNG is advised to allow keeping transparency. + // Ideally, use webp/avif here (future default?). You can also only use "default=webp" to force every format being converted to webp. + 'default' => 'png', + ], + 'processor_enabled' => true, + 'processor_path' => '/usr/bin/', + 'processor' => 'ImageMagick', + 'processor_effects' => false, + 'processor_allowUpscaling' => true, + 'processor_allowFrameSelection' => true, + 'processor_stripColorProfileByDefault' => true, + 'processor_stripColorProfileParameters' => ['+profile', '*'], + 'processor_colorspace' => '', + 'processor_interlace' => 'None', + 'jpg_quality' => 85, + 'webp_quality' => 85, + 'avif_quality' => 85, + ], + 'LANG' => [ + // english is implicit + 'availableLocales' => [], + 'format' => [ + 'priority' => 'xlf', + ], + 'loader' => [ + 'xlf' => \TYPO3\CMS\Core\Localization\Loader\XliffLoader::class, + ], + 'requireApprovedLocalizations' => true, + 'resourceOverrides' => [], + ], + 'SYS' => [ + 'session' => [ + 'BE' => [ + 'backend' => \TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend::class, + 'options' => [ + 'table' => 'be_sessions', + ], + ], + 'FE' => [ + 'backend' => \TYPO3\CMS\Core\Session\Backend\DatabaseSessionBackend::class, + 'options' => [ + 'table' => 'fe_sessions', + 'has_anonymous' => true, + ], + ], + ], + 'SystemResources' => [ + 'filesystemPublishingType' => 'link', + ], + 'fileCreateMask' => '0664', + 'folderCreateMask' => '2775', + 'features' => [ + 'extbase.enableHistoryTracking' => false, + 'frontend.cache.autoTagging' => false, + 'redirects.hitCount' => false, + 'security.backend.htmlSanitizeRte' => false, + 'security.backend.enforceReferrer' => true, + 'security.frontend.enforceContentSecurityPolicy' => false, + 'security.frontend.reportContentSecurityPolicy' => false, + 'security.frontend.allowInsecureSiteResolutionByQueryParameters' => false, + 'security.frontend.allowInsecureFrameOptionInShowImageController' => false, + // only file extensions configured in 'textfile_ext', 'mediafile_ext', 'miscfile_ext' are accepted + 'security.system.enforceAllowedFileExtensions' => false, + // only files having file-extension to mime-type matches are allowed + // (adjustable by `$GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility']`) + 'security.system.enforceFileExtensionMimeTypeConsistency' => true, + ], + 'createGroup' => '', + 'sitename' => 'TYPO3', + 'cookieDomain' => '', + 'trustedHostsPattern' => 'SERVER_NAME', + 'devIPmask' => '127.0.0.1,::1', + 'ddmmyy' => 'Y-m-d', + 'hhmm' => 'H:i', + 'loginCopyrightWarrantyProvider' => '', + 'loginCopyrightWarrantyURL' => '', + 'textfile_ext' => 'css,csv,htm,html,js,json,md,rst,rtf,sql,srt,tmpl,ts,txt,typoscript,xlf,xml,yaml,yml', + 'mediafile_ext' => '3gp,aac,ai,aif,avif,bmp,flac,gif,heic,ico,jpeg,jpg,m4a,m4v,mov,mp3,mp4,ogg,opus,pdf,png,psd,svg,vimeo,wav,webm,webp,youtube', + 'miscfile_ext' => '7z,doc,docm,docx,dot,dotm,dotx,epub,gz,ics,mobi,odp,ods,odt,potm,potx,ppam,pps,ppsm,ppsx,ppt,pptm,pptx,rar,sldm,sldx,tar,vcard,vcf,xlam,xls,xlsb,xlsm,xlsx,xlt,xltm,xltx,zip', + 'binPath' => '', + 'binSetup' => '', + 'setMemoryLimit' => 0, + 'phpTimeZone' => '', + 'UTF8filesystem' => false, + 'systemLocale' => '', + 'systemMaintainers' => null, // @todo: This will be set up as an empty array once the installer can define a system maintainers + 'reverseProxyIP' => '', + 'reverseProxyHeaderMultiValue' => 'none', + 'reverseProxyPrefix' => '', + 'reverseProxySSL' => '', + 'reverseProxyPrefixSSL' => '', + 'availablePasswordHashAlgorithms' => [ + \TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash::class, + \TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash::class, + ], + 'routing' => [ + 'enhancers' => [ + 'Simple' => \TYPO3\CMS\Core\Routing\Enhancer\SimpleEnhancer::class, + 'Plugin' => \TYPO3\CMS\Core\Routing\Enhancer\PluginEnhancer::class, + 'PageType' => \TYPO3\CMS\Core\Routing\Enhancer\PageTypeDecorator::class, + 'Extbase' => \TYPO3\CMS\Extbase\Routing\ExtbasePluginEnhancer::class, + ], + 'aspects' => [ + 'LocaleModifier' => \TYPO3\CMS\Core\Routing\Aspect\LocaleModifier::class, + 'PersistedAliasMapper' => \TYPO3\CMS\Core\Routing\Aspect\PersistedAliasMapper::class, + 'PersistedPatternMapper' => \TYPO3\CMS\Core\Routing\Aspect\PersistedPatternMapper::class, + 'StaticRangeMapper' => \TYPO3\CMS\Core\Routing\Aspect\StaticRangeMapper::class, + 'StaticValueMapper' => \TYPO3\CMS\Core\Routing\Aspect\StaticValueMapper::class, + ], + ], + 'locking' => [ + 'strategies' => [ + \TYPO3\CMS\Core\Locking\FileLockStrategy::class => [ + // if not set: use default priority of FileLockStrategy + //'priority' => 75, + + // if not set: use default path of FileLockStrategy + // If you change this, directory must exist! + // 'lockFileDir' => 'typo3temp/var' + ], + \TYPO3\CMS\Core\Locking\SemaphoreLockStrategy::class => [ + // if not set: use default priority of SemaphoreLockStrategy + // 'priority' => 50 + + // empty: use default path of SemaphoreLockStrategy + // If you change this, directory must exist! + // 'lockFileDir' => 'typo3temp/var' + ], + \TYPO3\CMS\Core\Locking\SimpleLockStrategy::class => [ + // if not set: use default priority of SimpleLockStrategy + //'priority' => 25, + + // empty: use default path of SimpleLockStrategy + // If you change this, directory must exist! + // 'lockFileDir' => 'typo3temp/var' + ], + ], + ], + 'deserialization' => [ + // List of class names that are allowed to be deserialized even if they carry + // __destruct() or __wakeup() and would otherwise be blocked. Use this to + // explicitly permit classes that have been reviewed and are known to be safe. + 'allowedClassNames' => [ + // Scheduler task entries prior to v9.5.4 contained + // a serialized logger instance. + // The `__wakeup()` method of this class is safe to be + // deserialized and is therefore excluded from the deny list + \TYPO3\CMS\Core\Log\Logger::class, + ], + ], + 'caching' => [ + 'cacheConfigurations' => [ + // The core cache is is for core php code only and must + // not be abused by third party extensions. + 'core' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\PhpFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'options' => [ + 'defaultLifetime' => 0, + ], + 'groups' => ['system'], + ], + 'hash' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class, + 'options' => [], + 'groups' => ['pages'], + ], + 'pages' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class, + 'options' => [ + 'compression' => true, + ], + 'groups' => ['pages'], + ], + 'runtime' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend::class, + 'options' => [], + 'groups' => [], + ], + 'rootline' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class, + 'options' => [ + 'defaultLifetime' => 2592000, // 30 days; set this to a lower value in case your cache gets too big + ], + 'groups' => ['pages'], + ], + 'assets' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'options' => [ + 'defaultLifetime' => 0, + ], + 'groups' => ['system'], + ], + 'l10n' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'options' => [ + 'defaultLifetime' => 0, + ], + 'groups' => ['system'], + ], + 'fluid_template' => [ + 'frontend' => \TYPO3\CMS\Fluid\Core\Cache\FluidTemplateCache::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'groups' => ['system'], + ], + 'fluid_component_definitions' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'groups' => ['system'], + ], + 'extbase' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'options' => [ + 'defaultLifetime' => 0, + ], + 'groups' => ['system'], + ], + 'ratelimiter' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'groups' => ['system'], + ], + 'typoscript' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\PhpFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'groups' => ['pages'], + ], + 'database_schema' => [ + 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, + 'backend' => \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class, + 'groups' => ['system'], + ], + ], + ], + 'rateLimiter' => [], + 'htmlSanitizer' => [ + 'default' => \TYPO3\CMS\Core\Html\DefaultSanitizerBuilder::class, + 'preview' => \TYPO3\CMS\Core\Html\PreviewSanitizerBuilder::class, + 'i18n' => \TYPO3\CMS\Core\Html\I18nSanitizerBuilder::class, + ], + 'displayErrors' => -1, + 'productionExceptionHandler' => \TYPO3\CMS\Core\Error\ProductionExceptionHandler::class, + 'debugExceptionHandler' => \TYPO3\CMS\Core\Error\DebugExceptionHandler::class, + 'errorHandler' => \TYPO3\CMS\Core\Error\ErrorHandler::class, + // @todo: Remove 2048 (deprecated E_STRICT) in v14, as this value is no longer used by PHP itself + // and only kept here here because possible custom PHP extensions may still use it. + // See https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant + 'errorHandlerErrors' => E_ALL & ~(2048 /* deprecated E_STRICT */ | E_NOTICE | E_COMPILE_WARNING | E_COMPILE_ERROR | E_CORE_WARNING | E_CORE_ERROR | E_PARSE | E_ERROR), + 'exceptionalErrors' => E_ALL & ~(2048 /* deprecated E_STRICT */ | E_NOTICE | E_COMPILE_WARNING | E_COMPILE_ERROR | E_CORE_WARNING | E_CORE_ERROR | E_PARSE | E_ERROR | E_DEPRECATED | E_USER_DEPRECATED | E_WARNING | E_USER_ERROR | E_USER_NOTICE | E_USER_WARNING), + 'belogErrorReporting' => E_ALL & ~(2048 /* deprecated E_STRICT */ | E_NOTICE), + 'allowedPhpDisableFunctions' => [], + 'generateApacheHtaccess' => 1, + 'ipAnonymization' => 1, + 'Objects' => [], + 'fal' => [ + 'registeredDrivers' => [ + 'Local' => [ + 'class' => \TYPO3\CMS\Core\Resource\Driver\LocalDriver::class, + 'shortName' => 'Local', + 'flexFormDS' => 'FILE:EXT:core/Configuration/Resource/Driver/LocalDriverFlexForm.xml', + 'label' => 'Local filesystem', + ], + ], + 'defaultFilterCallbacks' => [ + [ + \TYPO3\CMS\Core\Resource\Filter\FileNameFilter::class, + 'filterHiddenFilesAndFolders', + ], + ], + 'processors' => [ + 'SvgImageProcessor' => [ + 'className' => \TYPO3\CMS\Core\Resource\Processing\SvgImageProcessor::class, + 'before' => [ + 'LocalImageProcessor', + 'DeferredBackendImageProcessor', + ], + ], + 'DeferredBackendImageProcessor' => [ + 'className' => \TYPO3\CMS\Backend\Resource\Processing\DeferredBackendImageProcessor::class, + 'before' => [ + 'LocalImageProcessor', + 'OnlineMediaPreviewProcessor', + ], + 'after' => [ + 'SvgImageProcessor', + ], + ], + 'OnlineMediaPreviewProcessor' => [ + 'className' => \TYPO3\CMS\Core\Resource\OnlineMedia\Processing\PreviewProcessing::class, + 'after' => [ + 'SvgImageProcessor', + ], + 'before' => [ + 'LocalImageProcessor', + ], + ], + 'LocalImageProcessor' => [ + 'className' => \TYPO3\CMS\Core\Resource\Processing\LocalImageProcessor::class, + ], + ], + 'processingTaskTypes' => [ + 'Image.Preview' => \TYPO3\CMS\Core\Resource\Processing\ImagePreviewTask::class, + 'Image.CropScaleMask' => \TYPO3\CMS\Core\Resource\Processing\ImageCropScaleMaskTask::class, + ], + 'registeredCollections' => [ + 'static' => \TYPO3\CMS\Core\Resource\Collection\StaticFileCollection::class, + 'folder' => \TYPO3\CMS\Core\Resource\Collection\FolderBasedFileCollection::class, + 'category' => \TYPO3\CMS\Core\Resource\Collection\CategoryBasedFileCollection::class, + ], + 'onlineMediaHelpers' => [ + 'youtube' => \TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\YouTubeHelper::class, + 'vimeo' => \TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\VimeoHelper::class, + ], + ], + 'IconFactory' => [ + 'recordStatusMapping' => [ + 'hidden' => 'overlay-hidden', + 'fe_group' => 'overlay-restricted', + 'starttime' => 'overlay-scheduled', + 'endtime' => 'overlay-endtime', + 'futureendtime' => 'overlay-scheduled', + 'readonly' => 'overlay-readonly', + 'deleted' => 'overlay-deleted', + 'missing' => 'overlay-missing', + 'translated' => 'overlay-translated', + 'protectedSection' => 'overlay-includes-subpages', + ], + 'overlayPriorities' => [ + 'hidden', + 'starttime', + 'endtime', + 'futureendtime', + 'protectedSection', + 'fe_group', + ], + ], + 'FileInfo' => [ + // List of extensions/mimetypes that are detected as a more generic mimetype + // by finfo_file()/mime_content_type(), but are allowed to be + // mapped to a concrete MIME type by their file extension + // (but only if the file was detected as the generalized mime type!) + 'mimeTypeCompatibility' => [ + // mime-db/db.json only knows: "application/octet-stream", "application/x-msdos-program", "application/x-msdownload" + // So we map all other possible .exe types to this. + 'application/x-dosexec' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-mz-executable' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-wine-extension-mz' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-executable' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/binary' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-ms-application' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/dos-exe' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-ms-dos-executable' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/vnd.microsoft.portable-executable' => [ + 'exe' => 'application/x-msdos-program', + ], + 'application/x-winexe' => [ + 'exe' => 'application/x-msdos-program', + ], + // Encrypted Office Open XML documents + 'application/encrypted' => [ + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide', + 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + ], + // Word + // https://support.microsoft.com/en-us/office/open-xml-formats-and-file-name-extensions-5200d93c-3449-4380-8e11-31ef14555b18#ID0EDFBF + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => [ + // Macro-enabled document + 'docm' => 'application/vnd.ms-word.document.macroenabled.12', + // Template + 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + // Macro-enabled template + 'dotm' => 'application/vnd.ms-word.template.macroenabled.12', + ], + // PowerPoint + // https://support.microsoft.com/en-us/office/open-xml-formats-and-file-name-extensions-5200d93c-3449-4380-8e11-31ef14555b18#ID0EDBBF + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => [ + // Macro-enabled presentation + 'pptm' => 'application/vnd.ms-powerpoint.presentation.macroenabled.12', + // Template + 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', + // Macro-enabled template + 'potm' => 'application/vnd.ms-powerpoint.template.macroenabled.12', + // Macro-enabled add-in + 'ppam' => 'application/vnd.ms-powerpoint.addin.macroenabled.12', + // Show + 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + // Macro-enabled show + 'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', + // Slide + 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide', + // Macro-enabled slide + 'sldm' => 'application/vnd.ms-powerpoint.slide.macroenabled.12', + // Office theme + 'thmx' => 'application/vnd.ms-officetheme', + ], + // Excel + // https://support.microsoft.com/en-us/office/open-xml-formats-and-file-name-extensions-5200d93c-3449-4380-8e11-31ef14555b18#ID0EDDBF + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => [ + // Macro-enabled workbook + 'xlsm' => 'application/vnd.ms-excel.sheet.macroenabled.12', + // Template + 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + // Macro-enabled template + 'xltm' => 'application/vnd.ms-excel.template.macroenabled.12', + // Non-XML binary workbook + 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroenabled.12', + // Macro-enabled add-in + 'xlam' => 'application/vnd.ms-excel.addin.macroenabled.12', + ], + 'font/sfnt' => [ + 'otf' => 'font/otf', + 'ttf' => 'font/ttf', + ], + 'image/jpeg' => [ + 'jfif' => 'image/pjpeg', + ], + 'text/plain' => [ + 'json' => 'application/json', + 'srt' => 'application/x-subrip', + 'vimeo' => 'video/vimeo', + 'yaml' => 'application/yaml', + 'yml' => 'application/yaml', + 'youtube' => 'video/youtube', + ], + 'text/xml' => [ + 'opml' => 'text/x-opml', + ], + ], + // Former static mapping for file extensions to mime types, + // for special cases the mime type is not detected correctly. + // This array was used if the automatic detection does not work correct! + // Please use 'mimeTypeCompatibility' instead. + 'fileExtensionToMimeType' => [], + ], + 'fluid' => [ + 'interceptors' => [], + 'preProcessors' => [ + \TYPO3Fluid\Fluid\Core\Parser\TemplateProcessor\EscapingModifierTemplateProcessor::class, + \TYPO3Fluid\Fluid\Core\Parser\TemplateProcessor\PassthroughSourceModifierTemplateProcessor::class, + \TYPO3Fluid\Fluid\Core\Parser\TemplateProcessor\NamespaceDetectionTemplateProcessor::class, + \TYPO3Fluid\Fluid\Core\Parser\TemplateProcessor\RemoveCommentsTemplateProcessor::class, + ], + 'expressionNodeTypes' => [ + \TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\CastingExpressionNode::class, + \TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\MathExpressionNode::class, + \TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\TernaryExpressionNode::class, + ], + ], + 'defaultScheme' => \TYPO3\CMS\Core\LinkHandling\LinkHandlingInterface::DEFAULT_SCHEME, + 'linkHandler' => [ // Array: Available link types, class which implement the LinkHandling interface + 'page' => \TYPO3\CMS\Core\LinkHandling\PageLinkHandler::class, + 'file' => \TYPO3\CMS\Core\LinkHandling\FileLinkHandler::class, + 'folder' => \TYPO3\CMS\Core\LinkHandling\FolderLinkHandler::class, + 'url' => \TYPO3\CMS\Core\LinkHandling\UrlLinkHandler::class, + 'email' => \TYPO3\CMS\Core\LinkHandling\EmailLinkHandler::class, + 'record' => \TYPO3\CMS\Core\LinkHandling\RecordLinkHandler::class, + 'telephone' => \TYPO3\CMS\Core\LinkHandling\TelephoneLinkHandler::class, + ], + 'livesearch' => [], // Array: keywords used for commands to search for specific tables + 'formEngine' => [ + 'nodeRegistry' => [], // Array: Registry to add or overwrite FormEngine nodes. Main key is a timestamp of the date when an entry is added, sub keys type, priority and class are required. Class must implement TYPO3\CMS\Backend\Form\NodeInterface. + 'nodeResolver' => [], // Array: Additional node resolver. Main key is a timestamp of the date when an entry is added, sub keys type, priority and class are required. Class must implement TYPO3\CMS\Backend\Form\NodeResolverInterface. + 'formDataGroup' => [ // Array: Registry of form data providers for form data groups + 'tcaDatabaseRecord' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\ReturnUrl::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\ReturnUrl::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageLanguageOverlayRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseLanguageRows::class => [ + 'depends' => [ + // Language stuff depends on user ts, but it *may* also depend on new row defaults + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageLanguageOverlayRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultAsReadonly::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseLanguageRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultAsReadonly::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDateTimeFields::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultAsReadonly::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCountry::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaLanguage::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaLanguage::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCountry::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexPrepare::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexProcess::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexPrepare::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexProcess::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSlug::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexPrepare::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTtContentCtypeItemsRestrictionByBackendLayout::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTtContentColPosItemsRestrictionByBackendLayout::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTtContentCtypeItemsRestrictionByBackendLayout::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTtContentColPosItemsRestrictionByBackendLayout::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveEmptyRelations::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaLanguage::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTablePermission::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveEmptyRelations::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTablePermission::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFiles::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaShortcut::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFiles::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineIsOnSymmetricSide::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRecordTitle::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineIsOnSymmetricSide::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\EvaluateDisplayConditions::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRecordTitle::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SystemMaintainerAsReadonly::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\EvaluateDisplayConditions::class, + ], + ], + ], + 'tcaSelectTreeAjaxFieldData' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageLanguageOverlayRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseLanguageRows::class => [ + 'depends' => [ + // Language stuff depends on user ts, but it *may* also depend on new row defaults + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageLanguageOverlayRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultAsReadonly::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseLanguageRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultAsReadonly::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexPrepare::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexProcess::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexPrepare::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFlexProcess::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class, + ], + ], + ], + 'flexFormSegment' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDateTimeFields::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaUuid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCountry::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaLanguage::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFiles::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + ], + ], + ], + 'tcaInputPlaceholderRecord' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaJson::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectTreeItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTablePermission::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCategory::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTablePermission::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFiles::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInline::class, + ], + ], + ], + 'siteConfiguration' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteDatabaseEditRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteDatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseDefaultLanguagePageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDateTimeFields::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDateTimeFields::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteResolving::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordOverrideValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsOverrides::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaTypesCtrlOverrides::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineExpandCollapseState::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessRecordTitle::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessPlaceholders::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InlineOverrideChildTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseSystemLanguageRows::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldDescriptions::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRadioItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaFolder::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabasePageRootline::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfigMerged::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaCheckboxItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteTcaInline::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInlineConfiguration::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSiteLanguage::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\SiteTcaInline::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRecordTitle::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSiteLanguage::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDateTimeFields::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\EvaluateDisplayConditions::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaRecordTitle::class, + ], + ], + ], + 'backendUserSettings' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class => [], + \TYPO3\CMS\Backend\Form\FormDataProvider\UserSettingsDatabaseEditRow::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\UserSettingsDatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\UserSettingsDatabaseEditRow::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowDefaultValues::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRecordTypeValue::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessCommon::class, + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessShowitem::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsRemoveUnused::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaText::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaGroup::class, + ], + ], + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaColumnsProcessFieldLabels::class => [ + 'depends' => [ + \TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems::class, + ], + ], + ], + ], + ], + 'yamlLoader' => [ + 'placeholderProcessors' => [ + \TYPO3\CMS\Core\Configuration\Processor\Placeholder\EnvVariableProcessor::class => [], + \TYPO3\CMS\Core\Configuration\Processor\Placeholder\ValueFromReferenceArrayProcessor::class => [ + 'after' => [ + \TYPO3\CMS\Core\Configuration\Processor\Placeholder\EnvVariableProcessor::class, + ], + ], + ], + ], + 'passwordPolicies' => [ + 'installTool' => [ + 'generator' => [ + 'className' => \TYPO3\CMS\Core\PasswordPolicy\Generator\PasswordGenerator::class, + 'options' => [ + 'length' => 12, + 'upperCaseCharacters' => true, + 'lowerCaseCharacters' => true, + 'digitCharacters' => true, + 'specialCharacters' => true, + ], + ], + 'validators' => [ + \TYPO3\CMS\Core\PasswordPolicy\Validator\CorePasswordValidator::class => [ + 'options' => [ + 'minimumLength' => 8, + 'upperCaseCharacterRequired' => true, + 'lowerCaseCharacterRequired' => true, + 'digitCharacterRequired' => true, + 'specialCharacterRequired' => true, + ], + 'excludeActions' => [], + ], + ], + ], + 'secretToken' => [ + 'generator' => [ + 'className' => \TYPO3\CMS\Core\PasswordPolicy\Generator\PasswordGenerator::class, + 'options' => [ + 'length' => 40, + 'random' => 'hex', + ], + ], + 'validators' => [], + ], + 'default' => [ + 'generator' => [ + 'className' => \TYPO3\CMS\Core\PasswordPolicy\Generator\PasswordGenerator::class, + 'options' => [ + 'length' => 12, + 'upperCaseCharacters' => true, + 'lowerCaseCharacters' => true, + 'digitCharacters' => true, + 'specialCharacters' => true, + ], + ], + 'validators' => [ + \TYPO3\CMS\Core\PasswordPolicy\Validator\CorePasswordValidator::class => [ + 'options' => [ + 'minimumLength' => 8, + 'upperCaseCharacterRequired' => true, + 'lowerCaseCharacterRequired' => true, + 'digitCharacterRequired' => true, + 'specialCharacterRequired' => true, + ], + 'excludeActions' => [], + ], + \TYPO3\CMS\Core\PasswordPolicy\Validator\NotCurrentPasswordValidator::class => [ + 'options' => [], + 'excludeActions' => [ + \TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction::NEW_USER_PASSWORD, + \TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction::UPDATE_USER_PASSWORD_SWITCH_USER_MODE, + ], + ], + ], + ], + ], + 'messenger' => [ + 'routing' => [ + '*' => 'default', + ], + ], + ], + 'EXT' => [ // Options related to the Extension Management + 'excludeForPackaging' => '(?:\\.(?!htaccess$).*|.*~|.*\\.swp|.*\\.bak|node_modules|bower_components)', + ], + 'BE' => [ + // Backend Configuration. + 'entryPoint' => '/typo3', + 'fileadminDir' => 'fileadmin/', + 'lockRootPath' => [], + 'lockBackendFile' => '', + 'userHomePath' => '', + 'groupHomePath' => '', + 'userUploadDir' => '', + 'warning_email_addr' => '', + 'warning_mode' => 0, + 'passwordReset' => true, + 'passwordResetForAdmins' => true, + 'requireMfa' => 0, + 'recommendedMfaProvider' => 'totp', + 'loginRateLimit' => 5, + 'loginRateLimitInterval' => '15 minutes', + 'loginRateLimitIpExcludeList' => '', + 'lockIP' => 0, + 'lockIPv6' => 0, + 'sessionTimeout' => 28800, // a backend user logged in for 8 hours + 'IPmaskList' => '', + 'lockSSL' => false, + 'lockSSLPort' => 0, + 'cookieDomain' => '', + 'cookieName' => 'be_typo_user', + 'cookieSameSite' => 'strict', + 'showRefreshLoginPopup' => false, + 'adminOnly' => 0, + 'disable_exec_function' => false, + 'installToolPassword' => '', + 'installToolSessionHandler' => [ + 'className' => \TYPO3\CMS\Install\Service\Session\FileSessionHandler::class, + ], + 'contentSecurityPolicyReportingUrl' => '', + // String (exclude).Enter lines of default page TSconfig. + 'defaultPermissions' => [], + 'defaultUC' => [], + 'customPermOptions' => [], // Array with sets of custom permission options. Syntax is; 'key' => array('header' => 'header string, language split', 'items' => array('key' => array('label, language split','icon reference', 'Description text, language split'))). Keys cannot contain ":|," characters. + 'versionNumberInFilename' => false, + 'debug' => false, + 'HTTP' => [ + 'Response' => [ + 'Headers' => [ + 'clickJackingProtection' => 'X-Frame-Options: SAMEORIGIN', + 'strictTransportSecurity' => 'Strict-Transport-Security: max-age=31536000', + 'avoidMimeTypeSniffing' => 'X-Content-Type-Options: nosniff', + 'referrerPolicy' => 'Referrer-Policy: strict-origin-when-cross-origin', + ], + ], + ], + 'passwordHashing' => [ + 'className' => \TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash::class, + 'options' => [], + ], + 'passwordPolicy' => 'default', + 'stylesheets' => [ + 'backend' => 'EXT:backend/Resources/Public/Css', + ], + ], + 'FE' => [ // Configuration for the TypoScript frontend (FE). Nothing here relates to the administration backend! + 'debug' => false, + 'pageNotFoundOnCHashError' => true, + 'pageUnavailable_force' => false, + 'checkFeUserPid' => true, + 'loginRateLimit' => 10, + 'loginRateLimitInterval' => '15 minutes', + 'loginRateLimitIpExcludeList' => '', + 'lockIP' => 0, + 'lockIPv6' => 0, + 'lifetime' => 0, + 'sessionTimeout' => 6000, + 'sessionDataLifetime' => 86400, + 'permalogin' => 0, + 'cookieDomain' => '', + 'cookieName' => 'fe_typo_user', + 'cookieSameSite' => 'lax', + 'contentSecurityPolicyReportingUrl' => '', + 'defaultTypoScript_constants' => '', + 'defaultTypoScript_constants.' => [], // Lines of TS to include after a static template with the uid = the index in the array (Constants) + 'defaultTypoScript_setup' => '', + 'defaultTypoScript_setup.' => [], // Lines of TS to include after a static template with the uid = the index in the array (Setup) + 'enable_mount_pids' => true, + 'hidePagesIfNotTranslatedByDefault' => false, + 'eID_include' => [], // Array of key/value pairs where key is "tx_[ext]_[optional suffix]" and value is relative filename of class to include. Key is used as "?eID=" for \TYPO3\CMS\Frontend\Http\RequestHandlerRequestHandler to include the code file which renders the page from that point. (Useful for functionality that requires a low initialization footprint, eg. frontend ajax applications) + 'disableNoCacheParameter' => false, + 'cacheHash' => [ + 'cachedParametersWhiteList' => [], + 'excludedParameters' => [ + 'L', + // Matomo + 'mtm_campaign', + 'mtm_keyword', + 'mtm_kwd', + 'mtm_source', + 'mtm_medium', + 'mtm_content', + 'mtm_cid', + 'mtm_group', + 'mtm_placement', + // Piwik + 'pk_campaign', + 'pk_kwd', + '_stg_debug', + // Google + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'utm_id', + 'utm_source_platform', + 'utm_creative_format', + 'utm_marketing_tactic', + 'gtm_debug', + '_ga', + '_gl', + // Google ads + 'gad', + 'gad_campaignid', + 'gad_source', + 'gbraid', + 'gclid', + 'dclid', + 'wbraid', + // Facebook + 'fbclid', + // Microsoft Bing Ads. + 'msclkid', + // HubSpot Ad Tracking Parameters + 'hsa_acc', + 'hsa_ad', + 'hsa_cam', + 'hsa_grp', + 'hsa_kw', + 'hsa_mt', + 'hsa_net', + 'hsa_src', + 'hsa_tgt', + 'hsa_ver', + 'hsa_ol', + 'hsa_la', + // HubSpot Email Tracking Parameters + '_hsenc', + '_hsmi', + // HubSpot CTA Tracking Parameters + '__hssc', + '__hstc', + '__hsfp', + 'hsCtaTracking', + // HubSpot Form Tracking Parameters + 'submissionGuid', + // LinkedIn First-Party Ad Tracking ID + 'li_fat_id', + ], + 'requireCacheHashPresenceParameters' => [], + 'excludeAllEmptyParameters' => false, + 'excludedParametersIfEmpty' => [], + 'enforceValidation' => false, + ], + 'additionalCanonicalizedUrlParameters' => [], + 'workspacePreviewLogoutTemplate' => '', + 'versionNumberInFilename' => false, + 'contentRenderingTemplates' => [], // Array to define the TypoScript parts that define the main content rendering. Extensions like "fluid_styled_content" provide content rendering templates. Other extensions like "felogin" or "indexed search" extend these templates and their TypoScript parts are added directly after the content templates. + 'typolinkBuilder' => [ // Matches the LinkService implementations for generating URL, link text via typolink + 'page' => \TYPO3\CMS\Frontend\Typolink\PageLinkBuilder::class, + 'file' => \TYPO3\CMS\Frontend\Typolink\FileOrFolderLinkBuilder::class, + 'folder' => \TYPO3\CMS\Frontend\Typolink\FileOrFolderLinkBuilder::class, + 'url' => \TYPO3\CMS\Frontend\Typolink\ExternalUrlLinkBuilder::class, + 'email' => \TYPO3\CMS\Frontend\Typolink\EmailLinkBuilder::class, + 'record' => \TYPO3\CMS\Frontend\Typolink\DatabaseRecordLinkBuilder::class, + 'telephone' => \TYPO3\CMS\Frontend\Typolink\TelephoneLinkBuilder::class, + 'unknown' => \TYPO3\CMS\Frontend\Typolink\LegacyLinkBuilder::class, + ], + 'passwordHashing' => [ + 'className' => \TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash::class, + 'options' => [], + ], + 'passwordPolicy' => 'default', + 'exposeRedirectInformation' => false, + ], + 'MAIL' => [ // Mail configurations to tune how \TYPO3\CMS\Core\Mail\ classes will send their mails. + 'transport' => 'sendmail', + 'transport_smtp_server' => 'localhost:25', + 'transport_smtp_encrypt' => false, + 'transport_smtp_username' => '', + 'transport_smtp_password' => '', + 'transport_smtp_domain' => '', + 'transport_smtp_restart_threshold' => 0, + 'transport_smtp_restart_threshold_sleep' => 0, + 'transport_smtp_ping_threshold' => 0, + 'transport_smtp_stream_options' => null, + 'transport_sendmail_command' => '', + 'transport_mbox_file' => '', + 'transport_spool_type' => '', + 'transport_spool_filepath' => '', + 'dsn' => '', + 'validators' => [ + \Egulias\EmailValidator\Validation\RFCValidation::class, + ], + 'defaultMailFromAddress' => '', + 'defaultMailFromName' => '', + 'defaultMailReplyToAddress' => '', + 'defaultMailReplyToName' => '', + 'format' => 'both', + 'layoutRootPaths' => [ + 0 => 'EXT:core/Resources/Private/Layouts/', + 10 => 'EXT:backend/Resources/Private/Layouts/', + ], + 'partialRootPaths' => [ + 0 => 'EXT:core/Resources/Private/Partials/', + 10 => 'EXT:backend/Resources/Private/Partials/', + ], + 'templateRootPaths' => [ + 0 => 'EXT:core/Resources/Private/Templates/Email/', + 10 => 'EXT:backend/Resources/Private/Templates/Email/', + ], + ], + 'HTTP' => [ // HTTP configuration to tune how TYPO3 behaves on HTTP requests made by TYPO3. Have a look at http://docs.guzzlephp.org/en/latest/request-options.html for some background information on those settings. + 'allow_redirects' => [ // Mixed, set to false if you want to allow redirects, or use it as an array to add more values, + 'max' => 5, // Integer: Maximum number of tries before an exception is thrown. + 'strict' => false, // Boolean: Whether to keep request method on redirects via status 301 and 302 (TRUE, needed for compatibility with <a href="http://www.faqs.org/rfcs/rfc2616">RFC 2616</a>) or switch to GET (FALSE, needed for compatibility with most browsers). + ], + 'cert' => null, + 'connect_timeout' => 10, + 'proxy' => null, + 'ssl_key' => null, + 'timeout' => 0, + 'verify' => true, + 'version' => '1.1', + 'handler' => [], // Array of callables + 'headers' => [ // Additional HTTP headers sent by every request TYPO3 executes. + 'User-Agent' => 'TYPO3', // String: Default user agent. Defaults to TYPO3. + ], + ], + 'LOG' => [ + 'writerConfiguration' => [ + \Psr\Log\LogLevel::WARNING => [ + \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [], + ], + ], + 'TYPO3' => [ + 'CMS' => [ + 'Core' => [ + 'Resource' => [ + 'ResourceStorage' => [ + 'writerConfiguration' => [ + \Psr\Log\LogLevel::ERROR => [ + \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [], + \TYPO3\CMS\Core\Log\Writer\DatabaseWriter::class => [], + ], + ], + ], + ], + ], + 'deprecations' => [ + 'writerConfiguration' => [ + \Psr\Log\LogLevel::NOTICE => [ + \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [ + 'logFileInfix' => 'deprecations', + 'disabled' => true, + ], + ], + ], + ], + ], + ], + ], + 'USER' => [], + // Here you can more or less freely define additional configuration for scripts in TYPO3. Of course the features + // supported depends on the script. Keys in the array are the relative + // path of a script (for output scripts it should be the "script ID" as found in a comment in the HTML header ) and + // values can then be anything that scripts wants to define for itself. The key "GLOBAL" is reserved. + 'SC_OPTIONS' => [ + 'ext/install' => [ + 'update' => [], + ], + ], + 'SVCONF' => [], + 'EXTENSIONS' => [], +]; diff --git a/Configuration/DefaultConfigurationDescription.yaml b/Configuration/DefaultConfigurationDescription.yaml new file mode 100644 index 0000000..c275857 --- /dev/null +++ b/Configuration/DefaultConfigurationDescription.yaml @@ -0,0 +1,698 @@ +# For supported YAML types @see TYPO3\CMS\Install\Service\LocalConfigurationValueService->recursiveConfigurationFetching() docblock +GFX: + type: container + description: 'Image Processing' + items: + thumbnails: + type: bool + description: 'Enables the use of thumbnails in the backend interface.' + imagefile_ext: + type: list + description: 'Commalist of file extensions perceived as images by TYPO3. List should be set to "gif,png,jpeg,jpg" if ImageMagick is not available. Lowercase and no spaces between! To configure default conversion formats, please manually configure the extra array "imageFileConversionFormats" in settings.php.' + imageFileConversionFormats: + type: map + arrayKey: Input format (extension) + arrayValue: Output format (extension) + description: 'Define how image files are converted by default for preview output in the frontend when referenced with Image ViewHelpers for example. This settings allows to specify a map of input/output formats. The key "Input format (extension)" specifies a file extension like "jpg" (no dot). The right hand value "Output format (extension)" specifies the image file type/extension that files will be converted to. The special input format "default" will set the default preview creation format (usually "png" to preserve alpha-channel transparency). Any source file extension that should be kept when being rendered needs to be specified here, like jpeg=>jpeg, jpeg=>jpg, png=>png. Any other format listed in the "imagefile_ext" list, but not here, will use the "default" format. You can just set "default=>webp" for example to force every image being converted to webp. Non-convertible formats will be saved as png if possible, or else passed through.' + processor_enabled: + type: bool + description: 'Enables the use of Image- or GraphicsMagick.' + processor_path: + type: text + readonly: true + description: 'Path to the IM tools ''convert'', ''combine'', ''identify''.' + processor: + type: dropdown + allowedValues: + 'ImageMagick': 'Choose ImageMagick for processing images' + 'GraphicsMagick': 'Choose GraphicsMagick for processing images' + description: 'Select which external software on the server should process images - see also the Preset functionality to see what is available.' + processor_effects: + type: bool + description: 'If enabled, apply blur and sharpening in ImageMagick/GraphicMagick functions' + processor_allowUpscaling: + type: bool + description: 'If set, images can be scaled up if told so (in <code>\TYPO3\CMS\Core\Imaging\GraphicalFunctions</code>)' + processor_allowFrameSelection: + type: bool + description: 'If set, the [x] frame selector is appended to input filenames in stdgraphic. This speeds up image processing for PDF files considerably. Disable if your image processor or environment can''t cope with the frame selection.' + processor_stripColorProfileByDefault: + type: bool + description: 'If set, the processor_stripColorProfileParameters is used with all processor image operations by default. See tsRef for setting this parameter explicitly for IMAGE generation.' + processor_stripColorProfileParameters: + type: element-list + description: 'List of parameters: Specify the parameters to strip the profile information, which can reduce thumbnail size up to 60KB. Command can differ in IM/GM, IM also knows the -strip command. See <a href="http://www.imagemagick.org/Usage/thumbnails/#profiles" target="_blank" rel="noreferrer">imagemagick.org</a> for details' + processor_colorspace: + type: text + description: 'String: Specify the colorspace to use. Defaults to "RGB" when using GraphicsMagick as processor and "sRGB" when using ImageMagick. Images would be rendered darker than the original when using ImageMagick in combination with "RGB". <br />Possible Values: CMY, CMYK, Gray, HCL, HSB, HSL, HWB, Lab, LCH, LMS, Log, Luv, OHTA, Rec601Luma, Rec601YCbCr, Rec709Luma, Rec709YCbCr, RGB, sRGB, Transparent, XYZ, YCbCr, YCC, YIQ, YCbCr, YUV' + processor_interlace: + type: text + description: 'String: Specify the interlace option to use. The result differs in different GM / IM versions. See manual of GraphicsMagick or ImageMagick for right option. <br />Possible values: None, Line, Plane, Partition' + jpg_quality: + type: int + description: 'Integer: Default JPEG generation quality' + webp_quality: + type: int + description: 'Integer: Default WebP generation quality' + avif_quality: + type: int + description: 'Integer: Default AVIF generation quality' +SYS: + type: container + description: 'System' + items: + fileCreateMask: + type: text + description: 'File mode mask for Unix file systems (when files are uploaded/created).' + folderCreateMask: + type: text + description: 'As above, but for folders.' + createGroup: + type: text + description: 'Group for newly created files and folders (Unix only). Group ownership can be changed on Unix file systems (see above). Set this if you want to change the group ownership of created files/folders to a specific group. This makes sense in all cases where the webserver is running with a different user/group as you do. Create a new group on your system and add you and the webserver user to the group. Now you can safely set the last bit in fileCreateMask/folderCreateMask to 0 (e.g. 770). Important: The user who is running your webserver needs to be a member of the group you specify here! Otherwise you might get some error messages.' + sitename: + type: text + description: 'Name of the base-site.' + cookieDomain: + type: text + description: 'Restricts the domain name for FE and BE session cookies. When setting the value to ".domain.com" (replace domain.com with your domain!), login sessions will be shared across subdomains. Alternatively, if you have more than one domain with sub-domains, you can set the value to a regular expression to match against the domain of the HTTP request. The result of the match is used as the domain for the cookie. eg. <code>/\.(example1|example2)\.com$/</code> or <code>/\.(example1\.com)|(example2\.net)$/</code>. Separate domains for FE and BE can be set using <a href="#FE-cookieDomain">$TYPO3_CONF_VARS[''FE''][''cookieDomain'']</a> and <a href="#BE-cookieDomain">$TYPO3_CONF_VARS[''BE''][''cookieDomain'']</a> respectively.' + trustedHostsPattern: + type: text + description: 'Regular expression pattern that matches all allowed hostnames (including their ports) of this TYPO3 installation, or the string "SERVER_NAME" (default). The default value <code>SERVER_NAME</code> checks if the HTTP Host header equals the SERVER_NAME and SERVER_PORT. This is secure in correctly configured hosting environments and does not need further configuration. If you cannot change your hosting environment, you can enter a regular expression here. Examples: <code>.*\.domain\.com</code> matches all hosts that end with <code>.domain.com</code> with all corresponding subdomains. <code>(.*\.domain|.*\.otherdomain)\.com</code> matches all hostnames with subdomains from <code>.domain.com</code> and <code>.otherdomain.com</code>. Be aware that HTTP Host header may also contain a port. If your installation runs on a specific port, you need to explicitly allow this in your pattern, e.g. <code>www\.domain\.com:88</code> allows only <code>www.domain.com:88</code>, <strong>not</strong> <code>www.domain.com</code>. To disable this check completely (not recommended because it is <strong>insecure</strong>) you can use ".*" as pattern.' + devIPmask: + type: text + description: 'Defines a list of IP addresses which will allow development-output to display. The debug() function will use this as a filter. See the function <code>\TYPO3\CMS\Core\Utility\GeneralUtility::cmpIP()</code> for details on syntax. Setting this to blank value will deny all. Setting to "*" will allow all.' + ddmmyy: + type: text + description: 'Format of dates (without times) - see PHP-function <a href="https://php.net/date" target="_blank" rel="noreferrer">date()</a>' + hhmm: + type: text + description: 'Format of times (without dates) - see PHP-function <a href="https://php.net/date" target="_blank" rel="noreferrer">date()</a>' + defaultScheme: + type: text + allowedValues: + 'http': 'http' + 'https': 'https' + description: 'Default URI scheme to be used in case none was given, e.g. "www.typo3.org" becomes "http://www.typo3.org"' + loginCopyrightWarrantyProvider: + type: text + description: 'If you provide warranty for TYPO3 to your customers insert you (company) name here. It will appear in the login-dialog as the warranty provider. (You must also set URL below).' + loginCopyrightWarrantyURL: + type: text + description: 'Add the URL where you explain the extend of the warranty you provide. This URL is displayed in the login dialog as the place where people can learn more about the conditions of your warranty. Must be set (more than 10 chars) in addition with the ''loginCopyrightWarrantyProvider'' message.' + textfile_ext: + type: text + description: 'Text file extensions. Those that can be edited. Executable PHP files may not be editable if disallowed!' + mediafile_ext: + type: text + description: 'Commalist of file extensions perceived as media files by TYPO3. Lowercase and no spaces between!' + miscfile_ext: + type: list + description: "Commalist of file extensions that don't logically fit into `textfile_ext` or `mediafile_ext` (like `zip` or `xz`). Lowercase and no spaces between!" + binPath: + type: text + description: 'List of absolute paths where external programs should be searched for. Eg. <code>/usr/local/webbin/,/home/xyz/bin/</code>. (ImageMagick path have to be configured separately)' + binSetup: + type: multiline + description: 'List of programs (separated by newline or comma). By default programs will be searched in default paths and the special paths defined by <code>binPath</code>. When PHP has openbasedir enabled the programs can not be found and have to be configured here. Example: <code>perl=/usr/bin/perl,unzip=/usr/local/bin/unzip</code>' + setMemoryLimit: + type: int + description: 'Integer: memory_limit in MB: If more than 16, TYPO3 will try to use ini_set() to set the memory limit of PHP to the value. This works only if the function ini_set() is not disabled by your sysadmin.' + phpTimeZone: + type: text + description: 'timezone to force for all date() and mktime() functions. A list of supported values can be found at <a href="https://php.net/manual/en/timezones.php" target="_blank" rel="noreferrer">php.net</a>. If this is not set, a valid fallback will be searched for by PHP (php.ini''s <a href="http://www.php.net/manual/en/datetime.configuration.php#ini.date.timezone" target="_blank" rel="noreferrer">date.timezone</a> setting, server defaults, etc); and if no fallback is found, the value of "UTC" is used instead.' + UTF8filesystem: + type: bool + description: | + If TRUE then TYPO3 uses utf-8 to store file names. This allows for accented Latin letters as well as any other non-latin characters like Cyrillic and Chinese. + <strong>IMPORTANT:</strong> This requires a UTF-8 compatible locale in order to work. Otherwise problems with filenames containing special characters will occur. + See [SYS][systemLocale] and <a href="https://php.net/manual/en/function.setlocale.php" target="_blank" rel="noreferrer">setlocale()</a>. + systemLocale: + type: text + description: 'Locale used for certain system related functions, e.g. escaping shell commands. If problems with filenames containing special characters occur, the value of this option is probably wrong. See <a href="https://php.net/manual/en/function.setlocale.php" target="_blank" rel="noreferrer">setlocale()</a>. Available locales: ' + reverseProxyIP: + type: list + description: 'List of IP addresses. If TYPO3 is behind one or more (intransparent) reverse proxies the IP addresses must be added here and <a href="#SYS-reverseProxyHeaderMultiValue">[SYS][reverseProxyHeaderMultiValue]</a> must be set to ''first'' or ''last''.' + reverseProxyHeaderMultiValue: + type: text + allowedValues: + 'none': 'Do not evaluate the reverse proxy header' + 'first': 'Use the first IP address in the proxy header' + 'last': 'Use the last IP address in the proxy header' + description: 'Position of the authoritative IP address within the "X-Forwarded-For" header (e.g. "X-Forwarded-For: 1.2.3.4, 2.3.4.5, 3.4.5.6" uses "1.2.3.4" with "first" and "3.4.5.6" with "last").' + reverseProxyPrefix: + type: text + description: 'Optional prefix to be added to the internal URL (SCRIPT_NAME and REQUEST_URI). Example: When proxying ext-domain.com to int-server.com/prefix this has to be set to <em>prefix</em>' + reverseProxySSL: + type: text + description: '''*'' or list of IP addresses of proxies that use SSL (https) for the connection to the client, but an unencrypted connection (http) to the server. If ''*'' all proxies defined in <a href="#SYS-reverseProxyIP">[SYS][reverseProxyIP]</a> use SSL.' + reverseProxyPrefixSSL: + type: text + description: 'Prefix to be added to the internal URL (SCRIPT_NAME and REQUEST_URI) when accessing the server via an SSL proxy. This setting overrides <a href="#SYS-reverseProxyPrefix">[SYS][reverseProxyPrefix]</a>.' + displayErrors: + type: int + allowedValues: + '-1': 'TYPO3 does not touch the PHP setting. If [SYS][devIPmask] matches the user''s IP address, the configured [SYS][debugExceptionHandler] is used instead of the [SYS][productionExceptionHandler] to handle exceptions.' + '0': 'Live: Do not display any PHP error message. Sets "display_errors=0". Overrides the value of [SYS][exceptionalErrors] and sets it to 0 (= no errors are turned into exceptions). The configured [SYS][productionExceptionHandler] is used as exception handler.' + '1': 'Debug: Display error messages with the registered [SYS][errorHandler]. Sets "display_errors=1". The configured [SYS][debugExceptionHandler] is used as exception handler.' + description: 'Configures whether PHP errors or Exceptions should be displayed, effectively setting the PHP option <code>display_errors</code> during runtime.' + productionExceptionHandler: + type: phpClass + description: 'Classname to handle exceptions that might happen in the TYPO3-code. Leave empty to disable exception handling. Default: "TYPO3\CMS\Core\Error\ProductionExceptionHandler". This exception handler displays a nice error message when something went wrong. The error message is logged to the configured logs. Note: The configured "productionExceptionHandler" is used if [SYS][displayErrors] is set to "0" or is set to "-1" and [SYS][devIPmask] doesn''t match the user''s IP.' + debugExceptionHandler: + type: phpClass + description: 'Classname to handle exceptions that might happen in the TYPO3-code. Leave empty to disable exception handling. Default: "TYPO3\CMS\Core\Error\DebugExceptionHandler". This exception handler displays the complete stack trace of any encountered exception. The error message and the stack trace is logged to the configured logs. Note: The configured "debugExceptionHandler" is used if [SYS][displayErrors] is set to "1" or is set to "-1" and the [SYS][devIPmask] matches the user''s IP.' + errorHandler: + type: phpClass + description: 'Classname to handle PHP errors. E.g.: TYPO3\CMS\Core\Error\ErrorHandler. This class displays and logs all errors that are registered as [SYS][errorHandlerErrors]. Leave empty to disable error handling. Errors will be logged and can be sent to the optionally installed developer log or to the "syslog" database table. If an error is registered in [SYS][exceptionalErrors] it will be turned into an exception to be handled by the configured exceptionHandler.' + errorHandlerErrors: + type: errors + description: 'The E_* constant that will be handled by the [SYS][errorHandler]. Not all PHP error types can be handled! <code>E_USER_DEPRECATED</code> will always be handled, regardless of this setting. Default is 30466 = <code>E_ALL & ~(E_STRICT | E_NOTICE | E_COMPILE_WARNING | E_COMPILE_ERROR | E_CORE_WARNING | E_CORE_ERROR | E_PARSE | E_ERROR)</code> (see <a href="https://php.net/manual/en/errorfunc.constants.php" target="_blank" rel="noreferrer">PHP documentation</a>).' + exceptionalErrors: + type: errors + description: 'The E_* constant that will be converted into an exception by the default [SYS][errorHandler]. Default is 4096 = <code>E_ALL & ~(E_STRICT | E_NOTICE | E_COMPILE_WARNING | E_COMPILE_ERROR | E_CORE_WARNING | E_CORE_ERROR | E_PARSE | E_ERROR | E_DEPRECATED | E_USER_DEPRECATED | E_WARNING | E_USER_ERROR | E_USER_NOTICE | E_USER_WARNING)</code> (see <a href="https://php.net/manual/en/errorfunc.constants.php" target="_blank rel="noreferrer"">PHP documentation</a>). E_USER_DEPRECATED is always excluded to avoid exceptions to be thrown for deprecation messages.' + belogErrorReporting: + type: errors + description: 'Configures which PHP errors should be logged to the "syslog" database table (extension: belog). If set to "0" no PHP errors are logged to the sys_log table. Default is 30711 = <code>E_ALL & ~(E_STRICT | E_NOTICE)</code> (see <a href="https://php.net/manual/en/errorfunc.constants.php" target="_blank" rel="noreferrer">PHP documentation</a>).' + allowedPhpDisableFunctions: + type: element-list + description: 'A list of function names, which will not trigger an error but only a warning, if they can be found in your php.ini setting "disable_functions".' + generateApacheHtaccess: + type: bool + description: 'TYPO3 can create <em>.htaccess</em> files which are used by Apache Webserver. They are useful for access protection or performance improvements. Currently <em>.htaccess</em> files in the following directories are created, if they do not exist: <ul><li>typo3temp/var/log/</li></ul>You want to disable this feature, if you are not running Apache or want to use own rulesets.' + ipAnonymization: + type: int + allowedValues: + '0': 'Disabled - Do not modify IP addresses at all' + '1': 'Mask the last byte for IPv4 addresses / Mask the Interface ID for IPv6 addresses (default)' + '2': 'Mask the last two bytes for IPv4 addresses / Mask the Interface ID and SLA ID for IPv6 addresses' + description: 'Configures if and how IP addresses stored via TYPO3''s API should be anonymized ("masked") with a zero-numbered replacement.' + systemMaintainers: + type: array + description: 'A list of backend user IDs allowed to access the Install Tool' + SystemResources: + type: container + description: 'Options for system resources API' + items: + filesystemPublishingType: + type: dropdown + description: 'Can "mirror", "link", or "auto" (based on TYPO3_CONTEXT).' + allowedValues: + link: 'Link public resources to the public directory' + mirror: 'Mirror Resources/Public folder to the public directory (deletes stale public files)' + auto: 'Link in development context and mirror in production context' + features: + type: container + description: 'New features of TYPO3 that are activated on new installations but upgrading installations can still use the old behaviour' + items: + frontend.cache.autoTagging: + type: bool + description: 'Frontend caches are automatically tagged with the records they use, allowing affected caches to be evicted when those records change.' + extbase.enableHistoryTracking: + type: bool + description: 'Extbase persistence changes (update, add, delete) can be tracked in the general history (sys_history database table). When enabled, it can also be configured distinctly for each database table via TCA "[$tableName][ctrl][enableHistoryTracking]" (defaults to "true" / enabled). When the feature flag is disabled, the TCA setting is not evaluated.' + redirects.hitCount: + type: bool + description: 'Each performed redirect is counted and the time of the last hit is logged to the database when the <strong>Redirects</strong> extension is active.' + security.backend.htmlSanitizeRte: + type: bool + description: 'Rich-text content saved in the backend is processed with the HTML Sanitizer to remove potential cross-site scripting (XSS) from the markup.' + security.backend.enforceReferrer: + type: bool + description: 'HTTP referrer headers are enforced for backend and Install Tool requests to mitigate potential same-site request forgery attacks. This option can be disabled if HTTP proxies filter the required <code>Referer</code> header. Enabling this option is recommended.' + security.frontend.enforceContentSecurityPolicy: + type: bool + description: 'An HTTP <code>Content-Security-Policy</code> header is applied to all frontend requests. The policy can be overridden using a <code>csp.yaml</code> site configuration.' + security.frontend.reportContentSecurityPolicy: + type: bool + description: 'An HTTP <code>Content-Security-Policy-Report-Only</code> header is applied to all frontend requests. The policy can be overridden using a <code>csp.yaml</code> site configuration.' + security.frontend.allowInsecureFrameOptionInShowImageController: + type: bool + description: 'Allows the <code>tx_cms_showpic</code> eID script to accept the <code>frame</code> GET parameter without signature validation. This is not recommended, as it may allow uncontrolled resource consumption.' + security.frontend.allowInsecureSiteResolutionByQueryParameters: + type: bool + description: 'Site resolution can be overridden using the <code>&id=...&L=...</code> parameters. The URI path and host are then used only as defaults.' + security.system.enforceAllowedFileExtensions: + type: bool + description: 'Only file extensions configured in <code>textfile_ext,</code> <code>mediafile_ext</code>, and <code>miscfile_ext</code> are allowed to be processed by the File Abstraction Layer (FAL).' + security.system.enforceFileExtensionMimeTypeConsistency: + type: bool + description: 'Restricts file processing in the File Abstraction Layer (FAL) to files whose extension is consistent with their expected MIME type.' + rateLimiter: + type: array + description: 'Override rate limiter configuration by limiter ID. Each key is a limiter ID (e.g. "typo3-login-BE", "backend-password-recovery"), and each value is an array with keys like "limit", "interval", and/or "policy" to override the programmatic defaults.' + availablePasswordHashAlgorithms: + type: array + description: 'A list of available password hash mechanisms. Extensions may register additional mechanisms here. This is usually not extended in system/settings.php.' +LANG: + type: container + description: 'Language and Localization' + items: + loader: + type: map + description: | + Loader for localization files. Defaults to <code>['xlf' => 'TYPO3\\CMS\\Core\\Localization\\Loader\\XliffLoader']</code> for XLIFF files. + requireApprovedLocalizations: + type: bool + description: 'If set, translations are only taken into account if the according "approved" attribute is set to "yes" within the XLF file. Otherwise, all available translations are used.' + availableLocales: + type: element-list + description: 'Array of available locales for the system.' + resourceOverrides: + type: map + description: 'List of overrides for localization resources.' +EXT: + type: container + description: 'Extension Installation' + items: + excludeForPackaging: + type: list + description: 'List of directories and files which will not be packaged into extensions nor taken into account otherwise by the Extension Manager. Perl regular expression syntax!' +BE: + type: container + description: 'Backend' + items: + entryPoint: + type: text + description: 'URL slug for the TYPO3 backend. Defaults to "typo3".' + fileadminDir: + type: text + description: 'Path to the primary directory of files for editors. This is relative to the public web dir, DefaultStorage will be created with that configuration, do not access manually but via <code>\TYPO3\CMS\Core\Resource\ResourceFactory::getDefaultStorage().</code>' + lockRootPath: + type: element-list + description: 'List of absolute root path prefixes to be allowed for file operations (including FAL storages). The project root path is allowed in any case and does not need to be defined here. Ending slashes are enforced!' + lockBackendFile: + type: text + description: 'Optional file location to check whether the backend shall be locked. When not specified, uses the former default locations (legacy: "typo3conf/LOCK_BACKEND", composer "var/lock/LOCK_BACKEND"). Directory must be relative to the project root and must include the file name. The file must be writable for the PHP user and should be stored in a location that does not change between TYPO3 deployments (shared directory).' + userHomePath: + type: text + description: 'Combined folder identifier of the directory where TYPO3 backend-users have their home-dirs. A combined folder identifier looks like this: [storageUid]:[folderIdentifier]. Eg. <code>2:users/</code>. A home for backend user 2 would be: <code>2:users/2/</code>. Ending slash required!' + groupHomePath: + type: text + description: 'Combined folder identifier of the directory where TYPO3 backend-groups have their home-dirs. A combined folder identifier looks like this: [storageUid]:[folderIdentifier]. Eg. <code>2:groups/</code>. A home for backend group 1 would be: <code>2:groups/1/</code>. Ending slash required!' + userUploadDir: + type: text + description: 'Suffix to the user home dir which is what gets mounted in TYPO3. Eg. if the user dir is <code>../123_user/</code> and this value is <code>/upload</code> then <code>../123_user/upload</code> gets mounted.' + warning_email_addr: + type: text + description: 'Email address that will receive notification whenever an attempt to login to the Install Tool is made and that will also receive warnings whenever more than 3 failed backend login attempts (regardless of user) are detected within an hour.' + warning_mode: + type: int + allowedValues: + '0': 'Do not send notification-emails upon backend-login' + '1': 'Send a notification-email every time a backend user logs in' + '2': 'Send a notification-email every time an ADMIN backend user logs in' + description: 'Send emails to <code>warning_email_addr</code> upon backend-login' + passwordReset: + type: bool + description: 'Enable password reset functionality on the backend login for TYPO3 Backend users. Can be disabled for systems where only e.g. LDAP / OAuth login is allowed. Password reset will then still work on CLI and for admins in the backend.' + passwordResetForAdmins: + type: bool + description: 'Enable password reset functionality for TYPO3 Administrators. This will affect all places such as backend login or CLI. Disable this option for increased security.' + requireMfa: + type: int + allowedValues: + '0': 'Do not require multi-factor authentication' + '1': 'Require multi-factor authentication for all users' + '2': 'Require multi-factor authentication only for non-admin users' + '3': 'Require multi-factor authentication only for admin users' + '4': 'Require multi-factor authentication only for system maintainers' + description: 'Define users which should be required to set up multi-factor authentication.' + recommendedMfaProvider: + type: text + description: 'Set the identifier of the multi-factor authentication provider, recommended for all users.' + loginRateLimit: + type: int + description: 'Maximum amount of login attempts for the time interval in [BE][loginRateLimitInterval], before further login requests will be denied. Setting this value to "0" will disable login rate limiting.' + loginRateLimitInterval: + type: dropdown + allowedValues: + '1 minute': '1 minute' + '5 minutes': '5 minutes' + '15 minutes': '15 minutes' + '30 minutes': '30 minutes' + description: 'Allowed time interval for the configured rate limit. Individual values using PHP relative formats can be set in system/additional.php.' + loginRateLimitIpExcludeList: + type: list + description: 'IP-numbers (with *-wildcards) that are excluded from rate limiting. Syntax similar to [BE][IPmaskList]. An empty value disables the exclude list check.' + passwordPolicy: + type: text + description: 'Name of the password policy to use.' + lockIP: + type: int + allowedValues: + '0': 'Default: Do not lock Backend User sessions to their IP address at all' + '1': 'Use the first part of the editors'' IPv4 address (e.g. "192.") as part of the session locking of Backend Users' + '2': 'Use the first two parts of the editors'' IPv4 address (e.g. "192.168") as part of the session locking of Backend Users' + '3': 'Use the first three parts of the editors'' IPv4 address (e.g. "192.168.13") as part of the session locking of Backend Users' + '4': 'Use the editors'' full IPv4 address (e.g. "192.168.13.84") as part of the session locking of Backend Users (highest security)' + description: 'Session IP locking for backend users. See <a href="#FE-lockIP">[FE][lockIP]</a> for details.' + lockIPv6: + type: int + allowedValues: + '0': 'Default: Do not lock Backend User sessions to their IP address at all' + '1': 'Use the first block (16 bits) of the editors'' IPv6 address (e.g. "2001:") as part of the session locking of Backend Users' + '2': 'Use the first two blocks (32 bits) of the editors'' IPv6 address (e.g. "2001:0db8") as part of the session locking of Backend Users' + '3': 'Use the first three blocks (48 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3") as part of the session locking of Backend Users' + '4': 'Use the first four blocks (64 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3") as part of the session locking of Backend Users' + '5': 'Use the first five blocks (80 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319") as part of the session locking of Backend Users' + '6': 'Use the first six blocks (96 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e") as part of the session locking of Backend Users' + '7': 'Use the first seven blocks (112 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e:0370") as part of the session locking of Backend Users' + '8': 'Use the editors'' full IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e:0370:7344") as part of the session locking of Backend Users (highest security)' + description: 'Session IPv6 locking for backend users. See <a href="#FE-lockIPv6">[FE][lockIPv6]</a> for details.' + sessionTimeout: + type: int + description: 'Session time out for backend users in seconds. The value must be at least 180 to avoid side effects. Default is 28.800 seconds = 8 hours.' + IPmaskList: + type: list + description: 'Lets you define a list of IP-numbers (in CIDR-notation, e.g. 194.168.0.0/16,2002::1234:abcd:ffff:c0a8:101/64) that are the ONLY ones allowed access to ANY backend activity. On error an error header is sent and the script exits. Works like IP masking for users configurable through TSconfig. See syntax for that (or look up syntax for the function <code>\TYPO3\CMS\Core\Utility\GeneralUtility::cmpIP())</code>' + lockSSL: + type: bool + description: 'If set, the backend can only be operated from an SSL-encrypted connection (https). A redirect to the SSL version of a URL will happen when a user tries to access non-https admin-urls' + lockSSLPort: + type: int + description: 'Use a non-standard HTTPS port for lockSSL. Set this value if you use lockSSL and the HTTPS port of your webserver is not 443.' + cookieDomain: + type: text + description: 'Same as <a href="#SYS-cookieDomain">$TYPO3_CONF_VARS[''SYS''][''cookieDomain'']</a> but only for BE cookies. If empty, $TYPO3_CONF_VARS[''SYS''][''cookieDomain''] value will be used.' + cookieName: + type: text + description: 'Set the name for the cookie used for the back-end user session' + cookieSameSite: + type: text + allowedValues: + 'lax': 'Cookies set by TYPO3 are only available for the current site, third-party integrations are not allowed to read cookies, except for links and simple HTML forms' + 'strict': 'Cookies sent by TYPO3 are only available for the current site, never shared to other third-party packages' + 'none': 'Allow cookies set by TYPO3 to be sent to other sites as well, please note - this only works with HTTPS connections' + description: 'Indicates that the cookie should send proper information where the cookie can be shared (first-party cookies vs. third-party cookies) in TYPO3 Backend.' + showRefreshLoginPopup: + type: bool + description: 'If set, the Ajax relogin will show a real popup window for relogin after the count down. Some auth services need this as they add custom validation to the login form. If it''s not set, the Ajax relogin will show an inline relogin window.' + adminOnly: + type: int + allowedValues: + '-1': 'Total shutdown for maintenance purposes' + '0': 'Default: All users can access the TYPO3 Backend' + '1': 'Only administrators / system maintainers can log in, CLI interface is disabled as well' + '2': 'Only administrators / system maintainers have access to the TYPO3 Backend, CLI executions are allowed as well' + description: 'Restricts access to the TYPO3 Backend - especially useful when doing maintenance or updates' + disable_exec_function: + type: bool + description: 'Don''t use exec() function (except for ImageMagick which is disabled by <a href="#GFX-im">[GFX][im]</a>=0). If set, all file operations are done by the default PHP-functions. This is necessary under Windows! On Unix the system commands by exec() can be used, unless this is disabled.' + contentSecurityPolicyReportingUrl: + type: text + description: 'Content-Security-Policy reporting HTTP endpoint. If blank (""), the system default will be used. If set to zero ("0"), the reporting endpoint is disabled.' + fileDenyPattern: + type: text + readonly: true + description: 'A perl-compatible and JavaScript-compatible regular expression (without delimiters "/"!) that - if it matches a filename - will deny the file upload/rename or whatever. For security reasons, files with multiple extensions have to be denied on an Apache environment with mod_alias, if the filename contains a valid php handler in an arbitrary position. Also, ".htaccess" files have to be denied. Matching is done case-insensitive. Default value is stored in PHP constant FILE_DENY_PATTERN_DEFAULT' + versionNumberInFilename: + type: bool + description: | + If enabled, included asset files (like CSS, JS, SVG and other resources) loaded in the TYPO3 Backend will have their modification timestamp embedded in the referenced filename, ie. <code>filename.1269312081.js</code>. + This will make browsers and proxies reload the files if they change (thus avoiding caching issues). + <strong>IMPORTANT:</strong> This feature requires extra <code>.htaccess</code> rules to work (please refer to the <code>typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/root-htaccess</code> file shipped with TYPO3).<br /> + If disabled, the last modification date of the file will be appended as a query-string. + debug: + type: bool + description: 'If enabled, the loginrefresh is disabled and pageRenderer is set to debug mode. Furthermore the fieldname is appended to the label of fields. Use this to debug the backend only!' + passwordHashing: + type: container + items: + className: + type: dropdown + allowedValues: + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash': 'Good password hash mechanism. Used by default if available.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash': 'Good password hash mechanism.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash': 'Good password hash mechanism.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash': 'Fallback hash mechanism if argon and bcrypt are not available.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash': 'Fallback hash mechanism if none of the above are available.' + options: + type: array + description: 'Special settings for specific hashes.' +FE: + type: container + description: 'Frontend' + items: + debug: + type: bool + description: 'If enabled, the total parsetime of the page is added as HTTP response header "X-TYPO3-Parsetime". This can also be enabled/disabled via the TypoScript option <code>config.debug = 0</code>.' + pageNotFoundOnCHashError: + type: bool + description: 'If TRUE, a page not found call is made when cHash evaluation error occurs, otherwise caching is disabled and page output is displayed.' + pageUnavailable_force: + type: bool + description: 'If TRUE, every frontend page is shown as "unavailable". If the client matches <a href="#SYS-devIPmask">[SYS][devIPmask]</a>, the page is shown as normal. This is useful during temporary site maintenance.' + checkFeUserPid: + type: bool + description: 'If set, the pid of fe_user logins must be sent in the form as the field ''pid'' and then the user must be located in the pid. If you unset this, you should change the fe_users.username eval-flag ''uniqueInPid'' to ''unique'' in $TCA. This will do: <code>$TCA[''fe_users''][''columns''][''username''][''config''][''eval'']= ''nospace,lower,required,unique'';</code>' + loginRateLimit: + type: int + description: 'Maximum amount of login attempts for the time interval in [FE][loginRateLimitInterval], before further login requests will be denied. Setting this value to "0" will disable login rate limiting.' + loginRateLimitInterval: + type: dropdown + allowedValues: + '1 minute': '1 minute' + '5 minutes': '5 minutes' + '15 minutes': '15 minutes' + '30 minutes': '30 minutes' + description: 'Allowed time interval for the configured rate limit. Individual values using PHP relative formats can be set in system/additional.php.' + loginRateLimitIpExcludeList: + type: list + description: 'IP-numbers (with *-wildcards) that are excluded from rate limiting. Syntax similar to [BE][IPmaskList]. An empty value disables the exclude list check.' + passwordPolicy: + type: text + description: 'Name of the password policy to use.' + lockIP: + type: int + allowedValues: + '0': 'Default: Do not lock Frontend User sessions to their IP address at all' + '1': 'Use the first part of the visitors'' IPv4 address (e.g. "192.") as part of the session locking of Frontend Users' + '2': 'Use the first two parts of the visitors'' IPv4 address (e.g. "192.168") as part of the session locking of Frontend Users' + '3': 'Use the first three parts of the visitors'' IPv4 address (e.g. "192.168.13") as part of the session locking of Frontend Users' + '4': 'Use the visitors'' full IPv4 address (e.g. "192.168.13.84") as part of the session locking of Frontend Users (highest security)' + description: 'If activated, Frontend Users are locked to (a part of) their public IP (<code>$_SERVER[''REMOTE_ADDR'']</code>) for their session, if REMOTE_ADDR is an IPv4-address. Enhances security but may throw off users that may change IP during their session (in which case you can lower it). The integer indicates how many parts of the IP address to include in the check for the session.' + lockIPv6: + type: int + allowedValues: + '0': 'Default: Do not lock Backend User sessions to their IP address at all' + '1': 'Use the first block (16 bits) of the editors'' IPv6 address (e.g. "2001:") as part of the session locking of Backend Users' + '2': 'Use the first two blocks (32 bits) of the editors'' IPv6 address (e.g. "2001:0db8") as part of the session locking of Backend Users' + '3': 'Use the first three blocks (48 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3") as part of the session locking of Backend Users' + '4': 'Use the first four blocks (64 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3") as part of the session locking of Backend Users' + '5': 'Use the first five blocks (80 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319") as part of the session locking of Backend Users' + '6': 'Use the first six blocks (96 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e") as part of the session locking of Backend Users' + '7': 'Use the first seven blocks (112 bits) of the editors'' IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e:0370") as part of the session locking of Backend Users' + '8': 'Use the visitors'' full IPv6 address (e.g. "2001:0db8:85a3:08d3:1319:8a2e:0370:7344") as part of the session locking of Backend Users (highest security)' + description: 'If activated, Frontend Users are locked to (a part of) their public IP (<code>$_SERVER[''REMOTE_ADDR'']</code>) for their session, if REMOTE_ADDR is an IPv6-address. Enhances security but may throw off users that may change IP during their session (in which case you can lower it). The integer indicates how many parts of the IP address to include in the check for the session.' + lifetime: + type: int + description: 'If >0 and the option permalogin is >=0, the cookie of FE users will have a lifetime of the number of seconds this value indicates. Otherwise it will be a session cookie (deleted when browser is shut down). Setting this value to 604800 will result in automatic login of FE users during a whole week, 86400 will keep the FE users logged in for a day.' + sessionTimeout: + type: int + description: 'Server side session timeout for frontend users in seconds. Will be overwritten by the lifetime property if the lifetime is longer.' + sessionDataLifetime: + type: int + description: 'If >0, the session data of an anonymous session will timeout and be removed after the number of seconds given (86400 seconds represents 24 hours).' + permalogin: + type: text + description: '<dl><dt>-1</dt><dd>Permanent login for FE users is disabled.</dd><dt>0</dt><dd>By default permalogin is disabled for FE users but can be enabled by a form control in the login form.</dd><dt>1</dt><dd>Permanent login is by default enabled but can be disabled by a form control in the login form.</dd><dt>2</dt><dd>Permanent login is forced to be enabled.</dd></dl> In any case, permanent login is only possible if <a href="#FE-lifetime">[FE][lifetime]</a> lifetime is > 0.' + cookieDomain: + type: text + description: 'Same as <a href="#SYS-cookieDomain">$TYPO3_CONF_VARS[''SYS''][''cookieDomain'']</a> but only for FE cookies. If empty, $TYPO3_CONF_VARS[''SYS''][''cookieDomain''] value will be used.' + cookieName: + type: text + description: 'Set the name for the cookie used for the front-end user session' + cookieSameSite: + type: text + allowedValues: + 'lax': 'Cookies set by TYPO3 are only available for the current site, third-party integrations are not allowed to read cookies, except for links and simple HTML forms' + 'strict': 'Cookies sent by TYPO3 are only available for the current site, never shared to other third-party packages' + 'none': 'Allow cookies set by TYPO3 to be sent to other sites as well, please note - this only works with HTTPS connections' + description: 'Indicates that the cookie should send proper information where the cookie can be shared (first-party cookies vs. third-party cookies) in TYPO3 Frontend.' + contentSecurityPolicyReportingUrl: + type: text + description: 'Content-Security-Policy reporting HTTP endpoint. If blank (""), the system default will be used. If set to zero ("0"), the reporting endpoint is disabled.' + defaultTypoScript_constants: + type: multiline + description: 'Enter lines of default TypoScript, constants-field.' + compareValuesWithCurrentConfiguration: false + defaultTypoScript_setup: + type: multiline + description: 'Enter lines of default TypoScript, setup-field.' + compareValuesWithCurrentConfiguration: false + enable_mount_pids: + type: bool + description: 'If enabled, the mount_pid feature allowing ''symlinks'' in the page tree (for frontend operation) is allowed.' + hidePagesIfNotTranslatedByDefault: + type: bool + description: 'If enabled, pages that has no translation will be hidden by default. Basically this will inverse the effect of the page localization setting "Hide page if no translation for current language exists" to "Show page even if no translation exists"' + disableNoCacheParameter: + type: bool + description: 'If set, the no_cache request parameter will become ineffective. This is currently still an experimental feature and will require a website only with plugins that don''t use this parameter. However, using "&no_cache=1" should be avoided anyway because there are better ways to disable caching for a certain part of the website (see COA_INT/USER_INT documentation in TSref).' + cacheHash: + type: container + items: + cachedParametersWhiteList: + type: array + description: 'Only the given parameters will be evaluated in the cHash calculation. Example: tx_news_pi1[uid]' + requireCacheHashPresenceParameters: + type: array + description: 'Configure Parameters that require a cHash. If no cHash is given but one of the parameters are set, then TYPO3 triggers the configured cHash Error behaviour' + excludedParameters: + type: array + description: 'The given parameters will be ignored in the cHash calculation. Example: L,tx_search_pi1[query]' + excludedParametersIfEmpty: + type: array + description: 'Configure Parameters that are only relevant for the cHash if there''s an associated value available. Set excludeAllEmptyParameters to true to skip all empty parameters.' + excludeAllEmptyParameters: + type: bool + description: 'If true, all parameters which are relevant for cHash are only considered if they are non-empty.' + enforceValidation: + type: bool + description: 'If true, a cHash parameter is always required if a query parameter is included in the query string of the incoming request, unless the query argument is included in the excludedParameters list.' + fallbackToLegacyHash: + type: bool + description: 'If true, legacy cHash values (based on MD5) are accepted during frontend requests as fallback.' + additionalCanonicalizedUrlParameters: + type: array + description: The given parameters will be included when calculating canonicalized URL + workspacePreviewLogoutTemplate: + type: text + description: 'If set, points to an HTML file relative to the TYPO3_site root which will be read and outputted as template for this message. Example: <code>fileadmin/templates/template_workspace_preview_logout.html</code>. Inside you can put the marker %1$s to insert the URL to go back to. Use this in <code><a href="%1$s">Go back...</a></code> links.' + versionNumberInFilename: + type: bool + description: | + If enabled, included asset files (like CSS, JS, SVG and other resources) loaded in the TYPO3 Frontend will have their modification timestamp embedded in the referenced filename, ie. <code>filename.1269312081.js</code>. + This will make browsers and proxies reload the files if they change (thus avoiding caching issues). + <strong>IMPORTANT:</strong> This feature requires extra <code>.htaccess</code> rules to work (please refer to the <code>typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/root-htaccess</code> file shipped with TYPO3).<br /> + If disabled, the last modification date of the file will be appended as a query-string. + passwordHashing: + type: container + items: + className: + type: dropdown + allowedValues: + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash': 'Good password hash mechanism. Used by default if available.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash': 'Good password hash mechanism.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash': 'Good password hash mechanism.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash': 'Fallback hash mechanism if argon and bcrypt are not available.' + 'TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash': 'Fallback hash mechanism if none of the above are available.' + options: + type: array + description: 'Special settings for specific hashes.' + exposeRedirectInformation: + type: bool + description: 'If set, redirects executed by TYPO3 publicly expose the page ID in the HTTP header. As this is an internal information about the TYPO3 system, it should only be enabled for debugging purposes.' + +MAIL: + type: container + description: 'Mail' + items: + format: + type: dropdown + allowedValues: + 'html': 'Send emails only in HTML format' + 'plain': 'Send emails only in plain text format' + 'both': 'Send emails in HTML and plain text format' + description: 'The Mailer API allows to send out templated emails, which can be configured on a system-level to send out HTML-based emails or plain text emails, or emails with both variants.' + layoutRootPaths: + type: array + description: 'List of paths to look for layouts for templated emails. Should be specified as .txt and .html files.' + partialRootPaths: + type: array + description: 'List of paths to look for partials for templated emails. Should be specified as .txt and .html files.' + templateRootPaths: + type: array + description: 'List of paths to look for template files for templated emails. Should be specified as .txt and .html files.' + validators: + type: array + description: 'List of validators used to validate an email address.<br>TYPO3 bundles all available validators of the packagist package <code>egulias/email-validator</code>, namespace <code>\Egulias\EmailValidator\Validation\</code>. Currently these are:<br><ul><li><code>\Egulias\EmailValidator\Validation\DNSCheckValidation</code></li><li><code>\Egulias\EmailValidator\Validation\NoRFCWarningsValidation</code></li><li><code>\Egulias\EmailValidator\Validation\RFCValidation</code> (enabled by default)</li><li><code>\Egulias\EmailValidator\Validation\SpoofCheckValidation</code></li></ul>Custom validators can be implemented and listed here as well.' + transport: + type: text + description: '<dl><dt>smtp</dt><dd>Sends messages over the (standardized) Simple Message Transfer Protocol. It can deal with encryption and authentication. Most flexible option, requires a mail server and configurations in transport_smtp_* settings below. Works the same on Windows, Unix and MacOS.</dd><dt>sendmail</dt><dd>Sends messages by communicating with a locally installed MTA - such as sendmail. See setting transport_sendmail_command bellow.<dd><dt>dsn</dt><dd>Sends messages with the Symfony Mailer. Configure [MAIL][dsn] setting below.</dd><dt>mbox</dt><dd>This doesn''t send any mail out, but instead will write every outgoing mail to a file adhering to the RFC 4155 mbox format, which is a simple text file where the mails are concatenated. Useful for debugging the mail sending process and on development machines which cannot send mails to the outside. Configure the file to write to in the ''transport_mbox_file'' setting below</dd><dt><classname></dt><dd>Custom class which implements \Symfony\Component\Mailer\Transport\TransportInterface. The constructor receives all settings from the MAIL section to make it possible to add custom settings.</dd></dl>' + transport_smtp_server: + type: text + description: '<em>only with transport=smtp</em>: <server:port> of mailserver to connect to. <port> defaults to "25".' + transport_smtp_encrypt: + type: bool + description: '<em>only with transport=smtp</em>: Connect to the server using SSL/TLS (disables STARTTLS which is used by default if supported by the server). Must not be enabled when connecting to port 587, as servers will use STARTTLS (inner encryption) via SMTP instead of SMTPS. It will automatically be enabled if port is 465.' + transport_smtp_username: + type: text + description: '<em>only with transport=smtp</em>: If your SMTP server requires authentication, enter your username here.' + transport_smtp_password: + type: password + description: '<em>only with transport=smtp</em>: If your SMTP server requires authentication, enter your password here.' + transport_smtp_domain: + type: text + description: '<em>only with transport=smtp</em>: Mail domain under which emails will be sent.' + transport_smtp_restart_threshold: + type: int + description: '<em>only with transport=smtp</em>: Sets the maximum number of messages to send before re-starting the transport.' + transport_smtp_restart_threshold_sleep: + type: int + description: '<em>only with transport=smtp</em>: The number of seconds to sleep between stopping and re-starting the transport.' + transport_smtp_ping_threshold: + type: int + description: '<em>only with transport=smtp</em>: Sets the minimum number of seconds required between two messages, before the server is pinged.' + transport_smtp_stream_options: + type: mixed + description: | + <em>only with transport=smtp</em>: Sets the stream context options for the smtp stream<br /> + The configuration with an array must be made in the <code>system/additional.php</code>; see <a href="https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Configuration/Typo3ConfVars/Index.html#file-additionalconfiguration-php" target="_blank" rel="noreferrer">the documentation</a> for details.<br /> + transport_sendmail_command: + type: text + description: '<em>only with transport=sendmail</em>: The command to call to send a mail locally.' + readonly: true + transport_mbox_file: + type: text + description: '<em>only with transport=mbox</em>: The file where to write the mails into. This file will be conforming the mbox format described in RFC 4155. It is a simple text file with a concatenation of all mails. Path must be absolute.' + transport_spool_type: + type: text + description: '<dl><dt>file</dt><dd>Messages get stored to the file system till they get sent through the command mailer:spool:send.</dd><dt>memory</dt><dd>Messages get sent at the end of the running process.</dd><dt><classname></dt><dd>Custom class which implements the \TYPO3\CMS\Core\Mail\DelayedTransportInterface interface.</dd></dl>' + transport_spool_filepath: + type: text + description: '<em>only with transport_spool_type=file</em>: Path where messages get temporarily stored. Ensure that this is stored outside of your webroot.' + dsn: + type: text + description: '<em>only with transport=dsn</em>: The DSN configuration of the Symfony mailer (eg. smtp://user:pass@smtp.example.com:25). For 3rd party transports you have to add additional dependencies. See https://symfony.com/doc/current/mailer.html for more details.' + defaultMailFromAddress: + type: text + description: 'This default email address is used when no other "from" address is set for a TYPO3-generated email. You can specify an email address only (eg. info@example.org).' + defaultMailFromName: + type: text + description: 'This default name is used when no other "from" name is set for a TYPO3-generated email.' + defaultMailReplyToAddress: + type: text + description: 'This default email address is used when no other "reply-to" address is set for a TYPO3-generated email. You can specify an email address only (eg. info@example.org).' + defaultMailReplyToName: + type: text + description: 'This default name is used when no other "reply-to" name is set for a TYPO3-generated email.' +HTTP: + type: container + description: 'Connection' + items: + cert: + type: mixed + description: 'Mixed: Set to a string to specify the path to a file containing a PEM formatted client side certificate. See http://docs.guzzlephp.org/en/latest/request-options.html#cert' + connect_timeout: + type: int + description: 'Default timeout for connection. Exception will be thrown if connecting to remote host takes more than this number of seconds.' + proxy: + type: mixed + description: | + Default single proxy server as "proxy.example.org".<br /> + Multiple proxies for different protocols can be added separately as array as well as authentication and port; see <a href="http://docs.guzzlephp.org/en/latest/request-options.html#proxy" target="_blank" rel="noreferrer">the documentation</a> for details.<br /> + The configuration with an array must be made in the <code>system/additional.php</code>; see <a href="https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/GlobalValues/Typo3ConfVars/Index.html#file-additionalconfiguration-php" target="_blank" rel="noreferrer">the documentation</a> for details.<br /> + ssl_key: + type: mixed + description: 'Mixed: Local certificate and an optional passphrase, see http://docs.guzzlephp.org/en/latest/request-options.html#ssl-key' + timeout: + type: int + description: 'Default timeout for whole request. Exception will be thrown if sending the request takes more than this number of seconds. Should be greater than connection timeout (see above) or "0" to not set a limit. Defaults to "0".' + verify: + type: mixed + description: 'Mixed: Describes the SSL certificate verification behavior of a request. http://docs.guzzlephp.org/en/latest/request-options.html#verify' + version: + type: text + description: 'Default HTTP protocol version. Use either "1.0" or "1.1".' diff --git a/Configuration/DefaultPackageResources.php b/Configuration/DefaultPackageResources.php new file mode 100644 index 0000000..679ccaf --- /dev/null +++ b/Configuration/DefaultPackageResources.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +use TYPO3\CMS\Core\Package\Package; +use TYPO3\CMS\Core\Package\Resource\Definition\PublicResourceDefinition; +use TYPO3\CMS\Core\Package\Resource\Definition\ResourceDefinition; + +return static function (Package $package) { + $resourceDefinitions = [ + new ResourceDefinition('Resources/Private'), + ]; + if (is_dir($package->getPackagePath() . 'Resources/Public')) { + $resourceDefinitions[] = new PublicResourceDefinition('Resources/Public'); + } + return $resourceDefinitions; +}; diff --git a/Configuration/ExpressionLanguage.php b/Configuration/ExpressionLanguage.php new file mode 100644 index 0000000..12bd2db --- /dev/null +++ b/Configuration/ExpressionLanguage.php @@ -0,0 +1,10 @@ +<?php + +return [ + 'typoscript' => [ + \TYPO3\CMS\Core\ExpressionLanguage\TypoScriptConditionProvider::class, + ], + 'routing' => [ + \TYPO3\CMS\Core\ExpressionLanguage\RoutingConditionProvider::class, + ], +]; diff --git a/Configuration/FactoryConfiguration.php b/Configuration/FactoryConfiguration.php new file mode 100644 index 0000000..add6cae --- /dev/null +++ b/Configuration/FactoryConfiguration.php @@ -0,0 +1,33 @@ +<?php + +/** + * This is a boilerplate of %config-dir%/system/settings.php. It is + * used as base file during installation and can be overloaded with + * a package specific file %config-dir%/system/additional.php + * from eg. the government or introduction package. + */ +return [ + 'DB' => [ + 'Connections' => [ + 'Default' => [ + 'charset' => 'utf8', + 'driver' => 'mysqli', + ], + ], + ], + 'FE' => [ + 'disableNoCacheParameter' => true, + 'cacheHash' => [ + 'enforceValidation' => true, + ], + ], + 'SYS' => [ + 'sitename' => 'New TYPO3 site', + 'UTF8filesystem' => true, + 'features' => [ + 'frontend.cache.autoTagging' => true, + // only file extensions configured in 'textfile_ext', 'mediafile_ext', 'miscfile_ext' are accepted + 'security.system.enforceAllowedFileExtensions' => true, + ], + ], +]; diff --git a/Configuration/Fluid/Namespaces.php b/Configuration/Fluid/Namespaces.php new file mode 100644 index 0000000..2854a16 --- /dev/null +++ b/Configuration/Fluid/Namespaces.php @@ -0,0 +1,7 @@ +<?php + +return [ + 'core' => [ + 'TYPO3\\CMS\\Core\\ViewHelpers', + ], +]; diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php new file mode 100644 index 0000000..be7164b --- /dev/null +++ b/Configuration/JavaScriptModules.php @@ -0,0 +1,50 @@ +<?php + +return [ + 'dependencies' => [], + 'imports' => [ + '@typo3/core/' => [ + 'path' => 'EXT:core/Resources/Public/JavaScript/', + 'exclude' => [ + 'EXT:core/Resources/Public/JavaScript/Contrib/', + ], + ], + 'autosize' => 'EXT:core/Resources/Public/JavaScript/Contrib/autosize.js', + 'cropperjs' => 'EXT:core/Resources/Public/JavaScript/Contrib/cropperjs.js', + 'css-tree' => 'EXT:core/Resources/Public/JavaScript/Contrib/css-tree.js', + 'dompurify' => 'EXT:core/Resources/Public/JavaScript/Contrib/dompurify.js', + 'flatpickr' => 'EXT:core/Resources/Public/JavaScript/Contrib/flatpickr.js', + 'flatpickr/' => 'EXT:core/Resources/Public/JavaScript/Contrib/flatpickr/', + 'flatpickr/dist/l10n' => 'EXT:core/Resources/Public/JavaScript/Contrib/flatpickr/dist/l10n.js', + // legacy, has ben renamed 'flatpickr/dist/l10n' + 'flatpickr/locales' => 'EXT:core/Resources/Public/JavaScript/Contrib/flatpickr/dist/l10n.js', + 'interactjs' => 'EXT:core/Resources/Public/JavaScript/Contrib/interactjs.js', + 'intl-messageformat' => 'EXT:core/Resources/Public/JavaScript/Contrib/intl-messageformat.js', + '@lit/reactive-element' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-element.js', + '@lit/reactive-element/' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit/reactive-element/', + '@lit/task' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit/task/index.js', + '@lit/task/' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit/task/', + // @internal @lib-labs/motion shall not be used by extensions yet + '@lit-labs/motion' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit-labs/motion/index.js', + '@lit-labs/motion/' => 'EXT:core/Resources/Public/JavaScript/Contrib/@lit-labs/motion/', + 'lit' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit/index.js', + 'lit/' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit/', + 'lit-element' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit-element/index.js', + 'lit-element/' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit-element/', + 'lit-html' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit-html/lit-html.js', + 'lit-html/' => 'EXT:core/Resources/Public/JavaScript/Contrib/lit-html/', + 'luxon' => 'EXT:core/Resources/Public/JavaScript/Contrib/luxon.js', + 'nprogress' => 'EXT:core/Resources/Public/JavaScript/Contrib/nprogress.js', + 'marked' => 'EXT:core/Resources/Public/JavaScript/Contrib/marked.js', + 'shortcut-buttons-flatpickr' => 'EXT:core/Resources/Public/JavaScript/Contrib/shortcut-buttons-flatpickr.js', + // legacy, has ben renamed 'shortcut-buttons-flatpickr' + 'flatpickr/plugins/shortcut-buttons.min.js' => 'EXT:core/Resources/Public/JavaScript/Contrib/shortcut-buttons-flatpickr.js', + 'sortablejs' => 'EXT:core/Resources/Public/JavaScript/Contrib/sortablejs.js', + 'tablesort' => 'EXT:core/Resources/Public/JavaScript/Contrib/tablesort.js', + // legacy, bundled into `tablesort`, kept to minimize likelihood of breaking 3rd party extensions + 'tablesort.dotsep.js' => 'EXT:core/Resources/Public/JavaScript/Contrib/tablesort.js', + // legacy, bundled into `tablesort`, kept to minimize likelihood of breaking 3rd party extensions + 'tablesort.number.js' => 'EXT:core/Resources/Public/JavaScript/Contrib/tablesort.js', + 'taboverride' => 'EXT:core/Resources/Public/JavaScript/Contrib/taboverride.js', + ], +]; diff --git a/Configuration/RTE/SysNews.yaml b/Configuration/RTE/SysNews.yaml new file mode 100644 index 0000000..24bbbda --- /dev/null +++ b/Configuration/RTE/SysNews.yaml @@ -0,0 +1,33 @@ +# @internal Only to be used for sys_news + +imports: + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Processing.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml' } + +editor: + config: + + toolbarGroups: + - { name: basicstyles, groups: [ basicstyles ] } + - { name: paragraph, groups: [ list, indent, blocks, align ] } + - { name: links, groups: [ links ] } + - { name: clipboard, groups: [clipboard, undo] } + - { name: document, groups: [ mode ] } + - { name: fullscreen, groups: [ fullscreen ] } + + removeButtons: + - Anchor + + removePlugins: + - Heading + - Strikethrough + - Table + - TableToolbar + - TableProperties + - TableCellProperties + - TableCaption + - Underline + + importModules: + - { module: '@ckeditor/ckeditor5-fullscreen', exports: [ 'Fullscreen' ] } diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..0295438 --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,34 @@ +<?php + +/** + * An array consisting of implementations of middlewares for a middleware stack to be registered + * + * 'stackname' => [ + * 'middleware-identifier' => [ + * 'target' => classname or callable + * 'before/after' => array of dependencies + * ] + * ] + */ +return [ + 'core' => [ + /** internal: do not use or reference this middleware in your own code */ + 'typo3/cms-core/verify-host-header' => [ + 'target' => \TYPO3\CMS\Core\Middleware\VerifyHostHeader::class, + ], + /** internal: do not use or reference this middleware in your own code */ + 'typo3/cms-core/normalized-params-attribute' => [ + 'target' => \TYPO3\CMS\Core\Middleware\NormalizedParamsAttribute::class, + 'after' => [ + 'typo3/cms-core/verify-host-header', + ], + ], + /** internal: do not use or reference this middleware in your own code */ + 'typo3/cms-core/response-propagation' => [ + 'target' => \TYPO3\CMS\Core\Middleware\ResponsePropagation::class, + 'after' => [ + 'typo3/cms-core/verify-host-header', + ], + ], + ], +]; diff --git a/Configuration/Resource/Driver/LocalDriverFlexForm.xml b/Configuration/Resource/Driver/LocalDriverFlexForm.xml new file mode 100644 index 0000000..cd494d2 --- /dev/null +++ b/Configuration/Resource/Driver/LocalDriverFlexForm.xml @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="utf-8"?> +<T3DataStructure> + <ROOT> + <type>array</type> + <el> + <basePath> + <label>core.db.sys_file_storage:local_driver.base_path</label> + <description>core.db.sys_file_storage:local_driver.base_path_placeholder</description> + <config> + <type>input</type> + <required>1</required> + <size>30</size> + </config> + </basePath> + <pathType> + <label>core.db.sys_file_storage:local_driver.path_type</label> + <config> + <type>radio</type> + <items type="array"> + <numIndex index="0" type="array"> + <label>core.db.sys_file_storage:local_driver.path_type_relative</label> + <value>relative</value> + </numIndex> + <numIndex index="1" type="array"> + <label>core.db.sys_file_storage:local_driver.path_type_absolute</label> + <value>absolute</value> + </numIndex> + </items> + <default>relative</default> + </config> + </pathType> + <baseUri> + <label>core.db.sys_file_storage:local_driver.base_uri</label> + <description>core.db.sys_file_storage:local_driver.base_uri_placeholder</description> + <config> + <type>input</type> + <size>30</size> + </config> + </baseUri> + <caseSensitive> + <label>core.db.sys_file_storage:local_driver.case_sensitive</label> + <config> + <type>check</type> + <default>1</default> + </config> + </caseSensitive> + </el> + </ROOT> +</T3DataStructure> diff --git a/Configuration/Services.php b/Configuration/Services.php new file mode 100644 index 0000000..12b7982 --- /dev/null +++ b/Configuration/Services.php @@ -0,0 +1,167 @@ +<?php + +declare(strict_types=1); + +namespace TYPO3\CMS\Core; + +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerAwareInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use TYPO3\CMS\Core\Attribute\AsAllowedCallable; +use TYPO3\CMS\Core\Attribute\AsEventListener; +use TYPO3\CMS\Core\Attribute\AsFileRenderer; +use TYPO3\CMS\Core\Attribute\AsModuleAccessGate; +use TYPO3\CMS\Core\Attribute\UpgradeWizard; +use TYPO3\CMS\Core\Imaging\IconProviderInterface; +use TYPO3\CMS\Core\SystemResource\Publishing\FileSystem\FileSystemPublisherInterface; + +return static function (ContainerConfigurator $container, ContainerBuilder $containerBuilder) { + $containerBuilder->registerForAutoconfiguration(SingletonInterface::class)->addTag('typo3.singleton'); + $containerBuilder->registerForAutoconfiguration(LoggerAwareInterface::class)->addTag('psr.logger_aware'); + $containerBuilder->registerForAutoconfiguration(FileSystemPublisherInterface::class)->addTag('asset.filesystem_publisher'); + + // Services, to be read from container-aware dispatchers (on demand), therefore marked 'public' + $containerBuilder->registerForAutoconfiguration(MiddlewareInterface::class)->addTag('typo3.middleware'); + $containerBuilder->registerForAutoconfiguration(RequestHandlerInterface::class)->addTag('typo3.request_handler'); + + // Icon providers are resolved via the container by IconFactory (on demand), therefore marked 'public' + $containerBuilder->registerForAutoconfiguration(IconProviderInterface::class)->addTag('icon.provider'); + + $containerBuilder->registerAttributeForAutoconfiguration( + AsEventListener::class, + static function (ChildDefinition $definition, AsEventListener $attribute, \Reflector $reflector): void { + $definition->addTag( + AsEventListener::TAG_NAME, + [ + 'identifier' => $attribute->identifier, + 'event' => $attribute->event, + 'method' => $attribute->method ?? ($reflector instanceof \ReflectionMethod ? $reflector->getName() : null), + 'before' => $attribute->before, + 'after' => $attribute->after, + ] + ); + } + ); + + $containerBuilder->registerAttributeForAutoconfiguration( + AsMessageHandler::class, + static function (ChildDefinition $definition, AsMessageHandler $attribute, \Reflector $reflector): void { + $definition->addTag( + 'messenger.message_handler', + [ + 'bus' => $attribute->bus, + 'fromTransport' => $attribute->fromTransport, + 'handles' => $attribute->handles, + 'method' => $attribute->method ?? ($reflector instanceof \ReflectionMethod ? $reflector->getName() : null), + 'priority' => $attribute->priority, + ] + ); + } + ); + + $containerBuilder->registerAttributeForAutoconfiguration( + AsCommand::class, + static function (ChildDefinition $definition, AsCommand $attribute): void { + $commands = explode('|', $attribute->name); + $hidden = false; + $name = array_shift($commands); + + if ($name === '') { + // Symfony AsCommand attribute encodes hidden flag as an empty command name + $hidden = true; + $name = array_shift($commands); + } + + if ($name === null) { + // This happens in case no name and no aliases are given + // @todo Throw exception + return; + } + + $definition->addTag( + 'console.command', + [ + 'command' => $name, + 'description' => $attribute->description, + 'hidden' => $hidden, + // The `schedulable` flag is not configurable via symfony attribute parameters, use sane defaults. + // It can be overridden by using the Attribute #[AsNonSchedulableCommand] on a class. + 'schedulable' => true, + ] + ); + + foreach ($commands as $name) { + $definition->addTag( + 'console.command', + [ + 'command' => $name, + 'hidden' => $hidden, + 'alias' => true, + ] + ); + } + } + ); + + // notice: static method references cannot be analyzed this way, those are + // resolved during runtime in `AllowedCallableAssertion` using reflection + $containerBuilder->registerAttributeForAutoconfiguration( + AsAllowedCallable::class, + static function (ChildDefinition $definition, AsAllowedCallable $attribute, \Reflector $reflector): void { + // @todo user functions are `'ClassName->methodName'`, class-based `__invoke()` is not supported yet + if (!$reflector instanceof \ReflectionMethod) { + return; + } + $definition->addTag(AsAllowedCallable::TAG_NAME, [ + 'method' => $reflector->getName(), + ]); + } + ); + + $containerBuilder->registerAttributeForAutoconfiguration( + UpgradeWizard::class, + static function (ChildDefinition $definition, UpgradeWizard $attribute): void { + $definition->addTag(UpgradeWizard::TAG_NAME, ['identifier' => $attribute->identifier]); + }, + ); + + $containerBuilder->registerAttributeForAutoconfiguration( + AsFileRenderer::class, + static function (ChildDefinition $definition, AsFileRenderer $attribute): void { + $definition->addTag(AsFileRenderer::TAG_NAME, ['priority' => $attribute->priority]); + }, + ); + + $containerBuilder->registerAttributeForAutoconfiguration( + AsModuleAccessGate::class, + static function (ChildDefinition $definition, AsModuleAccessGate $attribute): void { + $definition->addTag(AsModuleAccessGate::TAG_NAME, [ + 'identifier' => $attribute->identifier, + 'before' => implode(',', $attribute->before), + 'after' => implode(',', $attribute->after), + ]); + }, + ); + + $containerBuilder->addCompilerPass(new DependencyInjection\SingletonPass('typo3.singleton')); + $containerBuilder->addCompilerPass(new DependencyInjection\LoggerAwarePass('psr.logger_aware')); + $containerBuilder->addCompilerPass(new DependencyInjection\LoggerInterfacePass()); + $containerBuilder->addCompilerPass(new DependencyInjection\MfaProviderPass('mfa.provider')); + $containerBuilder->addCompilerPass(new DependencyInjection\FileRendererPass(AsFileRenderer::TAG_NAME)); + $containerBuilder->addCompilerPass(new DependencyInjection\SoftReferenceParserPass('softreference.parser')); + $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener')); + $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware')); + $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler')); + $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('icon.provider')); + $containerBuilder->addCompilerPass(new DependencyInjection\ConsoleCommandPass('console.command')); + $containerBuilder->addCompilerPass(new DependencyInjection\MessageHandlerPass('messenger.message_handler')); + $containerBuilder->addCompilerPass(new DependencyInjection\MessengerMiddlewarePass('messenger.middleware')); + $containerBuilder->addCompilerPass(new DependencyInjection\AssetFileSystemPublisherPass('asset.filesystem_publisher')); + $containerBuilder->addCompilerPass(new DependencyInjection\AllowedCallablePass(AsAllowedCallable::TAG_NAME)); + $containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass()); +}; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..5559be2 --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,179 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Core\: + resource: '../Classes/*' + + TYPO3\CMS\Core\Http\Application: + + core.middlewares: + class: ArrayObject + factory: ['@TYPO3\CMS\Core\Http\MiddlewareStackResolver', 'resolve'] + arguments: ['core'] + public: true + + TYPO3\CMS\Core\Package\UnitTestPackageManager: + autoconfigure: false + + TYPO3\CMS\Core\Http\MiddlewareDispatcher: + autoconfigure: false + + TYPO3\CMS\Core\Resource\Rendering\RendererRegistry: + arguments: + $renderers: !tagged_iterator fal.file_renderer + + TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider: + tags: + - name: mfa.provider + identifier: 'totp' + title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.title' + description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.description' + setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:totp.setupInstructions' + icon: 'actions-qrcode' + defaultProviderAllowed: true + before: 'recovery-codes' + + TYPO3\CMS\Core\Authentication\Mfa\Provider\RecoveryCodesProvider: + tags: + - name: mfa.provider + identifier: 'recovery-codes' + title: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.title' + description: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.description' + setupInstructions: 'LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:recoveryCodes.setupInstructions' + icon: 'content-text-columns' + defaultProviderAllowed: false + after: 'totp' + + # Soft Reference Parsers + TYPO3\CMS\Core\DataHandling\SoftReference\SubstituteSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: substitute + + TYPO3\CMS\Core\DataHandling\SoftReference\TypolinkSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: typolink + + TYPO3\CMS\Core\DataHandling\SoftReference\TypolinkTagSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: typolink_tag + + TYPO3\CMS\Core\DataHandling\SoftReference\ExtensionPathSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: ext_fileref + + TYPO3\CMS\Core\DataHandling\SoftReference\EmailSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: email + + TYPO3\CMS\Core\DataHandling\SoftReference\UrlSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: url + + # @todo use Autoconfigure attribute in v13/v14 + TYPO3\CMS\Core\Resource\Service\ResourceConsistencyService: + public: true + shared: true + + # Core caches, cache.core, cache.assets and cache.runtime are injected as + # early entries in TYPO3\CMS\Core\Core\Bootstrap and therefore omitted here + cache.hash: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['hash'] + + cache.pages: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['pages'] + + cache.rootline: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['rootline'] + + cache.l10n: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['l10n'] + + cache.typoscript: + class: TYPO3\CMS\Core\Cache\Frontend\PhpFrontend + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['typoscript'] + + ## messenger + + Symfony\Component\Messenger\Middleware\SendMessageMiddleware: + tags: + # TODO: Autoconfigure MiddlewareInterface? + - { name: 'messenger.middleware' } + + Symfony\Component\Messenger\Middleware\HandleMessageMiddleware: + tags: + - name: 'messenger.middleware' + after: 'Symfony\Component\Messenger\Middleware\SendMessageMiddleware' + + messenger.bus.default: + class: Symfony\Component\Messenger\MessageBusInterface + factory: [ '@TYPO3\CMS\Core\Messenger\BusFactory', 'createBus' ] + + Symfony\Component\Messenger\MessageBusInterface: + alias: messenger.bus.default + + Symfony\Component\Messenger\Handler\HandlersLocatorInterface: + factory: [ '@TYPO3\CMS\Core\Messenger\HandlersLocatorFactory', 'createHandlersLocator' ] + + Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface: + alias: TYPO3\CMS\Core\Messenger\TransportLocator + + Symfony\Component\Messenger\Transport\Serialization\PhpSerializer: + + Symfony\Component\Messenger\Transport\Serialization\SerializerInterface: + alias: Symfony\Component\Messenger\Transport\Serialization\PhpSerializer + + Symfony\Component\Messenger\Transport\Sync\SyncTransport: + tags: + - name: 'messenger.sender' + identifier: 'default' + + Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineTransport: + factory: [ '@TYPO3\CMS\Core\Messenger\DoctrineTransportFactory', 'createTransport' ] + arguments: + $options: + queue_name: 'default' + tags: + - name: 'messenger.sender' + identifier: 'doctrine' + - name: 'messenger.receiver' + identifier: 'doctrine' + + # Interface implementations + Psr\Container\ContainerInterface: + alias: service_container + public: true + Psr\Http\Client\ClientInterface: + alias: GuzzleHttp\Client + public: true + GuzzleHttp\ClientInterface: + alias: GuzzleHttp\Client + public: true + TYPO3\CMS\Core\RateLimiter\RateLimiterFactoryInterface: + alias: TYPO3\CMS\Core\RateLimiter\RateLimiterFactory + public: true + + # External dependencies + + GuzzleHttp\Client: + factory: ['@TYPO3\CMS\Core\Http\Client\GuzzleClientFactory', 'getClient'] + Masterminds\HTML5: + public: true + factory: ['TYPO3\CMS\Core\DependencyInjection\CommonFactory', 'createHtml5Parser'] diff --git a/Configuration/Sets/Email/config.yaml b/Configuration/Sets/Email/config.yaml new file mode 100644 index 0000000..321c425 --- /dev/null +++ b/Configuration/Sets/Email/config.yaml @@ -0,0 +1,2 @@ +name: typo3/email +label: "TYPO3 Email Configuration" diff --git a/Configuration/Sets/Email/labels.xlf b/Configuration/Sets/Email/labels.xlf new file mode 100644 index 0000000..811ec83 --- /dev/null +++ b/Configuration/Sets/Email/labels.xlf @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> + <file source-language="en" datatype="plaintext" original="EXT:core/Configuration/Sets/Email/labels.xlf" date="2026-10-28T13:37:00Z" product-name="core"> + <header/> + <body> + <trans-unit id="label"> + <source>Email Configuration</source> + </trans-unit> + <trans-unit id="categories.email"> + <source>Email Configuration</source> + </trans-unit> + <trans-unit id="settings.email.format"> + <source>Email format</source> + </trans-unit> + <trans-unit id="settings.description.email.format"> + <source>The format to use for sending emails (html, plain, both). If empty, the global configuration is used.</source> + </trans-unit> + <trans-unit id="settings.email.templateRootPaths"> + <source>Email Template Root Paths</source> + </trans-unit> + <trans-unit id="settings.description.email.templateRootPaths"> + <source>Array of paths to email templates. These are merged with the global mail template paths. Format: "EXT:my_extension/Resources/Private/Templates/Email".</source> + </trans-unit> + <trans-unit id="settings.email.layoutRootPaths"> + <source>Email Layout Root Paths</source> + </trans-unit> + <trans-unit id="settings.description.email.layoutRootPaths"> + <source>Array of paths to email layouts. These are merged with the global mail layout paths. Format: "EXT:my_extension/Resources/Private/Layout/".</source> + </trans-unit> + <trans-unit id="settings.email.partialRootPaths"> + <source>Email Partial Root Paths</source> + </trans-unit> + <trans-unit id="settings.description.email.partialRootPaths"> + <source>Array of paths to email partials. These are merged with the global mail partial paths. Format: "EXT:my_extension/Resources/Private/Partials/Email".</source> + </trans-unit> + <trans-unit id="settings.email.format.enum."> + <source>None - inherit format</source> + </trans-unit> + <trans-unit id="settings.email.format.enum.html"> + <source>HTML</source> + </trans-unit> + <trans-unit id="settings.email.format.enum.plain"> + <source>Plain-text</source> + </trans-unit> + <trans-unit id="settings.email.format.enum.both"> + <source>Both (HTML and plain-text)</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/Configuration/Sets/Email/settings.definitions.yaml b/Configuration/Sets/Email/settings.definitions.yaml new file mode 100644 index 0000000..c9c78f0 --- /dev/null +++ b/Configuration/Sets/Email/settings.definitions.yaml @@ -0,0 +1,25 @@ +categories: + email: ~ + +settings: + email.format: + default: '' + type: string + category: email + enum: + - '' + - 'html' + - 'plain' + - 'both' + email.templateRootPaths: + default: [''] + type: stringlist + category: email + email.layoutRootPaths: + default: [''] + type: stringlist + category: email + email.partialRootPaths: + default: [''] + type: stringlist + category: email diff --git a/Configuration/TCA/Overrides/be_groups.php b/Configuration/TCA/Overrides/be_groups.php new file mode 100644 index 0000000..a7294ed --- /dev/null +++ b/Configuration/TCA/Overrides/be_groups.php @@ -0,0 +1,6 @@ +<?php + +defined('TYPO3') or die(); + +$GLOBALS['TCA']['be_groups']['columns']['hidden']['authenticationContext']['group'] = 'be.userManagement'; +$GLOBALS['TCA']['be_groups']['columns']['hidden']['label'] = 'core.db.accounts:group.enabled'; diff --git a/Configuration/TCA/Overrides/be_users.php b/Configuration/TCA/Overrides/be_users.php new file mode 100644 index 0000000..01c816f --- /dev/null +++ b/Configuration/TCA/Overrides/be_users.php @@ -0,0 +1,14 @@ +<?php + +defined('TYPO3') or die(); + +// New be_users are disabled by default and can not disable themselves +$GLOBALS['TCA']['be_users']['columns']['disable']['displayCond'] = 'USER:' . \TYPO3\CMS\Core\Hooks\TcaDisplayConditions::class . '->isRecordCurrentUser:false'; +$GLOBALS['TCA']['be_users']['columns']['disable']['config']['default'] = 1; + +$GLOBALS['TCA']['be_users']['columns']['disable']['authenticationContext']['group'] = 'be.userManagement'; +$GLOBALS['TCA']['be_users']['columns']['disable']['label'] = 'core.db.accounts:enabled'; +$GLOBALS['TCA']['be_users']['columns']['starttime']['authenticationContext']['group'] = 'be.userManagement'; +$GLOBALS['TCA']['be_users']['columns']['starttime']['label'] = 'core.db.accounts:starttime'; +$GLOBALS['TCA']['be_users']['columns']['endtime']['authenticationContext']['group'] = 'be.userManagement'; +$GLOBALS['TCA']['be_users']['columns']['endtime']['label'] = 'core.db.accounts:endtime'; diff --git a/Configuration/TCA/Overrides/pages.php b/Configuration/TCA/Overrides/pages.php new file mode 100644 index 0000000..9eba154 --- /dev/null +++ b/Configuration/TCA/Overrides/pages.php @@ -0,0 +1,32 @@ +<?php + +defined('TYPO3') or die(); + +if (!\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded('seo')) { + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addFieldsToPalette('pages', 'metatags', '--linebreak--, description', 'after:keywords'); +} + +// New pages are disabled by default +$GLOBALS['TCA']['pages']['columns']['hidden']['config']['default'] = 1; + +// @todo: It's unclear if different start/end times for page localizations actually work throughout the system. +$GLOBALS['TCA']['pages']['columns']['starttime']['config']['behaviour']['allowLanguageSynchronization'] = true; +$GLOBALS['TCA']['pages']['columns']['endtime']['config']['behaviour']['allowLanguageSynchronization'] = true; + +// 'editlock' has l10n_mode=exclude, note 'tt_content' does not have this ... +$GLOBALS['TCA']['pages']['columns']['editlock']['l10n_mode'] = 'exclude'; + +// 'sys_language_uid' has exclude=true by default, unset this for pages +unset($GLOBALS['TCA']['pages']['columns']['sys_language_uid']['exclude']); + +// transOrigPointerField needs an adaptions on pages table, deviating from default: +// sys_language_uid = -1 is not allowed. +$GLOBALS['TCA']['pages']['columns']['l10n_parent']['config']['foreign_table_where'] = 'AND {#pages}.{#uid}=###CURRENT_PID### AND {#pages}.{#sys_language_uid} = 0'; + +// Add language_tag to the language palette +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addFieldsToPalette( + 'pages', + 'language', + '--linebreak--, language_tag', + 'after:l18n_cfg' +); diff --git a/Configuration/TCA/Overrides/sys_category.php b/Configuration/TCA/Overrides/sys_category.php new file mode 100644 index 0000000..873f3f6 --- /dev/null +++ b/Configuration/TCA/Overrides/sys_category.php @@ -0,0 +1,7 @@ +<?php + +defined('TYPO3') or die(); + +// @todo: Review. It is unclear if start/end time restrictions different from default-lang actually work if FE. +$GLOBALS['TCA']['sys_category']['columns']['starttime']['config']['behaviour']['allowLanguageSynchronization'] = true; +$GLOBALS['TCA']['sys_category']['columns']['endtime']['config']['behaviour']['allowLanguageSynchronization'] = true; diff --git a/Configuration/TCA/Overrides/sys_file_metadata.php b/Configuration/TCA/Overrides/sys_file_metadata.php new file mode 100644 index 0000000..e827e84 --- /dev/null +++ b/Configuration/TCA/Overrides/sys_file_metadata.php @@ -0,0 +1,15 @@ +<?php + +defined('TYPO3') or die(); + +// 'sys_language_uid' is part of a hidden palette, there is no access rights configuration for it. +unset($GLOBALS['TCA']['sys_file_metadata']['columns']['sys_language_uid']['exclude']); + +// @todo: transOrigPointerField is configured differently. Needed? Possible to keep default? +$GLOBALS['TCA']['sys_file_metadata']['columns']['l10n_parent']['config'] = [ + 'type' => 'group', + 'allowed' => 'sys_file_metadata', + 'size' => 1, + 'relationship' => 'manyToOne', + 'default' => 0, +]; diff --git a/Configuration/TCA/Overrides/sys_file_reference.php b/Configuration/TCA/Overrides/sys_file_reference.php new file mode 100644 index 0000000..3cc87e8 --- /dev/null +++ b/Configuration/TCA/Overrides/sys_file_reference.php @@ -0,0 +1,15 @@ +<?php + +defined('TYPO3') or die(); + +// 'sys_language_uid' is part of a hidden palette, there is no access rights configuration for it. +unset($GLOBALS['TCA']['sys_file_reference']['columns']['sys_language_uid']['exclude']); + +// @todo: transOrigPointerField is configured differently. Needed? Possible to keep default? +$GLOBALS['TCA']['sys_file_reference']['columns']['l10n_parent']['config'] = [ + 'type' => 'group', + 'allowed' => 'sys_file_reference', + 'size' => 1, + 'relationship' => 'manyToOne', + 'default' => 0, +]; diff --git a/Configuration/TCA/Overrides/sys_file_storage.php b/Configuration/TCA/Overrides/sys_file_storage.php new file mode 100644 index 0000000..5123ed1 --- /dev/null +++ b/Configuration/TCA/Overrides/sys_file_storage.php @@ -0,0 +1,5 @@ +<?php + +defined('TYPO3') or die(); + +\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\Driver\DriverRegistry::class)->addDriversToTCA(); diff --git a/Configuration/TCA/be_groups.php b/Configuration/TCA/be_groups.php new file mode 100644 index 0000000..7b452fa --- /dev/null +++ b/Configuration/TCA/be_groups.php @@ -0,0 +1,339 @@ +<?php + +return [ + 'ctrl' => [ + 'label' => 'title', + 'descriptionColumn' => 'description', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'delete' => 'deleted', + 'default_sortby' => 'title', + 'prependAtCopy' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.prependAtCopy', + 'adminOnly' => true, + 'groupName' => 'backendaccess', + 'rootLevel' => 1, + 'typeicon_classes' => [ + 'default' => 'status-user-group-backend', + ], + 'enablecolumns' => [ + 'disabled' => 'hidden', + ], + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups', + 'useColumnsForDefaultValues' => 'file_permissions', + 'versioningWS_alwaysAllowLiveEdit' => true, + ], + 'columns' => [ + 'title' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.title', + 'config' => [ + 'type' => 'input', + 'size' => 25, + 'max' => 50, + 'required' => true, + 'eval' => 'trim', + ], + ], + 'db_mountpoints' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:page_tree_entry_points', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:page_tree_entry_points.description', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 3, + 'autoSizeMax' => 10, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'file_mountpoints' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'sys_filemounts', + 'foreign_table_where' => ' AND {#sys_filemounts}.{#pid}=0', + 'size' => 3, + 'autoSizeMax' => 10, + 'fieldControl' => [ + 'editPopup' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_edit_title', + ], + ], + 'addRecord' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_add_title', + 'setValue' => 'prepend', + ], + ], + 'listModule' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_list_title', + ], + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'file_permissions' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.fileoper_perms', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder', 'value' => '--div--', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_read', 'value' => 'readFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_write', 'value' => 'writeFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_add', 'value' => 'addFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_rename', 'value' => 'renameFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_move', 'value' => 'moveFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_copy', 'value' => 'copyFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_delete', 'value' => 'deleteFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_recursivedelete', 'value' => 'recursivedeleteFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files', 'value' => '--div--', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_read', 'value' => 'readFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_write', 'value' => 'writeFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_add', 'value' => 'addFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_rename', 'value' => 'renameFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_replace', 'value' => 'replaceFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_move', 'value' => 'moveFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_copy', 'value' => 'copyFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_delete', 'value' => 'deleteFile', 'icon' => 'mimetypes-other-other'], + ], + 'size' => 17, + 'maxitems' => 17, + 'default' => 'readFolder,writeFolder,addFolder,renameFolder,moveFolder,deleteFolder,readFile,writeFile,addFile,renameFile,replaceFile,moveFile,copyFile,deleteFile', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'workspace_perms' => [ + 'displayCond' => 'USER:TYPO3\CMS\Core\Hooks\TcaDisplayConditions->isExtensionInstalled:workspaces', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms.description', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms_live'], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'pagetypes_select' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.pagetypes_select', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateAvailablePageTypes', + 'size' => 5, + 'autoSizeMax' => 50, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'tables_modify' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tables_modify', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tables_modify.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'tablePermission', + 'selectFieldName' => 'tables_select', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateAvailableTables', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'tables_select' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.tables_select', + 'config' => [ + 'type' => 'passthrough', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'non_exclude_fields' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.non_exclude_fields', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.non_exclude_fields.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateExcludeFields', + 'size' => 25, + 'autoSizeMax' => 50, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'explicit_allowdeny' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.explicit_allowdeny', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateExplicitAuthValues', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'allowed_languages' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:allowed_languages', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:allowed_languages.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Localization\TcaSystemLanguageCollector::class . '->populateAvailableSiteLanguages', + 'dbFieldLength' => 255, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'custom_options' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.custom_options', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateCustomPermissionOptions', + ], + ], + 'groupMods' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:userMods', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateAvailableUserModules', + 'size' => 5, + 'autoSizeMax' => 50, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'mfa_providers' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:mfa_providers', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry::class . '->allowedProvidersItemsProcFunc', + 'size' => 5, + 'autoSizeMax' => 50, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'TSconfig' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:TSconfig', + 'config' => [ + 'type' => 'text', + 'cols' => 40, + 'renderType' => 'codeEditor', + 'format' => 'typoscript', + 'rows' => 5, + 'enableTabulator' => true, + 'fixedFont' => true, + ], + ], + 'tsconfig_includes' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:tsconfig_includes', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'size' => 10, + 'items' => [], + 'softref' => 'ext_fileref', + ], + ], + 'subgroup' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.subgroup', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'be_groups', + 'foreign_table_where' => 'AND NOT({#be_groups}.{#uid} = ###THIS_UID###)', + 'size' => 5, + 'autoSizeMax' => 50, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'category_perms' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:category_perms', + 'config' => [ + 'type' => 'category', + 'relationship' => 'oneToMany', + 'treeConfig' => [ + 'appearance' => [ + 'expandAll' => false, + 'showHeader' => false, + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + ], + 'types' => [ + '0' => ['showitem' => ' + --div--;core.form.tabs:general, + title, subgroup, + --palette--;;authentication, + --div--;core.form.tabs:recordpermissions, + --palette--;;permissionGeneral, + --palette--;;permissionSpecific, + --palette--;;permissionLanguages, + --div--;core.form.tabs:modulepermissions, + groupMods, custom_options, workspace_perms, + --div--;core.form.tabs:mounts, + db_mountpoints, file_mountpoints, file_permissions, category_perms, + --div--;core.form.tabs:options, + TSconfig, tsconfig_includes, + --div--;core.form.tabs:access, + hidden, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + '], + ], + 'palettes' => [ + 'authentication' => [ + 'label' => 'core.form.palettes:authentication', + 'showitem' => 'mfa_providers', + ], + 'permissionGeneral' => [ + 'label' => 'core.form.palettes:permission_general', + 'showitem' => ' + tables_modify, + --linebreak--, non_exclude_fields + ', + ], + 'permissionLanguages' => [ + 'label' => 'core.form.palettes:permission_languages', + 'showitem' => 'allowed_languages', + ], + 'permissionSpecific' => [ + 'label' => 'core.form.palettes:permission_specific', + 'showitem' => ' + pagetypes_select, + --linebreak--, explicit_allowdeny + ', + ], + ], +]; diff --git a/Configuration/TCA/be_users.php b/Configuration/TCA/be_users.php new file mode 100644 index 0000000..f2e4e0b --- /dev/null +++ b/Configuration/TCA/be_users.php @@ -0,0 +1,431 @@ +<?php + +return [ + 'ctrl' => [ + 'label' => 'username', + 'descriptionColumn' => 'description', + 'tstamp' => 'tstamp', + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users', + 'crdate' => 'crdate', + 'delete' => 'deleted', + 'adminOnly' => true, + 'rootLevel' => 1, + 'groupName' => 'backendaccess', + 'default_sortby' => 'admin, username', + 'enablecolumns' => [ + 'disabled' => 'disable', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'type' => 'admin', + 'typeicon_column' => 'admin', + 'typeicon_classes' => [ + '0' => 'status-user-backend', + '1' => 'status-user-admin', + 'default' => 'status-user-backend', + ], + 'useColumnsForDefaultValues' => 'usergroup,options,db_mountpoints,file_mountpoints,file_permissions,userMods', + 'versioningWS_alwaysAllowLiveEdit' => true, + ], + 'columns' => [ + 'username' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.username', + 'config' => [ + 'type' => 'input', + 'size' => 20, + 'max' => 50, + 'required' => true, + 'eval' => 'nospace,trim,lower,unique', + 'autocomplete' => false, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'password' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.password', + 'config' => [ + 'type' => 'password', + 'passwordPolicy' => $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? '', + 'size' => 20, + 'required' => true, + 'fieldControl' => [ + 'passwordGenerator' => [ + 'renderType' => 'passwordGenerator', + 'options' => [ + 'title' => 'core.core:labels.generatePassword', + 'allowEdit' => true, + 'passwordPolicy' => $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? '', + ], + ], + ], + ], + 'authenticationContext' => [ + //'group' => 'be.userManagement', + 'once' => true, + ], + ], + 'mfa' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.mfa', + 'config' => [ + // @todo Use a new internal TCA type to prevent raw data being displayed in the backend + 'type' => 'none', + 'renderType' => 'mfaInfo', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'usergroup' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'be_groups', + 'size' => 5, + 'dbFieldLength' => 512, + 'fieldControl' => [ + 'editPopup' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup_edit_title', + ], + ], + 'addRecord' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup_add_title', + 'setValue' => 'prepend', + ], + ], + 'listModule' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.usergroup_list_title', + ], + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'avatar' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.avatar', + 'config' => [ + 'type' => 'file', + 'relationship' => 'manyToOne', + 'allowed' => 'common-image-types', + ], + ], + 'db_mountpoints' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options_page_tree_entry_points', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options_page_tree_entry_points.description', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 3, + 'maxitems' => 100, + 'autoSizeMax' => 10, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'file_mountpoints' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options_file_mounts', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'sys_filemounts', + 'foreign_table_where' => ' AND {#sys_filemounts}.{#pid}=0', + 'size' => 3, + 'maxitems' => 100, + 'autoSizeMax' => 10, + 'fieldControl' => [ + 'editPopup' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_edit_title', + ], + ], + 'addRecord' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_add_title', + 'setValue' => 'prepend', + ], + ], + 'listModule' => [ + 'disabled' => false, + 'options' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file_mountpoints_list_title', + ], + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'email' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.email', + 'config' => [ + 'type' => 'email', + 'size' => 20, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'realName' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.name', + 'config' => [ + 'type' => 'input', + 'size' => 20, + 'eval' => 'trim', + 'max' => 80, + ], + ], + 'admin' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.admin', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + 'fieldInformation' => [ + 'adminIsSystemMaintainer' => [ + 'renderType' => 'adminIsSystemMaintainer', + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'options' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options', + 'config' => [ + 'type' => 'check', + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options_page_tree_entry_points'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.options_file_mounts'], + ], + 'default' => 3, + ], + ], + 'file_permissions' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.fileoper_perms', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder', 'value' => '--div--', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_read', 'value' => 'readFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_write', 'value' => 'writeFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_add', 'value' => 'addFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_rename', 'value' => 'renameFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_move', 'value' => 'moveFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_copy', 'value' => 'copyFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_delete', 'value' => 'deleteFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.folder_recursivedelete', 'value' => 'recursivedeleteFolder', 'icon' => 'apps-filetree-folder-default'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files', 'value' => '--div--', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_read', 'value' => 'readFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_write', 'value' => 'writeFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_add', 'value' => 'addFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_rename', 'value' => 'renameFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_replace', 'value' => 'replaceFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_move', 'value' => 'moveFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_copy', 'value' => 'copyFile', 'icon' => 'mimetypes-other-other'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_groups.file_permissions.files_delete', 'value' => 'deleteFile', 'icon' => 'mimetypes-other-other'], + ], + 'size' => 17, + 'maxitems' => 17, + 'default' => 'readFolder,writeFolder,addFolder,renameFolder,moveFolder,deleteFolder,readFile,writeFile,addFile,renameFile,replaceFile,moveFile,copyFile,deleteFile', + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'workspace_perms' => [ + 'displayCond' => 'USER:TYPO3\CMS\Core\Hooks\TcaDisplayConditions->isExtensionInstalled:workspaces', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms.description', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:workspace_perms_live'], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'lang' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.lang', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'itemsProcFunc' => \TYPO3\CMS\Core\Localization\TcaSystemLanguageCollector::class . '->populateAvailableSystemLanguagesForBackend', + 'default' => 'en', + 'dbFieldLength' => 10, + 'items' => [ + ], + 'itemGroups' => [ + 'installed' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.languageItemGroups.installed', + 'unavailable' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:be_users.languageItemGroups.unavailable', + ], + ], + ], + 'userMods' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:userMods', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Hooks\TcaItemsProcessorFunctions::class . '->populateAvailableUserModules', + 'size' => 5, + 'autoSizeMax' => 50, + 'maxitems' => 100, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'allowed_languages' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:allowed_languages', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:allowed_languages.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'itemsProcFunc' => \TYPO3\CMS\Core\Localization\TcaSystemLanguageCollector::class . '->populateAvailableSiteLanguages', + 'dbFieldLength' => 255, + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'TSconfig' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:TSconfig', + 'config' => [ + 'type' => 'text', + 'renderType' => 'codeEditor', + 'format' => 'typoscript', + 'cols' => 40, + 'rows' => 5, + 'enableTabulator' => true, + 'fixedFont' => true, + ], + ], + 'tsconfig_includes' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:tsconfig_includes', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'size' => 10, + 'items' => [], + 'softref' => 'ext_fileref', + ], + ], + 'lastlogin' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.lastlogin', + 'config' => [ + 'type' => 'datetime', + 'readOnly' => true, + 'default' => 0, + ], + ], + 'category_perms' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:category_perms', + 'config' => [ + 'type' => 'category', + 'relationship' => 'oneToMany', + 'treeConfig' => [ + 'appearance' => [ + 'expandAll' => false, + 'showHeader' => false, + ], + ], + ], + 'authenticationContext' => [ + 'group' => 'be.userManagement', + ], + ], + 'user_settings' => [ + 'label' => 'core.tca:user_settings', + 'config' => [ + 'type' => 'json', + ], + ], + ], + 'types' => [ + '0' => [ + 'title' => 'core.tca:be_users.types.user', + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;account, + usergroup, + --palette--;;authentication, + --div--;core.form.tabs:personaldata, + realName, email, avatar, lang, + --div--;core.form.tabs:recordpermissions, + --palette--;;permissionLanguages, + --div--;core.form.tabs:modulepermissions, + userMods, workspace_perms, + --div--;core.form.tabs:mounts, + db_mountpoints, options, file_mountpoints, file_permissions, category_perms, + --div--;core.form.tabs:options, + TSconfig, tsconfig_includes, + --div--;core.form.tabs:access, + --palette--;;status, + --palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + '1' => [ + 'title' => 'core.tca:be_users.types.admin', + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;account, + usergroup, + --palette--;;authentication, + --div--;core.form.tabs:personaldata, + realName, email, avatar, lang, + --div--;core.form.tabs:options, + TSconfig, tsconfig_includes, db_mountpoints, options, file_mountpoints, + --div--;core.form.tabs:access, + --palette--;;status, + --palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + ], + 'palettes' => [ + 'account' => [ + 'label' => 'core.form.palettes:account', + 'showitem' => ' + admin, + --linebreak--, username, password + ', + ], + 'authentication' => [ + 'label' => 'core.form.palettes:authentication', + 'showitem' => 'mfa', + ], + 'permissionLanguages' => [ + 'label' => 'core.form.palettes:permission_languages', + 'showitem' => 'allowed_languages', + ], + 'status' => [ + 'showitem' => 'disable, lastlogin', + ], + 'timeRestriction' => [ + 'showitem' => 'starttime, endtime', + ], + ], +]; diff --git a/Configuration/TCA/pages.php b/Configuration/TCA/pages.php new file mode 100644 index 0000000..2dcec5e --- /dev/null +++ b/Configuration/TCA/pages.php @@ -0,0 +1,1012 @@ +<?php + +return [ + 'ctrl' => [ + 'label' => 'title', + 'descriptionColumn' => 'rowDescription', + 'tstamp' => 'tstamp', + 'sortby' => 'sorting', + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:pages', + 'type' => 'doktype', + 'versioningWS' => true, + 'delete' => 'deleted', + 'crdate' => 'crdate', + 'hideAtCopy' => true, + 'prependAtCopy' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.prependAtCopy', + 'editlock' => 'editlock', + 'useColumnsForDefaultValues' => 'doktype,fe_group,hidden', + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'translationSource' => 'l10n_source', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + 'fe_group' => 'fe_group', + ], + 'typeicon_column' => 'doktype', + 'typeicon_classes' => [ + '1' => 'apps-pagetree-page-default', + '1-hideinmenu' => 'apps-pagetree-page-hideinmenu', + '1-root' => 'apps-pagetree-page-domain', + '3' => 'apps-pagetree-page-shortcut-external', + '3-hideinmenu' => 'apps-pagetree-page-shortcut-external-hideinmenu', + '3-root' => 'apps-pagetree-page-shortcut-external-root', + '4' => 'apps-pagetree-page-shortcut', + '4-hideinmenu' => 'apps-pagetree-page-shortcut-hideinmenu', + '4-root' => 'apps-pagetree-page-shortcut-root', + '6' => 'apps-pagetree-page-backend-users', + '6-hideinmenu' => 'apps-pagetree-page-backend-users-hideinmenu', + '6-root' => 'apps-pagetree-page-backend-users-root', + '7' => 'apps-pagetree-page-mountpoint', + '7-hideinmenu' => 'apps-pagetree-page-mountpoint-hideinmenu', + '7-root' => 'apps-pagetree-page-mountpoint-root', + '199' => 'apps-pagetree-spacer', + '199-hideinmenu' => 'apps-pagetree-spacer-hideinmenu', + '199-root' => 'apps-pagetree-page-domain', + '254' => 'apps-pagetree-folder-default', + '254-hideinmenu' => 'apps-pagetree-folder-default', + '254-root' => 'apps-pagetree-page-domain', + 'contains-shop' => 'apps-pagetree-folder-contains-shop', + 'contains-approve' => 'apps-pagetree-folder-contains-approve', + 'contains-fe_users' => 'apps-pagetree-folder-contains-fe_users', + 'contains-board' => 'apps-pagetree-folder-contains-board', + 'contains-news' => 'apps-pagetree-folder-contains-news', + 'page-contentFromPid' => 'apps-pagetree-page-content-from-page', + 'page-contentFromPid-root' => 'apps-pagetree-page-content-from-page-root', + 'page-contentFromPid-hideinmenu' => 'apps-pagetree-page-content-from-page-hideinmenu', + 'default' => 'apps-pagetree-page-default', + ], + // Will be filled automatically, if ctrl.security.ignorePageTypeRestrictions is enabled for a table. + 'defaultAllowedRecordTypes' => [], + ], + 'columns' => [ + 'doktype' => [ + 'exclude' => true, + 'label' => 'core.db.pages:doktype', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'core.db.pages:doktype.default', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT, + 'icon' => 'apps-pagetree-page-default', + 'group' => 'default', + ], + [ + 'label' => 'core.db.pages:doktype.be_user_section', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_BE_USER_SECTION, + 'icon' => 'apps-pagetree-page-backend-users', + 'group' => 'default', + ], + [ + 'label' => 'core.db.pages:doktype.shortcut', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SHORTCUT, + 'icon' => 'apps-pagetree-page-shortcut', + 'group' => 'link', + ], + [ + 'label' => 'core.db.pages:doktype.mountpoint', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_MOUNTPOINT, + 'icon' => 'apps-pagetree-page-mountpoint', + 'group' => 'link', + ], + [ + 'label' => 'core.db.pages:doktype.link', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_LINK, + 'icon' => 'apps-pagetree-page-shortcut-external', + 'group' => 'link', + ], + [ + 'label' => 'core.db.pages:doktype.sysfolder', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SYSFOLDER, + 'icon' => 'apps-pagetree-folder-default', + 'group' => 'special', + ], + [ + 'label' => 'core.db.pages:doktype.spacer', + 'value' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SPACER, + 'icon' => 'apps-pagetree-spacer', + 'group' => 'special', + ], + ], + 'itemGroups' => [ + 'default' => 'core.db.pages:doktype.group.page', + 'link' => 'core.db.pages:doktype.group.link', + 'special' => 'core.db.pages:doktype.group.special', + ], + 'default' => (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT, + ], + ], + 'title' => [ + 'l10n_mode' => 'prefixLangTitle', + 'label' => 'core.db.pages:title', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 255, + 'required' => true, + 'eval' => 'trim', + ], + ], + 'slug' => [ + 'label' => 'core.db.pages:slug', + 'config' => [ + 'type' => 'slug', + 'size' => 50, + 'generatorOptions' => [ + 'fields' => ['title'], + 'fieldSeparator' => '/', + 'prefixParentPageSlug' => true, + ], + 'fallbackCharacter' => '-', + 'eval' => 'uniqueInSite', + 'default' => '', + ], + ], + 'TSconfig' => [ + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:tsconfig', + 'displayCond' => 'HIDE_FOR_NON_ADMINS', + 'config' => [ + 'type' => 'text', + 'renderType' => 'codeEditor', + 'format' => 'typoscript', + 'cols' => 40, + 'rows' => 15, + 'enableTabulator' => true, + 'fixedFont' => true, + ], + ], + 'php_tree_stop' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:php_tree_stop', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + 'categories' => [ + 'config' => [ + 'type' => 'category', + ], + ], + 'layout' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:layout', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value', + 'value' => '0', + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.layout.I.1', + 'value' => '1', + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.layout.I.2', + 'value' => '2', + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.layout.I.3', + 'value' => '3', + ], + ], + 'default' => 0, + ], + ], + 'extendToSubpages' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:extend_to_subpages', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + 'nav_title' => [ + 'exclude' => true, + 'label' => 'core.db.pages:nav_title', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 255, + 'eval' => 'trim', + ], + ], + 'nav_hide' => [ + 'exclude' => true, + 'label' => 'core.db.pages:nav_hide', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'items' => [ + [ + 'label' => '', + 'invertStateDisplay' => true, + ], + ], + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'subtitle' => [ + 'exclude' => true, + 'l10n_mode' => 'prefixLangTitle', + 'label' => 'core.db.pages:subtitle', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 255, + 'eval' => 'trim', + ], + ], + 'target' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:target', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 80, + 'valuePicker' => [ + 'items' => [ + [ 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:target.I.1', 'value' => '_blank' ], + ], + ], + 'eval' => 'trim', + ], + ], + 'link' => [ + 'label' => 'core.db.pages:link', + 'description' => 'core.db.pages:link.description', + 'config' => [ + 'type' => 'link', + 'size' => 50, + 'appearance' => [ + 'allowedOptions' => ['params', 'target'], + ], + 'default' => '', + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + 'required' => true, + ], + ], + 'lastUpdated' => [ + 'exclude' => true, + 'label' => 'core.db.pages:last_updated', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'newUntil' => [ + 'exclude' => true, + 'label' => 'core.db.pages:new_until', + 'config' => [ + 'type' => 'datetime', + 'format' => 'date', + 'default' => 0, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'cache_timeout' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:cache_timeout', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value', + 'value' => 0, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.1', + 'value' => 60, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.2', + 'value' => 300, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.3', + 'value' => 900, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.4', + 'value' => 1800, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.5', + 'value' => 3600, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.6', + 'value' => 14400, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.7', + 'value' => 86400, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.8', + 'value' => 172800, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.9', + 'value' => 604800, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.cache_timeout.I.10', + 'value' => 2678400, + ], + ], + 'default' => 0, + ], + ], + 'cache_tags' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:cache_tags', + 'config' => [ + 'type' => 'input', + 'size' => 30, + 'max' => 255, + ], + ], + 'no_search' => [ + 'exclude' => true, + 'label' => 'core.db.pages:no_search', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'items' => [ + [ + 'label' => '', + 'invertStateDisplay' => true, + ], + ], + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'shortcut' => [ + 'label' => 'core.db.pages:shortcut', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 1, + 'relationship' => 'manyToOne', + 'suggestOptions' => [ + 'default' => [ + 'additionalSearchFields' => 'nav_title', + 'addWhere' => ' AND pages.uid != ###THIS_UID###', + ], + ], + 'default' => 0, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + 'fieldWizard' => [ + 'shortcutValidation' => [ + 'disabled' => false, + ], + ], + ], + ], + 'shortcut_mode' => [ + 'exclude' => true, + 'label' => 'core.db.pages:shortcut_mode', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.shortcut_mode.I.0', + 'value' => \TYPO3\CMS\Core\Domain\Repository\PageRepository::SHORTCUT_MODE_NONE, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.shortcut_mode.I.1', + 'value' => \TYPO3\CMS\Core\Domain\Repository\PageRepository::SHORTCUT_MODE_FIRST_SUBPAGE, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.shortcut_mode.I.3', + 'value' => \TYPO3\CMS\Core\Domain\Repository\PageRepository::SHORTCUT_MODE_PARENT_PAGE, + ], + ], + 'default' => 0, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'content_from_pid' => [ + 'exclude' => true, + 'label' => 'core.db.pages:content_from_pid', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 1, + 'relationship' => 'manyToOne', + 'suggestOptions' => [ + 'default' => [ + 'additionalSearchFields' => 'nav_title', + 'addWhere' => ' AND pages.uid != ###THIS_UID###', + ], + ], + 'default' => 0, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'mount_pid' => [ + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:mount_pid', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 1, + 'relationship' => 'manyToOne', + 'default' => 0, + ], + ], + 'keywords' => [ + 'exclude' => true, + 'l10n_mode' => 'prefixLangTitle', + 'label' => 'core.db.pages:keywords', + 'config' => [ + 'type' => 'text', + 'cols' => 40, + 'rows' => 3, + ], + ], + 'description' => [ + 'exclude' => true, + 'l10n_mode' => 'prefixLangTitle', + 'label' => 'core.db.pages:description', + 'config' => [ + 'type' => 'text', + 'cols' => 40, + 'rows' => 3, + ], + ], + 'abstract' => [ + 'exclude' => true, + 'l10n_mode' => 'prefixLangTitle', + 'label' => 'core.db.pages:abstract', + 'config' => [ + 'type' => 'text', + 'cols' => 40, + 'rows' => 3, + ], + ], + 'author' => [ + 'exclude' => true, + 'label' => 'core.db.pages:author', + 'config' => [ + 'type' => 'input', + 'size' => 23, + 'eval' => 'trim', + 'max' => 255, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'author_email' => [ + 'exclude' => true, + 'label' => 'core.db.pages:author_email', + 'config' => [ + 'type' => 'email', + 'size' => 23, + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'media' => [ + 'exclude' => true, + 'label' => 'core.db.pages:media', + 'config' => [ + 'type' => 'file', + 'behaviour' => [ + 'allowLanguageSynchronization' => true, + ], + ], + ], + 'is_siteroot' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:is_siteroot', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + 'mount_pid_ol' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:mount_pid_ol', + 'config' => [ + 'type' => 'radio', + 'items' => [ + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.mount_pid_ol.I.0', + 'value' => 0, + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.mount_pid_ol.I.1', + 'value' => 1, + ], + ], + ], + ], + 'module' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:module', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => '', + 'value' => '', + ], + [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.module.I.4', + 'value' => 'fe_users', + 'icon' => 'status-user-frontend', + ], + ], + 'default' => '', + ], + ], + 'l18n_cfg' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:l18n_cfg', + 'config' => [ + 'type' => 'check', + 'items' => [ + ['label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.l18n_cfg.I.1'], + ['label' => $GLOBALS['TYPO3_CONF_VARS']['FE']['hidePagesIfNotTranslatedByDefault'] ? 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.l18n_cfg.I.2a' : 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.l18n_cfg.I.2'], + ], + ], + ], + 'backend_layout' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:backend_layout', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => '', 'value' => ''], + ['label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.backend_layout.none', 'value' => -1], + ], + 'itemsProcFunc' => \TYPO3\CMS\Backend\View\BackendLayoutView::class . '->addBackendLayoutItems', + 'fieldWizard' => [ + 'selectIcons' => [ + 'disabled' => false, + ], + ], + 'fieldInformation' => [ + 'backendLayoutFromParentPage' => [ + 'renderType' => 'backendLayoutFromParentPage', + ], + ], + 'dbFieldLength' => 64, + ], + ], + 'backend_layout_next_level' => [ + 'exclude' => true, + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:backend_layout_next_level', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => '', 'value' => ''], + ['label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.backend_layout.none', 'value' => -1], + ], + 'itemsProcFunc' => \TYPO3\CMS\Backend\View\BackendLayoutView::class . '->addBackendLayoutItems', + 'fieldWizard' => [ + 'selectIcons' => [ + 'disabled' => false, + ], + ], + 'dbFieldLength' => 64, + ], + ], + 'tsconfig_includes' => [ + 'l10n_mode' => 'exclude', + 'label' => 'core.db.pages:tsconfig_includes', + 'displayCond' => 'HIDE_FOR_NON_ADMINS', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'size' => 10, + 'items' => [], + 'softref' => 'ext_fileref', + ], + ], + ], + 'types' => [ + // normal + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT => [ + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;standard, + --palette--;;title, + --div--;core.form.tabs:metadata, + --palette--;;abstract, + --palette--;;metatags, + --palette--;;editorial, + --div--;core.form.tabs:appearance, + --palette--;;layout, + --palette--;;replace, + --div--;core.form.tabs:behaviour, + --palette--;;links, + --palette--;;caching, + --palette--;;miscellaneous, + --palette--;;module, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'nav_title', 'hidden', 'nav_hide'], + ], + ], + ], + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_BE_USER_SECTION => [ + 'allowedRecordTypes' => ['*'], + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;standard, + --palette--;;title, + --div--;core.form.tabs:metadata, + --palette--;;abstract, + --palette--;;metatags, + --palette--;;editorial, + --div--;core.form.tabs:appearance, + --palette--;;layout, + --palette--;;replace, + --div--;core.form.tabs:behaviour, + --palette--;;links, + --palette--;;caching, + --palette--;;miscellaneous, + --palette--;;module, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'nav_title', 'hidden', 'nav_hide'], + ], + ], + ], + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_LINK => [ + 'showitem' => ' + --div--;core.form.tabs:general, + doktype, + --palette--;;title, + --palette--;;link, + --div--;core.form.tabs:metadata, + --palette--;;abstract, + --palette--;;editorial, + --div--;core.form.tabs:appearance, + --palette--;;layout, + --div--;core.form.tabs:behaviour, + --palette--;;miscellaneous, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'nav_title', 'hidden', 'nav_hide'], + ], + 'links' => [ + 'title' => 'backend.wizards.page:step.link', + 'fields' => ['link'], + 'after' => ['setup'], + ], + ], + ], + // shortcut + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SHORTCUT => [ + 'showitem' => ' + --div--;core.form.tabs:general, + doktype, + --palette--;;title, + --palette--;;shortcut, + --palette--;;shortcutpage, + --div--;core.form.tabs:metadata, + --palette--;;abstract, + --palette--;;editorial, + --div--;core.form.tabs:appearance, + --palette--;;layout, + --div--;core.form.tabs:behaviour, + --palette--;;links, + --palette--;;miscellaneous, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'nav_title', 'hidden', 'nav_hide'], + ], + 'shortcut' => [ + 'title' => 'backend.wizards.page:step.shortcut', + 'fields' => ['shortcut_mode', 'shortcut'], + 'after' => ['setup'], + ], + ], + ], + // mount page + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_MOUNTPOINT => [ + 'showitem' => ' + --div--;core.form.tabs:general, + doktype, + --palette--;;title, + --palette--;;mountpoint, + --palette--;;mountpage, + --div--;core.form.tabs:metadata, + --palette--;;abstract, + --palette--;;editorial, + --div--;core.form.tabs:appearance, + --palette--;;layout, + --div--;core.form.tabs:behaviour, + --palette--;;links, + --palette--;;miscellaneous, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'nav_title', 'hidden', 'nav_hide'], + ], + 'mounting' => [ + 'title' => 'backend.wizards.page:step.mounting', + 'fields' => ['mount_pid_ol', 'mount_pid'], + 'after' => ['setup'], + ], + ], + ], + // spacer + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SPACER => [ + 'isViewable' => false, + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;standard, + --palette--;;titleonly, + --div--;core.form.tabs:appearance, + --palette--;;backend_layout, + --div--;core.form.tabs:resources, + --palette--;;config, + --div--;core.form.tabs:access, + --palette--;;visibility, + --palette--;;access, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'hidden', 'nav_hide'], + ], + ], + ], + // Doktype 254 is a 'Folder' - a general purpose storage folder for whatever you like. + // In CMS context it's NOT a viewable page. Can contain any element. + (string)\TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_SYSFOLDER => [ + 'allowedRecordTypes' => ['*'], + 'isViewable' => false, + 'showitem' => ' + --div--;core.form.tabs:general, + --palette--;;standard, + --palette--;;titleonly, + --div--;core.form.tabs:appearance, + --palette--;;backend_layout, + --div--;core.form.tabs:behaviour, + --palette--;;module, + --div--;core.form.tabs:resources, + --palette--;;media, + --palette--;;config, + --div--;core.form.tabs:access, + --palette--;;hiddenonly, + --palette--;;adminsonly, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:notes, + rowDescription, + --div--;core.form.tabs:extended, + ', + 'wizardSteps' => [ + 'setup' => [ + 'title' => 'backend.wizards.page:step.setup', + 'fields' => ['title', 'slug', 'hidden'], + ], + ], + ], + ], + 'palettes' => [ + 'standard' => [ + 'label' => 'core.form.palettes:standard', + 'showitem' => 'doktype', + ], + 'shortcut' => [ + 'showitem' => 'shortcut_mode', + ], + 'shortcutpage' => [ + 'showitem' => 'shortcut', + ], + 'mountpoint' => [ + 'showitem' => 'mount_pid_ol', + ], + 'mountpage' => [ + 'showitem' => 'mount_pid', + ], + 'link' => [ + 'showitem' => 'link', + ], + 'title' => [ + 'label' => 'core.form.palettes:title', + 'showitem' => 'title, --linebreak--, slug, --linebreak--, nav_title, --linebreak--, subtitle', + ], + 'titleonly' => [ + 'label' => 'core.form.palettes:title', + 'showitem' => 'title, --linebreak--, slug', + ], + 'visibility' => [ + 'label' => 'core.form.palettes:visibility', + 'showitem' => 'hidden;core.db.pages:hidden, nav_hide', + ], + 'hiddenonly' => [ + 'label' => 'core.form.palettes:visibility', + 'showitem' => 'hidden;core.db.pages:hidden', + ], + 'access' => [ + 'label' => 'core.form.palettes:access', + 'showitem' => 'starttime, endtime, extendToSubpages, --linebreak--, fe_group, --linebreak--, editlock', + ], + 'abstract' => [ + 'label' => 'core.form.palettes:abstract', + 'showitem' => 'abstract', + ], + 'metatags' => [ + 'label' => 'core.form.palettes:metatags', + 'showitem' => 'keywords', + ], + 'editorial' => [ + 'label' => 'core.form.palettes:editorial', + 'showitem' => 'author, author_email, lastUpdated', + ], + 'layout' => [ + 'label' => 'core.form.palettes:layout', + 'showitem' => 'layout, newUntil, --linebreak--, backend_layout, backend_layout_next_level', + ], + 'backend_layout' => [ + 'label' => 'core.form.palettes:page_layout', + 'showitem' => 'backend_layout, backend_layout_next_level', + ], + 'module' => [ + 'label' => 'core.form.palettes:use_as_container', + 'showitem' => 'module', + ], + 'replace' => [ + 'label' => 'core.form.palettes:replace', + 'showitem' => 'content_from_pid', + ], + 'links' => [ + 'label' => 'core.form.palettes:links', + 'showitem' => 'target;core.db.pages:link.target', + ], + 'caching' => [ + 'label' => 'core.form.palettes:caching', + 'showitem' => 'cache_timeout, cache_tags', + ], + 'language' => [ + 'label' => 'core.form.palettes:language', + 'showitem' => 'l18n_cfg', + ], + 'miscellaneous' => [ + 'label' => 'core.form.palettes:miscellaneous', + 'showitem' => 'is_siteroot, no_search, php_tree_stop', + ], + 'adminsonly' => [ + 'label' => 'core.form.palettes:miscellaneous', + 'showitem' => 'editlock', + ], + 'media' => [ + 'label' => 'core.form.palettes:media', + 'showitem' => 'media', + ], + 'config' => [ + 'label' => 'core.form.palettes:config', + 'showitem' => 'tsconfig_includes, --linebreak--, TSconfig', + ], + ], +]; diff --git a/Configuration/TCA/sys_category.php b/Configuration/TCA/sys_category.php new file mode 100644 index 0000000..de6f3cf --- /dev/null +++ b/Configuration/TCA/sys_category.php @@ -0,0 +1,84 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category', + 'descriptionColumn' => 'description', + 'label' => 'title', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'delete' => 'deleted', + 'sortby' => 'sorting', + 'default_sortby' => 'title', + 'versioningWS' => true, + 'rootLevel' => -1, + 'groupName' => 'content', + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_category', + ], + 'security' => [ + 'ignoreRootLevelRestriction' => true, + ], + ], + 'types' => [ + '1' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + title, parent, + --div--;core.form.tabs:items, + items, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + hidden,--palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + ], + 'palettes' => [ + 'timeRestriction' => ['showitem' => 'starttime, endtime'], + 'language' => ['showitem' => 'sys_language_uid, l10n_parent'], + ], + 'columns' => [ + 'title' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.title', + 'config' => [ + 'type' => 'input', + 'required' => true, + 'eval' => 'trim', + ], + ], + 'parent' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.parent', + 'config' => [ + 'type' => 'category', + 'relationship' => 'oneToOne', + ], + ], + 'items' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.items', + 'config' => [ + 'type' => 'group', + 'allowed' => '*', + 'MM' => 'sys_category_record_mm', + 'MM_oppositeUsage' => [], + 'size' => 10, + 'fieldWizard' => [ + 'recordsOverview' => [ + 'disabled' => true, + ], + ], + ], + ], + ], +]; diff --git a/Configuration/TCA/sys_file.php b/Configuration/TCA/sys_file.php new file mode 100644 index 0000000..7f1709b --- /dev/null +++ b/Configuration/TCA/sys_file.php @@ -0,0 +1,128 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file', + 'label' => 'name', + 'tstamp' => 'tstamp', + 'type' => 'type', + 'hideTable' => true, + 'rootLevel' => 1, + 'default_sortby' => 'name ASC', + 'typeicon_column' => 'type', + 'typeicon_classes' => [ + \TYPO3\CMS\Core\Resource\FileType::TEXT->value => 'mimetypes-text-text', + \TYPO3\CMS\Core\Resource\FileType::IMAGE->value => 'mimetypes-media-image', + \TYPO3\CMS\Core\Resource\FileType::AUDIO->value => 'mimetypes-media-audio', + \TYPO3\CMS\Core\Resource\FileType::VIDEO->value => 'mimetypes-media-video', + \TYPO3\CMS\Core\Resource\FileType::APPLICATION->value => 'mimetypes-application', + 'default' => 'mimetypes-other-other', + ], + 'security' => [ + 'ignoreWebMountRestriction' => true, + 'ignoreRootLevelRestriction' => true, + ], + ], + 'columns' => [ + 'fileinfo' => [ + 'config' => [ + 'type' => 'none', + 'renderType' => 'fileInfo', + ], + ], + 'storage' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.storage', + 'config' => [ + 'readOnly' => true, + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => '', 'value' => 0], + ], + 'foreign_table' => 'sys_file_storage', + ], + ], + 'identifier' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.identifier', + 'config' => [ + 'readOnly' => true, + 'type' => 'input', + 'size' => 30, + ], + ], + 'name' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.name', + 'config' => [ + 'readOnly' => true, + 'type' => 'input', + 'size' => 30, + 'required' => true, + ], + ], + 'type' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type', + 'config' => [ + 'readOnly' => true, + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.unknown', 'value' => \TYPO3\CMS\Core\Resource\FileType::UNKNOWN->value], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.text', 'value' => \TYPO3\CMS\Core\Resource\FileType::TEXT->value], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.image', 'value' => \TYPO3\CMS\Core\Resource\FileType::IMAGE->value], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.audio', 'value' => \TYPO3\CMS\Core\Resource\FileType::AUDIO->value], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.video', 'value' => \TYPO3\CMS\Core\Resource\FileType::VIDEO->value], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.type.software', 'value' => \TYPO3\CMS\Core\Resource\FileType::APPLICATION->value], + ], + ], + ], + 'mime_type' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.mime_type', + 'config' => [ + 'readOnly' => true, + 'type' => 'input', + 'size' => 30, + ], + ], + 'sha1' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.sha1', + 'config' => [ + 'readOnly' => true, + 'type' => 'input', + 'size' => 30, + ], + ], + 'size' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.size', + 'config' => [ + 'readOnly' => true, + 'type' => 'number', + 'size' => 8, + 'default' => 0, + ], + ], + 'missing' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.missing', + 'config' => [ + 'readOnly' => true, + 'type' => 'check', + 'default' => 0, + ], + ], + 'metadata' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.metadata', + 'config' => [ + 'readOnly' => true, + 'type' => 'inline', + 'foreign_table' => 'sys_file_metadata', + 'foreign_field' => 'file', + 'size' => 1, + 'minitems' => 1, + 'relationship' => 'oneToOne', + ], + ], + ], + 'types' => [ + '1' => ['showitem' => 'fileinfo, storage, missing'], + ], + 'palettes' => [], +]; diff --git a/Configuration/TCA/sys_file_collection.php b/Configuration/TCA/sys_file_collection.php new file mode 100644 index 0000000..f218245 --- /dev/null +++ b/Configuration/TCA/sys_file_collection.php @@ -0,0 +1,143 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection', + 'label' => 'title', + 'descriptionColumn' => 'description', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'versioningWS' => true, + 'groupName' => 'content', + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'default_sortby' => 'crdate', + 'delete' => 'deleted', + 'type' => 'type', + 'typeicon_column' => 'type', + 'typeicon_classes' => [ + 'default' => 'apps-filetree-folder-media', + 'static' => 'apps-clipboard-images', + 'folder' => 'apps-filetree-folder-media', + ], + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + ], + 'columns' => [ + 'type' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.type', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.type.0', 'value' => 'static'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.type.1', 'value' => 'folder'], + ['label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.type.2', 'value' => 'category'], + ], + ], + ], + 'files' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.files', + 'config' => [ + 'type' => 'file', + ], + ], + 'title' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.title', + 'config' => [ + 'type' => 'input', + 'size' => 30, + 'required' => true, + ], + ], + 'folder_identifier' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.folder', + 'config' => [ + 'type' => 'folder', + 'minitems' => 1, + 'relationship' => 'manyToOne', + 'size' => 1, + ], + ], + 'recursive' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.recursive', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + ], + ], + 'category' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_collection.category', + 'config' => [ + 'type' => 'category', + 'relationship' => 'oneToOne', + ], + ], + ], + 'types' => [ + '0' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + type,title,files, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + hidden,--palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + 'creationOptions' => [ + 'enableDirectRecordTypeCreation' => false, + ], + ], + 'static' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + type,title,files, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + hidden,--palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + 'folder' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + type,title,folder_identifier, recursive, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + hidden,--palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + 'category' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + type,title,category, + --div--;core.form.tabs:language, + --palette--;;language, + --div--;core.form.tabs:access, + hidden,--palette--;;timeRestriction, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + ], + ], + 'palettes' => [ + 'timeRestriction' => ['showitem' => 'starttime, endtime'], + 'language' => ['showitem' => 'sys_language_uid, l10n_parent'], + ], +]; diff --git a/Configuration/TCA/sys_file_metadata.php b/Configuration/TCA/sys_file_metadata.php new file mode 100644 index 0000000..1f2f244 --- /dev/null +++ b/Configuration/TCA/sys_file_metadata.php @@ -0,0 +1,121 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_metadata', + 'label' => 'file', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'type' => 'file:type', + 'hideTable' => true, + 'rootLevel' => 1, + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'versioningWS' => true, + 'default_sortby' => 'crdate DESC', + 'typeicon_classes' => [ + 'default' => 'mimetypes-other-other', + ], + 'security' => [ + 'ignoreWebMountRestriction' => true, + 'ignoreRootLevelRestriction' => true, + ], + ], + 'columns' => [ + 'crdate' => [ + 'config' => [ + 'type' => 'passthrough', + ], + ], + 'categories' => [ + 'config' => [ + 'type' => 'category', + ], + ], + 'fileinfo' => [ + 'config' => [ + 'type' => 'none', + 'renderType' => 'fileInfo', + ], + ], + 'file' => [ + 'displayCond' => 'FIELD:sys_language_uid:=:0', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file', + 'config' => [ + 'readOnly' => true, + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'sys_file', + 'foreign_table_where' => 'AND {#sys_file}.{#uid} = ###REC_FIELD_file###', + 'minitems' => 1, + 'default' => 0, + ], + ], + 'title' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.title', + 'l10n_mode' => 'prefixLangTitle', + 'config' => [ + 'type' => 'input', + 'size' => 30, + 'placeholder' => '__row|file|name', + ], + ], + 'description' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.description', + 'l10n_mode' => 'prefixLangTitle', + 'config' => [ + 'type' => 'text', + 'cols' => 40, + 'rows' => 3, + ], + ], + 'alternative' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.alternative', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.alternative.description', + 'l10n_mode' => 'prefixLangTitle', + 'config' => [ + 'type' => 'input', + 'size' => 30, + ], + ], + 'width' => [ + 'l10n_mode' => 'exclude', + 'l10n_display' => 'defaultAsReadonly', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file.width', + 'config' => [ + 'type' => 'number', + 'size' => 10, + 'default' => 0, + 'readOnly' => true, + ], + ], + 'height' => [ + 'l10n_mode' => 'exclude', + 'l10n_display' => 'defaultAsReadonly', + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:file.height', + 'config' => [ + 'type' => 'number', + 'size' => 10, + 'default' => 0, + 'readOnly' => true, + ], + ], + ], + 'types' => [ + '1' => ['showitem' => ' + --div--;core.form.tabs:general, + fileinfo, alternative, description, title, --palette--;;language, + --div--;core.form.tabs:categories, + categories, + --div--;core.form.tabs:extended, + '], + ], + 'palettes' => [ + 'language' => [ + 'showitem' => 'sys_language_uid, l10n_parent', + 'isHiddenPalette' => true, + ], + ], +]; diff --git a/Configuration/TCA/sys_file_reference.php b/Configuration/TCA/sys_file_reference.php new file mode 100644 index 0000000..93e2989 --- /dev/null +++ b/Configuration/TCA/sys_file_reference.php @@ -0,0 +1,212 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference', + 'label' => 'uid_local', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'type' => 'uid_local:type', + 'hideTable' => true, + 'delete' => 'deleted', + 'versioningWS' => true, + 'languageField' => 'sys_language_uid', + 'transOrigPointerField' => 'l10n_parent', + 'transOrigDiffSourceField' => 'l10n_diffsource', + 'rootLevel' => -1, + 'enablecolumns' => [ + 'disabled' => 'hidden', + ], + 'typeicon_classes' => [ + 'default' => 'mimetypes-other-other', + ], + 'security' => [ + 'ignoreWebMountRestriction' => true, + 'ignoreRootLevelRestriction' => true, + ], + ], + 'columns' => [ + 'uid_local' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.uid_local', + 'config' => [ + 'type' => 'group', + 'size' => 1, + 'relationship' => 'manyToOne', + 'allowed' => 'sys_file', + 'hideSuggest' => true, + ], + ], + 'uid_foreign' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.uid_foreign', + 'config' => [ + 'type' => 'number', + 'size' => 10, + ], + ], + 'tablenames' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.tablenames', + 'config' => [ + // @todo: type=input is probably not a good choice here. + 'type' => 'input', + 'size' => 30, + 'max' => 64, + 'eval' => 'trim', + ], + ], + 'fieldname' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.fieldname', + 'config' => [ + // @todo: type=input is probably not a good choice here. + 'type' => 'input', + 'max' => 64, + 'size' => 30, + ], + ], + 'sorting_foreign' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.sorting_foreign', + 'config' => [ + 'type' => 'number', + 'size' => 4, + 'default' => 0, + ], + ], + 'title' => [ + 'l10n_mode' => 'prefixLangTitle', + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.title', + 'config' => [ + 'type' => 'input', + 'size' => 20, + 'max' => 255, + 'nullable' => true, + 'placeholder' => '__row|uid_local|metadata|title', + 'mode' => 'useOrOverridePlaceholder', + 'default' => null, + ], + ], + 'link' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.link', + 'config' => [ + 'type' => 'link', + 'size' => 20, + 'appearance' => [ + 'browserTitle' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.link', + ], + ], + ], + 'description' => [ + // This is used for captions in the frontend + 'l10n_mode' => 'prefixLangTitle', + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.description', + 'config' => [ + 'type' => 'text', + 'cols' => 20, + 'rows' => 5, + 'nullable' => true, + 'placeholder' => '__row|uid_local|metadata|description', + 'mode' => 'useOrOverridePlaceholder', + 'default' => null, + ], + ], + 'alternative' => [ + 'l10n_mode' => 'prefixLangTitle', + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.alternative', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.alternative.description', + 'config' => [ + 'type' => 'input', + 'size' => 20, + 'nullable' => true, + 'placeholder' => '__row|uid_local|metadata|alternative', + 'mode' => 'useOrOverridePlaceholder', + 'default' => null, + ], + ], + 'crop' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.crop', + 'config' => [ + 'type' => 'imageManipulation', + ], + ], + 'autoplay' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.autoplay', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + ], + ], + ], + 'types' => [ + // Note that at the moment we define the same fields for every media type. + // We leave the extensive definition of each type here anyway, to make clear that you can use it to differentiate between the types. + '0' => [ + 'showitem' => ' + --palette--;;basicoverlayPalette, + --palette--;;filePalette', + ], + \TYPO3\CMS\Core\Resource\FileType::TEXT->value => [ + 'showitem' => ' + --palette--;;basicoverlayPalette, + --palette--;;filePalette', + ], + \TYPO3\CMS\Core\Resource\FileType::IMAGE->value => [ + 'showitem' => ' + --palette--;;imageoverlayPalette, + --palette--;;filePalette', + ], + \TYPO3\CMS\Core\Resource\FileType::AUDIO->value => [ + 'showitem' => ' + --palette--;;audioOverlayPalette, + --palette--;;filePalette', + ], + \TYPO3\CMS\Core\Resource\FileType::VIDEO->value => [ + 'showitem' => ' + --palette--;;videoOverlayPalette, + --palette--;;filePalette', + ], + \TYPO3\CMS\Core\Resource\FileType::APPLICATION->value => [ + 'showitem' => ' + --palette--;;basicoverlayPalette, + --palette--;;filePalette', + ], + ], + 'palettes' => [ + // Used for basic overlays: having a filelist etc + 'basicoverlayPalette' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.basicoverlayPalette', + 'showitem' => 'title,description', + ], + // Used for everything that is an image (because it has a link and an alternative text) + 'imageoverlayPalette' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.imageoverlayPalette', + 'showitem' => ' + alternative,description,--linebreak--, + link,title,--linebreak--,crop + ', + ], + // Used for everything that is a video + 'videoOverlayPalette' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.videoOverlayPalette', + 'showitem' => ' + title,description,--linebreak--,autoplay + ', + ], + // Used for everything that is an audio file + 'audioOverlayPalette' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_reference.audioOverlayPalette', + 'showitem' => ' + title,description,--linebreak--,autoplay + ', + ], + // File palette, hidden but needs to be included all the time + 'filePalette' => [ + 'showitem' => 'uid_local, hidden, sys_language_uid, l10n_parent', + 'isHiddenPalette' => true, + ], + ], +]; diff --git a/Configuration/TCA/sys_file_storage.php b/Configuration/TCA/sys_file_storage.php new file mode 100644 index 0000000..489a7a3 --- /dev/null +++ b/Configuration/TCA/sys_file_storage.php @@ -0,0 +1,131 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage', + 'label' => 'name', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'default_sortby' => 'name', + 'delete' => 'deleted', + 'descriptionColumn' => 'description', + 'rootLevel' => 1, + 'groupName' => 'system', + 'versioningWS_alwaysAllowLiveEdit' => true, // Only have LIVE records of file storages + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_file_storage', + ], + 'type' => 'driver', + ], + 'columns' => [ + 'name' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.name', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 255, + 'required' => true, + ], + ], + 'is_browsable' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.is_browsable', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + ], + ], + 'is_default' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.is_default', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + 'eval' => 'maximumRecordsChecked', + 'validation' => [ + 'maximumRecordsChecked' => 1, + ], + ], + ], + 'is_public' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.is_public', + 'config' => [ + 'default' => 1, + 'type' => 'user', + 'renderType' => 'userSysFileStorageIsPublic', + ], + ], + 'is_writable' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.is_writable', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + ], + ], + 'is_online' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.is_online', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + ], + ], + 'auto_extract_metadata' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.auto_extract_metadata', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + ], + ], + 'processingfolder' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.processingfolder', + 'description' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.processingfolder.placeholder', + 'config' => [ + 'type' => 'input', + 'size' => 20, + ], + ], + 'driver' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.driver', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [], + 'default' => 'Local', + ], + ], + 'configuration' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file_storage.configuration', + 'config' => [ + 'type' => 'flex', + 'ds' => '<T3DataStructure><ROOT></ROOT></T3DataStructure>', + ], + ], + ], + 'types' => [ + '0' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + name, driver, configuration, is_default, auto_extract_metadata, processingfolder, + --div--;core.form.tabs:accesscapabilities, + --palette--;;capabilities, + --div--;core.form.tabs:access, + is_online, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + ', + 'creationOptions' => [ + 'enableDirectRecordTypeCreation' => false, + ], + ], + ], + 'palettes' => [ + 'capabilities' => [ + 'label' => 'core.form.tabs:capabilities', + 'showitem' => 'is_browsable, is_public, is_writable', + ], + ], +]; diff --git a/Configuration/TCA/sys_filemounts.php b/Configuration/TCA/sys_filemounts.php new file mode 100644 index 0000000..faeba53 --- /dev/null +++ b/Configuration/TCA/sys_filemounts.php @@ -0,0 +1,62 @@ +<?php + +return [ + 'ctrl' => [ + 'label' => 'title', + 'descriptionColumn' => 'description', + 'tstamp' => 'tstamp', + 'sortby' => 'sorting', + 'prependAtCopy' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.prependAtCopy', + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_filemounts', + 'adminOnly' => true, + 'rootLevel' => 1, + 'groupName' => 'backendaccess', + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + ], + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_filemounts', + ], + 'versioningWS_alwaysAllowLiveEdit' => true, + ], + 'columns' => [ + 'title' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_filemounts.title', + 'config' => [ + 'type' => 'input', + 'size' => 50, + 'max' => 255, + 'required' => true, + 'eval' => 'trim', + ], + ], + 'identifier' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_filemounts.identifier', + 'config' => [ + 'type' => 'folder', + 'required' => true, + 'relationship' => 'manyToOne', + 'size' => 1, + ], + ], + 'read_only' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_filemounts.read_only', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + ], + 'types' => [ + '0' => ['showitem' => ' + --div--;core.form.tabs:general, + title, identifier, read_only, + --div--;core.form.tabs:access, + hidden, + --div--;core.form.tabs:notes, + description, + --div--;core.form.tabs:extended, + '], + ], +]; diff --git a/Configuration/TCA/sys_news.php b/Configuration/TCA/sys_news.php new file mode 100644 index 0000000..d79efd6 --- /dev/null +++ b/Configuration/TCA/sys_news.php @@ -0,0 +1,65 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_news', + 'label' => 'title', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'adminOnly' => true, + 'rootLevel' => 1, + 'groupName' => 'backendaccess', + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'default_sortby' => 'crdate DESC', + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_news', + ], + ], + 'columns' => [ + 'title' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.title', + 'config' => [ + 'type' => 'input', + 'size' => 30, + 'max' => 255, + 'required' => true, + ], + ], + 'content' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.text', + 'config' => [ + 'type' => 'text', + 'cols' => 48, + 'rows' => 5, + 'enableRichtext' => true, + 'richtextConfiguration' => 'sys_news', + ], + ], + 'crdate' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + ], + ], + ], + 'types' => [ + '1' => [ + 'showitem' => ' + --div--;core.form.tabs:general, + title,content,crdate, + --div--;core.form.tabs:access, + hidden, --palette--;;timeRestriction, + --div--;core.form.tabs:extended, + ', + ], + ], + 'palettes' => [ + 'timeRestriction' => ['showitem' => 'starttime, endtime'], + ], +]; diff --git a/Configuration/page.tsconfig b/Configuration/page.tsconfig new file mode 100644 index 0000000..a1503c2 --- /dev/null +++ b/Configuration/page.tsconfig @@ -0,0 +1 @@ +TCEMAIN.translateToMessage = Translate to %s: diff --git a/Documentation/Changelog-10-combined.rst b/Documentation/Changelog-10-combined.rst new file mode 100644 index 0000000..e1db38a --- /dev/null +++ b/Documentation/Changelog-10-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v10-byType: + +========================== +10.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v10-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/10.*/Breaking-* + +.. _changelog-v10-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/10.*/Feature-* + +.. _changelog-v10-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/10.*/Deprecation-* + +.. _changelog-v10-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/10.*/Important-* diff --git a/Documentation/Changelog-10.rst b/Documentation/Changelog-10.rst new file mode 100644 index 0000000..b42e68d --- /dev/null +++ b/Documentation/Changelog-10.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v10: + +============= +ChangeLog v10 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/10.4.x/Index + Changelog/10.4/Index + Changelog/10.3/Index + Changelog/10.2/Index + Changelog/10.1/Index + Changelog/10.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-10-combined diff --git a/Documentation/Changelog-11-combined.rst b/Documentation/Changelog-11-combined.rst new file mode 100644 index 0000000..bc7a1e5 --- /dev/null +++ b/Documentation/Changelog-11-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v11-byType: + +========================== +11.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v11-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/11.*/Breaking-* + +.. _changelog-v11-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/11.*/Feature-* + +.. _changelog-v11-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/11.*/Deprecation-* + +.. _changelog-v11-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/11.*/Important-* diff --git a/Documentation/Changelog-11.rst b/Documentation/Changelog-11.rst new file mode 100644 index 0000000..3b567ea --- /dev/null +++ b/Documentation/Changelog-11.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v11: + +============= +ChangeLog v11 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/11.5.x/Index + Changelog/11.5/Index + Changelog/11.4/Index + Changelog/11.3/Index + Changelog/11.2/Index + Changelog/11.1/Index + Changelog/11.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-11-combined diff --git a/Documentation/Changelog-12-combined.rst b/Documentation/Changelog-12-combined.rst new file mode 100644 index 0000000..7071744 --- /dev/null +++ b/Documentation/Changelog-12-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v12-byType: + +========================== +12.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v12-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/12.*/Breaking-* + +.. _changelog-v12-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/12.*/Feature-* + +.. _changelog-v12-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/12.*/Deprecation-* + +.. _changelog-v12-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/12.*/Important-* diff --git a/Documentation/Changelog-12.rst b/Documentation/Changelog-12.rst new file mode 100644 index 0000000..1bbed40 --- /dev/null +++ b/Documentation/Changelog-12.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v12: + +============= +ChangeLog v12 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/12.4.x/Index + Changelog/12.4/Index + Changelog/12.3/Index + Changelog/12.2/Index + Changelog/12.1/Index + Changelog/12.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-12-combined diff --git a/Documentation/Changelog-13-combined.rst b/Documentation/Changelog-13-combined.rst new file mode 100644 index 0000000..bf9ace4 --- /dev/null +++ b/Documentation/Changelog-13-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v13-byType: + +========================== +13.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v13-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/13.*/Breaking-* + +.. _changelog-v13-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/13.*/Feature-* + +.. _changelog-v13-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/13.*/Deprecation-* + +.. _changelog-v13-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/13.*/Important-* diff --git a/Documentation/Changelog-13.rst b/Documentation/Changelog-13.rst new file mode 100644 index 0000000..9c3a88c --- /dev/null +++ b/Documentation/Changelog-13.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v13: + +============= +ChangeLog v13 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/13.4.x/Index + Changelog/13.4/Index + Changelog/13.3/Index + Changelog/13.2/Index + Changelog/13.1/Index + Changelog/13.0/Index + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-13-combined diff --git a/Documentation/Changelog-14-combined.rst b/Documentation/Changelog-14-combined.rst new file mode 100644 index 0000000..411ac90 --- /dev/null +++ b/Documentation/Changelog-14-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v14-byType: + +========================== +14.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v14-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/14.*/Breaking-* + +.. _changelog-v14-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/14.*/Feature-* + +.. _changelog-v14-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/14.*/Deprecation-* + +.. _changelog-v14-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/14.*/Important-* diff --git a/Documentation/Changelog-14.rst b/Documentation/Changelog-14.rst new file mode 100644 index 0000000..e6f702a --- /dev/null +++ b/Documentation/Changelog-14.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v14: + +============= +ChangeLog v14 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + + Changelog/14.3.x/Index + Changelog/14.3/Index + Changelog/14.2/Index + Changelog/14.1/Index + Changelog/14.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-14-combined diff --git a/Documentation/Changelog-15-combined.rst b/Documentation/Changelog-15-combined.rst new file mode 100644 index 0000000..0e7938c --- /dev/null +++ b/Documentation/Changelog-15-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v15-byType: + +========================== +15.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v15-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/15.*/Breaking-* + +.. _changelog-v15-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/15.*/Feature-* + +.. _changelog-v15-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/15.*/Deprecation-* + +.. _changelog-v15-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/15.*/Important-* diff --git a/Documentation/Changelog-15.rst b/Documentation/Changelog-15.rst new file mode 100644 index 0000000..5323247 --- /dev/null +++ b/Documentation/Changelog-15.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v15: + +============= +ChangeLog v15 +============= + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/15.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-15-combined diff --git a/Documentation/Changelog-7-combined.rst b/Documentation/Changelog-7-combined.rst new file mode 100644 index 0000000..1e9f446 --- /dev/null +++ b/Documentation/Changelog-7-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v7-byType: + +========================== +7.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v7-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/7.*/Breaking-* + +.. _changelog-v7-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/7.*/Feature-* + +.. _changelog-v7-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/7.*/Deprecation-* + +.. _changelog-v7-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/7.*/Important-* diff --git a/Documentation/Changelog-7.rst b/Documentation/Changelog-7.rst new file mode 100644 index 0000000..62bc7f3 --- /dev/null +++ b/Documentation/Changelog-7.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v7: + +============ +ChangeLog v7 +============ + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/7.6.x/Index + Changelog/7.6/Index + Changelog/7.5/Index + Changelog/7.4/Index + Changelog/7.3/Index + Changelog/7.2/Index + Changelog/7.1/Index + Changelog/7.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-7-combined diff --git a/Documentation/Changelog-8-combined.rst b/Documentation/Changelog-8-combined.rst new file mode 100644 index 0000000..a45ce79 --- /dev/null +++ b/Documentation/Changelog-8-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v8-byType: + +========================== +8.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v8-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/8.*/Breaking-* + +.. _changelog-v8-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/8.*/Feature-* + +.. _changelog-v8-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/8.*/Deprecation-* + +.. _changelog-v8-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/8.*/Important-* diff --git a/Documentation/Changelog-8.rst b/Documentation/Changelog-8.rst new file mode 100644 index 0000000..1bca60f --- /dev/null +++ b/Documentation/Changelog-8.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v8: + +============ +ChangeLog v8 +============ + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/8.7.x/Index + Changelog/8.7/Index + Changelog/8.6/Index + Changelog/8.5/Index + Changelog/8.4/Index + Changelog/8.3/Index + Changelog/8.2/Index + Changelog/8.1/Index + Changelog/8.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-8-combined diff --git a/Documentation/Changelog-9-combined.rst b/Documentation/Changelog-9-combined.rst new file mode 100644 index 0000000..c20f99f --- /dev/null +++ b/Documentation/Changelog-9-combined.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v9-byType: + +========================== +9.x Changes by type +========================== + +This lists all changes to the TYPO3 Core of minor versions grouped by their type. + +.. contents:: Table of contents + +.. _changelog-v9-bc: +Breaking Changes +================ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/9.*/Breaking-* + +.. _changelog-v9-feat: +Features +======== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/9.*/Feature-* + +.. _changelog-v9-dep: +Deprecations +============ + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + Changelog/9.*/Deprecation-* + +.. _changelog-v9-imp: +Important notes +=============== + +.. menu:: + :maxdepth: 3 + :titlesonly: + :glob: + + /Changelog/9.*/Important-* diff --git a/Documentation/Changelog-9.rst b/Documentation/Changelog-9.rst new file mode 100644 index 0000000..2bab966 --- /dev/null +++ b/Documentation/Changelog-9.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _changelog-v9: + +============ +ChangeLog v9 +============ + +Every change to the TYPO3 Core which might affect your site is documented here. + +.. toctree:: + :titlesonly: + + Changelog/9.5.x/Index + Changelog/9.5/Index + Changelog/9.4/Index + Changelog/9.3/Index + Changelog/9.2/Index + Changelog/9.1/Index + Changelog/9.0/Index + + +Also available +-------------- + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + Changelog-9-combined diff --git a/Documentation/Changelog/.htaccess b/Documentation/Changelog/.htaccess new file mode 100644 index 0000000..9a2aa5a --- /dev/null +++ b/Documentation/Changelog/.htaccess @@ -0,0 +1,11 @@ +# Apache < 2.3 +<IfModule !mod_authz_core.c> + Order allow,deny + Deny from all + Satisfy All +</IfModule> + +# Apache ≥ 2.3 +<IfModule mod_authz_core.c> + Require all denied +</IfModule> diff --git a/Documentation/Changelog/10.0/Breaking-21638-LockIPPropertyRemoved.rst b/Documentation/Changelog/10.0/Breaking-21638-LockIPPropertyRemoved.rst new file mode 100644 index 0000000..c4c59c2 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-21638-LockIPPropertyRemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-21638: + +====================================================================== +Breaking: #21638 - AbstractUserAuthentication::lockIP property removed +====================================================================== + +See :issue:`21638` + +Description +=========== + +The IP-locking-functionality is extended from IPv4 only to now also support IPv6. A separate IpLocker-functionality was added. + +The public property :php:`lockIP` in :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` is now removed. +It usually shouldn't have been accessed directly and supported IPv4 only. + + +Impact +====== + +Extensions relying on :php:`lockIP` won't be able to perform their task anymore. +This might for example be the case when :php:`lockIP` was set dynamically, depending on the REMOTE_ADDR. + + +Affected Installations +====================== + +Every 3rd party extension depending on the formerly public :php:`lockIP` property is affected. + + +Migration +========= + +Set :php:`lockIP` and :php:`lockIPv6` in :php:`TYPO3_CONF_VARS` - for FE or BE depending on the use case. +Use the new :php:`\TYPO3\CMS\Core\Authentication\IpLocker` API. + +.. index:: Backend, Frontend, LocalConfiguration, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-81950-RemoveLeftoverWorkspacesUnpublishingFunctionality.rst b/Documentation/Changelog/10.0/Breaking-81950-RemoveLeftoverWorkspacesUnpublishingFunctionality.rst new file mode 100644 index 0000000..6b8f578 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-81950-RemoveLeftoverWorkspacesUnpublishingFunctionality.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-81950: + +======================================================================== +Breaking: #81950 - Remove leftover workspaces unpublishing functionality +======================================================================== + +See :issue:`81950` + +Description +=========== + +A property within workspaces for "unpublishing" published records has been disabled since TYPO3 4.5. + +This functionality allowed to restore a published workspace which was published at a given time, to revert the changes on another +time, but had side-effects if changes were made between publishing and unpublishing. + +However, this functionality was not visible to TYPO3 out of the box, but only available with a possible third-party integration +since TYPO3 4.5. The feature was therefore removed from TYPO3 Core. + +The (hidden) database field :sql:`sys_workspace.unpublish_time` was removed. + + +Impact +====== + +Using the functionality will not work anymore, operating on the database with this field will result in a SQL error. + + +Affected Installations +====================== + +Any installation using the workspace functionality with automatic publishing and a third-party extension for unpublishing. + + +Migration +========= + +If this feature is required for an installation, the field should be re-added by the third-party extension in TCA (which was missing) +and the database which was using the functionality. On top, a custom auto-unpublishing CLI command should be created. + +.. index:: Database, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/10.0/Breaking-86862-DefaultLayoutOfExtfluid_styled_contentDoesNotUseSpacelessViewHelperAnymore.rst b/Documentation/Changelog/10.0/Breaking-86862-DefaultLayoutOfExtfluid_styled_contentDoesNotUseSpacelessViewHelperAnymore.rst new file mode 100644 index 0000000..fa73a9c --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-86862-DefaultLayoutOfExtfluid_styled_contentDoesNotUseSpacelessViewHelperAnymore.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-86862: + +======================================================================================================= +Breaking: #86862 - Default Layout of ext:fluid_styled_content does not use spaceless viewHelper anymore +======================================================================================================= + +See :issue:`86862` + +Description +=========== + +The default layout file of ext:fluid_styled_content removed all white space characters in the whole output, which led +to occasional issues with the generated markup. This general removal of whitespace characters has been removed. +It is in the hand of the integrator to apply white space character removal on their own on sensible places using template override functionality. + + +Impact +====== + +Markup of pages rendered using ext:fluid_styled_content will contain more white space characters. +This might influence the visual output. + + +Affected Installations +====================== + +Each instance using ext:fluid_styled_content as rendering template. + + +Migration +========= + +Review and adjust the markup generated for your front end. In case you did not experience any issues before, +you can override the default template and reintroduce the spaceless viewHelper, or apply it in other sections of the output where it will be helpful. + +.. index:: Fluid, Frontend, RTE, NotScanned, ext:fluid_styled_content diff --git a/Documentation/Changelog/10.0/Breaking-87009-UseMultipleTranslationFilesByDefaultInEXTform.rst b/Documentation/Changelog/10.0/Breaking-87009-UseMultipleTranslationFilesByDefaultInEXTform.rst new file mode 100644 index 0000000..4949a02 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87009-UseMultipleTranslationFilesByDefaultInEXTform.rst @@ -0,0 +1,82 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87009: + +======================================================================== +Breaking: #87009 - Use multiple translation files by default in EXT:form +======================================================================== + +See :issue:`87009` + +Description +=========== + +All :yaml:`translationFile` options in EXT:form setup and form definitions have been renamed to :yaml:`translationFiles`. + +The following default translation files are now registered at index :yaml:`10` in all locations: + +* :file:`EXT:form/Resources/Private/Language/locallang.xlf` +* :file:`EXT:form/Resources/Private/Language/Database.xlf` + + +Impact +====== + +Extending form setup or form definitions with additional translation files does not require adding the default translation files anymore. + +The option :yaml:`translationFile` does not work anymore and must be migrated to :yaml:`translationFiles`. + +Opening and saving a form with the form editor once also performs the migration of the corresponding form definition and makes it permanent. + + +Affected Installations +====================== + +All installations which use EXT:form and its :yaml:`translationFile` option. + + +Migration +========= + +In your custom form configuration, migrate the single value :yaml:`translationFile` option to the multi value :yaml:`translationFiles` option. + +Given that all default translation files of EXT:form are registered at index :yaml:`10` it is recommended to use a higher index for custom translation files. + +Single file +----------- + +Before: + +.. code-block:: yaml + + translationFile: path/to/locallang.xlf + +After: + +.. code-block:: yaml + + translationFiles: + 20: path/to/locallang.xlf + + +Multiple files +-------------- + +Before: + +.. code-block:: yaml + + translationFile: + 10: EXT:form/Resources/Private/Language/locallang.xlf + 20: path/to/locallang.xlf + 25: path/to/other/locallang.xlf + +After: + +.. code-block:: yaml + + translationFiles: + 20: path/to/locallang.xlf + 25: path/to/other/locallang.xlf + +.. index:: YAML, NotScanned, ext:form diff --git a/Documentation/Changelog/10.0/Breaking-87193-DeprecatedFunctionalityRemoved.rst b/Documentation/Changelog/10.0/Breaking-87193-DeprecatedFunctionalityRemoved.rst new file mode 100644 index 0000000..f43ed21 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87193-DeprecatedFunctionalityRemoved.rst @@ -0,0 +1,1521 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87193: + +=================================================== +Breaking: #87193 - Deprecated functionality removed +=================================================== + +See :issue:`87193` + +Description +=========== + +The following PHP classes that have been previously deprecated for v9 have been removed: + +* :php:`TYPO3\CMS\Adminpanel\View\AdminPanelView` +* :php:`TYPO3\CMS\Backend\Controller\LoginFramesetController` +* :php:`TYPO3\CMS\Backend\Form\Form\FieldWizard\FileThumbnails` +* :php:`TYPO3\CMS\Backend\Form\Form\FieldWizard\FileTypeList` +* :php:`TYPO3\CMS\Backend\Form\Form\FieldWizard\FileUpload` +* :php:`TYPO3\CMS\Backend\Http\AjaxRequestHandler` +* :php:`TYPO3\CMS\Backend\Module\AbstractFunctionModule` +* :php:`TYPO3\CMS\Backend\Module\AbstractModule` +* :php:`TYPO3\CMS\Backend\Module\BaseScriptClass` +* :php:`TYPO3\CMS\Backend\RecordList\AbstractRecordList` +* :php:`TYPO3\CMS\Core\Cache\Frontend\StringFrontend` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\AbstractComposedSalt` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\ExtensionManagerConfigurationUtility` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\SaltedPasswordService` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\SaltedPasswordsUtility` +* :php:`TYPO3\CMS\Core\Encoder\JavaScriptEncoder` +* :php:`TYPO3\CMS\Core\FrontendEditing\FrontendEditingController` +* :php:`TYPO3\CMS\Core\Integrity\DatabaseIntegrityCheck` +* :php:`TYPO3\CMS\Core\Log\Writer\RuntimeCacheWriter` +* :php:`TYPO3\CMS\Core\Package\DependencyResolver` +* :php:`TYPO3\CMS\Core\PageTitle\AltPageTitleProvider` +* :php:`TYPO3\CMS\Core\Resource\Service\UserStorageCapabilityService` +* :php:`TYPO3\CMS\Core\Resource\Utility\BackendUtility` +* :php:`TYPO3\CMS\Core\Site\Entity\PseudoSite` +* :php:`TYPO3\CMS\Core\Site\PseudoSiteFinder` +* :php:`TYPO3\CMS\Core\TypoScript\ConfigurationForm` +* :php:`TYPO3\CMS\Core\Utility\ClientUtility` +* :php:`TYPO3\CMS\Core\Utility\PhpOptionsUtility` +* :php:`TYPO3\CMS\Extbase\Command\CoreCommand` +* :php:`TYPO3\CMS\Extbase\Command\ExtbaseCommand` +* :php:`TYPO3\CMS\Extbase\Command\HelpCommand` +* :php:`TYPO3\CMS\Extbase\Command\HelpCommandController` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\Command` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\CommandArgumentDefinition` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\CommandManager` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\ConsoleOutput` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\Request` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\RequestBuilder` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\RequestHandler` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\Response` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\Controller\CommandController` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\AmbiguousCommandIdentifierException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\CommandException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\NoSuchCommandException` +* :php:`TYPO3\CMS\Extbase\Scheduler\FieldProvider` +* :php:`TYPO3\CMS\Extbase\Scheduler\Task` +* :php:`TYPO3\CMS\Extbase\Scheduler\TaskExecutor` +* :php:`TYPO3\CMS\Extbase\Tests\Unit\Validation\Validator\AbstractValidatorTestcase` +* :php:`TYPO3\CMS\Extbase\Validation\Validator\RawValidator` +* :php:`TYPO3\CMS\Extensionmanager\Command\ExtensionCommandController` +* :php:`TYPO3\CMS\Form\Domain\Model\FormElements\GridContainer` +* :php:`TYPO3\CMS\Frontend\ContentObject\FileContentObject` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\GraphicalMenuContentObject` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\ImageMenuContentObject` +* :php:`TYPO3\CMS\Frontend\Http\EidRequestHandler` +* :php:`TYPO3\CMS\Frontend\Page\ExternalPageUrlHandler` +* :php:`TYPO3\CMS\Frontend\Page\PageGenerator` +* :php:`TYPO3\CMS\Frontend\Utility\EidUtility` +* :php:`TYPO3\CMS\Recordlist\Controller\ElementBrowserFramesetController` +* :php:`TYPO3\CMS\Recordlist\RecordList\AbstractDatabaseRecordList` +* :php:`TYPO3\CMS\Workspaces\Service\AutoPublishService` +* :php:`TYPO3\CMS\Workspaces\Task\AutoPublishTask` +* :php:`TYPO3\CMS\Workspaces\Task\CleanupPreviewLinkTask` + + +The following PHP interfaces that have been previously deprecated for v9 have been removed: + +* :php:`TYPO3\CMS\Adminpanel\View\AdminPanelViewHookInterface` +* :php:`TYPO3\CMS\Extbase\Mvc\Cli\Controller\CommandControllerInterface` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\ComposedPasswordHashInterface` +* :php:`TYPO3\CMS\Frontend\Http\UrlHandlerInterface` + + +The following PHP class aliases that have been previously deprecated for v9 have been removed: + +* :php:`TYPO3\CMS\Backend\AjaxLoginHandler` +* :php:`TYPO3\CMS\Backend\Form\Wizard\ImageManipulationWizard` +* :php:`TYPO3\CMS\Core\Database\PdoHelper` +* :php:`TYPO3\CMS\Core\IO\PharStreamWrapper` +* :php:`TYPO3\CMS\Core\IO\PharStreamWrapperException` +* :php:`TYPO3\CMS\Core\History\RecordHistory` +* :php:`TYPO3\CMS\Core\Tree\TableConfiguration\ExtJsArrayTreeRenderer` +* :php:`TYPO3\CMS\ContextHelp\Controller\ContextHelpAjaxController` +* :php:`TYPO3\CMS\Cshmanual\Domain\Repository\TableManualRepository` +* :php:`TYPO3\CMS\Extbase\Configuration\Exception\ContainerIsLockedException` +* :php:`TYPO3\CMS\Extbase\Configuration\Exception\NoSuchFileException` +* :php:`TYPO3\CMS\Extbase\Configuration\Exception\NoSuchOptionException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidCommandIdentifierException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidMarkerException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidOrNoRequestHashException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestTypeException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidTemplateResourceException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidUriPatternException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidViewHelperException` +* :php:`TYPO3\CMS\Extbase\Mvc\Exception\RequiredArgumentMissingException` +* :php:`TYPO3\CMS\Extbase\Object\Container\Exception\CannotInitializeCacheException` +* :php:`TYPO3\CMS\Extbase\Object\Container\Exception\TooManyRecursionLevelsException` +* :php:`TYPO3\CMS\Extbase\Object\Exception\WrongScopeException` +* :php:`TYPO3\CMS\Extbase\Object\InvalidClassException` +* :php:`TYPO3\CMS\Extbase\Object\InvalidObjectConfigurationException` +* :php:`TYPO3\CMS\Extbase\Object\InvalidObjectException` +* :php:`TYPO3\CMS\Extbase\Object\ObjectAlreadyRegisteredException` +* :php:`TYPO3\CMS\Extbase\Object\UnknownClassException` +* :php:`TYPO3\CMS\Extbase\Object\UnknownInterfaceException` +* :php:`TYPO3\CMS\Extbase\Object\UnresolvedDependenciesException` +* :php:`TYPO3\CMS\Extbase\Persistence\Generic\Exception\CleanStateNotMemorizedException` +* :php:`TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidPropertyTypeException` +* :php:`TYPO3\CMS\Extbase\Persistence\Generic\Exception\MissingBackendException` +* :php:`TYPO3\CMS\Extbase\Property\Exception\FormatNotSupportedException` +* :php:`TYPO3\CMS\Extbase\Property\Exception\InvalidFormatException` +* :php:`TYPO3\CMS\Extbase\Property\Exception\InvalidPropertyException` +* :php:`TYPO3\CMS\Extbase\Reflection\Exception\InvalidPropertyTypeException` +* :php:`TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForRequestHashGenerationException` +* :php:`TYPO3\CMS\Extbase\Security\Exception\SyntacticallyWrongRequestHashException` +* :php:`TYPO3\CMS\Extbase\Service\FlexFormService` +* :php:`TYPO3\CMS\Extbase\Service\TypoScriptService` +* :php:`TYPO3\CMS\Extbase\Validation\Exception\InvalidSubjectException` +* :php:`TYPO3\CMS\Extbase\Validation\Exception\NoValidatorFoundException` +* :php:`TYPO3\CMS\Frontend\Controller\PageInformationController` +* :php:`TYPO3\CMS\Frontend\Controller\TranslationStatusController` +* :php:`TYPO3\CMS\Frontend\View\AdminPanelView` +* :php:`TYPO3\CMS\Frontend\View\AdminPanelViewHookInterface` +* :php:`TYPO3\CMS\Fluid\Core\Compiler\TemplateCompiler` +* :php:`TYPO3\CMS\Fluid\Core\Exception` +* :php:`TYPO3\CMS\Fluid\Core\Parser\SyntaxTree\AbstractNode` +* :php:`TYPO3\CMS\Fluid\Core\Parser\InterceptorInterface` +* :php:`TYPO3\CMS\Fluid\Core\Parser\SyntaxTree\NodeInterface` +* :php:`TYPO3\CMS\Fluid\Core\Parser\SyntaxTree\RootNode` +* :php:`TYPO3\CMS\Fluid\Core\Parser\SyntaxTree\ViewHelperNode` +* :php:`TYPO3\CMS\Fluid\Core\Rendering\RenderingContextInterface` +* :php:`TYPO3\CMS\Fluid\Core\Variables\CmsVariableProvider` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\AbstractConditionViewHelper` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\AbstractViewHelper` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\ArgumentDefinition` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\Exception` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\Exception\InvalidVariableException` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\Facets\ChildNodeAccessInterface` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\Facets\CompilableInterface` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\Facets\PostParseInterface` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\TagBuilder` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\TemplateVariableContainer` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\ViewHelperInterface` +* :php:`TYPO3\CMS\Fluid\Core\ViewHelper\ViewHelperVariableContainer` +* :php:`TYPO3\CMS\Fluid\View\Exception` +* :php:`TYPO3\CMS\Fluid\View\Exception\InvalidSectionException` +* :php:`TYPO3\CMS\Fluid\View\Exception\InvalidTemplateResourceException` +* :php:`TYPO3\CMS\InfoPagetsconfig\Controller\InfoPageTyposcriptConfigController` +* :php:`TYPO3\CMS\Lang\LanguageService` +* :php:`TYPO3\CMS\Lowlevel\Command\WorkspaceVersionRecordsCommand` +* :php:`TYPO3\CMS\Lowlevel\View\ConfigurationView` +* :php:`TYPO3\CMS\Recordlist\RecordList` +* :php:`TYPO3\CMS\Saltedpasswords\Exception\InvalidSaltException` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\AbstractSalt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\AbstractComposedSalt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\Argon2iSalt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\BcryptSalt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\BlowfishSalt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\ComposedSaltInterface` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\Md5Salt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\SaltFactory` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\SaltInterface` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\Pbkdf2Salt` +* :php:`TYPO3\CMS\Saltedpasswords\Salt\PhpassSalt` +* :php:`TYPO3\CMS\Saltedpasswords\SaltedPasswordService` +* :php:`TYPO3\CMS\Saltedpasswords\Utility\ExensionManagerConfigurationUtility` +* :php:`TYPO3\CMS\Saltedpasswords\Utility\SaltedPasswordsUtility` +* :php:`TYPO3\CMS\Sv\AbstractAuthenticationService` +* :php:`TYPO3\CMS\Sv\AuthenticationService` +* :php:`TYPO3\CMS\Sv\Report\ServicesListReport` +* :php:`TYPO3\CMS\T3editor\CodeCompletion` +* :php:`TYPO3\CMS\T3editor\TypoScriptReferenceLoader` +* :php:`TYPO3\CMS\Version\DataHandler\CommandMap` +* :php:`TYPO3\CMS\Version\Dependency\DependencyEntityFactory` +* :php:`TYPO3\CMS\Version\Dependency\DependencyResolver` +* :php:`TYPO3\CMS\Version\Dependency\ElementEntity` +* :php:`TYPO3\CMS\Version\Dependency\ElementEntityProcessor` +* :php:`TYPO3\CMS\Version\Dependency\EventCallback` +* :php:`TYPO3\CMS\Version\Dependency\ReferenceEntity` +* :php:`TYPO3\CMS\Version\Hook\DataHandlerHook` +* :php:`TYPO3\CMS\Version\Hook\PreviewHook` +* :php:`TYPO3\CMS\Version\Utility\WorkspacesUtility` + + +The following PHP class methods that have been previously deprecated for v9 have been removed: + +* :php:`TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider->foreignTranslationTable()` +* :php:`TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider->getTranslationTable()` +* :php:`TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider->isTranslationInOwnTable()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->evaluateCondition($string)` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getVariable($var)` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getGroupList()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getPage()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->isNewPageWithPageId($pageId)` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->determineRootline()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getUserId()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->isUserLoggedIn()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->isAdminUser()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getBackendUserAuthentication()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->determinePageId()` +* :php:`TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getPageIdByRecord()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->main()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->main()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->removeInvalidElements()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->wizard_appendWizards()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->wizard_getItem()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->wizard_getGroupHeader()` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->wizardArray()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->compileStoreDat()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->doProcessData()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->getNewIconMode()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->openInNewWindowLink()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->setDocument()` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->shortCutLink()` +* :php:`TYPO3\CMS\Backend\Controller\EditFileController->getButtons()` +* :php:`TYPO3\CMS\Backend\Controller\File\FileController->finish()` +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->main()` +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->renderUploadForm()` +* :php:`TYPO3\CMS\Backend\Controller\File\RenameFileController->main()` +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->main()` +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->initPage()` +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->main()` +* :php:`TYPO3\CMS\Backend\Controller\LoginController->main()` +* :php:`TYPO3\CMS\Backend\Controller\LoginController->makeInterfaceSelectorBox()` +* :php:`TYPO3\CMS\Backend\Controller\LogoutController->logout()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->isTableAllowedForThisPage()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->linkWrap()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->main()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->pagesonly()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->regularNew()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->showNewRecLink()` +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->sortNewRecordsByConfig()` +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->main()` +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->initClipboard()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->main()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\EditController->main()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\ListController->main()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->cfgArray2CfgString()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->cfgString2CfgArray()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->changeFunc()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->getConfigCode()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->getTableHTML()` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->tableWizard()` +* :php:`TYPO3\CMS\Backend\Controller\UserSettingsController->process()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->initializeAdminPanel()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->initializeFrontendEdit()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->isFrontendEditingActive()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->displayAdminPanel()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->isAdminPanelVisible()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->checkBackendAccessSettingsFromInitPhp()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->extPageReadAccess()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->extGetTreeList()` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->extGetLL()` +* :php:`TYPO3\CMS\Backend\Routing\UriBuilder->buildUriFromModule()` +* :php:`TYPO3\CMS\Backend\Template\DocumentTemplate->addStyleSheet()` +* :php:`TYPO3\CMS\Backend\Template\DocumentTemplate->formWidth()` +* :php:`TYPO3\CMS\Backend\Template\DocumentTemplate->xUaCompatible()` +* :php:`TYPO3\CMS\Backend\Template\ModuleTemplate->icons()` +* :php:`TYPO3\CMS\Backend\Template\ModuleTemplate->loadJavascriptLib()` +* :php:`TYPO3\CMS\Backend\Tree\View\ElementBrowserFolderTreeView->ext_isLinkable()` +* :php:`TYPO3\CMS\Backend\Tree\View\AbstractTreeView->setDataFromArray()` +* :php:`TYPO3\CMS\Backend\Tree\View\AbstractTreeView->setDataFromTreeArray()` +* :php:`TYPO3\CMS\Backend\Tree\View\PagePositionMap->getModConfig()` +* :php:`TYPO3\CMS\Backend\View\PageLayoutView->languageFlag()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService->compareUident()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->compareUident()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->fetchUserRecord()` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->addTScomment()` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->getTSConfigProp()` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->getTSConfigVal()` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->isPSet()` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->simplelog()` +* :php:`TYPO3\CMS\Core\Cache\PhpFrontend->getByTag()` +* :php:`TYPO3\CMS\Core\Cache\VariableFrontend->getByTag()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->convArray()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->convCaseFirst()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->crop()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->entities_to_utf8()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->parse_charset()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->utf8_char2byte_pos()` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->utf8_to_entities()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->strictSyntaxEnabled()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->normalizeExpression($expression)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->evaluateConditionCommon($key, $value)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->evaluateCustomDefinedCondition($condition)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->parseUserFuncArguments($arguments)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getVariableCommon(array $vars)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->compareNumber($test, $leftValue)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->searchStringWildcard($haystack, $needle)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getGlobal($var, $source = null)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->evaluateCondition($string)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getVariable($name)` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getGroupList()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->determinePageId()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getPage()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->determineRootline()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->getUserId()` +* :php:`TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher->isUserLoggedIn()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->__construct()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->configure()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->createApplicationContext()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->checkIfEssentialConfigurationExists()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->defineTypo3RequestTypes()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->disableCoreCaches()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->getEarlyInstance()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->getEarlyInstances()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->getInstance()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->initializeCachingFramework()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->initializePackageManagement()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->loadConfigurationAndInitialize()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->populateLocalConfiguration()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->setEarlyInstance()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->setFinalCachingFrameworkCacheConfiguration()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->setRequestType()` +* :php:`TYPO3\CMS\Core\Core\Bootstrap->usesComposerClassLoading()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash->getOptions()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash->setOptions()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash->getOptions()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash->setOptions()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->getHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->getMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->getMinHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->getSaltLength()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->getSetting()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->setHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->setMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishSalt->setMinHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash->getSetting()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash->getSaltLength()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getMinHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getSaltLength()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getSetting()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->setHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->setMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->setMinHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getMinHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getSaltLength()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getSetting()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->setHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->setMaxHashCount()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->setMinHashCount()` +* :php:`TYPO3\CMS\Core\Database\ReferenceIndex->createEntryData_fileRels()` +* :php:`TYPO3\CMS\Core\Database\ReferenceIndex->createEntryDataForFileRelationsUsingRecord()` +* :php:`TYPO3\CMS\Core\Database\ReferenceIndex->destPathFromUploadFolder()` +* :php:`TYPO3\CMS\Core\Database\ReferenceIndex->getRelations_procFiles()` +* :php:`TYPO3\CMS\Core\Database\ReferenceIndex->setReferenceValue_fileRels()` +* :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex->getPageIdFromAlias()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->checkValue_group_select_file()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->copyRecord_fixRTEmagicImages()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->copyRecord_procFilesRefs()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->deleteRecord_flexFormCallBack()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->extFileFields()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->extFileFunctions()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->getTCEMAIN_TSconfig()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->newlog2()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->process_uploads_traverseArray()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->removeRegisteredFiles()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->resorting()` +* :php:`TYPO3\CMS\Core\Imaging\GraphicalFunctions->init()` +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->transformStyledATags()` +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_links_rte()` +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->urlInfoForLinkTags()` +* :php:`TYPO3\CMS\Core\Package\PackageManager->injectDependencyResolver()` +* :php:`TYPO3\CMS\Core\Page\PageRenderer->addMetaTag()` +* :php:`TYPO3\CMS\Core\Page\PageRenderer->disableConcatenateFiles()` +* :php:`TYPO3\CMS\Core\Page\PageRenderer->enableConcatenateFiles()` +* :php:`TYPO3\CMS\Core\Page\PageRenderer->getConcatenateFiles()` +* :php:`TYPO3\CMS\Core\Page\PageRenderer->loadJquery()` +* :php:`TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver->getCharsetConversion()` +* :php:`TYPO3\CMS\Core\Resource\ResourceStorage->dumpFileContents()` +* :php:`TYPO3\CMS\Core\Service\AbstractService->devLog()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->getFileName()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->getFromMPmap()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->init()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->initMPmap_create()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->linkData()` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->printTitle()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->_GETset()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->arrayToLogString()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->clientInfo()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->deprecationLog()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->devLog()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->getDeprecationLogFileName()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->getHostname()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->getUserObj()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->initSysLog()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->llXmlAutoFileName()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->logDeprecatedFunction()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->logDeprecatedViewHelperAttribute()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->sysLog()` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->unQuoteFilenames()` +* :php:`TYPO3\CMS\Extbase\Core\Bootstrap->configureObjectManager()` +* :php:`TYPO3\CMS\Extbase\Mvc\Controller\Argument->getValidationResults()` +* :php:`TYPO3\CMS\Extbase\Mvc\Controller\Arguments->getValidationResults()` +* :php:`TYPO3\CMS\Extbase\Service\EnvironmentService->isEnvironmentInCliMode()` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility->processDatabaseUpdates()` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility->updateDbWithExtTablesSql()` +* :php:`TYPO3\CMS\Fluid\Core\Widget\Bootstrap->configureObjectManager()` +* :php:`TYPO3\CMS\Filelist\FileFacade->getIcon()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->evaluateCondition($string)` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getVariable($var)` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getSessionVariable(string $var)` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getGroupList()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->determinePageId()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getPage()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->determineRootline()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getUserId()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->isUserLoggedIn()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getTypoScriptFrontendController()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getCurrentSiteLanguage()` +* :php:`TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher->getCurrentSite()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->addParams()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->calcIntExplode()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->currentPageUrl()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->enableFields()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->filelink()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->filelist()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->typolinkWrap()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_addParams()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_filelink()` +* :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_filelist()` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->extProc_beforeAllWrap()` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->extProc_beforeLinking()` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->extProc_init()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->addTempContentHttpHeaders()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkAlternativeIdMethods()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkPageForMountpointRedirect()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkPageForShortcutRedirect()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkPageUnavailableHandler()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->connectToDB()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->convertCharsetRecursivelyToUtf8()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->convPOSTCharset()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->domainNameMatchesCurrentRequest()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getDomainDataForPid()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getDomainNameForPid()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getLLL()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getPageShortcut()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getUniqueId()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->handleDataSubmission()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->hook_eofe()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initFEuser()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initializeBackendUser()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initializeRedirectUrlHandlers()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initLLvars()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initTemplate()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->makeCacheHash()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->mergingWithGetVars()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageErrorHandler()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundAndExit()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageNotFoundHandler()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableAndExit()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageUnavailableHandler()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->previewInfo()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->processOutput()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->readLLfile()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->redirectToCurrentPage()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->redirectToExternalUrl()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sendCacheHeaders()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sendHttpHeadersDirectly()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->setCSS()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->storeSessionData()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getFirstWebPage()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getDomainStartPage()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getPageIdFromAlias()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getRootLine()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getRecordsByField()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->deleteClause()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->checkWorkspaceAccess()` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getFileReferences()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->checkExtObj()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->checkSubExtObj()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->extObjContent()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->extObjHeader()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->getExtObjContent()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->getExternalItemConfig()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->handleExternalFunctionValue()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->menuConfig()` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->mergeExternalItems()` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->extObjHeader()` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->checkSubExtObj()` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->getModuleTemplate()` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->checkExtObj()` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->extObjContent()` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->checkExtObj()` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->extObjContent()` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->checkExtObj()` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->getSystemLanguages()` +* :php:`TYPO3\CMS\Install\Service\CoreVersionService->getDownloadBaseUrl()` +* :php:`TYPO3\CMS\Install\Service\CoreVersionService->isYoungerPatchDevelopmentReleaseAvailable()` +* :php:`TYPO3\CMS\Install\Service\CoreVersionService->getYoungestPatchDevelopmentRelease()` +* :php:`TYPO3\CMS\Install\Service\CoreVersionService->updateVersionMatrix()` +* :php:`TYPO3\CMS\Lowlevel\Integrity\DatabaseIntegrityCheck->getFileFields()` +* :php:`TYPO3\CMS\Lowlevel\Integrity\DatabaseIntegrityCheck->testFileRefs()` +* :php:`TYPO3\CMS\Lowlevel\Integrity\DatabaseIntegrityCheck->whereIsFileReferenced()` +* :php:`TYPO3\CMS\Recordlist\Controller\ElementBrowserController->main()` +* :php:`TYPO3\CMS\Rsaauth\RsaEncryptionEncode[r->getRsaPublicKeyAjaxHandler()` +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->getFormProtection()` +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->simulateUser()` + + +The following PHP static class methods that have been previously deprecated for v9 have been removed: + +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::deleteClause()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::firstDomainRecord()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getDomainStartPage()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getHash()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getListGroupNames()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getModTSconfig()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getModuleUrl()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getOriginalTranslationTable()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getPidForModTSconfig()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getTCAtypes()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::shortcutExists()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::storeHash()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::unsetMenuItems()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory::determineSaltingHashingMethod()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory::getSaltingInstance()` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory::setPreferredHashingMethod()` +* :php:`TYPO3\CMS\Core\Context\LanguageAspectFactory::createFromTypoScript()` +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::configureModule()` +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::getExtensionKeyByPrefix()` +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::removeCacheFiles()` +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::siteRelPath()` +* :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::getActionMethodParameters()` + + +The following PHP methods have been additionally deprecated and are a no-op now: + +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->process_uploads()` + + +The following methods changed signature according to previous deprecations in v9 at the end of the argument list: + +* :php:`TYPO3\CMS\Backend\Http\RouteDispatcher->dispatch()` - Second argument dropped +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfig()` - Second and third argument dropped +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->modAccess()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->getTSConfig()` - First and second argument dropped +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->conv()` - Fourth argument dropped +* :php:`TYPO3\CMS\Core\Core\Bootstrap->checkIfEssentialConfigurationExists()` - First argument mandatory +* :php:`TYPO3\CMS\Core\Core\Bootstrap->populateLocalConfiguration()` - First argument mandatory +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash->getHashedPassword()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash->getHashedPassword()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->getHashedPassword()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->getHashedPassword()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Http\Dispatcher->dispatch()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Package\PackageManager->__construct()` - First argument mandatory +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_AtagToAbs()` - Second argument dropped and protected +* :php:`TYPO3\CMS\Core\Page\PageRenderer::addInlineLanguageLabelArray()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->explodeUrl2Array()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->getUrl()` - Third argument must be an array of arrays if given +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility->mkdir_deep()` - Second argument dropped +* :php:`TYPO3\CMS\Core\Utility\RootlineUtility->__construct()` - Third optional argument now has to be Context object or null +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->getRawRecord()` - Fourth argument dropped +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->__construct()` - Fourth argument unused +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->calculateLinkVars()` - First argument mandatory +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->preparePageContentGeneration()` - First argument mandatory +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->main()` - First argument mandatory + + +The following public class properties have been dropped: + +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->access` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->pageInfo` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->table` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->type` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->uid` +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->doc` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->cacheCmd` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->content` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->doc` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->MCONF` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->popViewId_addParams` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->redirect` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->template` +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->content` +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->title` +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->title` +* :php:`TYPO3\CMS\Backend\Controller\File\RenameFileController->content` +* :php:`TYPO3\CMS\Backend\Controller\File\RenameFileController->title` +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->content` +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->doc` +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->title` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->content` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\ListController->id` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\ListController->P` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\ListController->pid` +* :php:`TYPO3\CMS\Backend\Controller\Wizard\ListController->table` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->extAdmEnabled` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->adminPanel` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->frontendEdit` +* :php:`TYPO3\CMS\Backend\FrontendBackendUserAuthentication->extAdminConfig` +* :php:`TYPO3\CMS\Backend\Template\DocumentTemplate->hasDocheader` +* :php:`TYPO3\CMS\Backend\Tree\View\AbstractTreeView->data` +* :php:`TYPO3\CMS\Backend\Tree\View\AbstractTreeView->dataLookup` +* :php:`TYPO3\CMS\Backend\Tree\View\AbstractTreeView->subLevelID` +* :php:`TYPO3\CMS\Backend\Tree\View\PagePositionMap->getModConfigCache` +* :php:`TYPO3\CMS\Backend\Tree\View\PagePositionMap->modConfigStr` +* :php:`TYPO3\CMS\Backend\View\PageLayoutView->languageIconTitles` +* :php:`TYPO3\CMS\Backend\View\PageLayoutView->translateTools` +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->userTS_dontGetCached` +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->synonyms` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->alternativeFileName` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->alternativeFilePath` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->autoVersioningUpdate` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->bypassFileHandling` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->copiedFileMap` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->filefunc` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->removeFilesStore` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->RTEmagic_copyIndex` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->updateModeL10NdiffData` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->updateModeL10NdiffDataClear` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->uploadedFileArray` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->allowedPaths` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->debug` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->fileCache` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->frames` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->MPmap` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->whereClause` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->activeUrlHandlers` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->ADMCMD_preview_BEUSER_uid` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->altPageTitle` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->beUserLogin` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->debug` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->gr_list` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->lang` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->loginUser` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->MP_defaults` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->page_cache_reg1` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->showHiddenPage` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->showHiddenRecords` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->siteScript` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sys_language_content` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sys_language_contentOL` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sys_language_mode` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sys_language_uid` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->workspacePreview` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->debug` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->GMENU_fixKey` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->imgNameNotRandom` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->imgNamePrefix` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->INPfixMD5` +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->nameAttribute` +* :php:`TYPO3C\MS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMfreezePrefix` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->error_getRootLine_failPid` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->error_getRootLine` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->versioningPreview` +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->workspaceCache` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->CMD` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->content` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->doc` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->extClassConf` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->extObj` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->MCONF` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->MOD_MENU` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->MOD_SETTINGS` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->modMenu_dontValidateList` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->modMenu_setDefaultList` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->modMenu_type` +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->modTSconfig` +* :php:`TYPO3\CMS\Impexp\Export->maxFileSize` +* :php:`TYPO3\CMS\Impexp\Export->maxRecordSize` +* :php:`TYPO3\CMS\Impexp\Export->maxExportSize` +* :php:`TYPO3\CMS\IndexedSearch\Lexer->csObj` +* :php:`TYPO3\CMS\IndexedSearch\Indexer->csObj` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->CMD` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->doc` +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->MCONF` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->extClassConf` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->extObj` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->function_key` +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->localLangFile` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->extClassConf` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->extObj` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->function_key` +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->localLangFile` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->extClassConf` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->extObj` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->function_key` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->localLangFile` +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->pObj` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->addProperty` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->setModelType` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->getModelType` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->setUuidPropertyName` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->getUuidPropertyName` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->markAsIdentityProperty` +* :php:`TYPO3\CMS\Extbase\Reflection\ClassSchema->getIdentityProperties` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getClassTagsValues` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getClassTagValues` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getClassPropertyNames` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->hasMethod` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getMethodTagsValues` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getMethodParameters` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getPropertyTagsValues` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->getPropertyTagValues` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->isClassTaggedWith` +* :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService->isPropertyTaggedWith` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->buildMethodArgumentsValidatorConjunctions` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->buildSubObjectValidator` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->parseValidatorAnnotation` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->parseValidatorOptions` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->unquoteString` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver->getMethodValidateAnnotations` +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->doc` +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->imagemode` +* :php:`TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList->newWizards` + + + +The following class methods have changed visibility: + +* :php:`TYPO3\CMS\Backend\Controller\BackendController->render()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->getLabelForTableColumn` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->init()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\ElementInformationController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->init()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->init()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->closeDocument()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->compileForm()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->fixWSversioningInEditConf()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->getLanguages()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->getRecordForEdit()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->init()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->languageSwitch()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->localizationRedirect()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->makeEditForm()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->preInit()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->processData()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\FileController->initClipboard()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\FileController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->clearCache()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->contentIsNotLockedForEditors()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->getLocalizedPageTitle()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->getModuleTemplate()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->getNumberOfHiddenElements()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->init()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->local_linkThisScript()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->main()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->pageIsNotLockedForEditors()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->renderContent()` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->init()` changed from public to protected +* :php:`TYPO3\CMS\Beuser\Controller\BackendUserController->initializeView()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash->base64Encode()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash->isValidSalt()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash->base64Encode()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash->isValidSalt()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->base64Encode()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash->isValidSalt()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->base64Encode()` changed from public to protected +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash->isValidSalt()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_images_db()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_links_db()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_transform_db()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_transform_rte()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->HTMLcleaner_db()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->getKeepTags()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->divideIntoLines()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->setDivTags()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->getWHFromAttribs()` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_AtagToAbs()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->flattenSetup()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->mergeConstantsFromPageTSconfig()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->prependStaticExtra()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->processIncludes()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->substituteConstants()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->versionOL()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->error()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->nextDivider()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->parseSub()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->regHighLight()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->rollParseSub()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->setVal()` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->syntaxHighlight_print()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->initializeView()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->initializeIndexAction()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->indexAction()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->missingFolderAction()` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->searchAction()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->accessKey()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->changeLinksForAccessRestrictedPages()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->getBannedUids()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->getDoktypeExcludeWhere()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->getMPvar()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->getPageTitle()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->isActive()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->isCurrent()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->isItemState()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->isNext()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->isSubMenu()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->link()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->menuTypoLink()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->procesItemStates()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->setATagParts()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->subMenu()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->userProcess()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->extProc_afterLinking()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->extProc_finish()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\TextMenuContentObject->getBeforeAfter()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->clearPageCacheContent_pidList()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->contentStrReplace()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->realPageCacheContent()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->setPageCacheContent()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->setSysLastChanged()` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->tempPageCacheContent()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->addRecordsForPid()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->checkUpload()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->exec_listQueryPid()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->exportData()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->filterPageIds()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->getTableSelectOptions()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->importData()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->init()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->main()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->makeAdvancedOptionsForm()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->makeConfigurationForm()` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->makeSaveForm()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->checkExtObj()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->extObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->getExternalItemConfig()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->getExtObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->init()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->main()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->mergeExternalItems()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->modMenu()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->modMenu()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->extObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->getContentElementCount()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->getLangStatus()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->renderL10nTable()` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\TranslationStatusController->modMenu()` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->extObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\AbstractLinkBrowserController->getDisplayedLinkHandlerId()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\AbstractLinkBrowserController->renderLinkAttributeFields()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->init()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->clearCache()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->main()` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->getModuleTemplate()` changed from public to protected +* :php:`TYPO3\CMS\Reports\Controller\ReportController->detailAction()` changed from public to protected +* :php:`TYPO3\CMS\Reports\Controller\ReportController->indexAction()` changed from public to protected +* :php:`TYPO3\CMS\RteCKEditor\Controller\BrowseLinksController->renderLinkAttributeFields()` changed from public to protected +* :php:`TYPO3\CMS\RteCKEditor\Controller\BrowseLinksController->getPageConfigLabel()` changed from public to protected +* :php:`TYPO3\CMS\RteCKEditor\Controller\BrowseLinksController->getDisplayedLinkHandlerId()` changed from public to protected +* :php:`TYPO3\CMS\Scheduler\Controller\SchedulerModuleController->addMessage()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->mergeExternalItems()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->getExternalItemConfig()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->main()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->urlInIframe()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->extObjHeader()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->checkSubExtObj()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->checkExtObj()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->extObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->getExtObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->checkExtObj()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->checkSubExtObj()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->clearCache()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->extObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->extObjHeader()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->getExternalItemConfig()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->getExtObjContent()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->init()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->main()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->menuConfig()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->mergeExternalItems()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->setInPageArray()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->initialize_editor()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->modMenu()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->initialize_editor()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->initialize_editor()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->tableRowData()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->initialize_editor()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->handleExternalFunctionValue()` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->modMenu()` changed from public to protected + + +The following class properties have changed visibility: + +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->input_moveUid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->makeCopy` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->moveUid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->page_id` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->R_URI` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->sys_language` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\MoveElementController->table` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->access` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->config` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->colPos` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->id` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->R_URI` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->sys_language` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\ContentElement\NewContentElementController->uid_pid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->closeDoc` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->cmd` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->columnsOnly` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->defVals` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->docDat` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->docHandler` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->dontStoreDocumentRef` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->doSave` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->editconf` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->errorC` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->firstEl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->mirror` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->newC` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->noView` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->overrideVals` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->pageinfo` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->popViewId` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->recTitle` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->retUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->returnEditConf` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->returnNewPageId` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->R_URI` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->R_URL_getvars` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->R_URL_parts` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->storeArray` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->storeTitle` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->storeUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->storeUrlMd5` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->uc` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->viewId` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->viewId_addParams` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->viewUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->folderNumber` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->number` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\CreateFolderController->target` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->origTarget` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->target` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->title` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\EditFileController->doc` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\FileUploadController->target` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\RenameFileController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\RenameFileController->target` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\File\ReplaceFileController->uid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->foldertree` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->currentSubScript` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\FileSystemNavigationFrameController->cMR` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->allowedNewTables` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->allowedNewTables_pid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->code` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->deniedNewTables` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->deniedNewTables_pid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->newContentInto` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->newPagesAfter` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->newPagesInto` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->pageinfo` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->pagesOnly` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->pidInfo` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->R_URI` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->tRows` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->web_list_modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\NewRecordController->web_list_modTSconfig_pid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->activeColPosList` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->CALC_PERMS` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->clear_cache` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->colPosList` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->current_sys_language` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->descrTable` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->EDIT_CONTENT` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->imagemode` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->MCONF` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->MOD_MENU` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->modSharedTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->pointer` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->popView` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->search_field` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->search_levels` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\PageLayoutController->showLimit` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->cacheCmd` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->CB` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->cmd` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->data` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->flags` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->mirror` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->redirect` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\SimpleDataHandlerController->tce` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->id` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->P` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->pid` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->processDataFlag` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->returnEditConf` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\AddController->table` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\EditController->doClose` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\EditController->P` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->content` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->inputStyle` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->xmlStorage` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->numNewRows` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->colsFieldName` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->P` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->TABLECFG` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->tableParsing_quote` changed from public to protected +* :php:`TYPO3\CMS\Backend\Controller\Wizard\TableController->tableParsing_delimiter` changed from public to protected +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->checkWorkspaceCurrent_cache` changed from public to protected +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->TSdataArray` changed from public to protected +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->userTS` changed from public to protected +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->userTSUpdated` changed from public to protected +* :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->userTS_text` has been removed +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->eucBasedSets` changed from public to protected +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->noCharByteVal` changed from public to protected +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->parsedCharsets` changed from public to protected +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->toASCII` changed from public to protected +* :php:`TYPO3\CMS\Core\Charset\CharsetConverter->twoByteSets` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->allowedClasses` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->blockElementList` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->elRef` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->getKeepTags_cache` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->procOptions` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->recPid` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->TS_transform_db_safecounter` changed from public to protected +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->tsConfig` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->absoluteRootLine` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->matchAll` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->nextLevel` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->outermostRootlineIndexWithTemplate` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->rootId` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->rowSum` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->sectionsMatch` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->simulationHiddenOrTime` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->sitetitle` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->commentSet` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->highLightBlockStyles` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->highLightBlockStyles_basecolor` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->highLightData` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->highLightData_bracelevel` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->highLightStyles` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->inBrace` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->lastComment` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->lastConditionTrue` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->multiLineEnabled` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->multiLineObject` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->multiLineValue` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->raw` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->rawP` changed from public to protected +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->syntaxHighLight` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->MOD_MENU` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->MOD_SETTINGS` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->doc` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->id` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->pointer` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->table` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->imagemode` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->cmd` changed from public to protected +* :php:`TYPO3\CMS\Filelist\Controller\FileListController->filelist` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cacheContentFlag` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cacheExpires` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cacheTimeOutDefault` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->isClientCachable` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->loginAllowedInBranch` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->loginAllowedInBranch_mode` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->no_cacheBeforePageGen` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageAccessFailureHistory` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pageCacheTags` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->pagesTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->tempContent` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->uniqueCounter` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->uniqueString` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->alternativeMenuTempArray` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->alwaysActivePIDlist` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->conf` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->doktypeExcludeList` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->entryLevel` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->hash` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->id` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->I` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->mconf` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->menuArr` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->menuNumber` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->nextActive` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->MP_array` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->result` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->rL_uidRegister` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->spacerIDList` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->sys_page` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->tmpl` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMcObj` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMextraScript` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMmenuItems` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMresult` changed from public to protected +* :php:`TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject->WMsubmenuObjSuffixes` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->sys_language_uid` changed from public to protected +* :php:`TYPO3\CMS\Frontend\Page\PageRepository->versioningWorkspaceId` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->id` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->pageinfo` changed from public to protected +* :php:`TYPO3\CMS\Impexp\Controller\ImportExportController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->content` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->extObj` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->id` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->modMenu_dontValidateList` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->modMenu_setDefaultList` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->modMenu_type` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoModuleController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\PageInformationController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Info\Controller\InfoPageTyposcriptConfigController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->pObj` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->doc` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->function_key` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->localLangFile` changed from public to protected +* :php:`TYPO3\CMS\Linkvalidator\Report\LinkValidatorReport->extObj` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->id` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->pointer` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->table` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->search_field` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->search_levels` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->showLimit` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->returnUrl` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->clear_cache` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->cmd` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->cmd_table` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->pageinfo` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->MOD_MENU` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->content` changed from public to protected +* :php:`TYPO3\CMS\Recordlist\Controller\RecordListController->body` changed from public to protected +* :php:`TYPO3\CMS\Scheduler\Controller\SchedulerModuleController->CMD` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->OLD_BE_USER` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->MOD_MENU` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->MOD_SETTINGS` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->content` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->overrideConf` changed from public to protected +* :php:`TYPO3\CMS\Setup\Controller\SetupModuleController->languageUpdate` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->MCONF` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->id` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->MOD_MENU` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->modMenu_type` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->modMenu_setDefaultList` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->modMenu_dontValidateList` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->content` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->CMD` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Taskcenter\Controller\TaskModuleController->extObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->access` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->CMD` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->content` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->edit` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->extObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->id` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->MCONF` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->modMenu_type` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->modTSconfig` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->pageinfo` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->perms_clause` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->sObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController->textExtensions` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->function_key` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->localLangFile` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TemplateAnalyzerModuleFunctionController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->function_key` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->localLangFile` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateConstantEditorModuleFunctionController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->function_key` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->localLangFile` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->pObj` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateInformationModuleFunctionController->tce_processed` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->extClassConf` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->function_key` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->localLangFile` changed from public to protected +* :php:`TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateObjectBrowserModuleFunctionController->pObj` changed from public to protected + + +The following VieHelpers have changed: + +* :php:`TYPO3\CMS\Form\ViewHelpers\TranslateElementErrorViewHelper`: The arguments `code`, `arguments` & `defaultValue` have been removed. + + +The following scheduler tasks have been removed: + +* EXT:extbase Task +* EXT:workspaces CleanupPreviewLinkTask +* EXT:workspaces AutoPublishTask + + +The following user TSconfig options have been dropped: + +* Prefix `mod.` to override page TSconfig is ignored +* `TSFE.frontendEditingController` to override the frontend editing controller in EXT:feedit +* `RTE.proc.keepPDIVattribs` +* `RTE.proc.dontRemoveUnknownTags_db` +* `options.clearCache.system` +* `TCEMAIN.previewDomain` + + +The following TypoScript options have been dropped: + +* `config.concatenateJsAndCss` +* `config.defaultGetVars` +* `config.htmlTag_langKey` +* `config.htmlTag_dir` +* `config.language` +* `config.language_alt` +* `config.locale_all` +* `config.sys_language_isocode` +* `config.sys_language_isocode_default` +* `config.sys_language_mode` +* `config.sys_language_overlay` +* `config.sys_language_uid` +* `config.titleTagFunction` +* `config.tx_extbase.objects` +* `config.typolinkCheckRootline` +* `config.typolinkEnableLinksAcrossDomains` +* `config.USERNAME_substToken` +* `config.USERUID_substToken` +* `FILE` +* `page.javascriptLibs` +* `page.javascriptLibs.jQuery` +* `plugin.tx_%plugin%.objects` +* `stdWrap.addParams` +* `stdWrap.filelink` +* `stdWrap.filelist` +* `SVG.noscript` +* `SVG.value` +* `typolink.useCacheHash` +* `TMENU.beforeImg` +* `TMENU.afterImg` +* `GMENU` +* `GMENUITEMS` +* `IMGMENU` +* `IMGMENUITEMS` + +The following TypoScript conditions have been dropped: + +* `language` +* `IP` +* `hostname` +* `applicationContext` +* `hour` +* `minute` +* `month` +* `year` +* `dayofweek` +* `dayofmonth` +* `dayofyear` +* `usergroup` +* `loginUser` +* `page` +* `treeLevel` +* `PIDinRootline` +* `PIDupinRootline` +* `compatVersion` +* `globalVar` +* `globalString` +* `userFunc` + +The following constants have been dropped: + +* :php:`PATH_site` +* :php:`PATH_thisScript` +* :php:`PATH_typo3` +* :php:`PATH_typo3conf` +* :php:`T3_ERR_SV_GENERAL` +* :php:`T3_ERR_SV_FILE_NOT_FOUND` +* :php:`T3_ERR_SV_FILE_READ` +* :php:`T3_ERR_SV_FILE_WRITE` +* :php:`T3_ERR_SV_NO_INPUT` +* :php:`T3_ERR_SV_NOT_AVAIL` +* :php:`T3_ERR_SV_PROG_FAILED` +* :php:`T3_ERR_SV_PROG_NOT_FOUND` +* :php:`T3_ERR_SV_WRONG_SUBTYPE` +* :php:`TYPO3_URL_CONSULTANCY` +* :php:`TYPO3_OS` +* :php:`TYPO3_URL_CONTRIBUTE` +* :php:`TYPO3_URL_DOCUMENTATION` +* :php:`TYPO3_URL_DOCUMENTATION_TSCONFIG` +* :php:`TYPO3_URL_DOCUMENTATION_TSREF` +* :php:`TYPO3_URL_DOWNLOAD` +* :php:`TYPO3_URL_MAILINGLISTS` +* :php:`TYPO3_URL_SECURITY` +* :php:`TYPO3_URL_SYSTEMREQUIREMENTS` + + +The following class constants have been dropped: + +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash::ITOA64` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash::HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash::MAX_HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\BlowfishPasswordHash::MIN_HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Md5PasswordHash::ITOA64` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash::ITOA64` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash::HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash::MAX_HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\Pbkdf2PasswordHash::MIN_HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash::ITOA64` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash::HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash::MAX_HASH_COUNT` +* :php:`TYPO3\CMS\Core\Crypto\PasswordHashing\PhpassPasswordHash::MIN_HASH_COUNT` +* :php:`TYPO3\CMS\Core\DataHandling\TableColumnSubType::FILE` +* :php:`TYPO3\CMS\Core\DataHandling\TableColumnSubType::FILE_REFERENCE` +* :php:`TYPO3\CMS\Core\Page\PageRenderer::JQUERY_NAMESPACE_NONE` +* :php:`TYPO3\CMS\Core\Page\PageRenderer::JQUERY_VERSION_LATEST` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_ERROR` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_FATAL` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_INFO` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_NOTICE` +* :php:`TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_WARNING` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver::PATTERN_MATCH_VALIDATORS` +* :php:`TYPO3\CMS\Extbase\Validation\ValidatorResolver::PATTERN_MATCH_VALIDATOROPTIONS` +* :php:`TYPO3\CMS\Frontend\Page\PageAccessFailureReasons::PAGE_ALIAS_NOT_FOUND` + + +The following constants have been set to protected: + +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::DOCUMENT_CLOSE_MODE_CLEAR_ALL` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::DOCUMENT_CLOSE_MODE_DEFAULT` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::DOCUMENT_CLOSE_MODE_NO_REDIRECT` +* :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::DOCUMENT_CLOSE_MODE_REDIRECT` + + +The following global options are ignored: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_statheader']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_accessdeniedheader']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling_statheader']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/saltedpasswords']['saltMethods']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['enableDeprecationLog']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['recursiveDomainSearch']` + + +The following language files and aliases have been removed: + +* :php:`EXT:saltedpasswords/Resources/Private/Language/locallang.xlf` +* :php:`EXT:saltedpasswords/Resources/Private/Language/locallang_em.xlf` + + +The following global variables have been removed: + +* :php:`$GLOBALS['TYPO3_LOADED_EXT']` + + +The following hooks have been removed: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processUpload']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tstemplate.php']['linkData-PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/index_ts.php']['preBeUser']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/index_ts.php']['postBeUser']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/index_ts.php']['preprocessRequest']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['checkAlternativeIdMethods-PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['checkDataSubmission']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['connectToDB']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['initFEuser']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_previewInfo']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['tslib_fe-PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_parsehtml_proc.php']['modifyParams_LinksDb_PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_parsehtml_proc.php']['modifyParams_LinksRte_PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList']['buildQueryParameters']` + + +The following hooks don't pass the class reference anymore: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawHeaderHook']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawFooterHook']` + + +The following signals have been removed: + +* :php:`TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService` signal `hasInstalledExtensions` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility` signal `tablesDefinitionIsBeingBuilt` + + +The following features are now always enabled: + +* Extbase's :php:`consistentTranslationOverlayHandling` - Translations in Extbase are now always consistent +* :php:`simplifiedControllerActionDispatching` - Backend controller actions do not receive a prepared response object anymore +* :php:`unifiedPageTranslationHandling` - Page Translations are not within `pages_language_overlay` anymore +* TypoScript condition strict syntax - The feature toggle :php:`TypoScript.strictSyntax` has been dropped + + +The following features have been removed: + +* Migration from v4 to v5 PackagesStates.php +* Backend modules validated against special GET/POST `M` parameter +* `eID` script targets cannot define a script path anymore: + `$GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include']['my_eID'] = 'EXT:benni/Scripts/download.php'` will not work anymore. + Instead, they must contain a target (callable, class/method, function). +* TCA auto migration from core v6 to core v7 compatible TCA +* TCA auto migration from core v7 to core v8 compatible TCA +* TCA :php:`type='group'` with :php:`internal_type='file'` and :php:`internal_type='file_reference` +* Cache creation using :php:`\TYPO3\CMS\Cache\CacheManger` during :file:`ext_localconf.php` loading +* All install tool upgrade wizards upgrading from v7 to v8 +* The array key :php:`uploadfolder` in extensions :file:`ext_emconf.php` files is obsolete and ignored. +* Standalone install tool entry point :file:`typo3/install/index.php` has been dropped, use :file:`typo3/install.php` instead +* INCLUDE_TYPOSCRIPT statements in typoscript using a `.txt` ending for a file that ends with `.typoscript` does not work any longer +* These variables are no longer declared in :file:`ext_tables.php` and :file:`ext_localconf.php` files: :php:`$_EXTKEY`, :php:`$_EXTCONF`, + :php:`T3_SERVICES`, :php:`T3_VAR`, :php:`TYPO3_CONF_VARS`, :php:`TBE_MODULES`, :php:`TBE_MODULES_EXT`, :php:`TCA`, + :php:`PAGES_TYPES`, :php:`TBE_STYLES` +* Frontend, Backend and standalone install tool users who did not log in for multiple core versions and still use a :php:`M$` + prefixed password can not log in anymore. Auto converting those user passwords during first login has been dropped, those + users need their password being manually recovered or reset. +* Extension :php:`rsaauth` has been dropped from core +* Extension :php:`feedit` has been dropped from core +* The extension :php:`taskcenter` and its add-on extension :php:`sys_action` have been dropped from core +* Translation :php:`locallang` references :php:`EXT:lang` to removed extension "lang" do not work any longer +* EXT:form: type GridContainer +* EXT:form: :yaml:`renderingOptions._isHiddenFormElement` and :yaml:`renderingOptions._isReadOnlyFormElement` are dropped +* :php:`$TBE_MODULES`: configuring a module via a custom "configureModuleFunction" is dropped +* CLI Command alias "lang:language:update" is dropped in favor of "language:update" +* Accessing or modifying :php:`$_GET`/:php:`$_POST` parameters during any PSR-15 middleware will not reflect any change during the actual Request processing anymore as it is overridden by the incoming PSR-7 request object, but overridden again when the RequestHandler is accessed +* Parsing of the legacy `<link>` tags which were migrated to `<a>` tags in Frontend is dropped + +The following database tables have been removed: + +* `sys_domain` - Use site configuration instead +* `pages_language_overlay` - Migrate to `pages` with the upgrade wizard + + +The following database fields have been removed: + +* `pages.alias` +* `pages.t3ver_label` +* `index_phash.cHashParams` +* `index_phash.data_page_reg1` +* `sys_category.t3ver_label` +* `sys_collection.t3ver_label` +* `sys_file_collection.t3ver_label` +* `sys_file_metadata.t3ver_label` +* `sys_file_reference.t3ver_label` +* `sys_template.t3ver_label` +* `tt_content.t3ver_label` + + +The following php doc annotations have been removed: + +* `@cascade` +* `@cli` +* `@flushesCaches` +* `@ignorevalidation` +* `@inject` +* `@internal` +* `@lazy` +* `@transient` +* `@validate` + + +The following global JavaScript functions have been removed: + +* `launchView()` - Use the method `showItem()` of the `TYPO3/CMS/Backend/InfoWindow` module + + +The following JavaScript modules have been removed: + +* `TYPO3/CMS/Backend/Storage` - Use either `TYPO3/CMS/Backend/Storage/Client` or `TYPO3/CMS/Backend/Storage/Persistent` + + +The following global instances have been removed: + +* `TYPO3.Popover` - require `TYPO3/CMS/Backend/Popover` in your AMD module +* `TYPO3.Utility` - require `TYPO3/CMS/Backend/Utility` in your AMD module + + +Impact +====== + +Instantiating or requiring the PHP classes or calling the PHP methods directly will trigger PHP :php:`E_ERROR` errors. + +.. index:: Backend, CLI, FlexForm, Fluid, Frontend, JavaScript, LocalConfiguration, PHP-API, TCA, TSConfig, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/10.0/Breaking-87305-UseConstructorInjectionInDataMapper.rst b/Documentation/Changelog/10.0/Breaking-87305-UseConstructorInjectionInDataMapper.rst new file mode 100644 index 0000000..1218fb7 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87305-UseConstructorInjectionInDataMapper.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87305: + +========================================================== +Breaking: #87305 - Use constructor injection in DataMapper +========================================================== + +See :issue:`87305` + +Description +=========== + +Class :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper` does no longer use setter injection. Instead, constructor injection is used. + + +Impact +====== + +The method signature of the constructor changed. This means: + +- The amount of constructor arguments increased +- The order of arguments possibly changed + + +Affected Installations +====================== + +All installations that create instances of the class :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper` using :php:`GeneralUtility::makeInstance` or :php:`ObjectManager->get`. + + +Migration +========= + +If possible, do not create instances yourself. Avoid :php:`GeneralUtility::makeInstance` and :php:`ObjectManager->get`. Instead use dependency injection, preferably constructor injection: + +.. code-block:: php + + public function __constructor(\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper $object) + { + $this->property = $object; + } + +If dependency injection is not possible, check the dependencies and instantiate objects via the object manager: + +.. code-block:: php + + $object = $objectManager->get( + \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::class, + $objectManager->get(\TYPO3\CMS\Extbase\Reflection\ReflectionService::class), + // ... + ); + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87511-RemoveNamespacesViewObjectNamePatternProperty.rst b/Documentation/Changelog/10.0/Breaking-87511-RemoveNamespacesViewObjectNamePatternProperty.rst new file mode 100644 index 0000000..ff87a78 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87511-RemoveNamespacesViewObjectNamePatternProperty.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87511-1668719172: + +=================================================================== +Breaking: #87511 - Remove $namespacesViewObjectNamePattern property +=================================================================== + +See :issue:`87511` + +Description +=========== + +Property :php:`$namespacesViewObjectNamePattern` of class +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` has been +removed without replacement. + +Impact +====== + +Overriding the property :php:`$namespacesViewObjectNamePattern` in +controllers that extend :php:`ActionController` will no longer trigger +the instantiation of another view object, derived from the pattern. + +Affected Installations +====================== + +All extensions that override the property :php:`$namespacesViewObjectNamePattern`. + +Migration +========= + +If an action needs a template object other than the default +:php:`\TYPO3\CMS\Fluid\View\TemplateView`, the property :php:`$defaultViewObjectName` +needs to be overridden. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87511-RemoveViewFormatToObjectNameMapProperty.rst b/Documentation/Changelog/10.0/Breaking-87511-RemoveViewFormatToObjectNameMapProperty.rst new file mode 100644 index 0000000..2ec582d --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87511-RemoveViewFormatToObjectNameMapProperty.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87511: + +============================================================= +Breaking: #87511 - Remove $viewFormatToObjectNameMap property +============================================================= + +See :issue:`87511` + +Description +=========== + +Property :php:`$viewFormatToObjectNameMap` of class +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` has been +removed without replacement. + +Impact +====== + +Overriding the property :php:`$viewFormatToObjectNameMap` in +controllers that extend :php:`ActionController` will no longer trigger +the instantiation of another view object, derived from the mapping. + +Affected Installations +====================== + +All extensions that override the property :php:`$viewFormatToObjectNameMap`. + +Migration +========= + +If an action needs a template object other than the default +:php:`\TYPO3\CMS\Fluid\View\TemplateView`, the property :php:`$defaultViewObjectName` +needs to be overridden. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87558-ConsolidateExtbaseCaches.rst b/Documentation/Changelog/10.0/Breaking-87558-ConsolidateExtbaseCaches.rst new file mode 100644 index 0000000..0ca3f89 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87558-ConsolidateExtbaseCaches.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87558: + +============================================= +Breaking: #87558 - Consolidate extbase caches +============================================= + +See :issue:`87558` + +Description +=========== + +The caches of extbase have been consolidated as both of them shared the same caching frontend. +Cache identifiers `extbase_reflection` and `extbase_datamapfactory_datamap` do no longer exist. + +A single cache `extbase` is pre-configured and used for class schemata and data maps instead. + + +Impact +====== + +Adjusting the cache configuration of either `extbase_reflection` +or `extbase_datamapfactory_datamap` will no longer have any effect. + +The installation may throw an error depending on the php error level configuration, if the no longer existing +cache keys are written to without initializing them first. + +The following global settings do no longer exist: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['extbase_reflection']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['extbase_datamapfactory_datamap']` + +The following code code might throw an error depending on the php error level configuration: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['SYS']['cacheConfigurations']['extbase_reflection']['backend'] = \TYPO3\CMS\Core\Cache\Backend\NullBackend::class; + + +Affected Installations +====================== + +All installations that override the configuration of the caches `extbase_reflection` and `extbase_datamapfactory_datamap`. + + +Migration +========= + +Override new cache `extbase` in the same manner the former caches were overridden. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87567-GlobalVariableTBE_TEMPLATERemoved.rst b/Documentation/Changelog/10.0/Breaking-87567-GlobalVariableTBE_TEMPLATERemoved.rst new file mode 100644 index 0000000..f4bd964 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87567-GlobalVariableTBE_TEMPLATERemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87567: + +======================================================== +Breaking: #87567 - Global variable $TBE_TEMPLATE removed +======================================================== + +See :issue:`87567` + +Description +=========== + +The global variable :php:`$GLOBALS[TBE_TEMPLATE]` used in TYPO3 Backend which was available +for legacy reasons for old backend modules as an instance of :php:`DocumentTemplate` a.k.a. `alt_doc` +has been removed. + +The according PSR-15 middleware, which was marked as internal, is also removed. + + +Impact +====== + +Calling any method or property on :php:`$GLOBALS[TBE_TEMPLATE]` will trigger a PHP :php:`E_ERROR` error. + + +Affected Installations +====================== + +TYPO3 installations with older extensions using the global variable. + + +Migration +========= + +Instantiate the :php:`DocumentTemplate` class directly in the controller of the module, or migrate +to :php:`ModuleTemplate` which is available since TYPO3 v7. + +.. index:: PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-87583-RemoveObsoleteAPCCacheBackendImplementation.rst b/Documentation/Changelog/10.0/Breaking-87583-RemoveObsoleteAPCCacheBackendImplementation.rst new file mode 100644 index 0000000..ba3fc3d --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87583-RemoveObsoleteAPCCacheBackendImplementation.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87583: + +=================================================================== +Breaking: #87583 - Remove obsolete APC Cache Backend implementation +=================================================================== + +See :issue:`87583` + +Description +=========== + +The Caching framework backend implementation :php:`TYPO3\CMS\Core\Cache\Backend\ApcBackend` has +been removed. The APCu PHP extension has superseded in PHP 7.x. + +Impact +====== + +The PHP APC extension works until PHP 5.x. APCu can be used as "drop-in" replacement since TYPO3 8 +LTS which supports PHP 7.0+. + +Affected Installations +====================== + +Any installation which has been updated, and any legacy APC cache backend is configured (see +:file:`LocalConfiguration.php`). + +Migration +========= + +Use APCu implementation, which is implemented via :php:`TYPO3\CMS\Core\Cache\Backend\ApcuBackend` +instead of :php:`TYPO3\CMS\Core\Cache\Backend\ApcBackend` in your caching framework configuration. + +Example before: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = \TYPO3\CMS\Core\Cache\Backend\ApcBackend::class;` + +Example after: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = \TYPO3\CMS\Core\Cache\Backend\ApcuBackend::class;` + + +.. index:: Backend, PHP-API, ext:core, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-87594-HardenExtbase.rst b/Documentation/Changelog/10.0/Breaking-87594-HardenExtbase.rst new file mode 100644 index 0000000..0e37e0f --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87594-HardenExtbase.rst @@ -0,0 +1,101 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87594: + +================================= +Breaking: #87594 - Harden extbase +================================= + +See :issue:`87594` + +Description +=========== + +While hardening Extbase classes, method signatures changed due to an enforced strict type mode and introduced type hints for scalars. +The change of signatures is considered breaking for the following methods of the following interfaces and their implementations and for the following classes and their derivatives: + +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::getUid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::setPid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::getPid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::_isNew` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::_setProperty` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::_getProperty` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::_getProperties` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface::_getCleanProperty` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::getUid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::setPid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::getPid` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::_isNew` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::_setProperty` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::_getProperty` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::_getProperties` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject::_getCleanProperty` +- :php:`\TYPO3\CMS\Extbase\Service\ImageService::applyProcessingInstructions` +- :php:`\TYPO3\CMS\Extbase\Service\ImageService::getImageUri` +- :php:`\TYPO3\CMS\Extbase\Service\ImageService::getImage` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getSupportedSourceTypes()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getSupportedTargetType()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getTargetTypeForSource()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getPriority()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::canConvertFrom()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getSourceChildPropertiesToBeConverted()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::getTypeOfChildProperty()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface::convertFrom()` +- :php:`\TYPO3\CMS\Extbase\Error\Message::__construct` +- :php:`\TYPO3\CMS\Extbase\Error\Message::getMessage` +- :php:`\TYPO3\CMS\Extbase\Error\Message::getCode` +- :php:`\TYPO3\CMS\Extbase\Error\Message::getArguments` +- :php:`\TYPO3\CMS\Extbase\Error\Message::getTitle` +- :php:`\TYPO3\CMS\Extbase\Error\Message::render` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager::getContentObject` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager::getConfiguration` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager::isFeatureEnabled` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::reset()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::build()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::uriFor()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setAbsoluteUriScheme()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setAddQueryString()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setAddQueryStringMethod()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setArgumentPrefix()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setArguments()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setArgumentsToBeExcludedFromQueryString()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setCreateAbsoluteUri()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setFormat()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setLinkAccessRestrictedPages()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setNoCache()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setSection()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setTargetPageType()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setTargetPageUid()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setUseCacheHash()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getAddQueryString()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getAddQueryStringMethod()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getArguments()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getArgumentsToBeExcludedFromQueryString()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getCreateAbsoluteUri()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getFormat()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getLinkAccessRestrictedPages()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getNoCache()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getSection()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getTargetPageUid()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::getUseCacheHash()` + + +Impact +====== + +PHP might throw a fatal error if the method signature(s) of your implementations/derivatives aren't compatible with the interface(s) and/or parent class(es). + + +Affected Installations +====================== + +- All installations that use classes that implement mentioned interfaces and their methods. +- All installations that use classes that inherit mentioned classes and overwrite their methods. + + +Migration +========= + +Methods need to be adjusted to be compatible with the parent class and/or interface signature. + +.. index:: PHP-API, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-87623-ReplaceConfigpersistenceclassesTyposcriptConfiguration.rst b/Documentation/Changelog/10.0/Breaking-87623-ReplaceConfigpersistenceclassesTyposcriptConfiguration.rst new file mode 100644 index 0000000..b181746 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87623-ReplaceConfigpersistenceclassesTyposcriptConfiguration.rst @@ -0,0 +1,100 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87623: + +============================================================================== +Breaking: #87623 - Replace config.persistence.classes typoscript configuration +============================================================================== + +See :issue:`87623` + +Description +=========== + +The configuration of classes in the context of the Extbase persistence is no longer possible via typoscript. +All typoscript concerning the configuration of classes in that context needs to be converted to php, residing +in :file:`EXT:extension/Configuration/Extbase/Persistence/Classes.php`. + + +Impact +====== + +Unless converted to php, the configuration in typoscript does no longer have any effect and therefore the following things do no longer work: + +- Overwriting table names for models whose table name derived by conventions differ from the desired one. +- The mapping of database field names to model property names +- The definition of model sub classes which is necessary for a proper implementation of single table inheritance. + + +Affected Installations +====================== + +All installations that configure persistence related classes via typoscript. + + +Migration +========= + +Every extension that used typoscript for such configuration must provide a php configuration class called: +:file:`EXT:extension/Configuration/Extbase/Persistence/Classes.php` + +The migration is best described by an example: + +.. code-block:: typoscript + + config.tx_extbase { + persistence { + classes { + TYPO3\CMS\Extbase\Domain\Model\FileMount { + mapping { + tableName = sys_filemounts + columns { + title.mapOnProperty = title + path.mapOnProperty = path + base.mapOnProperty = isAbsolutePath + } + } + } + } + } + } + +This configuration will look like this, defined in php: + +.. code-block:: php + + <?php + declare(strict_types = 1); + + return [ + \TYPO3\CMS\Extbase\Domain\Model\FileMount::class => [ + 'tableName' => 'sys_filemounts', + 'properties' => [ + 'title' => [ + 'fieldName' => 'title' + ], + 'path' => [ + 'fieldName' => 'path' + ], + 'isAbsolutePath' => [ + 'fieldName' => 'base' + ], + ], + ], + ]; + +A few things are noteworthy here: + +- The typoscript node :typoscript:`mapping` has been dropped and all sub nodes like :typoscript:`tableName` and :typoscript:`columns` are now located directly + in the top node, i.e. the class name. +- The mapping of columns changed due to the fact that :typoscript:`mapOnProperty` has been dropped and the mapping direction changed. + With typoscript the top nodes were called like the class names which indicates the mapping direction model to table. But + then, one had to define a mapping by columns instead of properties, which means, the mapping directions was reversed, + forcing you to map database table fields on properties. This was quite confusing and the configuration is now eased as + one can always think in the model to table mapping direction. +- The load order of these files is determined by the load order of extensions. If multiple extensions override mapping + configuration of the same extbase domain classes, extension load order should be specified by :file:`ext_emconf.php` + constraints or dependencies using the :php:`suggests` or :php:`depends` keywords. See + :ref:`ext_emconf.php file<t3coreapi:extension-declaration>` for details. + +.. index:: TypoScript, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87627-RemovePropertyExtensionNameOfAbstractController.rst b/Documentation/Changelog/10.0/Breaking-87627-RemovePropertyExtensionNameOfAbstractController.rst new file mode 100644 index 0000000..943c9ff --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87627-RemovePropertyExtensionNameOfAbstractController.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87627: + +====================================================================== +Breaking: #87627 - Remove Property extensionName of AbstractController +====================================================================== + +See :issue:`87627` + +Description +=========== + +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController::$extensionName` +has been removed and is no longer available in subclasses of +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController`, i.e. +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` and their derivates. + + +Impact +====== + +Accessing the missing property :php:`$extensionName` will throw a fatal error. + + +Affected Installations +====================== + +All installations that read from :php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController::$extensionName`. + + +Migration +========= + +The extension name is set in and available through the request object that is available in the controller. +See :php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController::$request` and :php:`\TYPO3\CMS\Extbase\Mvc\Request::getControllerExtensionName()` +for more information. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87936-TCAForSysHistoryRemoved.rst b/Documentation/Changelog/10.0/Breaking-87936-TCAForSysHistoryRemoved.rst new file mode 100644 index 0000000..be039fb --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87936-TCAForSysHistoryRemoved.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87936: + +============================================== +Breaking: #87936 - TCA for sys_history removed +============================================== + +See :issue:`87936` + +Description +=========== + +The TCA definition for :sql:`sys_history` database table was removed. It was never shown in TYPO3 Backend, +and only in use for the BElog module as Extbase Domain Model. However, this relationship between +logs and sys_history was decoupled in TYP3 v9.0. + +The database field :sql:`pid` which was "0" at all times, is now removed. + + +Impact +====== + +Accessing :php:`$GLOBALS[TCA][sys_history]` will trigger a PHP :php:`E_WARNING`, and the contents of the array +are not available anymore. + + +Affected Installations +====================== + +Any TYPO3 installation with extensions accessing the global array by making use of +:sql:`sys_history`. + + +Migration +========= + +If still needed, an extension should deliver the full TCA definition of :sql:`sys_history`. + +.. index:: Database, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Breaking-87937-TCAOption_selicon_field_path_removed.rst b/Documentation/Changelog/10.0/Breaking-87937-TCAOption_selicon_field_path_removed.rst new file mode 100644 index 0000000..aed3cee --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87937-TCAOption_selicon_field_path_removed.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87937: + +========================================================== +Breaking: #87937 - TCA option "selicon_field_path" removed +========================================================== + +See :issue:`87937` + +Description +=========== + +The TCA option :php:`$GLOBALS['TCA'][$myTable]['ctrl']['selicon_field_path']` was removed. + +The option allowed to show icons in select items when using :php:`$myTable` as a foreign table +in relations, and was bound to using :php:`selicon_field` as a legacy file (:php:`internal_type=file`). + + +Impact +====== + +It is now only possible to use :php:`selicon_field` in inline relations towards :php:`sys_file_reference`. +Setting the :php:`selicon_field_path` has no effect anymore and a PHP :php:`E_USER_DEPRECATED` error will be triggered. + + +Affected Installations +====================== + +Any TYPO3 installation with an extension providing TCA with :php:`selicon_field_path`. + + +Migration +========= + +Remove the option :php:`selicon_field_path` and use an inline relation to file references in :php:`selicon_field` instead. + +.. index:: TCA, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Breaking-87957-DoNotMagicallyRegisterValidators.rst b/Documentation/Changelog/10.0/Breaking-87957-DoNotMagicallyRegisterValidators.rst new file mode 100644 index 0000000..789ecf9 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87957-DoNotMagicallyRegisterValidators.rst @@ -0,0 +1,164 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87957: + +================================================================================= +Breaking: #87957 - Validators are not registered automatically in Extbase anymore +================================================================================= + +See :issue:`87957` + +Description +=========== + +There were several validators that Extbase applies automatically. One example are domain validators that are registered +if created in a specific directory. Another one is the type validator which is created if a validator with a specific +name exists. + +The method :php:`TYPO3\CMS\Extbase\Utility\ClassNamingUtility::translateModelNameToValidatorName` has +been removed without substitution. This leads to no automatically registered validators anymore. + +Domain Validators +================= + +Given that there is a model :php:`\TYPO3\CMS\Extbase\Domain\Model\BackendUser`, extbase searched for a validator named +:php:`\TYPO3\CMS\Extbase\Domain\Validator\BackendUserValidator`. The `Model` part of the namespace had been replaced +with `Validator` and another `Validator` string had been added to the actual class name. In this example, `BackendUser` +has been replaced with `BackendUserValidator`. + +If such a validator class existed it had been magically applied and used during the validation of the model. + +Example:: + + <?php + namespace ExtbaseTeam\BlogExample\Domain\Validator; + + use TYPO3\CMS\Extbase\Validation\Validator; + + class BlogValidator implements ValidatorInterface + { + public function validate($value); + { + // ... + } + } + +:: + + <?php + namespace ExtbaseTeam\BlogExample\Controller; + + use ExtbaseTeam\BlogExample\Domain\Model\Blog; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class BlogController extends ActionController + { + public function showAction(Blog $blog) + { + // ... + } + } + +In this example there is a model validator :php:`ExtbaseTeam\BlogExample\Domain\Validator\BlogValidator` defined for +model :php:`ExtbaseTeam\BlogExample\Domain\Model\Blog`, which had been automatically registered before calling action +:php:`ExtbaseTeam\BlogExample\Controller\BlogController::showAction`. + +From now on the validator needs to be registered manually. + +:: + + <?php + namespace ExtbaseTeam\BlogExample\Controller; + + use ExtbaseTeam\BlogExample\Domain\Model\Blog; + use TYPO3\CMS\Extbase\Annotation as Extbase; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class BlogController extends ActionController + { + /** + * @Extbase\Validate(param="blog", validator="ExtbaseTeam\BlogExample\Domain\Validator\BlogValidator") + */ + public function showAction(Blog $blog) + { + // ... + } + } + + +Type Validators +=============== + +Given that there is any kind of simple type param or property that is to be validated, e.g. a property of a model or an +action method param, extbase tried to apply a validator for that param/property derived from its type. If there was an +action param of type string, extbase searched for a `StringValidator` in the namespace +`TYPO3\CMS\Extbase\Validation\Validator`. The :php:`TYPO3\CMS\Extbase\Validation\Validator\StringValidator` does +actually exist, as well as :php:`TYPO3\CMS\Extbase\Validation\Validator\IntegerValidator` and others. + +If a validator for a specific type existed it had been magically applied and used during the validation of models and +action arguments. + +Example: + +:: + + <?php + namespace ExtbaseTeam\BlogExample\Controller; + + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class BlogController extends ActionController + { + public function showAction(int $blogUid) + { + // ... + } + } + +In this example there is a simple type param, extbase automatically registered a type validator for. First, `int` had +been normalized to `integer`, then :php:`ucfirst($type)` had been called, resulting in `Integer` and then extbase looked +for a :php:`TYPO3\CMS\Extbase\Validation\Validator\IntegerValidator`. As this Validator exists, it had been +automatically registered. + +If this behaviour is desired, the validator needs to be registered manually from now on. + +:: + + <?php + namespace ExtbaseTeam\BlogExample\Controller; + + use TYPO3\CMS\Extbase\Annotation as Extbase; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class BlogController extends ActionController + { + /** + * @Extbase\Validate(param="blogUid", validator="TYPO3\CMS\Extbase\Validation\Validator\IntegerValidator") + */ + public function showAction(int $blogUid) + { + // ... + } + } + + +Impact +====== + +With these mentioned validators no longer being applied automatically, developers actively need to apply those +validators if needed. Most developers might want to register existing domain validators manually while leaving the type +validators unregistered. This however will vary from project to project. + + +Affected Installations +====================== + +All installations that use the extbase validation framework. + + +Migration +========= + +There is no automatic migration. Validators need to be re-applied manually if needed. + +.. index:: PHP-API, PartiallyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-87989-TCAOptionSetToDefaultOnCopyRemoved.rst b/Documentation/Changelog/10.0/Breaking-87989-TCAOptionSetToDefaultOnCopyRemoved.rst new file mode 100644 index 0000000..20a7d38 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-87989-TCAOptionSetToDefaultOnCopyRemoved.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87989: + +======================================================== +Breaking: #87989 - TCA option setToDefaultOnCopy removed +======================================================== + +See :issue:`87989` + +Description +=========== + +The special TCA option :php:`$TCA[$tableName]['ctrl']['setToDefaultOnCopy']` is removed. + +It allowed to reset a certain field to its default value when copying a record. + + +Impact +====== + +Having the setting set in TCA will trigger a PHP :php:`E_USER_DEPRECATED` error when building TCA. + +Copying records with this TCA setting enabled, will now keep the copied state and avoid side-effects. + + +Affected Installations +====================== + +TYPO3 installations with active usage of `sys_action` or other extensions using this TCA setting. + + +Migration +========= + +This option was only there for resetting some `sys_action` values to default, which +can easily be achieved by a hook if needed. If an extension author uses this setting, +this should be achieved with proper DataHandler hooks. + +.. index:: TCA, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Breaking-88129-RenameFeloginFlexformFields.rst b/Documentation/Changelog/10.0/Breaking-88129-RenameFeloginFlexformFields.rst new file mode 100644 index 0000000..16b267d --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88129-RenameFeloginFlexformFields.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88129: + +================================================== +Breaking: #88129 - Renamed felogin flexform fields +================================================== + +See :issue:`88129` + +Description +=========== + +In preparation to :issue:`84262` the felogin flexform field definition has been changed +and all field names are now prefixed with `settings.`. This has been done to easily access all +of the flexform values in the later extbase controller via :php:`$this->settings['foo']` and also in +the fluid templates via :html:`{settings.foo}`. + + +Impact +====== + +Any PageTsConfig that overrides felogin flexform fields will be ignored. + + +Affected Installations +====================== + +All installations with a felogin plugin need to migrate their flexform database values. +PageTsConfig that overrides the flexform needs to be adjusted. + + +Migration +========= + +An update wizard is provided to easily update all used felogin plugins. To migrate the flexform values, execute +`Migrate felogin plugins to use prefixed flexform keys`. + +All PageTsConfig that overrides felogin flexform fields e.g. :typoscript:`TCEFORM.tt_content.pi_flexform.login.sDEF.showForgotPassword.disabled = 1` +needs to add the `settings.` prefix to the keys. +Note the escaping backslash! :typoscript:`TCEFORM.tt_content.pi_flexform.login.sDEF.settings\.showForgotPassword.disabled = 1`. + +.. index:: FlexForm, NotScanned, ext:felogin diff --git a/Documentation/Changelog/10.0/Breaking-88143-Version-relatedDatabaseFieldT3ver_idRemoved.rst b/Documentation/Changelog/10.0/Breaking-88143-Version-relatedDatabaseFieldT3ver_idRemoved.rst new file mode 100644 index 0000000..27b0295 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88143-Version-relatedDatabaseFieldT3ver_idRemoved.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88143: + +==================================================================== +Breaking: #88143 - Version-related database field "t3ver_id" removed +==================================================================== + +See :issue:`88143` + +Description +=========== + +The database field for all workspace-enabled database tables :sql:`t3ver_id` is removed. It previously +contained an incrementing numeric value when using incrementing versioning - the versioning concept +which was in place before Workspaces were introduced in TYPO3 v4.0. + +Since the legacy versioning was removed in TYPO3 v9, the field is removed and not automatically +created for new installations anymore. + + +Impact +====== + +Creating SQL statements in custom extensions explicitly selecting this field will result in SQL +errors. + +In addition, when upgrading TYPO3 to v10.0 this field will be removed by the Database Analyzer +Tool in the Install Tool for all TYPO3 core database tables and extensions using the automatic +creation of database fields. + + +Affected Installations +====================== + +All installations with custom extensions explicitly requesting this field. + + +Migration +========= + +Search in any extension in `typo3conf/ext` for :sql:`t3ver_id` to see any usages, and remove the field +from any queries, database definitions in :file:`ext_tables.sql` files. + +.. index:: Database, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/10.0/Breaking-88182-JsfuncInlineJsHasBeenDropped.rst b/Documentation/Changelog/10.0/Breaking-88182-JsfuncInlineJsHasBeenDropped.rst new file mode 100644 index 0000000..d3e6b33 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88182-JsfuncInlineJsHasBeenDropped.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88182: + +==================================================== +Breaking: #88182 - jsfunc.inline.js has been dropped +==================================================== + +See :issue:`88182` + +Description +=========== + +The JavaScript file :file:`jsfunc.inline.js` which was responsible for FormEngine's subcomponent IRRE has been +superseded by the rewritten :php:`TYPO3\CMS\Backend\Form\Container\InlineControlContainer` component. + + +Impact +====== + +Requesting the file :file:`typo3/sysext/backend/Resources/Public/JavaScript/jsfunc.inline.js` will cause a 404 error. +Calling any method of the global :js:`inline` object will throw an error since the object doesn't exist anymore. + + +Affected Installations +====================== + +All installations of TYPO3 are affected. + + +Migration +========= + +There is no migration available in most cases, since the :php:`TYPO3\CMS\Backend\Form\Container\InlineControlContainer` component is now event-driven. + +One exception is the former :js:`inline.delayedImportElement()` method, since this part is now based on +`postMessage`. For this approach, a small helper utility :js:`TYPO3/CMS/Backend/Utility/MessageUtility` has +been added. + +See the example for a possible migration: + +.. code-block:: javascript + + // Previous code from DragUploader + window.inline.delayedImportElement( + irre_object, + 'sys_file', + file.uid, + 'file', + ); + + // New code + require(['TYPO3/CMS/Backend/Utility/MessageUtility'], function(MessageUtility) { + const message = { + objectGroup: irre_object, + table: 'sys_file', + uid: file.uid, + }; + MessageUtility.send(message); + }); + +The :js:`MessageUtility.send()` method automatically gets the current domain of the request and attaches it to +the postMessage. :js:`MessageUtility.verifyOrigin()` must be used to check whether the incoming request was sent +by the current TYPO3 backend to avoid possible security issues. + +.. index:: Backend, JavaScript, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-88366-RemovedCf_PrefixOfCacheTables.rst b/Documentation/Changelog/10.0/Breaking-88366-RemovedCf_PrefixOfCacheTables.rst new file mode 100644 index 0000000..8e03e69 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88366-RemovedCf_PrefixOfCacheTables.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88366: + +================================================= +Breaking: #88366 - Removed prefix of cache tables +================================================= + +See :issue:`88366` + +Description +=========== + +In addition, when the Typo3DatabaseBackend now accesses and creates tables without the ``cf_`` +prefix ("cf" = Caching Framework), so caches in the database are simply called `cache_rootline` +for instance. + + +Impact +====== + +Accessing the database tables directly with a ``cf_`` prefix will not work on the TYPO3 managed +cache tables. + + +Affected Installations +====================== + +Any TYPO3 instance using the Caching Framework with a Typo3DatabaseBackend. + + +Migration +========= + +Use the Caching Framework directly. + +In addition, run through the Database Table Analyzer of the Configuration module to +re-create any database tables of the Caching Framework. + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/10.0/Breaking-88376-RemovedObsoletePageNotFound_handlingSettings.rst b/Documentation/Changelog/10.0/Breaking-88376-RemovedObsoletePageNotFound_handlingSettings.rst new file mode 100644 index 0000000..e009a4d --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88376-RemovedObsoletePageNotFound_handlingSettings.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88376: + +==================================================================== +Breaking: #88376 - Removed obsolete "pageNotFound_handling" settings +==================================================================== + +See :issue:`88376` + +Description +=========== + +The following global TYPO3 settings, usually set within :file:`LocalConfiguration.php` have been removed: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_statheader']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFound_handling_accessdeniedheader']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling_statheader']` + +These settings are effectively replaced by the error handling of the newly introduced Site Handling +which is more flexible and robust, and is used instead of these options when Site Handling was +enabled in TYPO3 v9. For TYPO3 v10 Site Handling is a requirement, making these options useless. + + +Impact +====== + +Setting any of the options will have no effect any more. Executing the Silent Upgrade Wizard +will remove the settings automatically. + + +Affected Installations +====================== + +Any TYPO3 installations having these settings overridden in :file:`LocalConfiguration.php` +file of an installation. + + +Migration +========= + +Access the install tool to automatically update the :file:`LocalConfiguration.php` file and remove the +settings. + +Ensure to set up Site Handling with proper error handlers. Avoid accessing these settings but +rather use the available :php:`TYPO3\CMS\Frontend\Controller\ErrorController` class, when trying to manually trigger a 404/500 +in the Frontend (e.g. custom plugin) instead. + +.. index:: Frontend, LocalConfiguration, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/10.0/Breaking-88411-TBE_EDITORtypo3formRemoved.rst b/Documentation/Changelog/10.0/Breaking-88411-TBE_EDITORtypo3formRemoved.rst new file mode 100644 index 0000000..a08519b --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88411-TBE_EDITORtypo3formRemoved.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88411: + +=============================================== +Breaking: #88411 - TBE_EDITOR.typo3form removed +=============================================== + +See :issue:`88411` + +Description +=========== + +The global object :js:`TBE_EDITOR.typo3form` and its backward layers :js:`typo3FormFieldSet` and :js:`typo3FormFieldGet` +have been removed. + + +Impact +====== + +Any extension relying on this code will not work anymore. + + +Affected Installations +====================== + +All installations using the removed code are affected. + + +Migration +========= + +No direct migration possible. Refer to the FormEngine JavaScript API. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-88427-JsfuncevalfieldjsHasBeenRemoved.rst b/Documentation/Changelog/10.0/Breaking-88427-JsfuncevalfieldjsHasBeenRemoved.rst new file mode 100644 index 0000000..82eb196 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88427-JsfuncevalfieldjsHasBeenRemoved.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88427: + +======================================================= +Breaking: #88427 - jsfunc.evalfield.js has been removed +======================================================= + +See :issue:`88427` + +Description +=========== + +The file :file:`jsfunc.evalfield.js`, responsible for form value evaluation and validation, has been removed. This job is +now done by :js:`TYPO3/CMS/Backend/FormEngineValidation` since TYPO3 7.4. + + +Impact +====== + +Extensions still relying on this file and its API will not work anymore. + + +Affected Installations +====================== + +All installations with third party extensions using this API are affected. + + +Migration +========= + +In most cases no migration is necessary, unless this API is used in a custom built form. In such case, migrate to +FormEngine API to automatically use the new API. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-88458-RemovedFrontendTrackUserFtuFunctionality.rst b/Documentation/Changelog/10.0/Breaking-88458-RemovedFrontendTrackUserFtuFunctionality.rst new file mode 100644 index 0000000..1c51265 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88458-RemovedFrontendTrackUserFtuFunctionality.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88458: + +================================================================== +Breaking: #88458 - Removed Frontend Track User "ftu" functionality +================================================================== + +See :issue:`88458` + +Description +=========== + +The "ftu" feature, used to transfer sessions via GET parameter, has been removed. + +The implementation and the functionality exposed some security concerns, if enabled via TypoScript +:typoscript:`config.ftu` as sessions could have been taken over by link sharing, although this was mitigated +in the past by a security change. + + +Impact +====== + +The following public properties now trigger PHP :php:`E_WARNING` when accessed: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->get_name` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->getFallBack` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->getMethodEnabled` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->get_URL_ID` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getMethodUrlIdToken` + +The TypoScript setting :typoscript:`config.ftu` has no effect anymore. + +The global configuration setting :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['get_url_id_token']` is not +set anymore. + + +Affected Installations +====================== + +Any TYPO3 installation using the :typoscript:`config.ftu` functionality. + + +Migration +========= + +Remove any usages to the properties or options, and use a custom session handling without +handing over Session IDs in plaintext via GET parameters. Suggested alternatives for instance are +JWT payloads or OTP links for starting a session. + +For cookie-less session handling, a custom functionality depending on the use-case has to be +implemented as TYPO3 extension. + +.. index:: Frontend, LocalConfiguration, PHP-API, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88496-MethodGetSwitchableControllerActionsHasBeenRemoved.rst b/Documentation/Changelog/10.0/Breaking-88496-MethodGetSwitchableControllerActionsHasBeenRemoved.rst new file mode 100644 index 0000000..3ea48cc --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88496-MethodGetSwitchableControllerActionsHasBeenRemoved.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88496: + +========================================================================= +Breaking: #88496 - Method getSwitchableControllerActions has been removed +========================================================================= + +See :issue:`88496` + +Description +=========== + +The abstract method :php:`\TYPO3\CMS\Extbase\Configuration\AbstractConfigurationManager::getSwitchableControllerActions` +has been removed in favor of :php:`\TYPO3\CMS\Extbase\Configuration\AbstractConfigurationManager::getControllerConfiguration`. +While the method name changes, the expected implemented functionality stays the same. + + +Impact +====== + +Method :php:`getSwitchableControllerActions` will no longer be called. Instead :php:`getControllerConfiguration` is expected +to be implemented by classes that extend :php:`TYPO3\CMS\Extbase\Configuration\AbstractConfigurationManager`. + + +Affected Installations +====================== + +All installations that have custom configuration managers that extend :php:`TYPO3\CMS\Extbase\Configuration\AbstractConfigurationManager`. + + +Migration +========= + +Rename method :php:`getSwitchableControllerActions` to :php:`getControllerConfiguration` to be TYPO3 >= 10 compatible. + +To stay compatible with both version 10 and lower, simply implement both methods and call :php:`getSwitchableControllerActions` from within :php:`getControllerConfiguration`. + +Example:: + + protected function getSwitchableControllerActions($extensionName, $pluginName) + { + return $this->getControllerConfiguration($extensionName, $pluginName); + } + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-88498-GlobalDataForTimeTrackerStatisticsRemoved.rst b/Documentation/Changelog/10.0/Breaking-88498-GlobalDataForTimeTrackerStatisticsRemoved.rst new file mode 100644 index 0000000..41afa50 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88498-GlobalDataForTimeTrackerStatisticsRemoved.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88498: + +================================================================= +Breaking: #88498 - Global data for TimeTracker statistics removed +================================================================= + +See :issue:`88498` + +Description +=========== + +The TimeTracker used some global variables to store :php:`microtime()` when a Frontend request was started +and ended, as information for the Admin Panel and as HTTP Header, if debug mode is enabled for Frontend. + +This information is now encapsulated within the TimeTracker object, making the following global variables +obsolete: + +* :php:`$GLOBALS['TYPO3_MISC']['microtime_start']` +* :php:`$GLOBALS['TYPO3_MISC']['microtime_end']` +* :php:`$GLOBALS['TYPO3_MISC']['microtime_BE_USER_start']` +* :php:`$GLOBALS['TYPO3_MISC']['microtime_BE_USER_end']` + +This also results in having :php:`$GLOBALS['TYPO3_MISC']` to not be set anymore. + + +Impact +====== + +Accessing the global variables will trigger a PHP :php:`E_WARNING` error, as they do not exist anymore. + + +Affected Installations +====================== + +Any TYPO3 installation with an extension working with any of the global variables. + + +Migration +========= + +Remove the usages and either use the newly introduced :php:`TimeTracker->finish()` to calculate data, or set +your own variables, if microtime is needed. + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88500-RTEImageHandlingFunctionalityDropped.rst b/Documentation/Changelog/10.0/Breaking-88500-RTEImageHandlingFunctionalityDropped.rst new file mode 100644 index 0000000..bcbf857 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88500-RTEImageHandlingFunctionalityDropped.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88500: + +=========================================================== +Breaking: #88500 - RTE image handling functionality dropped +=========================================================== + +See :issue:`88500` + +Description +=========== + +With the replacement of CKEditor as RTE instead of RTEHtmlArea in TYPO3 v8, the native +and very incomplete functionality of having images within the RTE was unused. + +It is still possible to use HTMLArea in further versions (if adopted), however the +handling of images is removed. + +This includes: + +* RTE processing mode ("ts_images") +* SoftReference Index for handling inline images +* Removed public method :php:`ImportExport->getRTEoriginalFilename()` +* Removed public method :php:`RteHtmlParser->TS_images_rte()` +* Removed CLI command "cleanup:rteimages" and relevant command class +* The configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['RTE_imageStorageDir']` + + +Impact +====== + +Images within an RTE field are not processed at all anymore, not part of the CLI. + +Calling the CLI script, using the PHP methods or the PHP CLI command class directly +within PHP, will result in a PHP :php:`E_ERROR` error. + +Accessing the configuration option will trigger a PHP :php:`E_NOTICE` error, as it is +silently removed, if customary set in :file:`LocalConfiguration.php`. + + +Affected Installations +====================== + +Any TYPO3 installation using images within CKEditor (with plugins) or still +using RTEHtmlArea. + +Any TYPO3 installation triggering the CLI command, handling RTE images via EXT:impexp +or directly handling functionality from the CLI command PHP class. + + +Migration +========= + +If necessary, it is recommended to add this functionality to a custom extension +where this functionality can live on. It is important however, that most of the +added functionality of TYPO3 in the last years was not supported (image cropping +inside RTE was not possible via the Image Cropper of FAL). + +It is recommended to move all images within an RTE to proper relations, or to +use extensions like `rte_ckeditor_image` from https://extensions.typo3.org. + +If any fork of RTEHtmlArea is still used in TYPO3 v10.0, the image functionality for +SoftRefParser, CLI command and the processing mode should be added there. + +.. index:: CLI, PHP-API, RTE, PartiallyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88525-RemoveCreateDirsDirectiveOfExtensionInstallationEm_confphp.rst b/Documentation/Changelog/10.0/Breaking-88525-RemoveCreateDirsDirectiveOfExtensionInstallationEm_confphp.rst new file mode 100644 index 0000000..e81c1d4 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88525-RemoveCreateDirsDirectiveOfExtensionInstallationEm_confphp.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88525: + +======================================================================================== +Breaking: #88525 - Remove "createDirs" directive of extension installation / em_conf.php +======================================================================================== + +See :issue:`88525` + +Description +=========== + +Every TYPO3 extension has a file called :file`ext_emconf.php` where important information regarding +dependencies, current version and loading order are stored. + +The directive :php:`createDirs` that was responsible to create a list of folders in the file structure +during extension installation has been dropped. + +The option was available before any File Abstraction Layer. As the :file:`uploads/` folder is not +created by default by TYPO3 anymore, this directive is not supported anymore as well, as TYPO3 strives +to support unified file handling for content files, volatile files (file uploads within :file:`typo3temp/var/`) +or within Extensions directly. The Environment API, introduced in TYPO3 v9, should support for PHP-based +APIs to choose / create a correct folder location. + + +Impact +====== + +Extensions having this directive set will not have this folder available at installation time +of the extension. The folder will not be created for newly installed extensions, existing extensions +when upgrading from previous TYPO3 versions, will continue to exist. + + +Affected Installations +====================== + +Any TYPO3 extension having this property within :file:`ext_emconf.php` set. + + +Migration +========= + +When an extension supports TYPO3 v10+ only, this directive can be removed. + +If an extension needs a special directory, this should be created via PHP when it is needed +via e.g. :php:`GeneralUtility::mkdir_deep()`. + +.. index:: PHP-API, NotScanned, ext:extensionmanager diff --git a/Documentation/Changelog/10.0/Breaking-88527-OverridingCustomValuesInUserAuthenticationDerivatives.rst b/Documentation/Changelog/10.0/Breaking-88527-OverridingCustomValuesInUserAuthenticationDerivatives.rst new file mode 100644 index 0000000..c0dcded --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88527-OverridingCustomValuesInUserAuthenticationDerivatives.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88527: + +============================================================================== +Breaking: #88527 - Overriding custom values in User Authentication derivatives +============================================================================== + +See :issue:`88527` + +Description +=========== + +Due to some restructuring of :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` and its direct sub-classes +:php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication` (a.k.a. :php:`$BE_USER`) and :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication`, +various settings are now directly initiated and set in the respective constructor of each PHP class. + +Following this, the properties :php:`sessionTimeout`, :php:`gc_time` and :php:`sessionDataLifetime` are set already +when the constructor is called. Before this was the case when :php:`start()` was called. + +In addition, the property :php:`loginType` must be set for any subclass on instantiation. Previously +this was possible to be set just before :php:`start()` was called. + +The previous behavior allowed to override certain parameters to be evaluated just before :php:`start()`. + + +Impact +====== + +Setting any global variables between the constructor method and :php:`start()` will have no effect, as +this is transferred and evaluated at the public properties already when the constructor is called. + +Subclassing :php:`AbstractUserAuthentication` without setting :php:`loginType` will trigger an exception +on instantiation. + + +Affected Installations +====================== + +Any TYPO3 installation where a custom UserAuthentication instantiation or sub-class is in place, and the setting +order was changed between calling the constructor and the method :php:`start()`, which is considered a very rare case. + + +Migration +========= + +Consider using a proper subclass and a custom constructor method, or set all properties properly before +the constructor is called (default values of class members). + +.. index:: PHP-API, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88540-ChangedRequestWorkflowForFrontendRequests.rst b/Documentation/Changelog/10.0/Breaking-88540-ChangedRequestWorkflowForFrontendRequests.rst new file mode 100644 index 0000000..9056b73 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88540-ChangedRequestWorkflowForFrontendRequests.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88540: + +================================================================= +Breaking: #88540 - Changed Request Workflow for Frontend Requests +================================================================= + +See :issue:`88540` + +Description +=========== + +The "Frontend Request Workflow" is the PHP code responsible for +setting up various functionality when the TYPO3 Frontend (= rendering of the website) +is booted and the content is built. This includes Login/Permission Check, resolving +the current site + language, and checking the page + rootline, then +parsing TypoScript, which will then lead to building content (or taken from +cache), until the actual output is returned. + +Since TYPO3 v9, this is all built via PSR-15 middlewares, the PSR-15 Request Handler, +and the global TypoScriptFrontendController (TSFE). + +For TYPO3 v10.0, various changes were made in order to separate concerns / logic +from each other, allowing to easily exchange certain components with +other / extended functionality. + +The following changes have been made: + +Storing session data from a Frontend User Session / Anonymous session is now triggered within the Frontend User +(`typo3/cms-frontend/authentication`) Middleware, at a later point - once the page was generated. Up until TYPO3 v9, this +was part of the RequestHandler logic right after content was put together. This was due to legacy reasons of the +previous hook execution order. + +Resolving the actual site - that is the site configuration plus the language - now happens before Frontend +and Backend User Authentication. This is important to understand to be able to define further settings within +Site Handling configuration in the future. Site and Site Language Resolving is now 100% independent of any permission +settings. Evaluating if a language is active is evaluated separately. + +Backend User Authentication (:php:`$BE_USER`) is now started before Frontend User Authentication (`fe_user`), previously +this was the other way around. Frontend Users are now stored in the request object via the `frontend.user` attribute, +instead of :php:`$TSFE->fe_user`, until :php:`$TSFE` is instantiated. + +Once all site + permission/authentication functionality has been set up, Routing now tries to detect +the target page ID and the URL parameters (`PageResolver` middleware) and evaluates the result, so-called +"Page Arguments" directly afterwards (`PageArgumentValidator` middleware). This effectively validates the cHash +logic. + +All of the mentioned parts above do not depend on :php:`TSFE` anymore. In fact, they are 100% independent of +any TSFE-related code. :php:`TSFE` is instantiated after all site resolving, authentication, page resolving and argument +validation is done. + +The new request workflow looks like this (simplified): + +#. Evaluation of Normalized Parameters (a.k.a. :php:`getIndpEnv`) & Evaluation of "Maintenance Mode" functionality +#. Handling registered eID scripts depending on GET parameter `eID` +#. Resolving Site configuration and Language from URL if possible +#. Resolving logged-in Backend User Authentication for previewing hidden pages or languages +#. Authentication of Website Users ("Frontend Users") +#. Executing various static routes and redirect functionality +#. Resolving Target Page ID and URL parameters based on Routing, Validation of Page Arguments based on "cHash" +#. Setting up global :php:`$TSFE` object, injecting previously resolved settings into TSFE. +#. Resolving the Rootline for the page +#. Parsing and Evaluation of TypoScript Instructions to render the page content +#. Build the content (cached / uncached) +#. Return the Response (PSR-7) to the base application and output headers + content. + +In addition, :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController` now expects the following constructor arguments: + +#. Context API object (previously a copy of :php:`$TYPO3_CONF_VARS`, until TYPO3 v8, then, unused) +#. :php:`TYPO3\CMS\Core\Site\Entity\SiteInterface` object (previously the Page ID) +#. :php:`TYPO3\CMS\Core\Site\Entity\SiteLanguage` object (previously the Page Type) +#. :php:`TYPO3\CMS\Core\Routing\PageArguments` object (previously the no_cache GET parameter) +#. :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication` object (previously the cHash parameter) + +Impact +====== + +Hooks that depend on certain functionality being made before or after a hook is +called will likely have a different behavior when a Frontend Session is used within Hooks. + +Anything related to regular plugins / content / TypoScript is not affected. + + +Affected Installations +====================== + +Any hooks from third party extensions that run +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_eofe']` +and depend on the frontend session data being written. + +Any TYPO3 extensions using middlewares in the frontend. + +Migration +========= + +Consider using a PSR-15 middleware instead of using a hook, or explicitly call :php:`storeSessionData()` within +the PHP hook if necessary. + +If an existing middleware was used, ensure that it's loaded in TYPO3 v10 at the proper location, as the +`typo3-cms/frontend/tsfe` middleware is loaded at a very late point. + +Ensure to use proper objects for the constructor arguments on :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController` when instantiating +the object on your own. + +.. index:: Frontend, PHP-API, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88564-PageTSconfigSettingTSFEconstantsRemoved.rst b/Documentation/Changelog/10.0/Breaking-88564-PageTSconfigSettingTSFEconstantsRemoved.rst new file mode 100644 index 0000000..8e7220c --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88564-PageTSconfigSettingTSFEconstantsRemoved.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88564: + +================================================================ +Breaking: #88564 - PageTSconfig setting "TSFE.constants" removed +================================================================ + +See :issue:`88564` + +Description +=========== + +The PageTSconfig / UserTSconfig :typoscript:`TSFE.constants`, which allowed to override settings constants +on a per-tree level page was introduced in TYPO3 at the very beginning, long before TSconfig had conditions. + +It was used to share TypoScript-based configuration between frontend / backend, and on a per-page/tree level. + +However, this has been superseded for a long time by using proper configuration files which +can be loaded at any time, for example when :file:`ext_localconf.php` of an extension is loaded. + +Therefore, the option has been removed. + + +Impact +====== + +Setting :typoscript:`TSFE.constants` in PageTSconfig or UserTSconfig has no effect, as it is not evaluated +anymore. + + +Affected Installations +====================== + +Any TYPO3 installation using :typoscript:`TSFE.constants` in their PageTSconfig. + + +Migration +========= + +It is recommended to include TypoScript conditions in setup/constants, also since constants+setup +are evaluated in Backend context for Extbase modules. This option is not needed anymore and +can be substituted by simple constants in `sys_template` or any Extension inclusion files as well. + +.. index:: TSConfig, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88574-4thParameterOfPageRepository-enableFieldsRemoved.rst b/Documentation/Changelog/10.0/Breaking-88574-4thParameterOfPageRepository-enableFieldsRemoved.rst new file mode 100644 index 0000000..4fcf307 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88574-4thParameterOfPageRepository-enableFieldsRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88574: + +======================================================================== +Breaking: #88574 - 4th parameter of PageRepository->enableFields removed +======================================================================== + +See :issue:`88574` + +Description +=========== + +The fourth parameter of :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository->enableFields()` was meant to filter out versioned records +which are in Live Workspace (versioning, not workspaces). Although the method has largely been superseded +with Doctrine DBAL's Restrictions, it is still used in some places. + +With the introduction of the Context API, new PageRepository instances can be created to fetch multiple variants +of certain aspects, instead of modifying existing public properties. Therefore the fourth argument has been removed. + + +Impact +====== + +Calling the method above with the fourth parameter set to true has no effect anymore, and will +trigger a PHP :PHP:`E_NOTICE` error. + + +Affected Installations +====================== + +Any TYPO3 installation dealing with non-workspace versioning in Frontend requests with third-party extension +still relying on non-workspace versioning. + + +Migration +========= + +The fourth parameter on any method call can be removed (if set to "false"), or should be replaced with a +separate instance of :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository` with a custom Context. + +.. index:: Frontend, PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88583-DatabaseFieldSys_languagestatic_lang_isocodeRemoved.rst b/Documentation/Changelog/10.0/Breaking-88583-DatabaseFieldSys_languagestatic_lang_isocodeRemoved.rst new file mode 100644 index 0000000..54f5e7c --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88583-DatabaseFieldSys_languagestatic_lang_isocodeRemoved.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88583: + +========================================================================== +Breaking: #88583 - Database field sys_language.static_lang_isocode removed +========================================================================== + +See :issue:`88583` + +Description +=========== + +The database field :sql:`static_lang_isocode` is a reference to a language within the third-party +extension `static_info_tables`. This was tightly coupled to TYPO3 Core until Site Handling was +introduced to add meaning and meta-data to a language on a per-site level. + +The field is not in use by the TYPO3 Core anymore, so the database definition is removed as well. + + +Impact +====== + +Migrating to TYPO3 v10.0 will remove the field in the Database Analyzer. + + +Affected Installations +====================== + +Multilingual TYPO3 installations without the TYPO3 Extension `static_info_tables` but with usages of the +database field, which is very unlikely. + + +Migration +========= + +The field can safely be removed in the Database Analyzer if it is not used by an extension. + +If the field is still needed, it is recommended to install the extension `static_info_tables`. + +If the data from the database field is used, it is recommended to fetch all metadata for a language +via the Site Configuration and the `SiteLanguage` API instead. + +.. index:: Database, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88638-StreamlinedSoftRefParserReferenceLookup.rst b/Documentation/Changelog/10.0/Breaking-88638-StreamlinedSoftRefParserReferenceLookup.rst new file mode 100644 index 0000000..476f3c3 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88638-StreamlinedSoftRefParserReferenceLookup.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88638: + +============================================================= +Breaking: #88638 - Streamlined SoftRefParser reference lookup +============================================================= + +See :issue:`88638` + +Description +=========== + +The Soft Reference Parser is a registry to allow to find parsers (PHP Objects), +for a given Parser Type (images, internal links, email links) to keep track of +referenced records within arbitrary data (e.g. RTE text-fields). + +Parsers can be added or overridden via the hook registry +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['softRefParser'][$parserType]`. + +Previously, the API method for fetching the proper parsers +:php:`TYPO3\CMS\Backend\Utility\BackendUtility::softRefParserObj()` kept a runtime cache of created objects +per type within a global PHP array (:php:`T3_VAR`). This allowed to create objects +only once, even if there are multiple necessary parts required. + +TYPO3's Core SoftRefParser does not keep any state, but the class now has a +:php:`SingletonInterface`, which means that the object is now a re-used object +as before. + + +Impact +====== + +Calling the SoftRefParser factory method does not keep state of the parser +objects via :php:`$GLOBALS['T3_VAR']['softRefParser']` anymore. + +Instead, :php:`SingletonInterface` is recommended for re-using SoftRefParser objects +if they need to keep state. + + +Affected Installations +====================== + +TYPO3 installations with extensions that use the API with custom parsers, +or the global variable directly. + + +Migration +========= + +Replace the global variable access via the API call to :php:`TYPO3\CMS\Backend\Utility\BackendUtility`, if this +is applicable. + +If a custom parser is in use, it is recommended to evaluate whether it contains +re-usable data and switch to :php:`SingletonInterface` instead. + +.. index:: Backend, FullyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88640-DatabaseFieldSys_templatenextLevelAndTypoScriptSublevel-InheritanceRemoved.rst b/Documentation/Changelog/10.0/Breaking-88640-DatabaseFieldSys_templatenextLevelAndTypoScriptSublevel-InheritanceRemoved.rst new file mode 100644 index 0000000..d09ace7 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88640-DatabaseFieldSys_templatenextLevelAndTypoScriptSublevel-InheritanceRemoved.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88640: + +======================================================================================================== +Breaking: #88640 - Database field "sys_template.nextLevel" and TypoScript sublevel - inheritance removed +======================================================================================================== + +See :issue:`88640` + +Description +=========== + +The database field :sql:`nextLevel` of the database table :sql:`sys_template` where TypoScript configuration +is stored, has been removed. + +The field :sql:`nextLevel` was introduced in TYPO3 v3.x before TypoScript could be imported from +external files. + +Nowadays, TypoScript conditions should be used much more instead of this :sql:`nextLevel` feature, +which is kind of a pseudo-condition. + + +Impact +====== + +The database field is removed, and not evaluated anymore in TypoScript compilation. + +Requesting the database field in custom database queries will result in an SQL error. + + +Affected Installations +====================== + +TYPO3 installations that have :sql:`sys_template` records with this flag activated, +or querying this database field in third-party extensions. + + +Migration +========= + +Check for existing :sql:`sys_template` records having this flag activated by executing +this SQL command: + +:sql:`SELECT * FROM sys_template WHERE nextLevel>0 AND deleted=0;` + +before updating TYPO3 Core. + +Replace the sys_template record (the uid of the record is stored in the "nextLevel" field) with a condition e.g. :typoscript:`[tree.level > 1]` to add TypoScript for subpages. + +.. index:: Database, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88643-RemovedSwiftmailerswiftmailerDependency.rst b/Documentation/Changelog/10.0/Breaking-88643-RemovedSwiftmailerswiftmailerDependency.rst new file mode 100644 index 0000000..9f1ba2c --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88643-RemovedSwiftmailerswiftmailerDependency.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88643: + +============================================================= +Breaking: #88643 - Removed swiftmailer/swiftmailer dependency +============================================================= + +See :issue:`88643` + +Description +=========== + +TYPO3's dependency swiftmailer has been removed in favor of new symfony-based +components "mime" and "mailer". + +This means that all SwiftMailer-related PHP code has been removed. + + +Impact +====== + +Custom SwiftMailer plugins or transports cannot be used without further +migration anymore and will result in a fatal :php:`E_ERROR`. + +Using SwiftMailer-specific API by using TYPO3's :php:`TYPO3\CMS\Core\Mail\MailMessage` class might result +in fatal :php:`E_ERROR` when sending out emails. + + +Affected Installations +====================== + +Any TYPO3 installation with third-party extension sending out emails or extending +TYPO3's email sending capabilities. + + +Migration +========= + +Search the third-party extensions' code for occurrences of MailMessage or +parts starting with `\Swift_` and migrate to symfony/mime or symfony/mailer +APIs, which are included in TYPO3 v10.0. + +If required, SwiftMailer code can be installed via composer (when running TYPO3 via composer) +via `composer require swiftmailer/swiftmailer`. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/10.0/Breaking-88646-RemovedInheritanceOfAbstractServiceFromAbstractAuthenticationService.rst b/Documentation/Changelog/10.0/Breaking-88646-RemovedInheritanceOfAbstractServiceFromAbstractAuthenticationService.rst new file mode 100644 index 0000000..e80772d --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88646-RemovedInheritanceOfAbstractServiceFromAbstractAuthenticationService.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88646: + +============================================================================================ +Breaking: #88646 - Removed inheritance of AbstractService from AbstractAuthenticationService +============================================================================================ + +See :issue:`88646` + +Description +=========== + +The PHP :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService` class is used for any kind of Authentication +or Authorization towards Backend Users and Frontend Users. + +It was previously based on :php:`TYPO3\CMS\Core\Service\AbstractService` for any kind of Service API, which +also includes manipulating files and execution of external applications, which is +there for legacy reasons since TYPO3 3.x, where the Service API via :php:`GeneralUtility::makeInstanceService` was added. + +In order to refactor the Authentication API, the :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService` +class does not inherit from :php:`TYPO3\CMS\Core\Service\AbstractService` anymore. Instead, the most required +methods for executing a service is added to the Abstract class directly. + + +Impact +====== + +Any calls or checks on the :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService` class or methods, properties or constants that reside within +:php:`TYPO3\CMS\Core\Service\AbstractService` will result in PHP :php:`E_ERROR` or :php:`E_WARNING`. + +Since :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService` is used for most custom Authentication APIs, +this could affect some of the hooks or custom authentication providers available. + + +Affected Installations +====================== + +TYPO3 installations that have custom Authentication providers for frontend or backend +users / groups - e.g. LDAP or Two-Factor-Authentication. + + +Migration +========= + +If your custom Authentication Service extends from :php:`TYPO3\CMS\Core\Authentication\AbstractAuthenticationService` +but requires methods or properties from :php:`TYPO3\CMS\Core\Service\AbstractService`, ensure to copy over the +necessary methods/properties/constants into your custom Authentication provider. + +.. index:: PHP-API, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88657-PopupConfigurationInFormEngineDropped.rst b/Documentation/Changelog/10.0/Breaking-88657-PopupConfigurationInFormEngineDropped.rst new file mode 100644 index 0000000..a710fa1 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88657-PopupConfigurationInFormEngineDropped.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88657: + +============================================================ +Breaking: #88657 - Popup configuration in FormEngine dropped +============================================================ + +See :issue:`88657` + +Description +=========== + +The options :typoscript:`options.popupWindowSize` and :typoscript:`options.rte.popupWindowSize` used to configure popup sizes have been +removed. + + +Impact +====== + +These options are not evaluated anymore. + + +Affected Installations +====================== + +All installations using 3rd party extensions relying on the options are affected. + + +Migration +========= + +In most cases it's fine to remove the configuration. + +In the unlikely case one is negatively affected by this change, fetch the configuration from backend user's TSConfig and +use it where it is required. + +.. index:: Backend, RTE, TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-88660-GLOBALST3_VARRemoved.rst b/Documentation/Changelog/10.0/Breaking-88660-GLOBALST3_VARRemoved.rst new file mode 100644 index 0000000..9380453 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88660-GLOBALST3_VARRemoved.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88660: + +=========================================== +Breaking: #88660 - $GLOBALS[T3_VAR] removed +=========================================== + +See :issue:`88660` + +Description +=========== + +The global variable :php:`$GLOBALS['T3_VAR']` previously used to hold global state for special +use cases - previously used within Service API via :php:`GeneralUtility::makeInstanceService()` +and to magically inject special hard-coded local indexed search files, has been removed. + +The overall goal of TYPO3's application is to not keep any state within global variables, and +the :php:`T3_VAR` ("TYPO3 Various") has not been actively used for that anymore since TYPO3 6.0, and +has been kept only for backwards-compatibility of the existing solutions. + +The initialization of the global variable during TYPO3 Bootstrap, any usages of :php:`T3_VAR`, +especially within "indexed search" has been removed. + + +Impact +====== + +Accessing :php:`$GLOBALS['T3_VAR']` is fully custom and not evaluated by TYPO3 Core anymore. + +Using the variable to modify any global state for e.g. Indexed Search's indexer via +:php:`$GLOBALS['T3_VAR']['ext']['indexed_search']['indexLocalFiles']` is not respected anymore +and has no effect. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions or code within :file:`AdditionalConfiguration.php` +that actively set or read values from the global variable. + + +Migration +========= + +Use your own custom global namespace to identify that your specific extension code has nothing +to do with TYPO3's legacy work. + +Use specific hooks for indexing local files used by download extensions in conjunction with +Indexed Search. + +.. index:: PHP-API, FullyScanned, ext:indexed_search diff --git a/Documentation/Changelog/10.0/Breaking-88667-RemovedAdditionalJavaScriptSubmitFromFormEngine.rst b/Documentation/Changelog/10.0/Breaking-88667-RemovedAdditionalJavaScriptSubmitFromFormEngine.rst new file mode 100644 index 0000000..b9d6737 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88667-RemovedAdditionalJavaScriptSubmitFromFormEngine.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88667: + +===================================================================== +Breaking: #88667 - Removed additionalJavaScriptSubmit from FormEngine +===================================================================== + +See :issue:`88667` + +Description +=========== + +FormEngine had the feature to add additional submit handlers via the option :php:`additionalJavaScriptSubmit`, that can +be set by form element renderables. TYPO3 uses RequireJS and a rewritten FormEngine since version 7, the property +:php:`additionalJavaScriptSubmit` has been removed. + +Additional, functions of :js:`TBE_EDITOR` that are associated with that feature (namely :js:`addActionChecks`) were removed as well. + + +Impact +====== + +The option has no effect anymore, the code won't get executed at all. + + +Affected Installations +====================== + +All 3rd-party extensions using this option are affected. + + +Migration +========= + +It is possible to create and register an AMD module. + +.. code-block:: php + + $resultArray['requireJsModules'][] = 'TYPO3/CMS/MyExtension/SubmitHandler'; + + +.. code-block:: javascript + + // typo3conf/ext/my_extension/Resources/Public/JavaScript/SubmitHandler.js + define(['TYPO3/CMS/Backend/DocumentSaveActions'], function (DocumentSaveActions) { + DocumentSaveActions.getInstance().addPreSubmitCallback(function (e) { + // e is the submit event + // Do stuff here + + // e.stopPropagation() stops the execution chain + }); + }); + + +.. index:: Backend, JavaScript, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Breaking-88669-FormEngineFormDataProviderParentPageTcaRemoved.rst b/Documentation/Changelog/10.0/Breaking-88669-FormEngineFormDataProviderParentPageTcaRemoved.rst new file mode 100644 index 0000000..78badda --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88669-FormEngineFormDataProviderParentPageTcaRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88669: + +====================================================================== +Breaking: #88669 - FormEngine FormDataProvider "parentPageTca" removed +====================================================================== + +See :issue:`88669` + +Description +=========== + +FormEngine added :php:`parentPageTca` by default to the result object. It was added in TYPO3 v7 during +refactoring, but already commented that it wasn't used at all in Core, and might not be necessary. + +It contained a copy of :php:`$GLOBALS['TCA']['pages']`, which can be obtained directly as well. + +The DataProvider and the value within the result key has been removed. + + +Impact +====== + +When accessing the :php:`parentPageTca` key within a FormDataProvider or Node (FormEngine-related only), +a PHP notice is given due to a non-existing array key. + + +Affected Installations +====================== + +TYPO3 installations with custom FormDataProviders for FormEngine relying on the "parentPageTca" +DataProvider, which is highly unlikely. + + +Migration +========= + +Instead of accessing :php:`$result['parentPageTca']` within a custom FormDataProvider or FormRenderNode, +:php:`$GLOBALS['TCA']['pages']` can be accessed directly. + +.. index:: TCA, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88681-ImportOfPHPFilesInImportExportFilesRemoved.rst b/Documentation/Changelog/10.0/Breaking-88681-ImportOfPHPFilesInImportExportFilesRemoved.rst new file mode 100644 index 0000000..699582e --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88681-ImportOfPHPFilesInImportExportFilesRemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88681: + +===================================================================== +Breaking: #88681 - Import of PHP files in Import/Export files removed +===================================================================== + +See :issue:`88681` + +Description +=========== + +Importing XML data via EXT:impexp previously allowed to import PHP files for Administrators +in TYPO3 Backend. This by-pass functionality is removed, and the configured File Deny Pattern +now applies for all imports in order to streamline import functionality with other file +operations within TYPO3 Core. + + +Impact +====== + +Importing XML files with embedded PHP files via EXT:impexp will trigger an import error and disallow +the import of the file. + + +Affected Installations +====================== + +Any TYPO3 installations using the data importer that use import files with included PHP files. + + +Migration +========= + +Ensure to include PHP files into a custom local extension, as importing PHP code is highly +discouraged - even for administrators. + +.. index:: PHP-API, NotScanned, ext:impexp diff --git a/Documentation/Changelog/10.0/Breaking-88687-ConfigureExtbaseRequestHandlersViaPHP.rst b/Documentation/Changelog/10.0/Breaking-88687-ConfigureExtbaseRequestHandlersViaPHP.rst new file mode 100644 index 0000000..868d25a --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88687-ConfigureExtbaseRequestHandlersViaPHP.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88687: + +============================================================= +Breaking: #88687 - Configure extbase request handlers via PHP +============================================================= + +See :issue:`88687` + +Description +=========== + +The configuration of extbase request handlers is no longer possible via typoscript. +All typoscript concerning the configuration of request handlers needs to be converted to php, residing +in :file:`EXT:Configuration/Extbase/RequestHandlers.php`. + + +Impact +====== + +Unless converted to php, the configuration in typoscript does no longer have any effect and therefore the registration +of request handlers will no longer work. + + +Affected Installations +====================== + +All installations that configure request handlers via typoscript. + + +Migration +========= + +Every extension that used typoscript for such configuration must provide a php configuration class called: +:file:`EXT:Configuration/Extbase/RequestHandlers.php` + +The migration is best described by an example: + +.. code-block:: typoscript + + config.tx_extbase { + mvc { + requestHandlers { + Vendor\Extension\Mvc\Web\FrontendRequestHandler = Vendor\Extension\Mvc\Web\FrontendRequestHandler + } + } + } + +This configuration will look like this, defined in php: + +.. code-block:: php + + <?php + declare(strict_types = 1); + + return [ + \Vendor\Extension\Mvc\Web\FrontendRequestHandler::class, + ]; + +.. warning:: + + With typoscript it was possible to override request handlers, registered by extensions loaded before the current one. + This also included core extensions. This approach has been bad practice because suitable request handlers are chosen + by their ability to handle a request and their priority. The evaluation of priorities could have been bypassed by + overriding keys of the configuration. This is no longer possible as request handler configuration files can only + add possible request handlers. Hence the omitted keys in the configuration array. + +.. index:: TypoScript, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Breaking-88706-StreamlineFeloginLocallangKeys.rst b/Documentation/Changelog/10.0/Breaking-88706-StreamlineFeloginLocallangKeys.rst new file mode 100644 index 0000000..fb11c27 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88706-StreamlineFeloginLocallangKeys.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88706: + +==================================================== +Breaking: #88706 - Streamline felogin locallang keys +==================================================== + +See :issue:`88706` + +Description +=========== + +Remove `ll_` prefixes from translation keys in :file:`ext:felogin/Resources/private/Language/locallang.xlf` so that they share the same identifiers with the flexform settings. + + +Impact +====== + +Breaks installations that override ext:felogin language keys that are prefixed with `ll_`. + + +Affected Installations +====================== + +Only installations that override one of the following keys via TypoScript are affected. + +Keys: + +- `ll_welcome_header` +- `ll_welcome_message` +- `ll_logout_header` +- `ll_logout_message` +- `ll_error_header` +- `ll_error_message` +- `ll_success_header` +- `ll_success_message` +- `ll_status_header` +- `ll_status_message` +- `ll_change_password_header` +- `ll_change_password_message` +- `ll_change_password_nolinkprefix_message` +- `ll_change_password_notvalid_message` +- `ll_change_password_notequal_message` +- `ll_change_password_tooshort_message` +- `ll_change_password_done_message` +- `ll_forgot_header` +- `ll_forgot_email_password` +- `ll_forgot_email_nopassword` +- `ll_forgot_validate_reset_password` +- `ll_forgot_message` +- `ll_forgot_message_emailSent` +- `ll_forgot_reset_message` +- `ll_forgot_reset_message_emailSent` +- `ll_forgot_reset_message_error` +- `ll_forgot_header_backToLogin` +- `ll_enter_your_data` + + +Migration +========= + +Remove the `ll_` prefix from the key. + +.. index:: Frontend, NotScanned, ext:felogin diff --git a/Documentation/Changelog/10.0/Breaking-88724-RemoveSuperfluousMethodsOfLocalizationRedirect.rst b/Documentation/Changelog/10.0/Breaking-88724-RemoveSuperfluousMethodsOfLocalizationRedirect.rst new file mode 100644 index 0000000..afa0e86 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88724-RemoveSuperfluousMethodsOfLocalizationRedirect.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88724: + +===================================================================== +Breaking: #88724 - Remove superfluous methods of localizationRedirect +===================================================================== + +See :issue:`88724` + +Description +=========== + +The method :php:`localizationRedirect` in PageLayoutView, DatabaseRecordList and EditDocumentController were almost equal. +The usage has been streamlined and the methods in PageLayoutView and DatabaseRecordList have been removed. + + +Impact +====== + +Calling the routes `web_layout` or `web_list` with parameter `justLocalized` will not redirect to the translated record anymore. +Calling :php:`TYPO3\CMS\Backend\View\PageLayoutView->localizationRedirect` or :php:`TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList->localizationRedirect` +will result in a fatal :php:`E_ERROR`. + + +Migration +========= + +Use route `record_edit` instead of `web_layout` or `web_list`. Set as additional parameter `returnUrl` to the url to the certain module. +Use :php:`TYPO3\CMS\Backend\Controller\EditDocumentController->localizationRedirect` instead of +:php:`TYPO3\CMS\Backend\View\PageLayoutView->localizationRedirect` or :php:`TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList`. + +.. index:: PHP-API, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88741-CHashCalculationInIndexedSearchRemoved.rst b/Documentation/Changelog/10.0/Breaking-88741-CHashCalculationInIndexedSearchRemoved.rst new file mode 100644 index 0000000..cec6b39 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88741-CHashCalculationInIndexedSearchRemoved.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88741: + +============================================================== +Breaking: #88741 - cHash calculation in indexed search removed +============================================================== + +See :issue:`88741` + +Description +=========== + +When indexing a page, the indexer of `Indexed search` previously kept the used cHash and the +used "cHashParams" for storing search entries. This is not necessary anymore, as Site Handling now +contains the relevant arguments already in the search entry as well. This can be removed now. + +In addition, when setting up an Indexing configuration, the option to respect cHash is removed, +as this is done automatically when needed. + +The public property :php:`TYPO3\CMS\IndexedSearch\Indexer->cHashParams` has been removed. + +The sixth method argument of :php:`TYPO3\CMS\IndexedSearch\Indexer->backend_initIndexer()` +has been removed. + +The following database fields are unused and have been removed: + +* :sql:`index_config.chashcalc` +* :sql:`index_phash.cHashParams` + +The database field :sql:`index_debug.debuginfo` now contains data stored in a JSON-formatted string +instead of a serialized PHP string. + + +Impact +====== + +Manual database queries accessing the database fields will result in SQL errors. + +In addition, accessing the removed property or using the sixth argument of the changed public method +will have no effect anymore. + + +Affected Installations +====================== + +TYPO3 installations using Indexed Search and custom configuration or extending functionality +of Indexed Search. + + +Migration +========= + +No migration needed, as everything works as before. The data is now stored in +the database field as JSON-encoded string `index_phash.static_page_arguments`. + +In case of using debug information for Indexed Search (index with enabled debug information), +where data was previously stored in `index_debug.debuginfo` as serialized PHP string, +indexing needs to be rebuilt, but only to render the debug information properly in the TYPO3 Backend +module. If debug information is not enabled, re-indexing is not necessary. + +.. index:: Database, PHP-API, PartiallyScanned, ext:indexed_search diff --git a/Documentation/Changelog/10.0/Breaking-88744-DatabaseFieldsRelatedToCSSStyledContentRemoved.rst b/Documentation/Changelog/10.0/Breaking-88744-DatabaseFieldsRelatedToCSSStyledContentRemoved.rst new file mode 100644 index 0000000..724362a --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88744-DatabaseFieldsRelatedToCSSStyledContentRemoved.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88744: + +======================================================================== +Breaking: #88744 - Database fields related to CSS Styled Content removed +======================================================================== + +See :issue:`88744` + +Description +=========== + +CSS Styled Content was superseded with Fluid Styled Content in TYPO3 v7, and support was dropped +with TYPO3 v9. TYPO3 Core still shipped with some database fields that were kept to easy +manual migration for specific values in these fields. + +These database fields within the database table :sql:`tt_content` have been removed. + +* :sql:`tt_content.spaceBefore` (now used via space_before_class) +* :sql:`tt_content.spaceAfter` (now used via space_after_class) + + +Impact +====== + +Accessing the database fields with a custom SQL query will result in SQL errors or empty values. + + +Affected Installations +====================== + +TYPO3 installations from earlier TYPO3 versions (prior to v8) that still have CSS Styled Content +in use or adopted to migrate the fields to still render via CSS Styled Content. + +Additionally, TYPO3 installations that mis-used the database fields for other purposes but +still rely on the presence of the database fields. + + +Migration +========= + +If the database fields still contain value that hasn't been migrated, it is possible to re-add +these database fields in a custom extension. + +It is recommended to switch to Fluid Styled Content rendering or custom content types with +custom additional fields. + +.. index:: Database, Frontend, NotScanned, ext:frontend diff --git a/Documentation/Changelog/10.0/Breaking-88755-RemovePOSTOptionFromTypolinkaddQueryStringmethod.rst b/Documentation/Changelog/10.0/Breaking-88755-RemovePOSTOptionFromTypolinkaddQueryStringmethod.rst new file mode 100644 index 0000000..efa6900 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88755-RemovePOSTOptionFromTypolinkaddQueryStringmethod.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88755: + +========================================================================= +Breaking: #88755 - Remove POST option from typolink.addQueryString.method +========================================================================= + +See :issue:`88755` + +Description +=========== + +Setting :typoscript:`addQueryString.method` of typolink could be used like shown below in order to transform +HTTP POST parameters into according GET parameters. + +.. code-block:: typoscript + + typolink { + parameter = 123 + addQueryString = 1 + addQueryString.method = POST + } + +In terms of correctly using HTTP verbs it's bad practise in general to treat GET and POST equally, besides that +documentation already mentioned potential side-effects like accidentally exposing sensitive data submitted via +POST to proxies or log files. + +That's why values :typoscript:`POST`, :typoscript:`GET,POST` and :typoscript:`POST,GET` are not allowed anymore +for :typoscript:`typolink.addQueryString.method`. Maintaining functionality - if required at all - has to be done +using domain specific logic in according controllers or middleware implementations. + + +Impact +====== + +* using :typoscript:`GET,POST`, :typoscript:`POST,GET` or :typoscript:`POST` will trigger an :php:`E_USER_WARNING` +* using :typoscript:`GET,POST` or :typoscript:`POST,GET` will fall back to :typoscript:`GET` +* using :typoscript:`POST` will be ignored and an empty result + +In a consequence only query parameters submitted via HTTP GET are taken into account, parameters of HTTP POST +body are ignored. + + +Affected Installations +====================== + +* TypoScript defining :typoscript:`typolink.addQueryString.method` with values mentioned in previous section +* invocations of :php:`TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setAddQueryStringMethod()` with values + mentioned in previous section +* as an effect Fluid view helpers forwarding this information to + :php:`TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::setAddQueryStringMethod()` are affected - + argument :php:`addQueryStringMethod` is affected in view helper of TYPO3 core like shown below + + :html:`<f:form ... addQueryStringMethod="POST">` + + :html:`<f:link.action addQueryStringMethod="POST">` + + :html:`<f:link.page ... addQueryStringMethod="POST">` + + :html:`<f:link.typolink addQueryStringMethod="POST">` + + :html:`<f:uri.action ... addQueryStringMethod="POST">` + + :html:`<f:uri.page ... addQueryStringMethod="POST">` + + :html:`<f:uri.typolink addQueryStringMethod="POST">` + + :html:`<f:widget.uri ... addQueryStringMethod="POST">` + + :html:`<f:widget.link addQueryStringMethod="POST">` + + :html:`<f:widget.paginate ... configuration="{addQueryStringMethod: 'POST'}">` + + +Migration +========= + +* change to mentioned assignments in TypoScript, Fluid templates or PHP code to :typoscript:`GET` +* analyse and try to understand whether :typoscript:`POST` is still required or could be substituted + + +.. index:: Backend, Fluid, Frontend, PHP-API, TypoScript, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88758-SelectiveConcatenationOfCSSFilesInResourceCompressorRemoved.rst b/Documentation/Changelog/10.0/Breaking-88758-SelectiveConcatenationOfCSSFilesInResourceCompressorRemoved.rst new file mode 100644 index 0000000..8db6b2b --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88758-SelectiveConcatenationOfCSSFilesInResourceCompressorRemoved.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88758: + +===================================================================================== +Breaking: #88758 - Selective Concatenation of CSS files in ResourceCompressor removed +===================================================================================== + +See :issue:`88758` + +Description +=========== + +:php:`TYPO3\CMS\Core\Resource\ResourceCompressor`, used to merge and compress CSS and JS files, has had an option to only +merge CSS files from selected folders. This was used to limit CSS files of skins for TYPO3 +Backend files. + +The functionality has been removed, as all added CSS files are now merged into one file. + +As TYPO3 Frontend and TypoScript has a much more flexible system for adding CSS files, +which should be concatenated, this change does not affect TYPO3 API of Frontend Requests. + + +Impact +====== + +Calling :php:`TYPO3\CMS\Core\Resource\ResourceCompressor->concatenateCssFiles()` with a second argument has no effect anymore. + +Adding CSS files manually in TYPO3 Backend via custom extensions will now automatically be merged +with the loaded CSS styles of :php:`$TBE_STYLES` skin. + + +Affected Installations +====================== + +TYPO3 installations with extensions adding third-party CSS files in the TYPO3 Backend, +or extensions using :php:`TYPO3\CMS\Core\Resource\ResourceCompressor` directly. + + +Migration +========= + +None, as it is considered to be useful to have one larger CSS file for TYPO3 Backend. + +If necessary, add a CSS file manually via PageRenderer API which should be excluded from Concatenation. + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Breaking-88772-JavaScriptScriptTagsOmitTypetextjavascriptInHTML5.rst b/Documentation/Changelog/10.0/Breaking-88772-JavaScriptScriptTagsOmitTypetextjavascriptInHTML5.rst new file mode 100644 index 0000000..ea6dc5b --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88772-JavaScriptScriptTagsOmitTypetextjavascriptInHTML5.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88772: + +============================================================================ +Breaking: #88772 - JavaScript script tags omit type=text/javascript in HTML5 +============================================================================ + +See :issue:`88772` + +Description +=========== + +When rendering HTML5 output, :html:`<script>` tags do not the additional attribute :html:`type=text/javascript` +anymore as it is considered optional, and if none given, modern browsers fall back to this type +already. + +See the official W3C definition here: https://www.w3.org/TR/html52/semantics-scripting.html#element-attrdef-script-type + +For this reason, all of TYPO3's Backend (which is rendering HTML5) and Installer do not include +this optional attribute in :html:`<script>` tags anymore. + +For TYPO3 Frontend rendering, the attribute is omitted when having no doctype or HTML5 as doctype +configured (via TypoScript :typoscript:`config.doctype = html5`). This leads to a minimal smaller +HTML document submitted to the client. + +For any XHTML or HTML4-based website, the attribute is still added. + + +Impact +====== + +TYPO3's Frontend rendering does not render :html:`type=text/javascript` anymore in :html:`<script>` tags when +rendering a HTML5 output, unless explicitly specified. + + +Affected Installations +====================== + +Any TYPO3 installation running a HTML5-based frontend output. + + +Migration +========= + +As all modern browsers do not need this tag, and the specification says it's optional, there is +no migration needed at all. + +If still requested by a specific project, it can be added via: + +.. code-block:: javascript + + page.includeJS.myfile = EXT:site_mysite/Resources/Public/JavaScript/myfile.js + page.includeJS.myfile.type = text/javascript + +.. index:: Frontend, TypoScript, NotScanned diff --git a/Documentation/Changelog/10.0/Breaking-88779-RecordListRemoveUnusedCode.rst b/Documentation/Changelog/10.0/Breaking-88779-RecordListRemoveUnusedCode.rst new file mode 100644 index 0000000..497c680 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88779-RecordListRemoveUnusedCode.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88779: + +================================================= +Breaking: #88779 - RecordList: Remove unused code +================================================= + +See :issue:`88779` + +Description +=========== + +The following public properties have been removed from :php:`TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList`: + +* :php:`modSharedTSconfig` +* :php:`no_noWrap` +* :php:`setLMargin` +* :php:`JScode` +* :php:`leftMargin` + +The following public methods have been removed from :php:`TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList`: + +* :php:`getButtons` +* :php:`thumbCode` +* :php:`requestUri` +* :php:`writeTop` +* :php:`fwd_rwd_nav` +* :php:`fwd_rwd_HTML` + + +Impact +====== + +Calling one of the mentioned methods will trigger a fatal :php:`E_ERROR`. + + +Migration +========= + +Use :php:`BackendUtility::thumbCode` instead of :php:`thumbCode`. Use :php:`listURL` instead of :php:`requestUri`. + +.. index:: PHP-API, FullyScanned, ext:recordlist diff --git a/Documentation/Changelog/10.0/Breaking-88799-IntroducedPSR-3CompatibleLoggingAPI.rst b/Documentation/Changelog/10.0/Breaking-88799-IntroducedPSR-3CompatibleLoggingAPI.rst new file mode 100644 index 0000000..ea1f937 --- /dev/null +++ b/Documentation/Changelog/10.0/Breaking-88799-IntroducedPSR-3CompatibleLoggingAPI.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _breaking-88799: + +========================================================== +Breaking: #88799 - Introduced PSR-3 compatible Logging API +========================================================== + +See :issue:`88799` + +Description +=========== + +With the adaption of the PSR-3 standard some PHP code had to be changed in order to reach compliance. +The key difference is that log levels are now represented by strings rather than numbers. Note that the order +of log levels is not affected and stays the same. + +The breaking changes mostly affect internal functionality and should not apply to third-party extensions. + + +Impact +====== + +The class :php:`\TYPO3\CMS\Core\Log\LogLevel` now extends from the PSR-3 base class and therefore inherits the new definition +of the log levels (`EMERGENCY` to `DEBUG`) based on string values. + +The signatures of following methods have been adjusted to accept the new :php:`LogLevel::*` constants: + +* :php:`\TYPO3\CMS\Core\Log\Logger::addWriter` +* :php:`\TYPO3\CMS\Core\Log\Logger::addProcessor` + +The internal storage of the log level inside :php:`\TYPO3\CMS\Core\Log\LogRecord` has been adjusted, consequently the methods + +* :php:`setLevel()` and +* :php:`getLevel()` + +respectively accept and return :php:`string` values now. + +In case you have configured own logger or log targets, you have to adjust the integer level and use strings. + +Example: + +.. code-block:: php + + # old configuration + $GLOBALS['TYPO3_CONF_VARS']['LOG']['TYPO3']['CMS']['Core']['writerConfiguration'] = [ + 7 => [ + \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [ + 'logFile' => 'typo3temp/var/log/core.log' + ] + ], + ]; + + # new configuration + $GLOBALS['TYPO3_CONF_VARS']['LOG']['TYPO3']['CMS']['Core']['writerConfiguration'] = [ + 'debug' => [ + \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [ + 'logFile' => 'typo3temp/var/log/core.log' + ] + ], + ]; + +In case you have used the constants like :php:`LogLevel::DEBUG` you are fine and your config will work like before. + + +Affected Installations +====================== + +Any installation using third-party extensions interacting with the internals of the Logging API. + + +Migration +========= + +There are two easy ways to convert the integer to the string representation and vice versa: + +- Convert from integer to string: :php:`$logLevel = LogLevel::getInternalName($logLevelAsNumber)` +- Convert from string to integer: :php:`$logLevelAsNumber = LogLevel::normalizeLevel($logLevel)` + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-80420-EmailFinisherSingleAddressOptions.rst b/Documentation/Changelog/10.0/Deprecation-80420-EmailFinisherSingleAddressOptions.rst new file mode 100644 index 0000000..c61e938 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-80420-EmailFinisherSingleAddressOptions.rst @@ -0,0 +1,157 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-80420: + +========================================================== +Deprecation: #80420 - EmailFinisher single address options +========================================================== + +See :issue:`80420` + +Description +=========== + +The :php:`EmailFinisher` of EXT:form has options to set multiple recipients for :code:`To`, :code:`CC` and :code:`BCC`. +For consistency reasons and to limit the number of choices, resulting in easier configuration, the single value configuration options +have will be removed in favour for their respective multi value variants. + +For this reason, the following options have been marked as deprecated and will be removed in TYPO3 11.0: + +* :yaml:`recipientAddress` +* :yaml:`recipientName` +* :yaml:`replyToAddress` +* :yaml:`carbonCopyAddress` +* :yaml:`blindCarbonCopyAddress` + +If any of these options are used, their values will be automatically migrated to their replacements. + +Opening and saving a form with the form editor once also performs this migration and makes it permanent. + + +Impact +====== + +Any of these options will no longer work in TYPO3 11.0. + + +Affected Installations +====================== + +All installations which use EXT:form and its :php:`EmailFinisher`. + + +Migration +========= + +All single value options must be migrated to their list value successors. + + +Multiple Recipients +------------------- + +Change :yaml:`recipientAddress` and :yaml:`recipientName` to :yaml:`recipients`. + +Before: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + recipientAddress: to@example.org + recipientName: 'To Example' + +After: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + recipients: + to@example.org: 'To Example' + + +Multiple Reply-To Recipients +---------------------------- + +Change :yaml:`replyToAddress` to :yaml:`replyToRecipients`. Additionally this allows for setting the name of a Reply-To recipient. + +Before: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + replyToAddress: rt@example.org + +After: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + replyToRecipients: + rt@example.org@example.org: 'Reply-To Example' + + +Multiple Carbon Copy (CC) Recipients +------------------------------------ + +Change :yaml:`carbonCopyAddress` to :yaml:`carbonCopyRecipients`. Additionally this allows for setting the name of a CC recipient. + +Before: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + carbonCopyAddress: cc@example.org + +After: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + carbonCopyRecipients: + cc@example.org: 'CC Example' + + +Multiple Blind Carbon Copy (BCC) Recipients +------------------------------------------- + +Change :yaml:`blindCarbonCopyAddress` to :yaml:`blindCarbonCopyRecipients`. Additionally this allows for setting the name of a BCC recipient. + +Before: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + blindCarbonCopyAddress: bcc@example.org + +After: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + blindCarbonCopyRecipients: + bcc@example.org: 'BCC Example' + +.. index:: YAML, NotScanned, ext:form diff --git a/Documentation/Changelog/10.0/Deprecation-82669-StreamlineBackendRoutePathInconsistencies.rst b/Documentation/Changelog/10.0/Deprecation-82669-StreamlineBackendRoutePathInconsistencies.rst new file mode 100644 index 0000000..9f47da3 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-82669-StreamlineBackendRoutePathInconsistencies.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-82669: + +=================================================================== +Deprecation: #82669 - Streamline Backend route path inconsistencies +=================================================================== + +See :issue:`82669` + +Description +=========== + +When registering Backend modules, it is already possible to define a custom route path, via the :php:`path` +option in the module configuration section within :file:`ext_tables.php`. + +Backend Routes to modules without path configurations are now named using the pattern +"/module/<main-module-name>/<submodule-name>" e.g. `/module/web/ts`. + +Old route paths for modules are called "/web/ts/" will still work but are discouraged to use. + + +Impact +====== + +Creating modules without a defined "path" option will now have two path routes available to be +resolved, whereas the old path will be removed in TYPO3 v10.0. + + +Affected Installations +====================== + +Any installation using TYPO3 Backend Links via :php:`TYPO3\CMS\Backend\Routing\UriBuilder->buildUriFromRoutePath()` in custom extensions. + + +Migration +========= + +TYPO3 Backend Links via :php:`TYPO3\CMS\Backend\Routing\UriBuilder->buildUriFromRoutePath()` should be used with the new module name as +described above. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Deprecation-85895-DeprecateFile_getMetaData.rst b/Documentation/Changelog/10.0/Deprecation-85895-DeprecateFile_getMetaData.rst new file mode 100644 index 0000000..acca70f --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-85895-DeprecateFile_getMetaData.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-85895: + +==================================================== +Deprecation: #85895 - Deprecate File::_getMetaData() +==================================================== + +See :issue:`85895` + +Description +=========== + +The internal method :php:`TYPO3\CMS\Core\Resource\File::_getMetaData()` which is used to fetch meta data of a file +has been marked as deprecated. This method has been superseded by the :php:`TYPO3\CMS\Core\Resource\MetaDataAspect`. + + +Impact +====== + +Using this method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any 3rd party extension calling :php:`:php:`TYPO3\CMS\Core\Resource\File::_getMetaData()` is affected. + + +Migration +========= + +To fetch the meta data, call :php:`$fileObject->getMetaData()->get()` instead. + +.. index:: FAL, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatContants.rst b/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatContants.rst new file mode 100644 index 0000000..ecf762a --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatContants.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87200-1668719172: + +====================================================== +Deprecation: #87200 - EmailFinisher FORMAT_* constants +====================================================== + +See :issue:`87200` + +Description +=========== + +The constants + +* :php:`TYPO3\CMS\Form\Domain\Finishers\EmailFinisher::FORMAT_PLAINTEXT` and +* :php:`TYPO3\CMS\Form\Domain\Finishers\EmailFinisher::FORMAT_HTML` + +have been marked as deprecated and will be removed in TYPO3 11.0. + + +Impact +====== + +Accessing these constants will lead to a fatal :php:`E_ERROR` in TYPO3 11.0. + + +Affected Installations +====================== + +All installations which use EXT:form and directly access these constants. + + +Migration +========= + +Do not use these constants anymore. + +.. index:: PHP-API, FullyScanned, ext:form diff --git a/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatOption.rst b/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatOption.rst new file mode 100644 index 0000000..040e083 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87200-EmailFinisherFormatOption.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87200: + +=================================================== +Deprecation: #87200 - EmailFinisher "format" option +=================================================== + +See :issue:`87200` + +Description +=========== + +The :yaml:`format` option of the :php:`TYPO3\CMS\Form\Domain\Finishers\EmailFinisher` has been marked as deprecated and +will be removed in TYPO3 11.0. It is replaced by the new :yaml:`addHtmlPart` option which can be used to disable HTML +and enforce plaintext-only mails. If set, mails will contain a plaintext and HTML part, otherwise only a plaintext part. + +If the :yaml:`format` option is used, its value will be automatically migrated to :yaml:`addHtmlPart`: + +* :yaml:`format: html` becomes :yaml:`addHtmlPart: true` +* :yaml:`format: plaintext` becomes :yaml:`addHtmlPart: false` +* a missing :yaml:`format` becomes :yaml:`addHtmlPart: true` + +Opening and saving a form with the form editor once also performs this migration and makes it permanent. + + +Impact +====== + +The :yaml:`format` option will no longer work in TYPO3 11.0. + + +Affected Installations +====================== + +All installations which use EXT:form and its :php:`TYPO3\CMS\Form\Domain\Finishers\EmailFinisher`. + + +Migration +========= + +Replace :yaml:`format: html` with :yaml:`addHtmlPart: true`. + +Replace :yaml:`format: plaintext` with :yaml:`addHtmlPart: false`. + +.. index:: YAML, NotScanned, ext:form diff --git a/Documentation/Changelog/10.0/Deprecation-87305-UseConstructorInjectionInDataMapper.rst b/Documentation/Changelog/10.0/Deprecation-87305-UseConstructorInjectionInDataMapper.rst new file mode 100644 index 0000000..f3d8d04 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87305-UseConstructorInjectionInDataMapper.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87305: + +============================================================= +Deprecation: #87305 - Use constructor injection in DataMapper +============================================================= + +See :issue:`87305` + +Description +=========== + +The 8th argument (:php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface`) of method +:php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper->__construct` has been marked as deprecated. + + +Impact +====== + +Instantiating objects along with that argument will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations that create instances of the class :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper` while providing the 8th argument. + + +Migration +========= + +Instantiate the object without the 8th argument and use :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper->setQuery` if needed. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Deprecation-87332-AvoidRuntimeReflectionCallsInObjectAccess.rst b/Documentation/Changelog/10.0/Deprecation-87332-AvoidRuntimeReflectionCallsInObjectAccess.rst new file mode 100644 index 0000000..f702599 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87332-AvoidRuntimeReflectionCallsInObjectAccess.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87332: + +==================================================================== +Deprecation: #87332 - Avoid runtime reflection calls in ObjectAccess +==================================================================== + +See :issue:`87332` + +Description +=========== + +Class :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess` uses reflection to make non public properties gettable and settable. +This behaviour is triggered by setting the argument :php:`$forceDirectAccess` of methods + +* :php:`getProperty` +* :php:`getPropertyInternal` +* :php:`setProperty` + +to :php:`true`. Triggering this behaviour has been marked as deprecated and will be removed in TYPO3 11.0. + +Method :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess::buildSetterMethodName` has been marked as deprecated and will be removed in TYPO3 11.0. + + +Impact +====== + +1) Accessing non public properties via the mentioned methods will no longer work in TYPO3 11.0. + +2) Calling :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess::buildSetterMethodName` will no longer work in TYPO3 11.0. + + +Affected Installations +====================== + +1) All installations that use the mentioned methods with argument :php:`$forceDirectAccess` set to :php:`true`. + +2) All installations that call :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess::buildSetterMethodName`. + + +Migration +========= + +1) Make sure the affected property is accessible by either making it public or providing getters/hassers/issers or setters +(:php:`getProperty()`, :php:`hasProperty()`, :php:`isProperty()`, :php:`setProperty()`). + +2) Build setter names manually: :php:`$setterMethodName = 'set' . ucfirst($propertyName);` + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Deprecation-87550-UseControllerClassesWhenRegisteringPluginsmodules.rst b/Documentation/Changelog/10.0/Deprecation-87550-UseControllerClassesWhenRegisteringPluginsmodules.rst new file mode 100644 index 0000000..f88b5dd --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87550-UseControllerClassesWhenRegisteringPluginsmodules.rst @@ -0,0 +1,92 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87550: + +============================================================================= +Deprecation: #87550 - Use controller classes when registering plugins/modules +============================================================================= + +See :issue:`87550` + +Description +=========== + +Configuring plugins and modules via the following methods has changed in two important ways. + +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin` +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule` + +Both methods expect you to provide the argument :php:`$extensionName` and :php:`$controllerActions`. +:php:`configurePlugin` also allows the argument :php:`$nonCacheableControllerActions`. + +The first important change targets the :php:`$extensionName` argument. +During the switch from underscore class names :php:`Tx_Extbase_Foo_Bar` to actual namespaced classes +:php:`TYPO3\CMS\Extbase\Foo\Bar`, a vendor `TYPO3\CMS` has been introduced which had to be respected +during the configuration of plugins. To make that possible the argument :php:`$extensionName` has been +prepended with the vendor name, concatenated with dots. + +.. code-block:: php + + <?php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'TYPO3.CMS.Form', // $extensionName + 'Formframework', + ['FormFrontend' => 'render, perform'], + ['FormFrontend' => 'perform'], + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + +Setting the vendor name is now deprecated and must be omitted. Instead, the vendor name will be derived +from the controller class namespace, which leads to the second important change. + +Both arguments :php:`$controllerActions` and :php:`$nonCacheableControllerActions` used controller aliases as +array keys. The alias was the controller class name without the namespace and without the :php:`Controller` +suffix. There were a lot of conventions and a custom autoloader mechanism before the introduction +of the composer autoloader, which made it necessary to put controllers in a specific directory and to name +the controller accordingly. As this is no longer the case, there is no need to guess the controller class name +any longer. Instead, the configuration/registration is now done with fully qualified controller class names. + +.. code-block:: php + + <?php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'Form', + 'Formframework', + [\TYPO3\CMS\Form\Controller\FormFrontendController::class => 'render, perform'], + [\TYPO3\CMS\Form\Controller\FormFrontendController::class => 'perform'], + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + +Conclusion +========== + +The following things have been marked as deprecated: + +* Prepend the :php:`$extensionName` argument with a vendor name. +* Using controller aliases as array keys in both arguments :php:`$controllerActions` and :php:`$nonCacheableControllerActions`. + + +Impact +====== + +Using the deprecated syntax will trigger PHP :php:`E_USER_DEPRECATED` errors and will stop working in TYPO3 11.0. + + +Affected Installations +====================== + +All installations that use these methods: + +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin` +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule` + + +Migration +========= + +* Omit the vendor name in argument :php:`$extensionName` +* Use fully qualified class names as array keys in arguments :php:`$controllerActions` and :php:`$nonCacheableControllerActions` + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Deprecation-87613-DeprecateTYPO3CMSExtbaseUtilityTypeHandlingUtilityhex2bin.rst b/Documentation/Changelog/10.0/Deprecation-87613-DeprecateTYPO3CMSExtbaseUtilityTypeHandlingUtilityhex2bin.rst new file mode 100644 index 0000000..561632a --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87613-DeprecateTYPO3CMSExtbaseUtilityTypeHandlingUtilityhex2bin.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87613: + +============================================================================================ +Deprecation: #87613 - Deprecate \\TYPO3\\CMS\\Extbase\\Utility\\TypeHandlingUtility::hex2bin +============================================================================================ + +See :issue:`87613` + +Description +=========== + +:php:`\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::hex2bin` has been marked as deprecated and will be removed in TYPO3 11.0. + + +Impact +====== + +Calling :php:`\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::hex2bin` will trigger PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations that call :php:`\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::hex2bin`. + + +Migration +========= + +Use the native php function :php:`hex2bin` instead. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.0/Deprecation-87882-FileRelatedControllersMovedToEXTfilelist.rst b/Documentation/Changelog/10.0/Deprecation-87882-FileRelatedControllersMovedToEXTfilelist.rst new file mode 100644 index 0000000..c3bf6d1 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87882-FileRelatedControllersMovedToEXTfilelist.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87882: + +==================================================================== +Deprecation: #87882 - File related controllers moved to EXT:filelist +==================================================================== + +See :issue:`87882` + +Description +=========== + +The following controllers have been moved to extension `filelist` as they are part of +the filelist feature set: + +* :php:`CreateFolderController` +* :php:`EditFileController` +* :php:`FileUploadController` +* :php:`RenameFileController` +* :php:`ReplaceFileController` + + +Impact +====== + +The namespace changed from :php:`TYPO3\CMS\Backend\Controller\File` to :php:`TYPO3\CMS\Filelist\Controller\File`. Using +the old controllers will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Installations accessing any of the above controllers. + + +Migration +========= + +When wanting to use any of the functionality in these controllers, you should build your own controllers as they are +internal and might change at any time. Use the TYPO3 file abstraction layer as API and add your own functionality on top +of it with an own controller instead of reusing these. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:filelist diff --git a/Documentation/Changelog/10.0/Deprecation-87894-GeneralUtilityidnaEncode.rst b/Documentation/Changelog/10.0/Deprecation-87894-GeneralUtilityidnaEncode.rst new file mode 100644 index 0000000..aada133 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-87894-GeneralUtilityidnaEncode.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87894: + +================================================ +Deprecation: #87894 - GeneralUtility::idnaEncode +================================================ + +See :issue:`87894` + +Description +=========== + +PHP has the native function :php:`idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46)` for converting UTF-8 based domains to ascii-based ("punicode") +which is available in all supported PHP versions using :php:`"symfony/polyfill-intl-idn"`. + +For this reason the method :php:`GeneralUtility::idnaEncode()` has been marked as deprecated. + + +Impact +====== + +Calling :php:`GeneralUtility::idnaEncode()` directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with third-party extensions calling this method. + + +Migration +========= + +Use :php:`idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);` instead. + +Please be aware that contrary to :php:`GeneralUtility::idnaEncode()` the native PHP function only works on domain names, not email addresses or +similar. In order to encode email addresses split the address at the last :php:`'@'` and use :php:`idn_to_ascii()` on that last part. +Also, if there is an error in converting a string, a bool :php:`false` is returned. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-88366-DefaultCachingFrameworkCacheNamesChanged.rst b/Documentation/Changelog/10.0/Deprecation-88366-DefaultCachingFrameworkCacheNamesChanged.rst new file mode 100644 index 0000000..aba281e --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88366-DefaultCachingFrameworkCacheNamesChanged.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88366: + +=================================================================== +Deprecation: #88366 - Default caching framework cache names changed +=================================================================== + +See :issue:`88366` + +Description +=========== + +TYPO3's internal Caching Framework has several caches already shipped with TYPO3 Core. + +The caches have been renamed for convenience and for newcomers to overcome another "speciality" +of TYPO3 which was due to legacy and integration reasons back in TYPO3 4.3 when the Caching +Framework was introduced. + +The following caches have been renamed: + +* `cache_core` => `core` +* `cache_hash` => `hash` +* `cache_pages` => `pages` +* `cache_pagesection` => `pagesection` +* `cache_runtime` => `runtime` +* `cache_rootline` => `rootline` +* `cache_imagesizes` => `imagesizes` + +The caches should now be accessed via :php:`$cacheManager->getCache('core')` instead of +:php:`$cacheManager->getCache('cache_core')` - without the ``cache_`` prefix. + +In addition, when the DatabaseBackend cache is used, the database tables do not have the :sql:`cf_` +prefix anymore, making it clearer for integrators and developers what the caches mean. + + +Impact +====== + +When accessing the cache with a "cache" prefix, a PHP :php:`E_USER_DEPRECATED` error is triggered. + + +Affected Installations +====================== + +Any TYPO3 extension using the caching framework with the ``cache_`` prefix. + + +Migration +========= + +Remove the ``cache_`` prefix from the callers code. + +In addition, run through the Database Table Analyzer of the Configuration module to +re-create any database tables of the Caching Framework. + +.. index:: Database, LocalConfiguration, NotScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-88406-SetCacheHashnoCacheHashOptionsInViewHelpersAndUriBuilder.rst b/Documentation/Changelog/10.0/Deprecation-88406-SetCacheHashnoCacheHashOptionsInViewHelpersAndUriBuilder.rst new file mode 100644 index 0000000..27b4ba2 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88406-SetCacheHashnoCacheHashOptionsInViewHelpersAndUriBuilder.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88406: + +==================================================================================== +Deprecation: #88406 - setCacheHash/noCacheHash options in ViewHelpers and UriBuilder +==================================================================================== + +See :issue:`88406` + +Description +=========== + +Various Fluid ViewHelpers regarding linking have arguments similar to: + +* :php:`useCacheHash` +* :php:`noCacheHash` + +which are not evaluated anymore. + +Extbase's UriBuilder has the following options that have no effect anymore since the Site +Handling concept automatically detects when to a cHash argument is necessary: + +* :php:`TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->setUseCacheHash()` +* :php:`TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->getUseCacheHash()` + +Impact +====== + +Calling the UriBuilder methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Using the arguments :php:`noCacheHash`/:php:`useCacheHash` in the following ViewHelpers will +trigger a PHP :php:`E_USER_DEPRECATED` error: + +* :html:`f:form` +* :html:`f:link.action` +* :html:`f:link.page` +* :html:`f:link.typolink` +* :html:`f:uri.action` +* :html:`f:uri.page` +* :html:`f:uri.typolink` +* :html:`f:widget.link` +* :html:`f:widget.uri` + +If the underlying TypoLink logic is accessed directly, it will trigger a PHP :php:`E_USER_DEPRECATED` error +if :typoscript:`.useCacheHash` is set - without any effect either. + + +Affected Installations +====================== + +Any TYPO3 installation with custom templates setting this argument in Fluid or extensions +using Extbase's UriBuilder in a custom fashion. + + +Migration +========= + +Remove any usages within the Fluid templates or Extension code. + +.. index:: Fluid, PHP-API, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88428-ToprawurlencodeAndTopstr_replace.rst b/Documentation/Changelog/10.0/Deprecation-88428-ToprawurlencodeAndTopstr_replace.rst new file mode 100644 index 0000000..d561812 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88428-ToprawurlencodeAndTopstr_replace.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88428: + +========================================================== +Deprecation: #88428 - top.rawurlencode and top.str_replace +========================================================== + +See :issue:`88428` + +Description +=========== + +The global JavaScript functions :js:`top.rawurlencode()` and :js:`top.str_replace()` have been marked as deprecated. + + +Impact +====== + +Calling any of these two functions will trigger a deprecation log entry in the browser's console. + + +Affected Installations +====================== + +All installations using third party extensions with these functions are affected. + + +Migration +========= + +For :js:`top.rawurlencode()` it's safe to use native JavaScript function :js:`encodeURIComponent()` instead. The only +difference is that this function does not escape asterisk characters, which may be additionally achieved via +:js:`encodeURIComponent('*my_string*').replace(/\*/g, '%2A')`. + +For :js:`top.str_replace()` consider using JavaScript's string function `.replace()` instead. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Deprecation-88432-ReplacedMd5jsWithAnAMDModule.rst b/Documentation/Changelog/10.0/Deprecation-88432-ReplacedMd5jsWithAnAMDModule.rst new file mode 100644 index 0000000..f519085 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88432-ReplacedMd5jsWithAnAMDModule.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88432: + +======================================================== +Deprecation: #88432 - Replaced md5.js with an AMD module +======================================================== + +See :issue:`88432` + +Description +=========== + +The file :file:`md5.js` used to generate a MD5 hash in JavaScript has been marked as deprecated and replaced by an AMD module +:js:`TYPO3/CMS/Backend/Hashing/Md5`. + + +Impact +====== + +Using the global function `MD5()` will trigger a deprecation log entry in the browser's console. + + +Affected Installations +====================== + +All installations using third party extensions that use :js:`MD5()` of :file:`md5.js` are affected. + + +Migration +========= + +Load the AMD module :js:`TYPO3/CMS/Backend/Hashing/Md5` via RequireJS as `Md5` and call :js:`Md5.hash()` instead. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Deprecation-88433-DeprecateTopopenUrlInWindow.rst b/Documentation/Changelog/10.0/Deprecation-88433-DeprecateTopopenUrlInWindow.rst new file mode 100644 index 0000000..c667bbd --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88433-DeprecateTopopenUrlInWindow.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88433: + +=================================================== +Deprecation: #88433 - Deprecate top.openUrlInWindow +=================================================== + +See :issue:`88433` + +Description +=========== + +The global JavaScript function :js:`top.openUrlInWindow()` has been marked as deprecated. This method was used to open +links in a full size popup. + + +Impact +====== + +Calling this function will trigger a deprecation log entry in the browser's console. + + +Affected Installations +====================== + +All installations using third party extensions that use :js:`top.openUrlInWindow()` are affected. + + +Migration +========= + +Instead of using this method, consider using plain HTML and open the link in a new tab: +:html:`<a href="/path/to/my/document", target="_blank">Linked content</a>` + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Deprecation-88473-TypoScriptFrontendController-settingLocale.rst b/Documentation/Changelog/10.0/Deprecation-88473-TypoScriptFrontendController-settingLocale.rst new file mode 100644 index 0000000..81a01b9 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88473-TypoScriptFrontendController-settingLocale.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88473: + +================================================================= +Deprecation: #88473 - TypoScriptFrontendController->settingLocale +================================================================= + +See :issue:`88473` + +Description +=========== + +Due to Site Handling, setting the locale information (:php:`setlocale`) can be handled +much earlier without any dependencies on the global :php:`TSFE` object. + +The functionality of the method :php:`TypoScriptFrontendController->settingLocale()` has +been moved into :php:`Locales::setSystemLocaleFromSiteLanguage()`. The former method +has been marked as deprecated. + + +Impact +====== + +Calling :php:`TypoScriptFrontendController->settingLocale()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with a third party extension booting up a custom Frontend system and +explicitly calling the method. + + +Migration +========= + +Migrate the existing PHP code to :php:`Locales::setSystemLocaleFromSiteLanguage()` or ensure +that the SiteResolver middleware for Frontend Requests is executed where the locale is now +set automatically. + +.. index:: Frontend, PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88499-BackendUtilitygetViewDomain.rst b/Documentation/Changelog/10.0/Deprecation-88499-BackendUtilitygetViewDomain.rst new file mode 100644 index 0000000..3cf2818 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88499-BackendUtilitygetViewDomain.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88499: + +=================================================== +Deprecation: #88499 - BackendUtility::getViewDomain +=================================================== + +See :issue:`88499` + +Description +=========== + +The static method :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getViewDomain()` has been marked +as deprecated, as it has been superseded by directly using the PageRouter of Site Handling. + +Site Handling allows to generate proper frontend preview URLs the same way as TYPO3 Core does in +all other places, by calling the PageRouter of a Site object directly, so the workarounds are not +necessary anymore, making this method obsolete. + + +Impact +====== + +Calling :php:`BackendUtility::getViewDomain()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installations with custom extensions that call this method. + + +Migration +========= + +Substitute the method by directly detecting a site based on a given Page ID in the TYPO3 Backend. +Call the :php:`getRouter()` method on this Site object to create proper links to pages in TYPO3 Frontend. + +Example with additional GET parameters: + +.. code-block:: php + + $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId); + $url = $site->getRouter()->generateUri($pageId, ['type' => 13]); + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88554-DeprecatedMethodsInVersionNumberUtility.rst b/Documentation/Changelog/10.0/Deprecation-88554-DeprecatedMethodsInVersionNumberUtility.rst new file mode 100644 index 0000000..07baac9 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88554-DeprecatedMethodsInVersionNumberUtility.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88554: + +================================================================ +Deprecation: #88554 - Deprecated methods in VersionNumberUtility +================================================================ + +See :issue:`88554` + +Description +=========== + +The following methods of :php:`\TYPO3\CMS\Core\Utility\VersionNumberUtility` have been marked as deprecated and will be removed in +TYPO3 11.0: + +* :php:`convertIntegerToVersionNumber` +* :php:`splitVersionRange` +* :php:`raiseVersionNumber` + + +Impact +====== + +Calling the methods :php:`convertIntegerToVersionNumber`, :php:`splitVersionRange` or :php:`raiseVersionNumber` of +:php:\TYPO3\CMS\Core\Utility\VersionNumberUtility` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations that call the mentioned methods. + + +Migration +========= + +Implement the methods in your custom code. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-88559-TSFE-sys_language_isocode.rst b/Documentation/Changelog/10.0/Deprecation-88559-TSFE-sys_language_isocode.rst new file mode 100644 index 0000000..2187d0c --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88559-TSFE-sys_language_isocode.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88559: + +================================================= +Deprecation: #88559 - $TSFE->sys_language_isocode +================================================= + +See :issue:`88559` + +Description +=========== + +The public property :php:`TypoScriptFrontendController->sys_language_isocode` +has set the equivalent of :php:`TYPO3\CMS\Core\Site\Entity\SiteLanguage->getTwoLetterIsoCode()` since the introduction +of Site Handling in TYPO3 v9. + +As all code should switch to Site Handling, this property can be accessed via +the current site language as well, making this property obsolete. + +The property has been marked as deprecated. + + +Impact +====== + +Setting or fetching this property will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with a third party extension accessing this property, +or via TypoScript :typoscript:`TSFE:sys_language_isocode`. + + +Migration +========= + +Access the property via :php:`SiteLanguage->getTwoLetterIsoCode()` +and :typoscript:`sitelanguage:twoLetterIsoCode` instead. + +.. index:: Frontend, PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88567-GLOBALS_LOCAL_LANG.rst b/Documentation/Changelog/10.0/Deprecation-88567-GLOBALS_LOCAL_LANG.rst new file mode 100644 index 0000000..f3ea894 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88567-GLOBALS_LOCAL_LANG.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88567: + +============================================ +Deprecation: #88567 - $GLOBALS['LOCAL_LANG'] +============================================ + +See :issue:`88567` + +Description +=========== + +The global array :php:`$GLOBALS['LOCAL_LANG']` contains all labels from language +files that were loaded "globally". However, instead of having this in global +scope, it is more feasible to have it scoped to the actual :php:`LanguageService` +that loaded this data, in order to allow various language functionality in the +same PHP process without having to deal with global variables. + +For this reason, it is discouraged to use :php:`$GLOBALS['LOCAL_LANG']` +but instead rely on :php:`LanguageService->includeLLfile()` which returns +the actual values as well, but only the ones loaded from this instance. + +Since an instance of :php:`TYPO3\CMS\Core\Localization\LanguageService` is usually available via `$GLOBALS['LANG']` the +labels are accessible within PHP anyways. + +Due to this change, the second and third arguments of :php:`LanguageService->includeLLFile()` have been marked as deprecated. + + +Impact +====== + +Calling the method above with an explicit second and/or third argument will +trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with third-party extensions using +:php:`$GLOBALS['LOCAL_LANG']` or the mentioned method with more than one argument, +which is very unlikely. + + +Migration +========= + +Use the return value of :php:`LanguageService->includeLLFile()` and remove +the second and third arguments to work with label files. + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88569-LocalesinitializeInFavorOfRegularSingletonInstance.rst b/Documentation/Changelog/10.0/Deprecation-88569-LocalesinitializeInFavorOfRegularSingletonInstance.rst new file mode 100644 index 0000000..f7231ea --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88569-LocalesinitializeInFavorOfRegularSingletonInstance.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88569: + +================================================================================== +Deprecation: #88569 - Locales::initialize() in favor of regular singleton instance +================================================================================== + +See :issue:`88569` + +Description +=========== + +The method :php:`TYPO3\CMS\Core\Localization\Locales::initialize()` has been marked as deprecated. +It was a workaround to re-initialize the Singleton Instance of the PHP class :php:`Locales` for user-defined locales, which were +loaded by an extensions' :file:`ext_localconf.php`. + +:php:`Locales` is now initialized only when needed, and not during the early bootstrap process, +making this functionality obsolete, as this is taken care of within the regular constructor. + + +Impact +====== + +Calling :php:`TYPO3\CMS\Core\Localization\Locales::initialize()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with a third-party extension calling :php:`Locales::initialize()` directly. + + +Migration +========= + +Replace the function call by a regular :php:`GeneralUtility::makeInstance(Locales::class);` +or use Dependency Injection (Constructor Injection or ObjectManager) to fetch an instance of +the :php:`Locales` class. + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Deprecation-88651-ReplaceTYPO3CMSBackendSplitButtonsWithTYPO3CMSBackendDocumentSaveActions.rst b/Documentation/Changelog/10.0/Deprecation-88651-ReplaceTYPO3CMSBackendSplitButtonsWithTYPO3CMSBackendDocumentSaveActions.rst new file mode 100644 index 0000000..e9dc7d5 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88651-ReplaceTYPO3CMSBackendSplitButtonsWithTYPO3CMSBackendDocumentSaveActions.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88651: + +======================================================================================================= +Deprecation: #88651 - Replace TYPO3/CMS/Backend/SplitButtons with TYPO3/CMS/Backend/DocumentSaveActions +======================================================================================================= + +See :issue:`88651` + +Description +=========== + +Since FormEngine doesn't use split buttons anymore with TYPO3 v9, the JavaScript module +:js:`TYPO3/CMS/Backend/SplitButtons` has been replaced with :js:`TYPO3/CMS/Backend/DocumentSaveActions`. + + +Impact +====== + +Loading :js:`TYPO3/CMS/Backend/SplitButtons` will trigger a deprecation log entry in the browser's console. + + +Affected Installations +====================== + +All 3rd party extensions using :js:`TYPO3/CMS/Backend/SplitButtons` are affected. + + +Migration +========= + +Use :js:`TYPO3/CMS/Backend/DocumentSaveActions` instead. Since the module is a singleton, the instance can be fetched by +calling :js:`DocumentSaveActions.getInstance()`. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.0/Deprecation-88662-DeprecatedBackendRouteXMOD_tximpexp.rst b/Documentation/Changelog/10.0/Deprecation-88662-DeprecatedBackendRouteXMOD_tximpexp.rst new file mode 100644 index 0000000..1c62b39 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88662-DeprecatedBackendRouteXMOD_tximpexp.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88662: + +============================================================ +Deprecation: #88662 - Deprecated backend route xMOD_tximpexp +============================================================ + +See :issue:`88662` + +Description +=========== + +The route identifier :php:`xMOD_tximpexp` (route `record/importexport`) pointing to +:php:`ImportExportController::mainAction` has been marked as deprecated. The class was previously responsible to handle +either the export or the import process, controlled by a query parameter. + + +Impact +====== + +Calling the route will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All 3rd-party extensions using the route :php:`xMOD_tximpexp` are affected. + + +Migration +========= + +Depending on the task, either use :php:`tx_impexp_export` or :php:`tx_impexp_import`. Additionally, remove any +`tx_impexp[action]` query parameter. + +.. index:: Backend, NotScanned, ext:impexp diff --git a/Documentation/Changelog/10.0/Deprecation-88746-PageRepositoryPHPClassMovedFromFrontendToCoreExtension.rst b/Documentation/Changelog/10.0/Deprecation-88746-PageRepositoryPHPClassMovedFromFrontendToCoreExtension.rst new file mode 100644 index 0000000..0e9b673 --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88746-PageRepositoryPHPClassMovedFromFrontendToCoreExtension.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88746: + +==================================================================================== +Deprecation: #88746 - PageRepository PHP class moved from Frontend to Core Extension +==================================================================================== + +See :issue:`88746` + +Description +=========== + +In previous TYPO3 versions, accessing records was mixed between Frontend (handled by PageRepository) +and Backend (handled by static methods in BackendUtility). In TYPO3 v9, the Context API was introduced +and PageRepository now acts as a strong database accessor which is not bound to Frontend anymore, +at all. + +In addition, various places of the backend also used PageRepository already, which violated the +separation of packages, as TYPO3 Core aims to strictly separate Frontend and Backend application +code. + +In the case of PageRepository, the code is used by both applications, and is therefore moved +to Core system extension (EXT:core), and renamed to :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository`. + +Until TYPO3 v9, it was placed in :php:`TYPO3\CMS\Frontend\Page\PageRepository`. + +In addition, all interface'd hooks are moved to EXT:core as well with the same PHP namespace. + + +Impact +====== + +A class alias was introduced which does not trigger any deprecations, so both variants +still work as before, however it is recommended to rename any calls to the PHP class. + +No other functionality was changed. + + +Affected Installations +====================== + +Any TYPO3 installation with custom PHP extensions accessing PageRepository directly. + + +Migration +========= + +Replace any PHP references of :php:`TYPO3\CMS\Frontend\Page\PageRepository` +to :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository` in any custom PHP code. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.0/Deprecation-88792-ForceTemplateParsingInTSFEAndTemplateService.rst b/Documentation/Changelog/10.0/Deprecation-88792-ForceTemplateParsingInTSFEAndTemplateService.rst new file mode 100644 index 0000000..10f232c --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88792-ForceTemplateParsingInTSFEAndTemplateService.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88792: + +====================================================================== +Deprecation: #88792 - forceTemplateParsing in TSFE and TemplateService +====================================================================== + +See :issue:`88792` + +Description +=========== + +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController::forceTemplateParsing` and +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService::forceTemplateParsing` + +have been marked as deprecated and replaced by Context API. + + +Impact +====== + +Setting either :php:`forceTemplateParsing` of :php:`TypoScriptFrontendController` or :php:`TemplateService` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations setting or reading :php:`$TSFE->forceTemplateParsing` or :php:`TemplateService->forceTemplateParsing`. + + +Migration +========= + +Use the Context API :: + + GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('typoscript', 'forcedTemplateParsing'); + $context->setAspect('typoscript', GeneralUtility::makeInstance(TypoScriptAspect::class, true)); + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.0/Deprecation-88807-AdminPanelInitializableInterfaceHasBeenDeprecated.rst b/Documentation/Changelog/10.0/Deprecation-88807-AdminPanelInitializableInterfaceHasBeenDeprecated.rst new file mode 100644 index 0000000..577ee4c --- /dev/null +++ b/Documentation/Changelog/10.0/Deprecation-88807-AdminPanelInitializableInterfaceHasBeenDeprecated.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88807: + +=========================================================================== +Deprecation: #88807 - AdminPanel InitializableInterface has been deprecated +=========================================================================== + +See :issue:`88807` + +Description +=========== + +`\TYPO3\CMS\Adminpanel\ModuleApi\InitializableInterface` has been deprecated in favor of the newly +introduced `\TYPO3\CMS\Adminpanel\ModuleApi\RequestEnricherInterface`. + + +Impact +====== + +Using `\TYPO3\CMS\Adminpanel\ModuleApi\InitializableInterface` will trigger a deprecation message. + + +Affected Installations +====================== + +All instances that use `\TYPO3\CMS\Adminpanel\ModuleApi\InitializableInterface` are affected. + + +Migration +========= + +Switch to `\TYPO3\CMS\Adminpanel\ModuleApi\RequestEnricherInterface` instead: + +- change method name `initializeModule` to `enrich` +- change return value to return an instance of + :php:`\Psr\Http\Message\ServerRequestInterface` + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:adminpanel diff --git a/Documentation/Changelog/10.0/Feature-21638-IntroducedIpLockingForIpv6.rst b/Documentation/Changelog/10.0/Feature-21638-IntroducedIpLockingForIpv6.rst new file mode 100644 index 0000000..33b401e --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-21638-IntroducedIpLockingForIpv6.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-21638: + +================================================ +Feature: #21638 - Introduced IP locking for IPv6 +================================================ + +See :issue:`21638` + +Description +=========== + +The IP-locking functionality has been extended to support IPv6 now as well. +This security feature enables binding of a user session (Backend or Frontend) to an IP address or a part of it. + +The available configuration options with their default values for IP-locking are: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['FE']['lockIP'] = 2; + $GLOBALS['TYPO3_CONF_VARS']['FE']['lockIPv6'] = 2; + + $GLOBALS['TYPO3_CONF_VARS']['BE']['lockIP'] = 4; + $GLOBALS['TYPO3_CONF_VARS']['BE']['lockIPv6'] = 2; + +The configuration can be changed via the Admin Tools -> Settings menu. +The exact meaning of the numbers used for the configuration are documented there. + +Code-wise a separate IpLocker class :php:`\TYPO3\CMS\Core\Authentication\IpLocker` has been added, which takes care of the IP-locking for both IP versions. + +.. index:: Backend, Frontend, LocalConfiguration diff --git a/Documentation/Changelog/10.0/Feature-56213-AllowSortingFilelistByFileMetadataTitle.rst b/Documentation/Changelog/10.0/Feature-56213-AllowSortingFilelistByFileMetadataTitle.rst new file mode 100644 index 0000000..faadc60 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-56213-AllowSortingFilelistByFileMetadataTitle.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-56213: + +=================================================================== +Feature: #56213 - Allow sorting file list by file meta data "title" +=================================================================== + +See :issue:`56213` + +Description +=========== + +The possibility to sort files by their meta data title in the "File Links" content element has been introduced. +The title attribute is part of the base metadata table and therefor available in all TYPO3 installations. + +If you need further FAL fields to sort by, extend the TCA of tt_content field :php:`filelink_sorting` and add +metadata fields as options to choose from. + +For example add the following in :file:`TCA/Overrides/tt_content.php`:: + + $GLOBALS['TCA']['tt_content']['columns']['filelink_sorting']['config']['items'][] = ['Sort by alternative Text', 'alternative']; + +Or use Page TSConfig :typoscript:`TCEFORM.tt_content.filelink_sorting.addItems.alternative = sort by "Alternative" metadata field`. + + +Impact +====== + +The filelinks content element has a new option for sorting "by file metadata title" which can be chosen in +the drop down when creating the element. + +.. index:: Backend, TCA, ext:frontend diff --git a/Documentation/Changelog/10.0/Feature-78432-AddLogMessageForSwitchUserAction.rst b/Documentation/Changelog/10.0/Feature-78432-AddLogMessageForSwitchUserAction.rst new file mode 100644 index 0000000..2865e5b --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-78432-AddLogMessageForSwitchUserAction.rst @@ -0,0 +1,18 @@ +.. include:: /Includes.rst.txt + +.. _feature-78432: + +========================================================== +Feature: #78432 - Add log message for "Switch User action" +========================================================== + +See :issue:`78432` + +Description +=========== + +If an admin user switches to another be_user account via the "Switch User" action in the "Backend users" module, the action is now logged to the sys_log. + +`User admin switched to user editor (be_users:2)` + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/10.0/Feature-80420-AllowMultipleRecipientsInEmailFinisher.rst b/Documentation/Changelog/10.0/Feature-80420-AllowMultipleRecipientsInEmailFinisher.rst new file mode 100644 index 0000000..7bd023d --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-80420-AllowMultipleRecipientsInEmailFinisher.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _feature-80420: + +============================================================= +Feature: #80420 - Allow multiple recipients in email finisher +============================================================= + +See :issue:`80420` + +Description +=========== + +Mails sent by the :php:`EmailFinisher` of EXT:form can now have multiple recipients. For this the following new finisher options have been added: + +* :yaml:`recipients` (:code:`To`) +* :yaml:`replyToRecipients` (:code:`Reply-To`) +* :yaml:`carbonCopyRecipients` (:code:`CC`) +* :yaml:`blindCarbonCopyRecipients` (:code:`BCC`) + +These options must contain a YAML hash with email addresses as keys and recipient names as values: + +.. code-block:: yaml + + recipients: + first@example.org: First Recipient + second@example.org: Second Recipient + +Additionally this now allows for setting the name of a CC and BCC recipient: + +.. code-block:: yaml + + carbonCopyRecipients: + firstCC@example.org: First CC Recipient + +The form editor in the backend module provides a visual UI to enter an arbitrary amount of recipients. + + +Impact +====== + +Mails sent by EXT:form can be sent to multiple recipients, optionally via CC or BCC. Replies can be sent to multiple recipients. + +.. index:: ext:form diff --git a/Documentation/Changelog/10.0/Feature-83734-AddSupportForCurrentPageInConfigcache.rst b/Documentation/Changelog/10.0/Feature-83734-AddSupportForCurrentPageInConfigcache.rst new file mode 100644 index 0000000..4950d2c --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-83734-AddSupportForCurrentPageInConfigcache.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-83734: + +============================================================== +Feature: #83734 - Add support for current page in config.cache +============================================================== + +See :issue:`83734` + +Description +=========== + +When using the TypoScript property :typoscript:`config.cache`, it is possible to define a configuration that +affects all pages via: + +.. code-block:: typoscript + + config.cache.all = fe_users:2 + +However such configurations always depend on a precise page where to look up records. +A common scenario is to have records stored in each page itself. +Thus, the syntax with the keyword "current" is now possible: + +.. code-block:: typoscript + + config.cache.all = fe_users:current + +where :typoscript:`current` is dynamically replaced by the current Page ID. + + +Impact +====== + +When using :typoscript:`current` inside the :typoscript:`config.cache` TypoScript property, it is now replaced with +the current Page ID. + +.. index:: TypoScript diff --git a/Documentation/Changelog/10.0/Feature-84112-SymfonyDependencyInjectionForCoreAndExtbase.rst b/Documentation/Changelog/10.0/Feature-84112-SymfonyDependencyInjectionForCoreAndExtbase.rst new file mode 100644 index 0000000..c3820bf --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-84112-SymfonyDependencyInjectionForCoreAndExtbase.rst @@ -0,0 +1,218 @@ +.. include:: /Includes.rst.txt + +.. _feature-84112: + +=================================================================== +Feature: #84112 - Symfony dependency injection for core and extbase +=================================================================== + +See :issue:`84112` + +Description +=========== + +The PHP library `symfony/dependency-injection` has been integrated and is used +to manage system wide dependency management and injection for classes. +With the integration provided in TYPO3 the symfony dependency injection container +features support for Extbase and non-Extbase classes and is thus intended to +replace the Extbase dependency injection container and object manager. +The symfony container implements :php:`\Psr\Container\ContainerInterface` +as specified by PSR-11. This interface should be used when requiring access +to the container. +Therefore :php:`\TYPO3\CMS\Extbase\Object\ObjectManager` now resorts to this +new dependency injection container and prioritizes its entries over classical +Extbase dependency injection (which is still available), also +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance()` has been adapted +to retrieve instances from the container, if possible. + +Classes should be adapted to avoid both, :php:`ObjectManager` and +:php:`GeneralUtility::makeInstance()` whenever possible. +Service dependencies should be injected via constructor injection or +setter methods (inject methods as in Extbase are supported). + +Configuration +^^^^^^^^^^^^^ + +Extensions are encouraged to configure their classes to make use of the new +dependency injection. A symfony flavored yaml (or, for advanced functionality, +php) service configuration file may be used to do so. That means symfony +dependency injection is not applied automatically, extensions need to +define the desired dependency injection strategies. Extensions that do not +configure dependency injection will keep working – the legacy instance +management in :php:`\TYPO3\CMS\Extbase\Object\ObjectManager` and +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance()` is +still available. + +Whenever service configuration or class dependencies change, the core cache needs +to be flushed to rebuild the compiled symfony container. + +Autowiring +---------- + +A :file:`Configuration/Services.yaml` which uses autowiring pretty much +reflects the current feature set of Extbase DI. The configuration looks like: + +.. code-block:: yaml + + # Configuration/Services.yaml + services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Your\Namespace\: + resource: '../Classes/*' + +Extensions which have used Extbase dependency injection in the past, will want +to enable :yaml:`autowire` for a smooth migration. :yaml:`autowire: true` instructs symfony +to calculate the required dependencies from type declarations of the constructor +and inject methods. This calculation yields to a service initialization recipe +which is cached in php code (in TYPO3 core cache). +Note: An extension doesn't need to use autowiring, it is free to manually +wire dependencies in the service configuration file. + +It is suggested to enable :yaml:`autoconfigure: true` as this will automatically +add symfony service tags based on implemented interfaces or base classes. +An Example: autoconfiguration ensured that classes which implement +:php:`\TYPO3\CMS\Core\SingletonInterface` will be publicly available from the +symfony container (which is required for legacy singleton lookups through +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance()`). + +:yaml:`public: false` is a performance optimization and is therefore suggested to be +enabled in extensions (symfony does not enable this by default for backwards +compatibility reasons only). This settings controls which services are available +through :php:`\Psr\Container\ContainerInterface->get()`. Services that need to be public +(e.g. Singletons, because they need to be shared with legacy code that uses +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance()`, or Extbase controllers) +will be marked public automatically due to :yaml:`autoconfigure: true` by custom +TYPO3 provided symfony compiler passes. + + +Manual wiring +------------- + +Manual dependency wiring and service configuration can be used instead of +autowiring (it can actually be combined). This speeds up container compilation +and allows for custom service configuration/wiring. It has the drawback of +having to write some boilerplate. + +.. code-block:: yaml + + # Configuration/Services.yaml + services: + _defaults: + autoconfigure: false + public: false + + Your\Namespace\Service\ExampleService: + # mark public – means this service should be accessible from $container->get() + # and (often more important), both GeneralUtility::makeInstance() and the Extbase + # ObjectManager will be able to use the Symfony DI managed service + public: true + # Defining a service to be shared is equal to TYPO3's SingletonInterface behaviour + shared: true + # Configure constructor arguments + arguments: + $siteConfiguration: '@TYPO3\CMS\Core\Configuration\SiteConfiguration' + + # Example Extbase controller + Your\Namespace\Controller\ExampleController: + # mark public to be dispatchable + public: true + # Defining to be a prototype, as Extbase controllers are stateful (i. e. could not be defined as singleton) + shared: false + # Configure constructor arguments + arguments: + $exampleService: '@Your\Namespace\Service\ExampleService' + + +For more information please refer to the official documentation: +https://symfony.com/doc/4.3/service_container.html + + +Advanced functionality +---------------------- + +Container compilation and configuration can be enhanced using +a callback function returned from :file:`Configuration/Services.php`. +Here is an example: Given an interface :php:`MyCustomInterface`, +you can automatically add a symfony tag for (autoconfigured) services that +implement this interface. A compiler pass can use that tag and configure +autoregistration into a registry service :php:`MyRegistry`. + +.. code-block:: php + + # Configuration/Services.php + <?php + declare(strict_types = 1); + namespace Your\Namespace; + + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return function (ContainerConfigurator $container, ContainerBuilder $containerBuilder) { + $containerBuilder->registerForAutoconfiguration(MyCustomInterface::class)->addTag('my.custom.interface'); + + $containerBuilder->addCompilerPass(new DependencyInjection\MyCustomPass('my.custom.interface')); + }; + +.. code-block:: php + + # Classes/DependencyInjection/MyCustomPass.php + <?php + declare(strict_types = 1); + namespace Your\Namespace\DependencyInjection; + + use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Your\Namespace\MyRegistry; + + final class MyCustomPass implements CompilerPassInterface + { + public function process(ContainerBuilder $container) + { + $myRegistry = $container->findDefinition(MyRegistry::class); + + foreach ($container->findTaggedServiceIds('my.custom.interface') as $id => $tags) { + $definition = $container->findDefinition($id); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + // Services that implement MyCustomInterface need to be public, + // to be lazy loadable by the registry via $container->get() + $container->findDefinition($id)->setPublic(true); + // Add a method call to the registry class to the (auto-generated) factory for + // the registry service. + // This supersedes explicit registrations in ext_localconf.php (which're + // still possible and can be combined with this autoconfiguration). + $myRegistry->addMethodCall('registerMyCustomInterfaceImplementation', [$id]); + } + } + } + +Impact +====== + +* Symfony automatically resolves interfaces to classes when only one class + implementing an interface is available. Otherwise an explicit alias is required. + That means you SHOULD define an alias for interface to class mappings where + the implementation currently defaults to the interface minus the trailing Interface + suffix (which is the default for Extbase). + +* Dependency Injection can be added to constructors of existing services + without being breaking. :php:`GeneralUtility::makeInstance(ServiceName::class)` + will keep working, as :php:`makeInstance` has been adapted to resort to the + symfony container. + +* Cyclic dependencies are not supported with Symfony DI (Extbase DI did so). + +* Prototypes/Data classes (non singletons, e.g. models) that need both, + runtime constructor arguments (as passed to + :php:`\TYPO3\CMS\Extbase\Object\ObjectManager->get()`) and injected dependencies + are not supported in :php:`\Psr\Container\ContainerInterface->get()`. + It is suggested to switch to factories or stick with the object manager for now. + + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-84757-DoubleClickInStructureTreeChangesLabel.rst b/Documentation/Changelog/10.0/Feature-84757-DoubleClickInStructureTreeChangesLabel.rst new file mode 100644 index 0000000..76ecc46 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-84757-DoubleClickInStructureTreeChangesLabel.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-84757: + +============================================================== +Feature: #84757 - Double click in structure tree changes label +============================================================== + +See :issue:`84757` + +Description +=========== + +Functionality to change the labels of form elements via double click +in the form editor element tree has been added to allow quick changes +of labels. + +Impact +====== + +The label of a form element can be edited by double clicking on the title in the structure tree. + +.. index:: ext:form diff --git a/Documentation/Changelog/10.0/Feature-85569-ShowSchedulerInformationsInTheSystemInformationToolbar.rst b/Documentation/Changelog/10.0/Feature-85569-ShowSchedulerInformationsInTheSystemInformationToolbar.rst new file mode 100644 index 0000000..5c409d3 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-85569-ShowSchedulerInformationsInTheSystemInformationToolbar.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-85569: + +============================================================================== +Feature: #85569 - Show scheduler information in the system information toolbar +============================================================================== + +See :issue:`85569` + +Description +=========== + +The system information toolbar now shows useful information about the TYPO3 scheduler system extension. + +The following information can be gathered quickly via the toolbar: + +Warning if the scheduler execution seems not to be configured correctly + +Important information about the last successfully executed scheduler run + +* the start date +* the start time +* the duration (in minutes) +* the execution type (automatically/CLI or manually/via backend), a manual execution highlights the text + +Impact +====== + +The mentioned scheduler specific information is added to the system information toolbar, if the system extension +`scheduler` is active and if there are any tasks configured at all. + +.. index:: Backend, ext:scheduler diff --git a/Documentation/Changelog/10.0/Feature-85607-NewThumbnailViewHelperToRenderThumbnailsDeferred.rst b/Documentation/Changelog/10.0/Feature-85607-NewThumbnailViewHelperToRenderThumbnailsDeferred.rst new file mode 100644 index 0000000..e9368e6 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-85607-NewThumbnailViewHelperToRenderThumbnailsDeferred.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-85607: + +======================================================================= +Feature: #85607 - New ThumbnailViewHelper to render thumbnails deferred +======================================================================= + +See :issue:`85607` + +Description +=========== + +A new ViewHelper for the backend to render thumbnails deferred was introduced. + +The :php:`\TYPO3\CMS\Backend\ViewHelpers\ThumbnailViewHelper` extends the :php:`ImageViewHelper` and generates the image tag with the special URI. + +.. code-block:: html + + <be:thumbnail image="{file.resource}" width="{thumbnail.width}" height="{thumbnail.height}" /> + + +Impact +====== + +Thumbnails do not block the rendering of a page, because the processing runs an extra http request. + +.. index:: Backend, Fluid, ext:backend diff --git a/Documentation/Changelog/10.0/Feature-86629-ImplementLinkHandlerForTelephoneNumbers.rst b/Documentation/Changelog/10.0/Feature-86629-ImplementLinkHandlerForTelephoneNumbers.rst new file mode 100644 index 0000000..c2b4cb0 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-86629-ImplementLinkHandlerForTelephoneNumbers.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _feature-86629: + +============================================================= +Feature: #86629 - Implement LinkHandler for telephone numbers +============================================================= + +See :issue:`86629` + +Description +=========== + +A new phone number link handler has been added. + +With this new link handler you can set links in a new tab in the link browser to phone numbers using the `tel:` protocol. + +A valid phone number consists of only numbers and the + character. + +.. index:: Backend, Frontend diff --git a/Documentation/Changelog/10.0/Feature-86964-AllowGettingClassPropertyDefaultValue.rst b/Documentation/Changelog/10.0/Feature-86964-AllowGettingClassPropertyDefaultValue.rst new file mode 100644 index 0000000..87632c1 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-86964-AllowGettingClassPropertyDefaultValue.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-86964: + +============================================================ +Feature: #86964 - Allow getting class property default value +============================================================ + +See :issue:`86964` + +Description +=========== + +It is now possible to get the default value of a class property when using the :php:`TYPO3\CMS\Extbase\Reflection\ReflectionService`. + +.. code-block:: php + + class MyClass + { + public $myProperty = 'foo'; + } + + $property = GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Reflection\ReflectionService::class) + ->getClassSchema(MyClass::class) + ->getProperty('myProperty'); + + $defaultValue = $property->getDefaultValue(); // "foo" + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.0/Feature-87200-SendPlaintextAndHTMLMailsInEmailFinisher.rst b/Documentation/Changelog/10.0/Feature-87200-SendPlaintextAndHTMLMailsInEmailFinisher.rst new file mode 100644 index 0000000..641649f --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-87200-SendPlaintextAndHTMLMailsInEmailFinisher.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-87200: + +================================================================ +Feature: #87200 - Send plaintext and HTML mails in EmailFinisher +================================================================ + +See :issue:`87200` + +Description +=========== + +The :php:`EmailFinisher` of EXT:form now sends mails with a plaintext and an HTML part. +A new option :yaml:`addHtmlPart` has been added to configure if a HTML part should be added to mails. + + +Impact +====== + +Mails now contain both plaintext and HTML parts to support a wider audience. + +Enforcing a plaintext-only mail increases reception and security. + +.. index:: ext:form diff --git a/Documentation/Changelog/10.0/Feature-87433-AddChangefreqAndPriority.rst b/Documentation/Changelog/10.0/Feature-87433-AddChangefreqAndPriority.rst new file mode 100644 index 0000000..1be81f8 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-87433-AddChangefreqAndPriority.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-87433: + +============================================================= +Feature: #87433 - Add changefreq and priority for XML sitemap +============================================================= + +See :issue:`87433` + +Description +=========== + +Sitemap.xml files may contain a change frequency and a priority for entries. + +Change frequencies define how often each page is approximately updated and hence how often it should be +revisited (for example: News in an archive are "never" updated, while your home page might get "weekly" updates). + +Priority allows you to define how important the page is compared to other pages on your site. The priority is stated +in a value from 0 to 1. Your most important pages can get an higher priority as other pages. This value does not +affect how important your pages are compared to pages of other websites. + +This feature allows to define the properties :typoscript:`changefreq` and :typoscript:`priority` for sitemap entries in TYPO3. + +The properties :typoscript:`changefreq` and :typoscript:`priority` of pages can be controlled via page properties. +For records, the settings can be defined in TypoScript by mapping the properties to fields of the record by +using the options :typoscript:`changeFreqField` and :typoscript:`priorityField`. :typoscript:`changeFreqField` needs to point to a field containing +string values (see :typoscript:`pages` definition of field :typoscript:`sitemap_changefreq`), :typoscript:`priorityField` needs to point to a field with +a decimal value between 0 and 1. + + +.. code-block:: typoscript + + plugin.tx_seo { + config { + xmlSitemap { + sitemaps { + <unique key> { + provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider + config { + table = news_table + sortField = sorting + lastModifiedField = tstamp + changeFreqField = news_changefreq + priorityField = news_priority + additionalWhere = AND (no_index = 0 OR no_follow = 0) + pid = <page id('s) containing news records> + url { + pageId = <your detail page id> + fieldToParameterMap { + uid = tx_extension_pi1[news] + } + additionalGetParameters { + tx_extension_pi1.controller = News + tx_extension_pi1.action = detail + } + } + } + } + } + } + } + } + + +Impact +====== + +Two new fields are available in the page properties: `sitemap_priority` (decimal) and `sitemap_changefreq` (list of values, for example "weekly", "daily", "never"). + +Two new TypoScript options for the :typoscript:`RecordsXmlSitemapDataProvider` have been introduced: +:typoscript:`changeFreqField` and :typoscript:`priorityField`. + +All pages and records get a priority of 0.5 by default. + +.. attention:: + + Both priority and change frequency does have no impact on your rankings. These options only gives hints to search engines + in which order and how often you would like a crawler to visit your pages. + +.. index:: ext:seo, Frontend, TypoScript diff --git a/Documentation/Changelog/10.0/Feature-87457-UseSymfonyproperty-infoToGatherDocBlockInformation.rst b/Documentation/Changelog/10.0/Feature-87457-UseSymfonyproperty-infoToGatherDocBlockInformation.rst new file mode 100644 index 0000000..e022e2f --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-87457-UseSymfonyproperty-infoToGatherDocBlockInformation.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-87457: + +=========================================================================== +Feature: #87457 - Use symfony/property-info to gather doc block information +=========================================================================== + +See :issue:`87457` + +Description +=========== + +The use of `symfony/property-info` enables us to resolve non fully qualified class names. + +This is now possible: + +.. code-block:: php + + use TYPO3\CMS\Extbase\Persistence\ObjectStorage; + use ExtbaseTeam\BlogExample\Domain\Model\Comment; + + class Post + { + /* + * @var ObjectStorage<Comment> + */ + public $comments; + } + +Important: +This only works in extbase models as the reflection +costs are high and the information is only needed +in this case. + +The non fully qualified class name is now also +supported for injection properties, although it is +still recommended to avoid injection properties in +favor of injection methods or constructor injection. + +Example: + +.. code-block:: php + + use TYPO3\CMS\Extbase\Annotation as Extbase; + use TYPO3\CMS\Extbase\Configuration\ConfigurationManager; + + class Service + { + /* + * @Extbase\Inject + * @var ConfigurationManager + */ + public $configurationManager; + } + + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.0/Feature-87665-IntroduceBitSetClass.rst b/Documentation/Changelog/10.0/Feature-87665-IntroduceBitSetClass.rst new file mode 100644 index 0000000..d8798db --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-87665-IntroduceBitSetClass.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _feature-87665: + +======================================== +Feature: #87665 - Introduce BitSet class +======================================== + +See :issue:`87665` + +Description +=========== + +To efficiently handle boolean flags, bit sets can be used. Therefore a new class :php:`\TYPO3\CMS\Core\Type\BitSet` has been introduced. +The bit set can be used standalone and accessed from the outside but it can also be used to create specific BitSet classes that extend the BitSet class. + +The functionality is best described by an example: + +:: + + <?php + declare(strict_types = 1); + + define('PERMISSIONS_NONE', 0b0); // 0 + define('PERMISSIONS_PAGE_SHOW', 0b1); // 1 + define('PERMISSIONS_PAGE_EDIT', 0b10); // 2 + define('PERMISSIONS_PAGE_DELETE', 0b100); // 4 + define('PERMISSIONS_PAGE_NEW', 0b1000); // 8 + define('PERMISSIONS_CONTENT_EDIT', 0b10000); // 16 + define('PERMISSIONS_ALL', 0b11111); // 31 + + $bitSet = new \TYPO3\CMS\Core\Type\BitSet(PERMISSIONS_PAGE_SHOW | PERMISSIONS_PAGE_NEW); + $bitSet->get(PERMISSIONS_PAGE_SHOW); // true + $bitSet->get(PERMISSIONS_CONTENT_EDIT); // false + +Another example shows how to possibly extend the :php:`\TYPO3\CMS\Core\Type\BitSet` class. + +:: + + <?php + declare(strict_types = 1); + + class Permissions extends \TYPO3\CMS\Core\Type\BitSet + { + public const NONE = 0b0; // 0 + public const PAGE_SHOW = 0b1; // 1 + public const PAGE_EDIT = 0b10; // 2 + public const PAGE_DELETE = 0b100; // 4 + public const PAGE_NEW = 0b1000; // 8 + public const CONTENT_EDIT = 0b10000; // 16 + public const ALL = 0b11111; // 31 + + /** + * @param int $permission + * @return bool + */ + public function hasPermission(int $permission): bool + { + return $this->get($permission); + } + + /** + * @return bool + */ + public function hasAllPermissions(): bool + { + return $this->get(static::ALL); + } + + /** + * @param int $permission + */ + public function allow(int $permission): void + { + $this->set($permission); + } + } + + $permissions = new Permissions(Permissions::PAGE_SHOW | Permissions::PAGE_NEW); + $permissions->hasPermission(Permissions::PAGE_SHOW); // true + $permissions->hasPermission(Permissions::CONTENT_EDIT); // false + + +Impact +====== + +This class may come in handy in all situations where boolean flags need to be managed in an efficient way. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-87726-ExtendFrontendLoginControllerHookToValidatePassword.rst b/Documentation/Changelog/10.0/Feature-87726-ExtendFrontendLoginControllerHookToValidatePassword.rst new file mode 100644 index 0000000..85ef9ce --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-87726-ExtendFrontendLoginControllerHookToValidatePassword.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-87726: + +========================================================================== +Feature: #87726 - Extend FrontendLoginController Hook to validate password +========================================================================== + +See :issue:`87726` + +Description +=========== + +The Hook :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['password_changed']` is extended to validate the given password. +In the Hook you can set a custom validation message. + + +Impact +====== + +You can now use the hook via: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['password_changed'][] = \Your\Namespace\Hooks\MyBeautifulHook::class . '->passwordChanged'; + +Example implementation: +----------------------- + +.. code-block:: php + + public function passwordChanged(array &$params) + { + if($params['newPasswordUnencrypted']==='password'){ + $params['passwordValid']=FALSE; + $params['passwordInvalidMessage']='<p class="text-danger">Do not use password as password</p>'; + } + } + +.. index:: Frontend, ext:felogin, PHP-API diff --git a/Documentation/Changelog/10.0/Feature-88643-NewMailAPIBasedOnSymfonymailerAndSymfonymime.rst b/Documentation/Changelog/10.0/Feature-88643-NewMailAPIBasedOnSymfonymailerAndSymfonymime.rst new file mode 100644 index 0000000..4cd252a --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88643-NewMailAPIBasedOnSymfonymailerAndSymfonymime.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _feature-88643: + +======================================================================= +Feature: #88643 - New Mail API based on symfony/mailer and symfony/mime +======================================================================= + +See :issue:`88643` + +Description +=========== + +TYPO3 has relied on the third-party dependency "SwiftMailer" for a long time. + +However, the library has been superseded by the author in favor of new, more modern +libraries "symfony/mailer" for sending emails and "symfony/mime" for creating email +messages. + +TYPO3 has replaced swiftmailer with the symfony components. + +The new component does not handle the regular PHP function :php:`mail()`, which +has been declared unsafe in various scenarios, anymore. Instead it is recommended +to switch to `sendmail` or `smtp`, which can be configured within the TYPO3 +Install Tool or the Settings module for System Maintainers under "Presets" => "Mail". + +All existing installations which still configure ``mail`` are migrated to ``sendmail`` +by automatically detecting the sendmail path by checking PHP.ini settings, but +should be reviewed on update. + +In addition, the MailMessage API to create Email messages now inherits from +:php:`Symfony\Mail\Email` instead of :php:`Swift_Message`, and adds certain shortcuts +and more flexibility, but is also stricter in validation. + +Especially custom extensions using the MailMessage API need to be evaluated, +as it is not possible anymore to add multiple email addresses as a simple associative +array but rather an Address object from "symfony/mime" is required. + +All existing Swiftmailer-based transports which TYPO3 supports natively have been +replaced by Symfony-based transport APIs. + +Spool-based transports are still experimental, as it might be replaced by a native +Symfony component as well. + + +Impact +====== + +The MailMessage API now has more possibilities to add multi-part files and attachments, +for use in third-party extensions, but some APIs might be adapted. + +See the documentation of the Symfony components (https://symfony.com/doc/current/mailer.html) +for further details on how to use the new Email class where TYPO3's MailMessage +class extends from. + +An example implementation within a third-party extension: + +.. code-block:: php + + $email = GeneralUtility::makeInstance(MailMessage::class) + ->to(new Address('kasperYYYY@typo3.org'), new Address('benni@typo3.org', 'Benni Mack')) + ->subject('This is an example email') + ->text('This is the plain-text variant') + ->html('<h4>Hello Benni.</h4><p>Enjoy a HTML-readable email. <marquee>We love TYPO3</marquee>.</p>'); + + $email->send(); + +It is however also possible to re-use a Mailer instance, also adding custom Mailer +settings via a custom Transport for special cases. + +.. code-block:: php + + $mailer = GeneralUtility::makeInstance(Mailer::class) + + $email = GeneralUtility::makeInstance(MailMessage::class) + ->to(new Address('kasperYYYY@typo3.org'), new Address('benni@typo3.org', 'Benni Mack')) + ->subject('This is an example email') + ->text('This is the plain-text variant') + ->html('<h4>Hello Benni.</h4><p>Enjoy a HTML-readable email. <marquee>We love TYPO3</marquee>.</p>'); + + // Send the email via the Mailer instance + $mailer->send($email); + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-88648-DefineTwitterCardTypeInPageProperties.rst b/Documentation/Changelog/10.0/Feature-88648-DefineTwitterCardTypeInPageProperties.rst new file mode 100644 index 0000000..60abfda --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88648-DefineTwitterCardTypeInPageProperties.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-88648: + +========================================================== +Feature: #88648 - Set Twitter Card Type in page properties +========================================================== + +See :issue:`88648` + +Description +=========== + +It is now possible to select the type of Twitter Card to be shown when a page is shared +on Twitter. This option will render the :html:`twitter:card` meta tag in frontend. + +Impact +====== + +If you manually changed the value of the :html:`twitter:card` by for example TypoScript and you +want to override the value of the page properties, you have to use the replace option to +override this value from the page properties. + +Example: + +.. code-block:: typoscript + + page { + meta { + twitter:card = summary_large_image + twitter:card.replace = 1 + } + } + + +.. index:: ext:seo, Frontend, TypoScript diff --git a/Documentation/Changelog/10.0/Feature-88770-PSR-14BasedEventDispatcher.rst b/Documentation/Changelog/10.0/Feature-88770-PSR-14BasedEventDispatcher.rst new file mode 100644 index 0000000..f71fca2 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88770-PSR-14BasedEventDispatcher.rst @@ -0,0 +1,131 @@ +.. include:: /Includes.rst.txt + +.. _feature-88770: + +============================================== +Feature: #88770 - PSR-14 based EventDispatcher +============================================== + +See :issue:`88770` + +Description +=========== + +A new EventDispatcher system is added to extend TYPO3's Core behaviour via PHP code. In the past, +this was done via Extbase's SignalSlot and TYPO3's custom hook system. The new EventDispatcher +system is a fully capable replacement for new code in TYPO3, as well as a possibility to +migrate away from previous TYPO3 solutions. + +PSR-14 [https://www.php-fig.org/psr/psr-14/] is a lean solution that builds upon wide-spread +solutions for hooking into existing PHP code (Frameworks, CMS and the like). + +PSR-14 consists of four components: + +1. An `EventDispatcher` object that is used to trigger an Event. TYPO3 has a custom EventDispatcher +implementation for now, however all EventDispatchers of all frameworks are implementing +:php:`Psr\EventDispatcher\EventDispatcherInterface` thus it is possible to replace the event +dispatcher with another. The EventDispatcher's main method :php:`dispatch()` is called in TYPO3 Core +or extensions, that receives a PHP object and is then handed to all available listeners. + +2. A `ListenerProvider` object that contains all listeners which have been registered for all events. +TYPO3 has a custom ListenerProvider that collects all listeners during compile time. This component +is not exposed outside of TYPO3's Core Framework. + +3. Various `Event` objects. An event object can be any PHP object and is called from TYPO3 Core or +an extension ("Emitter") containing all information to be transported to the listeners. By default, +all registered listeners get triggered by an Event, however, if an Event has the interface +:php:`Psr\EventDispatcher\StoppableEventInterface` implemented, a listener can stop further execution +of other event listeners. This is especially useful if the listeners are candidates to provide information +to the emitter. This allows to finish event dispatching, once this information has been acquired. + +If an event can be modified, appropriate methods should be available, although due to PHP's +nature of handling objects and the PSR-14 Listener signature, it cannot be guaranteed to be immutable. + +4. Listeners: Extensions and PHP packages can add listeners that are registered. They are usually +associated to Event objects by the name of the event (FQCN) to be listened on. It is the task of +the `ListenerProvider` to provide configuration mechanisms to represent this relationship. + +The main benefits of the EventDispatcher approach over Hooks and Extbase's SignalSlot Dispatcher +is an implementation which helps extension authors to better understand the possibilities +by having a strongly typed system based on PHP. In addition, it serves as a bridge to also +incorporate other Events provided by frameworks that support PSR-14. + + +Impact +====== + +TYPO3's EventDispatcher serves as the basis to replace all Signal/Slots and hooks in the future, +however for the time being, hooks and registered Slots work the same way as before, unless migrated +to an EventDispatcher-like code, whereas a PHP :php:`E_USER_DEPRECATED` error can be triggered. + +Some hooks / signal/slots might not be replaced 1:1 to EventDispatcher, but rather superseded with +a more robust or future-proof API. + +Registration: + +If an extension author wants to provide a custom Event Listener, an according entry with the tag +`event.listener` can be added to the `Configuration/Services.yaml` file of that extension. + +Example: + +.. code-block:: yaml + + services: + MyCompany\MyPackage\EventListener\NullMailer: + tags: + - name: event.listener + identifier: 'myListener' + event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent + before: 'redirects, anotherIdentifier' + + +The tag name `event.listener` identifies that a listener should be registered. + +The custom PHP class :php:`MyCompany\MyPackage\EventListener\NullMailer` serves as the listener, +whereas the `identifier` is a common name so orderings can be built upon the identifier, +the optional `before` and `after` attributes allow for custom sorting against `identifier`. + +The `event` attribute is the FQCN of the Event object. + +If no attribute `method` is given, the class is treated as Invokable, thus `__invoke` method is called. + +An example listener, which hooks into the Mailer API to modify Mailer settings to not send any emails, +could look like this: + +.. code-block:: php + + namespace MyCompany\MyPackage\EventListener; + use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent; + + class NullMailer + { + public function __invoke(AfterMailerInitializationEvent $event): void + { + $event->getMailer()->injectMailSettings(['transport' => 'null']); + } + } + +An extension can define multiple listeners. + +Once the emitter is triggering an Event, this listener is called automatically. Be sure +to inspect the Event PHP class to fully understand the capabilities provided by an Event. + +Best Practices: + +1. When configuring Listeners, it is recommended to add one Listener class per Event type, and +have it called via `__invoke()`. + +2. When creating a new Event PHP class, it is recommended to add a `Event` suffix to the PHP class, +and to move it into an appropriate folder e.g. `Classes/Database/Event` to easily discover +Events provided by a package. Be careful about the context that should be exposed. + +3. Emitters (TYPO3 Core or Extension Authors) should always use Dependency Injection to receive the +EventDispatcher object as a constructor argument, where possible, by adding a type declaration +for :php:`Psr\EventDispatcher\EventDispatcherInterface`. + +Any kind of Event provided by TYPO3 Core falls under TYPO3's Core API deprecation policy, except +for its constructor arguments, which may vary. Events that should only be used within TYPO3 Core, +are marked as `@internal`, just like other non-API parts of TYPO3, but `@internal` Events will be +avoided whenever technically possible. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-88791-IntroducePreviewAspectInContext.rst b/Documentation/Changelog/10.0/Feature-88791-IntroducePreviewAspectInContext.rst new file mode 100644 index 0000000..0f9298d --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88791-IntroducePreviewAspectInContext.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-88791: + +==================================================== +Feature: #88791 - Introduce PreviewAspect in Context +==================================================== + +See :issue:`88791` + +Description +=========== + +A PreviewAspect for handling the preview flag has been introduced. This aspect may be used to indicate that the +frontend is in preview mode (for example in case a workspace is previewed or hidden pages or records should be shown). + +Impact +====== + +The Context API has a new Aspect called "frontend.preview". It can be used to determine whether the frontend is currently in preview mode. + +.. code-block:: php + + GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('frontend.preview', 'isPreview'); + +This Aspect replaces the now deprecated property :php:`TypoScriptFrontendController->fePreview`. Accessing this property +triggers a PHP :php:`E_USER_DEPRECATED` error, and fetches the information from the new Context Aspect instead. + +.. index:: Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-88792-AddTypoScriptAspectToHandleTypoScriptRenderingContextSettings.rst b/Documentation/Changelog/10.0/Feature-88792-AddTypoScriptAspectToHandleTypoScriptRenderingContextSettings.rst new file mode 100644 index 0000000..1549c4f --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88792-AddTypoScriptAspectToHandleTypoScriptRenderingContextSettings.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _feature-88792: + +====================================================================================== +Feature: #88792 - Add TypoScriptAspect to handle TypoScript Rendering Context settings +====================================================================================== + +See :issue:`88792` + +Description +=========== + +A new aspect was added to the context to handle TypoScript related `Context` settings. + + +Impact +====== + +A new Aspect `TypoScriptAspect` exists which can be used to manipulate/check whether TemplateRendering is forced. + +.. index:: Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-88799-IntroducedPSR-3CompatibleLoggingAPI.rst b/Documentation/Changelog/10.0/Feature-88799-IntroducedPSR-3CompatibleLoggingAPI.rst new file mode 100644 index 0000000..b22fdb0 --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88799-IntroducedPSR-3CompatibleLoggingAPI.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-88799: + +========================================================= +Feature: #88799 - Introduced PSR-3 compatible Logging API +========================================================= + +See :issue:`88799` + +Description +=========== + +The existing Logging API evolved pretty similar to the later established PSR-3 logging standard. +There was one key difference between the standard and the TYPO3 implementation though: +The log levels were represented by numbers in TYPO3 whereas PSR-3 requires the string representation. + +TYPO3 therefore finally adapted to the standard and opens up for better integration with other libraries +also following the standard. + + +Impact +====== + +The adaption was not possible without a few breaking changes to the code base. +Luckily no configuration changes are necessary, hence integrators are not affected by those. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Feature-88807-AdminPanelRequestEnricherInterfaceHasBeenIntroduced.rst b/Documentation/Changelog/10.0/Feature-88807-AdminPanelRequestEnricherInterfaceHasBeenIntroduced.rst new file mode 100644 index 0000000..1d31d4f --- /dev/null +++ b/Documentation/Changelog/10.0/Feature-88807-AdminPanelRequestEnricherInterfaceHasBeenIntroduced.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-88807: + +========================================================================= +Feature: #88807 - AdminPanel RequestEnricherInterface has been introduced +========================================================================= + +See :issue:`88807` + +Description +=========== + +The AdminPanel initialisation process has been refactored and an interface called +:php:`\TYPO3\CMS\Adminpanel\ModuleApi\RequestEnricherInterface` has been introduced. + + +Impact +====== + +With the :php:`\TYPO3\CMS\Adminpanel\ModuleApi\RequestEnricherInterface`, adminpanel modules gain the +ability to manipulate the request object during TYPO3's processing of the PSR-15 middlewares. +All modules implementing the interface need a method :php:`enrich($request)` and may return an altered +:php:`$request` in their processing. At the end of the processing, the `$request` has to be returned +and will in turn be used in further PSR-15 middleware stack processing. + +.. index:: Frontend, PHP-API, ext:adminpanel diff --git a/Documentation/Changelog/10.0/Important-87427-ClassSchemaConstantsMarkedAsPrivate.rst b/Documentation/Changelog/10.0/Important-87427-ClassSchemaConstantsMarkedAsPrivate.rst new file mode 100644 index 0000000..2bbcc5e --- /dev/null +++ b/Documentation/Changelog/10.0/Important-87427-ClassSchemaConstantsMarkedAsPrivate.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-87427: + +=========================================================== +Important: #87427 - ClassSchema constants marked as private +=========================================================== + +See :issue:`87427` + +Description +=========== + +The constants + +* :php:`ClassSchema::MODELTYPE_ENTITY` and +* :php:`ClassSchema::MODELTYPE_VALUEOBJECT` + +have been marked as private, as they are used in :php:`\TYPO3\CMS\Extbase\Reflection\ClassSchema` only. + +Since this class is marked as internal explicitly, nobody should be affected by this change. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.0/Important-87516-RemoveCoreHTTPRequestHandlerInterface.rst b/Documentation/Changelog/10.0/Important-87516-RemoveCoreHTTPRequestHandlerInterface.rst new file mode 100644 index 0000000..45b6e13 --- /dev/null +++ b/Documentation/Changelog/10.0/Important-87516-RemoveCoreHTTPRequestHandlerInterface.rst @@ -0,0 +1,19 @@ +.. include:: /Includes.rst.txt + + +.. _important-87516: + +============================================================ +Important: #87516 - Remove core HTTP RequestHandlerInterface +============================================================ + +See :issue:`87516` + +Description +=========== + +The internal interface :php:`\TYPO3\CMS\Core\Http\RequestHandlerInterface` has +been removed in favor of PSR-15 request handler and middleware interfaces which +are now used throughout the core. + +.. index:: PHP-API, FullyScanned diff --git a/Documentation/Changelog/10.0/Important-87603-ClassesUseStrictModeAndScarlarTypeHints.rst b/Documentation/Changelog/10.0/Important-87603-ClassesUseStrictModeAndScarlarTypeHints.rst new file mode 100644 index 0000000..c8ec2ed --- /dev/null +++ b/Documentation/Changelog/10.0/Important-87603-ClassesUseStrictModeAndScarlarTypeHints.rst @@ -0,0 +1,145 @@ +.. include:: /Includes.rst.txt + +.. _important-87594: + +================================================================= +Important: #87594 - Classes use strict mode and scalar type hints +================================================================= + +See :issue:`87594` + +Description +=========== + +The following PHP classes now use strict mode +and their methods will force parameter types with scalar type hints: + +- :php:`\TYPO3\CMS\Extbase\Configuration\AbstractConfigurationManager` +- :php:`\TYPO3\CMS\Extbase\Configuration\BackendConfigurationManager` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface` +- :php:`\TYPO3\CMS\Extbase\Configuration\Exception` +- :php:`\TYPO3\CMS\Extbase\Configuration\Exception\InvalidConfigurationTypeException` +- :php:`\TYPO3\CMS\Extbase\Configuration\Exception\ParseErrorException` +- :php:`\TYPO3\CMS\Extbase\Configuration\FrontendConfigurationManager` +- :php:`\TYPO3\CMS\Extbase\Core\Bootstrap` +- :php:`\TYPO3\CMS\Extbase\Core\BootstrapInterface` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\BackendUserGroupRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\BackendUserRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\CategoryRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FileMountRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FrontendUserGroupRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FrontendUserRepository` +- :php:`\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject` +- :php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface` +- :php:`\TYPO3\CMS\Extbase\Error\Error` +- :php:`\TYPO3\CMS\Extbase\Error\Message` +- :php:`\TYPO3\CMS\Extbase\Error\Notice` +- :php:`\TYPO3\CMS\Extbase\Error\Result` +- :php:`\TYPO3\CMS\Extbase\Error\Warning` +- :php:`\TYPO3\CMS\Extbase\Exception` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\Exception\RequiredArgumentMissingException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InfiniteLoopException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidActionNameException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentMixingException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentNameException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentTypeException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentValueException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidControllerException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidControllerNameException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidExtensionNameException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestMethodException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\NoSuchActionException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\NoSuchControllerException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\StopActionException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\UnsupportedRequestTypeException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder` +- :php:`\TYPO3\CMS\Extbase\Object\Container\Container` +- :php:`\TYPO3\CMS\Extbase\Object\Container\Exception\UnknownObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\Exception` +- :php:`\TYPO3\CMS\Extbase\Object\Exception\CannotBuildObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\Exception\CannotReconstituteObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\ObjectManager` +- :php:`\TYPO3\CMS\Extbase\Object\ObjectManagerInterface` +- :php:`\TYPO3\CMS\Extbase\Persistence\Exception` +- :php:`\TYPO3\CMS\Extbase\Persistence\Exception\IllegalObjectTypeException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Exception\IllegalRelationTypeException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Exception\UnknownObjectException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\InconsistentQuerySettingsException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidClassException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidNumberOfConstraintsException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidRelationConfigurationException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\MissingColumnMapException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\NotImplementedException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\RepositoryException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\TooDirtyException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnexpectedTypeException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedMethodException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedOrderException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedRelationException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\ColumnMap` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapFactory` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Storage\BackendInterface` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Storage\Exception\BadConstraintException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Storage\Exception\SqlErrorException` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbBackend` +- :php:`\TYPO3\CMS\Extbase\Property\Exception` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\DuplicateObjectException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\DuplicateTypeConverterException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\InvalidDataTypeException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\InvalidPropertyMappingConfigurationException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\InvalidSourceException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\InvalidTargetException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\TargetNotFoundException` +- :php:`\TYPO3\CMS\Extbase\Property\Exception\TypeConverterException` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractFileFolderConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\ArrayConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\BooleanConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\CoreTypeConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\DateTimeConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FileConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FileReferenceConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FloatConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FolderBasedFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FolderConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\IntegerConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\ObjectConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\ObjectStorageConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\PersistentObjectConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\StaticFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\StringConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverterInterface` +- :php:`\TYPO3\CMS\Extbase\Reflection\ClassSchema` +- :php:`\TYPO3\CMS\Extbase\Reflection\Exception` +- :php:`\TYPO3\CMS\Extbase\Reflection\Exception\PropertyNotAccessibleException` +- :php:`\TYPO3\CMS\Extbase\Reflection\Exception\UnknownClassException` +- :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess` +- :php:`\TYPO3\CMS\Extbase\Security\Cryptography\HashService` +- :php:`\TYPO3\CMS\Extbase\Security\Exception` +- :php:`\TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForHashGenerationException` +- :php:`\TYPO3\CMS\Extbase\Security\Exception\InvalidHashException` +- :php:`\TYPO3\CMS\Extbase\Service\CacheService` +- :php:`\TYPO3\CMS\Extbase\Service\EnvironmentService` +- :php:`\TYPO3\CMS\Extbase\Service\ExtensionService` +- :php:`\TYPO3\CMS\Extbase\Service\ImageService` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Dispatcher` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException` +- :php:`\TYPO3\CMS\Extbase\Utility\DebuggerUtility` +- :php:`\TYPO3\CMS\Extbase\Utility\Exception\InvalidTypeException` +- :php:`\TYPO3\CMS\Extbase\Utility\FrontendSimulatorUtility` +- :php:`\TYPO3\CMS\Extbase\Utility\LocalizationUtility` +- :php:`\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility` +- :php:`\TYPO3\CMS\Extbase\Validation\Exception` +- :php:`\TYPO3\CMS\Extbase\Validation\Exception\InvalidTypeHintException` +- :php:`\TYPO3\CMS\Extbase\Validation\Exception\InvalidValidationConfigurationException` +- :php:`\TYPO3\CMS\Extbase\Validation\Exception\InvalidValidationOptionsException` +- :php:`\TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException` + +.. index:: Backend, PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.0/Important-87894-RemovedPHPDependencyAlgo26-matthiasidna-convert.rst b/Documentation/Changelog/10.0/Important-87894-RemovedPHPDependencyAlgo26-matthiasidna-convert.rst new file mode 100644 index 0000000..a6c373b --- /dev/null +++ b/Documentation/Changelog/10.0/Important-87894-RemovedPHPDependencyAlgo26-matthiasidna-convert.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _important-87894: + +======================================================================= +Important: #87894 - Removed PHP dependency algo26-matthias/idna-convert +======================================================================= + +See :issue:`87894` + +Description +=========== + +PHP has native functions for converting UTF-8 based domains to ascii-based ("punicode"), which +can be used directly when the PHP extension "intl" is installed. For servers with PHP packages which +do not have the PHP extension "intl" installed, the symfony polyfill package "symfony/polyfill-intl-idn" +is available, allowing to use native PHP functionality in the TYPO3 code base. + +For this reason the PHP dependency "algo26-matthias/idna-convert" is no longer necessary and +has been removed. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.0/Important-88043-TypeScriptSourcesMovedIntoBuildDirectory.rst b/Documentation/Changelog/10.0/Important-88043-TypeScriptSourcesMovedIntoBuildDirectory.rst new file mode 100644 index 0000000..fe40885 --- /dev/null +++ b/Documentation/Changelog/10.0/Important-88043-TypeScriptSourcesMovedIntoBuildDirectory.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _important-88043: + +================================================================= +Important: #88043 - TypeScript sources moved into Build directory +================================================================= + +See :issue:`88043` + +Description +=========== + +The TypeScript sources of all system extensions have been moved into the :file:`Build` directory. The former directory +structure :file:`typo3/sysext/foobar/Resources/Private/TypeScript` has been superseded by the new structure +:file:`Build/Sources/TypeScript/foobar/Resources/Public/TypeScript`. + +.. note:: + + Mind that :file:`Public` is now the parent directory of :file:`TypeScript`. + +.. index:: JavaScript diff --git a/Documentation/Changelog/10.0/Index.rst b/Documentation/Changelog/10.0/Index.rst new file mode 100644 index 0000000..bb651c9 --- /dev/null +++ b/Documentation/Changelog/10.0/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-0: + +10.0 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Breaking-* + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/10.1/Deprecation-88787-BackendUtilityEditOnClick.rst b/Documentation/Changelog/10.1/Deprecation-88787-BackendUtilityEditOnClick.rst new file mode 100644 index 0000000..78d3835 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88787-BackendUtilityEditOnClick.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88787: + +================================================= +Deprecation: #88787 - BackendUtility::editOnClick +================================================= + +See :issue:`88787` + +Description +=========== + +The method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::editOnClick()` +used to generate JavaScript `onclick` targets to +:php:`\TYPO3\CMS\Backend\Controller\EditDocumentController` has been marked as deprecated. + + +Impact +====== + +Using this method will trigger PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations with extensions using :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::editOnClick()` are affected. + + +Migration +========= + +Migrate the method to use the :php:`\TYPO3\CMS\Backend\Routing\UriBuilder` API and attach the parameters manually. + +Example: + +.. code-block:: php + + // Previous + $old = BackendUtility::editOnClick($params); + + // Migrated + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + + // Variant 1 + $params = '&edit[pages][' . $pid . ']=new&returnNewPageId=1'; + $migrated = $uriBuilder->buildUriFromRoute('record_edit') . $params + . '&returnUrl=' . rawurlencode(GeneralUtility::getIndpEnv('REQUEST_URI')); + + // Variant 2 + $params = [ + 'edit' => [ + 'pages' => [ + $pid => 'new', + ], + ], + 'returnNewPageId' => 1, + 'returnUrl' => GeneralUtility::getIndpEnv('REQUEST_URI'), + ]; + $migrated = (string)$uriBuilder->buildUriFromRoute('record_edit', params); + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-88839-CLILowlevelRequestHandlers.rst b/Documentation/Changelog/10.1/Deprecation-88839-CLILowlevelRequestHandlers.rst new file mode 100644 index 0000000..e749c3a --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88839-CLILowlevelRequestHandlers.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88839: + +=================================================== +Deprecation: #88839 - CLI lowlevel request handlers +=================================================== + +See :issue:`88839` + +Description +=========== + +The interface :php:`\TYPO3\CMS\Core\Console\RequestHandlerInterface` +and the class :php:`\TYPO3\CMS\Core\Console\CommandRequestHandler` have been introduced in TYPO3 v7 to streamline +various entry points for CLI-related functionality. Back then, there were Extbase command requests and +`CommandLineController` entry points. + +With TYPO3 v10, the only way to handle CLI commands is via the :php:`\TYPO3\CMS\Core\Console\CommandApplication` class which is +a wrapper around Symfony Console. All logic is now located in the Application, and thus, the interface and +the class have been marked as deprecated. + + +Impact +====== + +When instantiating the CLI :php:`\TYPO3\CMS\Core\Console\RequestHandler` class, +a PHP :php:`E_USER_DEPRECATED` error will be triggered. + + +Affected Installations +====================== + +Any TYPO3 installation having custom CLI request handlers wrapped via the interface or extending the +CLI request handler class. + + +Migration +========= + +Switch to a Symfony Command or provide a custom CLI entry point. + +.. index:: CLI, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.1/Deprecation-88850-ContentObjectRendererSendNotifyEmail.rst b/Documentation/Changelog/10.1/Deprecation-88850-ContentObjectRendererSendNotifyEmail.rst new file mode 100644 index 0000000..e3bdc04 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88850-ContentObjectRendererSendNotifyEmail.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88850: + +============================================================ +Deprecation: #88850 - ContentObjectRenderer::sendNotifyEmail +============================================================ + +See :issue:`88850` + +Description +=========== + +The method :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::sendNotifyEmail()` +which has been used to send mails has been marked as deprecated. + + +Impact +====== + +Using this method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All 3rd party extensions calling +:php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::sendNotifyEmail()` are affected. + + +Migration +========= + +To send a mail, use the :php:`\TYPO3\CMS\Core\Mail\MailMessage`-API + +.. code-block:: php + + $email = GeneralUtility::makeInstance(MailMessage::class) + ->to(new Address('katy@domain.tld'), new Address('john@domain.tld', 'John Doe')) + ->subject('This is an example email') + ->text('This is the plain-text variant') + ->html('<h4>Hello John.</h4><p>Enjoy a HTML-readable email. <marquee>We love TYPO3</marquee>.</p>'); + + $email->send(); + +.. index:: PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.1/Deprecation-88854-JumpExtOfRecordListController.rst b/Documentation/Changelog/10.1/Deprecation-88854-JumpExtOfRecordListController.rst new file mode 100644 index 0000000..62a5ad2 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88854-JumpExtOfRecordListController.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88854: + +======================================================= +Deprecation: #88854 - jumpExt() of RecordListController +======================================================= + +See :issue:`88854` + +Description +=========== + +The JavaScript function :js:`jumpExt()` used to modify URLs by attaching `returnUrl` and `anchors` +arguments have been marked as deprecated. + +Impact +====== + +Calling :js:`jumpExt()` will trigger a deprecation warning in the browser console. + + +Affected Installations +====================== + +All third party extensions using :js:`jumpExt()` are affected. + + +Migration +========= + +It is only possible to call this function via hooks. To migrate this call, append a `returnUrl` argument to the URL if +required and move the URL to the :html:`href` argument of the button the function was attached to. + +.. index:: Backend, JavaScript, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-88854-T3_THIS_LOCATION.rst b/Documentation/Changelog/10.1/Deprecation-88854-T3_THIS_LOCATION.rst new file mode 100644 index 0000000..16ce05d --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88854-T3_THIS_LOCATION.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88854-1668719172: + +====================================== +Deprecation: #88854 - T3_THIS_LOCATION +====================================== + +See :issue:`88854` + +Description +=========== + +The global JavaScript variable :js:`T3_THIS_LOCATION` containing the URL to the current document (if not modified) has +been marked as deprecated. + + +Impact +====== + +Since this is a global JavaScript variable, no proper deprecation layer applies and thus no deprecation notice is rendered. + +Some PHP API uses :js:`T3_THIS_LOCATION` +(e.g. :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getLinkToDataHandlerAction()` with second argument being `-1`) +which has been marked as deprecated as well. + + +Affected Installations +====================== + +All third party extensions using :js:`T3_THIS_LOCATION` are affected. + + +Migration +========= + +When generating URLs containing a `returnUrl` (a common use-case for :js:`T3_THIS_LOCATION`), +consider using either :php:`rawurldecode(GeneralUtility::getIndpEnv('REQUEST_URI'))` +or :php:`normalizedParams` in the PSR-7 ServerRequest object: +:php:`$request->getAttribute('normalizedParams')->getRequestUri()`. + +In general, :js:`onclick` handlers doing a redirect are considered bad practice. +Use HTML's :html:`href` attribute and attach custom click handlers, if necessary. + +.. index:: Backend, JavaScript, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-88862-T3_RETURN_URL.rst b/Documentation/Changelog/10.1/Deprecation-88862-T3_RETURN_URL.rst new file mode 100644 index 0000000..f9408e8 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88862-T3_RETURN_URL.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88862: + +=================================== +Deprecation: #88862 - T3_RETURN_URL +=================================== + +See :issue:`88862` + +Description +=========== + +The JavaScript variable :js:`T3_RETURN_URL` holding the returnUrl sent with the current request either via `GET` or +`POST` has been marked as deprecated. + + +Impact +====== + +Since this is a global JavaScript variable, no proper deprecation layer applies and thus no deprecation notice is +rendered. + + +Affected Installations +====================== + +All third party extensions using :js:`T3_RETURN_URL` are affected. + + +Migration +========= + +Get the submitted returnUrl by using PHP: + +.. code-block:: php + + // Variant 1 + $returnUrl = GeneralUtility::sanitizeLocalUrl(GeneralUtility::_GP('returnUrl')); + + // Variant 2 + $returnUrl = $request->getParsedBody()['returnUrl'] ?? $request->getQueryParams()['returnUrl'] ?? ''; + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-88995-CallingRegisterPluginWithVendorName.rst b/Documentation/Changelog/10.1/Deprecation-88995-CallingRegisterPluginWithVendorName.rst new file mode 100644 index 0000000..b7ca3ef --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-88995-CallingRegisterPluginWithVendorName.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88995: + +============================================================= +Deprecation: #88995 - Calling registerPlugin with vendor name +============================================================= + +See :issue:`88995` + +Description +=========== + +The first parameter :php:`$extensionName` of method :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin` +used to contain the vendor name in the past. + +.. code-block:: php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( + 'TYPO3.CMS.Form', + 'Formframework', + 'Form', + 'content-form', + ); + +As the vendor name does not have any effect at all, it's usage has been marked as deprecated. + + +Impact +====== + +Calling :php:`registerPlugin()` with first parameter containing dots (considered to be the full vendor) name will trigger a PHP :php:`E_USER_DEPRECATED` error. +As of TYPO3 v11 using the vendor name along with the extension name will lead to a wrong registration of plugins. + + +Affected Installations +====================== + +All installations that add the vendor name to the first parameter :php:`$extensionName` +of method :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin()` are affected. + + +Migration +========= + +Just use the extension name like in this example. + +.. code-block:: php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( + 'Form', + 'Formframework', + 'Form', + 'content-form', + ); + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.1/Deprecation-89001-InternalPublicTSFEProperties.rst b/Documentation/Changelog/10.1/Deprecation-89001-InternalPublicTSFEProperties.rst new file mode 100644 index 0000000..2dcfd55 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-89001-InternalPublicTSFEProperties.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89001: + +===================================================== +Deprecation: #89001 - Internal public TSFE properties +===================================================== + +See :issue:`89001` + +Description +=========== + +The following properties of the :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController` class have been marked as deprecated: + +* :php:`cHash_array` +* :php:`cHash` +* :php:`domainStartPage` + +The properties are now built into proper arguments of the PHP objects +:php:`\TYPO3\CMS\Core\Site\Entity\Site` +and :php:`\TYPO3\CMS\Core\Routing\PageArguments`. + +This follows the pattern of not accessing these properties through +the global :php:`TSFE` object directly anymore. + + +Impact +====== + +Accessing these properties directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions or TypoScript directly +accessing these properties. + + +Migration +========= + +Use the properties of :php:`Site` and :php:`PageArguments` instead: + +* :php:`\TYPO3\CMS\Core\Site\Entity\Site->getRootPageId()` (e.g. via :php:`$request->getAttribute('site')`) +* :php:`\TYPO3\CMS\Core\Routing\PageArguments->getArguments()['cHash']` (e.g. via :php:`$request->getAttribute('routing')`) + +Please note that accessing these variables should be avoided via the :php:`TSFE` context. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.1/Deprecation-89033-JumpToUrl.rst b/Documentation/Changelog/10.1/Deprecation-89033-JumpToUrl.rst new file mode 100644 index 0000000..ec95c15 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-89033-JumpToUrl.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89033: + +=============================== +Deprecation: #89033 - jumpToUrl +=============================== + +See :issue:`89033` + +Description +=========== + +The JavaScript function :js:`jumpToUrl()` which is widely used in TYPO3 has been marked as deprecated. + + +Impact +====== + +Calling :js:`jumpToUrl()` will cause a deprecation entry in the browser's console. + + +Affected Installations +====================== + +Extensions using :js:`jumpToUrl()` implementation are affected. + + +Migration +========= + +Since :js:`jumpToUrl()` triggers a redirect only, it's safe to either use :js:`window.location.href = 'link/to/my/module';` +or use the link in combination with plain HTML as in :html:`<a href="link/to/my/module">my link</a>`. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-89037-DeprecatedLocallangXmlParser.rst b/Documentation/Changelog/10.1/Deprecation-89037-DeprecatedLocallangXmlParser.rst new file mode 100644 index 0000000..580475a --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-89037-DeprecatedLocallangXmlParser.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89037: + +=================================================== +Deprecation: #89037 - Deprecated LocallangXmlParser +=================================================== + +See :issue:`89037` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Localization\Parser\LocallangXmlParser` has been used to parse localization files based on the custom ll-XML format ("ll" refers to "locallang"). +Since TYPO3 version 4.6 XLIFF is being used and therefore the previous support for locallang-XML files has been marked as deprecated. + +Impact +====== + +Calling :php:`\TYPO3\CMS\Core\Localization\Parser\LocallangXmlParser` or using locallang-XML files will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All installations using extensions using ll-XML localization files. + + +Migration +========= + +Migrate all XML files to the XLIFF standard. + +.. index:: Backend, Frontend, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.1/Deprecation-89127-CleanupRecordHistoryHandling.rst b/Documentation/Changelog/10.1/Deprecation-89127-CleanupRecordHistoryHandling.rst new file mode 100644 index 0000000..d0ae80a --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-89127-CleanupRecordHistoryHandling.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89127: + +==================================================== +Deprecation: #89127 - Cleanup RecordHistory handling +==================================================== + +See :issue:`89127` + + +Description +=========== + +The following properties of the :php:`\TYPO3\CMS\Backend\History\RecordHistory` class have been marked as deprecated: + +* :php:`changeLog` +* :php:`lastHistoryEntry` + +The properties are now protected and have a public getter function. + +The following public methods of the :php:`\TYPO3\CMS\Backend\History\RecordHistory` class have changed visibility from public to protected: + +* :php:`getHistoryEntry()` +* :php:`getHistoryData()` + +The following methods of the :php:`\TYPO3\CMS\Backend\History\RecordHistory` class have been marked as deprecated: + +* :php:`createChangeLog()`, use :php:`getChangeLog()` instead +* :php:`shouldPerformRollback()` +* :php:`getElementData()`, use :php:`getElementInformation()` instead +* :php:`performRollback()`, use :php:`RecordHistoryRollback::performRollback()` instead +* :php:`createMultipleDiff()`, use :php:`getDiff()` instead +* :php:`setLastHistoryEntry()`, use :php:`setLastHistoryEntryNumber()` instead + + +Impact +====== + +Accessing these properties and methods directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions or TypoScript directly +accessing these properties and methods. + + +Migration +========= + +Use the mentioned alternative methods and new classes. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Deprecation-89215-JQueryClearable.rst b/Documentation/Changelog/10.1/Deprecation-89215-JQueryClearable.rst new file mode 100644 index 0000000..b472979 --- /dev/null +++ b/Documentation/Changelog/10.1/Deprecation-89215-JQueryClearable.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89215: + +====================================== +Deprecation: #89215 - jQuery.clearable +====================================== + +See :issue:`89215` + +Description +=========== + +The jQuery plugin :js:`jquery.clearable` that provides a button to clear an input field has been marked as deprecated. + + +Impact +====== + +Using :js:`jquery.clearable` will trigger a deprecation warning in the browser's console. + + +Affected Installations +====================== + +All 3rd party extensions using :js:`jquery.clearable` are affected. + + +Migration +========= + +Import the module :js:`TYPO3/CMS/Backend/Input/Clearable` and use the method :js:`clearable()` on a native :js:`HTMLInputElement`. + +Example code: + +.. code-block:: js + + require(['TYPO3/CMS/Backend/Input/Clearable'], function() { + const inputField = document.querySelector('#some-input'); + if (inputField !== null) { + inputField.clearable(); + } + + const clearables = Array.from(document.querySelectorAll('.t3js-clearable')).filter(inputElement => { + // Filter input fields being a date time picker and a color picker + return !inputElement.classList.contains('t3js-datetimepicker') && !inputElement.classList.contains('t3js-color-picker'); + }); + clearables.forEach(clearableField => clearableField.clearable()); + }); + +The method also accepts an :js:`options` object, allowing to set a :js:`onClear` callback. The callback receives the input field as an argument the clearing was applied to. + +Example code: + +.. code-block:: js + + const inputField = document.querySelector('#some-input'); + if (inputField !== null) { + inputField.clearable({ + onClear: function (input) { + input.closest('form').submit(); + } + }); + } + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-78488-AddRelNoreferrerToExternalLinks.rst b/Documentation/Changelog/10.1/Feature-78488-AddRelNoreferrerToExternalLinks.rst new file mode 100644 index 0000000..ee7cd0c --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-78488-AddRelNoreferrerToExternalLinks.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-78488: + +======================================================== +Feature: #78488 - Add rel="noreferrer" to external links +======================================================== + +See :issue:`78488` + +Description +=========== + +All links processed by :typoscript:`typolink` with external links opening in a new window have been extended to contain +:html:`rel="noreferrer"`. + +Links opening in a new window are defined as those having an attribute :html:`target` which is either not empty, +:html:`_self`, :html:`_top` or :html:`_parent`. + +.. note:: + + Initially this feature added :html:`rel="noopener noreferrer`. However :html:`noreferrer` also + implies the property :html:`noopener`. Therefore, the latter was removed. + + +Impact +====== + +This property improves the security of the site: + +:html:`noreferrer` + This property prevents the browser, when navigating to another page, to send the page address, or any other value, + as referrer in according HTTP header. :html:`noreferrer` also implies the property :html:`noopener`, which instructs + the browser to open the link without granting the new browsing context access to the document that opened it. + + +.. index:: Frontend diff --git a/Documentation/Changelog/10.1/Feature-84250-SeparatelyEnableDisableAddMediaByURLAndSelectUploadFiles.rst b/Documentation/Changelog/10.1/Feature-84250-SeparatelyEnableDisableAddMediaByURLAndSelectUploadFiles.rst new file mode 100644 index 0000000..bbfdf94 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-84250-SeparatelyEnableDisableAddMediaByURLAndSelectUploadFiles.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-84250: + +============================================================================================ +Feature: #84250 - Separately enable / disable "Add media by URL" and "Select & upload files" +============================================================================================ + +See :issue:`84250` + +Description +=========== + +A new appearance property "fileByUrlAllowed" is used to separately enable / disable the buttons "Add media by URL" and "Select & upload files". + +* :php:`fileUploadAllowed = false` now only hides the button "Select & upload files". +* :php:`fileByUrlAllowed = false` now hides the button "Add media by URL". + +If "elementBrowserType" is set to "file" both values are true by default. + +Example + +.. code-block:: php + + $GLOBALS['TCA']['pages']['columns']['media']['config']['appearance'] = [ + 'fileUploadAllowed' => false, + 'fileByUrlAllowed' => false, + ]; + +This will suppress both buttons and only leave "Create new relation". + +Impact +====== + +Users have to use the new appearance property "fileByUrlAllowed" to hide the button "Add media by URL" + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-85918-HideInMenuShowInMenuEntryForPagesInContextMenu.rst b/Documentation/Changelog/10.1/Feature-85918-HideInMenuShowInMenuEntryForPagesInContextMenu.rst new file mode 100644 index 0000000..bfd5f57 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-85918-HideInMenuShowInMenuEntryForPagesInContextMenu.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-85918: + +============================================================================= +Feature: #85918 - Hide in menu / Show in menu entry for pages in context menu +============================================================================= + +See :issue:`85918` + +Description +=========== + +A new entry has been added to the context menu. It enables editors to toggle the +`hide in menu` / `show in menu` flag without opening page properties. + +Find it as a child entry of `More Actions`. + +Removing the entry from the menu is possible via User TSconfig with the following setting: + +:typoscript:`options.contextMenu.table.pages.tree.disableItems = hideInMenus,showInMenus` + + +Impact +====== + +Editors will save some clicks when arranging menu structures. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-86670-MakeDefaultActionInDragUploaderAdjustable.rst b/Documentation/Changelog/10.1/Feature-86670-MakeDefaultActionInDragUploaderAdjustable.rst new file mode 100644 index 0000000..3b65a3d --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-86670-MakeDefaultActionInDragUploaderAdjustable.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-86670: + +================================================================ +Feature: #86670 - Make default action in DragUploader adjustable +================================================================ + +See :issue:`86670` + +Description +=========== + +It is now possible to configure the default action for DragUploader in the file list module using User TSConfig. + +.. code-block:: typoscript + + # Set default to replace: + options.file_list.uploader.defaultAction = replace + + # Set default to rename: + options.file_list.uploader.defaultAction = rename + + # Set default to cancel (cancel is also the default and set the option to skip): + options.file_list.uploader.defaultAction = cancel + + +.. index:: Backend, TSConfig, ext:filelist diff --git a/Documentation/Changelog/10.1/Feature-87525-AddApi1OptionInVimeoRenderer.rst b/Documentation/Changelog/10.1/Feature-87525-AddApi1OptionInVimeoRenderer.rst new file mode 100644 index 0000000..f8ad61c --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-87525-AddApi1OptionInVimeoRenderer.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _feature-87525: + +=================================================== +Feature: #87525 - Add api=1 option in VimeoRenderer +=================================================== + +See :issue:`87525` + +Description +=========== + +The parameter api=1 in Vimeo video urls allows API interactions with the video player, +for example adding a button to interact with a video on your page. +The configuration now allows setting this parameter when rendering Vimeo videos in TYPO3. + +Impact +====== + +Setting the parameter :typoscript:`api = 1` either in TypoScript or Fluid will append :html:`api=1` to the Vimeo video URL. + +Usage +===== + +Set the parameter via TypoScript for EXT:fluid_styled_content by using: + +.. code-block:: typoscript + + lib.contentElement.settings.media.additionalConfig.api = 1 + +When using Fluid use the Fluid media ViewHelper and :html:`additionalConfig` to set the argument: + +.. code-block:: html + + <f:media + file="{file}" + alt="{file.properties.alternative}" + title="{file.properties.title}" + additionalConfig="{api: 1}" + /> + + +.. index:: Fluid, TypoScript, ext:fluid_styled_content diff --git a/Documentation/Changelog/10.1/Feature-88318-DisplayApplicationContextInCLI.rst b/Documentation/Changelog/10.1/Feature-88318-DisplayApplicationContextInCLI.rst new file mode 100644 index 0000000..e897d36 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88318-DisplayApplicationContextInCLI.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-88318: + +==================================================== +Feature: #88318 - Display Application Context in CLI +==================================================== + +See :issue:`88318` + +Description +=========== + +The current Application Context is now shown next to the TYPO3 version number in CLI requests. +This makes it easier to check if the correct context is provided. + +Output example: + +.. code-block:: none + + TYPO3 CMS 10.1.0-dev (Application Context: Development/Docker) + +.. index:: CLI, ext:core diff --git a/Documentation/Changelog/10.1/Feature-88441-ShowConfigurationOfUSER_INTObjectsInAdminpanel.rst b/Documentation/Changelog/10.1/Feature-88441-ShowConfigurationOfUSER_INTObjectsInAdminpanel.rst new file mode 100644 index 0000000..d96d2bb --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88441-ShowConfigurationOfUSER_INTObjectsInAdminpanel.rst @@ -0,0 +1,17 @@ +.. include:: /Includes.rst.txt + +.. _feature-88441: + +====================================================================== +Feature: #88441 - Show configuration of USER_INT objects in adminpanel +====================================================================== + +See :issue:`88441` + +Description +=========== + +A new panel "USER_INT" is introduced in the info module of the admin panel, +which lists the basic configuration of each :typoscript:`USER_INT` present on the current page. + +.. index:: ext:adminpanel diff --git a/Documentation/Changelog/10.1/Feature-88602-AllowAdditionalFileProcessors.rst b/Documentation/Changelog/10.1/Feature-88602-AllowAdditionalFileProcessors.rst new file mode 100644 index 0000000..f64ff56 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88602-AllowAdditionalFileProcessors.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-88602: + +============================================================== +Feature: #88602 - Allow registering additional file processors +============================================================== + +See :issue:`88602` + +Description +=========== + +Registering additional file processors has been introduced. +New processors need to implement the interface :php:`\TYPO3\CMS\Core\Resource\Processing\ProcessorInterface`. + +To register a new processor, add the following code to :file:`ext_localconf.php` + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['processors']['MyNewImageProcessor'] = [ + 'className' => \Vendor\ExtensionName\Resource\Processing\MyNewImageProcessor::class, + 'before' => ['LocalImageProcessor'] + ]; + +To order the processors, use `before` and `after` statements. TYPO3 will process the file +with the first processor that is able to process a given task. + +Impact +====== + +Developers are now able to provide their own file processing. By providing priorities, the processor ending up handling +the file can be determined on a fine granular level including a fallback. + +Examples for custom implementations might be: + +* add a watermark to each image of type png +* compress uploaded pdf files into zip archives +* store images that should be cropped at a separate position in the target storage + +.. index:: Backend, ext:core, FAL diff --git a/Documentation/Changelog/10.1/Feature-88742-ImportYamlFilesRelativeToTheCurrentYamlFile.rst b/Documentation/Changelog/10.1/Feature-88742-ImportYamlFilesRelativeToTheCurrentYamlFile.rst new file mode 100644 index 0000000..b42e976 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88742-ImportYamlFilesRelativeToTheCurrentYamlFile.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-88742: + +===================================================================== +Feature: #88742 - Import Yaml files relative to the current yaml file +===================================================================== + +See :issue:`88742` + +Description +=========== + +The configuration language YAML (Yet Another Markup Language) is used to configure rich-text editor +configuration, Form Framework form definitions, and site handling configuration files. + +TYPO3's internal YAML loader has a special handling for cascading and including other YAML files +into the loaded resource via the following syntax: + +.. code-block:: yaml + + imports: + - { resource: "EXT:rte_ckeditor/Configuration/RTE/Processing.yaml" } + + another: + option: true + + +However, the reference to the file was usually handled by referencing other resources in +extensions as in :yaml:`EXT:my_extension/Configuration/MyConfig.yaml`. + +This is now optimized to allow imported resources to include files relative +to the current YAML file: + +.. code-block:: yaml + + imports: + - { resource: "misc/my_options.yaml" } + - { resource: "../path/to/something/within/the/project-folder/generic.yaml" } + + another: + option: true + + +Impact +====== + +Especially when using advanced site handling with multiple sites and similar configuration, it is now +possible to have one base configuration file that is referenced by the specific site configuration files +allowing to keep common config in a single place. + +.. index:: PHP-API, RTE diff --git a/Documentation/Changelog/10.1/Feature-88805-AddTypeToTYPO3CMSCoreDatabaseQueryQueryBuilderset.rst b/Documentation/Changelog/10.1/Feature-88805-AddTypeToTYPO3CMSCoreDatabaseQueryQueryBuilderset.rst new file mode 100644 index 0000000..6025367 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88805-AddTypeToTYPO3CMSCoreDatabaseQueryQueryBuilderset.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-88805: + +==================================================================================== +Feature: #88805 - Add type to \\TYPO3\\CMS\\Core\\Database\\Query\\QueryBuilder::set +==================================================================================== + +See :issue:`88805` + +Description +=========== + +:php:`TYPO3\CMS\Core\Database\Query\QueryBuilder::set()` accepts as additional fourth parameter +a type the query value should be casted to when third parameter (:php:`createNamedParameter`) +is :php:`true`. Per default string (:php:`\PDO::PARAM_STR`) is used. + +Impact +====== + +Type safe query parameter setting is now also possible via :php:`set()`. + +Example: + +.. code-block:: php + + $queryBuilder->set($fieldName, $fieldValue, true, \PDO::PARAM_INT); + +ensures :php:`$fieldValue` is handled as integer type in the resulting database query. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/10.1/Feature-88871-RequestFactoryRespectsGuzzleMiddlewareHandlerConfigurationFromTYPO3_CONF_VARS.rst b/Documentation/Changelog/10.1/Feature-88871-RequestFactoryRespectsGuzzleMiddlewareHandlerConfigurationFromTYPO3_CONF_VARS.rst new file mode 100644 index 0000000..9daa222 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88871-RequestFactoryRespectsGuzzleMiddlewareHandlerConfigurationFromTYPO3_CONF_VARS.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-88871: + +============================================================= +Feature: #88871 - Handle middleware handler in RequestFactory +============================================================= + +See :issue:`88871` + +Description +=========== + +Guzzle offers the possibility to register custom middleware handlers during the client initialization. +With this feature it is now possible to define those custom handlers in :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']` as an array. +The :php:`\TYPO3\CMS\Core\Http\RequestFactory` builds a handler stack based on the +:php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']` array and injects it into the created client. + +Impact +====== + +The default handler stack (guzzle defaults) will be extended and not overwritten. + +Example: +-------- + +.. code-block:: php + + # Add custom middleware to default Guzzle handler stack + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler'][] = + (\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\ACME\Middleware\Guzzle\CustomMiddleware::class))->handler(); + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler'][] = + (\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\ACME\Middleware\Guzzle\SecondCustomMiddleware::class))->handler(); + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-88907-AlwaysEnableFilterInSelectMultipleSideBySideFields.rst b/Documentation/Changelog/10.1/Feature-88907-AlwaysEnableFilterInSelectMultipleSideBySideFields.rst new file mode 100644 index 0000000..5df4c6f --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-88907-AlwaysEnableFilterInSelectMultipleSideBySideFields.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-88907: + +========================================================================= +Feature: #88907 - Always enable filter in SelectMultipleSideBySide fields +========================================================================= + +See :issue:`88907` + +Description +=========== + +The filter functionality of fields :php:`type = select` with :php:`renderType = selectMultipleSideBySide` +is always enabled now. + + +Impact +====== + +Before: + +.. code-block:: php + + 'tsconfig_includes' => [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.tsconfig_includes', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'size' => 10, + 'items' => [], + 'enableMultiSelectFilterTextfield' => true, + 'softref' => 'ext_fileref' + ] + ], + +Now just omit the line :php:`'enableMultiSelectFilterTextfield' => true`, the behaviour will stay the same. + +A migration wizard is available that removes the option from your TCA and leaves a message where code +adaption has to take place. + +.. index:: Backend, TCA, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89010-IntroduceSiteConfigForDistributionPackages.rst b/Documentation/Changelog/10.1/Feature-89010-IntroduceSiteConfigForDistributionPackages.rst new file mode 100644 index 0000000..9c0f112 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89010-IntroduceSiteConfigForDistributionPackages.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-89010: + +======================================================================== +Feature: #89010 - Introduce Site Configuration for Distribution Packages +======================================================================== + +See :issue:`89010` + +Description +=========== + +Distributions or site packages are designed to deliver a full blown TYPO3 instance with all necessary data and assets +to have a functional installation after the package has been activated. +The import of a distribution can now ship the config file (or many, if this is required). + +Similar to assets, that are moved to :file:`fileadmin` ready for use, site configurations are moved into the config folder. + +Impact +====== + +Distributions can now ship their own site configuration files. + +Example: +-------- + +Into the distribution package :file:`Initialisation/Site` folder, put a folder with the site identifier as name, containing the +:file:`config.yaml`. +Each folder will be moved into the target position upon extension activation. + +If a folder with the same name already exists, the file will *not* be overridden. In this case no change is made to the existing configuration. + + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst b/Documentation/Changelog/10.1/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst new file mode 100644 index 0000000..48bc666 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _feature-89018: + +========================================================================== +Feature: #89018 - Provide implementation for PSR-17 HTTP Message Factories +========================================================================== + +See :issue:`89018` + +Description +=========== + +Support for PSR-17_ HTTP Message Factories has been added. + +PSR-17 HTTP Factories are intended to be used by PSR-15_ request handlers in order to create PSR-7_ +compatible message objects. + +PSR-17 consists of six factory interfaces: + +- :php:`\Psr\Http\Message\RequestFactoryInterface` +- :php:`\Psr\Http\Message\ResponseFactoryInterface` +- :php:`\Psr\Http\Message\ServerRequestFactoryInterface` +- :php:`\Psr\Http\Message\StreamFactoryInterface` +- :php:`\Psr\Http\Message\UploadedFileFactoryInterface` +- :php:`\Psr\Http\Message\UriFactoryInterface` + +Request handlers shall use dependency injection to use any of the available PSR-17 HTTP Factory interfaces. + + +Impact +====== + +PSR-17 HTTP Factory interfaces are provided by `psr/http-factory` and should be used as +dependencies for PSR-15 request handlers or services that need to create PSR-7 message objects. + +It is discouraged to explicitly create PSR-7 instances of classes from the :php:`\TYPO3\CMS\Core\Http` +namespace (they are not public API). Use type declarations against PSR-17 HTTP Message Factory interfaces +and dependency injection instead. + +Example usage +------------- + +A middleware that needs to send a JSON response when a certain condition is met, uses the +PSR-17 response factory interface (the concrete TYPO3 implementation is injected as constructor +dependency) to create a new PSR-7 response object: + +.. code-block:: php + + use Psr\Http\Message\ResponseFactoryInterface; + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\MiddlewareInterface; + use Psr\Http\Server\RequestHandlerInterface; + + class StatusCheckMiddleware implements MiddlewareInterface + { + /** @var ResponseFactoryInterface */ + private $responseFactory; + + public function __construct(ResponseFactoryInterface $responseFactory) + { + $this->responseFactory = $responseFactory; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->getRequestTarget() === '/check') { + $data = ['status' => 'ok']; + $response = $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8'); + $response->getBody()->write(json_encode($data)); + return $response; + } + return $handler->handle($request); + } + } + +.. _PSR-17: https://www.php-fig.org/psr/psr-17/ +.. _PSR-15: https://www.php-fig.org/psr/psr-15/ +.. _PSR-7: https://www.php-fig.org/psr/psr-7/ + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89054-ProvideCoreCacheFrontendsViaDependencyInjection.rst b/Documentation/Changelog/10.1/Feature-89054-ProvideCoreCacheFrontendsViaDependencyInjection.rst new file mode 100644 index 0000000..52101d0 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89054-ProvideCoreCacheFrontendsViaDependencyInjection.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _feature-89054: + +======================================================================= +Feature: #89054 - Provide core cache frontends via dependency injection +======================================================================= + +See :issue:`89054` + +Description +=========== + +With TYPO3 v10.0 dependency injection has been introduced. To work with +the cache, currently only the :php:`\TYPO3\CMS\Core\Cache\CacheManager` is available as a service within +the dependency injection container. To foster the „Inversion of Control“ pattern, +the instances of :php:`\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface` should +be injected to the objects rather than using the :php:`\TYPO3\CMS\Core\Cache\CacheManager`. + +Classes should be adapted to avoid :php:`\TYPO3\CMS\Core\Cache\CacheManager` whenever possible. + +The TYPO3 core provides all core caches as dependency injection services. +The name of the service follows the scheme :php:`cache.[CONFIGURATION NAME]`. +E.g. the core cache frontend will have the service id :php:`cache.core`. + +Third party extensions are encouraged to do the same and provide a :php:`cache.my_cache` +service in :file:`Configuration/Services.yaml` for cache configuration they define +in :file:`ext_localconf.php`. + +Usage +===== + +Given a class needs the "my_cache" cache Frontend, then the code before TYPO3 v10.1 +looked like the following example: + +.. code-block:: php + + class MyClass + { + /** + * @var TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + */ + private $cache; + + public function __construct() + { + $cacheManager = GeneralUtility::makeInstance(CacheManager::class); + $this->cache = $cacheManager->getCache('my_cache'); + } + } + +The instance of :php:`\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface` was retrieved by creating an instance +of :php:`\TYPO3\CMS\Core\Cache\CacheManager` and then by calling the :php:`getCache()` method. + +To inject the cache directly, the class needs to be changed as follows. The instance +of :php:`\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface` will be passed as an argument to the constructor. + +.. code-block:: php + + class MyClass + { + /** + * @var TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + */ + private $cache; + + public function __construct(FrontendInterface $cache) + { + $this->cache = $cache; + } + } + +Since the auto-wiring feature of the dependency injection container cannot detect, +which cache configuration should be used for the :php:`$cache` argument, the container +service configuration needs to be extended as well: + +.. code-block:: yaml + + services: + cache.my_cache: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['my_cache'] + + MyClass: + arguments: + $cache: '@cache.my_cache' + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89061-IntroduceNotificationActions.rst b/Documentation/Changelog/10.1/Feature-89061-IntroduceNotificationActions.rst new file mode 100644 index 0000000..ac16e8f --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89061-IntroduceNotificationActions.rst @@ -0,0 +1,95 @@ +.. include:: /Includes.rst.txt + +.. _feature-89061: + +================================================ +Feature: #89061 - Introduce Notification Actions +================================================ + +See :issue:`89061` + +Description +=========== + +Notifications rendered by the :js:`TYPO3/CMS/Backend/Notification` module are now able to render action buttons. Each +notification method (:js:`info()`, :js:`success()` etc) accepts an array of actions, each action is described by a +:js:`label` and a pre-defined action type, containing a callback. + +However, notifications flagged with a duration will still disappear, unless an action is taken. + +.. important:: + + Such tasks must **never** be mandatory to be executed. This API is meant to suggest certain actions without enforcing + them. If a user is supposed to take immediate actions consider using modals instead. + + +Example: + +.. code-block:: js + + require([ + 'TYPO3/CMS/Backend/ActionButton/ImmediateAction', + 'TYPO3/CMS/Backend/ActionButton/DeferredAction', + 'TYPO3/CMS/Backend/Notification' + ], function(ImmediateAction, DeferredAction, Notification) { + const immediateActionCallback = new ImmediateAction(function () { /* your action code */ }); + Notification.info( + 'Great! We are almost done here...', + 'Common default settings have been applied based on your previous input.', + 0, + [ + {label: 'Show settings', action: immediateActionCallback} + ] + ); + }); + + +ImmediateAction +--------------- + +An action of type :js:`ImmediateAction` (:js:`TYPO3/CMS/Backend/ActionButtons/ImmediateAction`) is executed directly on +click and closes the notification. This action type is suitable for e.g. linking to a backend module. + +The class accepts a callback method executing very simple logic. + +Example: + +.. code-block:: js + + const immediateActionCallback = new ImmediateAction(function () { + require(['TYPO3/CMS/Backend/ModuleMenu'], function (ModuleMenu) { + ModuleMenu.App.showModule('web_layout'); + }); + }); + + +DeferredAction +-------------- + +An action of type :js:`DeferredAction` (:js:`TYPO3/CMS/Backend/ActionButtons/DeferredAction`) is recommended when a +long-lasting task is executed, e.g. an AJAX request. + +This class accepts a callback method which must return either a resolved or rejected promise. + +The :js:`DeferredAction` replaces the action button with a spinner icon to indicate a task will take some time. It's +still possible to dismiss a notification, which will **not** stop the execution. + +Example: + +.. code-block:: js + + const deferredActionCallback = new DeferredAction(function () { + const myAction = async function() { + return await 'something'; + } + + return myAction(); + }); + + const anotherDeferredActionCallback = new DeferredAction(function () { + // do some old-fashioned jQuery stuff + return Promise.resolve($.ajax(/* AJAX configuration */)); + }); + + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-89090-ReportsForConflictingRedirects.rst b/Documentation/Changelog/10.1/Feature-89090-ReportsForConflictingRedirects.rst new file mode 100644 index 0000000..629e442 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89090-ReportsForConflictingRedirects.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-89090: + +=================================================== +Feature: #89090 - Reports for conflicting redirects +=================================================== + +See :issue:`89090` + +Description +=========== + +A new Symfony command has been introduced that detects redirects that conflict with pages. The command is marked as +schedulable, thus it can be created as a scheduler task. + + +Impact +====== + +If EXT:scheduler and EXT:reports are installed, the redirect status may be checked and presented as an additional report. + +The command may be executed via CLI by invoking `./typo3/sysext/core/bin/typo3 redirects:checkintegrity`. The command +accepts the option `--site` which takes a site identifier to take the pages of that site into consideration only. + +.. index:: Backend, CLI, ext:redirects diff --git a/Documentation/Changelog/10.1/Feature-89115-Auto-createRedirectsOnSlugChanges.rst b/Documentation/Changelog/10.1/Feature-89115-Auto-createRedirectsOnSlugChanges.rst new file mode 100644 index 0000000..e2834b9 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89115-Auto-createRedirectsOnSlugChanges.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-89115: + +======================================================================= +Feature: #89115 - Auto slug update and redirect creation on slug change +======================================================================= + +See :issue:`89115` + +Description +=========== + +If EXT:redirects is installed and a slug is updated by a backend user, +a redirect from the old URL to the new URL will be created. +All sub pages are checked too and the slugs will be updated. + +After the creation of the redirects a notification will be shown to the user. + +The notification contains two possible actions: + +* revert the complete slug update and remove the redirects +* or only remove the redirects + +This new behaviour can be configured by site configuration (Example for your :file:`config.yaml`): + +.. code-block:: yaml + + settings: + redirects: + # Automatically update slugs of all sub pages + # (default: true) + autoUpdateSlugs: true + # Automatically create redirects for pages with a new slug (works only in LIVE workspace) + # (default: true) + autoCreateRedirects: true + # Time To Live in days for redirect records to be created - `0` disables TTL, no expiration + # (default: 0) + redirectTTL: 30 + # HTTP status code for the redirect, see + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections + # (default: 307) + httpStatusCode: 307 + +.. note:: + + No redirects are generated for workspace versions in the TYPO3 backend. + :yaml:`settings.redirect.autoCreateRedirects` is internally disabled in this case. + +.. attention:: + + This API is considered experimental and may change anytime until declared being stable. + For example there exists plans for moving the settings out of the :file:`config.yaml` file. + +.. index:: Backend, ext:redirects diff --git a/Documentation/Changelog/10.1/Feature-89142-CreateSiteConfigurationIfPageIsCreatedOnRootLevel.rst b/Documentation/Changelog/10.1/Feature-89142-CreateSiteConfigurationIfPageIsCreatedOnRootLevel.rst new file mode 100644 index 0000000..a005ea5 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89142-CreateSiteConfigurationIfPageIsCreatedOnRootLevel.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-89142: + +============================================================================ +Feature: #89142 - Create site configuration if page is created on root level +============================================================================ + +See :issue:`89142` + +Description +=========== + +When creating a typical new page on the root level of a TYPO3 installation, a new site configuration is now +automatically created as well. This makes it easier to work with multi-sites and get a basic configuration set up +more quickly than before. + +Under the hood, a new :php:`DataHandler` hook checks for new pages being one of the following page types: + +* Default pages +* Links +* Shortcuts + +The entry point consists of the current domain where the configuration has been created, plus a short identifier using +the page uid and the prefix "site", e.g. `https://example.com/site-42`. + +The identifier of the site uses the entry point without the domain, and a MD5 hash of the page id to avoid potential +conflicts for existing site configurations. An identifier may look like `site-42-a1d0c6e83f`. + +Impact +====== + +A new site configuration with a pre-defined identifier, entry point and a default language gets created automatically. + +Ideally, there are no scenarios anymore where a site needs to be created after a first page is created, avoiding +any issues related to Slug handling for root pages, which are always set to `/` by default. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89143-AllowRollbackForASetOfRecordHistoryEntries.rst b/Documentation/Changelog/10.1/Feature-89143-AllowRollbackForASetOfRecordHistoryEntries.rst new file mode 100644 index 0000000..e5b4741 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89143-AllowRollbackForASetOfRecordHistoryEntries.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-89143: + +==================================================================== +Feature: #89143 - Allow rollback for a set of record history entries +==================================================================== + +See :issue:`89143` + +Description +=========== + +To allow rollbacks for a set of record history entries, it is now possible to add a correlationId +while creating the RecordHistory entry. The correlationId should be an UUID but could also be any +string which is useful to identify a set of entries. + +To use this feature, an additional parameter :php:`$correlationId` has been added to the following methods of +:php:`\TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore`: + +* :php:`addRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null)` +* :php:`modifyRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null)` +* :php:`deleteRecord(string $table, int $uid, CorrelationId $correlationId = null)` +* :php:`undeleteRecord(string $table, int $uid, CorrelationId $correlationId = null)` +* :php:`moveRecord(string $table, int $uid, array $payload, CorrelationId $correlationId = null)` + +To resolve all entries for a given :php:`$correlationId` a new method has been added to the +:php:`\TYPO3\CMS\Core\DataHandling\History\RecordHistory` class: + +* :php:`\TYPO3\CMS\Core\DataHandling\History\RecordHistory::findEventsForCorrelation(string $correlationId): array` + + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-89150-AddEventsBeforeAndAfterRollbackOfRecordHistoryEntries.rst b/Documentation/Changelog/10.1/Feature-89150-AddEventsBeforeAndAfterRollbackOfRecordHistoryEntries.rst new file mode 100644 index 0000000..0e92b0e --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89150-AddEventsBeforeAndAfterRollbackOfRecordHistoryEntries.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-89150: + +================================================================================ +Feature: #89150 - Add events before and after rollback of record history entries +================================================================================ + +See :issue:`89150` + +Description +=========== + +Two new events have been introduced into record history that will be dispatched before and after a revert action. + +* :php:`\TYPO3\CMS\Backend\History\Event\BeforeHistoryRollbackStartEvent` before the rollback starts +* :php:`\TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent` after the rollback finished + +Both events resolve some information about the :php:`RecordHistory` item: + +* :php:`getRecordHistoryRollback()` returns the :php:`\TYPO3\CMS\Backend\History\RecordHistoryRollback` object +* :php:`getRollbackFields()` returns a string with the rollback fields +* :php:`getDiff()` returns an array with the differences +* :php:`getBackendUserAuthentication()` returns a :php:`\TYPO3\CMS\Backend\BackendUserAuthentication` object, which is used for this rollback operation + +Additionally the :php:`\TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent` gets the DataHandler input data: + +* :php:`getDataHandlerInput()` returns an array with the :php:`DataHandler` instructions. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-89216-PSR-18HTTPClientImplementation.rst b/Documentation/Changelog/10.1/Feature-89216-PSR-18HTTPClientImplementation.rst new file mode 100644 index 0000000..9bb8913 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89216-PSR-18HTTPClientImplementation.rst @@ -0,0 +1,111 @@ +.. include:: /Includes.rst.txt + +.. _feature-89216: + +=================================================== +Feature: #89216 - PSR-18 HTTP Client Implementation +=================================================== + +See :issue:`89216` + +Description +=========== + +Support for PSR-18_ HTTP Client has been added. + +PSR-18 HTTP Client is intended to be used by PSR-15_ request handlers in order to perform HTTP +requests based on PSR-7_ message objects without relying on a specific HTTP client implementation. + +PSR-18 consists of a client interfaces and three exception interfaces: + +- :php:`\Psr\Http\Client\ClientInterface` +- :php:`\Psr\Http\Client\ClientExceptionInterface` +- :php:`\Psr\Http\Client\NetworkExceptionInterface` +- :php:`\Psr\Http\Client\RequestExceptionInterface` + +Request handlers shall use dependency injection to retrieve the concrete implementation +of the PSR-18 HTTP client interface :php:`\Psr\Http\Client\ClientInterface`. + + +Impact +====== + +The PSR-18 HTTP Client interface is provided by `psr/http-client` and may be used as +dependency for services in order to perform HTTP requests using PSR-7 request objects. +PSR-7 request objects can be created with the PSR-17_ Request Factory interface. + +Note: This does not replace the currently available Guzzle wrapper +:php:`\TYPO3\CMS\Core\Http\RequestFactory->request()`, but is available as a framework +agnostic, more generic alternative. The PSR-18 interface does not allow to pass request +specific guzzle options. But global options defined in :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']` +are taken into account as GuzzleHTTP is used as backend for this PSR-18 implementation. +The concrete implementations is internal and will be replaced by a native guzzle PSR-18 +implementation once it is available. + +Example usage +------------- + +A middleware might need to request an external service in order to transform the response +into a new response. The PSR-18 HTTP client interface is used to perform the external +HTTP request. The PSR-17 Request Factory Interface is used to create the HTTP request that +the PSR-18 HTTP Client expects. The PSR-7 Response Factory is then used to create a new +response to be returned to the user. All off these interface implementations are injected +as constructor dependencies: + +.. code-block:: php + + use Psr\Http\Client\ClientInterface; + use Psr\Http\Message\RequestFactoryInterface; + use Psr\Http\Message\ResponseFactoryInterface; + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\MiddlewareInterface; + use Psr\Http\Server\RequestHandlerInterface; + + class ExampleMiddleware implements MiddlewareInterface + { + /** @var ResponseFactory */ + private $responseFactory; + + /** @var RequestFactory */ + private $requestFactory; + + /** @var ClientInterface */ + private $client; + + public function __construct( + ResponseFactoryInterface $responseFactory, + RequestFactoryInterface $requestFactory, + ClientInterface $client + ) { + $this->responseFactory = $responseFactory; + $this->requestFactory = $requestFactory; + $this->client = $client; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->getRequestTarget() === '/example') { + $req = $this->requestFactory->createRequest('GET', 'https://api.external.app/endpoint.json') + // Perform HTTP request + $res = $this->client->sendRequest($req); + // Process data + $data = [ + 'content' => json_decode((string)$res->getBody()); + ]; + $response = $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8'); + $response->getBody()->write(json_encode($data)); + return $response; + } + return $handler->handle($request); + } + } + + +.. _PSR-18: https://www.php-fig.org/psr/psr-18/ +.. _PSR-17: https://www.php-fig.org/psr/psr-17/ +.. _PSR-15: https://www.php-fig.org/psr/psr-15/ +.. _PSR-7: https://www.php-fig.org/psr/psr-7/ + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89227-AskForEmailAddressWhileInstallingTYPO3.rst b/Documentation/Changelog/10.1/Feature-89227-AskForEmailAddressWhileInstallingTYPO3.rst new file mode 100644 index 0000000..c32b8ff --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89227-AskForEmailAddressWhileInstallingTYPO3.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _feature-89227: + +============================================================== +Feature: #89227 - Ask for email address while installing TYPO3 +============================================================== + +See :issue:`89227` + +Description +=========== + +It is now possible to enter an email address for the first admin user while installing TYPO3. + +Within the install process, an admin user will be created by entering a username and password. +The user is asked for the email address as well, so this can be used +later on to e.g. notify the admin if somebody logged-in (Warning email address). + +This will also work in the install tool Maintenance module "Create Administrative User" card. + +.. index:: Backend, ext:install diff --git a/Documentation/Changelog/10.1/Feature-89229-CachePresetForSettingsInMaintenanceArea.rst b/Documentation/Changelog/10.1/Feature-89229-CachePresetForSettingsInMaintenanceArea.rst new file mode 100644 index 0000000..246acbc --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89229-CachePresetForSettingsInMaintenanceArea.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-89229: + +=============================================================== +Feature: #89229 - Cache Preset for Settings in Maintenance Area +=============================================================== + +See :issue:`89229` + +Description +=========== + +The maintenance area available in TYPO3 Backend under "Admin Tools" => "Settings" now also allows to quickly switch +between Cache Backends for the Caching Framework. + +This allows to select different settings without having to manually modify the :file:`LocalConfiguration.php` file. + + +Impact +====== + +Depending on a server setup, it might be easier to quickly see differences when running on a distributed Database system +(not localhost) or on a distributed file system to choose between caching options. + +In addition, the most common caches are explained in what is stored there. Further configuration can be applied by +manually modifying the :file:`LocalConfiguration.php` settings file. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/10.1/Feature-89244-BroadcastChannels.rst b/Documentation/Changelog/10.1/Feature-89244-BroadcastChannels.rst new file mode 100644 index 0000000..a18bb13 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89244-BroadcastChannels.rst @@ -0,0 +1,99 @@ +.. include:: /Includes.rst.txt + +.. _feature-89244: + +================================================== +Feature: #89244 - Broadcast Channels and Messaging +================================================== + +See :issue:`89244` + +Description +=========== + +It is now possible to send broadcast messages from anywhere in TYPO3 that are listened to via JavaScript. + +.. warning:: + + This API is considered internal and may change anytime until declared being stable. + + +Send a message +-------------- + +Any backend module may send a message using the :js:`TYPO3/CMS/Backend/BroadcastService` module. +The payload of such message is an object that consists at least of the following properties: + +* :js:`componentName` - the name of the component that sends the message (e.g. extension name) +* :js:`eventName` - the event name used to identify the message + +A message may contain any other property as necessary. The final event name to listen is a composition of "typo3", the +component name and the event name, e.g. `typo3:my_extension:my_event`. + +.. attention:: + + Since a polyfill is in place to add support for Microsoft Edge, the payload must contain JSON-serializable content + only. + + +To send a message, the :js:`post()` method has to be used. + +Example code: + +.. code-block:: js + + require(['TYPO3/CMS/Backend/BroadcastService'], function (BroadcastService) { + const payload = { + componentName: 'my_extension', + eventName: 'my_event', + hello: 'world', + foo: ['bar', 'baz'] + }; + + BroadcastService.post(payload); + }); + + +Receive a message +----------------- + +To receive and thus react on a message, an event handler needs to be registered that listens to the composed event +name (e.g. `typo3:my_component:my_event`) sent to :js:`document`. + +The event itself contains a property called `detail` **excluding** the component name and event name. + +Example code: + +.. code-block:: js + + define([], function() { + document.addEventListener('typo3:my_component:my_event', (e) => eventHandler(e.detail)); + + function eventHandler(detail) { + console.log(detail); // contains 'hello' and 'foo' as sent in the payload + } + }); + + +Hook into :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['constructPostProcess']` to load a custom +:php:`BackendController` hook that loads the event handler, e.g. via RequireJS. + +Example code: + +.. code-block:: php + + // ext_localconf.php + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['constructPostProcess'][] + = \Vendor\MyExtension\Hooks\BackendControllerHook::class . '->registerClientSideEventHandler'; + + // Classes/Hooks/BackendControllerHook.php + class BackendControllerHook + { + public function registerClientSideEventHandler(): void + { + $pageRenderer = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Page\PageRenderer::class); + $pageRenderer->loadRequireJsModule('TYPO3/CMS/MyExtension/EventHandler'); + } + } + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.1/Feature-89292-AddSupportForRecordHistoryCorrelationIdsToDataHandler.rst b/Documentation/Changelog/10.1/Feature-89292-AddSupportForRecordHistoryCorrelationIdsToDataHandler.rst new file mode 100644 index 0000000..8390ec0 --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-89292-AddSupportForRecordHistoryCorrelationIdsToDataHandler.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-89292: + +============================================================================== +Feature: #89292 - Add support for RecordHistory correlationId's to DataHandler +============================================================================== + +See :issue:`89292` + +Description +=========== + +With :issue:`89143` a new feature for correlation ids in :php:`\TYPO3\CMS\Backend\History\RecordHistory` was introduced. +:php:`DataHandler` now also supports this feature by setting the :php:`$correlationId` with its instance. + +.. code-block:: php + + $correlationId = CorrelationId::forSubject( + md5(StringUtility::getUniqueId('slug_')) + ); + $data['pages'][$uid]['slug'] = $newSlug; + // create new DataHandler instance + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($data, []); + // DataHandler::start assigns internal correlation id scope + // which will be overridden in this example by the next line + $dataHandler->setCorrelationId($correlationId); + // actually process and persist data + $dataHandler->process_datamap(); + +After this DataHandler operation, the created RecordHistory entry contains the :php:`$correlationId`. + +:php:`CorrelationId` model requires mandatory :php:`$subject` and allows optional :php:`$aspects` which +can be serialized into string like e.g. `0400$12ae0b042a5d75e3f2744f4b3faf8068/5d8e6e70/slug` + +* `0400$` is a flag prefix containing an internal version number for possible schema validations +* `12ae0b042a5d75e3f2744f4b3faf8068` is a unique subject +* `/5d8e6e70/slug` are aspects, separated by slashes + +.. index:: Backend, Database, PHP-API, ext:core diff --git a/Documentation/Changelog/10.1/Feature-9070-AllowTranslationOfIndexConfigurationTitles.rst b/Documentation/Changelog/10.1/Feature-9070-AllowTranslationOfIndexConfigurationTitles.rst new file mode 100644 index 0000000..955812a --- /dev/null +++ b/Documentation/Changelog/10.1/Feature-9070-AllowTranslationOfIndexConfigurationTitles.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-9070: + +================================================================ +Feature: #9070 - Allow translation of index configuration titles +================================================================ + +See :issue:`9070` + +Description +=========== + +Indexed search plugin allows to select specifically which index configuration to be queried from in the specific form, +where a dropdown shows all possible indexing configurations. + + +Impact +====== + +It is now possible to add a label for each configuration via TypoScript in each language. + +.. code-block:: typoscript + + plugin.tx_indexedsearch.settings._LOCAL_LANG { + de.indexingConfigurations.13 = Mein Titel in Deutsch für Konfiguration 13 + de.indexingConfigurationHeader.13 = Alle Ergebnisse für Konfiguration 13 + } + +.. index:: Backend, ext:indexed_search diff --git a/Documentation/Changelog/10.1/Important-89001-TSFE-createHashBase.rst b/Documentation/Changelog/10.1/Important-89001-TSFE-createHashBase.rst new file mode 100644 index 0000000..bc6794a --- /dev/null +++ b/Documentation/Changelog/10.1/Important-89001-TSFE-createHashBase.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-89001: + +======================================== +Important: #89001 - TSFE->createHashBase +======================================== + +See :issue:`89001` + +Description +=========== + +The method :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->createHashBase()` +calculates all components that are relevant for a specific cached version of a page. + +With TYPO3 v10.1, the keys in :php:`$hashParameters` used for calculating the hash have been modified: + +- `gr_list` has been replaced by `groupIds` but contains the same values +- `cHash` has been replaced by `dynamicArguments` but contains the same values +- `domainStartPage` has been replaced by `site` (identifier of the site) + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/10.1/Important-89122-UnifiedEvaluationOfVersionedRecordsInWorkspaces.rst b/Documentation/Changelog/10.1/Important-89122-UnifiedEvaluationOfVersionedRecordsInWorkspaces.rst new file mode 100644 index 0000000..d562e35 --- /dev/null +++ b/Documentation/Changelog/10.1/Important-89122-UnifiedEvaluationOfVersionedRecordsInWorkspaces.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _important-89122: + +========================================================================= +Important: #89122 - Unified evaluation of versioned records in workspaces +========================================================================= + +See :issue:`89122` + +Description +=========== + +TYPO3 Core handled the result of database queries in a lot of different ways to filter out workspace records. +In previous versions, where versioned records without workspaces (incremental versions) was supported, the main +identifier was always to check for records that are "Offline" - by checking via the "pid" field of database records, +that they are set to "-1". + +With workspaces, there are other, better ways to identify versioned via the following fields: + +- t3ver_state (what kind of versioned record it is - new version, moved record, deleted version) +- t3ver_oid (if the versioned record points to a live record) +- t3ver_wsid (the workspace ID, a relation to a sys_workspace record) + +The "pid" field was kept as misuse, but fine for most of the database queries. With the unified database abstraction +layer based on Doctrine DBAL and enriched via Query Restrictions, TYPO3 Core now checks for t3ver_state, t3ver_wsid +and t3ver_oid to identify versioned records. + +This is already achieved with any database query by using the WorkspaceRestrictions in place. Extension authors +should use Doctrine DBAL and apply workspace restrictions by default. + +If this is not possible when building custom queries without restrictions, it is recommended to check for: + +- t3ver_oid>0 = identifying a versioned record that has a counterpart in the live workspace +- t3ver_wsid=13 - identifying a versioned record or placeholder that resides in a specific workspace (in this case "13") +- t3ver_state IN (0,-1) AND t3ver_wsid IN (0,13) - to fetch records including "new record" placeholders + +Checking for "pid = -1" is not recommended anymore - using the restrictions and custom query information can be +used in previous TYPO3 versions already. + + +.. index:: Database, ext:workspaces diff --git a/Documentation/Changelog/10.1/Index.rst b/Documentation/Changelog/10.1/Index.rst new file mode 100644 index 0000000..788a02a --- /dev/null +++ b/Documentation/Changelog/10.1/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-1: + +10.1 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +None since TYPO3 v10.0 release. + +.. attention:: + + After TYPO3 v10.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v10.0 release on the way to LTS. + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/10.2/Deprecation-85592-DeprecatedSiteTitleConfiguration.rst b/Documentation/Changelog/10.2/Deprecation-85592-DeprecatedSiteTitleConfiguration.rst new file mode 100644 index 0000000..6ebe852 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-85592-DeprecatedSiteTitleConfiguration.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-85592: + +========================================================= +Deprecation: #85592 - Deprecated site title configuration +========================================================= + +See :issue:`85592` + +Description +=========== + +Defining the site title in the sys_template record (`sys_template.sitetitle` field) has been deprecated and should not be +used any longer. This field (database and TCA) will be removed in v11. + + +Impact +====== + +The field will be removed in version 11. In version 10 the site title in the sys_template will be used as a +fallback when no Site title is set in the site configuration. + + +Affected Installations +====================== + +Instances defining the site title in the sys_template record. + + +Migration +========= + +Copy the site title to the new available field in the site module language configuration. + +.. index:: Frontend, NotScanned diff --git a/Documentation/Changelog/10.2/Deprecation-88238-AllowedMimeTypesOfFileUploadAndImageUpload.rst b/Documentation/Changelog/10.2/Deprecation-88238-AllowedMimeTypesOfFileUploadAndImageUpload.rst new file mode 100644 index 0000000..23a109d --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-88238-AllowedMimeTypesOfFileUploadAndImageUpload.rst @@ -0,0 +1,99 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88238: + +====================================================================== +Deprecation: #88238 - Allowed MIME types of FileUpload and ImageUpload +====================================================================== + +See :issue:`88238` + +Description +=========== + +The predefined :yaml:`allowedMimeTypes` of the :yaml:`FileUpload` and :yaml:`ImageUpload` form elements are deprecated and should not be relied on any longer. These will be removed in TYPO3v11. + +The "form" extension setup did contain some predefined MIME types for the elements :yaml:`FileUpload` and :yaml:`ImageUpload`: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + FileUpload: + properties: + allowedMIMETypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.oasis.opendocument.text', 'application/pdf'] + + ImageUpload: + properties: + allowedMIMETypes: ['image/jpeg', 'image/png', 'image/bmp'] + + +Predefined values like this are used as starting values while the form element is created and later on, values from the form definition are merged. + +Thus, a form definition like this: + +.. code-block:: yaml + + type: Form + identifier: test-1 + label: test + prototypeName: standard + renderables: + - + type: Page + identifier: page-1 + label: Step + renderables: + - + type: FileUpload + identifier: fileupload-1 + label: 'File upload' + properties: + saveToFileMount: '1:/user_upload/' + allowedMIMETypes: + - application/pdf + + +... resulted in a final form element definition like this: + +.. code-block:: yaml + + type: FileUpload + identifier: fileupload-1 + label: 'File upload' + properties: + saveToFileMount: '1:/user_upload/' + allowedMIMETypes: + - application/msword + - application/vnd.openxmlformats-officedocument.wordprocessingml.document + - application/vnd.oasis.opendocument.text + - application/pdf + + +The expected behavior was that only files of type :code:`application/pdf` are accepted, but actually all preconfigured MIME types within the ext:form setup were also valid. + +To make the MIME type validation of :yaml:`FileUpload` and :yaml:`ImageUpload` more strict, the preconfigured MIME types have been deprecated and will be removed in TYPO3v11. + + +Impact +====== + +The predefined MIME types will be removed in version 11. In version 10 the feature toggle :code:`form.legacyUploadMimeTypes` can be disabled to enforce the new behavior. + + +Affected Installations +====================== + +Instances which use the "form" extension with :yaml:`FileUpload` or :yaml:`ImageUpload` form elements. + + +Migration +========= + +Explicitly list all valid MIME types in :yaml:`allowedMimeTypes` within your form definition. Afterwards disable the :code:`form.legacyUploadMimeTypes` feature flag. + +.. index:: Frontend, NotScanned, ext:form diff --git a/Documentation/Changelog/10.2/Deprecation-89331-FormEngineLegacyFunctions.rst b/Documentation/Changelog/10.2/Deprecation-89331-FormEngineLegacyFunctions.rst new file mode 100644 index 0000000..1128980 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89331-FormEngineLegacyFunctions.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89331: + +================================================= +Deprecation: #89331 - FormEngine legacy functions +================================================= + +See :issue:`89331` + +Description +=========== + +The FormEngine supports global callback functions executed on certain interactions. Such functions were overridden and +spread through some extensions which are not related to FormEngine at all. + +These functions have been marked as deprecated: + +* :js:`setFormValueOpenBrowser()` +* :js:`setFormValueFromBrowseWin()` +* :js:`setHiddenFromList()` +* :js:`setFormValueManipulate()` +* :js:`setFormValue_getFObj()` + +The function :js:`setFormValueFromBrowseWin()` is also called by `ElementBrowser`. Extensions not related to FormEngine +are able to override this function and inject custom handling. This approach has been marked as deprecated as well. + + +Impact +====== + +Calling a deprecated function will trigger a warning in the browser console. + + +Affected Installations +====================== + +All installations using 3rd party extensions calling any of these deprecated functions are affected. + + +Migration +========= + +Some functions can be used in FormEngine context only from now on. Load the module `TYPO3/CMS/Backend/FormEngine` and +use the according replacements: + +* :js:`setFormValueOpenBrowser()` - use :js:`FormEngine.openPopupWindow()` instead +* :js:`setFormValueFromBrowseWin()` - use :js:`FormEngine.setSelectOptionFromExternalSource()` instead +* :js:`setHiddenFromList()` - use :js:`FormEngine.updateHiddenFieldValueFromSelect()` instead +* :js:`setFormValueManipulate()` - no replacement, this is internal logic for form controls separated into according modules +* :js:`setFormValue_getFObj()` - use :js:`FormEngine.getFormElement()` instead + +If :js:`setFormValueFromBrowseWin()` is not used within a FormEngine context, it is possible to listen to the +:js:`message` event. + +Example code: + +.. code-block:: js + + require(['TYPO3/CMS/Backend/Utility/MessageUtility'], function (MessageUtility) { + window.addEventListener('message', function (e) { + // MessageUtility.MessageUtility is correct as this is not an AMD module + if (!MessageUtility.MessageUtility.verifyOrigin(e.origin)) { + throw 'Denied message sent by ' + e.origin; + } + + if (typeof e.data.fieldName === 'undefined') { + throw 'fieldName not defined in message'; + } + + if (typeof e.data.value === 'undefined') { + throw 'value not defined in message'; + } + + const result = e.data.value.split('_'); + const field = <HTMLInputElement>document.querySelector('input[name="' + e.data.fieldName + '"]'); + field.value = result[1]; + }); + } + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.2/Deprecation-89468-DeprecateInjectionOfEnvironmentServiceInWebRequest.rst b/Documentation/Changelog/10.2/Deprecation-89468-DeprecateInjectionOfEnvironmentServiceInWebRequest.rst new file mode 100644 index 0000000..578cb90 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89468-DeprecateInjectionOfEnvironmentServiceInWebRequest.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89468: + +============================================================================== +Deprecation: #89468 - Deprecate injection of EnvironmentService in Web Request +============================================================================== + +See :issue:`89468` + +Description +=========== + +The EnvironmentService is not needed any longer in the Web +Request of Extbase, therefore the property and the injection +method of said property have been marked as deprecated. + + +Impact +====== + +As of TYPO3 11.0, the property :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response::$environmentService` will no longer exist. If the +environment service is needed in a subclass of :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response`, it needs to be injected +manually. + + +Affected Installations +====================== + +All installations that implement subclasses of :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response` and expect an instance of the +:php:`EnvironmentService` to be injected into :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response::$environmentService`. + + +Migration +========= + +The environment service needs to be injected manually in the subclass. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.2/Deprecation-89554-DeprecateTYPO3CMSExtbaseMvcControllerAbstractController.rst b/Documentation/Changelog/10.2/Deprecation-89554-DeprecateTYPO3CMSExtbaseMvcControllerAbstractController.rst new file mode 100644 index 0000000..48f25be --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89554-DeprecateTYPO3CMSExtbaseMvcControllerAbstractController.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89554: + +========================================================================================== +Deprecation: #89554 - Deprecate \\TYPO3\\CMS\\Extbase\\Mvc\\Controller\\AbstractController +========================================================================================== + +See :issue:`89554` + +Description +=========== + +The class :php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController` has been marked as deprecated. + +The :php:`AbstractController` is an internal class which never really had any functionality besides +providing some basic methods for the :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController`. Therefore +and in order to streamline the codebase of extbase, the :php:`AbstractController` will be removed +with TYPO3 11.0. + + +Impact +====== + +As all functionality of the :php:`AbstractController` has been moved to the :php:`ActionController` there is no impact +for extbase extensions that used and extended the :php:`ActionController`. + + +Affected Installations +====================== + +Installations that extended the :php:`AbstractController` directly. + + +Migration +========= + +Extend the :php:`ActionController`. + + +.. index:: PHP-API, PartiallyScanned, ext:extbase diff --git a/Documentation/Changelog/10.2/Deprecation-89577-FALSignalSlotHandlingMigratedToPSR-14Events.rst b/Documentation/Changelog/10.2/Deprecation-89577-FALSignalSlotHandlingMigratedToPSR-14Events.rst new file mode 100644 index 0000000..1f4fc60 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89577-FALSignalSlotHandlingMigratedToPSR-14Events.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89577: + +======================================================================= +Deprecation: #89577 - FAL SignalSlot handling migrated to PSR-14 events +======================================================================= + +See :issue:`89577` + +Description +=========== + +Within the File Abstraction Layer, all "Signals" of Extbase's SignalSlot dispatcher have been migrated to PSR-14 events. + +For this reason, all FAL-related Signals have been migrated to PSR-14 event listeners which are prioritized as the +first listener to be executed when an Event is fired. + +The following interface has been deprecated and will be removed in TYPO3 v11: + +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface` + +The following constants have been deprecated and will be removed in TYPO3 v11: + +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PreProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PostProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreGeneratePublicUrl` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_SanitizeFileName` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PreFileProcess` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PostFileProcess` + +Impact +====== + +Calling the Signals still works as before, without any deprecation message triggered in order to still be fully working. +However, they will likely be removed and stop working in TYPO3 v11.0. + +All interfaces and constants which only existed for Signal-Slot related handling have been marked as deprecated. +The ExtensionScanner will detect any usages of the PHP symbols. + + +Affected Installations +====================== + +TYPO3 installations with extensions that hook into FAL-related functionality, e.g. "secure downloads" extension. + + +Migration +========= + +It is highly recommended to use the PSR-14 events and create custom event listeners and not depend on Signals to be +executed in FAL anymore. + +See all core examples, read the documentation about PSR-14 events and investigate especially the :php:`SlotReplacement` +PHP class on what can listened and modified. + +.. index:: FAL, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.2/Deprecation-89579-ServiceChainsRequireAnArrayForExcludedServiceKeys.rst b/Documentation/Changelog/10.2/Deprecation-89579-ServiceChainsRequireAnArrayForExcludedServiceKeys.rst new file mode 100644 index 0000000..4690117 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89579-ServiceChainsRequireAnArrayForExcludedServiceKeys.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89579: + +============================================================================== +Deprecation: #89579 - ServiceChains require an array for excluded Service keys +============================================================================== + +See :issue:`89579` + +Description +=========== + +The Service API within :php:`GeneralUtility::makeInstanceService()` and +:php:`ExtensionManagementUtility::findService()` has a third argument called +:php:`$excludeServiceKeys` which is used for skipping certain services when +using a chain. + +The third argument could previously be a comma-separated list +or an array. The argument now requires an array for consistency +and performance reasons. + +Handing in comma-separated value strings is deprecated and will +be removed in TYPO3 v11.0. + + +Impact +====== + +Calling any of the methods above with a non-array as third argument +will trigger a deprecation notice. + + +Affected Installations +====================== + +Any TYPO3 installation with custom extensions using the Service API +directly. Extensions that ship a custom authentication provider +are not affected. + + +Migration +========= + +Ensure to hand in an array as third argument. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/10.2/Deprecation-89631-UseEnvironmentAPIToFetchApplicationContext.rst b/Documentation/Changelog/10.2/Deprecation-89631-UseEnvironmentAPIToFetchApplicationContext.rst new file mode 100644 index 0000000..03ae9d7 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89631-UseEnvironmentAPIToFetchApplicationContext.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89631: + +====================================================================== +Deprecation: #89631 - Use Environment API to fetch application context +====================================================================== + +See :issue:`89631` + +Description +=========== + +The Environment API, introduced in TYPO3 v9.3, allows access to the current Application Context (Production, Testing or Development). + +The method :php:`GeneralUtility::getApplicationContext()` has been deprecated, as the same information is now available in :php:`TYPO3\CMS\Core\Core\Environment::getContext()`. + + +Impact +====== + +Calling the GeneralUtility method will trigger a PHP deprecation warning. + + +Affected Installations +====================== + +Any TYPO3 installation with a third-party extension calling the method directly. + + +Migration +========= + +Use the Environment API call and substitute the method directly. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.2/Deprecation-89718-LegacyPageTSconfigParsingLowlevelAPI.rst b/Documentation/Changelog/10.2/Deprecation-89718-LegacyPageTSconfigParsingLowlevelAPI.rst new file mode 100644 index 0000000..91bcaf5 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89718-LegacyPageTSconfigParsingLowlevelAPI.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89718: + +============================================================== +Deprecation: #89718 - Legacy PageTSconfig parsing lowlevel API +============================================================== + +See :issue:`89718` + +Description +=========== + +Two new PHP API classes for retrieving and parsing TsConfig are +introduced: + +- :php:`TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` +- :php:`TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` + +As this API is more consistent, and flexible, as well as agnostic +of the current Context of backend or frontend, the following +functionality has been marked as deprecated: + +- :php:`TYPO3\CMS\Core\Configuration\TsConfigParser` +- :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getRawPagesTSconfig()` + + +Impact +====== + +Instantiating the PHP class or the mentioned PHP method will trigger +a deprecation message. + + +Affected Installations +====================== + +TYPO3 Installations with extensions using the lowlevel API for handling PageTSconfig. + + +Migration +========= + +Loading and parsing PageTSconfig on a low-level should be done via the new PHP classes: + +- :php:`TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` +- :php:`TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` + +Usages for fetching all available PageTS of a page/rootline in one large string: + +.. code-block:: php + + $loader = GeneralUtility::makeInstance(PageTsConfigLoader::class); + $tsConfigString = $loader->load($rootLine); + + +The string is parsed (and conditions are applied) with the Parser: + +.. code-block:: php + + $parser = GeneralUtility::makeInstance( + PageTsConfigParser::class, + $typoScriptParser, + $hashCache + ); + $pagesTSconfig = $parser->parse( + $tsConfigString, + $conditionMatcher + ); + +Extension developers should rely on this syntax rather than +on :php:`$GLOBALS['TSFE']->getPagesTSconfig()` or :php:`BackendUtility::getPagesTsConfig()`, or the deprecated method / class. + +.. index:: PHP-API, TSConfig, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.2/Deprecation-89722-GMENU_LAYERSRelatedPropertyTSFE-divSection.rst b/Documentation/Changelog/10.2/Deprecation-89722-GMENU_LAYERSRelatedPropertyTSFE-divSection.rst new file mode 100644 index 0000000..f8359f3 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89722-GMENU_LAYERSRelatedPropertyTSFE-divSection.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89722: + +==================================================================== +Deprecation: #89722 - GMENU_LAYERS related property TSFE->divSection +==================================================================== + +See :issue:`89722` + +Description +=========== + +The public PHP property :php:`TypoScriptFrontendController->divSection` has been marked as deprecated. This was used in prior +TYPO3 versions to add dynamic JavaScript related to GMENU_LAYERS +functionality which was removed with previous TYPO3 versions, making +this property only produce unnecessary overhead in frontend rendering. + + +Impact +====== + +Accessing or setting this property will trigger a deprecation notice. + + +Affected Installations +====================== + +TYPO3 installations with extensions explicitly accessing this property, which is highly unlikely as this property is very lowlevel. + + +Migration +========= + +If there is a need to add JavaScript within uncached content, use +:php:`$GLOBALS['TSFE']->additionalHeaderData[]` instead. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.2/Deprecation-89733-SignalSlotsInCoreExtensionMigratedToPSR-14Events.rst b/Documentation/Changelog/10.2/Deprecation-89733-SignalSlotsInCoreExtensionMigratedToPSR-14Events.rst new file mode 100644 index 0000000..1f8e300 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89733-SignalSlotsInCoreExtensionMigratedToPSR-14Events.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89733: + +============================================================================== +Deprecation: #89733 - Signal Slots in Core Extension migrated to PSR-14 events +============================================================================== + +See :issue:`89733` + +Description +=========== + +The following Signal Slots have been replaced by new PSR-14 events +which can be used as 1:1 equivalents: + +- :php:`TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::getSystemInformation` +- :php:`TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::loadMessages` +- :php:`TYPO3\CMS\Backend\LoginProvider\UsernamePasswordLoginProvider::getPageRenderer` +- :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::preInitAfter` +- :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::initAfter` +- :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfigPreInclude` +- :php:`TYPO3\CMS\Beuser\Controller\BackendUserController::switchUser` +- :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex::setTypoLinkPartsElement` +- :php:`TYPO3\CMS\Core\Database\ReferenceIndex::shouldExcludeTableFromReferenceIndex` +- :php:`TYPO3\CMS\Core\Imaging\IconFactory::buildIconForResourceSignal` +- :php:`TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider::PostProcessTreeData` +- :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::tcaIsBeingBuilt` +- :php:`TYPO3\CMS\Impexp\Utility\ImportExportUtility::afterImportExportInitialisation` +- :php:`TYPO3\CMS\Install\Service\SqlExpectedSchemaService::tablesDefinitionIsBeingBuilt` +- :php:`TYPO3\CMS\Lang\Service\TranslationService::postProcessMirrorUrl` +- :php:`TYPO3\CMS\Linkvalidator\LinkAnalyzer::beforeAnalyzeRecord` +- :php:`TYPO3\CMS\Seo\Canonical\CanonicalGenerator::beforeGeneratingCanonical` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_BeforeCaching` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GetDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_SortDataArray_PostProcesss` + +In addition, the following public constants, marking a signal name, are deprecated: + +- :php:`TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider::SIGNAL_PostProcessTreeData` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_BeforeCaching` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GetDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_SortDataArray_PostProcesss` + +Impact +====== + +Using the mentioned signals will trigger a deprecation warning. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using these signals. + +Migration +========= + +Use the new PSR-14 alternatives: + +- :php:`TYPO3\CMS\Backend\Authentication\Event\SwitchUserEvent` +- :php:`TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent` +- :php:`TYPO3\CMS\Backend\Controller\Event\BeforeFormEnginePageInitializedEvent` +- :php:`TYPO3\CMS\Backend\Controller\Event\AfterFormEnginePageInitializedEvent` +- :php:`TYPO3\CMS\Backend\LoginProvider\Event\ModifyPageLayoutOnLoginProviderSelectionEvent` +- :php:`TYPO3\CMS\Core\Imaging\Event\ModifyIconForResourcePropertiesEvent` +- :php:`TYPO3\CMS\Core\DataHandling\Event\IsTableExcludedFromReferenceIndexEvent` +- :php:`TYPO3\CMS\Core\DataHandling\Event\AppendLinkHandlerElementsEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent` +- :php:`TYPO3\CMS\Core\Database\Event\AlterTableDefinitionStatementsEvent` +- :php:`TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` +- :php:`TYPO3\CMS\Impexp\Event\BeforeImportEvent` +- :php:`TYPO3\CMS\Install\Service\Event\ModifyLanguagePackRemoteBaseUrlEvent` +- :php:`TYPO3\CMS\Linkvalidator\Event\BeforeRecordIsAnalyzedEvent` +- :php:`TYPO3\CMS\Seo\Event\ModifyUrlForCanonicalTagEvent` +- :php:`TYPO3\CMS\Workspaces\Event\AfterCompiledCacheableDataForWorkspaceEvent` +- :php:`TYPO3\CMS\Workspaces\Event\AfterDataGeneratedForWorkspaceEvent` +- :php:`TYPO3\CMS\Workspaces\Event\GetVersionedDataEvent` +- :php:`TYPO3\CMS\Workspaces\Event\SortVersionedDataEvent` + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.2/Deprecation-89742-FormMixins.rst b/Documentation/Changelog/10.2/Deprecation-89742-FormMixins.rst new file mode 100644 index 0000000..84897a6 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89742-FormMixins.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89742: + +================================= +Deprecation: #89742 - Form mixins +================================= + +See :issue:`89742` + +Description +=========== + +All mixins in the "form" extension have been deprecated and should not be used anymore. This affects all inheritances from :yaml:`TYPO3.CMS.Form.mixins.*`. + +The mixins have been deprecated with TYPO3v10 and will be removed with TYPO3v11. + + +Impact +====== + +Form setup inheriting mixins from :yaml:`TYPO3.CMS.Form.mixins.*` will trigger a deprecation warning in TYPO3v10. + +With TYPO3v11 these mixins will be removed which will lead to an error. + + +Affected Installations +====================== + +Instances using the "form" extension and inheriting from :yaml:`TYPO3.CMS.Form.mixins.*` in their form setup. + + +Migration +========= + +Embed the essential parts from :yaml:`TYPO3.CMS.Form.mixins.*` or migrate them to custom mixins. + +.. index:: Backend, Frontend, NotScanned, ext:form diff --git a/Documentation/Changelog/10.2/Deprecation-89756-BackendUtilityTYPO3_copyRightNotice.rst b/Documentation/Changelog/10.2/Deprecation-89756-BackendUtilityTYPO3_copyRightNotice.rst new file mode 100644 index 0000000..3db89e2 --- /dev/null +++ b/Documentation/Changelog/10.2/Deprecation-89756-BackendUtilityTYPO3_copyRightNotice.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89756: + +=========================================================== +Deprecation: #89756 - BackendUtility::TYPO3_copyRightNotice +=========================================================== + +See :issue:`89756` + +Description +=========== + +The PHP method :php:`TYPO3\CMS\Backend\Utility\BackendUtility::TYPO3_copyRightNotice` that is +used to display information about the warranty and copyright of the product, e.g. used in the +login screen, has been superseded by a new API :php:`TYPO3\CMS\Core\Information\Typo3Information`. + +The existing static method has been marked as deprecated. + + +Impact +====== + +Calling the method will trigger a deprecation warning, but work as +before until TYPO3 v11.0. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions explicitly calling this +method. Run the Extension Scanner in the "Upgrade" module to see +if you are affected. + + +Migration +========= + +Use the new :php:`Typo3Information` PHP class, and its method :php:`getCopyrightNotice()` which will return the same output. + +.. index:: PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/10.2/Feature-79445-AddMultistepWizard.rst b/Documentation/Changelog/10.2/Feature-79445-AddMultistepWizard.rst new file mode 100644 index 0000000..c6ffd01 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-79445-AddMultistepWizard.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _feature-79445: + +====================================== +Feature: #79445 - Add Multistep Wizard +====================================== + +See :issue:`79445` + +Description +=========== + +Based on a concept - which has been created during the TYPO3 UX week - a new JavaScript module :js:`MultiStepWizard` has been introduced. + +Compared to the existing :js:`Wizard` - which is currently in use e.g. when translating content or creating a new form - the following changes have been implemented: + +* Navigation to previous steps is possible. +* Instead of labeling steps with just a numerical indicator (like "Step x of y") steps can have descriptive labels like "Start" or "Finish!". +* The structure of the configuration has been optimized. + +Code examples: + +.. code-block:: js + + // Show/ hide the wizard + MultiStepWizard.show(); + MultiStepWizard.dismiss(); + + // Add a slide to the wizard + MultiStepWizard.addSlide( + identifier, + stepTitle, + content, + severity, + progressBarTitle, + function() { + ... + } + ); + + // Lock/ unlock navigation buttons + MultiStepWizard.lockNextStep(); + MultiStepWizard.unlockNextStep(); + MultiStepWizard.lockPrevStep(); + MultiStepWizard.unlockPrevStep(); + + +Impact +====== + +Developers can provide editors with a vastly enhanced wizard. The UI and UX of the wizard have been improved big time. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.2/Feature-79445-ImproveFormCreationWizard.rst b/Documentation/Changelog/10.2/Feature-79445-ImproveFormCreationWizard.rst new file mode 100644 index 0000000..74d2d0c --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-79445-ImproveFormCreationWizard.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-79445-1668719171: + +============================================== +Feature: #79445 - Improve form creation wizard +============================================== + +See :issue:`79445` + +Description +=========== + +Based on a concept - which has been created during the TYPO3 UX week - the form creation wizard has been improved +greatly. This results in a vast enhancement of the user experience. In detail the following changes have been +implemented: + +* The user interface has been visually refurbished and rearranged. +* Step 3 has been removed, it just confirmed the successful form creation. +* Previous steps are now accessible. +* Steps now have descriptive labels like "Start" or "Finish!". + +This is achieved with the new JavaScript module :js:`MultiStepWizard`. + + +Impact +====== + +Editors will find a vastly enhanced form creation wizard. The UI and UX of the wizard have been improved big time. + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-82706-RenderFieldsetLabelsInFormTemplates.rst b/Documentation/Changelog/10.2/Feature-82706-RenderFieldsetLabelsInFormTemplates.rst new file mode 100644 index 0000000..10b5dec --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-82706-RenderFieldsetLabelsInFormTemplates.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-82706: + +========================================================== +Feature: #82706 - Render fieldset labels in form templates +========================================================== + +See :issue:`82706` + +Description +=========== + +The section element :yaml:`Fieldset` is now accessible in templates of the "form" extension and can be used to add more structure. + +By default this affects the :yaml:`SummaryPage` form element as well as the :yaml:`EmailToReceiver` / :yaml:`EmailToSender` finishers. + +A common use case are two fieldsets for a delivery and a billing address where the fields within the fieldset are usually named the same. Till now, the default mail sent by the "form" extension would e.g. show "Street" twice without giving a hint about the context. Now the fieldset label is rendered in between to separate those fields. + + +Impact +====== + +The summary page of the "form" extension and mails now show fieldset labels as separators. + +Custom templates have access to the fieldset element for rendering. + +.. index:: Frontend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-84203-UnifyFormSetupYAMLLoading.rst b/Documentation/Changelog/10.2/Feature-84203-UnifyFormSetupYAMLLoading.rst new file mode 100644 index 0000000..1582b17 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-84203-UnifyFormSetupYAMLLoading.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-84203: + +=============================================== +Feature: #84203 - Unify form setup YAML loading +=============================================== + +See :issue:`84203` + +Description +=========== + +Form setup files of the "form" extension now make use of the TYPO3 core YAML file loader. This allows +using the known TYPO3 core features: + +* import of other YAML files via :yaml:`imports` directive +* replacement of :yaml:`%placeholders%` + + +Impact +====== + +Form setups can now be structured more freely by splitting logical parts into separate files. + +.. index:: Backend, Frontend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-84713-AccessSingleFormValuesInTemplates.rst b/Documentation/Changelog/10.2/Feature-84713-AccessSingleFormValuesInTemplates.rst new file mode 100644 index 0000000..93a59f5 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-84713-AccessSingleFormValuesInTemplates.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _feature-84713: + +======================================================== +Feature: #84713 - Access single values in form templates +======================================================== + +See :issue:`84713` + +Description +=========== + +It is now possible to access single form values in templates of the "form" extension. For this a new :php:`RenderFormValueViewHelper` has been added which complements the existing :php:`RenderAllFormValuesViewHelper`. + +The :php:`RenderFormValueViewHelper` accepts a single form element and renders it exactly like the :php:`RenderAllFormValuesViewHelper` used to do within its internal traversal of renderable elements: + +To make it possible to access single form elements, a new method :php:`FormDefinition::getElements()` has been added. This method returns an array containing all elements in the form with their identifiers as keys. + +Viewhelper usage in mail templates: + +.. code-block:: html + + <p>The following message was just sent by <b><formvh:renderFormValue renderable="{form.formDefinition.elements.name}" as="formValue">{formValue.processedValue}</formvh:renderFormValue><b>:</p> + + <blockquote> + <formvh:renderFormValue renderable="{form.formDefinition.elements.message}" as="formValue"> + {formValue.processedValue} + </formvh:renderFormValue> + </blockquote> + +See which elements are accessible in mail templates: + +.. code-block:: html + + <f:debug>{form.formDefinition.elements}</f:debug> + +Viewhelper usage in the `SummaryPage` partial: + +.. code-block:: html + + <p>The following message was just sent by <b><formvh:renderFormValue renderable="{page.rootForm.elements.name}" as="formValue">{formValue.processedValue}</formvh:renderFormValue><b>:</p> + + <blockquote> + <formvh:renderFormValue renderable="{page.rootForm.elements.message}" as="formValue"> + {formValue.processedValue} + </formvh:renderFormValue> + </blockquote> + +See which elements are accessible in the `SummaryPage` partial: + +.. code-block:: html + + <f:debug>{page.rootForm.elements}</f:debug> + +.. attention:: + The form elements are accessed differently depending on the kind of template. + In mail templates `{form.formDefinition.elements}` is used, + in the `SummaryPage` partial `{page.rootForm.elements}` is used. + +Impact +====== + +Form values can now be placed freely in Fluid templates of the "form" extension instead of being bound to traverse all form values and skip rendering. + +.. index:: Fluid, Frontend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-84990-AddEventForCheckingExternalLinksInRTE.rst b/Documentation/Changelog/10.2/Feature-84990-AddEventForCheckingExternalLinksInRTE.rst new file mode 100644 index 0000000..151f4a3 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-84990-AddEventForCheckingExternalLinksInRTE.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-84990-1668719171: + +============================================================== +Feature: #84990 - Add event for checking external links in RTE +============================================================== + +See :issue:`84990` + +Description +=========== + +A new PSR-14-based event :php:`TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent` +can be used to get information about broken links set in the rich text editor (RTE). + +Up until now, TYPO3 only displayed page links with extra markup (yellow background with red +border) if a link would point to an internal page which does not exist anymore. + +This was previously checked internally in RTE, and is now moved to an Event Listener to make +this check more flexible and interchangeable. + +The procedure for marking the broken links in the RTE is as follow: + +#. RTE content is fetched from the database. Before it is displayed in + the edit form, RTE transformations are performed. +#. The transformation function parses the text and detects links. +#. For each link, a new PSR-14 event is dispatched. +#. If a listener is attached, it may set the link as broken and will set + the link as "checked". +#. If a link is detected as broken, RTE will mark it as broken. + +An implementation for external and page links is now supplied by the system +extension linkvalidator. External links are currently checked using the existing +`tx_linkvalidator_links` table. + +Other extensions can use the event to override the default behaviour. + + +Impact +====== + +The behaviour for page links stays the same - they are also marked if +they do not exist anymore - however, marking the links is now unified and only available +when the system extension `linkvalidator` is installed. + +If linkvalidator is installed and regularly crawls for broken links, broken external links +will be marked as well. + +If linkvalidator is used, it is recommended to use the scheduler to regularly crawl for broken links. + + +.. index:: RTE, ext:linkvalidator diff --git a/Documentation/Changelog/10.2/Feature-84990-MarkBrokenFileLinksInRTE.rst b/Documentation/Changelog/10.2/Feature-84990-MarkBrokenFileLinksInRTE.rst new file mode 100644 index 0000000..53cbe98 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-84990-MarkBrokenFileLinksInRTE.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-84990: + +=============================================== +Feature: #84990 - Mark broken file links in RTE +=============================================== + +See :issue:`84990` + +Description +=========== + +Links to files that were detected as broken by the system extension +`linkvalidator` are now marked accordingly in the RTE via +:php:`TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent`. + +Those links are now marked with extra markup (yellow background with +red border) in RTE. + +The procedure for marking the broken links in the RTE is as follow: + +#. RTE content is fetched from the database. Before it is displayed in + the edit form, RTE transformations are performed. +#. The transformation function parses the text and detects links. +#. For each link, a new PSR-14 event is dispatched. +#. If a listener is attached, it may set the link as broken and will set + the link as "checked". +#. If a link is detected as broken, RTE will mark it as broken. + +The implementation for checking file links is supplied by the system +extension `linkvalidator`. + +Other extensions can use the event to override the default behaviour. + + +Impact +====== + +The behaviour stays the same as before unless the system extension `linkvalidator` +is installed. + +If `linkvalidator` is installed and regularly checks for broken file links, those +links will be marked in the RTE. + +If `linkvalidator` is used, it is recommended to use the scheduler to regularly +check for broken links. + + +.. index:: RTE, ext:linkvalidator diff --git a/Documentation/Changelog/10.2/Feature-85592-AddSiteTitleConfigurationToSitesModule.rst b/Documentation/Changelog/10.2/Feature-85592-AddSiteTitleConfigurationToSitesModule.rst new file mode 100644 index 0000000..40c2706 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-85592-AddSiteTitleConfigurationToSitesModule.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-85592: + +============================================================== +Feature: #85592 - Add site title configuration to sites module +============================================================== + +See :issue:`85592` + +Description +=========== + +The site title can now be configured within the sites module instead of using the field in the system template record. +This allows now a different site title per language. + +This site title will be used for the page title as well as for future schema.org integrations. + + +Impact +====== + +The new way allows now to have a different site title per language. + +The old way using the system template record has been deprecated and will be removed in TYPO3 v11. When you have set +the site title in your site configuration, it will take precedence over your TypoScript setting. Overriding your +site title with a TypoScript extension template is not possible anymore when using the site configuration. + +.. index:: Backend, Frontend diff --git a/Documentation/Changelog/10.2/Feature-86759-SupportNomoduleAttributeForJavaScriptIncludes.rst b/Documentation/Changelog/10.2/Feature-86759-SupportNomoduleAttributeForJavaScriptIncludes.rst new file mode 100644 index 0000000..4629cb0 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-86759-SupportNomoduleAttributeForJavaScriptIncludes.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-86759: + +==================================================================== +Feature: #86759 - Support nomodule attribute for JavaScript includes +==================================================================== + +See :issue:`86759` + +Description +=========== + +When including JavaScript files in TypoScript, the HTML5 attribute :html:`nomodule` is now +supported. + +See https://html.spec.whatwg.org/multipage/scripting.html#attr-script-nomodule + +.. code-block:: typoscript + + page.includeJSFooter.file = path/to/file.js + page.includeJSFooter.file.nomodule = 1 + +.. index:: TypoScript diff --git a/Documentation/Changelog/10.2/Feature-86818-ReintroduceKeyboardAccessibleVersionOfThePagetree.rst b/Documentation/Changelog/10.2/Feature-86818-ReintroduceKeyboardAccessibleVersionOfThePagetree.rst new file mode 100644 index 0000000..e953834 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-86818-ReintroduceKeyboardAccessibleVersionOfThePagetree.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-86818: + +========================================================================= +Feature: #86818 - Reintroduce keyboard accessible version of the pagetree +========================================================================= + +See :issue:`86818` + +Description +=========== + +This feature makes the pagetree focusable via keyboard using the tab key. Now it is also possible to use +arrows, home and end keys in order to navigate through the pagetree. Besides that, using enter and +space keys will open the page in the according content area. + +Of course, it is still possible to use both mouse and keyboard navigation. + +This change follows the best practices as described in WAI-ARIA Authoring Practices 1.1, +see the `W3 document`_ for further reading. + +.. _W3 document: https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-22 + +Impact +====== + +Added :html:`tabindex`, :html:`role`, :html:`aria-*` and :html:`id` attributes to pagetree elements +as advised in WAI-ARIA Authoring Practices 1.1. Screenreaders are now able to recognize the pagetree as +tree element. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.2/Feature-86918-AddAdditionalConfigurationForExternalLinkTypesInLinkvalidator.rst b/Documentation/Changelog/10.2/Feature-86918-AddAdditionalConfigurationForExternalLinkTypesInLinkvalidator.rst new file mode 100644 index 0000000..2c9aa58 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-86918-AddAdditionalConfigurationForExternalLinkTypesInLinkvalidator.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _feature-86918: + +======================================================================================= +Feature: #86918 - Add additional configuration for external link types in Linkvalidator +======================================================================================= + +See :issue:`86918` + +Description +=========== + +Additional configuration is added for crawling external links: + +.. code-block:: typoscript + + mod.linkvalidator { + + linktypesConfig { + external { + + # User-Agent string is filled with information about the crawling site + httpAgentName = TYPO3 Linkvalidator + httpAgentUrl = + httpAgentEmail = + + headers { + } + + method = HEAD + + range = 0-4048 + } + } + +For a description of the fields, see the linkvalidator documentation: +https://docs.typo3.org/c/typo3/cms-linkvalidator/main/en-us//Configuration/Index.html + +It is recommended to fill out `httpAgentUrl` and `httpAgentEmail` so that the User-Agent +string is filled for crawling external URLs. + +For headers, method and range it is recommended to stick with the default values. + +Impact +====== + +It is possible to configure information for the 'User-Agent' string as is customary +for crawlers. + +The settings 'headers', 'method' and 'range' are advanced settings. They can be used to +optimize the crawling. + +Recommendation +============== + +Set the settings httpAgentUrl and httpAgentEmail. Details can be found in the +linkvalidator documentation. + + + +.. index:: ext:linkvalidator diff --git a/Documentation/Changelog/10.2/Feature-86967-AllowFetchingUidOfALazyLoadingProxyWithoutLoadingTheObjectFirst.rst b/Documentation/Changelog/10.2/Feature-86967-AllowFetchingUidOfALazyLoadingProxyWithoutLoadingTheObjectFirst.rst new file mode 100644 index 0000000..08f8347 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-86967-AllowFetchingUidOfALazyLoadingProxyWithoutLoadingTheObjectFirst.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-86967: + +=========================================================================================== +Feature: #86967 - Allow fetching uid of a LazyLoadingProxy without loading the object first +=========================================================================================== + +See :issue:`86967` + +Description +=========== + +A method :php:`getUid()` has been added to class :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy` which +allows for fetching the uid of the proxied object without fetching the object data itself from the database. + +Impact +====== + +The method :php:`getUid()` can be used to fetch the uid of objects for a quick comparison with other objects of the same +type which increases the performance of such comparisons a lot. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.2/Feature-87798-ProvideAWayToSortFormListsInExtform.rst b/Documentation/Changelog/10.2/Feature-87798-ProvideAWayToSortFormListsInExtform.rst new file mode 100644 index 0000000..683133b --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-87798-ProvideAWayToSortFormListsInExtform.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _feature-87798: + +============================================================== +Feature: #87798 - Provide a way to sort form lists in ext:form +============================================================== + +See :issue:`87798` + +Description +=========== + +Forms in ext:form were previously not sorted in any manner, +but just outputted in the order they were read from the filesystem's directories. + +Forms can now be sorted by multiple keys in either ascending or descending order. +Two new settings were introduced: ``sortByKeys`` and ``sortAscending``. + +Here is an example configuration, +that will sort forms by their name first and by their file uid second: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + persistenceManager: + sortByKeys: ['name', 'fileUid'] + sortAscending: true + +Valid keys, by which the forms can be sorted, are: + +``name`` + The forms name. + +``identifier`` + The filename. + +``fileUid`` + The files uid. + +``persistenceIdentifier`` + The files location. + + Example: ``1:/form_definitions/contact.form.yaml`` + +``readOnly`` + Is the form readonly? + +``removable`` + Is the form removable? + +``location`` + Either `storage` or `extension` + +``invalid`` + Does the form have an error? + +Impact +====== + +Forms will now initially be sorted by their name first and their file uid second in an ascending order. +This affects both the form list shown in the form module as well as the ordering of the available select options when creating a new form content element. + +To change the sorting, you can override the configuration via YAML as described by the example above. + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-88102-FrontendLoginViaFluidAndExtbase.rst b/Documentation/Changelog/10.2/Feature-88102-FrontendLoginViaFluidAndExtbase.rst new file mode 100644 index 0000000..26e5a36 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-88102-FrontendLoginViaFluidAndExtbase.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-88102: + +=========================================================== +Feature: #88102 - Frontend Login Form Via Fluid And Extbase +=========================================================== + +See :issue:`88102` + +Description +=========== + +The system extension "felogin" now has two plugins. The original plugin which was built via the "PiBase" Plugin +Framework and Marker-based templates continues to work, but is superseded with a new Extbase- and Fluid-based plugin, +allowing to customize templates just like any other modern plugin. + +A new feature toggle is introduced to switch between the "PiBase" plugin and the Extbase plugin, which can be switched +in the Install Tool. For existing installations, the default "PiBased" plugin is activated. + +Migration +========= + +To migrate existing Frontend Login Form plugins an update wizard called "Migrate felogin plugins to use extbase CType" +is provided. The wizard can also be used to switch back from the Extbase version to PiBase. + +When using extbase, fluid templates are used to display the depending content. These can be overridden via TypoScript +for customization. All existing templates are found in + +EXT:felogin/Resources/Private/Templates/{Login,PasswordRecovery} + +Examples +======== + +Overriding Templates: +All templates are now fluid based, which means they can be overridden by other extensions via typoscript. For example: + +To overwrite the :php:`\TYPO3\CMS\FrontendLogin\Controller\LoginController::loginAction()` template with an own one +located in :file:`EXT:my_extension/Resources/Private/Templates/Felogin/Login/Login.html`, the following config will do +the trick. + +.. code-block:: typoscript + + plugin.tx_felogin_login { + view { + templateRootPaths { + 10 = EXT:my_extension/Resources/Private/Templates/Felogin/ + } + } + } + +.. index:: Frontend, LocalConfiguration, ext:felogin diff --git a/Documentation/Changelog/10.2/Feature-88110-FeloginExtbasePasswordRecovery.rst b/Documentation/Changelog/10.2/Feature-88110-FeloginExtbasePasswordRecovery.rst new file mode 100644 index 0000000..a015519 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-88110-FeloginExtbasePasswordRecovery.rst @@ -0,0 +1,100 @@ +.. include:: /Includes.rst.txt + +.. _feature-88110: + +=================================================== +Feature: #88110 - Felogin extbase password recovery +=================================================== + +See :issue:`88110` + +Description +=========== + +As part of the felogin extbase plugin, a password recovery form has been added. + +FE users are able to request a password change via email. A mail with a forgot hash will be send to the requesting user. +If that hash is found valid a reset password form is shown. If all validators are met the users password will be updated. + +There is a way to define and override default validators. Configured as default are two validators: NotEmptyValidator and StringLengthValidator. + +They can be overridden by overwriting :typoscript:`plugin.tx_felogin_login.settings.passwordValidators`. +Default is as follows: + +.. code-block:: typoscript + + passwordValidators { + 10 = TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator + 20 { + className = TYPO3\CMS\Extbase\Validation\Validator\StringLengthValidator + options { + minimum = {$styles.content.loginform.newPasswordMinLength} + } + } + } + +A custom configuration could look like this: + +.. code-block:: typoscript + + passwordValidators { + 10 = TYPO3\CMS\Extbase\Validation\Validator\AlphanumericValidator + 20 { + className = TYPO3\CMS\Extbase\Validation\Validator\StringLengthValidator + options { + minimum = {$styles.content.loginform.newPasswordMinLength} + maximum = 32 + } + } + 30 = \Vendor\MyExt\Validation\Validator\MyCustomPasswordPolicyValidator + } + +Felogin uses FluidMail. The email_templateName variable in TypoScript is mandatory. Depending on the configuration of +$GLOBALS['TYPO3_CONF_VARS']['MAIL']['format'] the template has to exist as an HTML file, txt file or both. The template +files have to be placed in the same folder. +The template paths can be configured via TypoScript. These paths can either be added to the paths configured in +$GLOBALS['TYPO3_CONF_VARS']['MAIL'] or replace them. + +The template paths configuration can be extended as follows: + +.. code-block:: typoscript + + plugin.tx_felogin_login { + settings { + email { + templateName = MyRecoveryEmailTemplateName + + layoutRootPaths { + 30 = EXT:myext/Resources/Private/Layouts/Email/ + } + templateRootPaths { + 30 = EXT:myext/Resources/Private/Templates/Email/ + } + partialRootPaths { + 30 = EXT:myext/Resources/Private/Partials/Email/ + } + } + } + } + +To overwrite the template path configuration provided by felogin, it has to be as follows: + +.. code-block:: typoscript + + plugin.tx_felogin_login { + settings { + email{ + templateRootPaths { + 20 = EXT:myext/Resources/Private/Templates/Email/ + } + } + } + } + + +Impact +====== + +No direct impact. Only used, if feature toggle "felogin.extbase" is explicitly turned on. + +.. index:: Database, FlexForm, Fluid, Frontend, TypoScript, ext:felogin diff --git a/Documentation/Changelog/10.2/Feature-88238-FeatureToggleFormlegacyUploadMimeTypes.rst b/Documentation/Changelog/10.2/Feature-88238-FeatureToggleFormlegacyUploadMimeTypes.rst new file mode 100644 index 0000000..b74eaa6 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-88238-FeatureToggleFormlegacyUploadMimeTypes.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-88238: + +=========================================================== +Feature: #88238 - FeatureToggle: form.legacyUploadMimeTypes +=========================================================== + +See :issue:`88238` + +Description +=========== + +The feature toggle :code:`form.legacyUploadMimeTypes` makes it possible to enable some predefined :yaml:`allowedMimeTypes` in :yaml:`FileUpload` and :yaml:`ImageUpload` form elements. + +These MIME types are enabled through this feature toggle by default as of TYPO3v10 and will be removed completely in TYPO3v11. + + +Impact +====== + +Full control over file upload MIME type validation can be achieved by disabling this flag and explicitly listing all allowed MIME types. + +.. index:: Frontend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-88902-FeatureSwitchRedirectAndStaticRoutesMiddlewaresCanBeReordered.rst b/Documentation/Changelog/10.2/Feature-88902-FeatureSwitchRedirectAndStaticRoutesMiddlewaresCanBeReordered.rst new file mode 100644 index 0000000..3a80dab --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-88902-FeatureSwitchRedirectAndStaticRoutesMiddlewaresCanBeReordered.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-88902: + +========================================================================================= +Feature: #88902 - Feature Switch: Redirect and Base Redirect Middlewares can be reordered +========================================================================================= + +See :issue:`88902` + +Description +=========== + +A new feature switch :php:`rearrangedRedirectMiddlewares` has been introduced to rearrange the middlewares +:php:`typo3/cms-redirects/redirecthandler` and :php:`typo3/cms-frontend/base-redirect-resolver`. If enabled, the +middlewares are executed in reversed order, so the :php:`typo3/cms-redirects/redirecthandler` comes first. + +The new ordering aims to be a better default shipped by the TYPO3 core but might still need adjustment due to specific +configuration setups. + +The feature switch is turned off by default to assure a non-breaking behaviour. + +Impact +====== + +If turned on, the new ordering has the following implications: + +By putting the :php:`typo3/cms-frontend/base-redirect-resolver` last, redirects are always resolved even if no +configured base URL was requested. In most cases this is considered to be a bugfix. However, redirect behavior might +change. + +Custom middlewares that have been put in between the two above mentioned middlewares most likely will lead to a circular +dependency exception now. Such custom middlewares have to be revisited and registered differently. + +.. index:: Frontend, NotScanned, ext:redirects diff --git a/Documentation/Changelog/10.2/Feature-88950-AddStoreSessionArgumentToWidgetViewHelpers.rst b/Documentation/Changelog/10.2/Feature-88950-AddStoreSessionArgumentToWidgetViewHelpers.rst new file mode 100644 index 0000000..32d7d0a --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-88950-AddStoreSessionArgumentToWidgetViewHelpers.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-88950: + +=================================================================== +Feature: #88950 - Add "storeSession" argument to Widget ViewHelpers +=================================================================== + +See :issue:`88950` + +Description +=========== + +Widget ViewHelpers, by default, can store the widgets session in the database by utilizing a cookie. +In frontend context this would automatically create a ``fe_typo_user`` cookie when, +for instance, the :html:`<f:widget.autocomplete>` ViewHelper is used. + +As this is not always a desired behaviour (gdpr), +a boolean argument ``storeSession`` has been added to :php:`\TYPO3\CMS\Fluid\Core\Widget\AbstractWidgetViewHelper`, +which defaults to true and can be used to disable session storage for this ViewHelper. + +This will automatically create a ``fe_typo_user`` cookie in the frontend: + +.. code-block:: html + + <f:widget.autocomplete for="name" objects="{posts}" searchProperty="author" /> + +This will not create a cookie in frontend: + +.. code-block:: html + + <f:widget.autocomplete for="name" objects="{posts}" searchProperty="author" storeSession="false" /> + +Impact +====== + +The default value of the property `storeSession` is set to `true`, +so no changes need to be done in existing implementations of Widget ViewHelpers. + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/10.2/Feature-89171-AddedPossibilityToHaveMultipleSitemaps.rst b/Documentation/Changelog/10.2/Feature-89171-AddedPossibilityToHaveMultipleSitemaps.rst new file mode 100644 index 0000000..a8d1238 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89171-AddedPossibilityToHaveMultipleSitemaps.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _feature-89171: + +============================================================= +Feature: #89171 - Added possibility to have multiple sitemaps +============================================================= + +See :issue:`89171` + +Description +=========== + +You can now also create multiple different sitemaps. This can be handy for situations, where +different target systems need them in different format or order. (e.g. Google News Sitemaps) + +The syntax looks like this: + +.. code-block:: typoscript + + plugin.tx_seo { + config { + <sitemapType> { + sitemaps { + <unique key> { + provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider + config { + ... + } + } + } + } + } + } + +Example: + +.. code-block:: typoscript + + seo_googlenews < seo_sitemap + seo_googlenews.typeNum = 1571859552 + seo_googlenews.10.sitemapType = googleNewsSitemap + + plugin.tx_seo { + config { + xmlSitemap { + sitemaps { + news { + provider = GeorgRinger\News\Seo\NewsXmlSitemapDataProvider + config { + ... + } + } + } + } + googleNewsSitemap { + sitemaps { + news { + provider = GeorgRinger\News\Seo\NewsXmlSitemapDataProvider + config { + googleNews = 1 + ... + template = GoogleNewsXmlSitemap.html + } + } + } + } + } + } + + + +Impact +====== + +As it only gives the possibility to add multiple sitemaps, it won't affect any installation unless you add more sitemaps +yourself. + +.. index:: ext:seo diff --git a/Documentation/Changelog/10.2/Feature-89398-SupportForEnvironmentVariablesInImportsInSiteConfigurations.rst b/Documentation/Changelog/10.2/Feature-89398-SupportForEnvironmentVariablesInImportsInSiteConfigurations.rst new file mode 100644 index 0000000..8e783e2 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89398-SupportForEnvironmentVariablesInImportsInSiteConfigurations.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-89398: + +===================================================================================== +Feature: #89398 - Support for environment variables in imports in site configurations +===================================================================================== + +See :issue:`89398` + +Description +=========== + +Environment variables are now resolved in imports of site configuration YAML files. + +Example: + +.. code-block:: yaml + + imports: + - + resource: 'Env_%env("foo")%.yaml' + + +Impact +====== + +It is now possible to use environment variables in imports of site configuration yaml files. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.2/Feature-89458-ShowLinkToOnlineDocsInExtensionManager.rst b/Documentation/Changelog/10.2/Feature-89458-ShowLinkToOnlineDocsInExtensionManager.rst new file mode 100644 index 0000000..48f07ca --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89458-ShowLinkToOnlineDocsInExtensionManager.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-89458: + +=============================================================== +Feature: #89458 - Show link to online docs in extension manager +=============================================================== + +See :issue:`89458` + +Description +=========== + +The export of extensions provided by https://extensions.typo3.org has been extended with the link +to the documentation of an extension. This link is now shown in all lists of extensions as well +as the detail view of extensions in the extension manager. + +It is recommended that extension authors add documentation for their extensions on docs.typo3.org. + +However, if an extension provides an external documentation source, the custom link takes precedence +over any existing documentation at docs.typo3.org. + +Impact +====== + +Extension documentation links are now available in the extension manager. + +.. index:: Backend, ext:extensionmanager diff --git a/Documentation/Changelog/10.2/Feature-89526-FeatureFlagBetaTranslationServer.rst b/Documentation/Changelog/10.2/Feature-89526-FeatureFlagBetaTranslationServer.rst new file mode 100644 index 0000000..93a75f7 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89526-FeatureFlagBetaTranslationServer.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-89526-1668719171: + +==================================================== +Feature: #89526 - FeatureFlag: betaTranslationServer +==================================================== + +See :issue:`89526` + +Description +=========== + +The feature switch `betaTranslationServer` makes it possible for installations to fetch translations from the new translation server (beta status). +The new translation server is building labels from Crowdin (https://crowdin.com/project/typo3-cms) instead of the current translation server based on Pootle (https://translation.typo3.org/). + +The integration is currently work in progress but will be finished before the LTS release of version 10. +Once the work has been stabilized and tested well, the feature flag will be removed for 10 and backported for 9. + +If you are interested in this topic, join the Crowdin Initiative. All information can be found at https://typo3.org/community/teams/typo3-development/initiatives/localization-with-crowdin/. + + +Impact +====== + +Be aware that using this translation server is currently experimental. This means: + +- Translations are incomplete and might be removed and added anytime +- Translations of community extensions are currently not available + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/10.2/Feature-89577-NewPSR-14BasedEventsForFileAbstractionLayer.rst b/Documentation/Changelog/10.2/Feature-89577-NewPSR-14BasedEventsForFileAbstractionLayer.rst new file mode 100644 index 0000000..529a9b5 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89577-NewPSR-14BasedEventsForFileAbstractionLayer.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-89577: + +==================================================================== +Feature: #89577 - New PSR-14 based events for File Abstraction Layer +==================================================================== + +See :issue:`89577` + +Description +=========== + +The following new PSR-14 based Events have been introduced: + +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileAddedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileAddedToIndexEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileContentsSetEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileCopiedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileCreatedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileDeletedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileMarkedAsMissingEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileMetaDataCreatedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileMetaDataDeletedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileMetaDataUpdatedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileMovedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileProcessingEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileRemovedFromIndexEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileRenamedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileReplacedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFileUpdatedInIndexEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFolderAddedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFolderCopiedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFolderDeletedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFolderMovedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterFolderRenamedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\AfterResourceStorageInitializationEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileAddedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileContentsSetEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileCopiedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileCreatedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileDeletedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileMovedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileProcessingEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileRenamedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFileReplacedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFolderAddedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFolderCopiedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFolderDeletedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFolderMovedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeFolderRenamedEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\BeforeResourceStorageInitializationEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\EnrichFileMetaDataEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\GeneratePublicUrlForResourceEvent` +- :php:`\TYPO3\CMS\Core\Classes\Resource\Event\SanitizeFileNameEvent` + +They replace the existing Extbase Signal Slots in the File Abstraction Layer. + +Impact +====== + +All existing signals and their registered slots will work exactly the same as before, however +it is highly encouraged to migrate to the new PSR-14 based events. + +In addition, all Core hooks using these events have been migrated to new PSR-14 events, +all new Events have a description when to use them and what the benefits are. + +The Event `AfterFileCopiedEvent` in addition also contains the newly created File +object. + +Have a look at the new PHP classes to understand the Events and to learn more about PSR-14. + +.. index:: FAL, PHP-API, ext:core diff --git a/Documentation/Changelog/10.2/Feature-89603-IntroduceNativePaginationForLists.rst b/Documentation/Changelog/10.2/Feature-89603-IntroduceNativePaginationForLists.rst new file mode 100644 index 0000000..5bec568 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89603-IntroduceNativePaginationForLists.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-89603: + +======================================================= +Feature: #89603 - Introduce native pagination for lists +======================================================= + +See :issue:`89603` + +Description +=========== + +The TYPO3 core provides an interface to implement the native pagination of lists like arrays or +query results of Extbase. + +The foundation of that new interface :php:`\TYPO3\CMS\Core\Pagination\PaginatorInterface` is that +it's type agnostic. It means, that it doesn't define the type of paginatable objects. It's up to the +concrete implementations to enable pagination for specific types. The interface only forces you to +reduce the incoming list of items to an :php:`iterable` sub set of items. + +Along with that interface, an abstract paginator class :php:`\TYPO3\CMS\Core\Pagination\AbstractPaginator` +has been created that implements the base pagination logic for any kind of :php:`Countable` set of +items while it leaves the processing of items to the concrete paginator class. + + +Impact +====== + +Two concrete paginators have been introduced. One for :php:`array` and one for +:php:`\TYPO3\CMS\Extbase\Persistence\QueryResultInterface` objects. + +The introduction of this native support for the pagination of lists enables the creation of a new +paginate ViewHelper that is type agnostic. + +Code-Example for the :php:`ArrayPaginator`: + +.. code-block:: php + + // use TYPO3\CMS\Core\Pagination\ArrayPaginator; + // use TYPO3\CMS\Core\Pagination\SimplePagination; + + $itemsToBePaginated = ['apple', 'banana', 'strawberry', 'raspberry', 'ananas']; + $itemsPerPage = 2; + $currentPageNumber = 3; + + $paginator = new ArrayPaginator($itemsToBePaginated, $currentPageNumber, $itemsPerPage); + $paginator->getNumberOfPages(); // returns 3 + $paginator->getCurrentPageNumber(); // returns 3, basically just returns the input value + $paginator->getKeyOfFirstPaginatedItem(); // returns 4 + $paginator->getKeyOfLastPaginatedItem(); // returns 4 + + $pagination = new SimplePagination($paginator); + $pagination->getAllPageNumbers(); // returns [1, 2, 3] + $pagination->getPreviousPageNumber(); // returns 2 + $pagination->getNextPageNumber(); // returns null + + // … + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.2/Feature-89718-UnifiedPHPAPIForLoadingPageTSconfig.rst b/Documentation/Changelog/10.2/Feature-89718-UnifiedPHPAPIForLoadingPageTSconfig.rst new file mode 100644 index 0000000..08e7527 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89718-UnifiedPHPAPIForLoadingPageTSconfig.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-89718: + +========================================================== +Feature: #89718 - Unified PHP API for loading PageTSconfig +========================================================== + +See :issue:`89718` + +Description +=========== + +Most parts of TYPO3 Core share duplicate or similar functionality in +Frontend or Backend context. One of that is the loading and parsing +of PageTSconfig, the configuration syntax for various places in +TYPO3 Backend, which can also be used to define Backend Layouts. + +In order to streamline this functionality, the loading process of +gathering all data from a rootline of a page is now simplified in +a new :php:`PageTsLoader` PHP class. + +Additionally, parsing, and additional matching against conditions, +which was added later-on in 2009 and put on top, is now separated +properly, building a truly separation of concerns for compiling +and parsing TSconfig. This is put in the :php:`PageTsConfigParser` PHP class. + + +Impact +====== + +When there is the necessity for fetching and loading PageTSconfig, +it is recommended for extension developers to make use of both new +PHP classes: + +- :php:`TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` +- :php:`TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` + +Usages for fetching all available PageTS in one large string (not parsed yet):: + + $loader = GeneralUtility::makeInstance(PageTsConfigLoader::class); + $tsConfigString = $loader->load($rootLine); + + +The string can then be put in proper TSconfig array syntax:: + + $parser = GeneralUtility::makeInstance( + PageTsConfigParser::class, + $typoScriptParser, + $hashCache + ); + $pagesTSconfig = $parser->parse( + $tsConfigString, + $conditionMatcher + ); + +Extension developers should rely on this syntax rather than +on :php:`$GLOBALS['TSFE']->getPagesTSconfig()` or :php:`BackendUtility::getPagesTsConfig()`. + +.. index:: PHP-API, TSConfig, ext:core diff --git a/Documentation/Changelog/10.2/Feature-89733-NewPSR-14EventsForExistingSignalSlotsInCoreExtension.rst b/Documentation/Changelog/10.2/Feature-89733-NewPSR-14EventsForExistingSignalSlotsInCoreExtension.rst new file mode 100644 index 0000000..86b794e --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89733-NewPSR-14EventsForExistingSignalSlotsInCoreExtension.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-89733: + +=============================================================================== +Feature: #89733 - New PSR-14 events for existing Signal Slots in Core Extension +=============================================================================== + +See :issue:`89733` + +Description +=========== + +PSR-14 EventDispatching allows for TYPO3 Extensions or PHP packages to extend TYPO3 Core functionality in an exchangeable way. + +The following new PSR-14 events have been introduced: + +- :php:`TYPO3\CMS\Backend\Authentication\Event\SwitchUserEvent` +- :php:`TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent` +- :php:`TYPO3\CMS\Backend\Controller\Event\BeforeFormEnginePageInitializedEvent` +- :php:`TYPO3\CMS\Backend\Controller\Event\AfterFormEnginePageInitializedEvent` +- :php:`TYPO3\CMS\Backend\LoginProvider\Event\ModifyPageLayoutOnLoginProviderSelectionEvent` +- :php:`TYPO3\CMS\Core\Imaging\Event\ModifyIconForResourcePropertiesEvent` +- :php:`TYPO3\CMS\Core\DataHandling\Event\IsTableExcludedFromReferenceIndexEvent` +- :php:`TYPO3\CMS\Core\DataHandling\Event\AppendLinkHandlerElementsEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent` +- :php:`TYPO3\CMS\Core\Database\Event\AlterTableDefinitionStatementsEvent` +- :php:`TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` +- :php:`TYPO3\CMS\Impexp\Event\BeforeImportEvent` +- :php:`TYPO3\CMS\Install\Service\Event\ModifyLanguagePackRemoteBaseUrlEvent` +- :php:`TYPO3\CMS\Linkvalidator\Event\BeforeRecordIsAnalyzedEvent` +- :php:`TYPO3\CMS\Seo\Event\ModifyUrlForCanonicalTagEvent` +- :php:`TYPO3\CMS\Workspaces\Event\AfterCompiledCacheableDataForWorkspaceEvent` +- :php:`TYPO3\CMS\Workspaces\Event\AfterDataGeneratedForWorkspaceEvent` +- :php:`TYPO3\CMS\Workspaces\Event\GetVersionedDataEvent` +- :php:`TYPO3\CMS\Workspaces\Event\SortVersionedDataEvent` + +They replace the existing Extbase-based Signal Slots + +- :php:`TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::getSystemInformation` +- :php:`TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::loadMessages` +- :php:`TYPO3\CMS\Backend\LoginProvider\UsernamePasswordLoginProvider::getPageRenderer` +- :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::preInitAfter` +- :php:`TYPO3\CMS\Backend\Controller\EditDocumentController::initAfter` +- :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfigPreInclude` +- :php:`TYPO3\CMS\Beuser\Controller\BackendUserController::switchUser` +- :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex::setTypoLinkPartsElement` +- :php:`TYPO3\CMS\Core\Database\ReferenceIndex::shouldExcludeTableFromReferenceIndex` +- :php:`TYPO3\CMS\Core\Imaging\IconFactory::buildIconForResourceSignal` +- :php:`TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider::PostProcessTreeData` +- :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::tcaIsBeingBuilt` +- :php:`TYPO3\CMS\Impexp\Utility\ImportExportUtility::afterImportExportInitialisation` +- :php:`TYPO3\CMS\Install\Service\SqlExpectedSchemaService::tablesDefinitionIsBeingBuilt` +- :php:`TYPO3\CMS\Lang\Service\TranslationService::postProcessMirrorUrl` +- :php:`TYPO3\CMS\Linkvalidator\LinkAnalyzer::beforeAnalyzeRecord` +- :php:`TYPO3\CMS\Seo\Canonical\CanonicalGenerator::beforeGeneratingCanonical` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_BeforeCaching` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GetDataArray_PostProcesss` +- :php:`TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_SortDataArray_PostProcesss` + + +Impact +====== + +It is now possible to add listeners to the new PSR-14 Events which +define a clear API what can be read or modified. + +The listeners can be added to the :file:`Configuration/Services.yaml` as +it is done in TYPO3's shipped extensions as well. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.2/Feature-89746-CustomIconForRecordBrowserButtonInForms.rst b/Documentation/Changelog/10.2/Feature-89746-CustomIconForRecordBrowserButtonInForms.rst new file mode 100644 index 0000000..38a8367 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89746-CustomIconForRecordBrowserButtonInForms.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _feature-89746: + +================================================================ +Feature: #89746 - Custom icon for record browser button in forms +================================================================ + +See :issue:`89746` + +Description +=========== + +The record browser is used in form definitions e.g. to configure the :yaml:`ContentElement` element or the :yaml:`Redirect` finisher. + +The icons of the buttons which trigger the record browser are now configurable using the new option :yaml:`iconIdentifier`: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + ContentElement: + formEditor: + editors: + # ... + 300: + identifier: contentElement + # ... + browsableType: tt_content + iconIdentifier: mimetypes-x-content-text + propertyPath: properties.contentElementUid + # ... + + +Impact +====== + +The icons for the record browser button can now be customized. + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/10.2/Feature-89747-CustomTablesWithRecordBrowserInForms.rst b/Documentation/Changelog/10.2/Feature-89747-CustomTablesWithRecordBrowserInForms.rst new file mode 100644 index 0000000..efe98d7 --- /dev/null +++ b/Documentation/Changelog/10.2/Feature-89747-CustomTablesWithRecordBrowserInForms.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _feature-89747: + +============================================================ +Feature: #89747 - Custom tables with record browser in forms +============================================================ + +See :issue:`89747` + +Description +=========== + +The record browser in forms now accepts arbitrary custom tables if configured accordingly. + +The option :yaml:`browsableType` of the :yaml:`Inspector-Typo3WinBrowserEditor` can be set to an arbitrary table: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + MyCustomElement: + formEditor: + editors: + # ... + 300: + identifier: myRecord + # ... + browsableType: tx_myext_mytable + propertyPath: properties.myRecordUid + # ... + +Similar to the :yaml:`ContentElement` form element custom logic must be added in the matching frontend partial to actually display something for the selected record. + + +Impact +====== + +Form definitions can be set up to allow editors the selection of arbitrary database records and then render them using custom logic. + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/10.2/Important-84221-RestructuringOfFormSetup.rst b/Documentation/Changelog/10.2/Important-84221-RestructuringOfFormSetup.rst new file mode 100644 index 0000000..a35fa5c --- /dev/null +++ b/Documentation/Changelog/10.2/Important-84221-RestructuringOfFormSetup.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _important-84221: + +=============================================== +Important: #84221 - Restructuring of form setup +=============================================== + +See :issue:`84221` + +Description +=========== + +The setup of the "form" extension has been restructured. Till now the following files were used: + +* :file:`BaseSetup.yaml`: setup shared in all contexts +* :file:`FormEditorSetup.yaml`: setup of the Form Editor backend module +* :file:`FormEngineSetup.yaml`: setup of the Form plugin flexForm configuration + +From now on only a single file is used: + +* :file:`FormSetup.yaml`: basic setup including imports of the configuration for validators, form + elements and finishers. + +All previously used inheritances and mixins have been resolved which makes it very easy to +understand the entire configuration. + +Consequently the entries in :typoscript:`yamlConfigurations` have changed: + +* :typoscript:`10` is now :file:`FormSetup.yaml` +* :typoscript:`20` and :typoscript:`30` have been dropped + +Customizations of those entries must be adjusted accordingly. + +.. index:: Backend, FlexForm, Frontend, TypoScript, ext:form diff --git a/Documentation/Changelog/10.2/Important-87518-UsePreparedStatementsForPdo_mysqlPerDefault.rst b/Documentation/Changelog/10.2/Important-87518-UsePreparedStatementsForPdo_mysqlPerDefault.rst new file mode 100644 index 0000000..be559fa --- /dev/null +++ b/Documentation/Changelog/10.2/Important-87518-UsePreparedStatementsForPdo_mysqlPerDefault.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _important-87518-1668719171: + +===================================================================== +Important: #87518 - Use prepared statements for pdo_mysql per default +===================================================================== + +See :issue:`87518` + +Description +=========== + +Before this adaption, the `pdo_mysql` driver used emulated prepared statements per default. +With that, all returned values of a query were strings. + +With this change the behavior changes to use the actual prepared statements, +which return native data types. Thus, if a column is defined as INTEGER, +the returned value in PHP will also be an INTEGER. + +It is possible to deactivate this feature as follows: + +You need to "overwrite" the option to set `PDO::ATTR_EMULATE_PREPARES` +(reference: https://www.php.net/manual/en/pdo.setattribute.php) in your database connection: + +.. code-block:: php + + 'Connections' => [ + 'Default' => [ + 'dbname' => 'some_database_name', + 'driver' => 'pdo_mysql', + 'driverOptions' => [ + \PDO::ATTR_EMULATE_PREPARES => true + ], + 'password' => 's0meS3curePW!', + 'user' => 'someUser', + ], + ], + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/10.2/Important-88655-ChangedLoadingOrderOfRTEConfiguration.rst b/Documentation/Changelog/10.2/Important-88655-ChangedLoadingOrderOfRTEConfiguration.rst new file mode 100644 index 0000000..6bdf973 --- /dev/null +++ b/Documentation/Changelog/10.2/Important-88655-ChangedLoadingOrderOfRTEConfiguration.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _important-88655: + +============================================================== +Important: #88655 - Changed loading order of RTE Configuration +============================================================== + +See :issue:`88655` + +Description +=========== + +The order in which RTE Configuration is loaded has been changed. + +The new order is: + +#. preset defined for a specific field via PageTS + +#. richtextConfiguration defined for a specific field via TCA + +#. general preset defined via PageTS + +#. default + +This results in a change if you were used to using :typoscript:`RTE.default.preset` to overwrite _all_ RTE +configuration presets - as those with specific configuration in TCA now use their specific settings +instead of falling back to the default. Please make sure, that this new behavior is fitting for your +use cases. + +If you are an extension author and you want your RTE fields to use the systems default configuration +(the one configured for the complete web site) please do not set a specific preset for your fields. +If you as an extension author want to provide a specific preset - for example because you are +providing a custom parseFunc - set the property `richtextConfiguration` in TCA. + +If an extension provides a custom preset for a specific field and you as an integrator want to +override that configuration (for example to use "your" default), set it specifically for that field +in TSConfig or overwrite the TCA configuration. + +For example: + +If the blog extension configures `'richtextConfiguration' => 'blog'` for the tag description and +you want the tag description to use the default preset, set +:typoscript:`RTE.config.tx_blog_domain_model_tag.content.types.text.preset = default`. + +.. index:: RTE, TCA, TSConfig, ext:core diff --git a/Documentation/Changelog/10.2/Important-89645-RemovedSystemLogOptions.rst b/Documentation/Changelog/10.2/Important-89645-RemovedSystemLogOptions.rst new file mode 100644 index 0000000..31bf0a2 --- /dev/null +++ b/Documentation/Changelog/10.2/Important-89645-RemovedSystemLogOptions.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _important-89645: + +============================================= +Important: #89645 - Removed systemLog options +============================================= + +See :issue:`89645` + +Description +=========== + +The systemLog API has been changed in TYPO3 v9.0 to use the Logging API as a breaking change. The relevant systemLog +options have been kept in TYPO3 v9 for backwards-compatibility of existing extensions, however have no use in TYPO3 v10 +anymore. + +The affected options are: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLog']` + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLogLevel']` + +Impact +====== + +The options have been removed from the TYPO3's default configuration. When the options have been set, they are +automatically removed in TYPO3 v10.0 when accessing the Install Tool or System Maintenance area. + +For extension authors, the Logging API should be used starting with TYPO3 v9. The usage of the systemLog options +should then be removed from the extensions' code. + +.. index:: LocalConfiguration, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.2/Important-89764-IncompatibleEnvironmentRelatedDependencyInjectionServicesHaveBeenRemoved.rst b/Documentation/Changelog/10.2/Important-89764-IncompatibleEnvironmentRelatedDependencyInjectionServicesHaveBeenRemoved.rst new file mode 100644 index 0000000..5c292c2 --- /dev/null +++ b/Documentation/Changelog/10.2/Important-89764-IncompatibleEnvironmentRelatedDependencyInjectionServicesHaveBeenRemoved.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _important-89764: + +==================================================================================================== +Important: #89764 - Incompatible environment related dependency injection services have been removed +==================================================================================================== + +See :issue:`89764` + +Description +=========== + +TYPO3 added support for Symfony 5.0 `symfony/dependency-injection:5` and +therefore had to drop non-object services. + +Affected dependency injection services are the following boolean services: + +- env.is_unix +- env.is_windows +- env.is_cli +- env.is_composer_mode + +The services variables can be substituted by using dynamic Symfony +environment parameters: + +- "%env(TYPO3:isUnix)%" +- "%env(TYPO3:isWindows)%" +- "%env(TYPO3:isCli)%" +- "%env(TYPO3:isComposerMode)%" + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.2/Index.rst b/Documentation/Changelog/10.2/Index.rst new file mode 100644 index 0000000..790e4a3 --- /dev/null +++ b/Documentation/Changelog/10.2/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-2: + +10.2 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +None since TYPO3 v10.0 release. + +.. attention:: + + After TYPO3 v10.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v10.0 release on the way to LTS. + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/10.3/Deprecation-89139-ConsoleCommandsConfigurationFormatCommandsPhp.rst b/Documentation/Changelog/10.3/Deprecation-89139-ConsoleCommandsConfigurationFormatCommandsPhp.rst new file mode 100644 index 0000000..84cbc24 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89139-ConsoleCommandsConfigurationFormatCommandsPhp.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89139: + +======================================================================== +Deprecation: #89139 - Console Commands configuration format Commands.php +======================================================================== + +See :issue:`89139` + +Description +=========== + +The console command configuration file format :php:`Configuration/Commands.php` +has been marked as deprecated in favor of the symfony service tag +:yaml:`console.command`. The tag allows to configure dependency injection and +command registration in one single location. + + +Impact +====== + +Providing a command configuration in :php:`Configuration/Commands.php` will +trigger a PHP :php:`E_USER_DEPRECATED` error when the respective commands have not already +been defined via symfony service tags. + +Extensions that provide both, the deprecated configuration file and service +tags, will not trigger a PHP :php:`E_USER_DEPRECATED` error in order to allow extensions to +support multiple TYPO3 major versions. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions that configure symfony console commands +via :php:`Configuration/Commands.php` and have not been migrated to add symfony +service tags. + + +Migration +========= + +Add the :yaml:`console.command` tag to command classes. Use the tag attribute :yaml:`command` +to specify the command name. The optional tag attribute :yaml:`schedulable` may be set +to false to exclude the command from the TYPO3 scheduler. + +.. code-block:: yaml + + services: + _defaults: + autowire: true + autoconfigure: true + public: false + + MyVendor\MyExt\Command\FooCommand: + tags: + - name: 'console.command' + command: 'my:command' + schedulable: false + +Command aliases are to be configured as separate tags. +The optional tag attribute :yaml:`alias` should be set to true for alias commands. + +.. code-block:: yaml + + MyVendor\MyExt\Command\BarCommand: + tags: + - name: 'console.command' + command: 'my:bar' + - name: 'console.command' + command: 'my:old-bar-command' + alias: true + schedulable: false + +.. index:: CLI, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-89463-SwitchableControllerActions.rst b/Documentation/Changelog/10.3/Deprecation-89463-SwitchableControllerActions.rst new file mode 100644 index 0000000..10c59d1 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89463-SwitchableControllerActions.rst @@ -0,0 +1,110 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89463: + +=================================================== +Deprecation: #89463 - Switchable Controller Actions +=================================================== + +See :issue:`89463` + +Description +=========== + +Switchable controller actions have been marked as deprecated and will be removed +in TYPO3 version 12.0. + +Switchable controller actions are used to override the allowed set of controllers and actions via TypoScript or plugin +flexforms. While this is convenient for reusing the same plugin for a lot of different use cases, it's also very +problematic as it completely overrides the original configuration defined via +:php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin`. + +Switchable controller actions therefore have bad implications that rectify their removal. + +First of all, switchable controller actions override the original configuration of plugins at runtime and possibly +depending on conditions which contradicts the idea of :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin` +being the authoritative way to define configuration. + +Using the same plugin as an entry point for many different functionalities contradicts the idea of a plugin serving one +specific purpose. Switchable controller actions allow for creating one central plugin that takes care of everything. + + +Impact +====== + +All plugins that are using switchable controller actions need to be split into multiple different plugins. Usually, one +would create a new plugin for each possible switchable controller actions configuration entry. + + +Affected Installations +====================== + +All installations that make use of switchable controller actions, either via flexform configuration of plugins or via +TypoScript configuration. + + +Migration +========= + +Unfortunately, an automatic migration is not possible. As switchable controller actions allowed to override the whole +configuration of allowed controllers and actions, the only way to migrate is to create dedicated plugins for each former +switchable controller actions configuration entry. + +Example: + +.. code-block:: xml + + <switchableControllerActions> + <TCEforms> + <label>switchable controller actions</label> + <config> + <renderType>selectSingle</renderType> + <items> + <numIndex index="1"> + <numIndex index="0">List</numIndex> + <numIndex index="1">Product->list</numIndex> + </numIndex> + <numIndex index="2"> + <numIndex index="0">Show</numIndex> + <numIndex index="1">Product->show</numIndex> + </numIndex> + </items> + </config> + </TCEforms> + </switchableControllerActions> + +This configuration would lead to the creation configuration of two different plugins like this: + +.. code-block:: php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'extension', + 'list', + [ + 'Product' => 'list' + ] + ); + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'extension', + 'show', + [ + 'Product' => 'show' + ] + ); + + +Advantages of Separate Plugins +------------------------------ + +When using separate plugins for each switchable controller action combination, +it is possible to properly define which action should be cached. + +In addition, TYPO3 v10 LTS allows to group plugins in FormEngine directly +to semantically register various plugins in one specific group. + +See :ref:`changelog-Feature-91008-ItemGroupingForTCASelectItems` +for more details. + + +.. index:: FlexForm, PHP-API, TypoScript, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.3/Deprecation-89673-ExtbasesWebRequestAndWebResponse.rst b/Documentation/Changelog/10.3/Deprecation-89673-ExtbasesWebRequestAndWebResponse.rst new file mode 100644 index 0000000..ded390a --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89673-ExtbasesWebRequestAndWebResponse.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89673: + +========================================================== +Deprecation: #89673 - Extbase's WebRequest and WebResponse +========================================================== + +See :issue:`89673` + +Description +=========== + +Both classes :php:`\TYPO3\CMS\Extbase\Mvc\Web\Request` and :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response` +have been marked as deprecated. Along with their deprecation, all relevant logic has been moved into their parent +classes :php:`\TYPO3\CMS\Extbase\Mvc\Request` and :php:`\TYPO3\CMS\Extbase\Mvc\Response`. + +This is done to simplify the request/response handling of Extbase and to ease the transition towards +a PSR-7 compatible handling. + + +Impact +====== + +There is no impact yet as the "web" versions of the request and response are still used by Extbase. +The only thing that is worth mentioning is that those who implement custom requests and/or responses +should derive from the non "web" versions now. + + +Affected Installations +====================== + +All installations that implement custom request/response objects that derive from +:php:`\TYPO3\CMS\Extbase\Mvc\Web\Request` and :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response`. + +Those who don't change the request/response handling, will not realize this change. + + +Migration +========= + +All installations that implement custom request/response objects that derive from +:php:`\TYPO3\CMS\Extbase\Mvc\Web\Request` and :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response` should now +derive from :php:`\TYPO3\CMS\Extbase\Mvc\Request` (and override the :php:`$format` property) and +:php:`\TYPO3\CMS\Extbase\Mvc\Response` (and override the :php:`shutdown` method). + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.3/Deprecation-89866-Global-TYPO3-information-related-constants.rst b/Documentation/Changelog/10.3/Deprecation-89866-Global-TYPO3-information-related-constants.rst new file mode 100644 index 0000000..2d0a582 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89866-Global-TYPO3-information-related-constants.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89866: + +================================================================ +Deprecation: #89866 - Global TYPO3-information related constants +================================================================ + +See :issue:`89866` + +Description +=========== + +The following global constants, which are initialized at the very +beginning of each TYPO3-related PHP process, have been marked as deprecated: + +* :php:`TYPO3_copyright_year` +* :php:`TYPO3_URL_GENERAL` +* :php:`TYPO3_URL_LICENSE` +* :php:`TYPO3_URL_EXCEPTION` +* :php:`TYPO3_URL_DONATE` +* :php:`TYPO3_URL_WIKI_OPCODECACHE` + +They have been migrated to the PHP class :php:`TYPO3\CMS\Core\Information\Typo3Information` +in order to benefit from opcaching, and to exactly reference when they are used +and where they are used throughout TYPO3. + +This allows for further optimizations during the Bootstrap process and in our +testing suites. + +In addition, the new PHP class encapsulates all global TYPO3-information and +community-wide information in one place. + + +Impact +====== + +No :php:`E_USER_DEPRECATED` error is triggered, however the constants will work during +TYPO3 v10, and be removed with TYPO3 v11. + + +Affected Installations +====================== + +Any TYPO3 installation with a custom extension that uses these +constants directly, which is highly unlikely. + + +Migration +========= + +Use the public class constants or the public methods of the +new PHP class :php:`TYPO3\CMS\Core\Information\Typo3Information` directly. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-89868-RemoveReqCHashFunctionalityForPlugins.rst b/Documentation/Changelog/10.3/Deprecation-89868-RemoveReqCHashFunctionalityForPlugins.rst new file mode 100644 index 0000000..323cb50 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89868-RemoveReqCHashFunctionalityForPlugins.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89868: + +=============================================================== +Deprecation: #89868 - Remove reqCHash functionality for plugins +=============================================================== + +See :issue:`89868` + +Description +=========== + +Extbase and pi-based plugins that are non-cacheable could previously +require the validation of the cHash GET parameter +in order to validate GET parameters against the "cHash". + +In Extbase plugins, this could be configured via a TypoScript feature toggle (enabled by default): + +:typoscript:`config.tx_extbase.features.requireCHashArgumentForActionArguments = 1` + +In Pi-based plugins the public property :php:`AbstractPlugin->pi_checkCHash` +was used to enable the cHash validation for non-cacheable plugins. + +Both plugin systems triggered the method :php:`TypoScriptFrontendController->reqCHash` which +validated relevant GET parameters. However, the :php:`PageArgumentValidator` PSR-15 middleware now +always validates the cHash, so a plugin does not need to know about cHash validation anymore and +therefore does not need to set the option. + +This means the options are not needed anymore, as the validation already happens during the Frontend +request handling process. The options are removed. + +In addition, the method :php:`TypoScriptFrontendController->reqCHash()` has been marked as deprecated and +is not in use anymore. + + +Impact +====== + +Setting the option in Extbase or Pi-Base has no effect anymore. + +Calling the PHP method :php:`TypoScriptFrontendController->reqCHash()` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Internal classes such as the :php:`CacheHashEnforcer` are removed. + + +Affected Installations +====================== + +TYPO3 installations with plugins, where one of the options is set, +or where the PHP method is called directly. + + +Migration +========= + +Remove the options / flags as they have no effect in TYPO3 v10 anymore. + +Calling the method directly is also not needed, as the PageArgumentValidator is executing this +validation now at every request. + +.. index:: Frontend, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/10.3/Deprecation-89870-NewPSR-14EventsForExtbase-relatedSignals.rst b/Documentation/Changelog/10.3/Deprecation-89870-NewPSR-14EventsForExtbase-relatedSignals.rst new file mode 100644 index 0000000..99dcbe4 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-89870-NewPSR-14EventsForExtbase-relatedSignals.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89870: + +=================================================================== +Deprecation: #89870 - New PSR-14 Events for Extbase-related signals +=================================================================== + +See :issue:`89870` + +Description +=========== + +The following signals have been marked as deprecated in favor of new PSR-14 events: + +- :php:`TYPO3\CMS\Extbase\Mvc\Dispatcher::afterRequestDispatch` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::beforeCallActionMethod` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::afterMappingSingleRow` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::beforeGettingObjectData` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterGettingObjectData` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::endInsertObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterUpdateObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterPersistObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterRemoveObject` + +The method :php:`emitBeforeCallActionMethodSignal` in :php:`ActionController` +has been marked as deprecated and is not called by Extbase itself anymore. + +Impact +====== + +Using any of the signals will still work as expected, but will trigger +a PHP :php:`E_USER_DEPRECATED` error. + +Calling the method :php:`emitBeforeCallActionMethodSignal` will trigger a +PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +TYPO3 installations with extensions using the Extbase framework and +Extbase-internal hooks. + + +Migration +========= + +The following new PSR-14-based Events should be used instead: + +- :php:`TYPO3\CMS\Extbase\Event\Mvc\AfterRequestDispatchedEvent` +- :php:`TYPO3\CMS\Extbase\Event\Mvc\BeforeActionCallEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\AfterObjectThawedEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\ModifyQueryBeforeFetchingObjectDataEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\ModifyResultAfterFetchingObjectDataEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityAddedToPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityUpdatedInPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityRemovedFromPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityPersistedEvent` + +.. index:: PHP-API, PartiallyScanned, ext:extbase diff --git a/Documentation/Changelog/10.3/Deprecation-90007-GlobalConstantsTYPO3_versionAndTYPO3_branch.rst b/Documentation/Changelog/10.3/Deprecation-90007-GlobalConstantsTYPO3_versionAndTYPO3_branch.rst new file mode 100644 index 0000000..4711fcd --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90007-GlobalConstantsTYPO3_versionAndTYPO3_branch.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90007: + +===================================================================== +Deprecation: #90007 - Global constants TYPO3_version and TYPO3_branch +===================================================================== + +See :issue:`90007` + +Description +=========== + +Two of the most "stable" global constants in the TYPO3 Core - :php:`TYPO3_version` and :php:`TYPO3_branch` have been marked as deprecated. + +The change was mainly driven by the necessity to minimize runtime-generated constants in order to optimize performance, also for op-caching. + +The same information is available in a new PHP class :php:`TYPO3\CMS\Core\Information\Typo3Version`, which also defines +the constants for backwards-compatibility reasons. + + +Impact +====== + +No PHP :php:`E_USER_DEPRECATED` error is triggered, however the constants will work during +TYPO3 v10 and TYPO3 v11, but will be removed with TYPO3 v12. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions accessing the constants, +which is common for having extension support for multiple TYPO3 versions. + + +Migration +========= + +It is highly recommended to use the :php:`Typo3Version` class instead of +the constants, as they will be removed in a future TYPO3 version. + +Check the Extension Scanner in the Upgrade section of TYPO3 to see +if any extensions you use might be affected. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-90019-PagePermissionLogicByDataHandler.rst b/Documentation/Changelog/10.3/Deprecation-90019-PagePermissionLogicByDataHandler.rst new file mode 100644 index 0000000..d39f779 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90019-PagePermissionLogicByDataHandler.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90019: + +========================================================== +Deprecation: #90019 - Page permission logic by DataHandler +========================================================== + +See :issue:`90019` + +Description +=========== + +A new :php:`PagePermissionAssembler` class builds the page permissions, allowing to thin out certain parts of :php:`DataHandlers` responsibilities. + +The following properties and methods within :php:`DataHandler` have been marked as deprecated: + +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->defaultPermissions` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->pMap` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->setTSconfigPermissions()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->assemblePermissions()` + +The following methods should only be called with integers as permission argument: + +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->doesRecordExist()` +* :php:`TYPO3\CMS\Core\DataHandling\DataHandler->recordInfoWithPermissionCheck()` + + +Impact +====== + +Calling the mentioned methods will trigger a PHP :php:`E_USER_DEPRECATED` error and will be removed in TYPO3 v11.0. + + +Affected Installations +====================== + +Any TYPO3 installation that enriches page permission handling and directly accesses the methods or properties in :php:`DataHandler`. + + +Migration +========= + +Ensure to use the new :php:`PagePermissionAssembler` PHP class +which serves as a proper API for creating page permissions. + +.. index:: PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-90249-PackageRelatedSignalSlotsMigratedToPSR-14Events.rst b/Documentation/Changelog/10.3/Deprecation-90249-PackageRelatedSignalSlotsMigratedToPSR-14Events.rst new file mode 100644 index 0000000..248ae52 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90249-PackageRelatedSignalSlotsMigratedToPSR-14Events.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90249: + +============================================================================ +Deprecation: #90249 - Package related Signal Slots migrated to PSR-14 events +============================================================================ + +See :issue:`90249` + +Description +=========== + +The following Signal Slots have been replaced by new PSR-14 events +which can be used as 1:1 equivalents: + +* :php:`PackageManagement::packagesMayHaveChanged` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionInstall` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionUninstall` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionT3DImport` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionStaticSqlImport` +* :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionFileImport` +* :php:`TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService::willInstallExtensions` +* :php:`TYPO3\CMS\Extensionmanager\ViewHelper\ProcessAvailableActionsViewHelper::processActions` + + +Impact +====== + +Using the mentioned signals will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using these signals. + + +Migration +========= + +Use the new PSR-14 alternatives: + +* :php:`TYPO3\CMS\Core\Package\Event\PackagesMayHaveChangedEvent` +* :php:`TYPO3\CMS\Core\Package\Event\AfterPackageActivationEvent` +* :php:`TYPO3\CMS\Core\Package\Event\AfterPackageDeactivationEvent` +* :php:`TYPO3\CMS\Core\Package\Event\BeforePackageActivationEvent` +* :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionDatabaseContentHasBeenImportedEvent` +* :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionStaticDatabaseContentHasBeenImportedEvent` +* :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionFilesHaveBeenImportedEvent` +* :php:`TYPO3\CMS\Extensionmanager\Event\AvailableActionsForExtensionEvent` + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-90258-SimplifiedRTEParserAPI.rst b/Documentation/Changelog/10.3/Deprecation-90258-SimplifiedRTEParserAPI.rst new file mode 100644 index 0000000..328d272 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90258-SimplifiedRTEParserAPI.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90258: + +=============================================== +Deprecation: #90258 - Simplified RTE Parser API +=============================================== + +See :issue:`90258` + +Description +=========== + +The PHP class :php:`RteHtmlParser` which is used to transform RTE-based +textarea fields from the database to the configured Rich Text Editor, and back, has a new simplified API. + +For this reason, the following two methods have been marked as deprecated: + +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->init()` +* :php:`TYPO3\CMS\Core\Html\RteHtmlParser->RTE_transform()` + + +Impact +====== + +Calling any of the methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with extensions dealing with extracting or adding content such as "l10nmgr", or any custom extension using +the methods. + + +Migration +========= + +The method :php:`TYPO3\CMS\Core\Html\RteHtmlParser->init()` can be removed without substitution, as it +serves no purpose anymore. + +The method :php:`TYPO3\CMS\Core\Html\RteHtmlParser->RTE_transform()` now has two methods as substitute, +depending on the direction which is necessary. This was previously +done in the third method argument ("rte" and "db"): + +- :php:`transformTextForRichTextEditor($content, $configuration)` +- :php:`transformTextForPersistence($content, $configuration)` + +The second argument :php:`$configuration` is now the `processing` configuration (`proc`) of the RTE configuration. + +.. index:: RTE, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-90260-ResourceFactorygetInstancePseudo-factory.rst b/Documentation/Changelog/10.3/Deprecation-90260-ResourceFactorygetInstancePseudo-factory.rst new file mode 100644 index 0000000..1035d27 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90260-ResourceFactorygetInstancePseudo-factory.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90260: + +================================================================= +Deprecation: #90260 - ResourceFactory::getInstance pseudo-factory +================================================================= + +See :issue:`90260` + +Description +=========== + +The method :php:`ResourceFactory::getInstance()` acts as a wrapper +for the constructor which originally was meant as a performance +improvement as pseudo-singleton concept in TYPO3 v4.7. + +However, :php:`ResourceFactory` was never optimized and now with Dependency +Injection, :php:`ResourceFactory` can be used directly. + +Therefore the method has been marked as deprecated. + + +Impact +====== + +Calling :php:`ResourceFactory::getInstance()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with custom PHP code calling the method. + + +Migration +========= + +Check TYPO3's "Extension Scanner" in the Install Tool if you're affected and replace with constructor injection via Dependency +Injection if possible, or use :php:`GeneralUtility::makeInstance(ResourceFactory::class)` instead. + +The latter can already applied in earlier versions (TYPO3 v7 or higher) to ease optimal migration of this deprecation. + +.. index:: FAL, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.3/Deprecation-90348-PageLayoutViewClass.rst b/Documentation/Changelog/10.3/Deprecation-90348-PageLayoutViewClass.rst new file mode 100644 index 0000000..53b4673 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90348-PageLayoutViewClass.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90348: + +========================================== +Deprecation: #90348 - PageLayoutView class +========================================== + +See :issue:`90348` + +Description +=========== + +The :php:`PageLayoutView` class, which is considered internal API, has been marked as deprecated in favor +of the new Fluid-based alternative which renders the "page" BE module. + + +Impact +====== + +Implementations which depend on :php:`PageLayoutView` should prepare to use the alternative implementation (by overlaying and overriding Fluid templates of EXT:backend). + + +Affected Installations +====================== + +* Any site which overrides the :php:`PageLayoutView` class. The overridden class will + still be instantiated when rendering previews in BE page module - but no methods + will be called on the instance **unless** they are called by a third party hook subscriber. +* Any site which depends on PSR-14 events associated with :php:`PageLayoutView` will only + have those events dispatched if the :php:`fluidBasedPageModule` feature flag is :php:`false`. + * Affects :php:`\TYPO3\CMS\Backend\View\Event\AfterSectionMarkupGeneratedEvent`. + * Affects :php:`\TYPO3\CMS\Backend\View\Event\BeforeSectionMarkupGeneratedEvent`. + + +Migration +========= + +Fluid templates can be extended or replaced to render custom header, footer or preview of +a given :typoscript:`CType`, see feature description for feature :issue:`90348`. + +.. index:: Backend, Fluid, NotScanned, ext:backend diff --git a/Documentation/Changelog/10.3/Deprecation-90390-BrokenLinkRepositorygetNumberOfBrokenLinksInLinkvalidator.rst b/Documentation/Changelog/10.3/Deprecation-90390-BrokenLinkRepositorygetNumberOfBrokenLinksInLinkvalidator.rst new file mode 100644 index 0000000..b8b1b55 --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90390-BrokenLinkRepositorygetNumberOfBrokenLinksInLinkvalidator.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90390: + +===================================================================================== +Deprecation: #90390 - BrokenLinkRepository::getNumberOfBrokenLinks() in linkvalidator +===================================================================================== + +See :issue:`90390` + +Description +=========== + +The method :php:`BrokenLinkRepository::getNumberOfBrokenLinks()` has been marked as deprecated. + + +Impact +====== + +Usage of the method triggers a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Every TYPO3 installation that uses the method. + + +Migration +========= + +Use :php:`BrokenLinkRepository::isLinkTargetBrokenLink()` instead. + +.. index:: Backend, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/10.3/Deprecation-90421-DocumentTemplate.rst b/Documentation/Changelog/10.3/Deprecation-90421-DocumentTemplate.rst new file mode 100644 index 0000000..2a88f5d --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90421-DocumentTemplate.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90421: + +====================================== +Deprecation: #90421 - DocumentTemplate +====================================== + +See :issue:`90421` + +Description +=========== + +The PHP class :php:`TYPO3\CMS\Backend\Template\DocumentTemplate`, +also available as :php:`$GLOBALS['TBE_TEMPLATE']` until TYPO3 v10.0 +served as a basis to render backend modules or HTML-based output +in TYPO3 Backend. + +Since TYPO3 v7, the new API via php:`ModuleTemplate` can be used instead. The :php:`DocumentTemplate` class has been marked as deprecated. + + +Impact +====== + +Instantiating the :php:`DocumentTemplate` class will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions adding backend modules using the DocumentTemplate API. +These can typically be identified by extensions that "worked" but somehow looked ugly since TYPO3 v7 due to CSS and HTML changes. + + +Migration +========= + +Use ModuleTemplate API instead, which can be built like this in a typical non-Extbase Backend controller (e.g. in an action such as "overviewAction"): + +.. code-block:: php + + $moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class); + $content = $this->getHtmlContentFromMyModule(); + $moduleTemplate->setTitle('My module'); + $moduleTemplate->setContent($content); + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($this->streamFactory->createStream($moduleTemplate->renderContent())); + + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/10.3/Deprecation-90522-TSFEPropertiesRegardingImages.rst b/Documentation/Changelog/10.3/Deprecation-90522-TSFEPropertiesRegardingImages.rst new file mode 100644 index 0000000..98764ff --- /dev/null +++ b/Documentation/Changelog/10.3/Deprecation-90522-TSFEPropertiesRegardingImages.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90522: + +====================================================== +Deprecation: #90522 - TSFE properties regarding images +====================================================== + +See :issue:`90522` + +Description +=========== + +The image related properties :php:`$imagesOnPage` and :php:`$lastImageInfo` of +:php:`TypoScriptFrontendController` have been marked as deprecated. + +Impact +====== + +Calling these properties will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All installations using these properties are affected. + +Migration +========= + +For :php:`$imagesOnPage` the AssetCollector may be used instead: + +.. code-block:: php + + $assetCollector = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(TYPO3\CMS\Core\Page\AssetCollector::class); + $imagesOnPage = $assetCollector->getMedia(); + +.. index:: Frontend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/10.3/Feature-78347-AddStdWrapPropertiesToFilesProcessor.rst b/Documentation/Changelog/10.3/Feature-78347-AddStdWrapPropertiesToFilesProcessor.rst new file mode 100644 index 0000000..d9244f8 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-78347-AddStdWrapPropertiesToFilesProcessor.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-78347: + +========================================================== +Feature: #78347 - Add StdWrap properties to FilesProcessor +========================================================== + +See :issue:`78347` + +Description +=========== + +StdWrap properties have been added to FLUIDTEMPLATEs FilesProcessor the same way as in FilesContentObject. +That way you can implement slide-functionality on rootline for file resources. + + +TypoScript dataProcessing example with FilesProcessor +----------------------------------------------------- + +.. code-block:: typoscript + + page.10 = FLUIDTEMPLATE + page.10.dataProcessing { + 10 = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor + 10 { + references.data = levelmedia: -1, slide + as = myfiles + } + + +Impact +====== + +The FilesProcessor can slide up and down the rootline to collect images for FLUID templates. +One usual feature is to use images attached to pages and use them up and down the page tree +for header images in frontend. + + +.. index:: TypoScript, Frontend diff --git a/Documentation/Changelog/10.3/Feature-78450-IntroducePreviewRendererPattern.rst b/Documentation/Changelog/10.3/Feature-78450-IntroducePreviewRendererPattern.rst new file mode 100644 index 0000000..31bc517 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-78450-IntroducePreviewRendererPattern.rst @@ -0,0 +1,147 @@ +.. include:: /Includes.rst.txt + +.. _feature-78450: + +=================================================== +Feature: #78450 - Introduce PreviewRenderer pattern +=================================================== + +See :issue:`78450` + +Pre-requisites +============== + +The :php:`PreviewRenderer` usage is only active if the "fluid based page layout module" feature is enabled. This feature +is activated by default in TYPO3 versions 10.3 and later. + +The feature toggle can be located in the `Settings` admin module under `Feature Toggles`. Or it can be set in +PHP using :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['fluidBasedPageModule'] = true;`. + + +Description +=========== + +A new pattern has been introduced to facilitate (record) previews in TYPO3. A default implementation has been +added which provides support for the previous methods of generating previews (content previews - using hooks +or by defining a Fluid template to render). + +The new pattern creates a strict contract for code which generates such previews and enables switching out the +implementation of both the resolving logic (which finds a preview renderer for a given table and record) as well +as the rendering logic (which now renders both the actual preview and has contract methods for adding wrapping). + +The main differences between the old and the new approach are: + +* The class used to render previews is now defined in :php:`TCA` and can be defined per-type or for any type. +* The resolver used to find preview renderers is a global implementation overridable in configuration. +* A single preview renderer will now be used. Before, hook subscribers had to toggle passed-by-reference flags. +* Wrapping is no longer forced to be a :html:`<span>` tag so you are not restricted to inline and inline-block display. +* Preview renderers have a public contract which splits up actual preview and wrapping, allowing third party renderers + to subclass the original renderer and for example only change the wrapping tag. +* Preview rendering can now be done ad-hoc. The pattern can be used from any context where the old pattern + could only be used (was only used) in the :php:`PageLayoutView` for content previews. + + +Impact +====== + +The feature adds two new concepts: + +* :php:`PreviewRendererResolver` which is a global implementation to detect which :php:`PreviewRenderer` a given record needs. +* :php:`PreviewRenderer` which is the class responsible for generating the preview and the wrapping. + + +Configuring the implementation +------------------------------ + +Individual preview renderers can be defined by using one of the following two approaches: + +.. code-block:: php + + $GLOBALS['TCA'][$table]['ctrl']['previewRenderer'] = My\PreviewRenderer::class; + + +This specifies the PreviewRenderer to be used for any record in :php:`$table`. + +Or if your table has a "type" field/attribute: + +.. code-block:: php + + $GLOBALS['TCA'][$table]['types'][$type]['previewRenderer'] = My\PreviewRenderer::class; + +This specifies the PreviewRenderer only for records of type :php:`$type` as determined by the type field of your table. + +Or finally, if your table and field have a :php:`subtype_value_field` TCA setting (like :php:`tt_content.list_type` for example) +and you want to register a preview renderer that applies only when that value is selected (e.g. when a certain plugin type +is selected and you can't match it with the "type" of the record alone): + +.. code-block:: php + + $GLOBALS['TCA'][$table]['types'][$type]['previewRenderer'][$subType] = My\PreviewRenderer::class; + +Where :php:`$type` is for example :php:`list` (indicating a plugin) and :php:`$subType` is the value of the :php:`list_type` field when the +type of plugin you want to target is selected as plugin type. + +.. note:: + The recommended location is in the :php:`ctrl` array in your extension's :file:`Configuration/TCA/$table.php` or + :file:`Configuration/TCA/Overrides/$table.php` file. The former is used when your extension is the one that creates the table, + the latter is used when you need to override TCA properties of tables added by the core or other extensions. + + +The PreviewRenderer interface +----------------------------- + +:php:`\TYPO3\CMS\Backend\Preview\PreviewRendererInterface` must be implemented by any :php:`PreviewRenderer` and contains some +API methods: + +.. code-block:: php + + /** + * Dedicated method for rendering preview header HTML for + * the page module only. Receives $item which is an instance of + * GridColumnItem which has a getter method to return the record. + * + * @param GridColumnItem + * @return string + */ + public function renderPageModulePreviewHeader(GridColumnItem $item); + + /** + * Dedicated method for rendering preview body HTML for + * the page module only. + * + * @param GridColumnItem $item + * @return string + */ + public function renderPageModulePreviewContent(GridColumnItem $item); + + /** + * Render a footer for the record to display in page module below + * the body of the item's preview. + * + * @param GridColumnItem $item + * @return string + */ + public function renderPageModulePreviewFooter(GridColumnItem $item): string; + + /** + * Dedicated method for wrapping a preview header and body HTML. + * + * @param string $previewHeader + * @param string $previewContent + * @param GridColumnItem $item + * @return string + */ + public function wrapPageModulePreview($previewHeader, $previewContent, GridColumnItem $item); + +Further methods are expected to be added to support generic preview rendering, e.g. usages outside PageLayoutView. +Implementing these methods allows you to control the exact composition of the preview. + +This means assuming your :php:`PreviewRenderer` returns :html:`<h4>Header</h4>` from the header render method and :html:`<p>Body</p>` from +the preview content rendering method and your wrapping method does :php:`return '<div>' . $previewHeader . $previewContent . '</div>';` then the +entire output becomes :html:`<div><h4>Header</h4><p>Body</p></div>` when combined. + +Should you wish to reuse parts of the default preview rendering and only change, for example, the method that renders +the preview body content, you can subclass :php:`\TYPO3\CMS\Backend\Preview\StandardContentPreviewRenderer` in your +own :php:`PreviewRenderer` class - and selectively override the methods from the API displayed above. + +.. index:: Backend, TCA diff --git a/Documentation/Changelog/10.3/Feature-79310-AddOptionsAndClipboardToFilelistSearch.rst b/Documentation/Changelog/10.3/Feature-79310-AddOptionsAndClipboardToFilelistSearch.rst new file mode 100644 index 0000000..987f531 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-79310-AddOptionsAndClipboardToFilelistSearch.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-79310: + +============================================================== +Feature: #79310 - Add options and clipboard to filelist search +============================================================== + +See :issue:`79310` + +Description +=========== + +The filelist backend module now shows the clipboard and the display options +checkboxes on search results. + + +Impact +====== + +Files and folders can now be put on the clipboard when using the search result +view of the filelist backend module. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/10.3/Feature-82062-ProgressForReferenceIndexUpdateOnCLI.rst b/Documentation/Changelog/10.3/Feature-82062-ProgressForReferenceIndexUpdateOnCLI.rst new file mode 100644 index 0000000..ff55032 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-82062-ProgressForReferenceIndexUpdateOnCLI.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-82062: + +============================================================ +Feature: #82062 - Progress for Reference Index update on CLI +============================================================ + +See :issue:`82062` + +Description +=========== + +The Reference Index updating process now shows the current status +when looping over each database table, to have a more visualized +status. + + +Impact +====== + +Calling `./typo3/sysext/core/bin/typo3 referenceindex:update -c` shows the new output when running the reference update (`-c` is for checking only). + +.. index:: CLI, ext:lowlevel diff --git a/Documentation/Changelog/10.3/Feature-83847-RemoveRepairedLinksFromLinkvalidatorListAfterEditing.rst b/Documentation/Changelog/10.3/Feature-83847-RemoveRepairedLinksFromLinkvalidatorListAfterEditing.rst new file mode 100644 index 0000000..1c23793 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-83847-RemoveRepairedLinksFromLinkvalidatorListAfterEditing.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _feature-83847: + +============================================================================= +Feature: #83847 - Remove repaired links from Linkvalidator list after editing +============================================================================= + +See :issue:`83847` + +Description +=========== + +In the list of broken links provided by Linkvalidator, it is possible to click +on the edit icon for a broken link in order to edit the record directly. + +If the record was edited, the list of broken links may no longer be up to date. + +There are now 2 possibilities, depending on how :php:`actionAfterEditRecord` +is configured: + +recheck (default): + The field is rechecked. (Warning: an RTE field may contain a number + of links, rechecking may lead to delays.) + + +setNeedsRecheck: + The entries in the list are marked as needing a recheck + +Prior to this feature, fixed broken links were not removed from the list, which made fixing +several links at a time confusing and tedious because you either had to +remember which links were already fixed or switch back and forth between +the *Report* and the *Check Links* tab to recheck for broken links. + + +Impact +====== + +This feature improves the workflow of fixing broken links. + +If the recheck option is selected, this may lead to some delays when +rechecking for broken links, especially if external links are involved. + +.. index:: Backend, ext:linkvalidator diff --git a/Documentation/Changelog/10.3/Feature-84214-AddCheckIfFieldsAreEditableForLinkvalidator.rst b/Documentation/Changelog/10.3/Feature-84214-AddCheckIfFieldsAreEditableForLinkvalidator.rst new file mode 100644 index 0000000..684f194 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-84214-AddCheckIfFieldsAreEditableForLinkvalidator.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _feature-84214: + +==================================================================== +Feature: #84214 - Add check if fields are editable for Linkvalidator +==================================================================== + +See :issue:`84214` + +Description +=========== + +Broken links should only be shown in the list of broken links, +if current backend user has edit access to the field. This way +the editor will no longer get an error message on trying to +edit records he has no permission to edit. + +Whether the editor has access depends on a number of factors. + +We check the following: + +* The current permissions of the page. For editing the page, the editor must have + Permission::PAGE_EDIT, for editing content Permission::CONTENT_EDIT must be available. +* The user has write access to the table. We check if the table + is in 'tables_modify' for the group(s). +* The user has write access to the field. We check if the field + is an exclude field. If yes, it must be included in + 'non_exclude_fields' for the group(s). +* The user has write permission for the language of the record. +* For tt_content: The CType is in list of explicitly allowed + values for authMode. + +Impact +====== + +* Broken links for fields that are not editable for the current backend + user will no longer be shown. +* Fields were added to the :sql:`tx_linkvalidator_link` table. "Analyze + Database Structure" must be executed. +* After an update to the new version, checking of broken links should + be reinitialized for the entire site. Until this is done, some broken + links may not be displayed for editors in the broken link report. + +.. index:: Backend, ext:linkvalidator diff --git a/Documentation/Changelog/10.3/Feature-86614-AddHookBeforeRenderingHrefLang.rst b/Documentation/Changelog/10.3/Feature-86614-AddHookBeforeRenderingHrefLang.rst new file mode 100644 index 0000000..c8c89cc --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-86614-AddHookBeforeRenderingHrefLang.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-86614: + +========================================================================== +Feature: #86614 - Add PSR-14 event to control hreflang tags to be rendered +========================================================================== + +See :issue:`86614` + +Description +=========== + +It is now possible to alter the hreflang tags just before they +get rendered. You can do this by registering an event listener for +the event :php:`TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent`. + +Also the class :php:`TYPO3\CMS\Seo\HrefLang\HrefLangGenerator` has been +refactored to be a listener (identifier :php:`'typo3-seo/hreflangGenerator'`) +to the newly introduced event. This way the system extension seo still +provides hreflang tags but it is now possible to simply register +after or instead of the implementation. + +Example +======= + +An example implementation could look like this: + +:file:`EXT:my_extension/Configuration/Services.yaml` + +.. code-block:: yaml + + services: + Vendor\MyExtension\HrefLang\EventListener\OwnHrefLang: + tags: + - name: event.listener + identifier: 'my-ext/ownHrefLang' + after: 'typo3-seo/hreflangGenerator' + event: TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent + +With :yaml:`after` and :yaml:`before`, you can make sure your own listener is +executed after or before the given identifiers. + +:file:`EXT:my_extension/Classes/HrefLang/EventListener/OwnHrefLang.php` + +.. code-block:: php + + namespace Vendor\MyExtension\HrefLang\EventListener; + + use TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent; + + class OwnHrefLang + { + public function __invoke(ModifyHrefLangTagsEvent $event): void + { + $hrefLangs = $event->getHrefLangs(); + $request = $event->getRequest(); + + // Do anything you want with $hrefLangs + $hrefLangs = [ + 'en-US' => 'https://example.com', + 'nl-NL' => 'https://example.com/nl' + ]; + + // Override all hrefLang tags + $event->setHrefLangs($hrefLangs); + + // Or add a single hrefLang tag + $event->addHrefLang('de-DE', 'https://example.com/de'); + } + } + +.. index:: ext:seo, PHP-API diff --git a/Documentation/Changelog/10.3/Feature-87072-AddedConfigurationOptionsForLockingAddedConfigurationOptionsForLocking.rst b/Documentation/Changelog/10.3/Feature-87072-AddedConfigurationOptionsForLockingAddedConfigurationOptionsForLocking.rst new file mode 100644 index 0000000..4df697f --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-87072-AddedConfigurationOptionsForLockingAddedConfigurationOptionsForLocking.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-87072: + +========================================================= +Feature: #87072 - Added Configuration Options for Locking +========================================================= + +See :issue:`87072` + +Description +=========== + +With change `Feature: #47712 - New Locking API +<https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/7.2/Feature-47712-NewLockingAPI.html>`__ a new Locking API was introduced. +This API can be extended. It provides three locking strategies and an interface for adding your own locking strategy in an extension. +However, until now, the default behaviour could not be changed using only the TYPO3 core. + +The introduction of new options makes some of the default properties of the locking API configurable: + +* The priority of each locking strategy can be changed. +* The directory where the lock files are written can be configured. + +Configuration example +--------------------- + +:file:`typo3conf/AdditionalConfiguration.php`: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['locking']['strategies'][\TYPO3\CMS\Core\Locking\FileLockStrategy::class]['priority'] = 10; + // The directory specified here must exist und must be a subdirectory of `Environment::getProjectPath()` + $GLOBALS['TYPO3_CONF_VARS']['SYS']['locking']['strategies'][\TYPO3\CMS\Core\Locking\FileLockStrategy::class]['lockFileDir'] = 'mylockdir'; + + +This sets the priority of FileLockStrategy to 10, thus making it the locking strategy with the lowest priority, which +will be chosen last by the LockFactory. + +The directory for storing the locks is changed to :file:`mylockdir`. + +Impact +====== + +For administrators +------------------ + +Nothing changes by default. The default values are used for the Locking API, same as before this change. + +If :file:`AdditionalConfiguration.php` is used to change Global Configuration settings for Locking API, and not used with care, +it can seriously compromise the stability of the system. As usual, when overriding Global Configuration with +:file:`LocalConfiguration.php` or :file:`AdditionalConfiguration.php`, great caution must be exercised. + +Specifically, do the following: + +* Test this on a test system first +* If you change the priorities, make sure your system fully supports the locking strategy which will be chosen by default. +* If you change the directory, make sure the directory exists and will always exist in the future. + +For developers +-------------- + +If a locking strategy is added by an extension, the priority and possibly directory for storing locks should be made +configurable as well. + +.. code-block:: php + + public static function getPriority() + { + return $GLOBALS['TYPO3_CONF_VARS']['SYS']['locking']['strategies'][self::class]['priority'] + ?? self::DEFAULT_PRIORITY; + } + +.. index:: ext:core diff --git a/Documentation/Changelog/10.3/Feature-87451-SchedulerRunCommandAcceptsMultipleTaskOptions.rst b/Documentation/Changelog/10.3/Feature-87451-SchedulerRunCommandAcceptsMultipleTaskOptions.rst new file mode 100644 index 0000000..f992ca1 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-87451-SchedulerRunCommandAcceptsMultipleTaskOptions.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-87451: + +===================================================================== +Feature: #87451 - scheduler:run command accepts multiple task options +===================================================================== + +See :issue:`87451` + +Description +=========== + +The `scheduler:run` command now accepts multiple `--task` options. + +The tasks will be executed in the order in which they are given: + +.. code-block:: bash + + ./typo3/sysext/core/bin/typo3 scheduler:run --task 1 --task 2 + + +It is now also possible to pass verbose flags to the command to get more information about what is +going on. + +A single `-v` flag will output errors only. Two `-vv` flags will also output additional information. + +Impact +====== + +The new feature allows the execution of tasks in a given order. + +This can be used to debug side effects between tasks that are executed within the same scheduler run. + +.. index:: CLI, ext:scheduler diff --git a/Documentation/Changelog/10.3/Feature-88147-AddPossibilityToConfigureThePathToSitemapXslFile.rst b/Documentation/Changelog/10.3/Feature-88147-AddPossibilityToConfigureThePathToSitemapXslFile.rst new file mode 100644 index 0000000..6a1e3ea --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-88147-AddPossibilityToConfigureThePathToSitemapXslFile.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-88147: + +========================================================================== +Feature: #88147 - Add possibility to configure the path to sitemap xslFile +========================================================================== + +See :issue:`88147` + +Description +=========== + +The xsl file to create a layout for a XML sitemap can now be configured on three levels: + +1. for all sitemaps: + + .. code-block:: typoscript + + plugin.tx_seo.config.xslFile = EXT:myext/Resources/Public/CSS/mySite.xsl + +2. for all sitemaps of a certain sitemapType: + + .. code-block:: typoscript + + plugin.tx_seo.config.<sitemapType>.sitemaps.xslFile = EXT:myext/Resources/Public/CSS/mySite.xsl + +3. for a specific sitemap: + + .. code-block:: typoscript + + plugin.tx_seo.config.<sitemapType>.sitemaps.<sitemap>.config.xslFile = EXT:myext/Resources/Public/CSS/mySite.xsl + +Impact +====== + +The value is inherited until it is overwritten. + +If no value is specified at all, :file:`EXT:seo/Resources/Public/CSS/Sitemap.xsl` is used as default like before. + +.. index:: Frontend, TypoScript, ext:seo diff --git a/Documentation/Changelog/10.3/Feature-88818-IntroduceEventsToModifyCKEditorConfiguration.rst b/Documentation/Changelog/10.3/Feature-88818-IntroduceEventsToModifyCKEditorConfiguration.rst new file mode 100644 index 0000000..b67071c --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-88818-IntroduceEventsToModifyCKEditorConfiguration.rst @@ -0,0 +1,76 @@ +.. include:: /Includes.rst.txt + +.. _feature-88818: + +=================================================================== +Feature: #88818 - Introduce events to modify CKEditor configuration +=================================================================== + +See :issue:`88818` + +Description +=========== + +The following new PSR-14-based Events are introduced which allow +to modify CKEditor configuration. + +- :php:`TYPO3\CMS\RteCKEditor\Form\Element\Event\AfterGetExternalPluginsEvent` +- :php:`TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforeGetExternalPluginsEvent` +- :php:`TYPO3\CMS\RteCKEditor\Form\Element\Event\AfterPrepareConfigurationForEditorEvent` +- :php:`TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforePrepareConfigurationForEditorEvent` + +Example +======= + +An example implementation how you could extend the existing +configuration to register a new plugin: + +:file:`EXT:my_extension/Configuration/Services.yaml` + +.. code-block:: yaml + + services: + Vendor\MyExtension\EventListener\RteConfigEnhancer: + tags: + - name: event.listener + identifier: 'ext-myextension/rteConfigEnhancer' + method: 'beforeGetExternalPlugins' + event: TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforeGetExternalPluginsEvent + - name: event.listener + identifier: 'ext-myextension/rteConfigEnhancer' + method: 'beforePrepareConfiguration' + event: TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforePrepareConfigurationForEditorEvent + +:file:`EXT:my_extension/Classes/EventListener/RteConfigEnhancer.php` + +.. code-block:: php + + namespace Vendor\MyExtension\EventListener; + + use TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforeGetExternalPluginsEvent; + use TYPO3\CMS\RteCKEditor\Form\Element\Event\BeforePrepareConfigurationForEditorEvent; + + class RteConfigEnhancer + { + public function beforeGetExternalPlugins(BeforeGetExternalPluginsEvent $event): void + { + $data = $event->getData(); + // @todo make useful decisions on fetched data + $configuration = $event->getConfiguration(); + $configuration['example_plugin'] = [ + 'resource' => 'EXT:my_extension/Resources/Public/CKEditor/Plugins/ExamplePlugin/plugin.js' + ]; + $event->setConfiguration($configuration); + } + + public function beforePrepareConfiguration(BeforePrepareConfigurationForEditorEvent $event): void + { + $data = $event->getData(); + // @todo make useful decisions on fetched data + $configuration = $event->getConfiguration(); + $configuration['extraPlugins'][] = 'example_plugin'; + $event->setConfiguration($configuration); + } + } + +.. index:: Backend, PHP-API, RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/10.3/Feature-88901-RenderAllFieldsInElementInformationController.rst b/Documentation/Changelog/10.3/Feature-88901-RenderAllFieldsInElementInformationController.rst new file mode 100644 index 0000000..b78eb52 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-88901-RenderAllFieldsInElementInformationController.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-88901: + +=================================================================== +Feature: #88901 - Render all fields in ElementInformationController +=================================================================== + +See :issue:`88901` + +Description +=========== + +The element information modal now shows all fields of the current record and +the selected type. + + +Impact +====== + +The TCA configuration :php:`showRecordFieldList` inside the section :php:`interface` is +not evaluated anymore and all occurrences have been removed. + +A migration wizard is available that removes the option from your TCA and adds +a deprecation message to the deprecation log where code adaption has to take place. + +.. index:: Backend diff --git a/Documentation/Changelog/10.3/Feature-88921-NewEventInThePageLayoutViewClassToEnrichContentIntoTheColumnsInTheBackendLayout.rst b/Documentation/Changelog/10.3/Feature-88921-NewEventInThePageLayoutViewClassToEnrichContentIntoTheColumnsInTheBackendLayout.rst new file mode 100644 index 0000000..c1cf795 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-88921-NewEventInThePageLayoutViewClassToEnrichContentIntoTheColumnsInTheBackendLayout.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _feature-88921: + +=============================================================== +Feature: #88921 - New PSR-14 events in the PageLayoutView class +=============================================================== + +See :issue:`88921` + +Description +=========== + +Two new PSR-14 events have been added to the :php:`PageLayoutView` class. +Those events can be used to add content into any column of a BackendLayout. +You can use this for example to show some content in a column without a ``colPos`` assigned. + +The event :php:`BeforeSectionMarkupGeneratedEvent` can be used to add content above +the content elements of the column. The event :php:`AfterSectionMarkupGeneratedEvent` +can be used to add content below the content elements of the column. + +You can use business logic to show content in specific columns. +E.g. for displaying content only in columns without any ``colPos`` +in the BackendLayout configuration. + +Example how to register the event listener in your own extension: + +:file:`EXT:my_extension/Configuration/Services.yaml` + +.. code-block:: yaml + + services: + Vendor\MyExtension\Backend\View\PageLayoutViewDrawEmptyColposContent: + tags: + - name: event.listener + identifier: 'myColposListener' + before: 'backend-empty-colpos' + event: TYPO3\CMS\Backend\View\Event\AfterSectionMarkupGeneratedEvent + +With :yaml:`before` and :yaml:`after`, you can make sure your own listener is +executed before or after the given identifiers. + +:file:`EXT:my_extension/Classes/Backend/View/PageLayoutViewDrawEmptyColposContent.php` + +.. code-block:: php + + <?php + namespace Vendor\MyExtension\Backend\View; + + class PageLayoutViewDrawEmptyColposContent + { + public function __invoke(AfterSectionMarkupGeneratedEvent $event): void + { + if ( + !isset($event->getColumnConfig()['colPos']) + || trim($event->getColumnConfig()['colPos']) === '' + ) { + $content = $event->getContent(); + $content .= <<<EOD + <div class="t3-page-ce-wrapper"> + <div class="t3-page-ce"> + <div class="t3-page-ce-header">Empty colpos</div> + <div class="t3-page-ce-body"> + <div class="t3-page-ce-body-inner"> + <div class="row"> + <div class="col-xs-12"> + This column has no "colPos". This is only for display Purposes. + </div> + </div> + </div> + </div> + </div> + </div> + EOD; + + $event->setStopRendering(true); + $event->setContent($content); + } + } + } + +With the :php:`$event->setStopRendering()` method, +you can make sure that no other listeners are triggered after the current listener. + +.. index:: ext:backend, PHP-API diff --git a/Documentation/Changelog/10.3/Feature-88962-Re-implementOldPIDupinRootlineTypoScriptCondition.rst b/Documentation/Changelog/10.3/Feature-88962-Re-implementOldPIDupinRootlineTypoScriptCondition.rst new file mode 100644 index 0000000..43e8a38 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-88962-Re-implementOldPIDupinRootlineTypoScriptCondition.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-88962: + +======================================================================= +Feature: #88962 - Re-implement old PIDupinRootline TypoScript condition +======================================================================= + +See :issue:`88962` + +Description +=========== + +The :typoscript:`PIDupinRootline` condition in TypoScript has been reimplemented within the Symfony +expression language. + +A new property :typoscript:`tree.rootLineParentIds` has been added to the :typoscript:`tree` object which +is available in the Symfony expression language to provide checks for all parent +page IDs of the current rootline. + +Impact +====== + +When using the classic :typoscript:`PIDupinRootline` condition, you can easily switch to the +condition with the new expression: + +Old TypoScript condition syntax: + +.. code-block:: typoscript + + [PIDupinRootline = 30] + page.10.value = I'm on any subpage of page with uid=30. + [END] + +New TypoScript condition syntax: + +.. code-block:: typoscript + + [30 in tree.rootLineParentIds] + page.10.value = I'm on any subpage of page with uid=30. + [end] + +.. index:: Backend, Frontend, TypoScript diff --git a/Documentation/Changelog/10.3/Feature-89032-RenderFieldControlForSelectSingleElement.rst b/Documentation/Changelog/10.3/Feature-89032-RenderFieldControlForSelectSingleElement.rst new file mode 100644 index 0000000..943e3fa --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89032-RenderFieldControlForSelectSingleElement.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-89032: + +============================================================= +Feature: #89032 - Render fieldControl for SelectSingleElement +============================================================= + +See :issue:`89032` + +Description +=========== + +The missing rendering for the :html:`fieldControl` option for SelectSingleElements was added. + + +Impact +====== + +It is now possible to use the :html:`fieldControl` option for SelectSingleElements +to add nodes and wizards. + +For example, add a link popup button to a select called "field_name" of the pages table: + +.. code-block:: php + + $GLOBALS['TCA']['pages']['columns']['field_name']['config']['fieldControl']['linkPopup'] = [ + 'renderType' => 'linkPopup', + ]; + + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst b/Documentation/Changelog/10.3/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst new file mode 100644 index 0000000..27ad6fe --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89139-AddDependencyInjectionSupportForConsoleCommands.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _feature-89139: + +======================================================================= +Feature: #89139 - Add dependency injection support for console commands +======================================================================= + +See :issue:`89139` + +Description +=========== + +Support for dependency injection in console commands has been added. + +Command dependencies can now be injected via constructor or other injection techniques. +Therefore, a new dependency injection tag :yaml:`console.command` has been added. +Commands tagged with :yaml:`console.command` are lazy loaded. That means they will only be +instantiated when they are actually executed, when the `help` subcommand is executed, +or when available schedulable commands are iterated. + +The legacy command definition format :file:`Configuration/Commands.php` has been marked as deprecated. + + +Impact +====== + +It is recommended to configure dependency injection tags for all commands, as the legacy command +definition format :file:`Configuration/Commands.php` will be removed in TYPO3 v11. + +Commands that have been configured via :yaml:`console.command` tag override legacy commands from +:file:`Configuration/Commands.php` without triggering a PHP :php:`E_USER_DEPRECATED` error for those commands. +Backwards compatibility with older TYPO3 version can be achieved by specifying both variants, +legacy configuration in :file:`Configuration/Commands.php` and new configuration via +:yaml:`console.command` tag. + + +Usage +===== + +Add the :yaml:`console.command` tag to command classes. +Use the tag attribute :yaml:`command` to specify the command name. +The optional tag attribute :yaml:`schedulable` may be set to false +to exclude the command from the TYPO3 scheduler. + +:file:`your_extension/Configuration/Services.yaml` + +.. code-block:: yaml + + services: + _defaults: + autowire: true + autoconfigure: true + public: false + + MyVendor\MyExt\Command\FooCommand: + tags: + - name: 'console.command' + command: 'my:command' + schedulable: false + +Command aliases are to be configured as separate tags. +The optional tag attribute :yaml:`alias` should be set to true for alias commands. + +.. code-block:: yaml + + MyVendor\MyExt\Command\BarCommand: + tags: + - name: 'console.command' + command: 'my:bar' + - name: 'console.command' + command: 'my:old-bar-command' + alias: true + schedulable: false + + +.. index:: CLI, PHP-API, ext:core diff --git a/Documentation/Changelog/10.3/Feature-89551-AddFluidAdditionalAttributesToTheFormElement.rst b/Documentation/Changelog/10.3/Feature-89551-AddFluidAdditionalAttributesToTheFormElement.rst new file mode 100644 index 0000000..ea0a2db --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89551-AddFluidAdditionalAttributesToTheFormElement.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-89551: + +=================================================================== +Feature: #89551 - Add fluidAdditionalAttributes to the form element +=================================================================== + +See :issue:`89551` + +Description +=========== + +Allows to configure :yaml:`fluidAdditionalAttributes` within a form element: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Form: + renderingOptions: + fluidAdditionalAttributes: + novalidate: 'novalidate' + + +Impact +====== + +For projects using their own Form template, the following attribute can be set on viewhelper :html:`formvh:form` as attribute: +:html:`additionalAttributes="{formvh:translateElementProperty(element: form, property: 'fluidAdditionalAttributes')}"` + +.. index:: Fluid, ext:form diff --git a/Documentation/Changelog/10.3/Feature-89644-AddOptionalArgumentFieldsToEditRecordViewHelpers.rst b/Documentation/Changelog/10.3/Feature-89644-AddOptionalArgumentFieldsToEditRecordViewHelpers.rst new file mode 100644 index 0000000..d00d169 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89644-AddOptionalArgumentFieldsToEditRecordViewHelpers.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-89644: + +========================================================================== +Feature: #89644 - Add optional argument "fields" to editRecord ViewHelpers +========================================================================== + +See :issue:`89644` + +Description +=========== + +An optional argument "fields" is added to the :html:`uri.editRecord` and :html:`link.editRecord` ViewHelper. +This can contain the names of one or more database fields (comma separated). + +If the argument "fields" is set, FormEngine creates a form to edit only these fields. + + +Impact +====== + +This ViewHelper passes the value given in the :html:`fields` argument to the backend route +`/record/edit` as :html:`columnsOnly` argument. + +The functionality for :html:`columnsOnly` has always been there for the backend route +`/record/edit` even before this patch. + +Example +======= + +Create a link to edit the `tt_content.bodytext` field of record with uid 42: + +.. code-block:: xml + + <be:link.editRecord uid="42" table="tt_content" fields="bodytext" returnUrl="foo/bar"> + Edit record + </be:link.editRecord> + +Output: + +.. code-block:: html + + + <a href="/typo3/index.php?route=/record/edit&edit[tt_content][42]=edit&returnUrl=foo/bar&columnsOnly=bodytext"> + Edit record + </a> + + +.. index:: Fluid, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-89650-AllowLineBreaksInTCADescriptions.rst b/Documentation/Changelog/10.3/Feature-89650-AllowLineBreaksInTCADescriptions.rst new file mode 100644 index 0000000..33c05cf --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89650-AllowLineBreaksInTCADescriptions.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-89650: + +======================================================= +Feature: #89650 - Allow line breaks in TCA descriptions +======================================================= + +See :issue:`89650` + +Description +=========== + +TCA description texts are passed through :php:`nl2br()` to allow line breaks which make longer description texts easier to read. + +Impact +====== + + +To make use of this feature simply format your description text with new lines. Those will be converted to :html:`<br>` tags when creating the output. +Installations that make use of TCA descriptions heavily might want to double check the formatting of those texts to avoid unwanted new lines. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-89738-ApiForAjaxRequests.rst b/Documentation/Changelog/10.3/Feature-89738-ApiForAjaxRequests.rst new file mode 100644 index 0000000..057785a --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89738-ApiForAjaxRequests.rst @@ -0,0 +1,219 @@ +.. include:: /Includes.rst.txt + +.. _feature-89738: + +======================================= +Feature: #89738 - API for AJAX Requests +======================================= + +See :issue:`89738` + +Description +=========== + +Request +------- + +In order to become independent of jQuery, a new API to perform AJAX requests has been introduced. This API implements +the `fetch API`_ available in all modern browsers. + +To send a request, a new instance of `AjaxRequest` must be created which receives a single argument: + +* :js:`url` (string) - The endpoint to send the request to + +For compatibility reasons the :js:`Promise` prototype is extended to have basic support for jQuery's :js:`$.Deferred()`. +In all erroneous cases, the internal promise is rejected with an instance of `AjaxResponse` containing the original +`response object`_. + +withQueryArguments() +~~~~~~~~~~~~~~~~~~~~ + +Clones the current request object and sets query arguments used for requests that get sent. + +This method receives the following arguments: + +* :js:`queryArguments` (string | array | object) - Optional: Query arguments to append to the url + +The method returns a clone of the AjaxRequest instance. + + +get() +~~~~~ + +Sends a `GET` requests to the configured endpoint. + +This method receives the following arguments: + +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to an `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const request = new AjaxRequest('https://httpbin.org/json'); + request.get().then( + async function (response) { + const data = await response.resolve(); + console.log(data); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +post() +~~~~~~ + +Sends a `POST` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to an `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const body = { + foo: 'bar', + baz: 'quo' + }; + const init = { + mode: 'cors' + }; + const request = new AjaxRequest('https://example.com'); + request.post(body, init).then( + async function (response) { + console.log('Data has been sent'); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +put() +~~~~~ + +Sends a `PUT` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to an `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const fileField = document.querySelector('input[type="file"]'); + const body = { + file: fileField.files[0], + username: 'Baz Bencer' + }; + const request = new AjaxRequest('https://example.com'); + request.put(body).then(null, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + }); + }); + + +delete() +~~~~~~~~ + +Sends a `DELETE` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to an `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const request = new AjaxRequest('https://httpbin.org/delete'); + request.delete().then(null, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +abort() +~~~~~~~~~~ + +Aborts the request by using its instance of `AbortController`_. + + +Response +-------- + +Each response received is wrapped in an :js:`AjaxResponse` object. This object contains some methods to handle the response. + +resolve() +~~~~~~~~~ + +Converts and returns the response body according to the **received** `Content-Type` header either into JSON or plaintext. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + new AjaxRequest('https://httpbin.org/json').get().then( + async function (response) { + // Response is automatically converted into a JSON object + const data = await response.resolve(); + console.log(data); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +raw() +~~~~~ + +Returns the original response object, which is useful for e.g. add additional handling for specific headers in application +logic or to check the response status. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + new AjaxRequest('https://httpbin.org/status/200').get().then( + function (response) { + const raw = response.raw(); + if (raw.headers.get('Content-Type') !== 'application/json') { + console.warn('We didn\'t receive JSON, check your request.'); + } + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +.. _`fetch API`: https://developer.mozilla.org/docs/Web/API/Fetch_API +.. _`request configuration`: https://developer.mozilla.org/en-US/docs/Web/API/Request#Properties +.. _`response object`: https://developer.mozilla.org/en-US/docs/Web/API/Response +.. _`AbortController`: https://developer.mozilla.org/en-US/docs/Web/API/AbortController + +.. index:: JavaScript, ext:core diff --git a/Documentation/Changelog/10.3/Feature-89870-NewPSR-14EventsForExtbase-relatedSignals.rst b/Documentation/Changelog/10.3/Feature-89870-NewPSR-14EventsForExtbase-relatedSignals.rst new file mode 100644 index 0000000..fe62206 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89870-NewPSR-14EventsForExtbase-relatedSignals.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-89870: + +=============================================================== +Feature: #89870 - New PSR-14 Events for Extbase-related signals +=============================================================== + +See :issue:`89870` + +Description +=========== + +The following new PSR-14-based Events are introduced which allow +to modify various concerns in the MVC and persistence stacks of Extbase internals. + +- :php:`TYPO3\CMS\Extbase\Event\Mvc\AfterRequestDispatchedEvent` +- :php:`TYPO3\CMS\Extbase\Event\Mvc\BeforeActionCallEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\AfterObjectThawedEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\ModifyQueryBeforeFetchingObjectDataEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\ModifyResultAfterFetchingObjectDataEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityAddedToPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityFinalizedAfterPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityUpdatedInPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityRemovedFromPersistenceEvent` +- :php:`TYPO3\CMS\Extbase\Event\Persistence\EntityPersistedEvent` + + +Impact +====== + +Existing signals are replaced and should not be used anymore, as PSR-14 event classes exactly specify what can be modified or listened to. + +The following signals should not be used anymore then: + +- :php:`TYPO3\CMS\Extbase\Mvc\Dispatcher::afterRequestDispatch` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::beforeCallActionMethod` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::afterMappingSingleRow` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::beforeGettingObjectData` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterGettingObjectData` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterInsertObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::endInsertObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterUpdateObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterPersistObject` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterRemoveObject` + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/10.3/Feature-89894-SeparateSystemExtensionsFrom3rd-partyExtensionsVisually.rst b/Documentation/Changelog/10.3/Feature-89894-SeparateSystemExtensionsFrom3rd-partyExtensionsVisually.rst new file mode 100644 index 0000000..30d0506 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89894-SeparateSystemExtensionsFrom3rd-partyExtensionsVisually.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-89894: + +=============================================================================== +Feature: #89894 - Separate system extensions from 3rd-party extensions visually +=============================================================================== + +See :issue:`89894` + +Description +=========== + +The Extension Manager in TYPO3 allows backend users to list, activate, deactivate, configure +and possibly add/remove extensions from the system. When using the Extension Manager, +backend users work with either core extensions (system extensions) or 3rd-party extensions, +depending on their task. + +The extension list shown in the Extension Manager can now be filtered by certain extension types (system and 3rd-party extensions). + + +Impact +====== + +A limited list of extensions, either system or 3rd-party extensions, makes it easier for backend users +to find the extension they intend to work with and/or to get a quick overview which extensions are +currently installed (e.g. 3rd-party extensions). This improves the usability of the backend for integrators/administrators. + +.. index:: Backend, ext:extensionmanager diff --git a/Documentation/Changelog/10.3/Feature-89929-GalicianFlag.rst b/Documentation/Changelog/10.3/Feature-89929-GalicianFlag.rst new file mode 100644 index 0000000..015bea8 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89929-GalicianFlag.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-89929: + +=============================== +Feature: #89929 - Galician flag +=============================== + +See :issue:`89929` + +Description +=========== + +When adding a new language to a site or the system (`sys_language`) the Galician flag (ISO-639-1 Code "gl") is now available for selection. + + +Impact +====== + +A previous error, where the flag for Greenlandic was available (ISO Code "kl") under the "GL" shortcut, was resolved with this feature, +as both flags now represent the proper ISO code. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/10.3/Feature-89978-IntroduceStatusReportForInsecureExceptionHandlerSettings.rst b/Documentation/Changelog/10.3/Feature-89978-IntroduceStatusReportForInsecureExceptionHandlerSettings.rst new file mode 100644 index 0000000..0217c52 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-89978-IntroduceStatusReportForInsecureExceptionHandlerSettings.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-89978: + +================================================================================= +Feature: #89978 - Introduce Status Report for insecure exception handler settings +================================================================================= + +See :issue:`89978` + +Description +=========== + +When using a debug exception handler in production (either by configuring it explicitly +or by using the wrong application context) stack traces may disclose information. +To avoid such setups a new status report has been introduced that warns administrators if a debug exception handler is configured. + + +Impact +====== + +To mitigate the information disclosure, a new status report has +been introduced: + +- if display errors is set to 1 (-> uses DebugExceptionHandler setting) + and context is Production, an Error is displayed +- if display errors is set to 1 (-> uses DebugExceptionHandler setting) + and context is Development, a Warning is displayed +- if the production exception handler setting is configured to use the + DebugExceptionHandler, an Error is displayed + +.. index:: Backend, LocalConfiguration, ext:reports diff --git a/Documentation/Changelog/10.3/Feature-90026-ExposeInternalTypoLinkPartsInTypolinkViewHelper.rst b/Documentation/Changelog/10.3/Feature-90026-ExposeInternalTypoLinkPartsInTypolinkViewHelper.rst new file mode 100644 index 0000000..429917d --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90026-ExposeInternalTypoLinkPartsInTypolinkViewHelper.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-90026: + +===================================================================== +Feature: #90026 - Expose internal typoLinkParts in TypolinkViewHelper +===================================================================== + +See :issue:`90026` + +Description +=========== + +Parameters being generated internally by TypoLink using +:html:`<f:link.typolink parts-as="typoLinkParts">` view helper are exposed as +variable and can be used in Fluid templates. + +View helper attribute :html:`parts-as` (default :html:`typoLinkParts`) allows to define the +variable name to be used containing the following internal parts: + +* url +* target +* class +* title +* additionalParams + +Details for these internal parts are documented for :typoscript:`typolink.parameter` +in `TypoScript reference`_ + +.. _TypoScript reference: https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html?highlight=typolink#parameter + +Impact +====== + +Multiple instructions for attribute :html:`parameter` (e.g. persisted to entity +record) can be used individually. + +.. code-block:: html + + <f:link.typolink parameter="123 _top news title" parts-as="parts"> + {parts.url} + {parts.target} + {parts.class} + {parts.title} + {parts.additionalParams} + </f:link.typolink> + +.. index:: Fluid, Frontend, ext:fluid diff --git a/Documentation/Changelog/10.3/Feature-90042-SpecialPageIconsCustomizableByDoktype.rst b/Documentation/Changelog/10.3/Feature-90042-SpecialPageIconsCustomizableByDoktype.rst new file mode 100644 index 0000000..2a178d0 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90042-SpecialPageIconsCustomizableByDoktype.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-90042: + +========================================================= +Feature: #90042 - Customize special page icons by doktype +========================================================= + +See :issue:`90042` + +Description +=========== + +The page icon in the pagetree can now be fully customized for own doktypes. +Before this it was possible to provide one icon. This icon however was not used when the page was in one of the following states: + +* Page is hidden in navigation +* Page is site-root +* Page contains content from another page +* Page contains content from another page AND is hidden in navigation + +Provide custom icons in TCA like so: + +:file:`EXT:my_extension/Configuration/TCA/Overrides/pages.php` + +.. code-block:: php + + 'ctrl' => [ + 'typeicon_classes' => [ + '123' => "your-icon", + '123-contentFromPid' => "your-icon-contentFromPid", + '123-root' => "your-icon-root", + '123-hideinmenu' => "your-icon-hideinmenu", + ], + ] + +Icons you don't provide will automatically fall back to the variant for regular page doktypes. + +.. note:: + + Make sure to add the additional icons using the IconRegistry! + +.. index:: TCA, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90052-AddYamlConfigurationToConfigurationModule.rst b/Documentation/Changelog/10.3/Feature-90052-AddYamlConfigurationToConfigurationModule.rst new file mode 100644 index 0000000..0ee9ee1 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90052-AddYamlConfigurationToConfigurationModule.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _feature-90052: + +=========================================================================== +Feature: #90052 - Form YAML configuration available in configuration module +=========================================================================== + +See :issue:`90052` + +Description +=========== + +If the Form system extension is installed, a new entry +``Form: YAML Configuration`` is available in the menu of the +``SYSTEM > Configuration`` module of the lowlevel system extension. +When selected, the parsed YAML configuration of the form setup is displayed. + +.. index:: Backend, ext:lowlevel, ext:form + diff --git a/Documentation/Changelog/10.3/Feature-90068-ImplementBetterFileDumpController.rst b/Documentation/Changelog/10.3/Feature-90068-ImplementBetterFileDumpController.rst new file mode 100644 index 0000000..6ad33df --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90068-ImplementBetterFileDumpController.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _feature-90068: + +===================================================================== +Feature: #90068 - Implement better FileDumpController +===================================================================== + +See :issue:`90068` + +Description +=========== + +FileDumpController can now process UIDs of sys_file_reference records and +can adopt image sizes to records of sys_file. + +Following URI-Parameters are now possible: + ++ :php:`t` (*Type*): Can be one of :php:`f` (`sys_file`), :php:`r` (`sys_file_reference`) or :php:`p` (`sys_file_processedfile`) ++ :php:`f` (*File*): Use it for a UID of table :sql:`sys_file` ++ :php:`r` (*Reference*): Use it for a UID of table :sql:`sys_file_reference` ++ :php:`p` (*Processed*): Use it for a UID of table :sql:`sys_file_processedfile` ++ :php:`s` (*Size*): Use it for a UID of table :sql:`sys_file_processedfile` ++ :php:`cv` (*CropVariant*): In case of `sys_file_reference`, you can assign it a cropping variant + +You have to choose one of these parameters: :php:`f`, :php:`r` or :php:`p`. It is not possible +to use them multiple times in one request. + +The Parameter :php:`s` has following syntax: width:height:minW:minH:maxW:maxH. You +can leave this Parameter empty to load the file in original size. Parameter :php:`width` +and :php:`height` can feature the trailing :typoscript:`c` or :typoscript:`m` indicator like known from TS. + +See the following example on how to create a URI using the :php:`FileDumpController` for +a sys_file record with a fixed image size: + +.. code-block:: php + + $queryParameterArray = ['eID' => 'dumpFile', 't' => 'f']; + $queryParameterArray['f'] = $resourceObject->getUid(); + $queryParameterArray['s'] = '320c:280c'; + $queryParameterArray['token'] = GeneralUtility::hmac(implode('|', $queryParameterArray), 'resourceStorageDumpFile'); + $publicUrl = GeneralUtility::locationHeaderUrl(PathUtility::getAbsoluteWebPath(Environment::getPublicPath() . '/index.php')); + $publicUrl .= '?' . http_build_query($queryParameterArray, '', '&', PHP_QUERY_RFC3986); + + +In this example crop variant :php:`default` and an image size of 320:280 will be +applied to a sys_file_reference record: + +.. code-block:: php + + $queryParameterArray = ['eID' => 'dumpFile', 't' => 'r']; + $queryParameterArray['f'] = $resourceObject->getUid(); + $queryParameterArray['s'] = '320c:280c:320:280:320:280'; + $queryParameterArray['cv'] = 'default'; + $queryParameterArray['token'] = GeneralUtility::hmac(implode('|', $queryParameterArray), 'resourceStorageDumpFile'); + $publicUrl = GeneralUtility::locationHeaderUrl(PathUtility::getAbsoluteWebPath(Environment::getPublicPath() . '/index.php')); + $publicUrl .= '?' . http_build_query($queryParameterArray, '', '&', PHP_QUERY_RFC3986); + + +This example shows the usage how to create a URI to load an image of +sys_file_processedfile: + +.. code-block:: php + + $queryParameterArray = ['eID' => 'dumpFile', 't' => 'p']; + $queryParameterArray['p'] = $resourceObject->getUid(); + $queryParameterArray['token'] = GeneralUtility::hmac(implode('|', $queryParameterArray), 'resourceStorageDumpFile'); + $publicUrl = GeneralUtility::locationHeaderUrl(PathUtility::getAbsoluteWebPath(Environment::getPublicPath() . '/index.php')); + $publicUrl .= '?' . http_build_query($queryParameterArray, '', '&', PHP_QUERY_RFC3986); + + +There are some restriction while using the new URI-Parameters: + ++ You can't assign any size parameter to processed files, as they are already resized. ++ You can't apply CropVariants to `sys_file` and `sys_file_processedfile` records. + + +Impact +====== + +No impact, as this class was extended only. It's fully backwards compatible. + +.. index:: FAL, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90114-MakeTranslationOfFilelistOptional.rst b/Documentation/Changelog/10.3/Feature-90114-MakeTranslationOfFilelistOptional.rst new file mode 100644 index 0000000..85c2db7 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90114-MakeTranslationOfFilelistOptional.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-90114: + +======================================================= +Feature: #90114 - Make translation of filelist optional +======================================================= + +See :issue:`90114` + +Description +=========== + +The filelist module now takes :php:`$GLOBALS['TCA']['sys_file_metadata']['ctrl']['languageField']` +into account. By unsetting the field, translations in the filelist module are no longer possible. + + +Impact +====== + +If :php:`$GLOBALS['TCA']['sys_file_metadata']['ctrl']['languageField']` is set to an empty value, +translations are disabled for the filelist module. + +.. index:: Backend, FAL, TCA, ext:filelist diff --git a/Documentation/Changelog/10.3/Feature-90136-ShowApplicationContextInTheEnvironmentModule.rst b/Documentation/Changelog/10.3/Feature-90136-ShowApplicationContextInTheEnvironmentModule.rst new file mode 100644 index 0000000..0b5a2dd --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90136-ShowApplicationContextInTheEnvironmentModule.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-90136: + +==================================================================== +Feature: #90136 - Show application context in the Environment module +==================================================================== + +See :issue:`90136` + +Description +=========== + +The "Environment Overview" card in the admin tool will now show the +application context the TYPO3 instance is running with. + + +Impact +====== + +Administrators can now look up the application context from inside the admin tool without +having to log into the TYPO3 backend. + +.. index:: ext:install diff --git a/Documentation/Changelog/10.3/Feature-90168-IntroduceModalActions.rst b/Documentation/Changelog/10.3/Feature-90168-IntroduceModalActions.rst new file mode 100644 index 0000000..c791b21 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90168-IntroduceModalActions.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-90168: + +========================================= +Feature: #90168 - Introduce Modal Actions +========================================= + +See :issue:`90168` + +Description +=========== + +Action buttons in modals created by the :js:`TYPO3/CMS/Backend/Modal` module may +now make use of :js:`TYPO3/CMS/Backend/ActionButton/ImmediateAction` and +:js:`TYPO3/CMS/Backend/ActionButton/DeferredAction`. + +As an alternative to the existing :js:`trigger` option, the new option +:js:`action` may be used with an instance of the previously mentioned modules. + +Example: + +.. code-block:: js + + Modal.confirm('Header', 'Some content', Severity.error, [ + { + text: 'Based on trigger()', + trigger: function () { + console.log('Vintage!'); + } + }, + { + text: 'Based on action', + action: new DeferredAction(() => { + return new AjaxRequest('/any/endpoint').post({}); + }) + } + ]); + + +Impact +====== + +Activating any action disables all buttons in the modal. Once the action is +done, the modal disappears automatically. + +Buttons of the type :js:`DeferredAction` render a spinner on activation into the +button. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-90203-MakeWorkspaceAvailableInTypoScriptConditions.rst b/Documentation/Changelog/10.3/Feature-90203-MakeWorkspaceAvailableInTypoScriptConditions.rst new file mode 100644 index 0000000..ba07011 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90203-MakeWorkspaceAvailableInTypoScriptConditions.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-90203: + +=================================================================== +Feature: #90203 - Make workspace available in TypoScript conditions +=================================================================== + +See :issue:`90203` + +Description +=========== + +A new TypoScript expression language variable :typoscript:`workspace` has been added. +It can be used to match a given expression against common workspace parameters. + +Currently, the parameters :typoscript:`workspaceId`, :typoscript:`isLive` and :typoscript:`isOffline` are supported. + +Examples +-------- + +Match the current workspace id: + +.. code-block:: typoscript + + [workspace.workspaceId === 3] + # Current workspace id equals: 3 + [end] + +Match against current workspace state: + +.. code-block:: typoscript + + [workspace.isLive] + # Current workspace is live + [end] + + [workspace.isOffline] + # Current workspace is offline + [end] + + +Impact +====== + +The new feature allows matching against several workspace parameters within TypoScript. + +.. index:: TypoScript diff --git a/Documentation/Changelog/10.3/Feature-90213-SupportBitAndInTypoScriptStdWrapIf.rst b/Documentation/Changelog/10.3/Feature-90213-SupportBitAndInTypoScriptStdWrapIf.rst new file mode 100644 index 0000000..2a405d2 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90213-SupportBitAndInTypoScriptStdWrapIf.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-90213: + +============================================================ +Feature: #90213 - Support 'bit and' in TypoScript stdWrap_if +============================================================ + +See :issue:`90213` + +Description +=========== + +It is now possible to use :typoscript:`bitAnd` within TypoScript :typoscript:`if`. + +TYPO3 uses bits to store radio and checkboxes via TCA. +Without this feature one would need to check whether any possible bit value is in a +list. With this feature a simple comparison whether the expected value is part of the +bit set is possible. + +Example +======= + +An example usage could look like this: + +.. code-block:: typoscript + + hideDefaultLanguageOfPage = TEXT + hideDefaultLanguageOfPage { + value = 0 + value { + override = 1 + override.if { + bitAnd.field = l18n_cfg + value = 1 + } + } + } + +.. index:: ext:frontend, TypoScript diff --git a/Documentation/Changelog/10.3/Feature-90234-IntroduceCacheHashConfigurationAndMatchingIndicators.rst b/Documentation/Changelog/10.3/Feature-90234-IntroduceCacheHashConfigurationAndMatchingIndicators.rst new file mode 100644 index 0000000..b484d7e --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90234-IntroduceCacheHashConfigurationAndMatchingIndicators.rst @@ -0,0 +1,82 @@ +.. include:: /Includes.rst.txt + +.. _feature-90234: + +========================================================================== +Feature: #90234 - Introduce CacheHashConfiguration and matching indicators +========================================================================== + +See :issue:`90234` + +Description +=========== + +Settings for :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']` are modelled +in class :php:`CacheHashConfiguration` which takes care of validating configuration. +It also determines whether corresponding aspects apply to a given URL +parameter. + +Besides exact matches (*equals*) it is possible to apply partial matches at +the beginning of a parameter (*startsWith*) or inline occurrences (*contains*). + +URL parameter names are prefixed with the following indicators: + +* :php:`=` (*equals*): exact match, default behavior if not given +* :php:`^` (*startsWith*): matching the beginning of a parameter name +* :php:`~` (*contains*): matching any inline occurrence in a parameter name + +These indicators can be used for all previously existing sub-properties +:php:`cachedParametersWhiteList`, :php:`excludedParameters`, :php:`excludedParametersIfEmpty` +and :php:`requireCacheHashPresenceParameters`. + +Example (excerpt of `LocalConfiguration.php`) +--------------------------------------------- + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash'] = [ + 'excludedParameters' => [ + 'utm_source', + 'utm_medium', + '^utm_', // making previous two obsolete + ], + 'excludedParametersIfEmpty' => [ + '^tx_my_plugin[aspects]', + 'tx_my_plugin[filter]', + ], + ]; + + +Impact +====== + +Configuration related to *cHash* URL parameter supports partial matches which +overcomes the previous necessity to explicitly state all parameter names to be +excluded. + +For instance instead of having exclude items like + +.. code-block:: php + + 'excludedParameters' => [ + 'tx_my[data][uid]', + 'tx_my[data][category]', + 'tx_my[data][order]', + 'tx_my[data][origin]', + ... + ], + +partial matches allow to simplify the configuration and consider all items having +:php:`tx_my[data]` (or :php:`tx_my[data][` to be more specific) as prefix like + +.. code-block:: php + + 'excludedParameters' => [ + '^tx_my[data][', + ... + ], + +The present configuration for the :php:`cHash` section is still supported - there is +no syntactical requirement to adjust those changes. + +.. index:: Frontend, LocalConfiguration, ext:frontend diff --git a/Documentation/Changelog/10.3/Feature-90249-NewPSR-14EventsForExistingPackage-relatedSignalSlots.rst b/Documentation/Changelog/10.3/Feature-90249-NewPSR-14EventsForExistingPackage-relatedSignalSlots.rst new file mode 100644 index 0000000..42c7b21 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90249-NewPSR-14EventsForExistingPackage-relatedSignalSlots.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-90249: + +============================================================================= +Feature: #90249 - New PSR-14 events for existing package-related Signal Slots +============================================================================= + +See :issue:`90249` + +Description +=========== + +PSR-14-based event dispatching allows for TYPO3 extensions or PHP packages to +extend TYPO3 Core functionality in an exchangeable way. + +The following new PSR-14 events have been introduced: + +- :php:`TYPO3\CMS\Core\Package\Event\PackagesMayHaveChangedEvent` +- :php:`TYPO3\CMS\Core\Package\Event\AfterPackageActivationEvent` +- :php:`TYPO3\CMS\Core\Package\Event\AfterPackageDeactivationEvent` +- :php:`TYPO3\CMS\Core\Package\Event\BeforePackageActivationEvent` +- :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionDatabaseContentHasBeenImportedEvent` +- :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionStaticDatabaseContentHasBeenImportedEvent` +- :php:`TYPO3\CMS\Extensionmanager\Event\AfterExtensionFilesHaveBeenImportedEvent` +- :php:`TYPO3\CMS\Extensionmanager\Event\AvailableActionsForExtensionEvent` + +They replace the existing Extbase-based Signal Slots: + +- :php:`PackageManagement::packagesMayHaveChanged` +- :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionInstall` +- :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionUninstall` +- :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionT3DImport` +- :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionStaticSqlImport` +- :php:`TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionFileImport` +- :php:`TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService::willInstallExtensions` +- :php:`TYPO3\CMS\Extensionmanager\ViewHelper\ProcessAvailableActionsViewHelper::processActions` + +Impact +====== + +It is now possible to add listeners to the new PSR-14 Events which +define a clear API what can be read or modified. + +The listeners can be added to the :file:`Configuration/Services.yaml` as +it is done in TYPO3's shipped extensions as well. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90262-AddArgon2idToPasswordHashAlgorithms.rst b/Documentation/Changelog/10.3/Feature-90262-AddArgon2idToPasswordHashAlgorithms.rst new file mode 100644 index 0000000..e123436 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90262-AddArgon2idToPasswordHashAlgorithms.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _feature-90262: + +=========================================================== +Feature: #90262 - Add Argon2id to password hash algorithms +=========================================================== + +See :issue:`90262` + +Description +=========== + +The hash algorithm `argon2id` is now available and can be selected in the +section `Configuration Presets` of the admin tools > settings module if +the PHP instance supports it. + +Argon2id is usually available on systems with PHP version 7.3 or higher. + +.. index:: Backend, Frontend, PHP-API, ext:install diff --git a/Documentation/Changelog/10.3/Feature-90265-ShowDispatchedEventsInAdminPanel.rst b/Documentation/Changelog/10.3/Feature-90265-ShowDispatchedEventsInAdminPanel.rst new file mode 100644 index 0000000..c25ef27 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90265-ShowDispatchedEventsInAdminPanel.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-90265: + +======================================================= +Feature: #90265 - Show dispatched Events in Admin Panel +======================================================= + +See :issue:`90265` + +Description +=========== + +To promote the new PSR-14 Events and to make it easier for people to see which +kinds of events may be used, the admin panel displays all events that are +dispatched in the current request with their parameters. + + +Impact +====== + +The Admin Panel has a new section called "Events" (in "Debug") which shows all +events with their respective values that have been dispatched during the current +request. To allow smooth navigating of these objects, the symfony var-dumper +component is used. + +.. index:: PHP-API, ext:adminpanel diff --git a/Documentation/Changelog/10.3/Feature-90266-Fluid-basedTemplatedEmails.rst b/Documentation/Changelog/10.3/Feature-90266-Fluid-basedTemplatedEmails.rst new file mode 100644 index 0000000..eb800fa --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90266-Fluid-basedTemplatedEmails.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-90266: + +============================================== +Feature: #90266 - Fluid-based email templating +============================================== + +See :issue:`90266` + +Description +=========== + +TYPO3 now supports sending template-based emails for multi-part and HTML-based +emails out-of-the-box. The email contents are built with Fluid Templating Engine. + +TYPO3's backend functionality already ships with a default layout +for templated emails, which can be tested out in TYPO3's install tool test email functionality. + +It is also possible to set a default mode for sending out emails via :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['format']` +which can be :php:`both`, :php:`plain` or :php:`html`. + +This option can however overridden by Extension authors in their use cases. + +All Fluid-based template paths can be configured via + +:file:`LocalConfiguration.php`: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['layoutRootPaths']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['partialRootPaths']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths']` + +where TYPO3 reserves all array keys below :php:`100` for internal purposes. If you want to provide custom templates or layouts, +set this in your :file:`LocalConfiguration.php` / :file:`AdditionalConfiguration.php` file: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths'][700] = 'EXT:my_site_extension/Resources/Private/Templates/Email';` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['layoutRootPaths'][700] = 'EXT:my_site_extension/Resources/Private/Layouts';` + +In addition, it is possible to define a section within the Fluid template, +which - if set - takes precedence over the :php:`subject()` method. + +Impact +====== + +TYPO3 now sends out templated messages for system emails in both plaintext and HTML format. + +It is possible to use the same API in your custom extension like this: + +.. code-block:: php + + $email = GeneralUtility::makeInstance(FluidEmail::class); + $email + ->to('contact@acme.com') + ->from(new Address('jeremy@acme.com', 'Jeremy')) + ->subject('TYPO3 loves you - here is why') + ->format('html') // only HTML mail + ->setTemplate('TipsAndTricks') + ->assign('mySecretIngredient', 'Tomato and TypoScript'); + GeneralUtility::makeInstance(Mailer::class)->send($email); + +Defining a custom email subject in a custom template: + +.. code-block:: html + + <f:section name="Subject">New Login at "{typo3.sitename}"</f:section> + +Building templated emails with Fluid also allows to define the language key, +and use this within the Fluid template: + +.. code-block:: php + + $email = GeneralUtility::makeInstance(FluidEmail::class); + $email + ->to('contact@acme.com') + ->assign('language', 'de'); + +.. code-block:: html + + <f:translate languageKey="{language}" id="LLL:my_ext/Resources/Private/Language/emails.xml:subject" /> + +.. index:: Fluid, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90267-CustomPlaceholderProcessingInSiteConfig.rst b/Documentation/Changelog/10.3/Feature-90267-CustomPlaceholderProcessingInSiteConfig.rst new file mode 100644 index 0000000..8404c1d --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90267-CustomPlaceholderProcessingInSiteConfig.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-90267: + +============================================================== +Feature: #90267 - Custom placeholder processing in site config +============================================================== + +See :issue:`90267` + +Description +=========== + +The Yaml import for site configuration was changed to allow custom placeholder processors. + + +Impact +====== + +It is now possible to register a new placeholder processor: + +:file:`LocalConfiguration.php`: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors'][\Vendor\MyExtension\PlaceholderProcessor\CustomPlaceholderProcessor::class] = []; + +There are some options available to sort or disable placeholder processors if necessary. + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors'][\Vendor\MyExtension\PlaceholderProcessor\CustomPlaceholderProcessor::class] = [ + 'before' => [ + \TYPO3\CMS\Core\Configuration\Processor\Placeholder\ValueFromReferenceArrayProcessor::class + ], + 'after' => [ + \TYPO3\CMS\Core\Configuration\Processor\Placeholder\EnvVariableProcessor::class + ], + 'disabled' => false + ]; + +New placeholder processors must implement the :php:`\TYPO3\CMS\Core\Configuration\Processor\Placeholder\PlaceholderProcessorInterface` + +Placeholders look mostly like functions. +So an implementation may look like the following: + +.. code-block:: php + + class ExamplePlaceholderProcessor implements PlaceholderProcessorInterface + { + public function canProcess(string $placeholder, array $referenceArray): bool + { + return strpos($placeholder, '%example(') !== false; + } + + public function process(string $value, array $referenceArray) + { + // do some processing + $result = $this->getValue($value); + + // Throw this exception if the placeholder can't be substituted + if (!$envVar) { + throw new \UnexpectedValueException('Value not found', 1581596096); + } + return $result; + } + } + + +This may be used like the following in the site configuration: + +.. code-block:: yaml + + someVariable: '%example(somevalue)%' + anotherVariable: 'inline::%example(anotherValue)%::placeholder' + +If a new processor returns a string or number, it may also be used inline as above. +If it returns an array, it cannot be used inline since the whole content will be replaced with the new value. + + +.. index:: Backend, PHP-API, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90298-ImproveUserInfoInBeuserModule.rst b/Documentation/Changelog/10.3/Feature-90298-ImproveUserInfoInBeuserModule.rst new file mode 100644 index 0000000..7feaa02 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90298-ImproveUserInfoInBeuserModule.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-90298: + +===================================================== +Feature: #90298 - Improve user info in BE User module +===================================================== + +See :issue:`90298` + +Description +=========== + +The *Backend users* module has been improved by showing more details of TYPO3 +Administrators and Editors: + +- All assigned groups, including subgroups, are now evaluated +- All data which can be set in the backend user or an assigned group are now shown including allowed page types +- Read & write access to tables +- A new "detail view" for a TYPO3 Backend user has been added + + +Impact +====== + +Comparing users is more powerful now. It is now easier for TYPO3 Administrators +to check backend user permissions without the need to switch to the actual user +and test the behaviour. + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/10.3/Feature-90333-Dashboard.rst b/Documentation/Changelog/10.3/Feature-90333-Dashboard.rst new file mode 100644 index 0000000..7a186f8 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90333-Dashboard.rst @@ -0,0 +1,187 @@ +.. include:: /Includes.rst.txt + +.. _feature-90333: + +=========================== +Feature: #90333 - Dashboard +=========================== + +See :issue:`90333` + +Description +=========== + +A dashboard is introduced into TYPO3 to show relevant information to +the current logged in user. + +Every user with access to this backend module can now have one or more personal +dashboards. Each dashboard can contain several widgets. Which widgets and in which +order the widgets are shown is up to the users themselves. + +As an integrator, you have the possibility to create dashboard templates. You can +mark the template as a default template so it will be created by default for every +new user. + +As a developer, you can create your own widgets. Just use one of the available +abstracts, which will be extended in the future, and extend it with your own +information. + +You can find the new dashboard in the toolbar in the top of your window. + + +Available widgets +^^^^^^^^^^^^^^^^^ + +The following widgets are shipped by core extensions now: + +* TYPO3 news: A widget showing the latest 5 news items from typo3.org (EXT:dashboard) +* TYPO3 security advisories: A widget showing the latest 5 security advisories from typo3.org (EXT:dashboard) +* TYPO3: This widget will show you some background information about TYPO3 and shows the current version of TYPO3 installed (EXT:dashboard) +* Getting started with TYPO3: This widget will provide a link to the Getting Started Tutorial (EXT:dashboard) +* TypoScript Template Reference: This widget will provide a link to the TypoScript Template Reference (EXT:dashboard) +* TSconfig Reference: This widget will provide a link to the TSconfig Reference (EXT:dashboard) +* Number of errors in system log: Shows the number of errors in the sys_log grouped by day for the last month (EXT:dashboard) +* Type of backend users: A widget to show the different types of backend users (EXT:dashboard) +* Failed Logins: This widget will show you the number of failed logins during the last 24 hours (EXT:dashboard) + + +Creating your own widget +^^^^^^^^^^^^^^^^^^^^^^^^ + +Besides the widgets shipped with TYPO3 core, you can also write your own widget. To +do so, you can extend one of the WidgetAbstracts available in EXT:dashboard. + +* :php:`AbstractWidget`: a basic abstract that can be used as the start of simple widgets +* :php:`AbstractRssWidget`: with this abstract it is easy to create a widget showing a RSS feed +* :php:`AbstractListWidget`: this abstract will give you an easy start to show a list of items +* :php:`AbstractCtaButtonWidget`: when you want to show a Call-To-Action button, this is the right abstract +* :php:`AbstractChartWidget`: the base of all chart widgets +* :php:`AbstractBarChartWidget`: when you want to show a widget with a bar-chart you can extend this class +* :php:`AbstractDoughnutChartWidget`: this abstract gives you the possibility to create a doughnut-chart widget +* :php:`AbstractNumberWithIconWidget`: this abstract will give you the possibility to show a title, number and an icon + + +By extending one of those abstracts, and providing it with the needed data, you are able to +have a new widget quite fast. The only thing that is left is to register the widget. + +Tag your widget in :file:`EXT:your_extension/Configuration/Services.yaml`: + +.. code-block:: yaml + + # Variant 1, widget identifier as attribute + Vendor\Extension\Widgets\MyFirstWidget: + arguments: ['widget-identifier-1'] + tags: + - name: dashboard.widget + identifier: widget-identifier-1 + widgetGroups: 'general' + + # Variant 2, custom service name, allows multiple widget identifiers + # to share the same class + widget.identifier: + class: Vendor\Extension\Widgets\MySecondWidget + arguments: ['widget-identifier-1'] + tags: + - name: dashboard.widget + # If omitted, the identifier would be the service name, thus 'widget.identifier' + identifier: widget-identifier-2 + widgetGroups: 'general, typo3' + + +Every widget needs a unique identifier, the implementing class and at least one +associated widget group. Multiple widget groups are separated by comma. + + +Configuring Widget Groups +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Every widget is attached to one or more widget groups. Those groups are shown in the +modal when adding a new widget to your dashboard. In this way you can group the available +widgets to get a clear overview for your users. By default the following widget groups are +available: + +* `widgetGroup-general`: Widgets with a more generic purpose +* `widgetGroup-systemInfo`: Widgets which provide system related information +* `widgetGroup-typo3`: Widgets with information regarding the TYPO3 product or community +* `widgetGroup-documentation`: Widgets with links to TYPO3 documentation + +You can also configure your own widget groups. To do so, you create a file :file:`EXT:your_extension/Configuration/Backend/DashboardWidgetGroups.php`. +In that file you specify the information of the groups. + +.. code-block:: php + + return [ + 'widgetGroup-myOwnGroup' => [ + 'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:widget_group.myOwnGroup', + ], + ]; + +First you have the identifier as the key. This identifier is used to map widgets to this +group. You only have one property and that is the title. You can add a simple text, or a +translation string like in the example above. + + +Defining Dashboard Presets +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You have the possibility to create dashboard presets. Those +presets are used when a user is creating a new dashboard. He can choose one of the available presets. +In a preset you can define which widgets and in which order those widgets will be added to the dashboard when created. + +So for example, if you want to give editors the possibility to add a dashboard with several +SEO related widgets, you can create a dashboard preset and add all the useful widgets on that. +When a user creates a dashboard based on that preset, all those widgets will be initially added to +that dashboard. + +To define those dashboard presets, you can create a file :file:`EXT:your_extension/Configuration/Backend/DashboardPresets.php`. +In that file you specify the information of the presets. + +.. code-block:: php + + return [ + 'dashboardPreset-myOwnPreset' => [ + 'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:dashboard.myOwnPreset', + 'description' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:dashboard.myOwnPreset.description', + 'iconIdentifier' => 'content-dashboard', + 'defaultWidgets' => ['widget-identifier-1', 'widget-identifier-2'], + 'showInWizard' => true + ], + ]; + +You start again with the dashboard preset identifier which should be unique. Every preset needs a title, description, iconIdentifier, some widgets and a flag if the +preset should be shown in the wizard to create a new dashboard. This last setting is to make it possible to not show it as a preset, but it can be used to +create this dashboard preset by default for new users. + + +Automatically create a dashboard for new users +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have a new user in your backend, you might want to kickstart that user and provide a +basic dashboard. You can do this by defining which dashboard presets should be created by default +when a user gets created or when a user deletes all his dashboards. + +You can define which dashboards will be created automatically by using the following TSconfig setting: + +.. code-block:: typoscript + + options.dashboard.dashboardPresetsForNewUsers = default, dashboardPreset-myOwnPreset + +You can add the identifiers of multiple presets in a comma separated list. + + +Permissions +^^^^^^^^^^^ + +As widgets might contain sensitive information, it is also possible to define the permissions +of the widgets on a group base. In the backend group settings you have the possibility to allow +specific widgets. Only those widgets will be available for users in that group. Admin users +have access to all widgets by default. + + +Impact +====== + +This is a new backend module and will not replace any old features. If the dashboard +extension is installed, it will be the default startup page in TYPO3 Backend. + +.. index:: Backend, ext:dashboard diff --git a/Documentation/Changelog/10.3/Feature-90348-NewFluid-basedReplacementForPageLayoutView.rst b/Documentation/Changelog/10.3/Feature-90348-NewFluid-basedReplacementForPageLayoutView.rst new file mode 100644 index 0000000..5fcb1ae --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90348-NewFluid-basedReplacementForPageLayoutView.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _feature-90348: + +============================================================ +Feature: #90348 - Fluid-based replacement for PageLayoutView +============================================================ + +See :issue:`90348` + +Description +=========== + +A completely rewritten replacement for PageLayoutView has been added. This replacement allows third parties +to override and extend any part of the "page" module's output by overriding Fluid templates. + +Although it is visually identical to the old :php:`PageLayoutView`'s output, the new alternative has a number of benefits: + +* The grid defined in a BackendLayout is now represented as objects which are assigned to Fluid templates and can be iterated over + to render rows, columns and records. +* Custom BackendLayout implementations can now manipulate every part of the configuration that determines + how the page module is rendered - or completely replace the logic that draws the "columns" and "languages" views of the page BE module. +* Custom BackendLayout implementations can also provide custom classes for LanguageColumn, Grid, GridRow, GridColumn and GridColumnItem instances + that are assigned to and used by Fluid templates to render the page layout. +* Headers, footers and previews for content types can be created in Fluid in a way that groups + each of these component templates by the content type (CType) value of content records. +* Any part of the page layout can now be rendered elsewhere by creating instances of any of the "grid" objects and assigning them to Fluid templates. +* The "grid" structure of BackendLayouts can be manipulated as objects, adding and removing rows and columns on-the-fly. + +The new Fluid-based implementation is enabled by the global setting :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['fluidBasedPageModule']` +which can be changed from the install tool or from extensions. The setting is enabled by default, meaning that the Fluid-based implementation is used +as default method in this and future TYPO3 versions. +The feature flag can be managed either by setting it through code (for example, in :file:`ext_localconf.php` of an extension) or you can set it through +the "Settings" admin module's' "Feature Toggles" view. + +New Fluid templates: + +* :file:`EXT:backend/Resources/Private/Templates/PageLayout/PageLayout.html` +* :file:`EXT:backend/Resources/Private/Templates/PageLayout/UnusedRecords.html` +* :file:`EXT:backend/Resources/Private/Partials/PageLayout/Grid.html` +* :file:`EXT:backend/Resources/Private/Partials/PageLayout/Grid/Column.html` +* :file:`EXT:backend/Resources/Private/Partials/PageLayout/Record.html` +* :file:`EXT:backend/Resources/Private/Partials/PageLayout/Record/Header.html` +* :file:`EXT:backend/Resources/Private/Partials/PageLayout/Record/Footer.html` + +These Fluid templates can be overridden or extended by TS, depending on which type or types of templates you wish to override: + +* :typoscript:`module.tx_backend.view.templateRootPaths.100 = EXT:myext/Resources/Private/Templates/` +* :typoscript:`module.tx_backend.view.partialRootPaths.100 = EXT:myext/Resources/Private/Partials/` + + +In addition, custom header/footer/preview templates can be added by extending the :typoscript:`partialRootPaths` and placing for example a template file in: + +* :file:`EXT:myext/Resources/Private/Partials/PageLayout/Record/my_contenttype/Header` +* :file:`EXT:myext/Resources/Private/Partials/PageLayout/Record/my_contenttype/Footer` +* :file:`EXT:myext/Resources/Private/Partials/PageLayout/Record/my_contenttype/Preview` + +If no such templates exist the default partials (listed above) are used. Note that the folder name :file:`my_contenttype` +should use the CType value associated with the content type for which you wish to provide a custom header, footer or preview template. + +Within these last three types of templates the following variables are available: + +* :html:`{item}` which represents a single record. +* :html:`{backendLayout}` which represents the :php:`BackendLayout` instance that defined the grid which was rendered. +* :html:`{grid}` which represents the :php:`Grid` instance that was produced by the :php:`BackendLayout` + (also accessible through :html:`{backendLayout.grid}`, provided as extracted variable for easier and more performance-efficient access) + +Properties on :html:`{item}` include: + +* :html:`{item.record}` (the database row of the content element) +* :html:`{item.column}` (the :php:`GridColumn` instance within which the item resides) +* :html:`{item.delible}` +* :html:`{item.translations}` (bool, whether or not the item is translated) +* :html:`{item.dragAndDropAllowed}` (bool, whether or not the item can be dragged and dropped) +* :html:`{item.footerInfo}` (array) + +Properties on :html:`{backendLayout}` include: + +* :html:`{backendLayout.configurationArray}` (array, the low level definition of rows/columns within the :php:`BackendLayout` - array form of the pageTSconfig that defines the grid) +* :html:`{backendLayout.iconPath}` +* :html:`{backendLayout.description}` +* :html:`{backendLayout.identifier}` +* :html:`{backendLayout.title}` +* :html:`{backendLayout.drawingConfiguration}` (the instance of :php:`DrawingConfiguration` which holds properties like active language, site languages and TCA labels for content types and content record fields) +* :html:`{backendLayout.grid}` (the instance of the :php:`Grid` that represents the backend layout rows/columns as PHP objects) + + +Impact +====== + +* A new feature setting :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['fluidBasedPageModule']` has been introduced, enabled by default, which allows switching to the legacy :php:`PageLayoutView`. +* By default, a new set of objects and extended methods on :php:`BackendLayout` now provide a completely Fluid-based implementation of the "page" BE module. + +.. index:: Backend, Fluid, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-90370-UseEguliasEmailValidatorForEmailValidation.rst b/Documentation/Changelog/10.3/Feature-90370-UseEguliasEmailValidatorForEmailValidation.rst new file mode 100644 index 0000000..e6e5e2b --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90370-UseEguliasEmailValidatorForEmailValidation.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-90370: + +================================================================= +Feature: #90370 - Use Egulias\EmailValidator for email validation +================================================================= + +See :issue:`90370` + +Description +=========== + +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::validEmail` now uses the package `Egulias\EmailValidator` and the `RFCValidation` for validating the provided email address. + +This allows to follow the RFC more closely. + + +Impact +====== + +The following email addresses are now valid: + +- `foo@äöüfoo.com` +- `foo@bar.123` +- `test@localhost` +- `äöüfoo@bar.com` +- `Abc@def"@example.com` + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst b/Documentation/Changelog/10.3/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst new file mode 100644 index 0000000..3f8514f --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-90411: + +========================================================================== +Feature: #90411 - HTML-based workspace notification emails on stage change +========================================================================== + +See :issue:`90411` + +Description +=========== + +When inside workspaces, it is possible to notify affected (or all) +users belonging to that workspace by sending out an email when +items have been moved to the next stage in the workflow process. + +These emails have been limited in the past due to marker-based templating and plain-text only. + +The emails have been reworked and migrated to Fluid-based templated +emails, allowing for administrators to customize the contents of +those emails. + +The following TSconfig options have been added: + +.. code-block:: typoscript + + # path where to look for templates / layouts / partials + tx_workspaces.emails.layoutRootPaths.100 = EXT:myproject/... + tx_workspaces.emails.partialRootPaths.100 = EXT:myproject/... + tx_workspaces.emails.templateRootPaths.100 = EXT:myproject/... + # valid formats are "text", "html" or "both" + tx_workspaces.emails.format = html + tx_workspaces.emails.senderEmail = workspaces@example.com + tx_workspaces.emails.senderName = Your TYPO3 at Example.com + +The template name is always called `StageChangeNotification`. + +It is still possible to use the existing plain-text variant +by setting the format to "text" and using the previous email +contents, if applicable. It is however recommended to make use +of the Fluid-based variables to make output more efficient. + +The old TSconfig options have been superseded for defining the template via XLF labels. + + +Impact +====== + +Stage Change Notification emails are now sent as HTML+text by +default with the email template given in :file:`EXT:workspaces/Resources/Private/Templates/Emails/StageChangeNotification`. + +.. index:: TSConfig, ext:workspaces diff --git a/Documentation/Changelog/10.3/Feature-90416-SpecificTargetFileExtensionInImage-relatedViewHelpers.rst b/Documentation/Changelog/10.3/Feature-90416-SpecificTargetFileExtensionInImage-relatedViewHelpers.rst new file mode 100644 index 0000000..8e96410 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90416-SpecificTargetFileExtensionInImage-relatedViewHelpers.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-90416: + +============================================================================= +Feature: #90416 - Specific target file extension in image-related ViewHelpers +============================================================================= + +See :issue:`90416` + +Description +=========== + +TYPO3 core's shipped Fluid ViewHelpers now allow to optionally +specify a target file extension via the new attribute `fileExtension`. + +This affects the following ViewHelpers: + +- :html:`<f:image>` +- :html:`<f:media>` +- :html:`<f:uri.image>` + +This is rather important for specific scenarios where a :html:`<picture>` tag with multiple images are requested, allowing +to e.g. customize rendering for `webp` support, if the servers' ImageMagick version supports `webp` conversion. + +In other regard, this might become useful to specify the output +for preview images of `pdf` files which can be converted via `GhostScript` if installed. + + +Impact +====== + +TYPO3 Integrators can now use the additional attribute +in their custom Fluid Templates for specific use cases. + +Example: + +.. code-block:: html + + <picture> + <source srcset="{f:uri.image(image: fileObject, treatIdAsReference: true, fileExtension: 'webp')}" type="image/webp"> + <source srcset="{f:uri.image(image: fileObject, treatIdAsReference: true, fileExtension: 'jpg')}" type="image/jpeg"> + <f:image image="{fileObject}" treatIdAsReference="true" alt="{fileObject.alternative}" /> + </picture> + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/10.3/Feature-90425-AddSeoFieldsToInfoModule.rst b/Documentation/Changelog/10.3/Feature-90425-AddSeoFieldsToInfoModule.rst new file mode 100644 index 0000000..933cd04 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90425-AddSeoFieldsToInfoModule.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-90425: + +=============================================== +Feature: #90425 - Add SEO fields to info module +=============================================== + +See :issue:`90425` + +Description +=========== + +Two more options are added to the Info module (sub-module: "Pagetree Overview"): +"SEO" and "Social Media" to get a quick overview of the relevant data. + + +Impact +====== + +The options "SEO" and "Social Media" are added to the Pagetree Overview. + +.. index:: Backend, TSConfig, ext:seo diff --git a/Documentation/Changelog/10.3/Feature-90426-Browser-nativeLazyLoadingForImages.rst b/Documentation/Changelog/10.3/Feature-90426-Browser-nativeLazyLoadingForImages.rst new file mode 100644 index 0000000..6107927 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90426-Browser-nativeLazyLoadingForImages.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-90426: + +======================================================== +Feature: #90426 - Browser-native lazy loading for images +======================================================== + +See :issue:`90426` + +Description +=========== + +TYPO3 now supports the browser-native :html:`loading` HTML attribute in :html:`<img>` tags. + +It is set to "lazy" by default for all images within Content Elements rendered +with Fluid Styled Content. Supported browsers then choose to load these +images at a later point when the image is within the browsers' viewport. + +The configuration option is available via TypoScript constants and +can be easily adjusted via the TypoScript Constant Editor in the Template module. + +Please note that not all browsers support this option yet, but adding +this property will just be skipped for unsupported browsers. + + +Impact +====== + +TYPO3 Frontend now renders images in content elements with the :html:`"loading=lazy"` +attribute by default when using TYPO3's templates from Fluid Styled Content. + +Using the TypoScript constant :typoscript:`styles.content.image.lazyLoading`, +the behavior can be modified generally to be either set to :html:`eager`, +:html:`auto` or to an empty value, removing the property directly. + +The Fluid ImageViewHelper has the possibility to set this option +via :html:`<f:image src="{fileObject}" treatIdAsReference="true" loading="lazy" />` +to hint the browser on how the prioritization of image loading should be used. + +.. index:: Frontend, ext:fluid_styled_content diff --git a/Documentation/Changelog/10.3/Feature-90461-QuickCreateContentElementsViaNewContentElementWizard.rst b/Documentation/Changelog/10.3/Feature-90461-QuickCreateContentElementsViaNewContentElementWizard.rst new file mode 100644 index 0000000..75a5e34 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90461-QuickCreateContentElementsViaNewContentElementWizard.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-90461: + +=========================================================================== +Feature: #90461 - Quick-Create Content Elements via NewContentElementWizard +=========================================================================== + +See :issue:`90461` + +Description +=========== + +The new Content Element wizard within the Page Module now contains +an option called "saveAndClose" which directs a user back to the +Page Module directly instead of showing the FormEngine. + +This is especially useful for custom content elements or container +content types where pre-defined values can be put in place directly, +saving editors one click on content creation. + +The functionality is disabled by default, but explicitly enabled for the Content Type "divider". + + +Impact +====== + +This definition can be put into PageTSconfig (e.g. :file:`EXT:my_extension/Configuration/Page/main.tsconfig`) with the new flag "saveAndClose" enabled. + +.. code-block:: typoscript + + mod.wizards.newContentElement.wizardItems { + common.elements { + my_element { + iconIdentifier = content-my-icon + title = LLL:EXT:my_extension/Resources/Private/Language/ContentTypes.xlf:my_element_title + description = LLL:EXT:my_extension/Resources/Private/Language/ContentTypes.xlf:my_element_description + tt_content_defValues { + CType = my_element + header = Hello my friend + } + saveAndClose = 1 + } + } + } + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/10.3/Feature-90471-JavaScriptEventAPI.rst b/Documentation/Changelog/10.3/Feature-90471-JavaScriptEventAPI.rst new file mode 100644 index 0000000..ac76ed5 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90471-JavaScriptEventAPI.rst @@ -0,0 +1,188 @@ +.. include:: /Includes.rst.txt + +.. _feature-90471: + +====================================== +Feature: #90471 - JavaScript Event API +====================================== + +See :issue:`90471` + +Description +=========== + +A new Event API enables JavaScript developers to have a stable event listening +interface. The API takes care of common pitfalls like event delegation and clean +event unbinding. + + +Impact +====== + +Event Binding +------------- + +Each event strategy (see below) has two ways to bind a listener to an event: + +Direct Binding +^^^^^^^^^^^^^^ + +The event listener is bound to the element that triggers the event. This is done +by using the method :js:`bindTo()`, which accepts any element, :js:`document` and +:js:`window`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) { + new RegularEvent('click', function (e) { + // Do something + }).bindTo(document.querySelector('#my-element')); + }); + + +Event Delegation +^^^^^^^^^^^^^^^^ + +The event listener is called if the event was triggered to any matching element +inside its bound element. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) { + new RegularEvent('click', function (e) { + // Do something + }).delegateTo(document, 'a[data-action="toggle"]'); + }); + +The event listener is now called every time the element matching the selector +:js:`a[data-action="toggle"]` within :js:`document` is clicked. + + +Release an event +^^^^^^^^^^^^^^^^ + +Since each event is an object instance, it's sufficient to call :js:`release()` to +detach the event listener. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) { + const clickEvent = new RegularEvent('click', function (e) { + // Do something + }).delegateTo(document, 'a[data-action="toggle"]'); + + // Do more stuff + + clickEvent.release(); + }); + + +Event Strategies +---------------- + +The Event API brings several strategies to handle event listeners: + +RegularEvent +^^^^^^^^^^^^ + +The :js:`RegularEvent` attaches a simple event listener to an event and element +and has no further tweaks. This is the common use-case for event handling. + +Arguments: + +* :js:`eventName` (string) - the event to listen on +* :js:`callback` (function) - the event listener + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/RegularEvent'], function (RegularEvent) { + new RegularEvent('click', function (e) { + e.preventDefault(); + window.location.reload(); + }).bindTo(document.querySelector('#my-element')); + }); + + +DebounceEvent +^^^^^^^^^^^^^ + +The :js:`DebounceEvent` is most suitable if an event is triggered rather often +but executing the event listener may called only once after a certain wait time. + +Arguments: + +* :js:`eventName` (string) - the event to listen on +* :js:`callback` (function) - the event listener +* :js:`wait` (number) - the amount of milliseconds to wait before the event listener is called +* :js:`immediate` (boolean) - if true, the event listener is called right when the event started + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/DebounceEvent'], function (DebounceEvent) { + new DebounceEvent('mousewheel', function (e) { + console.log('Triggered once after 250ms!'); + }, 250).bindTo(document); + }); + + +ThrottleEvent +^^^^^^^^^^^^^ + +Arguments: + +* :js:`eventName` (string) - the event to listen on +* :js:`callback` (function) - the event listener +* :js:`limit` (number) - the amount of milliseconds to wait before the event listener is called + +The :js:`ThrottleEvent` is similar to the :js:`DebounceEvent`. The important +difference is that the event listener is called after the configured wait time +during the overall event time. + +If an event time is about 2000ms and the wait time is configured to be 100ms, +the event listener gets called up to 20 times in total (2000 / 100). + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/ThrottleEvent'], function (ThrottleEvent) { + new ThrottleEvent('mousewheel', function (e) { + console.log('Triggered every 100ms!'); + }, 100).bindTo(document); + }); + + +RequestAnimationFrameEvent +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The :js:`RequestAnimationFrameEvent` binds its execution to the browser's +:js:`RequestAnimationFrame` API. It is suitable for event listeners that +manipulate the DOM. + +Arguments: + +* :js:`eventName` (string) - the event to listen on +* :js:`callback` (function) - the event listener + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Event/RequestAnimationFrameEvent'], function (RequestAnimationFrameEvent) { + new RequestAnimationFrameEvent('mousewheel', function (e) { + console.log('Triggered every 16ms (= 60 FPS)!'); + }); + }); + + +.. index:: JavaScript, ext:core diff --git a/Documentation/Changelog/10.3/Feature-90522-IntroduceAssetCollector.rst b/Documentation/Changelog/10.3/Feature-90522-IntroduceAssetCollector.rst new file mode 100644 index 0000000..94a0c64 --- /dev/null +++ b/Documentation/Changelog/10.3/Feature-90522-IntroduceAssetCollector.rst @@ -0,0 +1,132 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Feature-90522-IntroduceAssetCollector: + +========================================== +Feature: #90522 - Introduce AssetCollector +========================================== + +See :issue:`90522` + +Description +=========== + +AssetCollector is a concept to allow custom CSS/JS code, inline or external, to be added multiple +times in e.g. a Fluid template (via :html:`<f:asset.script>` or :html:`<f:asset.css>` ViewHelpers) but only rendered once +in the output. + +The :php:`priority` flag (default: :php:`false`) controls where the asset is included: + +* JavaScript will be output inside :html:`<head>` (:php:`priority=true`) or at the bottom of the :html:`<body>` tag (:php:`priority=false`) +* CSS will always be output inside :html:`<head>`, yet grouped by :js:`priority`. + +By including assets per-component, it can leverage the adoption of HTTP/2 multiplexing which removes the necessity of having all CSS/JavaScript +concatenated into one file. + +AssetCollector is implemented as singleton and should slowly replace the various existing options +in TypoScript. + +AssetCollector also collects information about "imagesOnPage", effectively taking off pressure from +PageRenderer and TSFE to store common data in FE - as this is now handled in AssetCollector, +which can be used in cached and non-cached components. + +The new API +----------- + +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addJavaScript(string $identifier, string $source, array $attributes, array $options = []): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addInlineJavaScript(string $identifier, string $source, array $attributes, array $options = []): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addStyleSheet(string $identifier, string $source, array $attributes, array $options = []): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addInlineStyleSheet(string $identifier, string $source, array $attributes, array $options = []): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addMedia(string $fileName, array $additionalInformation): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeJavaScript(string $identifier): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeInlineJavaScript(string $identifier): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeStyleSheet(string $identifier): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeInlineStyleSheet(string $identifier): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeMedia(string $identifier): self` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getJavaScripts(?bool $priority = null): array` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getInlineJavaScripts(?bool $priority = null): array` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getStyleSheets(?bool $priority = null): array` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getInlineStyleSheets(?bool $priority = null): array` +- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getMedia(): array` + +New ViewHelpers +--------------- + +There are also two new ViewHelpers, the :html:`<f:asset.css>` and the - :html:`<f:asset.script>` ViewHelper which use the AssetCollector API. + +.. code-block:: html + + <f:asset.css identifier="identifier123" href="EXT:my_ext/Resources/Public/Css/foo.css" /> + <f:asset.css identifier="identifier123"> + .foo { color: black; } + </f:asset.css> + + <f:asset.script identifier="identifier123" src="EXT:my_ext/Resources/Public/JavaScript/foo.js" /> + <f:asset.script identifier="identifier123"> + alert('hello world'); + </f:asset.script> + +Considerations +-------------- + +Currently, assets registered with the AssetCollector are not included in callbacks of these hooks: + +- :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['cssCompressHandler']` +- :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['jsCompressHandler']` +- :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['cssConcatenateHandler']` +- :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['jsConcatenateHandler']` + +.. versionadded:: 10.4 + + Events for the new API have been introduced in + :ref:`changelog-Feature-90899-IntroduceAssetPreRenderingEvents` + +Currently, CSS and JavaScript registered with the AssetCollector will be rendered after their +PageRenderer counterparts. The order is: + +- :html:`<head>` +- :typoscript:`page.includeJSLibs.forceOnTop` +- :typoscript:`page.includeJSLibs` +- :typoscript:`page.includeJS.forceOnTop` +- :typoscript:`page.includeJS` +- :php:`AssetCollector::addJavaScript()` with 'priority' +- :typoscript:`page.jsInline` +- :php:`AssetCollector::addInlineJavaScript()` with 'priority' +- :html:`</head>` + +- :typoscript:`page.includeJSFooterlibs.forceOnTop` +- :typoscript:`page.includeJSFooterlibs` +- :typoscript:`page.includeJSFooter.forceOnTop` +- :typoscript:`page.includeJSFooter` +- :php:`AssetCollector::addJavaScript()` +- :typoscript:`page.jsFooterInline` +- :php:`AssetCollector::addInlineJavaScript()` + +Currently, JavaScript registered with AssetCollector is not affected by +:typoscript:`config.moveJsFromHeaderToFooter`. + +Examples +-------- + +Add a JavaScript file to the collector with script attribute data-foo="bar": + +.. code-block:: php + + GeneralUtility::makeInstance(AssetCollector::class) + ->addJavaScript('my_ext_foo', 'EXT:my_ext/Resources/Public/JavaScript/foo.js', ['data-foo' => 'bar']); + +Add a JavaScript file to the collector with script attribute :html:`data-foo="bar"` and priority which means rendering before other script tags: + +.. code-block:: php + + GeneralUtility::makeInstance(AssetCollector::class) + ->addJavaScript('my_ext_foo', 'EXT:my_ext/Resources/Public/JavaScript/foo.js', ['data-foo' => 'bar'], ['priority' => true]); + +Add a JavaScript file to the collector with :html:`type="module"` (by default, no type= is output for JavaScript): + +.. code-block:: php + + GeneralUtility::makeInstance(AssetCollector::class) + ->addJavaScript('my_ext_foo', 'EXT:my_ext/Resources/Public/JavaScript/foo.js', ['type' => 'module']); + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/10.3/Important-89672-TransOrigPointerFieldIsNotLongerAllowedToBeExcluded.rst b/Documentation/Changelog/10.3/Important-89672-TransOrigPointerFieldIsNotLongerAllowedToBeExcluded.rst new file mode 100644 index 0000000..8b26b85 --- /dev/null +++ b/Documentation/Changelog/10.3/Important-89672-TransOrigPointerFieldIsNotLongerAllowedToBeExcluded.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-89672: + +============================================================================== +Important: #89672 - transOrigPointerField is not longer allowed to be excluded +============================================================================== + +See :issue:`89672` + +Description +=========== + +The configured :php:`$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']` +can now not longer be excluded as this leads to inconsistent data stored in the +database. This happens when a non-admin user creates a localization by not having +the permission to edit the :php:`transOrigPointerField`. Usually this is the +:php:`l10n_parent` or :php:`l18n_parent` field. + +A migration wizard is available that removes the option from your TCA and adds a +deprecation message to the deprecation log where code adaption has to take place. + +.. index:: Backend, Database, TCA, ext:core diff --git a/Documentation/Changelog/10.3/Important-89720-OnlyTypoScriptFilesLoadedOnDirectoryImport.rst b/Documentation/Changelog/10.3/Important-89720-OnlyTypoScriptFilesLoadedOnDirectoryImport.rst new file mode 100644 index 0000000..7554388 --- /dev/null +++ b/Documentation/Changelog/10.3/Important-89720-OnlyTypoScriptFilesLoadedOnDirectoryImport.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _important-89720: + +==================================================================== +Important: #89720 - Only TypoScript files loaded on directory import +==================================================================== + +See :issue:`89720` + +Description +=========== + +With :issue:`82812` the new :typoscript:`@import` syntax for importing TypoScript has been added. + +Among others the change was documented to only load :file:`*.typoscript` files in case a directory is imported. However, this was not implemented as such and all files where imported instead. + +The code has been fixed to only load :file:`*.typoscript` files on directory import. To load other files besides :file:`*.typoscript` a suitable file pattern must be added explicitly now: + +.. code-block:: typoscript + + # Import TypoScript files with legacy ".txt" extension + @import 'EXT:myproject/Configuration/TypoScript/Setup/*.txt' + +.. index:: TypoScript, ext:core diff --git a/Documentation/Changelog/10.3/Important-89869-ChangeLockIpDefaultToDisabled.rst b/Documentation/Changelog/10.3/Important-89869-ChangeLockIpDefaultToDisabled.rst new file mode 100644 index 0000000..70fdaec --- /dev/null +++ b/Documentation/Changelog/10.3/Important-89869-ChangeLockIpDefaultToDisabled.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-89869: + +=================================================================================== +Important: #89869 - Change lockIP default to disabled for both frontend and backend +=================================================================================== + +See :issue:`89869` + +Description +=========== + +The default setting for the lockIP settings has been changed to disabled. This affects the following four settings: + +- FE->lockIP +- FE->lockIPv6 +- BE->lockIP +- BE->lockIPv6 + +While the lockIP feature helps to protect user sessions in some scenarios, the feature also breaks many usage scenarios. +In particular the feature causes random session loss with IPv6 usage because of the Happy eyeballs/Fast fallback algorithm, which causes clients +with IPv6 and IPv4 address support to arbitrarily change between IPv4 and IPv6 based on which connection is established first. + +Anyone considering re-enabling lockIP, should be be sure to evaluate any potential issues first, especially when using it with IPv6. + +.. index:: Backend, Frontend diff --git a/Documentation/Changelog/10.3/Important-89992-UseNewTranslationServer.rst b/Documentation/Changelog/10.3/Important-89992-UseNewTranslationServer.rst new file mode 100644 index 0000000..12324ef --- /dev/null +++ b/Documentation/Changelog/10.3/Important-89992-UseNewTranslationServer.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _important-89992: + +============================================== +Important: #89992 - Use new Translation Server +============================================== + +See :issue:`89992` + +Description +=========== + +The work on the new translation server has been finalized so that it is used by default. + +The SaaS solution Crowdin is being used to make it as simple as possible for everyone to improve the +localization of TYPO3 core and all extensions which are taking part. + +If you are interested in improving the localization, register at https://crowdin.com/ and suggest translations at +the official TYPO3 Project, which can be found at https://crowdin.com/project/typo3-cms. + +The documentation about the integration is part of the official TYPO3 documentation and +is available at https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/Internationalization/TranslationServer/Crowdin.html. +It also covers how to make your extension as extension developer available at Crowdin. + +Impact +====== + +The feature switch :php:`betaTranslationServer`, introduced with :issue:`89526`, +has been removed and is not evaluated anymore. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/10.3/Important-90020-LegacyBasicFileUtilityAndExtendedFileUtilityClassesMarkedAsInternal.rst b/Documentation/Changelog/10.3/Important-90020-LegacyBasicFileUtilityAndExtendedFileUtilityClassesMarkedAsInternal.rst new file mode 100644 index 0000000..c111cbc --- /dev/null +++ b/Documentation/Changelog/10.3/Important-90020-LegacyBasicFileUtilityAndExtendedFileUtilityClassesMarkedAsInternal.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _important-90020: + +============================================================================================== +Important: #90020 - Legacy BasicFileUtility and ExtendedFileUtility classes marked as internal +============================================================================================== + +See :issue:`90020` + +Description +=========== + +The two classes used to handle File permission and File upload logic - BasicFileUtility and ExtendedFileUtility - +have been marked as internal, as TYPO3 Core now fully relies on the File Abstraction Layer, which was introduced in TYPO3 v6.0. + +The remaining parts are partially in use and will be phased out, for the time being all +extension authors should rely on :php:`ResourceStorage` and :php:`ResourceFactory` for managing assets. + +.. index:: FAL, ext:core diff --git a/Documentation/Changelog/10.3/Important-90236-RespectExtensionStateExcludeFromUpdatesDuringLanguageUpdates.rst b/Documentation/Changelog/10.3/Important-90236-RespectExtensionStateExcludeFromUpdatesDuringLanguageUpdates.rst new file mode 100644 index 0000000..3009fb1 --- /dev/null +++ b/Documentation/Changelog/10.3/Important-90236-RespectExtensionStateExcludeFromUpdatesDuringLanguageUpdates.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _important-90236: + +======================================================================================== +Important: #90236 - Respect extension state 'excludeFromUpdates' during language updates +======================================================================================== + +See :issue:`90236` + +Description +=========== + +If the state property inside :file:`ext_emconf.php` is set to `excludeFromUpdates`, +the extension will be skipped while updating the language files in the Install Tool. + +This setting is especially helpful if you create a custom extension which uses the same extension +key as an existing TER extension. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/10.3/Important-90371-TypoScriptOptionConfigcontent_from_pid_allowOutsideDomainRemoved.rst b/Documentation/Changelog/10.3/Important-90371-TypoScriptOptionConfigcontent_from_pid_allowOutsideDomainRemoved.rst new file mode 100644 index 0000000..d3fe286 --- /dev/null +++ b/Documentation/Changelog/10.3/Important-90371-TypoScriptOptionConfigcontent_from_pid_allowOutsideDomainRemoved.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _important-90371: + +======================================================================================== +Important: #90371 - TypoScript option config.content_from_pid_allowOutsideDomain removed +======================================================================================== + +See :issue:`90371` + +Description +=========== + +TYPO3's Site Handling - introduced in TYPO3 v9 - allows defining +multiple sites within one installation, whereas before all configuration was based on domain records. +The TypoScript option :typoscript:`config.content_from_pid_allowOutsideDomain` was used to limit +the page property option "Show content from this page instead" (:typoscript:`pages.content_from_pid`) to be +evaluated outside of the current page tree which was ineffective since the usage of Site Handling. + +The option serves no purpose anymore and has been removed. + +.. index:: Frontend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/10.3/Index.rst b/Documentation/Changelog/10.3/Index.rst new file mode 100644 index 0000000..06d0619 --- /dev/null +++ b/Documentation/Changelog/10.3/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-3: + +10.3 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +None since TYPO3 v10.0 release. + +.. attention:: + + After TYPO3 v10.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v10.0 release on the way to LTS. + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/10.4.x/Feature-90728-AddFluidEmailOptionToEXTformEmailFinisher.rst b/Documentation/Changelog/10.4.x/Feature-90728-AddFluidEmailOptionToEXTformEmailFinisher.rst new file mode 100644 index 0000000..e95c6a0 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Feature-90728-AddFluidEmailOptionToEXTformEmailFinisher.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _feature-90728: + +================================================================= +Feature: #90728 - Add FluidEmail option to EXT:form EmailFinisher +================================================================= + +See :issue:`90728` + +Description +=========== + +After the introduction of FluidEmail in v10 the option to send mails in a +standardized way is now also added to the EmailFinisher of the system extension +EXT:from. + +To use FluidEmail a new option `useFluidEmail` is added to both the EmailToReceiver +and EmailToSender finisher. It defaults to :php:`FALSE` so extension authors are +able to smoothly test and upgrade their forms. Furthermore a new option `title` +is available which can be used to add an E-Mail title to the default FluidEmail +template. This option is capable of rendering form element variables using the +known bracket syntax and can be overwritten in the FlexForm configuration of the +form plugin. + +To customize the templates being used, the following options can be set: + +* `templateName`: The template name (for both HTML and plaintext) without the extension +* `templateRootPaths`: The paths to the templates +* `partialRootPaths`: The paths to the partials +* `layoutRootPaths`: The paths to the layouts + +For FluidEmail, the field `templatePathAndFilename` is not evaluated anymore. + +A finisher configuration could look like this: + +.. code-block:: yaml + + identifier: contact + type: Form + prototypeName: standard + finishers: + - + identifier: EmailToSender + options: + subject: 'Your Message: {message}' + title: 'Hello {name}, your confirmation' + templateName: ContactForm + templateRootPaths: + 100: 'EXT:sitepackage/Resources/Private/Templates/Email/' + partialRootPaths: + 100: 'EXT:sitepackage/Resources/Private/Partials/Email/' + addHtmlPart: true + useFluidEmail: true + +Please note that the old template name syntax `{@format}.html` does not work for +FluidEmail as each format needs a different template with the corresponding file +extension. In the example above the following files must exist in the specified +template path: + +* `ContactForm.html` +* `ContactForm.txt` + +Impact +====== + +It's now possible to use FluidEmail for sending mails in EXT:form. + +.. index:: Fluid, Frontend, ext:form diff --git a/Documentation/Changelog/10.4.x/Feature-91132-IntroduceUserSettingsJavaScriptModulesEvent.rst b/Documentation/Changelog/10.4.x/Feature-91132-IntroduceUserSettingsJavaScriptModulesEvent.rst new file mode 100644 index 0000000..c380299 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Feature-91132-IntroduceUserSettingsJavaScriptModulesEvent.rst @@ -0,0 +1,82 @@ +.. include:: /Includes.rst.txt + +.. _feature-91132: + +================================================================== +Feature: #91132 - Introduce User Settings JavaScript Modules Event +================================================================== + +See :issue:`91132` + +Description +=========== + +JavaScript events in custom User Settings Configuration options shall +not be placed as inline JavaScript anymore, but utilize a dedicated +JavaScript module to handle custom events +(see :doc:`Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions`) + +This new PSR-14 event is introduced: + +* :php:`\TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent` + +These public methods are exposed: + +* :php:`public function addModule(string $moduleName): void` +* :php:`public function getModules(): array` + +:php:`$moduleName` refers to the JavaScript module to be loaded with RequireJS +(e.g. `TYPO3/CMS/MyExtension/CustomUserSettingsModule`). + + +Example +======= + +A listener using mentioned PSR-14 event could look like the following. + +.. rst-class:: bignums + + 1. Register listener + + :file:`typo3conf/my-extension/Configuration/Services.yaml` + + .. code-block:: yaml + + services: + MyVendor\MyExtension\EventListener\CustomUserSettingsListener: + tags: + - name: event.listener + identifier: 'myExtension/CustomUserSettingsListener' + event: TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent + + + 2. Implement Listener to load JavaScript module `TYPO3/CMS/MyExtension/CustomUserSettingsModule` + + .. code-block:: php + + namespace MyVendor\MyExtension\EventListener; + + use TYPO3\CMS\SetupEvent\AddJavaScriptModulesEvent; + + class CustomUserSettingsListener + { + // name of JavaScript module to be loaded + private const MODULE_NAME = 'TYPO3/CMS/MyExtension/CustomUserSettingsModule'; + + public function __invoke(AddJavaScriptModulesEvent $event): void + { + $javaScriptModuleName = 'TYPO3/CMS/MyExtension/CustomUserSettings'; + if (in_array(self::MODULE_NAME, $event->getModules(), true)) { + return; + } + $event->addModule(self::MODULE_NAME); + } + } + + +Related +======= + +- :doc:`Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions` + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.4.x/Important-73227-TSconfigOptionAltIconsRestored.rst b/Documentation/Changelog/10.4.x/Important-73227-TSconfigOptionAltIconsRestored.rst new file mode 100644 index 0000000..dd666ab --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-73227-TSconfigOptionAltIconsRestored.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _important-73227: + +===================================================== +Important: #73227 - TSconfig option altIcons restored +===================================================== + +See :issue:`73227` + +Description +=========== + +The TSconfig option :typoscript:`altIcons`, introduced in :issue:`35891`, +allowed to add / override icons for TCA select items. This option was then +accidentally removed without further notice, while reworking the FormEngine. + +Therefore and because it's sometimes necessary to use different icons for +already defined select items - depending on the current page or site context - +the option is restored. + +The usage is as following: + +.. code-block:: typoscript + + TCEFORM.pages.doktype.altIcons { + 1 = custom-icon-identifier + 2 = EXT:my_ext/path/to/icon.svg + } + +For more information you can also have a look at the initial +:doc:`changelog <../7.1/Feature-35891-AddTCAItemsWithIconsViaPageTSConfig>`. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/10.4.x/Important-88824-AddCacheForErrorPageHandling.rst b/Documentation/Changelog/10.4.x/Important-88824-AddCacheForErrorPageHandling.rst new file mode 100644 index 0000000..dcfbd67 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-88824-AddCacheForErrorPageHandling.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _important-88824-1668719172: + +===================================================== +Important: #88824 - Add cache for error page handling +===================================================== + +See :issue:`88824` + +Description +=========== + +In order to prevent possible DoS attacks when the page-based error handler +is used, the content of the 404 error page is now cached in the TYPO3 +page cache. Any dynamic content on the error page (e.g. content created +by TypoScript or uncached plugins) will therefore also be cached. + +If the 404 error page contains dynamic content, TYPO3 administrators must +ensure that no sensitive data (e.g. username of logged in frontend user) +will be shown on the error page. + +If dynamic content is required on the 404 error page, it is recommended +to implement a custom PHP based error handler. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.4.x/Important-91070-SMTPTransportOptionTransport_smtp_encryptChangedToBoolean.rst b/Documentation/Changelog/10.4.x/Important-91070-SMTPTransportOptionTransport_smtp_encryptChangedToBoolean.rst new file mode 100644 index 0000000..f631a12 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-91070-SMTPTransportOptionTransport_smtp_encryptChangedToBoolean.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _important-91070: + +===================================================================================== +Important: #91070 - SMTP transport option 'transport_smtp_encrypt' changed to boolean +===================================================================================== + +See :issue:`91070` + +Description +=========== + +With https://forge.typo3.org/issues/90295 the allowed value for +:php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport_smtp_encrypt']` has been +changed to a boolean value. + +symfony/mailer does no longer allow to specify the `STARTTLS` usage, as it will +be used by default (if the server provides the needed support). + +Therefore, the SMTP encryption configuration setting +:php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport_smtp_encrypt']` is +automatically updated by the install tool's silent configuration upgrade. + +The configuration value `(string)tls` is removed to reflect that symfony/mailer +expects `(bool)false` for `STARTTLS`. Other values like `(string)ssl` are +converted too `(bool)true`. + +No migration is needed at all, as no deprecation is thrown. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/10.4.x/Important-91117-UseGlobalEventHandlerAndActionDispatcherInsteadOfInlineJS.rst b/Documentation/Changelog/10.4.x/Important-91117-UseGlobalEventHandlerAndActionDispatcherInsteadOfInlineJS.rst new file mode 100644 index 0000000..99f895c --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-91117-UseGlobalEventHandlerAndActionDispatcherInsteadOfInlineJS.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _important-91117: + +==================================================================================== +Important: #91117 - Use GlobalEventHandler and ActionDispatcher instead of inline JS +==================================================================================== + +See :issue:`91117` + +Description +=========== + +In order to reduce the amount of inline JavaScript (with the goal to pave the +way towards stronger Content-Security-Policy assignments) lots of inline JavaScript +code parts have been substituted by a declarative syntax - basically using HTML +:html:`data-*` attributes. + +The following list collects an overview of common JavaScript snippets and their +corresponding substitute using modules :js:`TYPO3/CMS/Backend/GlobalEventHandler` +and :js:`TYPO3/CMS/Backend/ActionDispatcher`. + + +`TYPO3/CMS/Backend/GlobalEventHandler` +-------------------------------------- + +.. code-block:: html + + <select onchange="window.location.href=this.options[this.selectedIndex].value;">' + <!-- ... changed to ... --> + <select data-global-event="change" data-action-navigate="$value">' + +Navigates to URL once selected drop-down was changed +(`$value` refers to selected value) + + +.. code-block:: html + + <select value="0" name="depth" + onchange="window.location.href='https://example.org/__VAL__'.replace(/__VAL__/, this.options[this.selectedIndex].value);"> + <!-- ... changed to ... --> + <select value="0" name="depth" data-global-event="change" + data-action-navigate="$data=~s/$value/" data-navigate-value="https://example.org/${value}"> + +Navigates to URL once selected drop-down was changed, including selected value +(`$data` refers to value of :html:`data-navigate-value`, `$value` to selected value, +`$data=~s/$value/` replaces literal `${value}` with selected value in `:html:`data-navigate-value`) + + +.. code-block:: html + + <input type="checkbox" name="setting" onclick="window.location.href='/?setting='+(this.checked ? 1 : 0)"> + <!-- ... changed to ... --> + <input type="checkbox" name="setting" value="1" data-empty-value="0" + data-global-event="change" data-action-navigate="$data=~s/$value/"> + +Checkboxes used to send a particular value when being unchecked can be achieved by using +:html:`data-empty-value="0"` - in case this attribute is omitted, an empty string `''` is sent. + + +.. code-block:: html + + <input type="checkbox" onclick="document.getElementById('formIdentifier').submit();"> + <!-- ... changed to ... --> + <input type="checkbox" data-global-event="change" data-action-submit="$form"> + <!-- ... or (using CSS selector) ... --> + <input type="checkbox" data-global-event="change" data-action-submit="#formIdentifier"> + +Submits a form once a value has been changed +(`$form` refers to paren form element, using CSS selectors like `#formIdentifier` +is possible as well) + + +`TYPO3/CMS/Backend/ActionDispatcher` +------------------------------------ + +.. code-block:: html + + <a href="#" onclick="top.TYPO3.InfoWindow.showItem('tt_content', 123); return false;"> + <!-- ... changed to ... --> + data-dispatch-action="TYPO3.InfoWindow.showItem" data-dispatch-args-list="be_users,123"> + <!-- ... or (using JSON arguments) ... --> + data-dispatch-action="TYPO3.InfoWindow.showItem" data-dispatch-args="["tt_content",123]"> + +Invokes :js:`TYPO3.InfoWindow.showItem` module function to display details for a given +record (of database table `tt_content`, having `uid=123` in the example above) + + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.4.x/Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions.rst b/Documentation/Changelog/10.4.x/Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions.rst new file mode 100644 index 0000000..2686965 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-91132-AvoidJavaScriptInUserSettingsConfigurationOptions.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _important-91132: + +=========================================================================== +Important: #91132 - Avoid JavaScript in User Settings Configuration options +=========================================================================== + +See :issue:`91132` + +Description +=========== + +User Settings Configuration options for buttons `onClick` and `onClickLabels` +(used to generate inline JavaScript `onclick` event) and `confirmData.jsCodeAfterOk` +(used to execute a JavaScript callback in modal confirmations) should be omitted. + +New options `clickData.eventName` and `conformationData.eventName` should be used +containing an individual event name that has to be handled individually using a +static JavaScript module. + +This step is advised to reduce the amount of inline JavaScript code towards +better support for Content-Security-Policy headers. + +Applications having custom changes in :php:`$GLOBALS['TYPO3_USER_SETTINGS']` +and using mentioned options `onClick*` or `confirmData.jsCodeAfterOk`. + +The following example show a potential migration path to avoid inline JavaScript. + +.. code-block:: php + + $GLOBALS['TYPO3_USER_SETTINGS'] = [ + 'columns' => [ + 'customButton' => [ + 'type' => 'button', + 'onClick' => 'alert("clicked the button")', + 'confirm' => true, + 'confirmData' => [ + 'message' => 'Please confirm...', + 'jsCodeAfterOk' => 'alert("confirmed the modal dialog")', + ] + ], + // ... + +The above configuration can be replace by the following. + +.. code-block:: php + + $GLOBALS['TYPO3_USER_SETTINGS'] = [ + 'columns' => [ + 'customButton' => [ + 'type' => 'button', + 'clickData' => [ + 'eventName' => 'setup:customButton:clicked', + ], + 'confirm' => true, + 'confirmData' => [ + 'message' => 'Please confirm...', + 'eventName' => 'setup:customButton:confirmed', + ] + ], + // ... + +Events declared in corresponding `eventName` options have to be handled by +a custom static JavaScript module. Following snippets show the relevant parts: + +.. code-block:: javascript + + document.querySelectorAll('[data-event-name]') + .forEach((element: HTMLElement) => { + element.addEventListener('setup:customButton:clicked', (evt: Event) => { + alert('clicked the button'); + }); + }); + document.querySelectorAll('[data-event-name]') + .forEach((element: HTMLElement) => { + element.addEventListener('setup:customButton:confirmed', (evt: Event) => { + evt.detail.result && alert('confirmed the modal dialog'); + }); + }); + +PSR-14 event :php:`\TYPO3\CMS\Setup\Event\AddJavaScriptModulesEvent` can be used +to inject a JavaScript module to handle those custom JavaScript events. + + +.. index:: Backend, NotScanned, ext:setup diff --git a/Documentation/Changelog/10.4.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst b/Documentation/Changelog/10.4.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst new file mode 100644 index 0000000..d04f7ce --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst @@ -0,0 +1,21 @@ +.. include:: /Includes.rst.txt + +.. _important-92020-1668719328: + +=============================================================================== +Important: #92020 - New API entry point available at https://get.typo3.org/api/ +=============================================================================== + +See :issue:`92020` + +Description +=========== + +The core version service now uses the new entry point of the REST API +available via https://get.typo3.org/api. + +The old entry point is still available but should not be longer used. + +For more information see `https://get.typo3.org/api/doc <https://get.typo3.org/api/doc>`_. + +.. index:: ext:install diff --git a/Documentation/Changelog/10.4.x/Important-92100-YAMLImportsFollowDeclarationOrder.rst b/Documentation/Changelog/10.4.x/Important-92100-YAMLImportsFollowDeclarationOrder.rst new file mode 100644 index 0000000..c3d705f --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92100-YAMLImportsFollowDeclarationOrder.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _important-92100: + +========================================================= +Important: #92100 - YAML imports follow declaration order +========================================================= + +See :issue:`92100` + +Description +=========== + +Since #78917 various places of TYPO3 can be configured using YAML. It's +also possible to use `imports` to split larger configurations into logical +subparts. The `imports` functionality previously imported the configured +files in the reverse order in which they were configured in the importing +file. Since it's sometimes important, e.g. when using `imports` in site +configurations, the import can now be configured to follow the declaration +order. The files are then imported in the exact same order as they are +configured in the importing file. Therefore, a new feature toggle +`yamlImportsFollowDeclarationOrder` is introduced. It defaults to +`false` for existing installations and to `true` for new installations. +This means, if you currently rely on the reverse order, nothing changes +in your existing installation. + +Example: + +.. code-block:: yaml + + imports: + - { resource: "EXT:site/Configuration/SomeFile.yaml" } + - { resource: "EXT:site/Configuration/AnotherFile.yaml" } + +With `yamlImportsFollowDeclarationOrder` set to `true`: + +1. :file:`EXT:site/Configuration/SomeFile.yaml` +2. :file:`EXT:site/Configuration/AnotherFile.yaml` + +With `yamlImportsFollowDeclarationOrder` set to `false`: + +1. :file:`EXT:site/Configuration/AnotherFile.yaml` +2. :file:`EXT:site/Configuration/SomeFile.yaml` + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/10.4.x/Important-92336-DiscardingRecordsInWorkspaceModuleHardDeletesThem.rst b/Documentation/Changelog/10.4.x/Important-92336-DiscardingRecordsInWorkspaceModuleHardDeletesThem.rst new file mode 100644 index 0000000..5bc5aca --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92336-DiscardingRecordsInWorkspaceModuleHardDeletesThem.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-92336: + +============================================================================ +Important: #92336 - Discarding records in workspace module hard deletes them +============================================================================ + +See :issue:`92336` + +Description +=========== + +The discard functionality in the workspace module allows to "throw away" +changes that have been done by editors in a workspace. + +On database side, discard previously created a mixture of hard deleted (dropped) +rows and soft deleted (field :sql:`deleted` set to :sql:`1`) rows. + +This has been streamlined: Discarding records now always hard deletes rows from +the database. Those records can't be "undeleted" using the recycler extension +anymore, which only worked in very simple and limited cases before. + +.. index:: Backend, Database, ext:workspaces diff --git a/Documentation/Changelog/10.4.x/Important-92356-DataHandlerPerformanceImprovements.rst b/Documentation/Changelog/10.4.x/Important-92356-DataHandlerPerformanceImprovements.rst new file mode 100644 index 0000000..c9c7e2f --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92356-DataHandlerPerformanceImprovements.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-92356: + +======================================================== +Important: #92356 - DataHandler performance improvements +======================================================== + +See :issue:`92356` + +Description +=========== + +The core :php:`DataHandler` is the central backend heart of the system to +persist state changes in the database whenever editors change elements. + +Some changes have been applied to reduce the database query load performed +by the :php:`DataHandler` and its related classes. Latest changes especially +dropped a number of useless queries and php operations when relations are handled. +Depending on the handled structure, the :php:`DataHandler` executes up to 30% less +queries than before. More improvements continue to happen and will be ported +to v10 if possible. + +.. index:: Backend, Database, ext:core diff --git a/Documentation/Changelog/10.4.x/Important-92655-MakeRequestTimeoutConfigurableForLinkvalidator.rst b/Documentation/Changelog/10.4.x/Important-92655-MakeRequestTimeoutConfigurableForLinkvalidator.rst new file mode 100644 index 0000000..1149ab5 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92655-MakeRequestTimeoutConfigurableForLinkvalidator.rst @@ -0,0 +1,99 @@ +.. include:: /Includes.rst.txt + +.. _important-92655: + +======================================================================= +Important: #92655 - Make request timeout configurable for linkvalidator +======================================================================= + +See :issue:`92655` + +Description +=========== + +The external link checking now uses a default (total) timeout of 20 seconds. +Previously, a timeout was not set, which resulted in the default from +Global Configuration :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout']` +being used, which was 0 by default. 0 means no timeout. + +In some edge cases, this caused the link checking to hang indefinitely, +which also lead to a scheduler task hanging indefinitely. + +The timeout now defaults to 20 seconds (which is twice the time that is set +as connect_timeout in the core Global Configuration). + +The timeout can be changed in Page TSconfig: + +.. code-block:: typoscript + + mod.linkvalidator.linktypesConfig.external.timeout = 10 + +You can also unset it, which will result in the Global Configuration +:php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout']` being used: + +.. code-block:: typoscript + + mod.linkvalidator.linktypesConfig.external.timeout > + +.. important:: + + It is not recommended to use 0. + +Background information +====================== + +The Linkvalidator :php:`ExternalLinktype` class uses the core +:php:`RequestFactory` (which uses Guzzle under the hood). +:php:`RequestFactory::request` expects a set of options where +the timeout can be passed along. + +If it is not, the core :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout']` +is used. + +If a timeout for querying an external link is not set, the request may linger +indefinitely and will not terminate. See the related issues for steps to +reproduce this. + +How does HTTP request timeout generally work? +--------------------------------------------- + +Depending on the library used and the tool, you can usually set: + +* connect timeout +* read timeout +* general timeout + +Libraries and utilities often have these options separately, including - for +example - the curl command line tool or Guzzle. + +TYPO3 uses Guzzle under the hood. + +Core Global Configuration +------------------------- + +These are currently the defaults in the core: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['connect_timeout'] = 10;` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout'] = 0;` + +This sets the corresponding timeouts in Guzzle. + +Guzzle request options +---------------------- + +These are currently the default timeouts in Guzzle (but connect_timeout +and timeout will be overridden by the core): + +* connect_timeout: 0 +* read_timeout: Defaults to the value of the default_socket_timeout PHP ini + setting +* timeout: 0 + +More information +================ + +* `Guzzle Request Options <https://docs.guzzlephp.org/en/stable/request-options.html>`__ +* see :file:`typo3/sysext/core/Configuration/DefaultConfiguration.php in core` +* see :php:`GuzzleClientFactory` and :php:`RequestFactory` in the core + +.. index:: Backend, ext:linkvalidator diff --git a/Documentation/Changelog/10.4.x/Important-92659-ChangeTCAConfigurationOfImagewidthImageheight.rst b/Documentation/Changelog/10.4.x/Important-92659-ChangeTCAConfigurationOfImagewidthImageheight.rst new file mode 100644 index 0000000..8b04172 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-92659-ChangeTCAConfigurationOfImagewidthImageheight.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _important-92659: + +======================================================================== +Important: #92659 - Change TCA configuration of imagewidth & imageheight +======================================================================== + +See :issue:`92659` + +Description +=========== + +The TCA configuration for `tt_content` fields `imagewidth` and `imageheight` has +been simplified. Therefore, the following options have been removed from these +fields: + +.. code-block:: php + + 'max' => 4, + 'range' => [ + 'upper' => 1999 + ] + +TYPO3 itself shouldn't limit the inputs of an editor by using a number which +was assumed to be large, 10 years ago. + +If you rely on these options, please provide it in your site package by defining +it in :file:`Configuration/TCA/Overrides/tt_content.php`: + +.. code-block:: php + + $GLOBALS['TCA']['tt_content']['columns']['imagewidth']['config']['max'] = 4; + $GLOBALS['TCA']['tt_content']['columns']['imagewidth']['config']['range']['upper'] = 1999; + + $GLOBALS['TCA']['tt_content']['columns']['imageheight']['config']['max'] = 4; + $GLOBALS['TCA']['tt_content']['columns']['imageheight']['config']['range']['upper'] = 1999; + +.. index:: Backend, TCA, ext:frontend diff --git a/Documentation/Changelog/10.4.x/Important-93331-DescriptionOfSelectCheckBoxItems.rst b/Documentation/Changelog/10.4.x/Important-93331-DescriptionOfSelectCheckBoxItems.rst new file mode 100644 index 0000000..49f4334 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-93331-DescriptionOfSelectCheckBoxItems.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _important-93331: + +======================================================= +Important: #93331 - Description of SelectCheckBox items +======================================================= + +See :issue:`93331` + +Description +=========== + +Due to the introduction of grouping and sorting for TCA columns of type +`select` in #91008, the position of the items description, also referred +as "Help text" has changed in the corresponding TCA configuration. This +previously led to misbehaviour when using `renderType=selectCheckBox` +since the old position was still checked by this FormEngine element. + +Adding descriptions is now working again and it will be used when configured +at the correct position: + +.. code-block:: php + + 'items' => [ + ..., + [ + 'the label', + 'the value', + 'iconIdentifier', + 'groupIdentifier', + // The item description must be added as the fifth argument + 'item description' + ], + ] + +It's furthermore still possible to define an array with the `title` and +`description` keys: + +.. code-block:: php + + 'items' => [ + ..., + [ + 'the label', + 'the value', + 'iconIdentifier', + 'groupIdentifier', + // The item description must be added as the fifth argument + [ + 'title' => 'Help title', + 'description' => 'Help description' + ] + ] + ] + +In case you are using :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['customPermOptions']` +for defining custom permission options, nothing changes. The description has +still to be placed at the third position in each items configuration. + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['customPermOptions'] => [ + 'my_custom_field' => [ + 'items' => [ + 'someKey' => [ + 'the label', + 'anIconIdentifier', + 'item description', + ] + ] + ] + ] + +.. index:: Backend, TCA diff --git a/Documentation/Changelog/10.4.x/Important-93854-AddDisabledOptionForAllowedAspectRatios.rst b/Documentation/Changelog/10.4.x/Important-93854-AddDisabledOptionForAllowedAspectRatios.rst new file mode 100644 index 0000000..2b877e5 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-93854-AddDisabledOptionForAllowedAspectRatios.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _important-93854: + +================================================================= +Important: #93854 - Add disabled option for allowed aspect ratios +================================================================= + +See :issue:`93854` + +Description +=========== + +Like for crop variants it is now possible to add the option to disable aspect ratios by adding a "disabled" key to the array. + +.. code-block:: php + + $GLOBALS['TCA']['tt_content']['types']['textmedia']['columnsOverrides'] + ['assets']['config']['overrideChildTca']['columns']['crop']['config'] = [ + 'cropVariants' => [ + 'default' => [ + 'allowedAspectRatios' => [ + '4:3' => [ + 'disabled' => true, + ], + ], + ], + ], + ]; + +This works for each field, that defines crop variants for any +:sql:`sys_file_reference` usage. + +Impact +====== + +This will optionally let you disable aspect ratios for a specific field or +:sql:`CType`, which is sometimes necessary because the ratio will not fit in +the frontend. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/10.4.x/Important-93931-ValidationOfExtensionsComposerjsonFiles.rst b/Documentation/Changelog/10.4.x/Important-93931-ValidationOfExtensionsComposerjsonFiles.rst new file mode 100644 index 0000000..37fbbe5 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-93931-ValidationOfExtensionsComposerjsonFiles.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _important-93931: + +================================================================= +Important: #93931 - Validation of Extensions' composer.json files +================================================================= + +See :issue:`93931` + +Description +=========== + +Future TYPO3 versions will require extensions to have a valid +:file:`composer.json` file as a replacement for :file:`ext_emconf.php`. +This description file is used to define dependencies and the +loading order of extensions within TYPO3. + +In order to support site administrators by creating valid +:file:`composer.json` files for their extensions, the Extension manager +now lists all affected extensions with details about the necessary +adaptations. Site administrators can also use the new proposal +functionality, which suggests a possible and valid :file:`composer.json` +file for those extensions by accessing TYPO3.org (TER). TYPO3.org +is used to resolve dependencies to extensions, available in the TER. + +You can also check your current installation for such extensions +in the reports module. + +Further information on the transition phase and examples +of valid :file:`composer.json` files for TYPO3 Extensions can be found on +https://extensions.typo3.org/help/composer-support + +.. index:: Backend, ext:extensionmanager diff --git a/Documentation/Changelog/10.4.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst b/Documentation/Changelog/10.4.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst new file mode 100644 index 0000000..eedbe71 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _important-94951-1655368664: + +=================================================================== +Important: #94951 - Restrict export functionality to allowed users +=================================================================== + +See :issue:`94951` + +.. important:: + This change was introduced as part of the + `TYPO3 11.5.11 and 10.4.29 security release <https://typo3.org/security/advisory/typo3-core-sa-2022-001>`__. + +Description +=========== + +The export functionality has the following security drawbacks: + +* Export for editors is not limited on field level +* The :guilabel:`Save to filename` functionality saves to a shared folder, + which other editors with different access rights may have access to. + +Both issues are not easy to resolve and also the target +audience for the Import/Export functionality are mainly +TYPO3 admins. + +Impact +====== + +The export functionality is restricted +to TYPO3 admin users and to users, who explicitly have +access through the new user TSConfig setting +:typoscript:`options.impexp.enableExportForNonAdminUser`. + +Affected installations +====================== + +Installations with EXT:impexp installed where non-admin users need to use the +export functionality. + +Migration +========= + +If non-admin users should be able to use the export tool, set the +following user TSconfig: + +.. code-block:: typoscript + :caption: EXT:my_sitepackage/Configuration/TSconfig/allusers.tsconfig + + options.impexp.enableExportForNonAdminUser = 1 + +.. index:: Backend, TSConfig, NotScanned, ext:impexp diff --git a/Documentation/Changelog/10.4.x/Important-95297-StrictCHashValidationFeatureFlag.rst b/Documentation/Changelog/10.4.x/Important-95297-StrictCHashValidationFeatureFlag.rst new file mode 100644 index 0000000..e4bb456 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Important-95297-StrictCHashValidationFeatureFlag.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _important-95297-1674809371: + +======================================================== +Important: #95297 - Strict cHash validation feature flag +======================================================== + +See :issue:`95297` + +Description +=========== + +Since TYPO3 v9 and the PSR-15 Middleware concept, cHash validation was moved +outside of plugins and rendering code inside a validation middleware to check if +a given "cHash" acts as a signature of other query parameters in order to use a +cached version of a frontend page. + +However, the check only provided information about an invalid "cHash" in the +query parameters. When no "cHash" was given, the only option was to add a +"required list" (global TYPO3 configuration option +`requireCacheHashPresenceParameters`), but not based on the final +`excludedParameters` for cache hash calculation of given query parameters. + +For this reason, a new global TYPO3 configuration option +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['enforceValidation']` +has been added. + +When enabled, the same validation for calculating a "cHash" value is used as +when a valid or invalid "cHash" parameter is given to a request, even when no +"cHash" is given. + +The new option is disabled for existing installations, but enabled for new +installations. It is also highly recommended to enable this option in +your existing installations. + +In future TYPO3 versions, this functionality will be enabled for all TYPO3 +installations, while the configuration option +`requireCacheHashPresenceParameters` will be removed. + +.. index:: Frontend, LocalConfiguration, ext:frontend diff --git a/Documentation/Changelog/10.4.x/Index.rst b/Documentation/Changelog/10.4.x/Index.rst new file mode 100644 index 0000000..f3b8ff0 --- /dev/null +++ b/Documentation/Changelog/10.4.x/Index.rst @@ -0,0 +1,48 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-4-x: + +============== +10.4.x Changes +============== + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +================ + +None since TYPO3 v10.4.0 LTS release. + +.. attention:: + + Breaking changes are not planned after the TYPO3 v10.4.0 LTS release. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +None + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/10.4/Breaking-90660-RegistrationOfWidgetsChanged.rst b/Documentation/Changelog/10.4/Breaking-90660-RegistrationOfWidgetsChanged.rst new file mode 100644 index 0000000..9c6637d --- /dev/null +++ b/Documentation/Changelog/10.4/Breaking-90660-RegistrationOfWidgetsChanged.rst @@ -0,0 +1,157 @@ +.. include:: /Includes.rst.txt + +.. _breaking-90660: + +============================================================ +Breaking: #90660 - Registration of dashboard widgets changed +============================================================ + +See :issue:`90660` + +Description +=========== + +As the registration of dashboard widgets changed to allow creation of widgets +through configuration, it is necessary to change your registration of widgets you +registered yourself in version 10.3. The abstracts used to kick start your +widgets were removed and the widgets shipped with EXT:dashboard were refactored. + + +Impact +====== + +As the abstracts previously used to kick-start a widget are removed, you need +to change to the new way of registering widgets. The dashboard +module will break if you do not update your registration. + + +Affected Installations +====================== + +All 3rd party extensions that registered an own widget with TYPO3 v10.3, will be +affected and need to update the widget registration. If you only used the widgets +shipped with core, you don't have to do anything. + + +Migration +========= + + +Migration of widgets based on default widget types +-------------------------------------------------- + +This section demonstrates how to migrate widgets that are based on one of +the existing widget types shipped by core. If your widgets are extending +one of the following classes, you can use this section to migrate your registration +to the new syntax. + +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractBarChartWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractChartWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractCtaButtonWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractDoughnutChartWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractListWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractNumberWithIconWidget` +- :php:`\TYPO3\CMS\Dashboard\Widgets\AbstractRssWidget` + +First of all you need to update your registration in the :file:`Services.yaml` file. +Here comes an example of a registration of RSS widget in the old version. + +**Before** + +.. code-block:: yaml + + Vendor\Package\Widgets\MyOwnRSSWidget: + arguments: [‘myOwnRSSWidget’] + tags: + - name: dashboard.widget + identifier: myOwnRSSWidget + widgetGroups: ‘general’ + + +As you can now use the predefined widgets and only have to register your own +implementation with your own configuration, you have to alter this registration +a little bit. + +**Now** + +.. code-block:: yaml + + dashboard.widget.myOwnRSSWidget: + class: 'TYPO3\CMS\Dashboard\Widgets\RssWidget' + arguments: + $view: '@dashboard.views.widget' + $cache: '@cache.dashboard.rss' + $options: + rssFile: 'https://typo3.org/rss' + # 12 hours cache + lifeTime: 43200 + tags: + - name: dashboard.widget + identifier: 'myOwnRSSWidget' + groupNames: ‘general’ + title: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:widgets.myOwnRSSWidget.title' + description: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:widgets.myOwnRSSWidget.description' + iconIdentifier: 'content-widget-rss' + height: 'medium' + width: 'medium' + + +It starts with the name of the service. Best practise is to use a dot-styled +name as there will be no class with that name. You can have multiple services +using the same class. + +On the second line, we define which widget to use. In this case we choose the +RssWidget from the dashboard core extension. In the documentation, we explain +all the arguments like :php:`$view` and :php:`$cache`. For the migration you need +the :php:`$options` argument. + +As you can see we specify the RSS file and the cache lifetime for this feed. +In the old situation you had to set these values in the class. +Now you can just put those values in the registration. + +The second part that changed a little bit, is that you need to set the title, +description, icon, height and width in the tags section of the registration. +You can still use translatable strings like +``LLL:EXT:extension/Resources/Private/Language/locallang.xlf:widgets.myOwnRSSWidget.title``. +Important to remember is that the :yaml:`widgetGroups` property changed to :yaml:`groupNames` +to stay consistent with other service registrations. + +Please note that valid values for height and width are now: :yaml:`small`, :yaml:`medium`, +and :yaml:`large`. + +In the following table you can see which WidgetType to use now based on the +abstract you used previously. + ++--------------------------------------+----------------------------------------------------------------------+ +| Previously used abstract | Widget class to use for your registration | ++======================================+======================================================================+ +| :php:`AbstractBarChartWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\BarChartWidget` | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractChartWidget` | This was only used as an abstract of the other chart widgets and is | +| | not used anymore. If you want another graph type, you have to create | +| | your own widget. | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractCtaButtonWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\CtaWidget` | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractDoughnutChartWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\DoughnutChartWidget` | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractListWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\ListWidget` | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractNumberWithIconWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\NumberWithIconWidget` | ++--------------------------------------+----------------------------------------------------------------------+ +| :php:`AbstractRssWidget` | :php:`TYPO3\CMS\Dashboard\Widgets\RssWidget` | ++--------------------------------------+----------------------------------------------------------------------+ + + +You can check the documentation of EXT:dashboard to see the exact options for every type of widget. + + +Migration of widgets based on own widget type +--------------------------------------------- + +When you created your complete own widget type, the main thing to check is you +use the Dependency Injection options you have now. Please refer to the documentation +of EXT:dashboard to see how to create your own widget type and what options you +have. + +.. index:: Backend, ext:dashboard, NotScanned diff --git a/Documentation/Changelog/10.4/Breaking-91066-MovedInterfacesOfDashboard.rst b/Documentation/Changelog/10.4/Breaking-91066-MovedInterfacesOfDashboard.rst new file mode 100644 index 0000000..082c63b --- /dev/null +++ b/Documentation/Changelog/10.4/Breaking-91066-MovedInterfacesOfDashboard.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91066: + +=============================================== +Breaking: #91066 - Move interfaces of Dashboard +=============================================== + +See :issue:`91066` + +Description +=========== + +The interfaces of the dashboard have been moved out of the +interfaces folder to be consistent with the overall TYPO3 structure. + + +Impact +====== + +New widget types that have implemented one or more of the interfaces of EXT:dashboard. +If the namespace of those interfaces is not changed, you will get errors saying +that the interfaces are not found anymore. + + +Affected Installations +====================== + +All 3rd party extensions that created own widget types and implement one of the +interfaces of EXT:dashboard should update their paths. The accepted interfaces +are: + +- :php:`AdditionalCssInterface` +- :php:`AdditionalJavascriptInterface` +- :php:`ButtonProviderInterface` +- :php:`ChartDataProviderInterface` +- :php:`EventDataProviderInterface` +- :php:`ListDataProviderInterface` +- :php:`NumberWithIconDataProviderInterface` +- :php:`RequireJsModuleInterface` +- :php:`WidgetConfigurationInterface` +- :php:`WidgetInterface` + + +Migration +========= + +The interfaces listed above have been moved from :php:`TYPO3\CMS\Dashboard\Widgets\Interfaces` +to :php:`TYPO3\CMS\Dashboard\Widgets`. You need to adapt the namespaces of those +interfaces in your own widgets. + +.. index:: Backend, ext:dashboard, FullyScanned diff --git a/Documentation/Changelog/10.4/Breaking-91066-RemovedButtonUtility.rst b/Documentation/Changelog/10.4/Breaking-91066-RemovedButtonUtility.rst new file mode 100644 index 0000000..0362b32 --- /dev/null +++ b/Documentation/Changelog/10.4/Breaking-91066-RemovedButtonUtility.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91066-1668719172: + +======================================== +Breaking: #91066 - Removed ButtonUtility +======================================== + +See :issue:`91066` + +Description +=========== + +The :php:`ButtonUtility` was superfluous and therefor removed. + + +Impact +====== + +You need to remove the usage of the :php:`ButtonUtility` class, otherwise you +will get fatal errors of missing classes. + + +Affected Installations +====================== + +All 3rd party extensions that created own widget types with the option to add +a button using the :php:`ButtonUtility::generateButtonConfig()` method are +affected. + + +Migration +========= + +First of all you need to change one line in your Widget class. When assigning +your button parameter to your Fluid Template, you most probably have the following +line: + +.. code-block:: php + + 'button' => ButtonUtility::generateButtonConfig($this->buttonProvider), + +You have to change that into: + +.. code-block:: php + + 'button' => $this->buttonProvider, + +Because you change the variable passed to your template, you also need to do a +small change in your template. + +In your template in the footer section, you will find a line like this: + +.. code-block:: html + + <a href="{button.link}" target="{button.target}" class="widget-cta">{f:translate(id: button.text, default: button.text)}</a> + +You need to change the text property to the title property. So the line above will +become: + +.. code-block:: html + + <a href="{button.link}" target="{button.target}" class="widget-cta">{f:translate(id: button.title, default: button.title)}</a> + +.. index:: Backend, ext:dashboard, FullyScanned diff --git a/Documentation/Changelog/10.4/Deprecation-88740-ExtFeloginPibasePlugin.rst b/Documentation/Changelog/10.4/Deprecation-88740-ExtFeloginPibasePlugin.rst new file mode 100644 index 0000000..8aa78fe --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-88740-ExtFeloginPibasePlugin.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-88740: + +============================================================= +Deprecation: #88740 - ext:felogin pibase plugin related hooks +============================================================= + +See :issue:`88740` + +Description +=========== + +All legacy hooks related to the pibase plugin of EXT:felogin have been disabled +and will be removed in TYPO3v11. + + +Impact +====== + +Extensions that use any of the following hooks will trigger a PHP :php:`E_USER_DEPRECATED` error: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['beforeRedirect']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['postProcContent']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['password_changed']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['forgotPasswordMail']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_confirmed']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_error']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['loginFormOnSubmitFuncs']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['logout_confirmed']` + + +Affected Installations +====================== + +All instances using extensions that use any of the previously named hooks. + +Migration +========= + +All of the hooks have been replaced by equivalent PSR-14 events. + ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +| Pibase hook | PSR-14 event | ++===================================================================================+======================================================================+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['beforeRedirect']` | :php:`\TYPO3\CMS\FrontendLogin\Event\BeforeRedirectEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['postProcContent']` | :php:`\TYPO3\CMS\FrontendLogin\Event\ModifyLoginFormViewEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['forgotPasswordMail']` | :php:`\TYPO3\CMS\FrontendLogin\Event\SendRecoveryEmailEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['password_changed']` | :php:`\TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_confirmed']` | :php:`\TYPO3\CMS\FrontendLogin\Event\LoginConfirmedEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_error']` | :php:`\TYPO3\CMS\FrontendLogin\Event\LoginErrorOccurredEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['logout_confirmed']` | :php:`\TYPO3\CMS\FrontendLogin\Event\LogoutConfirmedEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ +|:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['loginFormOnSubmitFuncs']` | :php:`\TYPO3\CMS\FrontendLogin\Event\ModifyLoginFormViewEvent` | ++-----------------------------------------------------------------------------------+----------------------------------------------------------------------+ + +.. index:: Frontend, FullyScanned, ext:felogin diff --git a/Documentation/Changelog/10.4/Deprecation-90147-UnifiedFileNameValidator.rst b/Documentation/Changelog/10.4/Deprecation-90147-UnifiedFileNameValidator.rst new file mode 100644 index 0000000..da8af23 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90147-UnifiedFileNameValidator.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90147: + +================================================= +Deprecation: #90147 - Unified File Name Validator +================================================= + +See :issue:`90147` + +Description +=========== + +The logic for validating if a new (uploaded) or renamed file's name is allowed +is now available in an encapsulated PHP class :php:`FileNameValidator`. + +The functionality is moved so all logic is encapsulated in one single place: + +- PHP constant `FILE_DENY_PATTERN_DEFAULT` is migrated into a class constant. +- :file:`LocalConfiguration.php` setting is only used when it differs from the default. +- The :php:`GeneralUtility` method has been marked as deprecated and calls :php:`FileNameValidator->isValid()` directly. + +This optimization helps to only utilize and use PHPs memory if +needed, and avoids to define run-time constants or variables. +Logic is only initialized when needed - e.g. when uploading files or using TYPO3's importer via EXT:impexp. + +In addition, the PHP constant :php:`PHP_EXTENSIONS_DEFAULT` which is not +in use anymore, has been marked as deprecated, too. + + +Impact +====== + +Using the method :php:`GeneralUtility::verifyFilenameAgainstDenyPattern()` directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Using the constants will continue to work but will stop doing so TYPO3 v11.0, when they will be removed. + +The system-wide setting to override the default file deny pattern, +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['fileDenyPattern']` is only set when +different from the systems default. If it is the same, the option is not set anymore by TYPO3 Core. + + +Affected Installations +====================== + +TYPO3 installations with PHP code calling the mentioned method directly or using one of the global constants directly. + + +Migration +========= + +Instead of calling + +:php:`GeneralUtility::verifyFilenameAgainstDenyPattern($filename)` + +use + +:php:`GeneralUtility::makeInstance(FileNameValidator::class)->isValid($filename);` + +Instead of using the constant :php:`FILE_DENY_PATTERN_DEFAULT`, use :php:`FileNameValidator::DEFAULT_FILE_DENY_PATTERN`. + +For the PHP constant :php:`PHP_EXTENSIONS_DEFAULT` there is no replacement, as it has no benefit for TYPO3 Core anymore. + +The extension scanner will detect the method calls or the usage of the constants. + +.. index:: LocalConfiguration, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-90377-ParamTypesRefOfMethodCallUserFunction.rst b/Documentation/Changelog/10.4/Deprecation-90377-ParamTypesRefOfMethodCallUserFunction.rst new file mode 100644 index 0000000..07b6ca4 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90377-ParamTypesRefOfMethodCallUserFunction.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90377: + +================================================================= +Deprecation: #90377 - Param types $ref of method callUserFunction +================================================================= + +See :issue:`90377` + +Description +=========== + +:php:`GeneralUtility::callUserFunction()` accepts a reference variable which is +used to pass on the caller to the called function. Said variable :php:`$ref` +does not have a type hint, therefore it's possible to hand over any type of variable +whilst it's purpose is to only accept objects. + + +Impact +====== + +Passing :php:`$ref` into :php:`GeneralUtility::callUserFunction()` with a type other than :php:`object` or :php:`null` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations that pass a non :php:`object` or non :php:`null` type :php:`$ref` variable into :php:`GeneralUtility::callUserFunction()`. + + +Migration +========= + +There is none. :php:`$ref` is meant to be the calling object. Using it to pass arbitrary data to the user function will eventually be forbidden. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-90625-ExtbaseSignalSlotDispatcher.rst b/Documentation/Changelog/10.4/Deprecation-90625-ExtbaseSignalSlotDispatcher.rst new file mode 100644 index 0000000..7b3a25a --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90625-ExtbaseSignalSlotDispatcher.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90625: + +=================================================== +Deprecation: #90625 - Extbase SignalSlot Dispatcher +=================================================== + +See :issue:`90625` + +Description +=========== + +TYPO3 has various methods to extend existing TYPO3 Core functionality via PHP. + +One of the famous APIs is the so-called "SignalSlot Dispatcher", originally provided by Extbase and +TYPO3 Flow. + +The SignalSlot Dispatcher follows the Observer pattern, which was originally not designed to +actually interact (= modify) the information handed in - it's a signal that is sent. + +Since March 2019, a new standard recommendation in PHP - PSR-14 - was put into place, and adopted +in TYPO3 v10.0. TYPO3s PSR-14 implementation has several advantages over SignalSlot: + +* All Events ("Signals" in Extbase world) are actual PHP objects that clearly define what can + be read or modified. +* All Events are registered at compile-time (inside the Service Container), so the Listeners + ("Slots" in Extbase world) are defined in one place and are always available. Previously the + registration of the slots was done in :file:`ext_localconf.php`. +* Events can be used across other PHP projects as well, and the EventDispatcher can be the same + instance, as it is standard recommendation. + +In TYPO3 v10, all Extbase signals provided by TYPO3 Core have been migrated to PSR-14 events. + +For this reason, the Extbase SignalSlot Dispatcher has been marked as deprecated in TYPO3 Core. +It is recommended to migrate to PSR-14 Events and Event Listeners. + + +Impact +====== + +As :php:`SignalSlotDispatcher` is still in place within TYPO3 Core for backwards-compatibility reasons, +and extensions still have lots of Signals defined, no PHP :php:`E_USER_DEPRECATED` error will be triggered +if an extension is using the SignalSlot mechanism. However using it is highly discouraged, as it +will be removed in future TYPO3 versions. + + +Affected Installations +====================== + +Any TYPO3 installations with custom extensions that are using the SignalSlot Dispatcher. + + +Migration +========= + +Use PSR-14 Events and Event-Listeners instead. + +See the documentation for details: +:ref:`EventDispatcher (PSR-14 Events) <t3coreapi:EventDispatcher>` + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.4/Deprecation-90686-ModelFileMount.rst b/Documentation/Changelog/10.4/Deprecation-90686-ModelFileMount.rst new file mode 100644 index 0000000..0ebbc3c --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90686-ModelFileMount.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90686: + +===================================== +Deprecation: #90686 - Model FileMount +===================================== + +See :issue:`90686` + +Description +=========== + +The class :php:`\TYPO3\CMS\Extbase\Domain\Model\FileMount` has been marked as deprecated. + +The :php:`FileMount` is an internal class which never really had any functionality +besides being an Extbase model for the database table :sql:`sys_filemounts`. Therefore +and in order to streamline the codebase of Extbase, the class :php:`FileMount` will be removed with TYPO3 11.0. + + +Impact +====== + +Using :php:`FileMount` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with a third-party extension using the model. + + +Migration +========= + +Copy the class and mapping to your own extension and adopt the usages. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.4/Deprecation-90692-FileCollectionModels.rst b/Documentation/Changelog/10.4/Deprecation-90692-FileCollectionModels.rst new file mode 100644 index 0000000..3247d91 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90692-FileCollectionModels.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90692: + +=========================================== +Deprecation: #90692 - FileCollection models +=========================================== + +See :issue:`90692` + +Description +=========== + +The following classes have been marked as deprecated: + +- :php:`\TYPO3\CMS\Extbase\Domain\Model\StaticFileCollection` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\FolderBasedFileCollection` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\AbstractFileCollection` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\StaticFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FolderBasedFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractFileCollectionConverter` + +The classes were marked as internal and never contained any logic. Therefore and in order to streamline the codebase of Extbase, the files will be removed with TYPO3 11.0. + + +Impact +====== + +Using any of the mentioned classes will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with a third-party extension using the classes. + + +Migration +========= + +Copy the classes to your own extension and adopt the usages. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/10.4/Deprecation-90800-GeneralUtilityisRunningOnCgiServerApi.rst b/Documentation/Changelog/10.4/Deprecation-90800-GeneralUtilityisRunningOnCgiServerApi.rst new file mode 100644 index 0000000..c4a0329 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90800-GeneralUtilityisRunningOnCgiServerApi.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90800: + +============================================================= +Deprecation: #90800 - GeneralUtility::isRunningOnCgiServerApi +============================================================= + +See :issue:`90800` + +Description +=========== + +The lowlevel API method :php:`GeneralUtility::isRunningOnCgiServerApi()` which detects if +the current PHP is executed via a CGI wrapper script ("SAPI", see https://www.php.net/manual/en/function.php-sapi-name.php) has been +moved to the Environment API and is now available via :php:`Environment::isRunningOnCgiServer()`. + + +Impact +====== + +Calling the method from :php:`GeneralUtility` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with an extension using this PHP method, which will happen only in rare circumstances. + + +Migration +========= + +Use the new method :php:`Environment::isRunningOnCgiServer()` instead, which works exactly the same. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-90803-DeprecationOfObjectManagergetInExtbaseContext.rst b/Documentation/Changelog/10.4/Deprecation-90803-DeprecationOfObjectManagergetInExtbaseContext.rst new file mode 100644 index 0000000..2e3de42 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90803-DeprecationOfObjectManagergetInExtbaseContext.rst @@ -0,0 +1,172 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Deprecation-90803-ObjectManagerGet: + +=========================================================== +Deprecation: #90803 - ObjectManager::get in Extbase context +=========================================================== + +See :issue:`90803` + +Description +=========== + +To help understand the deprecation of :php:`$objectManager->get(Service::class)` let's first have a look at its domain: Dependency Injection +and its history as well as the culprits to deal with. + +With the introduction of Extbase over one decade ago, a lot of modern software development paradigms have been introduced into TYPO3. +One of that paradigms is Dependency Injection (DI) which is an approach of handling dependencies different than the one the TYPO3 core followed ever since. + +Given there is an EmailService class, which is responsible for sending emails, the usual approach of creating such a service was to create it +the moment it was needed. TYPO3 never used the :php:`new` keyword to create new objects, but :php:`GeneralUtility::makeInstance()`, which pretty much does the same thing. +So, one approach of creating dependencies is creating them in the current scope where the dependency is needed. + +.. tip:: + + As a rule of thumb, you can remember the following: + Whenever you are creating dependencies yourself with :php:`new` or :php:`GeneralUtility::makeInstance()`, you are not using Dependency Injection. + +Extbase introduced the concept of Dependency Injection (DI) which means, that all dependencies are declared in a way, that the dependency chain is known before runtime. +The most common way of implementing DI is to declare dependencies as constructor arguments. This means, in the scope of the current class, all dependencies are made visible as constructor arguments. +As those dependencies need to be created outside the current scope, a service container implementation is responsible for the creation and management of service instances. +Then, instead of calling :php:`new Service(...)`, the container needs to be queried for the needed service, e.g. by calling :php:`$container->get(Service::class)`. +This also assures that the container provide the requested services with their dependencies, as they are created the same way. + +There is an service container in Extbase but it's not exposed to the public. Instead, there is the :php:`ObjectManager` class, which acts as a proxy for the container and also has a :php:`get` method, to query instances of services. + +Exactly that :php:`get()` method is now deprecated in the extbase context because it should never be called directly. + +The usual extbase context is a controller. All controllers are created by the object manager and therefore support DI. Whenever a dependency is needed in an extbase context, +instead of calling :php:`$objectManager->get(Service::class)`, the usual DI approaches have to be used. Those approaches are constructor, method and property injection. + +Migration +--------- + +If you are using code similar to the following example, you should migrate to dependency injection: + +.. code-block:: php + + class MainController + { + public function listAction() + { + $service = $this->objectManager->get(Service::class); + $service->doSomething(); + } + } + + +Examples how to use dependency injection: + +Constructor Injection +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: php + + class MainController + { + private $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function listAction() + { + $this->service->doSomething(); + } + } + + +.. tip:: + + Constructor injection is the preferred type of injection for dependencies. + + +Method Injection +^^^^^^^^^^^^^^^^ + +.. code-block:: php + + class MainController + { + private $service; + + public function injectService(Service $service) + { + $this->service = $service; + } + + public function listAction() + { + $this->service->doSomething(); + } + } + + +Property Injection +^^^^^^^^^^^^^^^^^^ + +.. code-block:: php + + class MainController + { + /** + * @var Service + * @TYPO3\CMS\Extbase\Annotation\Inject + */ + public $service; + + public function listAction() + { + $this->service->doSomething(); + } + } + + +Unfortunately, there is even more to consider here. Dependencies usually are services and services are objects which are shareable. TYPO3 users might be more used to the term `Singleton`, which means, +that there is just one instance of a service during runtime which is shared across all scopes. Singletons are a great way to save resources but there is more to Singletons than just that. +To be able to share the same instance of a class across all scopes, the instance cannot store information about its state in its properties. +The idea of Singletons is to have an object that always behaves the same, no matter where it is used. + +Let's have a look at classes that are no services. We can borrow the term prototype from the Java world. A commonly used prototype object is a model. Each instance of a model clearly has a different state and therefore a different functionality. +Those objects can theoretically be injected but it's very uncommon to do so. Still, in Extbase, instances of prototypes (e.g. instances of models, or other instances that hold state) are very often created with the object manager, +which is bad practice. :php:`new` or :php:`GeneralUtility::makeInstance()` should be used for instantiating prototypes. + +However, when it comes to prototypes, there is a mechanic which cannot be implemented differently yet: the override of an implementation. + +It means, that it's possible to tell the :php:`ObjectManager` to create an instance of a different class than the one which is requested. +One example of that is class :php:`TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbBackend`, which can be fetched from the :php:`ObjectManager` by requesting an instance of the :php:`TYPO3\CMS\Extbase\Persistence\Generic\Storage\BackendInterface` interface. +This feature should only be used for services as well but it is often used to override models of other extensions. For models you can either decide to simply instantiate via :php:`new`, or if you want to provide support for overwriting models +via XCLASSes configured in :file:`ext_localconf.php` (configuration variable: :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects']`) you may also use :php:`GeneralUtility::makeInstance()`. + +.. tip:: + + Conclusion: + + Singletons (services without state) should be provided by Dependency Injection wherever possible. + + To create prototypes (instances with state), use :php:`new` or :php:`GeneralUtility::makeInstance()`. + + :php:`ObjectManager->get()` must no longer be used. + + +Impact +====== + +There is no impact yet. No PHP :php:`E_USER_DEPRECATED` error is triggered in TYPO3 10. This will probably change in TYPO3 11.x. + + +Affected Installations +====================== + +All installations that use :php:`ObjectManager->get()` directly to create instances of dependencies in a scope that supports native Dependency Injection. + + +Migration +========= + +As mentioned above, constructor, method or property injection must be used instead. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/10.4/Deprecation-90856-WidgetAutocompleteViewHelper.rst b/Documentation/Changelog/10.4/Deprecation-90856-WidgetAutocompleteViewHelper.rst new file mode 100644 index 0000000..ede429e --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90856-WidgetAutocompleteViewHelper.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90856: + +==================================================== +Deprecation: #90856 - Widget AutoComplete ViewHelper +==================================================== + +See :issue:`90856` + +Description +=========== + +The Fluid ViewHelper :html:`<f:widget.autocomplete>` and the related controller +:php:`TYPO3\CMS\Fluid\ViewHelpers\Widget\Controller\AutocompleteController` +have been marked as deprecated and will be removed in TYPO3 v11. + +The widget depends on third-party libraries that cannot be +maintained for a full LTS release lifecycle. + + +Impact +====== + +Any usage of this ViewHelper or extending one of the following classes will trigger a PHP :php:`E_USER_DEPRECATED` error: + +* :php:`TYPO3\CMS\Fluid\ViewHelpers\Widget\AutocompleteViewHelper` +* :php:`TYPO3\CMS\Fluid\ViewHelpers\Widget\Controller\AutocompleteController` + + +Affected Installations +====================== + +Any TYPO3 installation with custom templates that contain this ViewHelper. + + +Migration +========= + +Remove any usages within the Fluid templates. There is no replacement provided by the core. +If you need this widget, you have to provide your own implementation with your +own frontend libraries for the handling. + +If you still need it, copy the ViewHelper and Controller into an own extension. + + +.. index:: Fluid, PHP-API, NotScanned, ext:fluid diff --git a/Documentation/Changelog/10.4/Deprecation-90861-Image-relatedMethodsWithinContentObjectRenderer.rst b/Documentation/Changelog/10.4/Deprecation-90861-Image-relatedMethodsWithinContentObjectRenderer.rst new file mode 100644 index 0000000..3b16786 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90861-Image-relatedMethodsWithinContentObjectRenderer.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90861: + +======================================================================== +Deprecation: #90861 - Image-related methods within ContentObjectRenderer +======================================================================== + +See :issue:`90861` + +Description +=========== + +The following methods within :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`, +all which are related to generating :html:`<img>` tags for TYPO3 Frontend output via TypoScript, have been marked as deprecated: + +* :php:`cImage()` +* :php:`getBorderAttr()` +* :php:`getImageTagTemplate()` +* :php:`getImageSourceCollection()` +* :php:`linkWrap()` +* :php:`getAltParam()` + +An additional method, :php:`imageLinkWrap()` has been marked as "internal" now in order to allow refactoring in future TYPO3 versions. + +All methods have been moved to the :php:`ImageContentObject` class, als known as "IMAGE" cObject. + +The methods purpose is only relevant for generating IMAGE, thus making the actual ContentObjectRenderer class smaller. + + +Impact +====== + +Any TypoScript configuration using code of this is not affected. + +Only third-party extensions that use this code for frontend-related +image rendering might directly call these PHP methods. Calling these +methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom third-party extensions calling these +methods. TYPO3's Extension Scanner code can directly detect these calls. + + +Migration +========= + +As all moved methods are protected, it is recommended to either +extend the ImageContentObject class, or copy the respective code +into the third-party extension requiring this code. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.4/Deprecation-90937-VariousHooksInContentObjectRenderer.rst b/Documentation/Changelog/10.4/Deprecation-90937-VariousHooksInContentObjectRenderer.rst new file mode 100644 index 0000000..cf2cbdb --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90937-VariousHooksInContentObjectRenderer.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90937: + +============================================================ +Deprecation: #90937 - Various hooks in ContentObjectRenderer +============================================================ + +See :issue:`90937` + +Description +=========== + +The following hooks within class :php:`ContentObjectRenderer` have been marked as deprecated: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['cObjTypeAndClass']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['cObjTypeAndClassDefault']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['extLinkATagParamsHandler']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typolinkLinkHandler']` + +All hooks have been available for a long time, and several new concepts and APIs that have been added in previous LTS versions already, that superseded these hooks. + + +Impact +====== + +Extensions registering the any one of the hooks listed above will trigger a PHP :php:`E_USER_DEPRECATED` error when the code is executed. + + +Affected Installations +====================== + +TYPO3 installations with older extensions implementing one of the hooks above, which is very rare and only serve specific use-cases +for rendering ContentObjects or custom link style tags that are not related to TYPO3 v8 linking syntax (`t3://...`). + + +Migration +========= + +The hooks :php:`cObjTypeAndClass` and :php:`cObjTypeAndClassDefault` can be simplified by using the new way of registering custom ContentObjects via: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['ContentObjects']` - see :file:`EXT:frontend/ext_localconf.php` for examples - TYPO3 Core adds its shipped ContentObjects exactly the same way. + +The :php:`typolinkLinkHandler` hook is used for registering custom link syntax that start with a certain keyword such as "news:13". + +Since TYPO3 v8, LinkHandler support has been added to TYPO3 Core natively, using the new `t3://` syntax. +The "LinkHandler" registry can be extended via :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['linkHandler']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder']` that serves the same purpose with a better API. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.4/Deprecation-90956-AlternativeFetchMethodsAndReportsForGeneralUtilitygetUrl.rst b/Documentation/Changelog/10.4/Deprecation-90956-AlternativeFetchMethodsAndReportsForGeneralUtilitygetUrl.rst new file mode 100644 index 0000000..a71e1e1 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90956-AlternativeFetchMethodsAndReportsForGeneralUtilitygetUrl.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90956: + +======================================================================================== +Deprecation: #90956 - Alternative fetch methods and reports for GeneralUtility::getUrl() +======================================================================================== + +See :issue:`90956` + +Description +=========== + +The short-hand method :php:`GeneralUtility::getUrl()` provides a +fast way to fetch the contents of a local file or remote URL. + +For Remote URLs, TYPO3 v8 provides an object-oriented (PSR-7 compatible) way by using +the :php:`RequestFactory->request($url, $method, $options)` API. Under the hood, the PHP library GuzzleHTTP is used, +which evaluates what best option (e.g. curl library) should handle +the download to TYPO3. + +In general, it is recommended for any third-party extension developer to use either +PHP's native :php:`file_get_contents($file)` method or the :php:`RequestFactory->request()` method to fetch a PSR-7 ResponseInterface object. + +The additional arguments in :php:`GeneralUtility::getUrl()` which allowed +to send headers to the content or just do a HEAD request, or find reports on why +the request did not succeed have been marked as deprecated. + +PHP's native Exception Handling and the response object give enough insights already to load the HTTP headers as well, or even do HTTP `POST` requests. + + +Impact +====== + +Calling the method :php:`GeneralUtility::getUrl()` with more than one +method argument will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations using a third-party extension with :php:`GeneralUtility::getUrl()` +and more than one parameter in the call. + + +Migration +========= + +Depending on the use-case of using the additional method parameters, +certain alternatives exist since TYPO3 v8 already: + +Fetching the headers (as array) from a HTTP response: + +.. code-block:: php + + $response = GeneralUtility::makeInstance(RequestFactory::class)->request($url); + $allHeaders = $response->getHeaders(); + // Also see $response->getHeader($headerName) and $response->getHeaderLine($headerName) + +Sending additional headers with the HTTP request: + +.. code-block:: php + + $response = GeneralUtility::makeInstance(RequestFactory::class)->request($url, 'GET', ['headers' => ['accept' => 'application/json']]); + +Finding additional information about the response: + +.. code-block:: php + + $response = GeneralUtility::makeInstance(RequestFactory::class)->request($url, 'GET', ['headers' => ['accept' => 'application/json']]); + if ($response->getStatusCode() >= 300) { + $content = $response->getReasonPhrase(); + } else { + $content = $response->getBody()->getContents(); + } + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-90964-LanguageServiceFunctionalityAndInternalProperties.rst b/Documentation/Changelog/10.4/Deprecation-90964-LanguageServiceFunctionalityAndInternalProperties.rst new file mode 100644 index 0000000..9aaf459 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-90964-LanguageServiceFunctionalityAndInternalProperties.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-90964: + +=========================================================================== +Deprecation: #90964 - LanguageService functionality and internal properties +=========================================================================== + +See :issue:`90964` + +Description +=========== + +LanguageService - also known as :php:`$GLOBALS[LANG]` within TYPO3 Core +is used to fetch a label string from a XLF file and deliver the +translated value from that string. + +Some functionality related to legacy functionality or internal logic has been marked as deprecated and changed visibility: + +* :php:`LanguageService->LL_files_cache` - is now protected instead of public +* :php:`LanguageService->LL_labels_cache` - is now protected instead of public +* :php:`LanguageService->getLabelsWithPrefix()` - is deprecated as it is not needed +* :php:`LanguageService->getLLL()` - is now protected instead of public +* :php:`LanguageService->debugLL()` - is now protected instead of public + +The method :php:`LanguageService->loadSingleTableDescription()` is marked as internal now. + + +Impact +====== + +Calling any of the methods or properties listed above will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with extensions of custom logic using the internals of specifics of the :php:`LanguageService` class. + + +Migration +========= + +Use the Public API of the :php:`LanguageService` - namely :php:`sL()` and :php:`getLL()` directly. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-91001-VariousMethodsWithinGeneralUtility.rst b/Documentation/Changelog/10.4/Deprecation-91001-VariousMethodsWithinGeneralUtility.rst new file mode 100644 index 0000000..1f124fc --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-91001-VariousMethodsWithinGeneralUtility.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91001: + +=========================================================== +Deprecation: #91001 - Various methods within GeneralUtility +=========================================================== + +See :issue:`91001` + +Description +=========== + +The following methods within GeneralUtility have been marked as deprecated, +as the native PHP methods can be used directly: + +* :php:`GeneralUtility::IPv6Hex2Bin()` +* :php:`GeneralUtility::IPv6Bin2Hex()` +* :php:`GeneralUtility::compressIPv6()` +* :php:`GeneralUtility::milliseconds()` + +In addition, these methods are unused by Core and marked as deprecated as well: + +* :php:`GeneralUtility::linkThisUrl()` +* :php:`GeneralUtility::flushDirectory()` + + +Impact +====== + +Calling any methods directly from PHP will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions using any of these methods. + + +Migration +========= + +As the following methods are just wrappers around native PHP methods, it is +recommended to switch to native PHP to speed up performance: + +* :php:`GeneralUtility::IPv6Hex2Bin($hex)`: :php:`inet_pton($hex)` +* :php:`GeneralUtility::IPv6Bin2Hex($bin)`: :php:`inet_ntop($bin)` +* :php:`GeneralUtility::compressIPv6($address)`: :php:`inet_ntop(inet_pton($address))` +* :php:`GeneralUtility::milliseconds()`: :php:`round(microtime(true) * 1000)` + +As for :php:`GeneralUtility::linkThisUrl()` it is recommended to migrate to +PSR-7 (UriInterface). + +The method :php:`GeneralUtility::flushDirectory()` uses a clearing +folder structure which is only used for caching to avoid race-conditioning. It +is recommended to use :php:`GeneralUtility::rmdir()` or implement the code +directly in the third-party extension. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Deprecation-91012-VariousHooksRelatedToTypoScriptFrontendController.rst b/Documentation/Changelog/10.4/Deprecation-91012-VariousHooksRelatedToTypoScriptFrontendController.rst new file mode 100644 index 0000000..3aceb1f --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-91012-VariousHooksRelatedToTypoScriptFrontendController.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91012: + +=========================================================================== +Deprecation: #91012 - Various hooks related to TypoScriptFrontendController +=========================================================================== + +See :issue:`91012` + +Description +=========== + +The following hooks related to class :php:`TypoScriptFrontendController` +and frontend-rendering have been marked as deprecated: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['pageIndexing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['isOutputting']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['tslib_fe-contentStrReplace']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-output']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_eofe']` + +The following methods have been marked as deprecated as well, as they only +contain code relevant for executing the hooks: + +* :php:`TypoScriptFrontendController->isOutputting()` +* :php:`TypoScriptFrontendController->processContentForOutput()` + + +Impact +====== + +If third-party extensions are using the hooks, a PHP :php:`E_USER_DEPRECATED` error will be triggered when the hook is executed. + +Calling the two methods above will also trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using the hooks or mentioned above, which is common if they haven't been using +PSR-15 middlewares or other hooks instead. + + +Migration +========= + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['pageIndexing']` +should be replaced by the :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-cached']` hook +to index pages. However, please note that :php:`$TSFE->content` might contain UTF-8 content now, +instead of content already converted to the defined character set related to :typoscript:`metaCharset` TypoScript property. + +Since TYPO3 v9, the emitter of HTTP responses is based on PSR-7, the hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['isOutputting']` can be removed, as +TYPO3 can be configured via PSR-15 middlewares to define whether +page content should be emitted / rendered or not. + +The hook to dynamically replace content via :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['tslib_fe-contentStrReplace']` +is removed as it serves no purpose for TYPO3 Core anymore. If content should be dynamically modified, use a PSR-15 middleware instead. + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-output']` is not needed as this can be built via a PSR-15 middleware instead, and +all content is returned via the RequestHandler of TYPO3 Frontend. + +Extensions using hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_eofe']` should +be converted to PSR-15 middlewares, as this allows to modify content and headers of a PSR-7 Response object. + +The method :php:`TypoScriptFrontendController->isOutputting()` is obsolete and can be removed in third-party code. + +The same applies to :php:`TypoScriptFrontendController->processContentForOutput()` which should only be used to trigger +legacy hooks still applied in the system. + +.. index:: PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/10.4/Deprecation-91030-Runtime-ActivatedPackages.rst b/Documentation/Changelog/10.4/Deprecation-91030-Runtime-ActivatedPackages.rst new file mode 100644 index 0000000..f51cdf9 --- /dev/null +++ b/Documentation/Changelog/10.4/Deprecation-91030-Runtime-ActivatedPackages.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91030: + +================================================ +Deprecation: #91030 - Runtime-Activated Packages +================================================ + +See :issue:`91030` + +Description +=========== + +TYPO3's global configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['EXT']['runtimeActivatedPackages']` has been marked as deprecated. + +The option to register packages during runtime was introduced as +a work-around to dynamically modify the "extension list" when migrating from TYPO3 v4.5 to TYPO3 v6.x. + +However, using this feature has certain limitations: + +* Runtime-activated Extensions cannot add their DI configuration +* Runtime-activated Extensions make every (!) single TYPO3 request much slower just like back in 6.2.0 times + +The main use case we know from people was to this functionality to enable e.g. extensions such as "devlog", "mask"/"mask_export" or "extensionbuilder" only on development systems. + + +Impact +====== + +Having a TYPO3 system using Runtime Activated Packages functionality +will trigger a PHP :php:`E_USER_DEPRECATED` error on every TYPO3 request. + + +Affected Installations +====================== + +TYPO3 installations having the affected option set in either :file:`typo3conf/LocalConfiguration.php` or :file:`typo3conf/AdditionalConfiguration.php`. + + +Migration +========= + +It is recommended - if this functionality is needed - to use TYPO3 +Console and Composer Mode (with require-dev) to achieve a similar behavior. + +If it is critical to have such features, consider modifying the extension in question to deal with TYPO3's Context +feature to enable / disable functionality for Production environment. + +.. index:: LocalConfiguration, FullyScanned, ext:core diff --git a/Documentation/Changelog/10.4/Feature-83128-ContentElementFilter.rst b/Documentation/Changelog/10.4/Feature-83128-ContentElementFilter.rst new file mode 100644 index 0000000..2956c0e --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-83128-ContentElementFilter.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-83128: + +======================================== +Feature: #83128 - Content Element Filter +======================================== + +See :issue:`83128` + +Description +=========== + +A backend user is now able to search for a set of content types in the "New +Content Element" wizard. + +Impact +====== + +If a user enters a search query, any content type whose title or description +doesn't match the query are hidden to the user. Since content types are grouped in +tabs, tabs without content get disabled to the user. If the current active tab +becomes empty, the next available tab is activated. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.4/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst b/Documentation/Changelog/10.4/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst new file mode 100644 index 0000000..0e7bf3e --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _feature-87776: + +============================================================== +Feature: #87776 - Limit Restriction to table/s in QueryBuilder +============================================================== + +See :issue:`87776` + +Description +=========== + +In some cases it is needed to apply restrictions only to a certain table. +With the new :php:`\TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer` +it is possible to apply restrictions to a query only for a given set of tables, or to be precise, table aliases. +Since it is a restriction container, it can be added to the restrictions of the query builder and +it can hold restrictions itself. The restrictions it holds can be limited to tables like this: + + +Example implementation: +----------------------- + +.. code-block:: php + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); + $queryBuilder->getRestrictions() + ->removeByType(HiddenRestriction::class) + ->add( + GeneralUtility::makeInstance(LimitToTablesRestrictionContainer::class) + ->addForTables(GeneralUtility::makeInstance(HiddenRestriction::class), ['tt']) + ); + $queryBuilder->select('tt.uid', 'tt.header', 'sc.title') + ->from('tt_content', 'tt') + ->from('sys_category', 'sc') + ->from('sys_category_record_mm', 'scmm') + ->where( + $queryBuilder->expr()->eq('scmm.uid_foreign', $queryBuilder->quoteIdentifier('tt.uid')), + $queryBuilder->expr()->eq('scmm.uid_local', $queryBuilder->quoteIdentifier('sc.uid')), + $queryBuilder->expr()->eq('tt.uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)) + ); + + +In this example the HiddenRestriction is only applied to :sql:`tt` table alias of :sql:`tt_content`. + +Furthermore it is possible to restrict the complete set of restrictions of a query builder to a +given set of table aliases. + +.. code-block:: php + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(HiddenRestriction::class)); + $queryBuilder->getRestrictions()->limitRestrictionsToTables(['c2']); + $queryBuilder + ->select('c1.*') + ->from('tt_content', 'c1') + ->leftJoin('c1', 'tt_content', 'c2', 'c1.parent_field = c2.uid') + ->orWhere($queryBuilder->expr()->isNull('c2.uid'), $queryBuilder->expr()->eq('c2.pid', $queryBuilder->createNamedParameter(1, \PDO::PARAM_INT))); + +Which will result in: + +.. code-block:: sql + + SELECT "c1".* + FROM "tt_content" "c1" + LEFT JOIN "tt_content" "c2" ON c1.parent_field = c2.uid + WHERE (("c2"."uid" IS NULL) OR ("c2"."pid" = 1)) AND ("c2"."hidden" = 0)) + +Impact +====== + +It is now easily possible to add restrictions that are only applied to certain tables/ table aliases, +by using :php:`\TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer`. + +.. index:: Database, ext:core, PHP-API diff --git a/Documentation/Changelog/10.4/Feature-89513-PasswordResetForBackendUsers.rst b/Documentation/Changelog/10.4/Feature-89513-PasswordResetForBackendUsers.rst new file mode 100644 index 0000000..36ae6f6 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-89513-PasswordResetForBackendUsers.rst @@ -0,0 +1,96 @@ +.. include:: /Includes.rst.txt + +.. _feature-89513: + +================================================================ +Feature: #89513 - Password Reset Functionality For Backend Users +================================================================ + +See :issue:`89513` + +Description +=========== + +It is now possible for TYPO3 Backend users who use the default username / password +mechanism to log in, to reset their password by triggering an email through the +Login form. + +The reset link is only shown if there is at least one user that matches the +following criteria: + +* The user has a password entered previously (used to indicate that no third-party login was used) +* The user has a valid email added to their user record +* The user is neither deleted nor disabled +* The email address is only used once among all Backend users of the instance + +Once the user has entered their email address, an email is sent out with a +link to set a new password which needs to have a least 8 characters. + +The link is valid for 2 hours, and a token is added to the link. + +If the password was provided correctly, it is updated for the user and can log-in. + +Some notes on security: + +* When having multiple users with the same email address, no reset functionality is provided +* No information disclosure is built-in, so if the email address is not in the system, it is not known to the outside +* Rate limiting is activated for allowing three emails to be sent within 30 minutes per email address +* Tokens are stored for the backend users in the database but hashed again just like the password +* When a user has logged in successfully (e.g. because he/she remembered the password) the token is removed from the database, effectively invalidating all existing email links + +The feature is active by default and can be deactivated completely via the system-wide +configuration option: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset']` + +Optionally it is possible to restrict this feature to non-admins only, by setting +the following system-wide option to "false". + +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins']` + +Both options are available to be configured within the Maintenance Area +=> Settings module, or in the Install Tool, but can be set manually via +:file:`typo3conf/LocalConfiguration.php` or :file:`typo3conf/AdditionalConfiguration.php`. + +In addition, it is possible for administrators to reset a users password. +This is especially useful for security purposes so an administrator does not +need to send a password over the wire in plaintext (e.g. email) to a user. + +The administrator can use the CLI command: + +.. code-block:: bash + + ./typo3/sysext/core/bin/typo3 backend:resetpassword https://www.example.com/typo3/ editor@example.com + +where usage is described as this: + +.. code-block:: bash + + backend:resetpassword <backendurl> <email> + +Alternatively it is possible for administrators to use the "Backend users" module +and select the password reset button to initiate the password reset process for +a specific user. + +Both options are only available for users that have an email address and a password +set. + +Impact +====== + +Administrators do not have additional overhead to re-set passwords for editors, +and they do not need to add the passwords for editors themselves. + +In addition, the email can be styled completely for HTML and plain-text only +versions through the Fluid-based templated email feature. + +Further improvements on the horizon: + +* Trigger a password-reset via CLI or the Backend users module +* Trigger a password-set email on creation of a new user, so the admin has no + involvement in needing to know or share the password +* Require an email address when adding backend users to enable this feature for everybody +* Implement ways to allow the password reset functionality via different ways than email +* Find solutions for handling third-party authentication system + +.. index:: LocalConfiguration, ext:backend diff --git a/Documentation/Changelog/10.4/Feature-89573-AllowFlexibleBaseUrlForSlugFieldsInFormEngine.rst b/Documentation/Changelog/10.4/Feature-89573-AllowFlexibleBaseUrlForSlugFieldsInFormEngine.rst new file mode 100644 index 0000000..f9986e0 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-89573-AllowFlexibleBaseUrlForSlugFieldsInFormEngine.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-89573: + +======================================================================= +Feature: #89573 - Allow flexible base url for slug fields in FormEngine +======================================================================= + +See :issue:`89573` + +Description +=========== + +It is now possible to add a custom base url for TCA columns of type :php:`slug`. The +base url is displayed in front of the input field in FormEngine. + +To add a custom base url a :php:`userFunc` can be assigned to the new setting +:php:`prefix` which is available under :php:`['columns'][*]['config']['appearance']` at the fields TCA definition. + +.. code-block:: php + + 'config' => [ + 'type' => 'slug', + 'appearance' => [ + 'prefix' => \Vendor\Extension\UserFunctions\FormEngine\SlugPrefix::class . '->getPrefix' + ] + ] + +The :php:`userFunc` receives two parameters. The first parameter is the parameters +array containing the site object, the language id, the current table and the +current row. The second parameter is the reference object :php:`TcaSlug`. The +:php:`userFunc` should return the string which is then used as the base url in +FormEngine. + +.. code-block:: php + + <?php + declare(strict_types = 1); + + namespace Vendor\Extension\UserFunctions\FormEngine + + use TYPO3\CMS\Backend\Form\FormDataProvider\TcaSlug; + + class SlugPrefix + { + public function getPrefix(array $parameters, TcaSlug $reference): string + { + return 'custom base url'; + } + } + + +Impact +====== + +Developers are enabled to provide custom base urls for their slug fields. If you +are already using slug fields in your TCA, nothing changes as the current +behaviour is still used as the default. + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/Documentation/Changelog/10.4/Feature-90613-AddLanguageArgumentToPage-relatedLinkViewHelpersAndUriViewHelpersInFluid.rst b/Documentation/Changelog/10.4/Feature-90613-AddLanguageArgumentToPage-relatedLinkViewHelpersAndUriViewHelpersInFluid.rst new file mode 100644 index 0000000..bcc0a1a --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-90613-AddLanguageArgumentToPage-relatedLinkViewHelpersAndUriViewHelpersInFluid.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _feature-90613: + +=================================================================================================== +Feature: #90613 - Add language argument to page-related LinkViewHelpers and UriViewHelpers in Fluid +=================================================================================================== + +See :issue:`90613` + +Description +=========== + +A new argument :html:`language` is added to the following Fluid ViewHelpers: + +* :html:`<f:link.typolink>` +* :html:`<f:link.page>` +* :html:`<f:uri.typolink>` +* :html:`<f:uri.page>` + +They are responsible for linking to a page, and are using TypoLink functionality +under-the-hood. + + +Examples +-------- + +A Link to page with ID 13 but with language 3 - no matter what language the +current page is: + + +.. code-block:: html + + <f:link.page pageUid="13" language="3">Go to french version of about us page</f:link.page> + + +Creating a language menu: + +.. code-block:: html + + <ul> + <li> + <f:link.typolink parameter="current" language="3">Current page in french</f:link.typolink> + </li> + <li> + <f:link.typolink parameter="current" language="4">Current page in german</f:link.typolink> + </li> + <li> + <f:link.typolink parameter="current" language="5">Current page in spanish</f:link.typolink> + </li> + </ul> + + +Impact +====== + +The new argument allows to force a language when linking to a specific page, +making it consistent with the TypoLink option added in site handling for TYPO3 v9: + +https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html#language + +This Fluid option should be used instead of adding a `L` parameter to +`additionalParameters` argument to make linking to a specific language possible. +In general, using of the magic GET variable `L` is discouraged. + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/10.4/Feature-90826-CompareBackendUsergroups.rst b/Documentation/Changelog/10.4/Feature-90826-CompareBackendUsergroups.rst new file mode 100644 index 0000000..38ec1a6 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-90826-CompareBackendUsergroups.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _feature-90826: + +============================================ +Feature: #90826 - Compare backend usergroups +============================================ + +See :issue:`90826` + +Description +=========== + +Integrators are now able to compare individual backend usergroups. + +Backend usergroups are used to split permissions into smaller parts which can be later assigned to a backend user. +This feature makes it possible to compare the defined permissions including the ones inherited from sub groups. + + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/10.4/Feature-90899-IntroduceAssetPreRenderingEvents.rst b/Documentation/Changelog/10.4/Feature-90899-IntroduceAssetPreRenderingEvents.rst new file mode 100644 index 0000000..e8d1cf0 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-90899-IntroduceAssetPreRenderingEvents.rst @@ -0,0 +1,129 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Feature-90899-IntroduceAssetPreRenderingEvents: + +============================================================== +Feature: #90899 - Introduce AssetRenderer pre-rendering events +============================================================== + +See :issue:`90899` + +Description +=========== + +AssetRenderer is amended by two events which allow post-processing of +AssetCollector assets. + +These new PSR-14 events are introduced: + +* :php:`\TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent` +* :php:`\TYPO3\CMS\Core\Page\Event\BeforeStylesheetsRenderingEvent` + +Both stem fom the abstract base class +:php:`\TYPO3\CMS\Core\Page\Event\AbstractBeforeAssetRenderingEvent` and provide +these public methods: + +* :php:`getAssetCollector(): AssetCollector` +* :php:`isInline(): bool` +* :php:`isPriority(): bool` + +:php:`inline` and :php:`priority` refer to how the asset was registered with +:ref:`AssetCollector <changelog-Feature-90522-IntroduceAssetCollector>`. + +The events are fired exactly once for every combination of +:php:`inline`/:php:`priority` before the corresponding section of JS/CSS assets +is rendered by the AssetRenderer. + +To make the events easier to use, the :php:`AssetCollector::get*()` methods +have gotten an optional parameter :html:`?bool $priority = null` which when given a +boolean only returns assets of the given priority. + + +.. note:: + + post-processing functionality for assets registered via + TypoScript :typoscript:`page.include...` or the :php:`PageRenderer::add*()` + functions are still provided by these hooks: + + * :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['cssCompressHandler']` + * :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['jsCompressHandler']` + * :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['cssConcatenateHandler']` + * :php:`$GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['jsConcatenateHandler']` + * :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_pagerenderer.php']['render-preProcess']` + + Assets registered with the AssetCollector (and output through the + AssetRenderer) are not included in those. + + +Example +======= + +As an example let's make sure jQuery is included in a specific version and +from a CDN. + +.. rst-class:: bignums + + 1. Register our listeners + + :file:`Configuration/Services.yaml` + + .. code-block:: yaml + + services: + MyVendor\MyExt\EventListener\AssetRenderer\LibraryVersion: + tags: + - name: event.listener + identifier: 'myExt/LibraryVersion' + event: TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent + + + 2. Implement Listener to enforce a library version or CDN URI + + .. code-block:: php + + namespace MyVendor\MyExt\EventListener\AssetRenderer; + + use TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent; + + /** + * If a library has been registered, it is made sure that it is loaded + * from the given URI + */ + class LibraryVersion + { + protected $libraries = [ + 'jquery' => 'https://code.jquery.com/jquery-3.4.1.min.js', + ]; + + public function __invoke(BeforeJavaScriptsRenderingEvent $event): void + { + if ($event->isInline()) { + return; + } + + foreach ($this->libraries as $library => $source) { + $asset = $event->getAssetCollector()->getJavaScripts($event->isPriority()) + // if it was already registered + if ($asset[$library] ?? false) { + // we set our authoritative version + $event->getAssetCollector()->addJavaScript($library, $source); + } + } + } + } + + +Impact +====== + +Existing installations are not affected. + +If using the AssetCollector API, these new events should be used for asset +postprocessing. + +Related +======= + +- :ref:`changelog-Feature-90522-IntroduceAssetCollector` + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/10.4/Feature-90945-PSR-14EventForLocalizationControllerWhenReadingRecordscolumnsToBeTranslated.rst b/Documentation/Changelog/10.4/Feature-90945-PSR-14EventForLocalizationControllerWhenReadingRecordscolumnsToBeTranslated.rst new file mode 100644 index 0000000..cfe436c --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-90945-PSR-14EventForLocalizationControllerWhenReadingRecordscolumnsToBeTranslated.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-90945: + +======================================================================================================= +Feature: #90945 - PSR-14 event for LocalizationController when reading records/columns to be translated +======================================================================================================= + +See :issue:`90945` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\AfterPageColumnsSelectedForLocalizationEvent` +has been added and will be dispatched after records and columns are collected in the :php`LocalizationController`. + +The event receives: + +* The default columns and columnsList built by :php:`LocalizationController` +* The list of records that were analyzed to create the columns manifest +* The parameters received by the :php`LocalizationController` + +The event allows changes to: + +* the columns +* the columnsList + + +Impact +====== + +This allows third party code to read or manipulate the "columns manifest" that gets displayed in the +translation modal when a user has clicked the ``Translate`` button in the page module, by implementing +a listener for the :php:`\TYPO3\CMS\Backend\Controller\Event\AfterPageColumnsSelectedForLocalizationEvent` event. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.4/Feature-91008-ItemGroupingForTCASelectItems.rst b/Documentation/Changelog/10.4/Feature-91008-ItemGroupingForTCASelectItems.rst new file mode 100644 index 0000000..0615f79 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-91008-ItemGroupingForTCASelectItems.rst @@ -0,0 +1,154 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Feature-91008-ItemGroupingForTCASelectItems: + +==================================================== +Feature: #91008 - Item grouping for TCA select items +==================================================== + +See :issue:`91008` + +Description +=========== + +The TCA column type ``select`` now has a clean API to group items for dropdowns +in FormEngine. This was previously handled via placeholder ``--div--`` items, +which then rendered as :html:`<optgroup>` HTML elements in a dropdown. + +In larger installations or TYPO3 instances with lots of extensions, Plugins +(:php:`tt_content.list_type`), Content Types (:php:`tt_content.CType`) or custom +Page Types (:php:`pages.doktype`) drop down lists could grow large and adding item groups +caused tedious work for developers or integrators. +Grouping can now be configured on a per-item +basis. Custom groups can be added via an API or when defining TCA for a new table. + +Adding Custom Select Item Groups +-------------------------------- + +Registration of a select item group takes place in :file:`Configuration/TCA/tx_mytable.php` +for new TCA tables, and in :file:`Configuration/TCA/Overrides/a_random_core_table.php` +for modifying an existing TCA definition. + +The following two examples illustrate adding a new group to a field of +type "select": + +.. code-block:: php + + ExtensionManagementUtility::addTcaSelectItemGroup( + 'tt_content', + 'CType', + 'sliders', + 'LLL:EXT:my_slider_mixtape/Resources/Private/Language/locallang_tca.xlf:tt_content.group.sliders', + 'after:lists' + ); + +The TCA for :php:`tt_content.CType` column configuration looks like this now: + +.. code-block:: php + + 'items' => ... + 'itemGroups' => [ + 'default' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.standard', + 'lists' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.lists', + 'sliders' => 'LLL:EXT:my_slider_mixtape/Resources/Private/Language/locallang_tca.xlf:tt_content.group.sliders', + 'menu' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.menu', + 'forms' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.forms', + 'special' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:CType.div.special', + ], + +When adding a new select field, itemGroups should be added directly in the +original TCA definition without using the API method. Use the API within +:file:`TCA/Configuration/Overrides/` files to extend an existing TCA select field with +grouping. + +Attaching Select Items to Item Groups +------------------------------------- + +A select item now has a fourth array key to define a "Group ID" which group it +belongs to. In the example above, the group ID is named "sliders" and used +in the examples below to attach items to this group. + +Grouping for select items can be used via API or in TCA configuration directly. + +This is the example for a custom Content Type "slickslider" belonging to the +group from above: + +.. code-block:: php + + 'items' => [ + ..., + [ + // Label + 'LLL:EXT:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.CType.slickslider', + // Value written to the database + 'slickslider', + // Icon for the dropdown + 'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png', + // The group ID, if not given, falls back to "none" or the last used --div-- in the item array + 'sliders' + ], + ] + + +The item can be added via API like this: + +.. code-block:: php + + ExtensionManagementUtility::addTcaSelectItem( + 'tt_content', + 'CType', + [ + 'LLL:EXT:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.CType.slickslider', + 'slickslider', + 'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png', + 'sliders' + ] + ); + +The same approach applies to :php:`ExtensionManagementUtility::addPlugin()` when +adding pi-based plugins. + +When adding Extbase plugins, the API method now allows to specify a group ID +directly as additional parameter. This falls back to the "default" group ID, +which is available in :php:`tt_content.CType` and :php:`tt_content.list_type`. + +.. code-block:: php + + ExtensionUtility::registerPlugin( + // Extension key + 'my_slider_mixtape', + // Plugin value + 'slider_from_records', + // Plugin label + 'LLL:EXT:my_slider_mixtape/Resources/Private/Locallang/locallang_tca.xlf:tt_content.plugin.slider_from_records', + // Icon for plugin + 'EXT:my_slider_mixtape/Resources/Public/Icons/slickslider.png', + // Group ID + 'sliders' + ); + + +Impact +====== + +By default, Page Types (:php:`pages.doktype`), Content Types (:php:`tt_content.CType`) and +Plugins (:php:`tt_content.list_type`) now have native grouping enabled. + +The order of the :php:`itemGroups` value is important when using groups, as this +is the order of the groups rendered in the dropdown of FormEngine. + +The API methods can be used to build more groups without juggling with +TCA arrays. + +It is possible now, and encouraged to remove the :php:`--div--` items in custom +selects and use itemGroups instead. TYPO3 Core keeps the :php:`--div--` for +backwards-compatible reasons in TYPO3 v10, but all items of the fields mentioned +above the grouping parameter has been added already. + +Please note that this :php:`--div--` is related to select items, and not the +"showItem" definition which fields should be shown. + +Currently Item Groups are used in FormEngine DropDowns / single-select items +from TYPO3 Core, but can be used in multi-select fields as well. + +.. index:: TCA, ext:core diff --git a/Documentation/Changelog/10.4/Feature-91008-ItemSortingForTCASelectItems.rst b/Documentation/Changelog/10.4/Feature-91008-ItemSortingForTCASelectItems.rst new file mode 100644 index 0000000..745d320 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-91008-ItemSortingForTCASelectItems.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-91008: + +=================================================== +Feature: #91008 - Item sorting for TCA select items +=================================================== + +See :issue:`91008` + +Description +=========== + +A new option :php:`sortOrders` for TCA-based select fields has been added to allow +sorting of static TCA select items by their values or labels. + +This is now used in TYPO3 Core's :php:`tt_content.list_type` whereas +a previous :php:`itemsProcFunc` was used to sort all plugins by label +in the FormEngine dropdown. + +Built-in orderings are to sort items by their labels or values. It is also possible +to define custom :php:`sortOrders` via custom PHP code. + +Examples from tt_contents' :php:`list_type` TCA: + +.. code-block:: php + + // Sort all items by label ("asc" or "desc" is possible) + $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [ + 'label' => 'asc' + ]; + + // Sort all items by value ("asc" or "desc" is possible) + $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [ + 'value' => 'desc' + ]; + + // Sort all items by a custom function + $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [ + 'My_Extension' => 'ksort' + ]; + + $GLOBALS['TCA']['tt_content']['columns']['list_type']['config']['sortItems'] = [ + 'My_Extension' => \VendorName\PackageName\TcaSorter::class . '->sortByMagic' + ]; + +When using grouped select fields with "itemGroups", sorting happens on a +per-group basis - all items within one group are sorted - as the group ordering +is preserved. + + +Impact +====== + +Plugins in FormEngine are now using this option in TYPO3 Core, and other TCA +select fields can benefit from this as well. + +This option is solely built for display purposes in FormEngine. + +.. index:: TCA, ext:core diff --git a/Documentation/Changelog/10.4/Feature-91080-SiteSettingsAsTsConstantsAndInTsConfig.rst b/Documentation/Changelog/10.4/Feature-91080-SiteSettingsAsTsConstantsAndInTsConfig.rst new file mode 100644 index 0000000..f7e4080 --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-91080-SiteSettingsAsTsConstantsAndInTsConfig.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _feature-91080-1657827157: + +======================================================================= +Feature: #91080 - Site settings as TypoScript constants and in TSconfig +======================================================================= + +See :issue:`91080` +See :issue:`91081` + +Description +=========== + +Prior to TYPO3 v10.0 it was possible to inject information from +page TSconfig into TypoScript constants with :typoscript:`TSFE.constants.const1 = a`. + +This could be used to centralize configuration of e.g. record storagePids, +which could then be used in Backend for modules or for IRRE and for Frontend plugins. + +This old feature has been removed, because it was recommended to add site settings. +The according new feature added with TYPO3 v10 was reverted in v10.1 though. + +This re-implementation now allows to define site settings via :file:`config/sites/<site-name>/config.yml` + +The newly introduced settings inside :file:`config.yml` are made available +as TypoScript constants and page TSconfig constants. + +An example configuration in the :file:`config/sites/<site-name>/config.yml`: + +.. code-block:: yaml + + settings: + categoryPid: 658 + styles: + content: + loginform: + pid: 23 + +This will make these constants available in the template and in page TSconfig: + +* :typoscript:`{$categoryPid}` +* :typoscript:`{$styles.content.loginform.pid}` + +The newly introduced constants for page TSconfig can be used just like constants +in TypoScript. + +In page TSconfig this can be used like this: + +.. code-block:: typoscript + + # store tx_ext_data records on the given storage page by default (e.g. through IRRE) + TCAdefaults.tx_ext_data.pid = {$categoryPid} + # load category selection for plugin from out dedicated storage page + TCEFORM.tt_content.pi_flexform.ext_pi1.sDEF.categories.PAGE_TSCONFIG_ID = {$categoryPid} + + +.. note:: + + The TypoScript constants are now evaluated in this order: + + #. Global :php:`'defaultTypoScript_constants'` + #. Site specific settings from the site configuration + #. Constants from sys_template database records + + +Impact +====== + +It is now possible again to have a central place for configuration relevant +for Backend and Frontend. + +For instance: It is now possible to define all page-uid related configuration centrally +with the site configuration and get templates and page TSconfig independent +of actual UIDs. + +.. index:: TypoScript, ext:core, ext:frontend, ext:backend diff --git a/Documentation/Changelog/10.4/Feature-91122-IntroduceDocumentServiceAsJQueryreadySubstitute.rst b/Documentation/Changelog/10.4/Feature-91122-IntroduceDocumentServiceAsJQueryreadySubstitute.rst new file mode 100644 index 0000000..d2e09be --- /dev/null +++ b/Documentation/Changelog/10.4/Feature-91122-IntroduceDocumentServiceAsJQueryreadySubstitute.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-91122: + +====================================================================== +Feature: #91122 - Introduce DocumentService as JQuery.ready substitute +====================================================================== + +See :issue:`91122` + +Description +=========== + +The module :js:`TYPO3/CMS/Core/DocumentService` provides native JavaScript +functions to detect DOM ready-state returning a :js:`Promise<Document>`. + +Internally the Promise is resolved when native :js:`DOMContentLoaded` event has +been emitted or when :js:`document.readyState` is defined already. It means +that initial HTML document has been completely loaded and parsed, without +waiting for stylesheets, images, and subframes to finish loading. + + +Impact +====== + +.. code-block:: javascript + + $(document).ready(() => { + // your application code + }); + +Above JQuery code can be transformed into the following using :js:`DocumentService`: + +.. code-block:: javascript + + require(['TYPO3/CMS/Core/DocumentService'], function (DocumentService) { + DocumentService.ready().then(() => { + // your application code + }); + }); + +.. index:: Backend, JavaScript, ext:core diff --git a/Documentation/Changelog/10.4/Important-18079-PagesdoktypeRestrictionForFrontendQueriesRefined.rst b/Documentation/Changelog/10.4/Important-18079-PagesdoktypeRestrictionForFrontendQueriesRefined.rst new file mode 100644 index 0000000..b3a3572 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-18079-PagesdoktypeRestrictionForFrontendQueriesRefined.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _important-18079: + +========================================================================== +Important: #18079 - pages.doktype restriction for frontend queries refined +========================================================================== + +See :issue:`18079` + +Description +=========== + +Since over 15 years, TYPO3's Frontend rendering had a restriction to only allow +pages with a "page type" (pages.doktype such as "Shortcut", "Link to external URL") to be limited to a fixed number less than 200. + +This meant that pages of certain types such as a Sys Folder and Recycler never were +respected when fetching content from a specific page (via Typoscript) or querying records from there. + +This limitation has now been lifted in order to fix certain bugs, +such as "content sliding" via TypoScript. But this also allows custom page doktypes to be used that have a number higher than 200. + +This could potentially result in unexpected behavior in TypoScript or content fetching, if the previous limited behavior was mis-used +for certain purposes. + +.. index:: Frontend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/10.4/Important-77715-NoMorePasswordTrimmingForThird-partyAuthenticationServices.rst b/Documentation/Changelog/10.4/Important-77715-NoMorePasswordTrimmingForThird-partyAuthenticationServices.rst new file mode 100644 index 0000000..cb35ff9 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-77715-NoMorePasswordTrimmingForThird-partyAuthenticationServices.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _important-77715: + +===================================================================================== +Important: #77715 - No more password trimming for third-party authentication services +===================================================================================== + +See :issue:`77715` + +Description +=========== + +TYPO3's Authentication Service API allows third-party extensions +to handle custom login / password data to authenticate against identity brokers +via "OAuth", "LDAP" or "SAML2" now receive a given password (called `uident`) +directly as given from the input value. + +Before TYPO3 v10 LTS, TYPO3's "AbstractUserAuthentication" object trimmed all incoming +usernames and passwords, and afterwards handed the sanitized input values over +to the Authentication Providers. + +This made it impossible to ever have passwords that included spaces at +the beginning or the end of a given password. + +This behaviour is now changed, and only affects Third-Party Authentication +providers - which can now decide to also trim passwords or keep them as is. + +This logic is mostly handled within :php:`processLoginData()`. If the Third-Party +Authentication Provider is extending from Core's :php:`AuthenticationService` class and does +not override the method, then the behaviour will still be the same as before. + +TYPO3's native Authentication Service still requires a password without spaces +at the beginning or end, however it is now up to the Authentication Service to +define what is possible or allowed. + +.. index:: PHP-API, ext:frontend diff --git a/Documentation/Changelog/10.4/Important-86343-ReplaceJQueryDataTablesWithTablesort.rst b/Documentation/Changelog/10.4/Important-86343-ReplaceJQueryDataTablesWithTablesort.rst new file mode 100644 index 0000000..e6763b7 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-86343-ReplaceJQueryDataTablesWithTablesort.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-86343: + +============================================================ +Important: #86343 - Replace jQuery.datatables with tablesort +============================================================ + +See :issue:`86343` + +Description +=========== + +In our effort to reduce the dependency to jQuery, the internally used JavaScript +library ``jQuery.datatables`` has been replaced with ``tablesort``. + +Extensions relying on that internal library may be dysfunctional now. + +.. important:: + + Extension authors are encouraged to not use libraries that are not explicitly + marked as public API. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.4/Important-89555-Workspace-relatedDatabaseRecordsContainTheProperPageID.rst b/Documentation/Changelog/10.4/Important-89555-Workspace-relatedDatabaseRecordsContainTheProperPageID.rst new file mode 100644 index 0000000..167b279 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-89555-Workspace-relatedDatabaseRecordsContainTheProperPageID.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _important-89555: + +================================================================================== +Important: #89555 - Workspace-related database records contain the proper Page ID. +================================================================================== + +See :issue:`89555` + +Description +=========== + +Back in 2006, when the workspaces functionality was added to TYPO3 v4.0, Kasper - the original author of TYPO3 - provided +an easy way to put workspaces on top while not worrying about existing logic. Every record that wasn't published had +the "pid" field set to "-1" - and thus was filtered out from any database query without having to worry about specific implementations. + +14 years later, we have Doctrine DBAL and the solution for "enableFields" has widely been replaced by Database Restrictions, +allowing to modify database queries by TYPO3 Core without having to worry about custom queries. + +For workspaces however, it is and was very tedious to find the "real pid" for versioned records, +and the "pid = -1" scenario is also one of the reasons why workspace overlays are more complex than they need to be. + +For this reason, TYPO3 Core now handles versioned records by validating their "t3ver_wsid" (the workspace ID the record is versioned in), +"t3ver_state" (the type of the versioned record) and "t3ver_oid" (the live version of a record), and does not need to check for "pid=-1" anymore. + +This opens up a more straightforward approach to select and overlay +records and reduce the need for some magic methods in TYPO3 Core, +which still exist. + +An Upgrade Wizard transfers all "pid" fields of versioned records, +into the real "pid" fields. TYPO3 Core now only checks for versionized records based on the other fields above. + +Please note: This only affects TYPO3 installations with workspaces enabled, and nothing should change for any extension if they use +proper WorkspaceRestriction or Workspace Overlay mechanisms in TYPO3 v10. + +.. index:: Database, ext:workspaces diff --git a/Documentation/Changelog/10.4/Important-90285-FreshInstallsWithoutConstraintForTypo3fluidfluidWillGetVersion30.rst b/Documentation/Changelog/10.4/Important-90285-FreshInstallsWithoutConstraintForTypo3fluidfluidWillGetVersion30.rst new file mode 100644 index 0000000..6e3885f --- /dev/null +++ b/Documentation/Changelog/10.4/Important-90285-FreshInstallsWithoutConstraintForTypo3fluidfluidWillGetVersion30.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _important-90285: + +================================================================================================ +Important: #90285 - Fresh installs without constraint for typo3fluid/fluid will get version 3.0+ +================================================================================================ + +See :issue:`90285` + +Description +=========== + +Projects which have no dependencies that add a constraint on the maximum allowed version of Fluid +will in the future download and install ``typo3fluid/fluid:^3``. + +The TYPO3 core is fully compatible with both major versions of Fluid and lets you choose between +version ``2.6+`` or ``3.0+`` by constraining your project dependencies. However, some projects based +on TYPO3 may contain Fluid templates or dependencies which are not compatible with Fluid 3.0, yet +neglect to declare a maximum version constraint for Fluid - since until the release of version 3.0, +the only/highest major version was 2.6 and ``composer install`` would therefore always select version +``^2.6`` as it was the only option. + +If your project has no maximum version constraint and contains Fluid templates which are incompatible +with version ``3.0+`` you will therefore need to take one of the following actions: + +* Either declare a maximum version constraint for ``typo3fluid/fluid:^2`` in the root project + ``composer.json`` or any dependency of the project that you control, and perform ``composer update``. +* Or execute ``composer req typo3fluid/fluid:^2`` in the project directory to make the project itself + declare the maximum version constraint. + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/10.4/Important-90897-RemoveBootstrap-slider.rst b/Documentation/Changelog/10.4/Important-90897-RemoveBootstrap-slider.rst new file mode 100644 index 0000000..7f81064 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-90897-RemoveBootstrap-slider.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _important-90897: + +=========================================== +Important: #90897 - Remove bootstrap-slider +=========================================== + +See :issue:`90897` + +Description +=========== + +The internally used library `bootstrap-slider` has been removed. HTML input +fields using `type="range"` are used as substitution. + +Extension relying on that internal library may be dysfunctional now. + +Example: + +.. code-block:: html + + <div class="slider-wrapper"> + <input type="range" class="slider" min="10" max="50" step="5"> + </div> + + +If the value of the `range` field is changed, the `input` event is emitted which +can be listened to by registering an event listener. + +.. important:: + + Extension authors are encouraged to not use libraries that are not explicitly + marked as public API. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/10.4/Important-91079-VariousTypoScriptFrontendRendererFunctionalityIsNowInternal.rst b/Documentation/Changelog/10.4/Important-91079-VariousTypoScriptFrontendRendererFunctionalityIsNowInternal.rst new file mode 100644 index 0000000..cfb6ab1 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-91079-VariousTypoScriptFrontendRendererFunctionalityIsNowInternal.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _important-91079: + +==================================================================================== +Important: #91079 - Various TypoScriptFrontendRenderer functionality is now internal +==================================================================================== + +See :issue:`91079` + +Description +=========== + +TypoScriptFrontendController has methods and properties which +are marked as "@internal" in TYPO3 v10. + +They are still used in TYPO3 v10 from within TYPO3 Core, but +extension authors should use the actual APIs directly. + +The properties + +* :php:`TypoScriptFrontendController->sPre` +* :php:`TypoScriptFrontendController->pSetup` +* :php:`TypoScriptFrontendController->all` + +are related to unpacking TypoScript details related +to a page object in TypoScript and to its caching part, +this is now officially marked as internal - if needed, +TemplateService should be queried directly. These properties +will likely be removed in future TYPO3 versions, in order to +decouple TypoScript Parsing from the global `TSFE` object. + +The properties + +* :php:`TypoScriptFrontendController->additionalJavaScript` +* :php:`TypoScriptFrontendController->additionalCSS` +* :php:`TypoScriptFrontendController->JSCode` +* :php:`TypoScriptFrontendController->inlineJS` + +and the method :php:`TypoScriptFrontendController->setJS()` are +marked as internal. The AssetCollector API and the PageRenderer +can be used instead, and TYPO3 Core will move towards these +APIs completely internally. + +The property :php:`TypoScriptFrontendController->indexedDocTitle` +is now marked as internal as the PageTitle API is in place since +TYPO3 v9 LTS. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/10.4/Important-91095-VariousMethodsAndPropertiesOfBackend-relatedCoreAPIsNowInternal.rst b/Documentation/Changelog/10.4/Important-91095-VariousMethodsAndPropertiesOfBackend-relatedCoreAPIsNowInternal.rst new file mode 100644 index 0000000..bcd87f3 --- /dev/null +++ b/Documentation/Changelog/10.4/Important-91095-VariousMethodsAndPropertiesOfBackend-relatedCoreAPIsNowInternal.rst @@ -0,0 +1,236 @@ +.. include:: /Includes.rst.txt + +.. _important-91095: + +============================================================================================ +Important: #91095 - Various methods and properties of Backend-related Core APIs now internal +============================================================================================ + +See :issue:`91095` + +Description +=========== + +Some cornerstones of TYPO3 Core have been kept and migrated since before TYPO3 v4.0. This was when PHP 5 and class visibility was not +even available. + +Most classes contain various methods which have been marked as +"public", making it public API for TYPO3, even though their usages +should only be available for TYPO3 Core. + +All methods are now marked as "@internal", as official Core API +should be used instead. + +:php:`DataHandler` class properties and methods: Except for the public methods +that are still available, it is highly recommended to use DataHandler as defined in the official documentation. + +The following properties and methods are now marked as internal: + +* :php:`DataHandler->checkSimilar` +* :php:`DataHandler->bypassWorkspaceRestrictions` +* :php:`DataHandler->copyWhichTables` +* :php:`DataHandler->defaultValues` +* :php:`DataHandler->overrideValues` +* :php:`DataHandler->data_disableFields` +* :php:`DataHandler->callBackObj` +* :php:`DataHandler->autoVersionIdMap` +* :php:`DataHandler->substNEWwithIDs_table` +* :php:`DataHandler->newRelatedIDs` +* :php:`DataHandler->copyMappingArray_merged` +* :php:`DataHandler->errorLog` +* :php:`DataHandler->pagetreeRefreshFieldsFromPages` +* :php:`DataHandler->pagetreeNeedsRefresh` +* :php:`DataHandler->userid` +* :php:`DataHandler->username` +* :php:`DataHandler->admin` +* :php:`DataHandler->sortIntervals` +* :php:`DataHandler->dbAnalysisStore` +* :php:`DataHandler->registerDBList` +* :php:`DataHandler->registerDBPids` +* :php:`DataHandler->copyMappingArray` +* :php:`DataHandler->remapStack` +* :php:`DataHandler->remapStackRecords` +* :php:`DataHandler->updateRefIndexStack` +* :php:`DataHandler->callFromImpExp` +* :php:`DataHandler->checkValue_currentRecord` +* :php:`DataHandler->setControl()` +* :php:`DataHandler->setMirror()` +* :php:`DataHandler->setDefaultsFromUserTS()` +* :php:`DataHandler->hook_processDatamap_afterDatabaseOperations()` +* :php:`DataHandler->placeholderShadowing()` +* :php:`DataHandler->getPlaceholderTitleForTableLabel()` +* :php:`DataHandler->fillInFieldArray()` +* :php:`DataHandler->checkValue()` +* :php:`DataHandler->checkValue_SW()` +* :php:`DataHandler->checkValue_flexArray2Xml()` +* :php:`DataHandler->checkValue_inline()` +* :php:`DataHandler->checkValueForInline()` +* :php:`DataHandler->checkValue_checkMax()` +* :php:`DataHandler->getUnique()` +* :php:`DataHandler->getRecordsWithSameValue()` +* :php:`DataHandler->checkValue_text_Eval()` +* :php:`DataHandler->checkValue_input_Eval()` +* :php:`DataHandler->checkValue_group_select_processDBdata()` +* :php:`DataHandler->checkValue_group_select_explodeSelectGroupValue()` +* :php:`DataHandler->checkValue_flex_procInData()` +* :php:`DataHandler->checkValue_flex_procInData_travDS()` +* :php:`DataHandler->copyRecord()` +* :php:`DataHandler->copyPages()` +* :php:`DataHandler->copySpecificPage()` +* :php:`DataHandler->copyRecord_raw()` +* :php:`DataHandler->insertNewCopyVersion()` +* :php:`DataHandler->copyRecord_flexFormCallBack()` +* :php:`DataHandler->copyL10nOverlayRecords()` +* :php:`DataHandler->moveRecord()` +* :php:`DataHandler->moveRecord_raw()` +* :php:`DataHandler->moveRecord_procFields()` +* :php:`DataHandler->moveRecord_procBasedOnFieldType()` +* :php:`DataHandler->moveL10nOverlayRecords()` +* :php:`DataHandler->localize()` +* :php:`DataHandler->deleteAction()` +* :php:`DataHandler->deleteEl()` +* :php:`DataHandler->deleteVersionsForRecord()` +* :php:`DataHandler->undeleteRecord()` +* :php:`DataHandler->deleteRecord()` +* :php:`DataHandler->deletePages()` +* :php:`DataHandler->canDeletePage()` +* :php:`DataHandler->cannotDeleteRecord()` +* :php:`DataHandler->isRecordUndeletable()` +* :php:`DataHandler->deleteRecord_procFields()` +* :php:`DataHandler->deleteRecord_procBasedOnFieldType()` +* :php:`DataHandler->deleteL10nOverlayRecords()` +* :php:`DataHandler->versionizeRecord()` +* :php:`DataHandler->version_remapMMForVersionSwap()` +* :php:`DataHandler->version_remapMMForVersionSwap_flexFormCallBack()` +* :php:`DataHandler->version_remapMMForVersionSwap_execSwap()` +* :php:`DataHandler->remapListedDBRecords()` +* :php:`DataHandler->remapListedDBRecords_flexFormCallBack()` +* :php:`DataHandler->remapListedDBRecords_procDBRefs()` +* :php:`DataHandler->remapListedDBRecords_procInline()` +* :php:`DataHandler->processRemapStack()` +* :php:`DataHandler->addRemapAction()` +* :php:`DataHandler->addRemapStackRefIndex()` +* :php:`DataHandler->getVersionizedIncomingFieldArray()` +* :php:`DataHandler->checkModifyAccessList()` +* :php:`DataHandler->isRecordInWebMount()` +* :php:`DataHandler->isInWebMount()` +* :php:`DataHandler->checkRecordUpdateAccess()` +* :php:`DataHandler->checkRecordInsertAccess()` +* :php:`DataHandler->isTableAllowedForThisPage()` +* :php:`DataHandler->doesRecordExist()` +* :php:`DataHandler->doesBranchExist()` +* :php:`DataHandler->tableReadOnly()` +* :php:`DataHandler->tableAdminOnly()` +* :php:`DataHandler->destNotInsideSelf()` +* :php:`DataHandler->getExcludeListArray()` +* :php:`DataHandler->doesPageHaveUnallowedTables()` +* :php:`DataHandler->pageInfo()` +* :php:`DataHandler->recordInfo()` +* :php:`DataHandler->getRecordProperties()` +* :php:`DataHandler->getRecordPropertiesFromRow()` +* :php:`DataHandler->eventPid()` +* :php:`DataHandler->updateDB()` +* :php:`DataHandler->insertDB()` +* :php:`DataHandler->checkStoredRecord()` +* :php:`DataHandler->setHistory()` +* :php:`DataHandler->updateRefIndex()` +* :php:`DataHandler->getSortNumber()` +* :php:`DataHandler->newFieldArray()` +* :php:`DataHandler->addDefaultPermittedLanguageIfNotSet()` +* :php:`DataHandler->overrideFieldArray()` +* :php:`DataHandler->compareFieldArrayWithCurrentAndUnset()` +* :php:`DataHandler->convNumEntityToByteValue()` +* :php:`DataHandler->deleteClause()` +* :php:`DataHandler->getTableEntries()` +* :php:`DataHandler->getPID()` +* :php:`DataHandler->dbAnalysisStoreExec()` +* :php:`DataHandler->int_pageTreeInfo()` +* :php:`DataHandler->compileAdminTables()` +* :php:`DataHandler->fixUniqueInPid()` +* :php:`DataHandler->fixCopyAfterDuplFields()` +* :php:`DataHandler->isReferenceField()` +* :php:`DataHandler->getInlineFieldType()` +* :php:`DataHandler->getCopyHeader()` +* :php:`DataHandler->prependLabel()` +* :php:`DataHandler->resolvePid()` +* :php:`DataHandler->clearPrefixFromValue()` +* :php:`DataHandler->isRecordCopied()` +* :php:`DataHandler->log()` +* :php:`DataHandler->newlog()` +* :php:`DataHandler->printLogErrorMessages()` +* :php:`DataHandler->insertUpdateDB_preprocessBasedOnFieldType()` +* :php:`DataHandler->hasDeletedRecord()` +* :php:`DataHandler->getAutoVersionId()` +* :php:`DataHandler->getHistoryRecords()` + +The reason for this long list is this: If the DataHandler API is +not called via :php:`start()` and the :php:`process_*` methods, but rather +the methods would be called directly, certain hooks would be disabled completely, resulting in a huge data inconsistency. + +At this point, it is highly recommended to use the official API +of :php:`DataHandler` as written in the main documentation. + +Various :php:`BackendUtility` class methods are called statically, but cannot +guarantee any Context. Short-hand functions for TCA or Database +Queries are now better suited by using the appropriate Database +Restrictions. + +* :php:`BackendUtility::purgeComputedPropertiesFromRecord()` +* :php:`BackendUtility::purgeComputedPropertyNames()` +* :php:`BackendUtility::splitTable_Uid()` +* :php:`BackendUtility::BEenableFields()` +* :php:`BackendUtility::openPageTree()` +* :php:`BackendUtility::getUserNames()` +* :php:`BackendUtility::getGroupNames()` +* :php:`BackendUtility::blindUserNames()` +* :php:`BackendUtility::blindGroupNames()` +* :php:`BackendUtility::getCommonSelectFields()` +* :php:`BackendUtility::helpTextArray()` +* :php:`BackendUtility::helpText()` +* :php:`BackendUtility::wrapInHelp()` +* :php:`BackendUtility::softRefParserObj()` +* :php:`BackendUtility::explodeSoftRefParserList()` +* :php:`BackendUtility::selectVersionsOfRecord()` +* :php:`BackendUtility::fixVersioningPid()` +* :php:`BackendUtility::movePlhOL()` +* :php:`BackendUtility::getLiveVersionIdOfRecord()` +* :php:`BackendUtility::versioningPlaceholderClause()` +* :php:`BackendUtility::getWorkspaceWhereClause()` +* :php:`BackendUtility::wsMapId()` +* :php:`BackendUtility::getMovePlaceholder()` +* :php:`BackendUtility::getBackendScript()` +* :php:`BackendUtility::getWorkspaceWhereClause()` + + +:php:`BackendUserAuthentication` a.k.a. :php:`$GLOBALS['BE_USER']` contains a lot of internal calls and properties which are only +used for within TYPO3 Core or to keep state. This should not +be exposed in the future anymore, especially when a more flexible +permission system might get introduced. The affected properties +and methods are: + +* :php:`BackendUserAuthentication->includeGroupArray` +* :php:`BackendUserAuthentication->errorMsg` +* :php:`BackendUserAuthentication->sessionTimeout` +* :php:`BackendUserAuthentication->firstMainGroup` +* :php:`BackendUserAuthentication->uc_default` +* :php:`BackendUserAuthentication->isMemberOfGroup()` +* :php:`BackendUserAuthentication->getPagePermsClause()` +* :php:`BackendUserAuthentication->isRTE()` +* :php:`BackendUserAuthentication->recordEditAccessInternals()` +* :php:`BackendUserAuthentication->workspaceCannotEditRecord()` +* :php:`BackendUserAuthentication->workspaceAllowLiveRecordsInPID()` +* :php:`BackendUserAuthentication->workspaceAllowsLiveEditingInTable()` +* :php:`BackendUserAuthentication->workspaceCreateNewRecord()` +* :php:`BackendUserAuthentication->workspaceCanCreateNewRecord()` +* :php:`BackendUserAuthentication->workspaceAllowAutoCreation()` +* :php:`BackendUserAuthentication->workspaceCheckStageForCurrent()` +* :php:`BackendUserAuthentication->workspaceInit()` +* :php:`BackendUserAuthentication->checkWorkspace()` +* :php:`BackendUserAuthentication->checkWorkspaceCurrent()` +* :php:`BackendUserAuthentication->setWorkspace()` +* :php:`BackendUserAuthentication->setTemporaryWorkspace()` +* :php:`BackendUserAuthentication->setDefaultWorkspace()` +* :php:`BackendUserAuthentication->getDefaultWorkspace()` +* :php:`BackendUserAuthentication->checkLockToIP()` + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/10.4/Important-91099-ChangedFlagIdentifierForEngland.rst b/Documentation/Changelog/10.4/Important-91099-ChangedFlagIdentifierForEngland.rst new file mode 100644 index 0000000..3608fdb --- /dev/null +++ b/Documentation/Changelog/10.4/Important-91099-ChangedFlagIdentifierForEngland.rst @@ -0,0 +1,21 @@ +.. include:: /Includes.rst.txt + +.. _important-91099: + +==================================================================== +Important: #91099 - Flag identifier changed for SiteLanguage England +==================================================================== + +See :issue:`91099` + +Description +=========== + +The flag identifier for England ("england") in the SiteLanguage was broken and resulted +in a broken icon in the backend. +To fix that issue the identifier has been changed ("gb-eng") and results in a proper icon. + +If you used this flag identifier in your Frontend setup, double check whether things are +still working as desired. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/10.4/Index.rst b/Documentation/Changelog/10.4/Index.rst new file mode 100644 index 0000000..39aab08 --- /dev/null +++ b/Documentation/Changelog/10.4/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-10-4: + +10.4 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Breaking-* + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.0/Breaking-23736-PageLanguageDetectionSetEarlierInFrontendRequestProcess.rst b/Documentation/Changelog/11.0/Breaking-23736-PageLanguageDetectionSetEarlierInFrontendRequestProcess.rst new file mode 100644 index 0000000..b8284f3 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-23736-PageLanguageDetectionSetEarlierInFrontendRequestProcess.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-23736: + +================================================================================== +Breaking: #23736 - Page Language detection set earlier in Frontend Request Process +================================================================================== + +See :issue:`23736` + +Description +=========== + +Previous TYPO3 sites without Site Handling +used TypoScript conditions like `[globalVar = GP:L = 1]` to switch +between languages. For this, TYPO3's Frontend Request Process needed a parsed TypoScript before +doing the language overlay of the currently visited page. + +This made it impossible to use conditions for accessing the translated page record like `[page["nav_title"] == "Bienvenue"]`, +which was a long outstanding conceptual issue that was finally made possible through Site Handling. + +Now, the translated page is resolved directly after the actual page and rootline resolving. + + +Impact +====== + +The translated page record (based on the fallback handling in the +Site Configuration) is now available in :php:`$TSFE->page` at a much earlier stage of the Frontend Request process. + +This means, TypoScript conditions based on the page record (see example above) might be different. + +In addition, the two hooks + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_preProcess']` and +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_postProcess']` + +are called earlier, no TypoScript is available yet. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using the hooks mentioned +above or that have language-specific "page-based" conditions. + + +Migration +========= + +Review the hooks or use a PSR-15 middleware to use the same place +to extend TYPO3's Frontend Request process after TypoScript was +initialized. + +Also, be sure to review any of the TypoScript conditions (possible +via the `Web->Template` module) if they are related to values only +available in the default language, which seems to be a very rare case however. + +.. index:: PHP-API, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-29342-ImproveValidatorTask.rst b/Documentation/Changelog/11.0/Breaking-29342-ImproveValidatorTask.rst new file mode 100644 index 0000000..263ba30 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-29342-ImproveValidatorTask.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-29342: + +========================================================= +Breaking: #29342 - Fluid Email Template for ValidatorTask +========================================================= + +See :issue:`29342` + +Description +=========== + +In TYPO3 v10 `ext:linkvalidator` has been improved a lot. The +:php:`\TYPO3\CMS\Linkvalidator\Task\ValidatorTask`, a scheduler task for reporting +broken links via email, has been refactored now. + +The old marker template has been replaced by Fluid templates, which are now +used for generating the report email. The marker template has been removed completely +along with corresponding functionality. + +The following property of the :php:`ValidatorTask` class has been removed: + +* :php:`$emailTemplateFile` + +The following hooks have been removed and won't be executed anymore: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['reportEmailMarkers']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['buildMailMarkers']` + +The following properties of the :php:`ValidatorTask` class have changed their type: + +* :php:`$page` is now :php:`int` +* :php:`$depth` is now :php:`int` +* :php:`$emailOnBrokenLinkOnly` is now :php:`bool` +* :php:`$configuration` is now :php:`string` + + +Impact +====== + +It is no longer possible to set a custom marker based template file with +:php:`emailTemplateFile`. Instead, the new field :php:`emailTemplateName` can be used to +specify a Fluid template file, see Migration section below. + + +Affected Installations +====================== + +All installations which use: + +* the scheduler task and provide a custom template file +* one of the hooks mentioned above + + +Migration +========= + +Provide your custom templates using the new field :php:`emailTemplateName` +in the scheduler task configuration and add your custom template +path to :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths']`. + +Use the new PSR-14 event :php:`\TYPO3\CMS\Linkvalidator\Event\ModifyValidatorTaskEmailEvent` to adjust the +:php:`\TYPO3\CMS\Linkvalidator\Result\LinkAnalyzerResult` along with the `FluidEmail` object. + +.. index:: Backend, CLI, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/11.0/Breaking-45512-NoTypeAttributesForStyleAndLinkTags.rst b/Documentation/Changelog/11.0/Breaking-45512-NoTypeAttributesForStyleAndLinkTags.rst new file mode 100644 index 0000000..c2813b4 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-45512-NoTypeAttributesForStyleAndLinkTags.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-45512: + +============================================================= +Breaking: #45512 - No type attributes for style and link tags +============================================================= + +See :issue:`45512` + +Description +=========== + +It is recommended for :html:`<style>` and :html:`<link>` HTML tags +to not use the "type" attribute anymore. + +These references state its recommended practice to omit them: + +- https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link +- https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style + +For this reason, TYPO3 does not add this "type" attribute to the mentioned +HTML elements anymore when rendering HTML. + +Impact +====== + +The attribute :html:`type` is removed from the HTML tags :html:`<style>` and :html:`<link>` +by default for TYPO3 Backend and Frontend output. + +Affected Installations +====================== + +All installations of TYPO3 that use :html:`<style>` or :html:`<link>` tags are affected. +The probability this has negative impact on the user experience is low, however. + + +Migration +========= + +If requested due to very old browser requirements for TYPO3 Frontend, +the type attribute can be added via TypoScript options or Fluid +AssetCollector attributes again. + +.. index:: Backend, Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-79565-RemovedUsergroup_cached_listDatabaseField.rst b/Documentation/Changelog/11.0/Breaking-79565-RemovedUsergroup_cached_listDatabaseField.rst new file mode 100644 index 0000000..0d626dc --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-79565-RemovedUsergroup_cached_listDatabaseField.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-79565: + +================================================================= +Breaking: #79565 - Removed "usergroup_cached_list" database field +================================================================= + +See :issue:`79565` + +Description +=========== + +The database field :sql:`be_users.usergroup_cached_list` has been +removed. It was populated by a list of all groups (including +subgroups) the user belongs to, and stored when a user logged in. +The field however was never updated when an admin added or removed a group +from the users group list. + + +Impact +====== + +The mentioned database field is removed, any direct SQL queries +accessing or writing this field will result in a database error. + +The PHP entry is removed from +:php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->user` array. +Accessing the array key will result in warnings since PHP 8.0. + +Affected Installations +====================== + +TYPO3 installations using or querying this database field +with third-party extensions. + +TYPO3 installations reading the array key from +:php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->user` array. + +Migration +========= + +Use the class :php:`\TYPO3\CMS\Core\Authentication\GroupResolver` +to fetch all groups of a user directly. + +.. index:: Backend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-89137-DatabaseFieldsT3verTstampAndT3verCountDropped.rst b/Documentation/Changelog/11.0/Breaking-89137-DatabaseFieldsT3verTstampAndT3verCountDropped.rst new file mode 100644 index 0000000..ab78bfe --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-89137-DatabaseFieldsT3verTstampAndT3verCountDropped.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-89137: + +======================================================================= +Breaking: #89137 - Database fields t3ver_tstamp and t3ver_count removed +======================================================================= + +See :issue:`89137` + +Description +=========== + +The two workspace related database fields :sql:`t3ver_tstamp` and :sql:`t3ver_count` +have been dropped from all workspace aware database tables. Also, all code handling these +fields has been removed from the code base. + + +Impact +====== + +The core did not show these fields to the user. It's very unlikely extensions made use of them. +Admins upgrading to TYPO3 v11 can usually assume zero impact for their site functionality. + + +Affected Installations +====================== + +All instances are affected by this change, the database analyzer will propose to drop the fields +from a lot of tables including :sql:`pages` and :sql:`tt_content`. + + +Migration +========= + +Use the database analyzer during upgrade to drop the fields from affected database tables. + +.. index:: Database, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-90799-DependencyInjectionWithNonPublicPropertiesHasBeenRemoved.rst b/Documentation/Changelog/11.0/Breaking-90799-DependencyInjectionWithNonPublicPropertiesHasBeenRemoved.rst new file mode 100644 index 0000000..2142023 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-90799-DependencyInjectionWithNonPublicPropertiesHasBeenRemoved.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _breaking-90799: + +=================================================================================== +Breaking: #90799 - Dependency injection with non-public properties has been removed +=================================================================================== + +See :issue:`90799` + +Description +=========== + +In TYPO3 v9, the (dependency) injection via :php:`@Inject` has been marked as deprecated for +non-public properties. The reason was to avoid having the core use the PHP reflection +api to make non-public properties writable from outside the class scope. Since there +are other methods for dependency injection (constructor/setter injection), injection +into non-public properties has now been removed. + + +Impact +====== + +Non-public properties with :php:`@Inject` annotations will no longer trigger extbase +dependency injection. Those properties will have their default state after object +instantiation. + + +Affected Installations +====================== + +All installations that use non-public properties for extbase dependency injection +as seen in this example: + +.. code-block:: php + + class Foo + { + /** + * @var Service + * @TYPO3\CMS\Extbase\Annotation\Inject + */ + private $service; + } + + +Migration +========= + +When not using constructor/setter injection instead, switch to inject methods +(recommended for compatibility with symfony dependency injection) or mark the +property public (works with extbase dependency injection only): + +.. code-block:: php + + class Foo + { + /** + * @var Service + * @TYPO3\CMS\Extbase\Annotation\Inject + */ + public $service; + } + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-91473-DeprecatedFunctionalityRemoved.rst b/Documentation/Changelog/11.0/Breaking-91473-DeprecatedFunctionalityRemoved.rst new file mode 100644 index 0000000..8c8e81b --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91473-DeprecatedFunctionalityRemoved.rst @@ -0,0 +1,390 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91473: + +=================================================== +Breaking: #91473 - Deprecated functionality removed +=================================================== + +See :issue:`91473` + +Description +=========== + +The following PHP classes that have previously been marked as deprecated for v10 and were now removed: + +- :php:`\TYPO3\CMS\Backend\Configuration\TsConfigParser` +- :php:`\TYPO3\CMS\Backend\Controller\File\CreateFolderController` +- :php:`\TYPO3\CMS\Backend\Controller\File\EditFileController` +- :php:`\TYPO3\CMS\Backend\Controller\File\FileUploadController` +- :php:`\TYPO3\CMS\Backend\Controller\File\RenameFileController` +- :php:`\TYPO3\CMS\Backend\Controller\File\ReplaceFileController` +- :php:`\TYPO3\CMS\Backend\Template\DocumentTemplate` +- :php:`\TYPO3\CMS\Core\Console\CommandRequestHandler` +- :php:`\TYPO3\CMS\Core\Localization\Parser\LocallangXmlParser` +- :php:`\TYPO3\CMS\Core\Routing\Aspect\PersistenceDelegate` +- :php:`\TYPO3\CMS\Core\Routing\Legacy\PersistedAliasMapperLegacyTrait` +- :php:`\TYPO3\CMS\Core\Routing\Legacy\PersistedPatternMapperLegacyTrait` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\AbstractFileCollection` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\FileMount` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\FolderBasedFileCollection` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\StaticFileCollection` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FileMountRepository` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\AbstractController` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Request` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Response` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\FolderBasedFileCollectionConverter` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\StaticFileCollectionConverter` +- :php:`\TYPO3\CMS\Felogin\Controller\FrontendLoginController` +- :php:`\TYPO3\CMS\Felogin\Hooks\CmsLayout` +- :php:`\TYPO3\CMS\Fluid\ViewHelpers\Widget\AutocompleteViewHelper` +- :php:`\TYPO3\CMS\Fluid\ViewHelpers\Widget\Controller\AutocompleteController` + +The following PHP interfaces that have previously been marked as deprecated for v10 and were now removed: + +- :php:`\TYPO3\CMS\Adminpanel\ModuleApi\InitializableInterface` +- :php:`\TYPO3\CMS\Core\Console\RequestHandlerInterface` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface` +- :php:`\TYPO3\CMS\Core\Routing\Aspect\DelegateInterface` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectGetSingleHookInterface` + +The following PHP class aliases that have previously been marked as deprecated for v10 and were now removed: + +* :php:`TYPO3\CMS\Frontend\Page\PageRepository` +* :php:`TYPO3\CMS\Frontend\Page\PageRepositoryGetPageHookInterface` +* :php:`TYPO3\CMS\Frontend\Page\PageRepositoryGetPageOverlayHookInterface` +* :php:`TYPO3\CMS\Frontend\Page\PageRepositoryGetRecordOverlayHookInterface` +* :php:`TYPO3\CMS\Frontend\Page\PageRepositoryInitHookInterface` +* :php:`TYPO3\CMS\Lowlevel\Utility\ArrayBrowser` + +The following PHP class methods that have previously been marked as deprecated for v10 and were now removed: + +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->createChangeLog` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->createMultipleDiff` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->getElementData` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->getHistoryData` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->getHistoryEntry` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->performRollback` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->setLastHistoryEntry` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->shouldPerformRollback` +- :php:`\TYPO3\CMS\Core\Console\CommandRegistry->getIterator` +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->assemblePermissions` +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->process_uploads` +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->setTSconfigPermissions` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->getLabelsWithPrefix` +- :php:`\TYPO3\CMS\Core\Html\RteHtmlParser->init` +- :php:`\TYPO3\CMS\Core\Html\RteHtmlParser->RTE_transform` +- :php:`\TYPO3\CMS\Core\Resource\File->_getMetaData` +- :php:`\TYPO3\CMS\Core\Resource\FileRepository->searchByName` +- :php:`\TYPO3\CMS\Core\Resource\Index\FileIndexRepository->findBySearchWordInMetaData` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactory->getInstance` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorage->checkFileAndFolderNameFilters` +- :php:`\TYPO3\CMS\Core\Utility\BasicFileUtility->setFileExtensionPermissions` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->emitBeforeCallActionMethodSignal` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->setUseCacheHash` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->getUseCacheHash` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->cImage` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getAltParam` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getBorderAttr` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getImageSourceCollection` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getImageTagTemplate` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->linkWrap` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->sendNotifyEmail` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->isOutputting` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->processContentForOutput` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->reqCHash` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->settingLocale` +- :php:`\TYPO3\CMS\Linkvalidator\Repository\BrokenLinkRepository->getNumberOfBrokenLinks` + + +The following PHP static class methods that have previously been marked as deprecated for v10 and were now removed: + +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getRawPagesTSconfig` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::editOnClick` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getViewDomain` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::TYPO3_copyRightNotice` +- :php:`\TYPO3\CMS\Core\Localization\Locales::initialize` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::compressIPv6` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::flushDirectory` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::getApplicationContext` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::idnaEncode` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::IPv6Hex2Bin` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::IPv6Bin2Hex` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::isRunningOnCgiServerApi` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::linkThisUrl` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::milliseconds` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::presetApplicationContext` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::resetApplicationContext` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::verifyFilenameAgainstDenyPattern` +- :php:`\TYPO3\CMS\Core\Utility\VersionNumberUtility::convertIntegerToVersionNumber` +- :php:`\TYPO3\CMS\Core\Utility\VersionNumberUtility::splitVersionRange` +- :php:`\TYPO3\CMS\Core\Utility\VersionNumberUtility::raiseVersionNumber` +- :php:`\TYPO3\CMS\Extbase\Reflection\ObjectAccess::buildSetterMethodName` +- :php:`\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::hex2bin` + +The following methods changed signature according to previous deprecations in v10 at the end of the argument list: + +- :php:`\TYPO3\CMS\Core\Database\ReferenceIndex->updateIndex` (argument 2 is now either null or ProgressListenerInterface, not boolean anymore) +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->doesRecordExist` (argument 3 is now an integer) +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->recordInfoWithPermissionCheck` (argument 3 is now an integer) +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->includeLLFile` (arguments 2 and 3 are dropped) +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::findService` (arguments 3 :php:`$excludeServiceKeys` is now an array) +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::callUserFunction` (arguments 3 no expects an object or null) +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::getUrl` (arguments 2, 3 and 4 are dropped) +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstanceService` (arguments 3 :php:`$excludeServiceKeys` is now an array) +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper->__construct` (argument :php:`$query` is removed) +- :php:`\TYPO3\CMS\Extbase\Persistence\Reflection\ObjectAccess->setProperty` (argument :php:`$forceDirectAccess` is removed) +- :php:`\TYPO3\CMS\Extbase\Persistence\Reflection\ObjectAccess->getProperty` (argument :php:`$forceDirectAccess` is removed) +- :php:`\TYPO3\CMS\Extbase\Persistence\Reflection\ObjectAccess->getPropertyInternal` (argument :php:`$forceDirectAccess` is removed) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->__construct` + +The following public class properties have been dropped: + +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->changeLog` +- :php:`\TYPO3\CMS\Backend\History\RecordHistory->lastHistoryEntry` +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->defaultPermissions` +- :php:`\TYPO3\CMS\Core\DataHandling\DataHandler->pMap` +- :php:`\TYPO3\CMS\Core\TypoScript\TemplateService->forceTemplateParsing` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cHash` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cHash_array` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->divSection` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->domainStartPage` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->fePreview` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->forceTemplateParsing` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sys_language_isocode` + +The following class methods have changed visibility: + +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->debugLL()` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->getLLL()` + +The following class properties have changed visibility: + +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->LL_files_cache` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->LL_labels_cache` + +The following ViewHelpers have changed: + +- :html:`<f:form>` ViewHelper argument "noCacheHash" is dropped +- :html:`<f:link.action>` ViewHelper argument "noCacheHash" is dropped +- :html:`<f:link.page>` ViewHelper argument "noCacheHash" is dropped +- :html:`<f:link.typolink>` ViewHelper argument "useCacheHash" is dropped +- :html:`<f:uri.action>` ViewHelper argument "noCacheHash" is dropped +- :html:`<f:uri.page>` ViewHelper argument "noCacheHash" is dropped +- :html:`<f:uri.typolink>` ViewHelper argument "useCacheHash" is dropped +- :html:`<f:widget.link>` ViewHelper argument "useCacheHash" is dropped +- :html:`<f:widget.uri>` ViewHelper argument "useCacheHash" is dropped +- :html:`<f:widget.autocomplete>` ViewHelper is removed + +The following TypoScript options have been dropped: + +- Extbase TypoScript option `requireCHashArgumentForActionArguments` for any plugin +- `typolink.useCacheHash` +- `typolink.addQueryString.method = POST` +- `typolink.addQueryString.method = POST,GET` +- `typolink.addQueryString.method = GET,POST` + +The following constants have been dropped: + +- :php:`FILE_DENY_PATTERN_DEFAULT` +- :php:`PHP_EXTENSIONS_DEFAULT` +- :php:`TYPO3_copyright_year` +- :php:`TYPO3_URL_DONATE` +- :php:`TYPO3_URL_EXCEPTION` +- :php:`TYPO3_URL_GENERAL` +- :php:`TYPO3_URL_LICENSE` +- :php:`TYPO3_URL_WIKI_OPCODECACHE` + +The following class constants have been dropped: + +- :php:`\TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider::SIGNAL_PostProcessTreeData` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PreProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PostProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreGeneratePublicUrl` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_SanitizeFileName` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PreFileProcess` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PostFileProcess` +- :php:`\TYPO3\CMS\Form\Domain\Finishers\EmailFinisher::FORMAT_PLAINTEXT` +- :php:`\TYPO3\CMS\Form\Domain\Finishers\EmailFinisher::FORMAT_HTML` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_BeforeCaching` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_PostProcesss` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GetDataArray_PostProcesss` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_SortDataArray_PostProcesss` + +The following global options are ignored: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXT']['runtimeActivatedPackages']` + +The following global variables have been removed: + +- :php:`$GLOBALS['LOCAL_LANG']` + +The following hooks have been removed: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['cObjTypeAndClassDefault']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['cObjTypeAndClass']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['extLinkATagParamsHandler']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typolinkLinkHandler']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-output']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['isOutputting']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_eofe']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['pageIndexing']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['tslib_fe-contentStrReplace']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['beforeRedirect']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['forgotPasswordMail']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_confirmed']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['login_error']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['loginFormOnSubmitFuncs']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['logout_confirmed']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['password_changed']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['felogin']['postProcContent']` + +The following signals have been removed: + +- :php:`PackageManagement::packagesMayHaveChanged` +- :php:`\TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::getSystemInformation` +- :php:`\TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem::loadMessages` +- :php:`\TYPO3\CMS\Backend\LoginProvider\UsernamePasswordLoginProvider::getPageRenderer` +- :php:`\TYPO3\CMS\Backend\Controller\EditDocumentController::preInitAfter` +- :php:`\TYPO3\CMS\Backend\Controller\EditDocumentController::initAfter` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfigPreInclude` +- :php:`\TYPO3\CMS\Beuser\Controller\BackendUserController::switchUser` +- :php:`\TYPO3\CMS\Core\Database\SoftReferenceIndex::setTypoLinkPartsElement` +- :php:`\TYPO3\CMS\Core\Database\ReferenceIndex::shouldExcludeTableFromReferenceIndex` +- :php:`\TYPO3\CMS\Core\Imaging\IconFactory::buildIconForResourceSignal` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PreProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceFactoryInterface::SIGNAL_PostProcessStorage` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PostFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCreate` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileReplace` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileSetContents` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderAdd` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderCopy` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderDelete` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderMove` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFolderRename` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreGeneratePublicUrl` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_SanitizeFileName` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PreFileProcess` +- :php:`\TYPO3\CMS\Core\Resource\Service\FileProcessingService::SIGNAL_PostFileProcess` +- :php:`\TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider::PostProcessTreeData` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::tcaIsBeingBuilt` +- :php:`\TYPO3\CMS\Extbase\Mvc\Dispatcher::afterRequestDispatch` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController::beforeCallActionMethod` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::afterMappingSingleRow` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::beforeGettingObjectData` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterGettingObjectData` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::endInsertObject` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterUpdateObject` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterPersistObject` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Backend::afterRemoveObject` +- :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionInstall` +- :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionUninstall` +- :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionT3DImport` +- :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionStaticSqlImport` +- :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility::afterExtensionFileImport` +- :php:`\TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService::willInstallExtensions` +- :php:`\TYPO3\CMS\Extensionmanager\ViewHelper\ProcessAvailableActionsViewHelper::processActions` +- :php:`\TYPO3\CMS\Install\Service\SqlExpectedSchemaService::tablesDefinitionIsBeingBuilt` +- :php:`\TYPO3\CMS\Impexp\Utility\ImportExportUtility::afterImportExportInitialisation` +- :php:`\TYPO3\CMS\Lang\Service\TranslationService::postProcessMirrorUrl` +- :php:`\TYPO3\CMS\Linkvalidator\LinkAnalyzer::beforeAnalyzeRecord` +- :php:`\TYPO3\CMS\Seo\Canonical\CanonicalGenerator::beforeGeneratingCanonical` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_BeforeCaching` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GenerateDataArray_PostProcesss` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_GetDataArray_PostProcesss` +- :php:`\TYPO3\CMS\Workspaces\Service\GridDataService::SIGNAL_SortDataArray_PostProcesss` + +The following features are now always enabled: + +- `felogin.extbase` + +The following features have been removed: + +- All install tool upgrade wizards upgrading from v8 to v9 +- CLI Command Configuration definition via :file:`Commands.php` +- Pi-based plugin for "felogin" (CType `login`) +- XML-based (TYPO3-custom XML format) label parsing + +The following database fields have been removed: + +- :sql:`sys_template.sitetitle` +- :sql:`pages.legacy_overlay_uid` + +The following Backend route identifiers have been removed: + +- `xMOD_tximpexp` + +The following global JavaScript variables have been removed: + +- :js:`T3_THIS_LOCATION` +- :js:`T3_RETURN_URL` + +The following global JavaScript functions have been removed: + +- :js:`jumpExt` +- :js:`jumpToUrl` +- :js:`rawurlencode` +- :js:`str_replace` +- :js:`openUrlInWindow` +- :js:`setFormValueOpenBrowser` +- :js:`setFormValueFromBrowseWin` +- :js:`setHiddenFromList` +- :js:`setFormValueManipulate` +- :js:`setFormValue_getFObj` + +The following JavaScript modules have been removed: + +- :js:`jquery.clearable` +- :js:`md5` + +Impact +====== + +Instantiating or requiring the PHP classes or calling the PHP methods directly will trigger PHP :php:`E_ERROR` errors. + +.. index:: Backend, CLI, FlexForm, Fluid, Frontend, JavaScript, LocalConfiguration, PHP-API, TCA, TSConfig, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/11.0/Breaking-91562-CObjectTEMPLATERemoved.rst b/Documentation/Changelog/11.0/Breaking-91562-CObjectTEMPLATERemoved.rst new file mode 100644 index 0000000..36192ac --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91562-CObjectTEMPLATERemoved.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91562: + +=========================================== +Breaking: #91562 - cObject TEMPLATE removed +=========================================== + +See :issue:`91562` + +Description +=========== + +The cObject :typoscript:`TEMPLATE`, used for rendering marker-based templates +has been removed along with the PHP class :php:`TYPO3\CMS\Frontend\ContentObject\TemplateContentObject`. + +The successor :typoscript:`FLUIDTEMPLATE` is widely used since TYPO3 v7, +and acts as a replacement for marker-based templates. + + +Impact +====== + +Using TypoScript with :typoscript:`page.10 = TEMPLATE` will result in a PHP +error when rendering the frontend. + +Referencing the PHP class will result in a fatal PHP error. + + +Affected Installations +====================== + +TYPO3 installation still using :typoscript:`TEMPLATE` cObject in their TypoScript. + + +Migration +========= + +Refactor TypoScript templates to not use the cObject :typoscript:`TEMPLATE` anymore. + +In case you can not or want not make the switch to :typoscript:`FLUIDTEMPLATE`, install +the extension `modern_template_building` from the official +TYPO3 Extension Repository at https://extensions.typo3.org/, which acts as a drop-in replacement, and also ships the cObject :typoscript:`FILE` +which is highly useful for :typoscript:`TEMPLATE` cObjects. + +The extension is compatible with TYPO3 v9+. + +.. index:: TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-91563-PHP-basedJSCSSInclusionsForFrontendRemoved.rst b/Documentation/Changelog/11.0/Breaking-91563-PHP-basedJSCSSInclusionsForFrontendRemoved.rst new file mode 100644 index 0000000..ecf3a18 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91563-PHP-basedJSCSSInclusionsForFrontendRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91563: + +===================================================================== +Breaking: #91563 - PHP-based JS + CSS inclusions for Frontend removed +===================================================================== + +See :issue:`91563` + +Description +=========== + +In the past, TYPO3's :php:`TSFE` object allowed to manually add CSS or JavaScript snippets via PHP code with the following method and properties: + +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->setJS()` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->additionalJavaScript` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->additionalCSS` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->JSCode` +* :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->inlineJS` + +These have been removed due to better APIs like :php:`PageRenderer` (available since TYPO3 v4.5) and :php:`AssetCollector` (available since TYPO3 v10). + + +Impact +====== + +Accessing the method and properties will have no effect and trigger PHP errors. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this functionality directly to inject custom CSS or JavaScript. + + +Migration +========= + +Use the :php:`AssetCollector` API in PHP to add JavaScript and CSS code or use files directly. + +.. index:: PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-91578-IrreRelatedJavaScriptHasBeenRemoved.rst b/Documentation/Changelog/11.0/Breaking-91578-IrreRelatedJavaScriptHasBeenRemoved.rst new file mode 100644 index 0000000..3003e0b --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91578-IrreRelatedJavaScriptHasBeenRemoved.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91578: + +=========================================================== +Breaking: #91578 - IRRE related JavaScript has been removed +=========================================================== + +See :issue:`91578` + +Description +=========== + +The JavaScript functions :js:`TBE_EDITOR.fieldChanged_fName` and its legacy +alias :js:`TBE_EDITOR_fieldChanged_fName`, used to extract the table name, field +name and uid from the incoming field name, have been removed. + + +Impact +====== + +Calling any of the removed function will trigger a JavaScript error. + + +Affected Installations +====================== + +All 3rd party extensions calling these functions are affected. + + +Migration +========= + +No migration is possible as this is IRRE-related code only. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-91606-DatetimeOperationsInFormEngineRemoved.rst b/Documentation/Changelog/11.0/Breaking-91606-DatetimeOperationsInFormEngineRemoved.rst new file mode 100644 index 0000000..d822080 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91606-DatetimeOperationsInFormEngineRemoved.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91606: + +============================================================= +Breaking: #91606 - Date/time operations in FormEngine removed +============================================================= + +See :issue:`91606` + +Description +=========== + +FormEngine supported to add or subtract a date or time range (depending on the +field type) by appending e.g. `+5` or `-42` to the field values. These kind of +operations have been removed as they are rather unknown and clumsy to use. + + +Impact +====== + +Using these operations doesn't have any effect anymore. + + +Affected Installations +====================== + +All installations of TYPO3 are affected. + + +Migration +========= + +There is no migration possible. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-91740-DeprecatedIconIdentifierRemoved.rst b/Documentation/Changelog/11.0/Breaking-91740-DeprecatedIconIdentifierRemoved.rst new file mode 100644 index 0000000..6287c2a --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91740-DeprecatedIconIdentifierRemoved.rst @@ -0,0 +1,132 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91740: + +===================================================== +Breaking: #91740 - Deprecated icon identifier removed +===================================================== + +See :issue:`91740` + +Description +=========== + +The following deprecated icon identifiers have been removed from the Icon API: + ++-------------------------------------------+-------------------------------------------+ +| Deprecated identifier | New identifier | ++===========================================+===========================================+ +| module-web | modulegroup-web' | ++-------------------------------------------+-------------------------------------------+ +| module-site | modulegroup-site' | ++-------------------------------------------+-------------------------------------------+ +| module-file | modulegroup-file' | ++-------------------------------------------+-------------------------------------------+ +| module-tools | modulegroup-tools' | ++-------------------------------------------+-------------------------------------------+ +| module-system | modulegroup-system' | ++-------------------------------------------+-------------------------------------------+ +| module-help | modulegroup-help' | ++-------------------------------------------+-------------------------------------------+ +| module-workspaces-action-preview-link | actions-version-workspaces-preview-link' | ++-------------------------------------------+-------------------------------------------+ +| generate-ws-preview-link | actions-version-workspaces-preview-link' | ++-------------------------------------------+-------------------------------------------+ +| extensions-workspaces-generatepreviewlink | 'actions-version-workspaces-preview-link' | ++-------------------------------------------+-------------------------------------------+ +| extensions-extensionmanager-update-script | 'actions-refresh' | ++-------------------------------------------+-------------------------------------------+ +| extensions-scheduler-run-task | actions-play' | ++-------------------------------------------+-------------------------------------------+ +| extensions-scheduler-run-task-cron | actions-clock' | ++-------------------------------------------+-------------------------------------------+ +| status-warning-lock | warning-lock' | ++-------------------------------------------+-------------------------------------------+ +| status-warning-in-use | warning-in-use' | ++-------------------------------------------+-------------------------------------------+ +| status-status-reference-hard | status-reference-hard' | ++-------------------------------------------+-------------------------------------------+ +| status-status-reference-soft | status-reference-soft' | ++-------------------------------------------+-------------------------------------------+ +| status-status-edit-read-only | status-edit-read-only' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-advanced-password | form-advanced-password' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-checkbox | form-checkbox' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-content-element | form-content-element' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-date-picker | form-date-picker' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-duplicate | actions-duplicate' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-email | form-email' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-fieldset | form-fieldset' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-file-upload | form-file-upload' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-finisher | form-finisher' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-form-element-selector | actions-variable-select' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-gridcontainer | form-gridcontainer' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-gridrow | form-gridrow' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-hidden | form-hidden' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-image-upload | form-image-upload' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-insert-after | actions-form-insert-after' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-insert-in | actions-form-insert-in' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-multi-checkbox | form-multi-checkbox' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-multi-select | form-multi-select' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-number | form-number' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-page | form-page' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-password | form-password' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-radio-button | form-radio-button' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-single-select | form-single-select' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-static-text | form-static-text' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-summary-page | form-summary-page' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-telephone | form-telephone' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-text | form-text' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-textarea | form-textarea' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-url | form-url' | ++-------------------------------------------+-------------------------------------------+ +| t3-form-icon-validator | form-validator' | ++-------------------------------------------+-------------------------------------------+ + +Impact +====== + +Loading any removed icon will result in getting the icon identifier +`default-not-found`. + + +Affected Installations +====================== + +All installations using these deprecated icons are affected. + + +Migration +========= + +Use the icon identifiers as listed in the table above. + +.. index:: Backend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-91782-LockToDomain.rst b/Documentation/Changelog/11.0/Breaking-91782-LockToDomain.rst new file mode 100644 index 0000000..a2c4562 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91782-LockToDomain.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91782: + +====================================================================================================== +Breaking: #91782 - lockToDomain feature for frontend users / groups and backend users / groups removed +====================================================================================================== + +See :issue:`91782` + +Description +=========== + +TYPO3 Core shipped with a feature called "lockToDomain" for frontend and backend users which made the user login only valid if +the exact given HTTP_HOST matches the filled domain. + +A similar functionality with the same name for groups existed, which only added the group to a specific user during a session, +if the user was accessing a TYPO3 site under a specific domain. + +Both features have been removed. + +Impact +====== + +Frontend users or backend users that have this option set previously, will now be able to login independent of the defined HTTP_HOST +header sent with the login page. + +Regardless of any setting of the "lockToDomain" setting of a specific group, all groups added +to a user are now applied during login of a user, both for frontend and backend. + + +Affected Installations +====================== + +TYPO3 Installations using this feature in their database records are affected. Following SQL SELECT statements help to identify records +with a value for the features, which indicates those users and groups will now be able to log in without the domain restriction. + +Frontend Users: + +.. code-block:: sql + + SELECT uid, pid, username FROM fe_users WHERE lockToDomain != '' AND lockToDomain IS NOT NULL; + +Backend Users: + +.. code-block:: sql + + SELECT uid, pid, username FROM be_users WHERE lockToDomain != '' AND lockToDomain IS NOT NULL; + +Frontend Groups: + +.. code-block:: sql + + SELECT uid, pid, username FROM fe_groups WHERE lockToDomain != '' AND lockToDomain IS NOT NULL; + +Backend Groups: + +.. code-block:: sql + + SELECT uid, pid, username FROM be_groups WHERE lockToDomain != '' AND lockToDomain IS NOT NULL; + + +Migration +========= + +Any installations needing this feature should build this in +custom extensions extending TCA and a custom Authentication Service. + +In addition, if such a feature is needed for frontend users +or groups, it is recommended to use the storagePid option to limit +frontend user login by Storage Folders. + +.. index:: Database, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-91906-StoreTransOrigDiffSourceFieldAsJson.rst b/Documentation/Changelog/11.0/Breaking-91906-StoreTransOrigDiffSourceFieldAsJson.rst new file mode 100644 index 0000000..cdad758 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91906-StoreTransOrigDiffSourceFieldAsJson.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91906: + +================================================================ +Breaking: #91906 - Store TransOrigDiffSourceField as json string +================================================================ + +See :issue:`91906` + +Description +=========== + +The TCA field :php:`['tableName']['ctrl']['transOrigDiffSourceField']` - often set to +:php:`l18n_diffsource` - stores the state of the source language record a translated +record has been created from. This is used if content in the source language +of a record has been changed to hint editors for a potentially needed update +of the translated record. + +The storage format of this field has been changed from a PHP serialized string +to a json encoded string. + +Impact +====== + +Usages of this field can be expected to be core internal. The impact on existing +instances in low since it's unlikely that an extension uses the field content. + + +Affected Installations +====================== + +Installations with multi language sites are affected and should run the +upgrade wizard. + + +Migration +========= + +Run "Admin Tools" -> "Upgrade" -> "Upgrade Wizard" -> "Migrate transOrigDiffSourceField field to json encoded string." +to adapt existing rows to the new storage format. + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-91909-SysCollectionDatabaseTablesMovedIntoExternalExtension.rst b/Documentation/Changelog/11.0/Breaking-91909-SysCollectionDatabaseTablesMovedIntoExternalExtension.rst new file mode 100644 index 0000000..cbc9e58 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91909-SysCollectionDatabaseTablesMovedIntoExternalExtension.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91909: + +=============================================================================== +Breaking: #91909 - sys_collection database tables moved into external extension +=============================================================================== + +See :issue:`91909` + +Description +=========== + +The generic :sql:`sys_collection` database table and its MM table :sql:`sys_collection_entries`, which +holds information of connected records to a "Record Collection" have +been removed from TYPO3 Core. + +This feature was added as a more generic approach of the `sys_file_collection` +definition along with the File Abstraction Layer in TYPO3 v6.0. The file collection +allows to create a group of files (e.g. from a folder, or from a category). + +However, the more generic API was never picked up in TYPO3 Core since 2012. + +The database table, the TCA definition (for editing the records +in the database), and the PHP API are now available in a separate +extension installable via the TYPO3 Extension Repository (https://extensions.typo3.org) +or via composer ("friendsoftypo3/legacy-collections"). + +The third-party extension can be used as a 1:1 drop-in replacement +for the removed Core functionality. + + +Impact +====== + +It is not possible to modify / edit :sql:`sys_collection` records anymore in the TYPO3 Backend. + +The database tables are not defined anymore, neither is the TCA definition. + +Accessing the PHP API class will result in fatal PHP errors. + + +Affected Installations +====================== + +Any TYPO3 installation using the database tables belonging to the :sql:`sys_collection` feature +which is very unlikely. + + +Migration +========= + +Use the upgrade wizard or install the `legacy_collections` extension +to re-add the functionality - but only if it is needed. + +As the PHP classes have a class alias, everything should work +as before. + +.. index:: Database, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-91974-ConfigurationOptionIPmaskMountGroupsRemoved.rst b/Documentation/Changelog/11.0/Breaking-91974-ConfigurationOptionIPmaskMountGroupsRemoved.rst new file mode 100644 index 0000000..0697b43 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-91974-ConfigurationOptionIPmaskMountGroupsRemoved.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-91974: + +================================================================= +Breaking: #91974 - Configuration Option IPmaskMountGroups removed +================================================================= + +See :issue:`91974` + +Description +=========== + +The global configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['FE'][IPmaskMountGroups]` has been removed. It allowed to automatically assign +groups to users visiting the TYPO3 Frontend from specific IP addresses / networks. + +This is especially handy to show content only in Intranet/Extranet +sites where internal members see restricted content automatically. + +However, showing content based on certain contexts is usually solved with a much more flexible way through third-party extensions +such as EXT:contexts. Third-party extensions allow even for automatic login based on IP-addresses, which should be used instead. + + +Impact +====== + +The mentioned option is automatically removed from :file:`LocalConfiguration.php` +on upgrade, and not evaluated anymore. + + +Affected Installations +====================== + +Installations having the global configuration setting set in +:file:`typo3conf/LocalConfiguration.php` or :file:`typo3conf/AdditionalConfiguration.php`, mostly related to intranet / extranet websites. + + +Migration +========= + +If this functionality explicitly is required, it can be provided by a third-party extension, or a custom extension registering +a AuthenticationService ("getGroupsFE") to assign the groups on a more specific approach. + +.. index:: LocalConfiguration, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-92060-DroppedClassTYPO3CMSBackendViewPageTreeView.rst b/Documentation/Changelog/11.0/Breaking-92060-DroppedClassTYPO3CMSBackendViewPageTreeView.rst new file mode 100644 index 0000000..1faf0cf --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92060-DroppedClassTYPO3CMSBackendViewPageTreeView.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92060: + +======================================================================== +Breaking: #92060 - Dropped class TYPO3\\CMS\\Backend\\View\\PageTreeView +======================================================================== + +See :issue:`92060` + +Description +=========== + +Class :php:`TYPO3\CMS\Backend\View\PageTreeView` has been dropped without substitution. + + +Impact +====== + +Extensions using or extending this class will throw fatal PHP errors. + + +Affected Installations +====================== + +This core internal class has been unused for a while. There is little +chance some extension depends on it. The extension scanner finds affected +extensions with a strong match. + + +Migration +========= + +If still needed, copy the class code from an older core version to the affected extension, +adapt namespace and usages. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92118-TCACtrlThumbnailSettingDropped.rst b/Documentation/Changelog/11.0/Breaking-92118-TCACtrlThumbnailSettingDropped.rst new file mode 100644 index 0000000..5ed9ab1 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92118-TCACtrlThumbnailSettingDropped.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92118: + +===================================================== +Breaking: #92118 - TCA ctrl thumbnail setting dropped +===================================================== + +See :issue:`92118` + +Description +=========== + +The TCA setting :php:`$GLOBALS['TCA'][$aTableName]['ctrl']['thumbnail']` has been dropped. + + +Impact +====== + +Setting the control field for a custom table has no effect anymore. + + +Affected Installations +====================== + +The setting has been used in the :guilabel:`List` module for tables with image fields to render a preview +of attached images. It has been used for :php:`tt_content` in core versions until TYPO3 v7. +There are probably not many extensions using the setting. The :guilabel:`List` module will +no longer show preview images for rendered rows. + + +Migration +========= + +Drop this setting during extension clean up. The setting is simply ignored, no PHP error will be thrown. + +.. index:: TCA, NotScanned, ext:recordlist diff --git a/Documentation/Changelog/11.0/Breaking-92128-DatabaseRecordListDropHookToModifySearchFields.rst b/Documentation/Changelog/11.0/Breaking-92128-DatabaseRecordListDropHookToModifySearchFields.rst new file mode 100644 index 0000000..0f67edd --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92128-DatabaseRecordListDropHookToModifySearchFields.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92128: + +======================================================================= +Breaking: #92128 - DatabaseRecordList: Drop hook to modify searchFields +======================================================================= + +See :issue:`92128` + +Description +=========== + +The TCA configuration :php:`searchFields` in the `ctrl` section was introduced in TYPO3 4.6. +This configuration allows defining search columns. +Those columns are taken into account by the search in the TYPO3 backend. + +To enable a smooth transition between TYPO3 4.5 and 4.6, the hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['mod_list']['getSearchFieldList']` was introduced as well. It allowed +to manipulate the :php:`searchFields` for the list modules search. + +As this transition should be finished now, the hook has been removed. + +Impact +====== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['mod_list']['getSearchFieldList']` +isn't evaluated anymore. + + +Affected Installations +====================== + +All installations using this hook. + + +Migration +========= + +Set the search fields in the following TCA configuration: +:php:`['ctrl']['searchFields'] = 'list, of, search, columns'` + +.. index:: Backend, PHP-API, FullyScanned, ext:recordlist diff --git a/Documentation/Changelog/11.0/Breaking-92132-LastRemainsOfGlobalsSOBERemoved.rst b/Documentation/Changelog/11.0/Breaking-92132-LastRemainsOfGlobalsSOBERemoved.rst new file mode 100644 index 0000000..49d36a8 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92132-LastRemainsOfGlobalsSOBERemoved.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92132: + +======================================================= +Breaking: #92132 - Last remains of globals SOBE removed +======================================================= + +See :issue:`92132` + +Description +=========== + +The :php:`$GLOBALS['SOBE']` object has been used as a controller to +sub module communication. It's usage has been reduced in previous core versions +already. It is now fully removed. + + +Impact +====== + +Backend extensions that rely on :php:`$GLOBALS['SOBE']` may behave differently. + + +Affected Installations +====================== + +Some old backend extensions may still rely on :php:`$GLOBALS['SOBE']` being set. +The extension scanner will find usages. + + +Migration +========= + +Do not rely on :php:`$GLOBALS['SOBE']` being set anymore, hand over arguments to other classes directly. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92206-RemoveWorkspaceSwappingOfElements.rst b/Documentation/Changelog/11.0/Breaking-92206-RemoveWorkspaceSwappingOfElements.rst new file mode 100644 index 0000000..54bd917 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92206-RemoveWorkspaceSwappingOfElements.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92206: + +======================================================== +Breaking: #92206 - Remove workspace swapping of elements +======================================================== + +See :issue:`92206` + +Description +=========== + +When using workspaces, putting modified content into the live workspace can be achieved by two methods: + +1. Publishing +Content is replaced with the live version, and the current live version is removed. + +2. Swapping +Content is switched (swapped) with the live version, making the current live version the previously versioned content. + +Especially when doing + +* partial swapping +* multiple swapping +* swapping newly created content + +TYPO3 will leave the workspace in an inconsistent state. + +The swapping mechanism was therefore removed, leaving "Publishing" the only option to select for editors to push content from a workspace into the live website. + + +Impact +====== + +The database field :sql:`sys_workspace.swap_modes` and the TCA option :php:`sys_workspace.swap_modes` are removed. + +The Workspace module only shows the "Publish" option, as "Swap" is removed. + +The auto-publishing feature now always publishes instead of optionally swaps content. + + +Affected Installations +====================== + +TYPO3 installations which use workspaces with the swapping option +activated. + + +Migration +========= + +All draft content is using the publishing mechanism, whereas there +is no migration needed. + +.. index:: Database, TCA, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-92238-ServiceInjectionInExtbaseValidators.rst b/Documentation/Changelog/11.0/Breaking-92238-ServiceInjectionInExtbaseValidators.rst new file mode 100644 index 0000000..9311bd9 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92238-ServiceInjectionInExtbaseValidators.rst @@ -0,0 +1,88 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92238: + +========================================================== +Breaking: #92238 - Service injection in Extbase validators +========================================================== + +See :issue:`92238` + +Description +=========== + +With the deprecation and removal of objectManager usage in TYPO3, Extbase does not +use the objectManager to create validator instances any more. + +.. note:: + + This has been mitigated with recent TYPO3 v11 core releases: Extbase validators + can use dependency injection again. See :doc:`this changelog <../11.5.x/Important-96332-ExtbaseValidatorsCanUseDependencyInjection>` + for details. + Additionally, all validators delivered by EXT:extbase and EXT:form will be marked + :php:`final` in v12. Extensions can no longer extend validators but must extend abstract + classes or implement the interfaces directly. + +Impact +====== + +Validators that use dependency injection will experience non injected services for affected properties. + + +Affected Installations +====================== + +All installations that use dependency injection in Extbase validators. + + +Migration +========= + + +Instead of injecting services to the validator, use :php:`GeneralUtility::makeInstance` +to create an instance of required services. + +Given the following example for a service injection in a validator: + +.. code-block:: php + + /** + * @var ConfigurationManagerInterface + */ + protected $configurationManager; + + /** + * @param ConfigurationManagerInterface $configurationManager + */ + public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager) + { + $this->configurationManager = $configurationManager; + } + +Since the configurationManager is required globally in the class, :php:`GeneralUtility::makeInstance` +is used in the constructor of the validator to create an instance of the service. + +.. code-block:: php + + /** + * @var ConfigurationManagerInterface + */ + protected $configurationManager; + + public function __construct(array $options = []) + { + $this->configurationManager = GeneralUtility::makeInstance(ConfigurationManagerInterface::class); + parent::__construct($options); + } + +In order to create instances of services that require dependency injection and which +are not already instantiated in the service container, it is required to declare those +services as :php:`public: true` in the :php:`Configuration/Services.yaml` of the given extension. + +.. code-block:: yaml + + Vendor\MyExtension\Services\MyService: + public: true + + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-92289-DecoupleLogicOfResourceFactoryIntoStorageRepository.rst b/Documentation/Changelog/11.0/Breaking-92289-DecoupleLogicOfResourceFactoryIntoStorageRepository.rst new file mode 100644 index 0000000..2ef8787 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92289-DecoupleLogicOfResourceFactoryIntoStorageRepository.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92289: + +=========================================================================== +Breaking: #92289 - Decouple logic of ResourceFactory into StorageRepository +=========================================================================== + +See :issue:`92289` + +Description +=========== + +The :php:`ResourceFactory` class was initially created for the File Abstraction Layer (FAL) +as a Factory class, which created PHP objects. + +However, in the recent years, it became apparent that it is more useful to +separate the concerns of the creation and retrieving of existing information. + +For this reason, the :php:`StorageRepository` class is now handling the creation of +:php:`ResourceStorage` objects. This layer accesses the Database and the needed Driver +objects and configuration. + +The :php:`StorageRepository` class does not extend from :php:`AbstractRepository` anymore, +and is available standalone. + +Most of the logic in the :php:`ResourceFactory` concerning Storages has been moved to +:php:`StorageRepository`, which has a lot of options available now. + +The following methods within :php:`ResourceFactory` have been marked +as internal, and are kept for backwards-compatibility without deprecation: + +* :php:`ResourceFactory->getDefaultStorage()` +* :php:`ResourceFactory->getStorageObject()` +* :php:`ResourceFactory->convertFlexFormDataToConfigurationArray()` +* :php:`ResourceFactory->createStorageObject()` +* :php:`ResourceFactory->createFolderObject()` +* :php:`ResourceFactory->getFileObjectByStorageAndIdentifier()` +* :php:`ResourceFactory->getStorageObjectFromCombinedIdentifier()` + +The following method has been removed + +* :php:`ResourceFactory->getDriverObject()` + + +Impact +====== + +Calling the removed method will throw a fatal error. + +Checking :php:`StorageRepository` for an instance of :php:`AbstractRepository` +will have different results. + + +Affected Installations +====================== + +TYPO3 installations with specific third-party extensions working with the FAL +API directly might use the existing functionality. + + +Migration +========= + +Migrate to the :php:`StorageRepository` API in the third-party extension code. + +.. index:: FAL, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92352-NewDefaultPositionForRedirectMiddleware.rst b/Documentation/Changelog/11.0/Breaking-92352-NewDefaultPositionForRedirectMiddleware.rst new file mode 100644 index 0000000..a50f5a7 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92352-NewDefaultPositionForRedirectMiddleware.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92352: + +=============================================================== +Breaking: #92352 - New default position for redirect middleware +=============================================================== + +See :issue:`92352` + +Description +=========== + +TYPO3 10 introduced the feature toggle `rearrangedRedirectMiddlewares` to rearrange the middlewares +:php:`typo3/cms-redirects/redirecthandler` and :php:`typo3/cms-frontend/base-redirect-resolver`. If enabled, the +the :php:`typo3/cms-redirects/redirecthandler` is executed first. + +This order has the advantage that any redirect would work regardless whether the request made it through the +:php:`typo3/cms-frontend/base-redirect-resolver`. While this might cause problems in some scenarios it is by +far the better default. + +Therefore the feature switch has been removed now and the above described order is the new default. + +Impact +====== + +By putting the :php:`typo3/cms-frontend/base-redirect-resolver` last, redirects are always resolved even if no +configured base URL was requested. In most cases this is considered to be a bugfix. However, redirect behavior might +change. + +Custom middlewares that have been put in between the two above mentioned middlewares most likely will lead to a circular +dependency exception now. Such custom middlewares have to be revisited and registered differently. + +Affected Installations +====================== + +All installations that need the :php:`typo3/cms-frontend/base-redirect-resolver` executed before the +:php:`typo3/cms-redirects/redirecthandler` or that have the feature switch turned off and registered +a custom middleware in between the two or with one of the two as a position definition via `after` or `before`. + +Migration +========= + +Manually check the position of your custom middlewares and adapt accordingly. + +If needed the order of the middlewares can be switched back manually as described in the documentation. + +.. index:: Frontend, NotScanned, ext:redirects diff --git a/Documentation/Changelog/11.0/Breaking-92457-ExtensionRepositoryDatabaseTableRemoved.rst b/Documentation/Changelog/11.0/Breaking-92457-ExtensionRepositoryDatabaseTableRemoved.rst new file mode 100644 index 0000000..d713f79 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92457-ExtensionRepositoryDatabaseTableRemoved.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92457: + +============================================================== +Breaking: #92457 - Extension Repository database table removed +============================================================== + +See :issue:`92457` + +Description +=========== + +The existing extension manager had functionality to add +multiple repositories by adding new database rows into the +database table :sql:`tx_extensionmanager_domain_model_repository`. + +Because this functionality has been superseded by a configurable +and more robust Remote API, where the configuration of possible +additional TER endpoints are not stored in the database anymore, +the database table is removed. + + +Impact +====== + +Accessing :sql:`tx_extensionmanager_domain_model_repository` will +result in a SQL error, as existing TYPO3 installations will drop this +database table in the Database Compare View during upgrade. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions accessing this +database table, which is highly unlikely. + +Also, TYPO3 installations depending on additional repositories +rather than the official TYPO3 Extension Repository (TER) at +extensions.typo3.org, will not work anymore. + + +Migration +========= + +Additional Extension Repositories (remotes) have to be added in +:file:`Configuration/Services.yaml` using the :yaml:`extension.remote` tag. + +.. code-block:: yaml + + extension.remote.myremote: + class: 'TYPO3\CMS\Extensionmanager\Remote\TerExtensionRemote' + arguments: + $identifier: 'myremote' + $options: + remoteBase: 'https://my_own_remote/' + tags: + - name: 'extension.remote' + enabled: true + +.. index:: Database, FullyScanned, ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Breaking-92497-WorkspacesMovePlaceholdersRemoved.rst b/Documentation/Changelog/11.0/Breaking-92497-WorkspacesMovePlaceholdersRemoved.rst new file mode 100644 index 0000000..6983663 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92497-WorkspacesMovePlaceholdersRemoved.rst @@ -0,0 +1,111 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92497: + +======================================================== +Breaking: #92497 - Workspaces: Move Placeholders removed +======================================================== + +See :issue:`92497` + +Description +=========== + +Workspaces had a so-called Move Placeholder since TYPO3 4.2, which +indicated that a versioned record (the move pointer) was moved to a +new location - either to a new page or to a different sorting position. + +When querying records in a workspace, the Move Placeholder was included in the +initial database query, and then reverted to the actual live record +(to get the original PID), and then overloaded with the versioned record +(Move Pointer) containing other modified fields. + +The main two fields of this Move Placeholder record were the PID +and the sorting, referring to the newly moved location. All +other fields were insignificant. An additional field "t3ver_move_id" contained +the actual live record ID. + +Move Placeholders were identified by having the following field values: + +* t3ver_state = 3 - indicating the type Move Placeholder +* t3ver_wsid = the workspace ID where a record was moved +* t3ver_oid = 0, in order to fetch them from the database together with live records +* pid = new Page location +* sorting (optional) = the new sorting location +* t3ver_move_id = the live version which was moved in the workspace + +The Move Pointer is indicated like this: + +* t3ver_state = 4 - indicating the type Move Pointer +* t3ver_wsid = the workspace ID where a record was moved +* t3ver_oid = the live version which was moved in the workspace +* pid = new Page location +* sorting (optional) = the new sorting location + +Due to a significant change in TYPO3 v10, the Move Pointer (versioned record) +now also has the new PID, which was previously set to "-1", indicating a +versioned record. However, since all information is now also available in +the Move Pointer, the move placeholder database record is not needed anymore. + +Move Placeholders are now neither evaluated, nor created by TYPO3 Core anymore, +and remaining move placeholders are removed with an Upgrade Wizard. + +The TCA setting :php:`$TCA[$table][ctrl][shadowColumnsForMovePlaceholders]` +is not evaluated anymore and removed at TCA building-time. + +The main benefits of this change: + +* fewer database queries when fetching records within a workspace +* more consistent handling with versioned records +* less complexity within TYPO3's internal API +* fewer database records when working with TYPO3's Workspaces feature + + +Impact +====== + +When querying database records in a workspace, all Move Pointer records +are now fetched directly instead of the Move Placeholder records. +This is all done with the existing API methods in :php:`PageRepository`, :php:`BackendUtility` +and the Doctrine DBAL Workspace Restriction. + +When moving a record in a workspace, Move Placeholders are not created anymore, +making them obsolete, as all information is now stored in the Move Pointer. + +The constant :php:`VersionState::MOVE_PLACEHOLDER` is obsolete. + +Lots of internal functionality regarding move placeholders has been removed. + +The ctrl section :php:`$TCA[$table][ctrl][shadowColumnsForMovePlaceholders]` is automatically removed +from any table with a deprecation notice. + +The database field :sql:`t3ver_move_id` is obsolete and not created +automatically for workspace enabled tables anymore. + + +Affected Installations +====================== + +Any TYPO3 installation using Workspaces which also hooks into the +workspaces-internal process via third-party extensions. + +Any TYPO3 extension not using the Doctrine DBAL restrictions for handling +Workspaces. + + +Migration +========= + +Run the upgrade wizard to remove any obsolete Move Placeholder records. + +Use the TYPO3 API to read and write data from the database, including the +WorkspaceRestriction and the Versioning Overlay methods. + +Remove the setting :php:`$TCA[$table][ctrl][shadowColumnsForMovePlaceholders]` +which is not evaluated anymore to avoid deprecation notices. + +The database analyzer suggests the removal of database field :sql:`t3ver_move_id` +for various tables. The field can be safely dropped after the upgrade wizard +has been executed. + +.. index:: Database, PHP-API, TCA, FullyScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-92499-AdminPanelDoesNotPreviewHiddenFrontendUserGroups.rst b/Documentation/Changelog/11.0/Breaking-92499-AdminPanelDoesNotPreviewHiddenFrontendUserGroups.rst new file mode 100644 index 0000000..3ef607e --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92499-AdminPanelDoesNotPreviewHiddenFrontendUserGroups.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92499: + +========================================================================== +Breaking: #92499 - AdminPanel does not preview hidden Frontend User Groups +========================================================================== + +See :issue:`92499` + +Description +=========== + +Admin Panel previously allowed to also render a page with frontend groups that +were hidden / disabled. This feature has been removed, +in order to ensure consistency for the authentication process. + +The property :php:`AbstractUserAuthentication::showHiddenRecords` which +was used to transfer this information is removed. + + +Impact +====== + +The Admin Panel selector now only shows a list of active groups +to simulate from. + +Using the removed PHP property :php:`AbstractUserAuthentication::showHiddenRecords` will result +in a PHP notice. + + +Affected Installations +====================== + +TYPO3 installations with Admin Panel activated and Frontend Groups +that are disabled. + + +Migration +========= + +It is recommended to include groups where no user is assigned to +for simulation purposes, if this feature is needed to preview +content. + +.. index:: Frontend, ext:adminpanel, FullyScanned diff --git a/Documentation/Changelog/11.0/Breaking-92502-MakeExtbaseHandlePSR7ResponsesOnly.rst b/Documentation/Changelog/11.0/Breaking-92502-MakeExtbaseHandlePSR7ResponsesOnly.rst new file mode 100644 index 0000000..36a74eb --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92502-MakeExtbaseHandlePSR7ResponsesOnly.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92502: + +=========================================================== +Breaking: #92502 - Make Extbase handle PSR-7 responses only +=========================================================== + +See :issue:`92502` + +Description +=========== + +Extbase does no longer handle/return extbase responses whose api was defined by the +interface :php:`TYPO3\CMS\Extbase\Mvc\ResponseInterface`. Instead, Extbase does create a `PSR-7` +compatible response object (see :php:`Psr\Http\Message\ResponseInterface`) and passes +it back through the request handling stack. + +Since `PSR-7` requires response objects to be immutable, it no longer makes sense to expose the response object +to the user via :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$response` +and :php:`TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext->getResponse()`. + + +The following interface has been removed and is no longer usable: + +- :php:`TYPO3\CMS\Extbase\Mvc\ResponseInterface` + +The following class has been removed and is no longer usable: + +- :php:`TYPO3\CMS\Extbase\Mvc\Response` + + +Impact +====== + +Since interface :php:`TYPO3\CMS\Extbase\Mvc\ResponseInterface` and class :php:`TYPO3\CMS\Extbase\Mvc\Response` +have been removed, they can no longer be used. + +Affected Installations +====================== + +All installations that: + +* declared classes that implemented the interface :php:`TYPO3\CMS\Extbase\Mvc\ResponseInterface` +* instantiated or extended class :php:`TYPO3\CMS\Extbase\Mvc\Response` +* accessed the request object through :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$response` or :php:`TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext->getResponse()` + +Migration +========= + +To regain full control over the response object, a PSR-7 compatible response object SHOULD be created in the +controller action and returned instead of returning a string or void. + +Example: + +.. code-block:: php + + public function listAction() + { + // do your action stuff + return $this->htmlResponse(); + } + +.. note:: + + If no argument is given to :php:`$this->htmlResponse()`, the current view + is automatically rendered, and applied as content for the PSR-7 Response. + For more information about this topic, please refer to the corresponding + :doc:`changelog <../11.0/Deprecation-92784-ExtbaseControllerActionsMustReturnResponseInterface>`. + +Further: Method :php:`TYPO3\CMS\Extbase\Mvc\Response::addAdditionalHeaderData()` +had been used to add additional header data such as css or js to the global TypoScriptFrontendController. +This has to be done via :php:`TYPO3\CMS\Core\Page\AssetCollector` now. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-92513-MethodSignatureChangeOfTYPO3CMSExtbaseMvcControllerControllerInterfaceprocessRequest.rst b/Documentation/Changelog/11.0/Breaking-92513-MethodSignatureChangeOfTYPO3CMSExtbaseMvcControllerControllerInterfaceprocessRequest.rst new file mode 100644 index 0000000..79b84ec --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92513-MethodSignatureChangeOfTYPO3CMSExtbaseMvcControllerControllerInterfaceprocessRequest.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92513: + +======================================================================================================================= +Breaking: #92513 - Method signature change of TYPO3\\CMS\\Extbase\\Mvc\\Controller\\ControllerInterface::processRequest +======================================================================================================================= + +See :issue:`92513` + +Description +=========== + +The signature of method :php:`TYPO3\CMS\Extbase\Mvc\Controller\ControllerInterface::processRequest` +changed in the regard that no longer :php:`$request` and :php:`$response` are passed into it. +Instead, only a :php:`$request` argument is needed. Additionally, that method now requires to return a response. + + +Impact +====== + +This change affects all classes that either implement said interface directly (presumably none) +and those classes (controllers) that override method :php:`processRequest()` +of class :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController`. Those, that override said method +will experience the following fatal error: + +`Declaration of ... must be compatible with TYPO3\CMS\Extbase\Mvc\Controller\ControllerInterface::processRequest(TYPO3\CMS\Extbase\Mvc\RequestInterface $request): TYPO3\CMS\Extbase\Mvc\ResponseInterface`. + + +Affected Installations +====================== + +All installations that override method :php:`processRequest()` of class :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController`. + + +Migration +========= + +There are two steps to migrate: + +- Remove the now superfluous :php:`$response` argument +- Return a response object. + +The latter is usually achieved by calling :php:`return parent::processRequest($request)` instead of just :php:`parent::processRequest($request)`. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-92529-AllFluidWidgetFunctionalityRemoved.rst b/Documentation/Changelog/11.0/Breaking-92529-AllFluidWidgetFunctionalityRemoved.rst new file mode 100644 index 0000000..6822cfc --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92529-AllFluidWidgetFunctionalityRemoved.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92529: + +========================================================= +Breaking: #92529 - All Fluid widget functionality removed +========================================================= + +See :issue:`92529` + +Description +=========== + +First things first: All fluid widgets and all widget functionality have been removed! + +The most important issue of fluid widgets is, that they initiate sub requests to a +controller from inside the view and output their content. Those sub requests bring +another layer of complexity to the TYPO3 world that is impossible to handle properly +as many bug reports show. TYPO3 already uses some kind of namespacing for url query +arguments to separate arguments for different plugins on a single page. Widgets +introduced another layer which made it necessary to yet again introduce namespacing +in the existing namespace. + +To make fluid widgets work, they need to work on the request object of the parent +plugin, i.e. the one that renders the view which holds the widget. This is a problem +regarding our efforts using PSR-7 request objects in Extbase which are immutable +by definition. + +A special kind of widget is the ajax widget which introduced even more complexity. +One example was the already removed auto complete widget. The widget could be used +to fetch values from the database for autocompletion while entering a textfield in +a form. In order to perform that kind of magic, fluid came with a new page type (7076) +for handling incoming ajax requests. Since that endpoint didn't know about the specifics +of the widget that should be rendered, the widget context had to be serialized before +the ajax request, bound to the user with a unique id and stored in the users session +data just to be unserialized moments later to have a back reference to initiating request. + +The fluid widgets violated the design pattern "separation of concern" to a degree +that they caused more trouble than benefit. Therefore, fluid widgets have been +removed from TYPO3 core. + +Impact +====== + +- All fluid templates that used an existing widget will no longer work as expected. +- Also, all custom widgets of users will no longer work and have to be replaced + with custom solutions. + + +Affected Installations +====================== + +All installations that either used widgets defined by the core or those installations +that created own widgets. + + +Migration +========= + +There is no simple migration strategy for all widgets but the most common +functionality (pagination) can be solved with a new pagination core api. The main +difference compared to a widget is that the pagination has to be initialized +in the controller action and not in the view. + +For all other widgets, custom solutions have to be found. + +.. index:: Fluid, PHP-API, NotScanned, ext:fluid diff --git a/Documentation/Changelog/11.0/Breaking-92532-SupportForExtension-in-extensionInstallationInExtensionManagerRemoved.rst b/Documentation/Changelog/11.0/Breaking-92532-SupportForExtension-in-extensionInstallationInExtensionManagerRemoved.rst new file mode 100644 index 0000000..8ef2a17 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92532-SupportForExtension-in-extensionInstallationInExtensionManagerRemoved.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92532: + +=============================================================================================== +Breaking: #92532 - Support for extension-in-extension installation in Extension Manager removed +=============================================================================================== + +See :issue:`92532` + +Description +=========== + +The installation process within the Extension Manager allowed extensions to be +installed having custom dependencies to other extensions in +:file:`EXT:my_extension/Initialisation/Extensions/third_party_ext`. + +This feature was originally introduced for the Introduction Package, +which had a few more dependencies until TYPO3 v9. + +As this (undocumented) feature was not used in public for any other extensions, +and since Extension Manager can fetch dependencies from TER directly as well, +this feature is removed. + + +Impact +====== + +If an extension is installed which contains other extensions as +dependencies in :file:`Initialisation/Extensions/*` they are now ignored +on installation, and instead looked up in the remote TYPO3 Extension Repository, +as with any other depending extension. + + +Affected Installations +====================== + +TYPO3 extensions using this dependency management as "Extension-in-Extension" +functionality. + + +Migration +========= + +Upload the proper extension into https://extensions.typo3.org and remove +the folder :file:`Initialisation/Extensions` from any custom extensions. + +.. index:: PHP-API, FullyScanned, ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Breaking-92558-DatabaseFieldBe_userscreatedByActionRemoved.rst b/Documentation/Changelog/11.0/Breaking-92558-DatabaseFieldBe_userscreatedByActionRemoved.rst new file mode 100644 index 0000000..4b3311c --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92558-DatabaseFieldBe_userscreatedByActionRemoved.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92558: + +================================================================== +Breaking: #92558 - Database Field be_users.createdByAction removed +================================================================== + +See :issue:`92558` + +Description +=========== + +The database field :sql:`be_users.createdByAction` which was used +as a type of history for the extracted `sys_action` extension, +has been removed from TYPO3 Core. + + +Impact +====== + +Accessing or writing to this database field directly will result +in a SQL error. + + +Affected Installations +====================== + +TYPO3 installations using this database field directly. + + +Migration +========= + +Re-add this field manually if needed, otherwise it is recommended +to put such information in the History functionality of TYPO3 Core. + +.. index:: Database, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92559-RemovedPer-userIPLockingForBackendUsers.rst b/Documentation/Changelog/11.0/Breaking-92559-RemovedPer-userIPLockingForBackendUsers.rst new file mode 100644 index 0000000..f61f327 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92559-RemovedPer-userIPLockingForBackendUsers.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92559: + +================================================================ +Breaking: #92559 - Removed per-user IP locking for backend users +================================================================ + +See :issue:`92559` + +Description +=========== + +TYPO3 has installation-wide options to allow so-called "IP Locking" +for Frontend User Sessions and Backend User Sessions ("lockIP"). + +Since TYPO3 v10, this feature is disabled by default, as some ISPs +allow for so-called Happy Eyeballs [https://en.wikipedia.org/wiki/Happy_Eyeballs] +to switch between IPv4 and IPv6, where a fixed IP Address per user session +cannot be guaranteed and is not proven as a useful measure for locking +a session anymore. + +TYPO3 Core however had another specific BE-user feature, *if* the IP locking +features enabled for Backend users, it could be again *disabled* +for a specific user. This was previously built as a workaround +for users who did not have a specific IP address. This specific +feature, disabling IP locking for a specific Backend user, has +been removed as it lacks comprehensible use cases in the current +internet world, especially nowadays where home office and constantly +changing IP addresses are normal. + +Impact +====== + +The additional checkbox when editing a backend user is removed, +including its Database field :sql:`be_users.disableIPlock` and its TCA +definition. + +Accessing the field via a direct database request will result in a +SQL error. Accessing the TCA information will trigger a PHP notice. + +If the system-wide setting is activated for backend users, it will apply +to any Backend user regardless of custom settings. + +Affected Installations +====================== + +TYPO3 installations which use the IP locking mechanism for Backend +users (see :php:`$TYPO3_CONF_VARS[BE][lockIP]` and +:php:`$TYPO3_CONF_VARS[BE][lockIPv6]`) but explicitly deactivate +it for a specific backend user, which is highly unlikely. + +The latter can be identified via a SQL query: + +:sql:`SELECT count(uid) AS amount FROM be_users WHERE deleted=0 AND disableIPlock=1`. + + +Migration +========= + +It is possible that this option was set by accident from administrators. +If not and some IP locking problems exist for certain backend users, it is +recommended to either remove the IP locking of backend users completely +via the Settings module (set system-wide options "lockIP" and "lockIPv6" to "0") +or add the functionality for your specific use case as custom extension, e.g. by +hooking into the authentication process and using the +:php:`\TYPO3\CMS\Core\Authentication\IpLocker` API. + +.. index:: Backend, Database, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92560-BackendEditorsCanAlwaysDeletePagesRecursive.rst b/Documentation/Changelog/11.0/Breaking-92560-BackendEditorsCanAlwaysDeletePagesRecursive.rst new file mode 100644 index 0000000..862bb6b --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92560-BackendEditorsCanAlwaysDeletePagesRecursive.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92560: + +==================================================================== +Breaking: #92560 - Backend editors can always delete pages recursive +==================================================================== + +See :issue:`92560` + +Description +=========== + +The feature to deny editors from deleting pages that have sub pages has been +removed. This has been an optional setting on a per-user basis and is now not +only enabled by default but the restriction has been fully removed. + + +Impact +====== + +Editors can always delete full page trees. + + +Affected Installations +====================== + +All instances are affected. + + +Migration +========= + +In case an editor deletes an entire tree by accident, administrators can and +should use the recycler extension to resurrect page trees. + +Additionally, administrators can and should set access rights of important key +pages to disallow editors from deleting them. More complex use cases can be +handled with a dedicated DataHandler hook. + +Another good solution is to configure and restrict users to a workspace to +implement a sophisticated review process for pending live content changes. + +On PHP level, the property :php:`DataHandler->deleteTree` has been dropped. +Setting this property will raise a PHP warning level error. Extensions may be +affected by this. The extension scanner will find usages with a weak match. + +Furthermore, on PHP level, the backend user uc setting :php:`uc['recursiveDelete']` +has been dropped and is of no use anymore within the TYPO3 core. + +Finally, the User TSconfig for setting a default value, overriding the value or +disabling the field, has also no effect anymore. Therefore, the following +settings within custom TSconfig should be removed: + +* :typoscript:`setup.default.recursiveDelete` +* :typoscript:`setup.override.recursiveDelete` +* :typoscript:`setup.fields.recursiveDelete.disabled` + +.. index:: Backend, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92582-ResizableTextAreaUserSettingDropped.rst b/Documentation/Changelog/11.0/Breaking-92582-ResizableTextAreaUserSettingDropped.rst new file mode 100644 index 0000000..1d568ea --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92582-ResizableTextAreaUserSettingDropped.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92582: + +=========================================================== +Breaking: #92582 - Resizable text area user setting dropped +=========================================================== + +See :issue:`92582` + +Description +=========== + +The user setting "Make text areas flexible" has been dropped and is +no longer available for editors. + +When editing records in the backend, text areas now always grow in height up to +the maximum height defined by the 'maximum text area height' in user settings. + + +Impact +====== + +The backend is a little less restricted for editors. + + +Affected Installations +====================== + +All instances are affected. + + +Migration +========= + +The option has been removed, there is no migration path. + +The following User TSconfig settings are obsolete and should be removed: + +* :typoscript:`setup.default.resizeTextareas_Flexible` +* :typoscript:`setup.override.resizeTextareas_Flexible` +* :typoscript:`setup.fields.resizeTextareas_Flexible.disabled` + +.. index:: Backend, TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92590-RemovedSupportForExtensionUploadOfT3xFiles.rst b/Documentation/Changelog/11.0/Breaking-92590-RemovedSupportForExtensionUploadOfT3xFiles.rst new file mode 100644 index 0000000..ad1e47c --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92590-RemovedSupportForExtensionUploadOfT3xFiles.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92590: + +==================================================================== +Breaking: #92590 - Removed support for extension upload of t3x files +==================================================================== + +See :issue:`92590` + +Description +=========== + +With the inception of the concept of Extensions, the Extension +Manager in TYPO3 and the TYPO3 Extension Repository (TER) on +https://extensions.typo3.org, the file format `t3x` ("TYPO3 eXtension") +was created. + +The proprietary format was introduced because the lack of support +for zip handling in PHP4 in 2004. However, the format was proven +to be cumbersome for developers and zip was bundled with most PHP5 versions. + +For this reason, the TYPO3 Ecosystem started to support extensions as regular +`zip` archives during TYPO3 v6 development. + +The zip format for extension downloading and uploading was used more and more +in favor of the `t3x` data format, so today the TER only offers the download of +`zip` files via the Web GUI. + +However, TYPO3's Extension Manager still supported uploading +`.t3x` files even though files were not created by the Extension Manager +anymore since TYPO3 v6 - downloading an extension via the Extension Manager only +created an archive of the `.zip` format of the extension. + +The feature of uploading files with a `t3x` format (identified by the +file extension `.t3x`) has been removed. + +Both TER and the Extension Manager for downloading extensions still support `t3x` +under the hood for legacy reasons, but this is not exposed to end-users, +integrators or developers anymore. + + +Impact +====== + +Uploading a `t3x`-based extension file in the Extension Manager will result in +an error message. + + +Affected Installations +====================== + +TYPO3 installations where administrators still handle `t3x` files for uploading +extensions, which is highly unlikely and only applies for +TYPO3 installations not installed via Composer. + + +Migration +========= + +When using a public extension, it is recommended to download the +`zip` variant from https://extensions.typo3.org. + +When a `.t3x` file is provided by a third party, it is possible to upload the +extension in the Extension Manager of an older TYPO3 Core version +(e.g. TYPO3 v10), and then download the extension there as a `.zip` file. + +.. index:: Backend, NotScanned, ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Breaking-92598-Workspace-overlaysAuto-fixThePIDValueForMovedRecords.rst b/Documentation/Changelog/11.0/Breaking-92598-Workspace-overlaysAuto-fixThePIDValueForMovedRecords.rst new file mode 100644 index 0000000..8ad0cdc --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92598-Workspace-overlaysAuto-fixThePIDValueForMovedRecords.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92598: + +============================================================================== +Breaking: #92598 - Workspace-overlays auto-fix the PID value for moved records +============================================================================== + +See :issue:`92598` + +Description +=========== + +When handling versioned records while reading data from the database, +the common behavior is to apply a "workspace overlay". When using the TYPO3 API +in both classes like: + +* :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository->getRecord()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getRecordWSOL()` + +the currently live records are fetched from the database and being overlayed with +possible versioned record in a specific workspace by replacing all selected fields +with the versioned values. + +However, the fields "uid" and "pid" of the live record were kept, and the values +of the versioned record were stored in "_ORIG_uid" and "_ORIG_pid" +when a record was successfully overlaid. + +This was necessary in the past, because the versioned records did not contain +a meaningful "pid" value ("pid=-1") so in order to keep a useful value, +the live value was kept. + +The meaning of "_ORIG_pid" has now changed: + +* All versioned records contain the same "pid" as the live record, so the + "_ORIG_pid" value is not needed anymore. +* However, when a record is moved to another page in a workspace, the PID changes. + Handling this case is drastically simpler now. In order to work with the + modified data in moved versions, the "pid" field now contains the value of + the new page in a workspace, and the "_ORIG_pid" field contains + the value of the live record's "pid" field. + This behavior is now streamlined with what :php:`fixVersioningPid()` was doing. + Therefore :php:`fixVersioningPid()` has been marked as deprecated. + +Impact +====== + +When using workspaces and the API methods, the "_ORIG_pid" field is only set +for moved records where a workspace overlay has been properly applied. + +The "pid" field now always contains the actual pid of a versioned record in +the workspace, where as the "_ORIG_pid" contains the live record pid value. + +In other words: if a moved record has been overlaid, the "_ORIG_pid" and "pid" field values +are now switched. + +Affected Installations +====================== + +TYPO3 installations with custom code regarding workspaces that dealt with +the value "_ORIG_pid" for resolving moved records in a workspace. + +Migration +========= + +The change helps to reduce the complexity when dealing with workspace overlays, +so existing PHP code probably does not need to check for "_ORIG_pid" anymore, +and extension developers can just safely use the overlay methods and directly +use the "pid" field, knowing that the "pid" field contains the value of the +record within the workspace. + +.. index:: PHP-API, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-92609-UseControllerClassesWhenRegisteringPluginsmodules.rst b/Documentation/Changelog/11.0/Breaking-92609-UseControllerClassesWhenRegisteringPluginsmodules.rst new file mode 100644 index 0000000..ced34f1 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92609-UseControllerClassesWhenRegisteringPluginsmodules.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92609: + +========================================================================== +Breaking: #92609 - Use controller classes when registering plugins/modules +========================================================================== + +See :issue:`92609` + +Description +=========== + +Configuring plugins and modules via the following methods has changed in two important ways. + +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin` +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule` + +Both methods expect to be provided with the arguments :php:`$extensionName` and :php:`$controllerActions`. +:php:`configurePlugin` also allows the argument :php:`$nonCacheableControllerActions`. + +The first important change targets the :php:`$extensionName` argument. +During the switch from underscore class names :php:`Tx_Extbase_Foo_Bar` to actual namespaced classes +:php:`TYPO3\CMS\Extbase\Foo\Bar`, a vendor `TYPO3\CMS` has been introduced which had to be respected +during the configuration of plugins. To make that possible the argument :php:`$extensionName` has been +prepended with the vendor name, concatenated with dots. + +Before: + +.. code-block:: php + + <?php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'TYPO3.CMS.Form', // $extensionName + 'Formframework', + ['FormFrontend' => 'render, perform'], + ['FormFrontend' => 'perform'], + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + +Setting the vendor name has been marked as deprecated and must be omitted. Instead, the vendor name will be derived +from the controller class namespace, which leads to the second important change. + +Both arguments :php:`$controllerActions` and :php:`$nonCacheableControllerActions` used controller aliases as +array keys. The alias was the controller class name without the namespace and without the :php:`Controller` +suffix. There were a lot of conventions and a custom autoloader mechanism before the introduction +of the composer autoloader, which made it necessary to put controllers in a specific directory and to name +the controller accordingly. As this is no longer the case, there is no need to guess the controller class name +any longer. Instead, the configuration/registration is now done with fully qualified controller class names. + +After: + +.. code-block:: php + + <?php + + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( + 'Form', + 'Formframework', + [\TYPO3\CMS\Form\Controller\FormFrontendController::class => 'render, perform'], + [\TYPO3\CMS\Form\Controller\FormFrontendController::class => 'perform'], + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + + +Impact +====== + +Using non fully qualified class names during plugin/module registration will lead to malfunctioning plugins/modules at best. +Probably an Exception will be thrown or a fatal error occurs during plugin/module dispatching. + + +Affected Installations +====================== + +All installations that use these methods: + +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin()` +* :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule()` + + +Migration +========= + +* Omit the vendor name in argument :php:`$extensionName` +* Use fully qualified class names as array keys in arguments :php:`$controllerActions` and :php:`$nonCacheableControllerActions` + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-92678-CssClassCheckboxInvertRemoved.rst b/Documentation/Changelog/11.0/Breaking-92678-CssClassCheckboxInvertRemoved.rst new file mode 100644 index 0000000..5f1ea6e --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92678-CssClassCheckboxInvertRemoved.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92678: + +==================================================== +Breaking: #92678 - CSS class checkbox-invert removed +==================================================== + +See :issue:`92678` + +Description +=========== + +FormEngine used to have a class `checkbox-invert` for the styling +of an item with enabled flag `invertStateDisplay`. Now the checkbox value +itself is inverted. Therefore the class has been removed as it is not needed +any more. + + +Impact +====== + +Using the class doesn't have any effect on styling anymore. + + +Affected Installations +====================== + +Standard installations of TYPO3 are not affected. Only installations that +use the class `checkbox-invert` for customizations are affected. + + +Migration +========= + +There is no migration required if only the invertStateDisplay configuration +is used. If CSS styling or JavaScript in the backend relies on the +class `checkbox-invert` present custom code needs to be added to make it +available again. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92693-RemoveLinkHandlerLinktypeInLinkvalidator.rst b/Documentation/Changelog/11.0/Breaking-92693-RemoveLinkHandlerLinktypeInLinkvalidator.rst new file mode 100644 index 0000000..3462cc6 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92693-RemoveLinkHandlerLinktypeInLinkvalidator.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92693: + +=============================================================== +Breaking: #92693 - Remove LinkHandler Linktype in Linkvalidator +=============================================================== + +See :issue:`92693` + +Description +=========== + +Linkvalidator ships with several link type classes that are used to check +specific links such as ExternalLinktype, Filelinktype etc. + +The link type LinkHandler is no longer used by default (see Page TSconfig +:typoscript:`mod.linkvalidator.linktypes`). It was used to check links of the extension +"linkhandler" which is now outdated. The latest version supports TYPO3 4.1.0. + +LinkHandler functionality was integrated into the core in TYPO3 8, but the +format of the links has changed since then. + +The LinkHandler link type expects links which start with "record:" - +a syntax that is now outdated. + +Links to records are successfully checked in the InternalLinktype class. + +Impact +====== + +It is no longer possible to use the "linkhandler" link type. Setting this +in the configuration will not have any effect. + + +Affected Installations +====================== + +There should be no affected installations as the linkhandler extension and +the corresponding format of the links has long been outdated. + +Migration +========= + +Normally, no migration is necessary. + +You should remove the linkhandler link type from the page TSconfig configuration: + +.. code-block:: diff + + - :typoscript:`mod.linkvalidator.linktypes = db,file,external,linkhandler` + + :typoscript:`mod.linkvalidator.linktypes = db,file,external` + +You should no longer use :typoscript:`linkhandler.reportHiddenRecords = 0`. + +.. code-block:: diff + + - :typoscript:`mod.linkvalidator.linkhandler.reportHiddenRecords = 0` + +.. index:: Backend, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/11.0/Breaking-92791-NewPlaceholderRecordsRemovedInWorkspaces.rst b/Documentation/Changelog/11.0/Breaking-92791-NewPlaceholderRecordsRemovedInWorkspaces.rst new file mode 100644 index 0000000..10133c2 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92791-NewPlaceholderRecordsRemovedInWorkspaces.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92791: + +================================================================== +Breaking: #92791 - "New Placeholder" records removed in Workspaces +================================================================== + +See :issue:`92791` + +Description +=========== + +When creating a new record in a workspace, TYPO3 created two database +entries: A "new placeholder" which served as a pseudo-live pendant with +no content but only the target PID value to be added, and a "versioned +record" which – until TYPO3 v10 – had the PID value "-1". On publishing +the contents of both records were exchanged (except the PID) and the +versioned record was removed. + +Since TYPO3 v10 the "new placeholder" had little information to be kept +alive. Only on publishing, the behaviour was simple, as the publishing process +as described above worked the same way as for other "versioned records" like +modifying a live record, or moving records. + +Apart from having two database records created where only one contained +user input, there were some conceptual drawbacks with having a placeholder record: +Sorting, and type fields had to be configured via a special +:php:`$TCA[$table][ctrl][shadowColumnsForNewPlaceholders]` TCA option, which wasn't kept in sync when +modifying the versioned record. + +Both record types were identified in the database as the following: + +New Placeholder Record +********************** + +* t3ver_state => 1 - identifying as "new placeholder" +* t3ver_wsid => the ID of the workspace it was created +* t3ver_oid => 0 - as it should behave as the "online version" +* pid => the PID where the record should be published in (same with "sorting", when set) + +New Versioned Record +******************** + +* t3ver_state => -1 - identifying as "new record created in workspace" +* t3ver_wsid => the ID of the workspace it was created +* t3ver_oid => ID of the New Placeholder Record +* pid => the PID where the record should be published in (same with "sorting", when set) + +The placeholder record was queried when reading the database while in a workspace +with other live records. It was then overlaid by the versioned record. + +TYPO3 v11 does not create placeholder records anymore, but instead creates +one record containing all information. When fetching records +from the database, the new versioned records are added directly, so no overlays +need to happen anymore, which speeds up performance when querying the +database via the TYPO3 Database via API classes such as :php:`PageRepository` or +:php:`BackendUtility`. + +Impact +====== + +No records with :sql:`t3ver_state=-1` are found in the TYPO3 installation anymore. + +When using the Doctrine DBAL API with Workspace Restrictions within a workspace, +the new versions are included in the SQL query result. + +DataHandler will not create placeholders anymore, making the TCA option +:php:`$TCA[$table][ctrl][shadowColumnsForNewPlaceholders]` obsolete. + +Using methods like :php:`getWorkspaceVersionOfRecord` on a new versioned record +will return the same record again, as there is no "workspace version" of this +record anymore. + +The CLI command `cleanup:versions` is adapted as the option +`--action=unused_placeholders` is removed. + +In addition, records overlaid via the TYPO3 API classes that have been +newly created in a workspace do not carry the :sql:`ORIG_uid` information anymore +which keeps the UID of the versioned record. + +Affected Installations +====================== + +TYPO3 installations using Workspaces with newly created records that haven't +been published yet, or with third-party extensions directly querying, resolving +or writing based on :sql:`t3ver_state` database fields, which is very uncommon. + +Migration +========= + +An upgrade wizard is used to migrate possibly left-over "placeholder" records +within the database. This is only needed when workspaces are in use and there +are records in the database that are newly created and have not been +published. + +A TCA migration will automatically remove and log any usages of the TCA option +:php:`$TCA[$table][ctrl][shadowColumnsForNewPlaceholders]`. + +It is highly recommend to use the TYPO3 API methods within Extbase, :php:`PageRepository` +and :php:`BackendUtility` to ensure records are resolved properly. + +At any times, it is recommended to use the :php:`WorkspaceRestriction` of TYPO3's +implementation of Doctrine DBAL in conjunction with Workspace overlays. + +.. index:: Database, FullyScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst b/Documentation/Changelog/11.0/Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst new file mode 100644 index 0000000..dc494b5 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92801: + +======================================================================================= +Breaking: #92801 - Removed "Failed Login" functionality from User Authentication object +======================================================================================= + +See :issue:`92801` + +Description +=========== + +The functionality to send an email to a defined sender was previously hard-coded +into the API class :php:`AbstractUserAuthentication` and activated specifically for +Backend Users via the option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr']`. + +With some custom implementation it was also possible to use a hook to +enable this for frontend users, but the API was not clean. + +The backend-user specific logic is now extracted into a hook, so it is possible +to replace this functionality with a custom notification API. + +For this reason, the following public properties and methods within +:php:`AbstractUserAuthentication` and its subclasses have been removed: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningEmail` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningPeriod` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningMax` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->checkLogFailures()` + +Impact +====== + +Using one of the public properties in custom PHP will trigger a PHP Warning. + +Calling the public PHP method will result in a fatal PHP error. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions and custom PHP code that is +related to failed login notifications, and rely on the existing login +notification code. + + +Migration +========= + +As the properties were public, they made it possible to override the +warningMax / warningPeriod values via hooks and middlewares in PHP. + +Instead it is recommended to override this functionality via a hook the same way +the new hook in EXT:backend is registered within PHP. + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92802-DatabaseBasedAuthenticationTimeoutFieldRemoved.rst b/Documentation/Changelog/11.0/Breaking-92802-DatabaseBasedAuthenticationTimeoutFieldRemoved.rst new file mode 100644 index 0000000..a762f58 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92802-DatabaseBasedAuthenticationTimeoutFieldRemoved.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92802: + +=========================================================================== +Breaking: #92802 - User-database-based authentication timeout field removed +=========================================================================== + +See :issue:`92802` + +Description +=========== + +The :php:`AbstractUserAuthentication` object had the possibility to +theoretically use a database field where a session timeout value +for the session storage could be set. This was never implemented but +rather separated into a separate property called :php:`sessionTimeout`. + +This functionality, together with the public property +:php:`auth_timeout_field`, has been removed. + + +Impact +====== + +Setting the property via a custom extension will result in a PHP warning, as +the property does not exist anymore. + +In addition, this property is never evaluated anymore when determining the +session timeout. + + +Affected Installations +====================== + +TYPO3 installations that used third-party code to modify the session timeout +value based on a database field, which relied on the public property for +implementation purposes. + + +Migration +========= + +Use a custom implementation with custom hooks or custom authentication provider +to achieve the same results. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92807-RemovedFeatureForKeepingSessionDataOnFrontendUserLogout.rst b/Documentation/Changelog/11.0/Breaking-92807-RemovedFeatureForKeepingSessionDataOnFrontendUserLogout.rst new file mode 100644 index 0000000..4a7e0c2 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92807-RemovedFeatureForKeepingSessionDataOnFrontendUserLogout.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92807: + +=================================================================================== +Breaking: #92807 - Removed feature for keeping session data on frontend user logout +=================================================================================== + +See :issue:`92807` + +Description +=========== + +When a frontend user logged out, the session data was kept +and transferred to an anonymous session when the feature +flag :php:`security.frontend.keepSessionDataOnLogout` was enabled. + +Since this functionality is insecure, and was only introduced +to keep backwards-compatibility in a security release, the feature +has been removed completely. + + +Impact +====== + +When logging out as a frontend user, all session data is now +actively removed and not kept as a new anonymous session. + + +Affected Installations +====================== + +TYPO3 installations having this feature enabled and actively +using this feature, e.g. in cart functionality. + + +Migration +========= + +It is recommended to build the web application in a way that +the session data is not needed, and instead a frontend user +should know that their session data is lost upon log out. + +Make sure to bind user-specific data either to the +frontend user itself, or re-implement this functionality +yourself by using a :php:`logoff()` hook for transferring sessions +to anonymous sessions. + +.. index:: Frontend, PHP-API, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-92837-RemoveSettingModweb_layoutdisableAdvanced.rst b/Documentation/Changelog/11.0/Breaking-92837-RemoveSettingModweb_layoutdisableAdvanced.rst new file mode 100644 index 0000000..9c9cbad --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92837-RemoveSettingModweb_layoutdisableAdvanced.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92837: + +================================================================= +Breaking: #92837 - Removed setting mod.web_layout.disableAdvanced +================================================================= + +See :issue:`92837` + +Description +=========== + +The TSconfig setting :typoscript:`mod.web_layout.disableAdvanced` has been used to disable the +"clear cache"-button in the page module. + +Since this behaviour can be triggered through various other ways like the context menu or +by just saving the page record, this feature has been removed completely. + + +Impact +====== + +The setting :typoscript:`mod.web_layout.disableAdvanced` is not evaluated anymore and the "clear cache"-button +is always shown. + + +Affected Installations +====================== + +TYPO3 installations using the setting :typoscript:`mod.web_layout.disableAdvanced`. + + +Migration +========= + +There is no migration possible. + +.. index:: Backend, TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92838-AdditionalWorkspaceServicesDropped.rst b/Documentation/Changelog/11.0/Breaking-92838-AdditionalWorkspaceServicesDropped.rst new file mode 100644 index 0000000..6abbb38 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92838-AdditionalWorkspaceServicesDropped.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92838: + +======================================================== +Breaking: #92838 - Additional workspace services dropped +======================================================== + +See :issue:`92838` + +Description +=========== + +Back in the ExtJS era, the workspace backend module had two PHP classes designed +for extensions to add additional columns and JavaScript handling to the module. +With the transition to a native JavaScript implementation of the workspace module +in TYPO3 v8, this stopped working. The related PHP classes have now been removed. + + +Impact +====== + +There was one specific customer this feature has been implemented for. It does +not use it anymore. Considering the fact the feature has been broken since years, +there should be little to no impact for any instance. + +The following classes and interfaces have been removed: + +* :php:`TYPO3\CMS\Workspaces\ColumnDataProviderInterface` +* :php:`TYPO3\CMS\Workspaces\Service\AdditionalColumnService` +* :php:`TYPO3\CMS\Workspaces\Service\AdditionalResourceService` + + +Affected Installations +====================== + +Instances with extensions using above classes or interfaces. The extension +scanner will find usages with a strong match. + + +Migration +========= + +No migration available. + +.. index:: Backend, JavaScript, PHP-API, FullyScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Breaking-92853-MethodCanProcessRequestHasBeenRemoved.rst b/Documentation/Changelog/11.0/Breaking-92853-MethodCanProcessRequestHasBeenRemoved.rst new file mode 100644 index 0000000..4702d74 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92853-MethodCanProcessRequestHasBeenRemoved.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92853: + +============================================================ +Breaking: #92853 - Method canProcessRequest has been removed +============================================================ + +See :issue:`92853` + +Description +=========== + +Method :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->canProcessRequest()` +had been called to check if the currently passed in request could be handled by +the controller. This allowed to handle additional request types other than the +default one Extbase delivers. This would have only be useful if a user implemented +a request which didn't extend the Extbase request and therefore didn't necessarily +comply with its API. This however would have only been possible if the user +registered a custom request handler, violating the method signature of +:php:`TYPO3\CMS\Extbase\Mvc\RequestHandlerInterface->handleRequest()`. + +Back in 2012 this was an option to use Flow and Extbase interchangeably which was +never possible, and to allow Extbase Command Controllers via a CLI Request object, +which was removed in TYPO3 v10. + +To unify the request/response handling and making it PSR-7 compatible, this check +has simply been removed along with its exception +:php:`\TYPO3\CMS\Extbase\Mvc\Exception\UnsupportedRequestTypeException`. + +Impact +====== + +Actually very little if this feature had been used to have the controller handle +custom requests that extend the Extbase request. Custom requests with a different +api than the one needed by the framework will result in fatal errors. + + +Affected Installations +====================== + +All installations with Extbase controllers that have overridden property +:php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$supportedRequestTypes` +or method :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->canProcessRequest()` +and all installations which used :php:`\TYPO3\CMS\Extbase\Mvc\Exception\UnsupportedRequestTypeException` +in some way. + + +Migration +========= + +There isn't that one code migration path. If you intend to extend (XClass) the +request object to add further properties/methods, you can still do so and nothing +actually changes. If you violated the api, implemented custom request builders +and handlers that handled requests with a different api than the one needed by +the framework, you will encounter fatal errors eventually. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Breaking-92940-GlobalOptionLockBeUserToDBmountsRemoved.rst b/Documentation/Changelog/11.0/Breaking-92940-GlobalOptionLockBeUserToDBmountsRemoved.rst new file mode 100644 index 0000000..485df01 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92940-GlobalOptionLockBeUserToDBmountsRemoved.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92940: + +=============================================================== +Breaking: #92940 - Global option "lockBeUserToDBmounts" removed +=============================================================== + +See :issue:`92940` + +Description +=========== + +The system-wide setting :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['lockBeUserToDBmounts']` +which was active by default, was used to allow any non-administrator to access +all pages in a TYPO3 installation without considering "Web Mounts" / "DB Mounts" +regardless of their permissions. + +It was recommended to keep this setting turned on at any time due to several +security reasons. + +This setting itself breaks TYPO3's internal permission concept and was never +implemented in all relevant places of TYPO3. + +For this reason, the setting and all its usages are removed. + + +Impact +====== + +Activating or deactivating this option has no effect anymore as TYPO3 Core API +is working as this option was enabled at any time. + + +Affected Installations +====================== + +TYPO3 installations that have this option disabled in their system-wide +configuration in the :file:`LocalConfiguration.php` file. + + +Migration +========= + +None, as this feature was removed for security purposes, re-adding this feature +is not recommended. + +All usages in custom TYPO3 extensions can be removed. + +.. index:: Backend, LocalConfiguration, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92941-LockToIPUserTsConfigOptionRemoved.rst b/Documentation/Changelog/11.0/Breaking-92941-LockToIPUserTsConfigOptionRemoved.rst new file mode 100644 index 0000000..cfe2193 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92941-LockToIPUserTsConfigOptionRemoved.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92941: + +========================================================= +Breaking: #92941 - "lockToIP" UserTsConfig option removed +========================================================= + +See :issue:`92941` + +Description +=========== + +The UserTsConfig setting :typoscript:`options.lockToIP` which allowed Backend +users or usergroups to only be valid when the user was accessing +TYPO3 with a certain IP address / range list, is removed. + +Due to the IPv4/IPv6 dilemma "Happy Eyeballs" this feature only +has little use, and should be handled outside the Application instead, +but certainly not work on a per user/group basis. + +This option was only used when the global option +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['enabledBeUserIPLock']` was enabled, and +could be disabled as system-wide setting where the UserTsConfig setting was +never evaluated anymore. + +The global toggle was also removed, as it did not serve any other purposes. + +Side note: From a TYPO3-internal request workflow this feature was never part of +the authentication process, as this usually happened after a successful user +login or session activation had happened, overruling any previous Authentication +Services registered. This was due to some ancient architectural decisions +18 years ago when this feature was added. + + +Impact +====== + +When the UserTsConfig setting :typoscript:`options.lockToIP` is set, it will not be +evaluated anymore. + +When set, the global configuration flag will be automatically removed when the +Install Tool is accessed. + + +Affected Installations +====================== + +TYPO3 installations actively using this option in any UserTsConfig field or +file for Backend users or Backend user groups. + + +Migration +========= + +If this functionality is still needed (mostly in Intranets), this needs to be +implemented as a third-party authentication service to validate an authenticated +user or group to be added to the current user / groups or via a custom PSR-15 +middleware. + +.. index:: TSConfig, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-92989-AbstractUserAuthentication-loginFailureRemoved.rst b/Documentation/Changelog/11.0/Breaking-92989-AbstractUserAuthentication-loginFailureRemoved.rst new file mode 100644 index 0000000..f578fdd --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92989-AbstractUserAuthentication-loginFailureRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92989: + +=================================================================== +Breaking: #92989 - AbstractUserAuthentication->loginFailure removed +=================================================================== + +See :issue:`92989` + +Description +=========== + +The public PHP property :php:`loginFailure` of the PHP class :php:`AbstractUserAuthentication` has +been removed. This property stored information if a login attempt was made +but was not successful. + + +Impact +====== + +Accessing or setting the property from third-party code via PHP has no effect +anymore. + + +Affected Installations +====================== + +TYPO3 installations with custom code in PHP accessing or setting this property, +which is highly unlikely as this property only had limited use and the existing +hook is better suited for doing custom work. + + +Migration +========= + +If this information is needed, it is recommended to use the hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing']` +which allows to run custom PHP code if a login attempt has been made which was +not successful. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92990-AbstractUserAuthentication-svConfigRemoved.rst b/Documentation/Changelog/11.0/Breaking-92990-AbstractUserAuthentication-svConfigRemoved.rst new file mode 100644 index 0000000..062dc2d --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92990-AbstractUserAuthentication-svConfigRemoved.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92990: + +=============================================================== +Breaking: #92990 - AbstractUserAuthentication->svConfig removed +=============================================================== + +See :issue:`92990` + +Description +=========== + +The public property :php:`svConfig` of the PHP class :php:`AbstractUserAuthentication` is removed. + +It served as a short-hand for :php:`$GLOBALS['TYPO3_CONF_VARS']['SVCONF']['auth']`, which was common in TYPO3 v4 days, but is +useless nowadays. This property is removed in favor of a local +variable allowing for further refactoring of the Authentication +process in the future. + + +Impact +====== + +Accessing or setting the property has no effect anymore, +and will trigger a PHP warning. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions with PHP code accessing the property related to authentication, which is highly unlikely. + + +Migration +========= + +Manipulate the global array :php:`$GLOBALS['TYPO3_CONF_VARS']['SVCONF']['auth']` directly instead, +preferably in :file:`AdditionalConfiguration.php` or in an extensions :file:`ext_localconf.php` file. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-92993-GenericSearchStatisticsFromIndexedSearchRemoved.rst b/Documentation/Changelog/11.0/Breaking-92993-GenericSearchStatisticsFromIndexedSearchRemoved.rst new file mode 100644 index 0000000..cbc3048 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92993-GenericSearchStatisticsFromIndexedSearchRemoved.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92993: + +======================================================================== +Breaking: #92993 - Generic search statistics from indexed search removed +======================================================================== + +See :issue:`92993` + +Description +=========== + +When using TYPO3 Cores built-in Frontend Search ("Indexed Search"), search +statistics were written which were never evaluated, but might contain +user-specific information about logged-in users and their previously used sessions, +which might be conflicting with privacy policies. + +The IP Address could be masked via Indexed Search Extension Setting +:php:`trackIpInStatistic` which is now removed, along the database table +:sql:`index_search_stat`. + + +TYPO3 also stores statistics on the searched word, which is evaluated +in the TYPO3 Backend, and kept. + + +Impact +====== + +Searching within Indexed Search will only track the searched words, but not +additional meta data anymore. + +The database table :sql:`index_search_stat` is not available anymore, along with the +Extension setting to disable IP address tracking, as nothing is tracked anymore. + + +Affected Installations +====================== + +TYPO3 installations using Indexed Search and accessing this information. + + +Migration +========= + +It is recommended to use a more generic and sophisticated analytics tool like +Matomo or Google Analytics to track searched terms. + +.. index:: Database, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/11.0/Breaking-92997-Authentication-relatedHTTPCacheHeadersAreEmittedOnlyByPSR-15Middlewares.rst b/Documentation/Changelog/11.0/Breaking-92997-Authentication-relatedHTTPCacheHeadersAreEmittedOnlyByPSR-15Middlewares.rst new file mode 100644 index 0000000..3f3aa63 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-92997-Authentication-relatedHTTPCacheHeadersAreEmittedOnlyByPSR-15Middlewares.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92997: + +=================================================================================================== +Breaking: #92997 - Authentication-related HTTP cache headers are emitted only by PSR-15 middlewares +=================================================================================================== + +See :issue:`92997` + +Description +=========== + +In previous TYPO3 versions, when a user session was initiated or set +(e.g. due to login or cookie), class :php:`AbstractUserAuthentication` was instructed +to send HTTP headers immediately via the PHP function :php:`header()`. + +These headers were sent directly to the client without having a chance to +manipulate a response, or simulate this behavior via proper tests in a testing +suite. + +These HTTP headers for not caching a HTTP response were already attached to the +PSR-7 Response when an active Backend user was available in Frontend and Backend +requests, but not when a Frontend user was logged in. + +The internal methods in class :php:`AbstractUserAuthentication` are removed. + +Impact +====== + +These headers are now only sent via the PSR-7 Response object, and emitted at +the very end of a Request/Response lifecycle in a TYPO3 Application (for Frontend +and Backend Requests), and not via the :php:`header()` function anymore. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions manipulating HTTP headers or the +options within class :php:`AbstractUserAuthentication` to send such headers. + + +Migration +========= + +If any changes regarding the PSR-7 Response headers are needed, it is +recommended to build a custom PSR-15 middleware in a TYPO3 Extension. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93002-SupportForSessionTransferViaFE_SESSION_KEYRemoved.rst b/Documentation/Changelog/11.0/Breaking-93002-SupportForSessionTransferViaFE_SESSION_KEYRemoved.rst new file mode 100644 index 0000000..a1e3c66 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93002-SupportForSessionTransferViaFE_SESSION_KEYRemoved.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93002: + +========================================================================== +Breaking: #93002 - Support for session transfer via FE_SESSION_KEY removed +========================================================================== + +See :issue:`93002` + +Description +=========== + +TYPO3s Frontend Session Handling has had a custom feature by setting a custom +GET variable called :html:`FE_SESSION_KEY` to inject an existing session into a +Frontend Request without having a cookie sent as response. + +This seldom used feature, which was limited to Frontend sessions only, and +required knowledge of third-party integrations for TYPO3s encryption key to +create such a session key is removed. + +Features for integrating sessions should instead be built with custom +AuthenticationServices, e.g. for Single-Sign-On functionality. + + +Impact +====== + +Calling TYPO3s Frontend with :html:`FE_SESSION_KEY` as GET parameter has no effect +anymore and will not pick up an existing session. + + +Affected Installations +====================== + +TYPO3 installations using this :html:`FE_SESSION_KEY` which is very rare and unlikely +to be used. + + +Migration +========= + +Build a custom Authentication Service to log in and use user session instead +in a third-party extension. + +.. index:: Frontend, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-93003-LimitationOfPageRendererToOnlyRenderFullPage.rst b/Documentation/Changelog/11.0/Breaking-93003-LimitationOfPageRendererToOnlyRenderFullPage.rst new file mode 100644 index 0000000..c7ce407 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93003-LimitationOfPageRendererToOnlyRenderFullPage.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93003: + +====================================================== +Breaking: #93003 - PageRenderer renders only full page +====================================================== + +See :issue:`93003` + +Description +=========== + +TYPO3s main API class to build a full HTML page for Frontend +and Backend rendering - :php:`PageRenderer` - previously allowed +to only render the header or footer separately, which was built +due to historical reasons when rendering content. + +This is however obsolete and TYPO3 Core only renders full pages in +Frontend and Backend internally. + +For this reason, PageRenderer's :php:`render` method does not accept +any method arguments anymore and always renders the complete HTML page. + +In addition, the constants + +* :php:`PageRenderer::PART_COMPLETE` +* :php:`PageRenderer::PART_HEADER` +* :php:`PageRenderer::PART_FOOTER` + +are now marked as protected and should not be accessed from outside +the PHP class anymore. + + +Impact +====== + +Calling :php:`PageRenderer->render()` does not respect any given +method argument. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions manipulating the underlying +API to render the page. + + +Migration +========= + +It is recommended for third-party extensions to use custom hooks to +process or manipulate header or footer parts. + +.. index:: Backend, Frontend, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93023-ReworkedSessionHandling.rst b/Documentation/Changelog/11.0/Breaking-93023-ReworkedSessionHandling.rst new file mode 100644 index 0000000..cb4c374 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93023-ReworkedSessionHandling.rst @@ -0,0 +1,154 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Breaking-93023-ReworkedSessionHandling: + +============================================ +Breaking: #93023 - Reworked session handling +============================================ + +See :issue:`93023` + +Description +=========== + +The overall session handling within TYPO3 Core has been overhauled. This was +done to separate the actual User object, the Authentication process and the +session handling. + +The main result of this refactoring is the user authentication objects such as +:php:`BackendUserAuthentication` and :php:`FrontendUserAuthentication` +do not longer contain the session data directly. Instead, this is now encapsulated +in a :php:`UserSession` object which is handled by the new +:php:`UserSessionManager`. + +Furthermore, the user authentication objects internally do not longer know about +a specific session backend implementation, since this is also wrapped by the +:php:`UserSessionManager`. This also means it is not possible to create sessions +outside of the new session manager anymore. + +For this purpose, there are several changes within the user authentication +classes which are described below. + +The array :php:`AbstractUserAuthentication->user` previously contained the logged-in +user record (from be_users / fe_users database table) AND the session record +prefixed via :php:`ses_*` array properties. This has been removed, to separate +the functionality. Instead, all session properties are placed inside the +:php:`UserSession` object, accessible via e.g. :php:`$GLOBALS[BE_USER]->getSession()`. + +The following public properties within :php:`AbstractUserAuthentication` and +its subclasses have been removed: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->id` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->hash_length` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->sessionTimeout` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->gc_time` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->gc_probability` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->newSessionID` + +The following public methods within :php:`AbstractUserAuthentication` and its +subclasses have been removed: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->getNewSessionRecord()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->getSessionId()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->isExistingSessionRecord()` + +The following public property within :php:`AbstractUserAuthentication` has +changed their visibility to :php:`protected`: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->lifetime` + +The following public methods within :php:`AbstractUserAuthentication` and its +subclasses have changed their return type: + +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->createUserSession()` + now returns :php:`TYPO3\CMS\Core\Session\UserSession` and the first parameter + :php:`$tempuser` is now type-hinted :php:`array`. + +The following public properties within :php:`FrontendUserAuthentication` have +been removed: + +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->sesData_change` + +The following database fields have been removed: + +* :sql:`be_sessions.ses_backuserid` +* :sql:`fe_sessions.ses_anonymous` + + +Impact +====== + +Accessing a dropped property or calling a dropped method will raise a fatal PHP +error. + +Accessing a property whose visibility was changed to :php:`protected` will also +raise a fatal PHP error if no deprecation functionality is in place. See +:ref:`changelog-Deprecation-93023-ReworkedSessionHandling` for more information. + +Calling a method whose parameter signature changed with a wrong type will raise +a PHP type error. + +Directly querying a dropped database field will raise a doctrine dbal exception. + + +Affected Installations +====================== + +All TYPO3 installations with custom extensions directly accessing or calling +the changed properties or methods. + + +Migration +========= + +The :php:`sessionTimeout` property is now set internally to the value of the +global configuration :php:`(int)$GLOBALS['TYPO3_CONF_VARS'][$loginType]['sessionTimeout'];`. +This value can also be set dynamically in e.g. a middleware if needed. Because +it is only needed for User Session objects, it is now resolved within +the :php:`UserSessionManager` object. + +:php:`gc_time` is still set to `86400` by default and will be overwritten +with the value from :php:`sessionTimeout` (see above) if greater than `0`. + +Since it's very unlikely that :php:`gc_probability` will be changed in +custom code there is no direct way to set a custom value anymore. It's now +directly set to `1` in the consuming method +:php:`UserSessionManager->collectGarbage()`. If your custom code however rely +on another value you can call :php:`UserSessionManager->collectGarbage()` +in your code by providing a custom value as first argument for +:php:`$garbageCollectionProbability`. + +The property :php:`newSessionID` is now available in :php:`UserSession->isNew()`. + +Use the :php:`UserSessionManager->elevateToFixatedUserSession()` as a +replacement for :php:`getNewSessionRecord()` to migrate an anonymous session +to a user-bound session. + +If you directly call :php:`createUserSession()` in your custom code make sure +to pass an :php:`array` as argument for :php:`$tempuser` and to handle the +returned :php:`UserSession` object accordingly. + +Use :php:`UserSession->dataWasUpdated()` as replacement for +:php:`FrontendUserAuthentication->sesData_change`. + +The :sql:`be_sessions.ses_backuserid` field was migrated into the session data +and is now available inside :php:`UserSession->data`, which can be accessed +using :php:`get()` or :php:`getAll()`. Since this value is only present in +"switch-user" sessions, it's very unlikely that custom code is directly +accessing it. If you however perform database queries using this field, +then they have to be adjusted accordingly. + +The :sql:`fe_sessions.ses_anonymous` field is not needed anymore since this +information can also be obtained using the :sql:`fe_sessions.ses_userid` field. +If it's lower or equals `0` the session is an anonymous one. If you perform +database queries using this field, change it to use :sql:`ses_userid` instead. +If a session is anonymous can furthermore be checked using +:php:`UserSession->isAnonymous()`. + +Related +======= + +- :ref:`changelog-Deprecation-93023-ReworkedSessionHandling` +- :ref:`changelog-Feature-93023-IntroduceUserSessionAndUserSessionManager` + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93029-DroppedDeletedFieldFromSys_refindex.rst b/Documentation/Changelog/11.0/Breaking-93029-DroppedDeletedFieldFromSys_refindex.rst new file mode 100644 index 0000000..96a3117 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93029-DroppedDeletedFieldFromSys_refindex.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93029: + +========================================================== +Breaking: #93029 - Dropped deleted field from sys_refindex +========================================================== + +See :issue:`93029` + +Description +=========== + +The database field :sql:`deleted` has been removed from table +:sql:`sys_refindex`. Therefore, the table does no longer store +relations between soft deleted records. + +Following properties and methods of class +:php:`TYPO3\CMS\Core\Database\ReferenceIndex` have been set to +protected: + +* :php:`temp_flexRelations` +* :php:`relations` - not scanned by extension scanner +* :php:`hashVersion` +* :php:`getWorkspaceId()` - not scanned by extension scanner +* :php:`getRelations_procDB()` +* :php:`setReferenceValue_dbRels()` +* :php:`setReferenceValue_softreferences()` +* :php:`isReferenceField()` - not scanned by extension scanner + +Following methods of class :php:`TYPO3\CMS\Core\Database\ReferenceIndex` +have been removed: + +* :php:`generateRefIndexData()` +* :php:`createEntryData()` +* :php:`createEntryData_dbRels()` +* :php:`createEntryData_softreferences()` + + +Impact +====== + +Accessing the properties of class :php:`ReferenceIndex` or calling +dropped or protected methods will raise fatal PHP errors. + +Querying the :sql:`deleted` field of table :sql:`sys_refindex` will raise a +doctrine dbal exception. + + +Affected Installations +====================== + +The hash sums of existing table rows change. The reference index +should be updated, typically by using the CLI command +:php:`bin/typo3 referenceindex:update` + +Codewise, instances with extensions that query table :sql:`sys_refindex` +or use class :php:`ReferenceIndex` may be affected. The extension +scanner helps to find some usages. + + +Migration +========= + +Use the CLI command :php:`bin/typo3 referenceindex:update` to update +the reference index. + +The :sql:`sys_refindex.deleted` field should be dropped from database +queries. + +When accessing class :php:`ReferenceIndex`, use the main API method +:php:`->updateRefIndexTable()`, plus a couple of other less often +used methods. + +.. index:: Database, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93041-RemoveTypoScriptOptionAddQueryStringmethod.rst b/Documentation/Changelog/11.0/Breaking-93041-RemoveTypoScriptOptionAddQueryStringmethod.rst new file mode 100644 index 0000000..15b10b0 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93041-RemoveTypoScriptOptionAddQueryStringmethod.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93041: + +================================================================= +Breaking: #93041 - Remove TypoScript option addQueryString.method +================================================================= + +See :issue:`93041` + +Description +=========== + +The TypoScript option :typoscript:`addQueryString.method` has been removed. + +If omitted, this added all parameters from the PHP `$_SERVER[QUERY_STRING]` +value, which was used heavily in PHP 3 / PHP 4 times, instead of the more +"modern" `$_GET` parameters, which was set via `addQueryString.method = GET`. + +However, the latter solution was / is the default for working in PSR-7 +requests, and with routing. The option itself is removed, in order to +have TYPO3 use the same values throughout TYPO3 Core, making `method = GET` +the default and thus, the only option. + +To further streamline TYPO3s source code, the underlying PHP method +:php:`ContentObjectRenderer->getQueryArguments()` now only accepts exactly +one argument. + +All Fluid arguments related to that setting, or Extbase UriBuilder methods +do not change any behavior anymore related to building an Uri. + +Impact +====== + +Calling :php:`UriBuilder->setAddQueryStringMethod()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Calling :php:`ContentObjectRenderer->getQueryArguments()` with more +than one argument will have no effect anymore. + +Setting the TypoScript option :typoscript:`addQueryString.method` will +have no effect anymore. + +Using the :html:`addQueryStringMethod` argument in the following +ViewHelpers will trigger a deprecation notice: + +* :html:`<f:form>` +* :html:`<f:link.action>` +* :html:`<f:link.page>` +* :html:`<f:link.typolink>` +* :html:`<f:uri.action>` +* :html:`<f:uri.page>` +* :html:`<f:uri.typolink>` + + +Affected Installations +====================== + +Any TYPO3 installation + +* with extensions calling Extbase's :php:`UriBuilder->setAddQueryStringMethod()` method +* with extensions calling :php:`ContentObjectRenderer->getQueryArguments()` with more then one argument +* with custom templates setting the :html:`addQueryStringMethod` argument in Fluid using one of the mentioned ViewHelper. + + +Migration +========= + +Remove any usages within the Fluid templates or Extension code. + +.. index:: Frontend, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-93047-RemovedPropertySendNoCacheHeadersInAbstractUserAuthentication.rst b/Documentation/Changelog/11.0/Breaking-93047-RemovedPropertySendNoCacheHeadersInAbstractUserAuthentication.rst new file mode 100644 index 0000000..e91d972 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93047-RemovedPropertySendNoCacheHeadersInAbstractUserAuthentication.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93047: + +==================================================================================== +Breaking: #93047 - Removed property sendNoCacheHeaders in AbstractUserAuthentication +==================================================================================== + +See :issue:`93047` + +Description +=========== + +The public property :php:`sendNoCacheHeaders` of class :php:`AbstractUserAuthentication` which was +enabled by default, but disabled in Frontend User objects, ensured that appropriate +HTTP headers telling the client that this HTTP request is not allowed to be +cached by the client. + +This property is removed, as this is now built into PSR-15 middlewares for +both Frontend and Backend users since TYPO3 v10. + + +Impact +====== + +Setting the property :php:`sendNoCacheHeaders` has no effect anymore. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions dealing with session +handling, using this property, which is very unlikely. + + +Migration +========= + +Use a PSR-15 middleware to set headers depending on your needs, +if TYPO3s default header evaluation does not fit your requirements +in Frontend Requests. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-93048-BackendURLRewrites.rst b/Documentation/Changelog/11.0/Breaking-93048-BackendURLRewrites.rst new file mode 100644 index 0000000..de52360 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93048-BackendURLRewrites.rst @@ -0,0 +1,108 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Breaking-93048-BackendURLRewrites: + +======================================= +Breaking: #93048 - Backend URL rewrites +======================================= + +See :issue:`93048` + +Description +=========== + +To introduce human readable urls to the TYPO3 backend, a new rewrite +rule for the backend is necessary. Therefore the rewrite process +should not longer be stopped if the :file:`typo3/` directory is accessed, +like it was configured for a long time. Instead, all requests below +:file:`/typo3/` which do not exist, are now redirected to the TYPO3 Backend +entry point. + +Further do the Backend URLs now not longer require the :html:`&route=` +parameter since its value is now part of the URL. For example the +main entry point changed from :html:`/typo3/index.php?route=%2Fmain` to +:html:`/typo3/main`. + +The :html:`&route=` parameter will however be still applied to the URL +for backwards compatibility. + + +Impact +====== + +Accessing the backend without changing the webserver configuration +will usually lead to a `404 - Not found` response. + +Custom backend links which are not build using the :php:`UriBuilder` +API also may lead to a `404 - Not found` response. + +Using relative paths for backend links, e.g. for icons / images, will +may not longer work as expected. + +Extensions relying on the `&route=` parameter to be set will still work +but break at least in v12 when this parameter will finally be removed. + + +Affected Installations +====================== + +All installations are affected. + + +Migration +========= + +There is a silent update in place which automatically updates the +webserver configuration file when accessing the install tool, at +least for Apache and Microsoft IIS webservers. + +Note: This does not work if you are not using the default configuration, +which is shipped with Core and automatically applied during the TYPO3 +installation process, as basis. No worries, some custom adjustments like +redirects do not prevent the update. Only the default rewrite rules must +be in place. + +If you however use a fully custom configuration, especially when using +a custom entry point for the backend, you may have to perform the +necessary changes manually. Therefore, please have a look at the changes +to the default :file:`.htaccess` configuration, for reference. + +Apache Config before: + +.. code-block:: none + + RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/) - [L] + +Apache Config after: + +.. code-block:: none + + RewriteRule ^(?:fileadmin/|typo3conf/|typo3temp/|uploads/) - [L] + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-l + RewriteRule ^typo3/(.*)$ %{ENV:CWD}typo3/index.php [QSA,L] + +For Nginx, add following block: + +.. code-block:: none + + location /typo3/ { + absolute_redirect off; + try_files $uri /typo3/index.php$is_args$args; + } + +Additionally, make sure to use the public :php:`UriBuilder` API for +all custom generated backend links. + +Finally, check custom backend modules for the use of relative paths, +because they may not longer work as expected. + + +Related +======= + +- :ref:`changelog-Feature-93048-IntroduceBackendURLRewrites` + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-93056-RemovedHooksWhenRetrievingBackendUserGroups.rst b/Documentation/Changelog/11.0/Breaking-93056-RemovedHooksWhenRetrievingBackendUserGroups.rst new file mode 100644 index 0000000..dc6c915 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93056-RemovedHooksWhenRetrievingBackendUserGroups.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93056: + +==================================================================== +Breaking: #93056 - Removed hooks when retrieving Backend user groups +==================================================================== + +See :issue:`93056` + +Description +=========== + +When the user groups of a backend user are loaded, two hooks +(before and after fetching) were in place to modify the +list of groups. + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['fetchGroupQuery']` +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['fetchGroups_postProcessing']` + +This functionality is replaced by a new PHP :php:`GroupResolver` class, +the hooks have been removed, and a new Event has been added instead. + + +Impact +====== + +Using those hooks has no effect anymore, as the hooks are never called in TYPO3 v11. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using these hooks, +which is usually around enhancing the permission system or custom +group resolving. + + +Migration +========= + +When user groups are loaded, for example when a backend editors' groups and permissions +are calculated, a new PSR-14 event :php:`AfterGroupsResolvedEvent` is fired. + +The hooks have been removed without deprecation in order to allow +extensions to make their extension compatible with TYPO3 v10 (using the hooks), +and TYPO3 v11 (use the PSR-14 instead). + +.. index:: PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-93062-VariousGroup-relatedPublicPropertiesInBE_USERRemoved.rst b/Documentation/Changelog/11.0/Breaking-93062-VariousGroup-relatedPublicPropertiesInBE_USERRemoved.rst new file mode 100644 index 0000000..2f89c71 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93062-VariousGroup-relatedPublicPropertiesInBE_USERRemoved.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93062: + +============================================================================= +Breaking: #93062 - Various group-related public properties in BE_USER removed +============================================================================= + +See :issue:`93062` + +Description +=========== + +The PHP API class :php:`BackendUserAuthentication` was built back in +PHP4 days and had a few public properties which have been removed. + +Their purpose was to store data between methods while resolving +groups, where there are other methods containing all group-related +information already anyways. + +- :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->groupList` +- :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->includeGroupArray` + + +Impact +====== + +Accessing or setting these properties will raise a PHP warning. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions accessing these +:php:`BackendUserAuthentication` properties, which is highly unlikely, +or because they were built 10 years ago, still accessing these properties. + + +Migration +========= + +Use :php:`BackendUserAuthentication->userGroupsUID` (array of group UIDs) instead, +which contains the groups in the proper order on how they were resolved. + +If this is not needed directly, it is usually highly recommended to use the +Context API's "backend.user" aspect to retrieve groups of a +backend user. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93073-AbstractUserAuthentication-forceSetCookieRemoved.rst b/Documentation/Changelog/11.0/Breaking-93073-AbstractUserAuthentication-forceSetCookieRemoved.rst new file mode 100644 index 0000000..0b0d423 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93073-AbstractUserAuthentication-forceSetCookieRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93073: + +===================================================================== +Breaking: #93073 - AbstractUserAuthentication->forceSetCookie removed +===================================================================== + +See :issue:`93073` + +Description +=========== + +The public property :php:`forceSetCookie` +is removed from the PHP class :php:`AbstractUserAuthentication`. + +This property served to ensure that a cookie should be added +at any times, which is useful for time-based cookies, which only +happen in Frontend user sessions. This property is now moved as a protected +property into the :php:`FrontendUserAuthentication` class and used in this class +solely to reduce the complexity of the internal logic as well as outside API. + + +Impact +====== + +Setting this property has no effect anymore, setting this property on a Frontend User object will trigger a PHP warning. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions and special cookie handling, which is very unlikely. + + +Migration +========= + +If custom functionality for setting cookies is needed, it is highly +recommended to send cookies manually via a PSR-15 middleware. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93077-RemovedUnneededConfigurationsInPageLayoutView.rst b/Documentation/Changelog/11.0/Breaking-93077-RemovedUnneededConfigurationsInPageLayoutView.rst new file mode 100644 index 0000000..3aae49b --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93077-RemovedUnneededConfigurationsInPageLayoutView.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93077: + +==================================================================== +Breaking: #93077 - Removed unneeded configurations in PageLayoutView +==================================================================== + +See :issue:`93077` + +Description +=========== + +The following TSconfig settings have been removed in favor of strong defaults and less configuration: + +- :typoscript:`mod.web_layout.disableIconToolbar` +- :typoscript:`mod.web_layout.disableSearchBox` + + +Impact +====== + +The settings :typoscript:`mod.web_layout.disableIconToolbar` and :typoscript:`mod.web_layout.disableSearchBox` are +not evaluated anymore and the edit button and the search box are always shown in the page module. + + +Affected Installations +====================== + +TYPO3 installations using the settings :typoscript:`mod.web_layout.disableIconToolbar` or :typoscript:`mod.web_layout.disableSearchBox`. + + +Migration +========= + +There is no migration possible. + +.. index:: Backend, TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-93080-RelationHandlerInternalsProtected.rst b/Documentation/Changelog/11.0/Breaking-93080-RelationHandlerInternalsProtected.rst new file mode 100644 index 0000000..9337365 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93080-RelationHandlerInternalsProtected.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93080: + +====================================================== +Breaking: #93080 - RelationHandler internals protected +====================================================== + +See :issue:`93080` + +Description +=========== + +Various properties and methods of class +:php:`TYPO3\CMS\Core\Database\RelationHandler` have been set to protected: + +* :php:`$firstTable` - internal +* :php:`$secondTable` - internal +* :php:`$MM_is_foreign` - internal +* :php:`$MM_oppositeField` - internal +* :php:`$MM_oppositeTable` - internal +* :php:`$MM_oppositeFieldConf` - internal +* :php:`$MM_isMultiTableRelationship` - internal +* :php:`$currentTable` - internal +* :php:`$MM_match_fields` - internal +* :php:`$MM_hasUidField` - internal +* :php:`$MM_insert_fields` - internal +* :php:`$MM_table_where` - internal + + +* :php:`getWorkspaceId()` - internal +* :php:`setUpdateReferenceIndex()` - still public but deprecated, logs deprecation on use. +* :php:`readList()` - use class state after calling start() +* :php:`sortList()` - use class state after calling start() +* :php:`readMM()` - use class state after calling start() +* :php:`readForeignField()` - use class state after calling start() +* :php:`updateRefIndex()` - internal +* :php:`isOnSymmetricSide()` - internal + + +Impact +====== + +Calling above properties or methods will raise a PHP fatal error. + + +Affected Installations +====================== + +It is quite unlikely many extensions are affected by this API change. +The extension scanner finds affected extensions as weak matches. + + +Migration +========= + +Above properties and methods are considered internal, there shouldn't be any +need to call them. Instances with extensions using those should be refactored +to for instance call :php:`start()` instead of an additional call to :php:`readList()`. + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Breaking-93081-RemovedFetchingTranslationFileMirrorFromTypo3org.rst b/Documentation/Changelog/11.0/Breaking-93081-RemovedFetchingTranslationFileMirrorFromTypo3org.rst new file mode 100644 index 0000000..3bcf32e --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93081-RemovedFetchingTranslationFileMirrorFromTypo3org.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93081: + +========================================================================== +Breaking: #93081 - Removed fetching translation file mirror from typo3.org +========================================================================== + +See :issue:`93081` + +Description +=========== + +The process of downloading translation of XLF files has been simplified. +The URL `https://localize.typo3.org/xliff/` is always used instead of download a static XML +file from typo3.org and persisting the URL in the registry. + + +Impact +====== + +The URL `https://localize.typo3.org/xliff/` is always used and typo3.org is not contacted anymore. + +If any extension has overridden the information in the registry, this path won't be taken into account anymore. + + +Affected Installations +====================== + +Any TYPO3 installation which uses a different URL to fetch translations of TYPO3 core or any extension. + + +Migration +========= + +Use the existing event :php:`ModifyLanguagePackRemoteBaseUrlEvent` to change the URL used to fetch translations. + +.. index:: Backend, Frontend, NotScanned, ext:install diff --git a/Documentation/Changelog/11.0/Breaking-93083-Classext_updatephpHandlingRemoved.rst b/Documentation/Changelog/11.0/Breaking-93083-Classext_updatephpHandlingRemoved.rst new file mode 100644 index 0000000..c6e6ed0 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93083-Classext_updatephpHandlingRemoved.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93083: + +======================================================== +Breaking: #93083 - class.ext_update.php handling removed +======================================================== + +See :issue:`93083` + +Description +=========== + +Handling of old :file:`class.ext_update.php` update scripts has been +dropped: The core introduced a much more solid API for extensions to +perform upgrades with the release of TYPO3 v9. That API matured +and many extensions use it in favor of the clumsy +:file:`class.ext_update.php` solution. Removal of this functionality +within the extension manager has been long overdue and is finally done +with TYPO3 v11. + + +Impact +====== + +The :file:`class.ext_update.php` was an old way for extensions to +perform upgrade steps. The TYPO3 core no longer supports this API. + + +Affected Installations +====================== + +Some old-style extensions may still rely on this script. It's usage +has been discouraged since the new upgrade wizards API. + + +Migration +========= + +Migrate :file:`class.ext_update.php` to the :ref:`upgrade wizard API of the +Install Tool <t3coreapi:upgrade-wizards>`. + + +.. index:: PHP-API, NotScanned, ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Breaking-93093-ReworkShortcutPHPAPI.rst b/Documentation/Changelog/11.0/Breaking-93093-ReworkShortcutPHPAPI.rst new file mode 100644 index 0000000..a0e0c9f --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93093-ReworkShortcutPHPAPI.rst @@ -0,0 +1,129 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Breaking-93093-ReworkShortcutPHPAPI: + +========================================== +Breaking: #93093 - Rework Shortcut PHP API +========================================== + +See :issue:`93093` + +Description +=========== + +The Shortcut PHP API used to store the full URL of the shortcut target +in the :sql:`sys_be_shortcuts` table. It turned out that +this is not working well, but error-prone and laborious. For example, all +created shortcuts are automatically invalid as soon as the corresponding +module changed its route path. Furthermore the :sql:`url` column included +the token which was actually never used but regenerated on every link +generation, e.g. when reloading the backend. Since even the initial +`returnUrl` was stored in the database, a shortcut which linked to +FormEngine has returned to this initial url. + +All these characteristics oppose the introduction of speaking urls for +the TYPO3 backend. Therefore, the internal handling and registration of +the Shortcut PHP API was reworked. + +A shortcut record does now not longer store the full url of the shortcut +target but instead only the modules route identifier and the necessary +arguments (parameters) for the URL. + +The fields :sql:`module_name` and :sql:`url` of the :php:`sys_be_shortcuts` +table have been replaced with: + +* :sql:`route` - Contains the route identifier of the module to link to +* :sql:`arguments` - Contains all necessary arguments (parameters) for the link as JSON encoded string + +The :sql:`arguments` field not longer stores any of the +following parameters: + +* `route` +* `token` +* `returnUrl` + +Shortcuts are usually created by the JavaScript function +:js:`TYPO3.ShortcutMenu.createShortcut()` which performs an AJAX call to +:php:`ShortcutController->addAction()`. The parameter signature of the +JavaScript function has been changed and the :php:`addAction()` +method does now feature an additional result string `missingRoute`, in case +no :js:`routeIdentifier` was provided in the AJAX call. + +The parameter signature changed as followed: + +.. code-block:: javascript + + // Old signature: + public createShortcut( + moduleName: string, + url: string, + confirmationText: string, + motherModule: string, + shortcutButton: JQuery, + displayName: string, + ) + + // New signature: + public createShortcut( + routeIdentifier: string, + routeArguments: string, + displayName: string, + confirmationText: string, + shortcutButton: JQuery, + ) + +The :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton` +API for generating such links now provides a new public method +:php:`setRouteIdentifier()` which replaces the deprecated +:php:`setModuleName()` method. See +:ref:`changelog-Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI` for +all deprecations done during the rework. + + +Impact +====== + +Directly calling :js:`TYPO3.ShortcutMenu.createShortcut()` with the old +parameter signature will result in a JavaScript error. + +Already created shortcuts won't be available prior to running the provided +upgrade wizard. + +The columns :sql:`module_name` and :sql:`url` have been removed. Directly +querying these columns will raise a doctrine dbal exception. + + +Affected Installations +====================== + +Installations with created shortcuts. + +Installations with custom extensions directly calling +:js:`TYPO3.ShortcutMenu.createShortcut()` with the old parameter signature. + +Installations with custom extensions, directly using the database columns +:sql:`module_name` and :sql:`url` or relying on them being filled. + +Installations with custom extensions using deprecated functionality of +the Shortcut PHP API. + + +Migration +========= + +Update the database schema (only "Add fields to tables") and run the +`shortcutRecordsMigration` upgrade wizard either in the install tool or on +CLI with +:bash:`./typo3/sysext/core/bin/typo3 upgrade:run shortcutRecordsMigration`. +Remove the unused :sql:`module_name` and :sql:`url` columns only after running +the wizard. + +Change any call to :js:`TYPO3.ShortcutMenu.createShortcut()` to use the new +parameter signature. + +Migrate custom extension code to use :sql:`route` and :sql:`arguments` instead +of :sql:`module_name` and :sql:`url`. + +Migrate any call to deprecated functionality of the Shortcut PHP API. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Breaking-93108-ReworkedInternalUserGroupFetchingForFrontendUsers.rst b/Documentation/Changelog/11.0/Breaking-93108-ReworkedInternalUserGroupFetchingForFrontendUsers.rst new file mode 100644 index 0000000..72b7c8f --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93108-ReworkedInternalUserGroupFetchingForFrontendUsers.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93108: + +=========================================================================== +Breaking: #93108 - Reworked internal user group fetching for frontend users +=========================================================================== + +See :issue:`93108` + +Description +=========== + +Frontend users now support the same loading mechanism for usergroups as +backend users, making it easier to exchange functionality by unifying the +code base. + +In previous versions, the Authentication Service was used to fetch groups and +enable groups, which can be achieved via the :php:`AfterGroupsResolved` PSR-14 event. + +Fetching groups and permissions belongs to authorization, and not authentication +(identities), where this removal is conceptually suited outside of +authentication services. + +The respective methods and properties + +* :php:`TYPO3\CMS\Core\Authentication\AuthenticationService->getGroups()` +* :php:`TYPO3\CMS\Core\Authentication\AuthenticationService->getSubGroups()` +* :php:`TYPO3\CMS\Core\Authentication\AuthenticationService->db_groups` + +have been removed. + +At the same time, much of the PHP 4-based code base from frontend users +within :php:`FrontendUserAuthentication` has been marked as internal or removed +completely, allowing this information not to be read or modified from the +outside anymore. + +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->TSdataArray` +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->userTS` +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->userTSUpdated` +* :php:`TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->userData_change` + + +Impact +====== + +The authentication services "subtype", "getGroupsFE" and "authGroupsFE" are never +executed anymore. + +Accessing the properties will trigger a PHP warning. + +Affected Installations +====================== + +TYPO3 installations with custom extensions handling group related authentication +services, e.g. LDAP extensions. + + +Migration +========= + +Use the mentioned PSR-14 event to load custom groups from different sources or +based on rules, or use a custom PSR-15 middleware to inject custom groups, +not based on a specific user, but related to a request. + +It is possible to keep extensions compatible with TYPO3 v10 and v11 by keeping +the AuthenticationService "getGroupsFE" subtype, and adding the PSR-14 event to +an extension. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.0/Breaking-93110-IndexedSearchDoesNotProvideHookForEXTcrawlerAnymore.rst b/Documentation/Changelog/11.0/Breaking-93110-IndexedSearchDoesNotProvideHookForEXTcrawlerAnymore.rst new file mode 100644 index 0000000..67d23cf --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-93110-IndexedSearchDoesNotProvideHookForEXTcrawlerAnymore.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93110: + +=============================================================================== +Breaking: #93110 - Indexed search does not provide hook for EXT:crawler anymore +=============================================================================== + +See :issue:`93110` + +Description +=========== + +Indexed search had an explicit dependency on an old API of +the third-party extension "crawler". This cross-dependency did +not allow either component to move forward. + +In order to build a new solution, legacy code has been removed +without substitution for the time being, where as new code +will be added during further TYPO3 v11 development. + + +Impact +====== + +TYPO3 v11 does not use existing EXT:crawler hooks and APIs anymore. + + +Affected Installations +====================== + +TYPO3 installations using EXT:crawler and EXT:indexed_search. + + +Migration +========= + +None until a more flexible solution is provided, however +this only affects the maintainers of EXT:crawler. + +.. index:: PHP-API, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/11.0/Breaking-94861-DeprecatedFormMixinsRemoved.rst b/Documentation/Changelog/11.0/Breaking-94861-DeprecatedFormMixinsRemoved.rst new file mode 100644 index 0000000..a4765c6 --- /dev/null +++ b/Documentation/Changelog/11.0/Breaking-94861-DeprecatedFormMixinsRemoved.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _breaking-94861: + +================================================= +Breaking: #94861 - Deprecated form mixins removed +================================================= + +See :issue:`94861` + +Description +=========== + +The deprecated EXT:form setup mixins from :yaml:`TYPO3.CMS.Form.mixins.*` have been removed. + + +Impact +====== + +Form setup inheriting mixins from :yaml:`TYPO3.CMS.Form.mixins.*` will not work properly anymore. + + +Affected Installations +====================== + +All installations using the deprecated form setup mixins are affected. + + +Migration +========= + +Embed the essential parts from :yaml:`TYPO3.CMS.Form.mixins.*` or migrate them to custom mixins. + +.. index:: Backend, Frontend, NotScanned, ext:form diff --git a/Documentation/Changelog/11.0/Deprecation-89938-DeprecatedLanguageModeInTypo3QuerySettings.rst b/Documentation/Changelog/11.0/Deprecation-89938-DeprecatedLanguageModeInTypo3QuerySettings.rst new file mode 100644 index 0000000..603a04d --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-89938-DeprecatedLanguageModeInTypo3QuerySettings.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-89938: + +========================================================= +Deprecation: #89938 - Language mode in Typo3QuerySettings +========================================================= + +See :issue:`89938` + +Description +=========== + +The following methods have been marked as deprecated and will be removed in TYPO3 v12. + +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings::setLanguageMode()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings::getLanguageMode()` + + +Impact +====== + +Calling these methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Calling these methods as of TYPO3 v12 will result in a fatal error. + + +Affected Installations +====================== + +All installations that call the mentioned methods. + + +Migration +========= + +The deprecated methods have been used in combination with the non consistent translation handling of +Extbase. As that handling mode disappeared, there is no need to migrate these method calls and just +stop calling those instead. + +For more information regarding this change, see issue :issue:`87264` + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Deprecation-91606-GlobalDatetimePickerInitialization.rst b/Documentation/Changelog/11.0/Deprecation-91606-GlobalDatetimePickerInitialization.rst new file mode 100644 index 0000000..d354538 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-91606-GlobalDatetimePickerInitialization.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91606: + +=========================================================== +Deprecation: #91606 - Global Datetime Picker initialization +=========================================================== + +See :issue:`91606` + +Description +=========== + +Initializing all datetime pickers at once by invoking +:js:`DateTimePicker.initialize()` without passing an element has been marked as +deprecated. + + +Impact +====== + +Initializing all datetime pickers at once will trigger a deprecation warning in +the browser's console. + + +Affected Installations +====================== + +All 3rd party extensions calling :js:`DateTimePicker.initialize()` without any +arguments are affected. + + +Migration +========= + +Initialize the datetime picker by passing an input element to the +:js:`.initialize()` method. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-91911-OptionElOfTypeJQueryInFormEnginesetSelectOptionFromExternalSource.rst b/Documentation/Changelog/11.0/Deprecation-91911-OptionElOfTypeJQueryInFormEnginesetSelectOptionFromExternalSource.rst new file mode 100644 index 0000000..1d6e2cc --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-91911-OptionElOfTypeJQueryInFormEnginesetSelectOptionFromExternalSource.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91911: + +============================================================================================= +Deprecation: #91911 - optionEl of type jQuery in FormEngine.setSelectOptionFromExternalSource +============================================================================================= + +See :issue:`91911` + +Description +=========== + +The 6th argument :js:`optionEl` of the method +:js:`FormEngine.setSelectOptionFromExternalSource()` now accepts objects of type +`HTMLOptionElement`. + +In the same run, passing a jQuery object has been marked as deprecated. + + +Impact +====== + +jQuery objects automatically get converted to their native HTMLElement object. +Calling the method with passing a jQuery object will log a deprecation warning +to the browser's console. + + +Affected Installations +====================== + +All installations passing a jQuery object as :js:`optionEl` to +:js:`FormEngine.setSelectOptionFromExternalSource()` are affected. + + +Migration +========= + +Pass a native HTMLOptionElement to +:js:`FormEngine.setSelectOptionFromExternalSource()`. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-92062-MigrateRecordListControllerHooksToAnPSR-14Event.rst b/Documentation/Changelog/11.0/Deprecation-92062-MigrateRecordListControllerHooksToAnPSR-14Event.rst new file mode 100644 index 0000000..edcf32a --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92062-MigrateRecordListControllerHooksToAnPSR-14Event.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92062: + +======================================================================== +Deprecation: #92062 - Migrate RecordListController hooks to PSR-14 event +======================================================================== + +See :issue:`92062` + +Description +=========== + +The following hooks have been marked as deprecated: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawHeaderHook']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawFooterHook']` + +Both hooks were used to add content before or after the main content of the list module. + +Impact +====== + +Using the hooks still works as before, but trigger a PHP :php:`E_USER_DEPRECATED` error. +The hooks will be removed and stop working in TYPO3 v12. +Please migrate to the PSR-14 event: :php:`TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent`. + + +Affected Installations +====================== + +TYPO3 installations with extensions that hook into the RecordListController. + + +Migration +========= + +The functionality of both hooks has been migrated to the following PSR-14 event: +:php:`TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent`. + +The event class contains the following relevant public methods: + +* :php:`getRequest` + Returns the request object from the list module request. +* :php:`addContentAbove` + Add additional content as string as it is to be shown above the main content. +* :php:`addContentBelow` + Add additional content as string as it is to be shown below the main content. + +The event object is used as parameter for the event listener method (default is :php:`__invoke`). + +The listener needs to be registered in the extension: :file:`EXT:myext/Configuration/Services.yaml`. + +Example: + +.. code-block:: yaml + + My\Extension\Provider\MyAdditionalContentProvider: + tags: + - name: event.listener + identifier: 'my-additional-content' + event: TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent + + +Please have a look at :php:`TYPO3\CMS\SysNote\Provider\RecordListProvider` as an example for the +listener implementation. + +.. index:: Backend, PHP-API, FullyScanned, ext:recordlist diff --git a/Documentation/Changelog/11.0/Deprecation-92080-DeprecatedQueryGeneratorAndQueryView.rst b/Documentation/Changelog/11.0/Deprecation-92080-DeprecatedQueryGeneratorAndQueryView.rst new file mode 100644 index 0000000..66d4a11 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92080-DeprecatedQueryGeneratorAndQueryView.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92080: + +================================================== +Deprecation: #92080 - QueryGenerator and QueryView +================================================== + +See :issue:`92080` +See :issue:`92129` + +Description +=========== + +The classes :php:`TYPO3\CMS\Core\Database\QueryGenerator` and +:php:`TYPO3\CMS\Core\Database\QueryView` have been marked as deprecated. + + +Impact +====== + +Using the classes will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Both classes have been used within the backend only, the method +:php:`getTreelist()` has been used occasionally by backend extensions to recursively +fetch children of pages. Even if they are quite inflexible, some extensions may rely +on them. The extension scanner will find class usages with a strong match. + + +Migration +========= + +As most simple solutions, the :php:`getTreeList` method could be copied over to an own extension. + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedShortcutPHPAPI.rst b/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedShortcutPHPAPI.rst new file mode 100644 index 0000000..71aa914 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedShortcutPHPAPI.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92132-1668719172: + +====================================== +Deprecation: #92132 - Shortcut PHP API +====================================== + +See :issue:`92132` + +Description +=========== + +Some methods related to shortcut / bookmark handling in TYPO3 Backend have been marked as deprecated: + +* :php:`TYPO3\CMS\Backend\Template\ModuleTemplate->makeShortcutIcon()` +* :php:`TYPO3\CMS\Backend\Template\ModuleTemplate->makeShortcutUrl()` +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getSetVariables()` +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getGetVariables()` +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setGetVariables()` +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setSetVariables()` + +See also: + +- :ref:`changelog-Deprecation-93060-ShortcutTitleMustBeSetByControllers` +- :ref:`changelog-Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI` + + +Impact +====== + +Using those methods directly or indirectly will trigger PHP :php:`E_USER_DEPRECATED` errors. + + +Affected Installations +====================== + +Extensions with backend modules that show the shortcut button in the doc header may +be affected. The extension scanner will find all PHP usages as weak match. + + +Migration +========= + +The new method :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setArguments()` has been +introduced. This method expects the full set of arguments and values to create a shortcut to a specific view, example: + +.. code-block:: php + + $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $pageId = (int)($request->getQueryParams()['id'] ?? 0); + $shortCutButton = $buttonBar->makeShortcutButton() + ->setRouteIdentifier('page_preview') + ->setDisplayName('View page ' . $pageId) + ->setArguments([ + 'id' => $pageId, + ]); + $buttonBar->addButton($shortCutButton, ButtonBar::BUTTON_POSITION_RIGHT); + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedViewHelperFbebuttonsshortcut.rst b/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedViewHelperFbebuttonsshortcut.rst new file mode 100644 index 0000000..2ed1f5e --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92132-DeprecatedViewHelperFbebuttonsshortcut.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92132: + +====================================================== +Deprecation: #92132 - ViewHelper f:be.buttons.shortcut +====================================================== + +See :issue:`92132` + +Description +=========== + +The Fluid ViewHelper `f:be.buttons.shortcut` has been marked as deprecated. + +Additionally, the argument `getVars` of `ext:backend` related +ViewHelper `be:moduleLayout.button.shortcutButton` has been marked as deprecated. + + +Impact +====== + +Using ViewHelper `f:be.buttons.shortcut` and using argument `getVars` of +ViewHelper `be:moduleLayout.button.shortcutButton` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +The ViewHelpers are occasionally used in backend module context to render the +shortcut / bookmark icon in the doc header. Some custom backend extensions may be affected. + + +Migration +========= + +Use `ext:backend` related ViewHelper `be:moduleLayout.button.shortcutButton` +with argument `arguments` instead, or use the :php:`ButtonBar->makeShortcutButton()` API in PHP directly. + +.. index:: Fluid, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-92386-DeprecatedExtbasePropertyInjection.rst b/Documentation/Changelog/11.0/Deprecation-92386-DeprecatedExtbasePropertyInjection.rst new file mode 100644 index 0000000..5ee8985 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92386-DeprecatedExtbasePropertyInjection.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92386: + +================================================ +Deprecation: #92386 - Extbase property injection +================================================ + +See :issue:`92386` + +Description +=========== + +Since core dependency injection is in place and is about to replace the extbase dependency injection completely, +using property injection via the :php:`@Extbase\Inject` annotation has been marked as deprecated. + + +Impact +====== + +Classes that use extbase property injection will experience non injected services for properties that have a :php:`@Extbase\Inject` annotation. + + +Affected Installations +====================== + +All installations that use extbase property injection via annotation :php:`@Extbase\Inject`. + + +Migration +========= + +Extbase property injection can be replaced by one of the following methods: + +- constructor injection: works both with core and extbase dependency injection and is well suited to make extensions compatible for multiple TYPO3 versions. +- setter injection: Basically the same as constructor injection. Both the core and extbase DI can handle setter injection and both are supported in different TYPO3 versions. +- (core) property injection: This kind of injection can be used but it requires the configuration of services via a :file:`Services.yaml` in the :file:`Configuration` folder of an extension. + + +Given the following example for a :php:`@Extbase\Inject` annotation based injection: + +.. code-block:: php + + /** + * @var MyService + * @Extbase\Inject + */ + protected $myService; + + +This service injection can be changed to constructor injection by adding the +service as constructor argument and removing the :php:`@Extbase\Inject` annotation: + +.. code-block:: php + + /** + @var MyService + */ + protected $MyService; + + public function __construct(MyService $MyService) { + $this->myService = $myService; + } + +Please consult the dependency-injection_ documentation for more information. + +.. _dependency-injection: https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/DependencyInjection/Index.html + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Deprecation-92435-DeprecatedStandaloneViewForEmailFinisher.rst b/Documentation/Changelog/11.0/Deprecation-92435-DeprecatedStandaloneViewForEmailFinisher.rst new file mode 100644 index 0000000..33f8d82 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92435-DeprecatedStandaloneViewForEmailFinisher.rst @@ -0,0 +1,82 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92435: + +====================================================== +Deprecation: #92435 - StandaloneView for EmailFinisher +====================================================== + +See :issue:`92435` + +Description +=========== + +The :php:`EmailFinisher` class of EXT:form was extended for the possibility to use +FluidEmail in TYPO3 v10. Therefore the previously used StandaloneView has now been marked as +deprecated along with the configuration option :yaml:`templatePathAndFilename`. + + +Impact +====== + +Using the StandaloneView will trigger a PHP :php:`E_USER_DEPRECATED` error. Using +:yaml:`templatePathAndFilename` for custom templates will also trigger a +PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations not already using FluidEmail for the EXT:form EmailFinisher. + + +Migration +========= + +Adjust your finisher configuration to use FluidEmail by setting :yaml:`useFluidEmail: true`. + +Before: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + useFluidEmail: false + +After: + +.. code-block:: yaml + + finishers: + - + identifier: EmailToReceiver + options: + useFluidEmail: true + +For custom templates, replace :yaml:`templatePathAndFilename` with :yaml:`templateName` +and :yaml:`templateRootPaths`. + +Before: + +.. code-block:: yaml + + finishersDefinition: + EmailToReceiver: + options: + templatePathAndFilename: EXT:sitepackage/Resources/Private/Templates/Email/ContactForm.html + +After: + +.. code-block:: yaml + + finishersDefinition: + EmailToReceiver: + options: + templateName: ContactForm + templateRootPaths: + 100: 'EXT:sitepackage/Resources/Private/Templates/Email/' + + +.. index:: YAML, NotScanned, ext:form diff --git a/Documentation/Changelog/11.0/Deprecation-92551-GeneralUtilityMethodsRelatedToPagesl18n_cfgBehavior.rst b/Documentation/Changelog/11.0/Deprecation-92551-GeneralUtilityMethodsRelatedToPagesl18n_cfgBehavior.rst new file mode 100644 index 0000000..3f40e6a --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92551-GeneralUtilityMethodsRelatedToPagesl18n_cfgBehavior.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92551: + +=============================================================================== +Deprecation: #92551 - GeneralUtility methods related to pages.l18n_cfg behavior +=============================================================================== + +See :issue:`92551` + +Description +=========== + +The methods + +* :php:`GeneralUtility::hideIfNotTranslated()` +* :php:`GeneralUtility::hideIfDefaultLanguage()` + +have been marked as deprecated in favor of a new BitSet-based PHP class +:php:`TYPO3\CMS\Core\Type\Bitmask\PageTranslationVisibility`. + + +Impact +====== + +Calling both methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installation with custom third-party extensions calling +these methods for explicit and special page translation handling. + + +Migration +========= + +Instead of :php:`GeneralUtility::hideIfDefaultLanguage()` use + +.. code-block:: php + + $pageTranslationVisibility = new PageTranslationVisibility((int)$page['l18n_cfg'] ?? 0) + $pageTranslationVisibility->shouldBeHiddenInDefaultLanguage() + + +Instead of :php:`GeneralUtility::hideIfNotTranslated()` use + +.. code-block:: php + + $pageTranslationVisibility = new PageTranslationVisibility((int)$page['l18n_cfg'] ?? 0) + $pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists() + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-92583-DeprecateLastArgumentsOfWrapClickMenuOnIcon.rst b/Documentation/Changelog/11.0/Deprecation-92583-DeprecateLastArgumentsOfWrapClickMenuOnIcon.rst new file mode 100644 index 0000000..80ad827 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92583-DeprecateLastArgumentsOfWrapClickMenuOnIcon.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92583: + +=============================================================== +Deprecation: #92583 - 3 last arguments of wrapClickMenuOnIcon() +=============================================================== + +See :issue:`92583` + +Description +=========== + +:php:`BackendUtility::wrapClickMenuOnIcon()` has a boolean flag to let the method +return an array with tag parameters instead of a fully build HTML tag as string. +As this are two completely different things and cause problems when analysing +return types it should not be done in the same method. + +Calling :php:`BackendUtility::wrapClickMenuOnIcon()` with the 7th and last argument +:php:`$returnTagParameters` set to :php:`true` has been marked as deprecated alongside the 5th +and 6th arguments that are already unused. + +A new method has been introduced that returns the aforementioned array. + + +Impact +====== + +Calling :php:`BackendUtility::wrapClickMenuOnIcon()` with more than 4 arguments +will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All 3rd party extensions calling :php:`BackendUtility::wrapClickMenuOnIcon()` with more +than 4 arguments are affected. + + +Migration +========= + +Arguments 5 and 6 can be safely removed as they are already unused. + +If :php:`$returnTagParameters` was set to :php:`true` the newly introduced method +:php:`BackendUtility::getClickMenuOnIconTagParameters()` should be called to +retrieve the array with the tag parameters. + +Example +======= + +.. code-block:: php + + $parameters = BackendUtility::getClickMenuOnIconTagParameters($tableName, $uid, 'tree'); + + +.. index:: Backend, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-92598-Workspace-relatedMethodsFixVersioningPid.rst b/Documentation/Changelog/11.0/Deprecation-92598-Workspace-relatedMethodsFixVersioningPid.rst new file mode 100644 index 0000000..e2635a4 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92598-Workspace-relatedMethodsFixVersioningPid.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92598: + +================================================================== +Deprecation: #92598 - Workspace-related methods "fixVersioningPid" +================================================================== + +See :issue:`92598` + +Description +=========== + +The two workspace-related methods + +* :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository->fixVersioningPid()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::fixVersioningPid()` + +have been marked as deprecated, as they are not needed in TYPO3 v11 anymore. + +Both methods served to replace the value of a record's "pid" of +a live version with the actual "pid" value of a versioned record. + +Since TYPO3 v11 this is only different for versioned records which +have been moved, where the live record has e.g. a PID value of 13 +but in a workspace the record was moved to PID 20. In order to +correctly resolve e.g. a page path or a rootline, these methods +helped to modify the "pid" value. + +However, as TYPO3 v11 does not use Move Placeholders anymore, +and move pointers (records moved in a workspace) already contain +the newly moved location as "pid" value, the extra database +call is not needed. + + +Impact +====== + +Calling these methods in custom PHP code will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom PHP code respecting versioned +records with these methods. This usually does not apply to +Extbase-related extensions or extensions that do not consider +moved records in Workspaces (yet). + + +Migration +========= + +The API methods: + +* :php:`TYPO3\CMS\Core\Domain\Repository\PageRepository->versionOL()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::workspaceOL()` +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::getRecordWSOL()` + +now override the "pid" value of the moved records directly, and +keep the live "pid" value in "_ORIG_pid". + +It is highly recommended to use these methods. + +If it is needed to manually find the online PID for a versioned record, it is +recommended to just fetch the live record (stored in :sql:`t3ver_oid`) via +typical Doctrine-based database queries and load the PID value from there, +or use the overlay methods as described to get both values. + + +.. index:: PHP-API, FullyScanned, ext:workspaces diff --git a/Documentation/Changelog/11.0/Deprecation-92607-DeprecatedGeneralUtilityuniqueList.rst b/Documentation/Changelog/11.0/Deprecation-92607-DeprecatedGeneralUtilityuniqueList.rst new file mode 100644 index 0000000..c3b21c4 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92607-DeprecatedGeneralUtilityuniqueList.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92607: + +================================================ +Deprecation: #92607 - GeneralUtility::uniqueList +================================================ + +See :issue:`92607` + +Description +=========== + +Since longer than a decade, the :php:`GeneralUtility::uniqueList()` method does not +accept an :php:`array` as first argument anymore. The second +parameter is unused just as long. Both throw an :php:`InvalidArgumentException` upon usage. + +As the method doesn't belong to :php:`GeneralUtility` at all, a new refactored +version was added to :php:`StringUtility`. Therefore, the exceptions were removed +along with the unused second parameter. The first parameter is now type hinted +:php:`string` and the return type :php:`string` was added. The +PHPDoc was updated accordingly. + + +Impact +====== + +Calling the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + + +Affected Installations +====================== + +TYPO3 installations with custom third-party extensions calling this method. + + +Migration +========= + +Use the new :php:`StringUtility::uniqueList()` method instead and ensure you +pass a valid string as first argument and omit the second argument. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-92784-ExtbaseControllerActionsMustReturnResponseInterface.rst b/Documentation/Changelog/11.0/Deprecation-92784-ExtbaseControllerActionsMustReturnResponseInterface.rst new file mode 100644 index 0000000..8639cd1 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92784-ExtbaseControllerActionsMustReturnResponseInterface.rst @@ -0,0 +1,145 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92784: + +============================================================================== +Deprecation: #92784 - Extbase controller actions must return ResponseInterface +============================================================================== + +See :issue:`92784` + +Description +=========== + +Until now, Extbase controller actions could return either nothing (void), null, a string, or an object that implements :php:`__toString()`. + +From now on Extbase expects actions to return an instance of :php:`Psr\Http\Message\ResponseInterface`. + + +Impact +====== + +All actions that do not return an instance of :php:`Psr\Http\Message\ResponseInterface` trigger a PHP :php:`E_USER_DEPRECATED` error and will fail as of TYPO3 v12. + + +Affected Installations +====================== + +All installations that use Extbase controller actions which don't return an instance of :php:`Psr\Http\Message\ResponseInterface`. + + +Migration +========= + +Since the core follows not only PSR-7 (https://www.php-fig.org/psr/psr-7/) +but also PSR-17 (https://www.php-fig.org/psr/psr-17/), +the PSR-17 factories should be used. Both the :php:`$responseFactory` as +well as the :php:`$streamFactory` are available in all extbase controllers. +The :php:`$responseFactory` can be used to create a blank response object +whose content and headers can be set freely. The content can therefore be +set using the :php:`$streamFactory`. + +Example: + +.. code-block:: php + + use Psr\Http\Message\ResponseInterface; + + public function listAction(): ResponseInterface + { + $items = $this->itemRepository->findAll(); + $this->view->assign('items', $items); + + return $this->responseFactory->createResponse() + ->withAddedHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($this->streamFactory->createStream($this->view->render())); + } + +This example only shows the most common use case. It causes html with a :html:`Content-Type: text/html` header and +HTTP status code `200 OK` to be returned as the response to the client. + +.. tip:: + + Using the factory is a clean architectural solution but it's a lot of new code for a migration + from returning nothing at all. To ease the migration path the method :php:`htmlResponse(string $html = null)` + has been introduced which makes a quite small change possible. + When called without an argument, said method renders the current view. + + .. code-block:: php + + public function listAction(): ResponseInterface + { + $items = $this->itemRepository->findAll(); + $this->view->assign('items', $items); + + return $this->htmlResponse(); + } + + +Of course you are free to adjust this response object before returning it. + +Example: + +.. code-block:: php + + public function listAction(): ResponseInterface + { + $items = $this->itemRepository->findAll(); + $this->view->assign('items', $items); + + return $this->responseFactory + ->createResponse() + ->withHeader('Cache-Control', 'must-revalidate') + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withStatus(200, 'Super ok!') + ->withBody($this->streamFactory->createStream($this->view->render())); + } + +.. tip:: + + To adjust the content of an already created PSR-7 response object, + :php:`$response->getBody()->write()` can be used. + +.. tip:: + + Since Extbase uses PSR-7 responses, you should make yourself familiar with its API. + Documentation and more information regarding PSR-7 responses can be found here: https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface + +In case you are using the :php:`JsonView` in your extbase controller, you may +want to ease the migration path with the new :php:`jsonResponse(string $json = null)` +method. Similar to :php:`htmlResponse()`, this method creates a PSR-7 Response +with the :html:`Content-Type: application/json` header and http code `200 Ok`. +If argument :php:`$json` is omitted, the current view is rendered automatically. + +Example: + +.. code-block:: php + + public function listApiAction(): ResponseInterface + { + $items = $this->itemRepository->findAll(); + $this->view->assign('value', [ + 'items' => $items + ]); + + return $this->jsonResponse(); + } + +Above example is equivalent to: + +.. code-block:: php + + public function listApiAction(): ResponseInterface + { + $items = $this->itemRepository->findAll(); + $this->view->assign('value', [ + 'items' => $items + ]); + + return $this->responseFactory + ->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withBody($this->streamFactory->createStream($this->view->render())); + } + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Deprecation-92815-ActionControllerForward.rst b/Documentation/Changelog/11.0/Deprecation-92815-ActionControllerForward.rst new file mode 100644 index 0000000..b57ebf5 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92815-ActionControllerForward.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92815: + +================================================= +Deprecation: #92815 - ActionController::forward() +================================================= + +See :issue:`92815` + +Description +=========== + +Method :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::forward()` has been marked as deprecated +in favor of returning a :php:`TYPO3\CMS\Extbase\Http\ForwardResponse` in a controller action. + + +Impact +====== + +Calling :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::forward()`, +which itself throws a `TYPO3\CMS\Extbase\Mvc\Exception\StopActionException` to initiate abortion +of the current request and to initiate a new request, will also trigger PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations using method :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::forward()`. + + +Migration +========= + +Instead of calling the helper method, a controller action must return a :php:`TYPO3\CMS\Extbase\Http\ForwardResponse`. + +Before: + +.. code-block:: php + + <?php + + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class FooController extends ActionController + { + public function listAction() + { + // do something + + $this->forward('show'); + } + + // more actions here + } + +After: + +.. code-block:: php + + <?php + + use Psr\Http\Message\ResponseInterface; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + use TYPO3\CMS\Extbase\Http\ForwardResponse; + + class FooController extends ActionController + { + public function listAction(): ResponseInterface + { + // do something + + return new ForwardResponse('show'); + } + + // more actions here + } + + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Deprecation-92922-UseOfRecordUidInAbstractTreeViewgetIcon.rst b/Documentation/Changelog/11.0/Deprecation-92922-UseOfRecordUidInAbstractTreeViewgetIcon.rst new file mode 100644 index 0000000..2946b68 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92922-UseOfRecordUidInAbstractTreeViewgetIcon.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92922: + +====================================================================== +Deprecation: #92922 - Use of record uid in AbstractTreeView::getIcon() +====================================================================== + +See :issue:`92922` + +Description +=========== + +To increase the type safety through the whole TYPO3 core and to properly +reflect the expected value for the parameter (based on its name), +calling :php:`AbstractTreeView::getIcon()` with a record uid as first +argument has been marked as deprecated. + +Note: Using a record uid had actually no benefit (performance wise) +since the method fetched the record internally in that case anyways, +but without adding any restrictions or respecting any overlays e.g. +for workspaces. + + +Impact +====== + +Calling the method with an :php:`integer` for parameter :php:`$row` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations calling this method with an :php:`integer` for +parameter :php:`$row`. + + +Migration +========= + +Provide the full record row as first argument. + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-92947-DeprecateTYPO3_MODEAndTYPO3_REQUESTTYPEConstants.rst b/Documentation/Changelog/11.0/Deprecation-92947-DeprecateTYPO3_MODEAndTYPO3_REQUESTTYPEConstants.rst new file mode 100644 index 0000000..5ae6786 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-92947-DeprecateTYPO3_MODEAndTYPO3_REQUESTTYPEConstants.rst @@ -0,0 +1,191 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92947: + +================================================================ +Deprecation: #92947 - TYPO3_MODE and TYPO3_REQUESTTYPE constants +================================================================ + +See :issue:`92947` + +Description +=========== + +The following global constants have been marked as deprecated: + +* :php:`TYPO3_MODE` +* :php:`TYPO3_REQUESTTYPE` +* :php:`TYPO3_REQUESTTYPE_FE` +* :php:`TYPO3_REQUESTTYPE_BE` +* :php:`TYPO3_REQUESTTYPE_CLI` +* :php:`TYPO3_REQUESTTYPE_AJAX` +* :php:`TYPO3_REQUESTTYPE_INSTALL` + + +Impact +====== + +The main issues with constants :php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE` is, that they +are NOT constant: Their value depends on the context they are called from. They usually indicate +if a TYPO3 frontend or backend request is executed. Since constants can't be re-defined, this +is a blocker if a single TYPO3 PHP call wants to execute multiple requests to both the +frontend or backend application in one process. This is used by the core testing framework +already and various core features and extensions will benefit from it, too. + +There is no other solution than to phase out :php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE`. The +new API to substitute them is only available at a later point during TYPO3 bootstrap, so a couple of +details have to be considered when switching away from usage of those constants in extensions. + +Extension developers are highly encouraged to drop usage when making extensions TYPO3 v11 ready. +The constants are only deprecated, their usage is not breaking, yet. To simplify the transition, +the new API has been added to TYPO3 v10, too - it is available since TYPO3 10.4.11. Switching to the +new API early is thus easily possible for extensions that support v10 and v11 in the same version, +without a TYPO3 version check. + + +Affected Installations +====================== + +Many extensions use especially the :php:`TYPO3_MODE` constant. The extension scanner will +find the corresponding usages. + + +Migration +========= + +:php:`TYPO3_REQUESTTYPE_*` constants +------------------------------------ + +* :php:`TYPO3_REQUESTTYPE_FE` - Use :php:`ApplicationType->isFrontend()` instead, see below. +* :php:`TYPO3_REQUESTTYPE_BE` - Use :php:`ApplicationType->isBackend()` instead, see below. +* :php:`TYPO3_REQUESTTYPE_CLI` - Use :php:`Environment::isCli()` instead. +* :php:`TYPO3_REQUESTTYPE_AJAX` - Extensions should barely need this at all. If really required, + using :php:`strpos($request->getQueryParams()['route'] ?? '', '/ajax/') === 0` could be used + as alternative to find out if a request is a backend ajax request. A better solution however + is to refactor consuming code to not depend on this distinction between backend and backend-ajax + at all - the TYPO3 core may drop this separation at some point in the future, too. +* :php:`TYPO3_REQUESTTYPE_INSTALL` - Extensions should never use this. There is only a small + number of places in the install tool extensions can extend. Those should have a proper API + to separate code from other use cases. A specific check for an install tool scope should not + be required. + + +:php:`TYPO3_MODE` usage as global script file security gate +----------------------------------------------------------- + +TYPO3 still has some extension PHP script files executed in global context without class or +callable encapsulation, namely :file:`ext_localconf.php`, :file:`ext_tables.php` and +files within :file:`Configuration/TCA/Overrides/`. When those files are located within +the public document root of an instance and called via HTTP directly, they may error out and +render error messages. This can be a security risk. To prevent this, those files MUST have a +security gate as first line. This typically looks like:: + + defined('TYPO3_MODE') or die(); + +These calls should be changed to use the new constant :php:`TYPO3` instead. It is simply defined +to :php:`true` in early TYPO3 bootstrap and can be used for this purpose:: + + defined('TYPO3') or die(); + + +Other usages of :php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE` in bootstrap script files +---------------------------------------------------------------------------------------- + +The new API class :php:`ApplicationType` MUST NOT be used in the extension related early bootstrap +script files :file:`ext_localconf.php`, :file:`ext_tables.php` and :file:`Configuration/TCA/*`. + +The reason is simple: The frontend and backend :php:`Application` classes are the first objects +within TYPO3 bootstrap that "know" which kind of application is executed. They add this information +to the PSR-7 request object as attribute :php:`applicationType`. The helper class +:php:`ApplicationType` - the main substitution for :php:`TYPO3_MODE` - operates on this. :php:`TCA` +related extension files and :php:`ext_*` script files however are executed *before* the Application +object has been started, and before the request object is set in globals. The information if a frontend +or backend is called does not exist at this point in time, so the helper class :php:`ApplicationType` +can't be used. + +This change is in line with a general core bootstrap strategy: A mid-term goal is to have a static +framework state after bootstrap, that does not depend on the executed application type. In the future, +executed code which must change the static state, after the Application object has been set up, will +have better opportunities to reset this state before the Application emits a response. Extensions should +bow to this goal and should drop application related state changes in bootstrap related files. + + +:php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE` in :file:`Configuration/TCA/*` files +................................................................................... + +For extensions which use :php:`TYPO3_MODE` or :php:`TYPO3_REQUESTTYPE` in :php:`TCA` related files in +:file:`Configuration/TCA/*`, the situation is simple: This is not allowed for a while already. +:php:`$GLOBALS['TCA']` state MUST NOT depend on those constants. The :php:`TCA` state is cached after +first call and this cache is used in all applications. If extensions still use those constants in these +files, the :php:`TCA` state depends on whether a first frontend or backend application call is done with +empty caches, which leads to bugs. Extension developers MUST drop this usage in those files. + + +:php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE` in :file:`ext_localconf.php` and :file:`ext_tables.php` files +............................................................................................................ + +As outlined above, class :php:`ApplicationType` MUST NOT be used in these files as substitution for +usages of :php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE`. There are a couple of strategies to avoid +this. All of them lead to the situation that framework state changes are always registered and +necessary switches, depending on the executed application, are done at a later point in time. + +One example has been realized with core issue :issue:`92848`: This changed the registration of additional +JavaScript for the PageRenderer in backend scope to a hook implementation. The hook has later been +changed to use the :php:`ApplicationType` helper class instead (see below). The idea is that a hook registration +that changes :php:`GLOBALS['TYPO3_CONF_VARS']` or other globals can *always* be done. The decision, +if something should be applied, is determined later, when the hook is called. + +Another example is the change for issue :issue:`92952`: It is the same strategy - something is always +registered, the decision if it should actually *do* stuff is postponed to a point when the registered code +is executed. + + +:php:`TYPO3_MODE` and :php:`TYPO3_REQUESTTYPE` usages in class files +-------------------------------------------------------------------- + +Some generic extension classes not involved in TYPO3 bootstrap still need to execute different things +if they are executed in frontend or backend scope. A use cases is for instance the need to calculate +different resource paths depending on frontend or backend. + +This code should use the new :php:`ApplicationType` class. + +Before:: + + if (TYPO3_MODE === 'FE') { + ... + } + +After:: + + use TYPO3\CMS\Core\Http\ApplicationType; + ... + if (ApplicationType::fromRequest($request)->isFrontend()) { + ... + } + +This needs the PSR-7 request that is handed over by the Application specific request handlers to single +controllers. Code that needs this switch should be refactored to receive this request object if it is +not available already. However, some extension code (especially core hooks) do not provide the request +object, yet. In those cases, it is ok to fall back to the request object that has been registered as +:php:`$GLOBALS['TYPO3_REQUEST']` by the TYPO3 core. This is always set by the :php:`RequestHandler` that +is called before a controller action is executed. It should be noted that falling back to +:php:`$GLOBALS['TYPO3_REQUEST']` is a technical debt in itself, the TYPO3 core will try to reduce the need +for this fallback over time. A call using this fallback looks like:: + + if (ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend()) + ... + } + +As a last use case, there may be low level code executed by a CLI command controller, sometimes using +classes that are also used in frontend or backend scope. Some of these CLI calls do not set up a request +object at all. The core will change this over time with upcoming patches, but some use cases may remain +that are called by CLI directly without a PSR-7 request. The fact that a request object may be missing +and still a detection for frontend or backend application type is needed can lead to this code:: + + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + ... + } + +.. index:: Backend, CLI, Frontend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-93023-ReworkedSessionHandling.rst b/Documentation/Changelog/11.0/Deprecation-93023-ReworkedSessionHandling.rst new file mode 100644 index 0000000..80a0edc --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-93023-ReworkedSessionHandling.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Deprecation-93023-ReworkedSessionHandling: + +=============================================== +Deprecation: #93023 - Reworked session handling +=============================================== + +See :issue:`93023` + +Description +=========== + +As described in :ref:`changelog-Breaking-93023-ReworkedSessionHandling` +the whole session handling in the TYPO3 Core was reworked by moving it +out of the user authentication classes. + +Therefore some properties and methods within :php:`AbstractUserAuthentication` +and its subclasses have been marked as deprecated: + +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->createSessionId()` +* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->fetchUserSession()` + + +Impact +====== + +Accessing :php:`id` or calling :php:`isExistingSessionRecord()` +respectively :php:`getSessionId()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Calling :php:`createSessionId()` or :php:`fetchUserSession()` will not +trigger a PHP :php:`E_USER_DEPRECATED` error but will still be reported by the extension +scanner. + + +Affected Installations +====================== + +All TYPO3 installations with custom extensions directly accessing or calling +the deprecated properties or methods. + + +Migration +========= + +Creating a new session is now handled by the :php:`UserSessionManager`. +Therefore the identifier is set internally on creation of a new session +and should not longer be called directly. Use e.g. +:php:`UserSessionManager->createAnonymousSession()` or +:php:`UserSessionManager->regenerateSession()` to create a new session +and then access :php:`UserSession->getIdentifier()`. + +Use :php:`UserSessionManager->isSessionPersisted()` instead of +:php:`isExistingSessionRecord()` to check if a session is already persisted. + +Use the :php:`UserSessionManager` to create a new session and then directly +access the :php:`UserSession` instead of calling :php:`fetchUserSession()`. + +Use :php:`UserSession->getIdentifier()` instead of :php:`getSessionId()`. To +access this information from an user authentication object, call +:php:`$userAuthentication->getSession()->getIdentifier()`. + +Related +======= + +* :ref:`changelog-Breaking-93023-ReworkedSessionHandling` +* :ref:`changelog-Feature-93023-IntroduceUserSessionAndUserSessionManager` + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-93038-ReferenceIndexRuntimeCache.rst b/Documentation/Changelog/11.0/Deprecation-93038-ReferenceIndexRuntimeCache.rst new file mode 100644 index 0000000..9cc5fcb --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-93038-ReferenceIndexRuntimeCache.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93038: + +================================================== +Deprecation: #93038 - ReferenceIndex runtime cache +================================================== + +See :issue:`93038` + +Description +=========== + +Two methods of class :php:`ReferenceIndex` have been marked as deprecated: + +* :php:`ReferenceIndex->enableRuntimeCache()` +* :php:`ReferenceIndex->disableRuntimeCache()` + + +Impact +====== + +Calling these methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Instances with extensions calling above methods are affected. The extension +scanner locates candidates. + + +Migration +========= + +The method calls can be dropped, cache handling is done internally. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.0/Deprecation-93060-ShortcutTitleMustBeSetByControllers.rst b/Documentation/Changelog/11.0/Deprecation-93060-ShortcutTitleMustBeSetByControllers.rst new file mode 100644 index 0000000..21dc7e5 --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-93060-ShortcutTitleMustBeSetByControllers.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Deprecation-93060-ShortcutTitleMustBeSetByControllers: + +=============================================================== +Deprecation: #93060 - Shortcut title must be set by controllers +=============================================================== + +See :issue:`93060` + +Description +=========== + +Previously the class :php:`ShortcutRepository` automatically generated a +shortcut title based on the given arguments. This generation was never reliable, +especially for custom extension code, since the repository +does not know about controller specific logic. Therefore, this functionality +has now been marked as deprecated. Backend controllers which add a shortcut button to +their module header are now required to also set the desired title. + + +Impact +====== + +Adding a new shortcut button without defining the :php:`$displayName` triggers a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations using the shortcut button API without defining the +:php:`$displayName` property. + + +Migration +========= + +Define the title with +:php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setDisplayName()`. + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI.rst b/Documentation/Changelog/11.0/Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI.rst new file mode 100644 index 0000000..9af023b --- /dev/null +++ b/Documentation/Changelog/11.0/Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Deprecation-93093-DeprecateMethodNameInShortcutPHPAPI: + +==================================================== +Deprecation: #93093 - MethodName in Shortcut PHP API +==================================================== + +See :issue:`93093` + +Description +=========== + +Since :issue:`92723` the TYPO3 backend uses symfony routing for resolving +internal endpoints, e.g. modules. This will allow human readable urls and also +deep-linking in the future. To achieve this, the shortcut PHP API had to +be reworked to be fully compatible with the new routing. +See :ref:`changelog-Breaking-93093-ReworkShortcutPHPAPI` for more information +regarding the rework. + +In the course of the rework, following methods within :php:`ShortcutButton` +have been marked as deprecated: + +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setModuleName()` +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getModuleName()` + +Impact +====== + +Using those methods directly or indirectly will trigger PHP :php:`E_USER_DEPRECATED` errors. + + +Affected Installations +====================== + +Installations with custom extensions, adding a shortcut button in the module +header of their backend modules using the mentioned methods. The extension +scanner will find all PHP usages as weak match. + + +Migration +========= + +Use the new methods :php:`ShortcutButton->setRouteIdentifier()` and +:php:`ShortcutButton->getRouteIdentifier()` as replacement. Please note +that these methods require the route identifier of the backend module +which may differ from the module name. To find out the route identifier, +the "Backend Routes" section within the configuration module can be used. + +Before: + +.. code-block:: php + + $shortCutButton = $buttonBar + ->makeShortcutButton() + ->setModuleName('web_list'); + +After: + +.. code-block:: php + + $shortCutButton = $buttonBar + ->makeShortcutButton() + ->setRouteIdentifier('web_list'); + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-29342-ImproveValidatorTask.rst b/Documentation/Changelog/11.0/Feature-29342-ImproveValidatorTask.rst new file mode 100644 index 0000000..50f41a0 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-29342-ImproveValidatorTask.rst @@ -0,0 +1,136 @@ +.. include:: /Includes.rst.txt + +.. _feature-29342: + +======================================= +Feature: #29342 - Improve ValidatorTask +======================================= + +See :issue:`29342` + +Description +=========== + +The :php:`\TYPO3\CMS\Linkvalidator\Task\ValidatorTask` scheduler task for +reporting broken links via email which still used marker templates, has been +improved. This is achieved by switching to `FluidEmail`, extending the task +configuration along with the mails content for more detailed reports, code +refactoring and introduction of strict types for both + +:php:`\TYPO3\CMS\Linkvalidator\Task\ValidatorTask` and +:php:`\TYPO3\CMS\Linkvalidator\Task\ValidatorTaskAdditionalFieldProvider`. + +The task configuration got the following new fields: + +* `languages` Comma separated list of language uids +* `emailTemplateName` Name of the fluid template + +With `languages` it's now possible to limit the report to specified system +languages. This is useful if multiple tasks for different groups of recipients +should be registered. + +With `emailTemplateName` it is possible to use different custom templates for each +task. The template path must be set in :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths']`. Additionally +the used `SystemEmail` layout can be changed by setting your custom layout +path in :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['layoutRootPaths']`. If no +`emailTemplateName` is set or the specified input is invalid, the task +automatically uses the default template name on task execution. + +The following new PSR-14 event has been introduced: + +:php:`\TYPO3\CMS\Linkvalidator\Event\ModifyValidatorTaskEmailEvent` + +This event can be used to manipulate the :php:`\TYPO3\CMS\Linkvalidator\Result\LinkAnalyzerResult`, +which contains all information from the linkvalidator API. Also the `FluidEmail` +object can be adjusted here. This allows to e.g. pass additional information to +the view by using :php:`$fluidEmail->assign()` or dynamically adding mail information +such as the receivers list. The added values in the event take precedence over the +:typoscript:`modTSconfig` configuration. The event contains the full :typoscript:`modTSconfig` +to access further information about the actual configuration of the task when +assigning new values to `FluidEmail`. + +Note: As it's now also possible to set the recipient addresses dynamically using +the event, the `email` field in the task configuration can remain empty but will +be added, if defined, on top of already defined recipients from the event. All +other values such as `subject`, `from` or `replyTo` will only be set according to +`modTSconfig` if not already defined through the event. + +An example implementation of the PSR-14 event: + +.. code-block:: php + + <?php + declare(strict_types=1); + namespace Vendor\Extension\EventListener; + + use TYPO3\CMS\Linkvalidator\Event\ModifyValidatorTaskEmailEvent; + + class ModifyValidatorTaskEmail + { + public function modify(ModifyValidatorTaskEmailEvent $event): void + { + $linkAnalyzerResult = $event->getLinkAnalyzerResult(); + $fluidEmail = $event->getFluidEmail(); + $modTSconfig = $event->getModTSconfig(); + + if ($modTSconfig['mail.']['fromname'] === 'John Smith') { + $fluidEmail->assign('myAdditionalVariable', 'foobar'); + } + + $fluidEmail->subject( + $linkAnalyzerResult->getTotalBrokenLinksCount() . ' new broken links' + ); + + $fluidEmail->to(new Address('custom@mail.com')); + } + } + +.. code-block:: yaml + + Vendor\Extension\EventListener\ModifyValidatorTaskEmail: + tags: + - name: event.listener + identifier: 'modify-validation-task-email' + event: TYPO3\CMS\Linkvalidator\Event\ModifyValidatorTaskEmailEvent + method: 'modify' + +The :php:`\TYPO3\CMS\Linkvalidator\Result\LinkAnalyzerResult` contains following +information by default: + +* :php:`$oldBrokenLinkCounts` Amount of broken links from the last run, separated by type (e.g. all, internal) +* :php:`$newBrokenLinkCounts` Amount of broken links from this run, separated by type (e.g. all, internal) +* :php:`$brokenLinks` List of broken links with the raw database row +* :php:`$differentToLastResult` Whether the broken links count changed + +The :php:`brokenLinks` property gets further processed internally to provide additional +information for the email. Following additional information is provided by default: + +* :php:`full_record` The full record, the broken link was found in (e.g. pages or tt_content) +* :php:`record_title` Value of the :php:`full_record` title field +* :php:`record_type` The title of the record type (e.g. "Page" or "Page Content") +* :php:`language_code` The language code of the broken link +* :php:`real_pid` The real page id of the record the broken link was found in +* :php:`page_record` The whole page row of records parent page + +More can be added using the PSR-14 event. + +Additionally to the already existing content the email now includes a list of all +broken links fetched according to the task configuration. This list consists of +following columns: + +* `Record` The :php:`record_uid` and :php:`record_title` +* `Language` The :php:`language_code` and language id +* `Page` The :php:`real_pid` and :php:`page_record.title` of the parent page +* `Record Type` The :php:`record_type` +* `Link Target` The :php:`target` +* `Link Type` Type of the broken link (Either `internal`, `external` or `file`) + + +Impact +====== + +The main improvement is the more detailed report which is delivered by `FluidEmail`, +using the default `SystemEmail` layout. Along with the new PSR-14 event, extension authors +are now able to fully customize the content of the report as needed. + +.. index:: Backend, CLI, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/11.0/Feature-83814-AddSystemNotesCreationButtonToModulesButtonBar.rst b/Documentation/Changelog/11.0/Feature-83814-AddSystemNotesCreationButtonToModulesButtonBar.rst new file mode 100644 index 0000000..631bb44 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-83814-AddSystemNotesCreationButtonToModulesButtonBar.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-83814: + +======================================================================== +Feature: #83814 - Add system notes creation button to modules button bar +======================================================================== + +See :issue:`83814` + +Description +=========== + +System notes can be used to add internal information about a page in the backend. +The corresponding notes are being displayed in several modules, depending on the +records configuration, above or below the module content. Previously, one had +to always switch to the list module and usually also to the "new record" wizard +to create such notes. To improve the usability, a new button is added to the +button bar in the top right of page, list and info module. This allows to +directly create a new :php:`sys_note` record for the current page. + +The new button can be disabled via page TSconfig: + +.. code-block:: typoscript + + mod.SHARED.disableSysNoteButton = 1 + + +Impact +====== + +It's now possible to create system notes directly in the corresponding modules +using the button in the modules top right button bar. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-87301-SecureCookiesEnabledByDefault.rst b/Documentation/Changelog/11.0/Feature-87301-SecureCookiesEnabledByDefault.rst new file mode 100644 index 0000000..647d85d --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-87301-SecureCookiesEnabledByDefault.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-87301: + +=================================================== +Feature: #87301 - Secure cookies enabled by default +=================================================== + +See :issue:`87301` + +Description +=========== + +In previous TYPO3 installations an option existed to define +whether a cookie was shared between HTTP and HTTPS requests. + +This allowed to have the same cookie available for HTTPS and non-HTTPS, when a site was available on both ports / protocols. + +In order to enhance security, the option is removed and the feature +provides sensible defaults in the current state of the web, where +it is recommended to run sites with HTTPS, or if this is not possible +to use HTTP, but not using a mixed mode, which also has SEO downsides. + + +Impact +====== + +The new defaults are: + +* If a website is running on HTTPS, the cookie is only exposed via HTTPS. +* If a website is running on HTTP, the cookie is available for HTTPS as well, but not vice-versa. + +The TYPO3 Configuration option :php:`$TYPO3_CONF_VARS[SYS][cookieSecure]` is removed when upgrading TYPO3 installations. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.0/Feature-88276-TypoScriptConditionForPageLayout.rst b/Documentation/Changelog/11.0/Feature-88276-TypoScriptConditionForPageLayout.rst new file mode 100644 index 0000000..58d3bfc --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-88276-TypoScriptConditionForPageLayout.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-88276: + +====================================================== +Feature: #88276 - TypoScript Condition for page layout +====================================================== + +See :issue:`88276` + +Description +=========== + +A new condition enables integrators to check for the defined backend layout of a page including the +inheritance of the field *Backend Layout (subpages of this page)* + +.. code-block:: typoscript + + # Using backend_layout records + [tree.pagelayout == 2] + page.1 = TEXT + page.1.value = Layout 2 + [END] + + # Using TsConfig provider of Backend Layouts + [tree.pagelayout == "pagets__Home"] + page.1 = TEXT + page.1.value = Layout Home + [END] + +This condition is available for both frontend and backend. + +Impact +====== + +Change TypoScript or TsConfig based on the backend layout of a page. + +.. index:: Frontend, Backend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/11.0/Feature-89496-MakeContextMenuUsableViaKeyboard.rst b/Documentation/Changelog/11.0/Feature-89496-MakeContextMenuUsableViaKeyboard.rst new file mode 100644 index 0000000..c2b1fb7 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-89496-MakeContextMenuUsableViaKeyboard.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-89496: + +====================================================== +Feature: #89496: Make context menu usable via keyboard +====================================================== + +See :issue:`89496` + +Description +=========== + +The context menus are now usable via keyboard. Pressing Shift+F10 +will open the context menu for the focused element. It is also possible to use arrows, home and end keys +in order to navigate through the menu. Besides that, using enter and +space keys will active items or open submenus. + +This change follows the best practices as described in WAI-ARIA Authoring Practices 1.1, +see the `W3 document`_ for further reading. + +.. _W3 document: https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-12 + +Impact +====== + +Added :html:`tabindex`, :html:`role`, and :html:`aria-*` attributes to context menus +as advised in WAI-ARIA Authoring Practices 1.1. Screen readers are now +able to recognize the context menu properly. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-91712-RedirectModuleCleanupSchedulerTask.rst b/Documentation/Changelog/11.0/Feature-91712-RedirectModuleCleanupSchedulerTask.rst new file mode 100644 index 0000000..7a29b1c --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91712-RedirectModuleCleanupSchedulerTask.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-91712: + +====================================================================== +Feature: #91712 - Cleanup scheduler task and CLI command for redirects +====================================================================== + +See :issue:`91712` + +Description +=========== + +A new CLI command (which can also run as scheduler task) has been added to cleanup existing redirects periodically under given conditions. + +In the scheduler task settings it is possible to set the following options: + +- Age of records in days ( query usage: createdon < :age ) +- Domain(s) comma separated ( query usage: source_host IN (:domains) ) +- Hit Count ( query usage: hitcount < :hitCount ) +- Status code(s) comma separated ( query usage: target_statuscode IN (:statusCodes) ) (multiple values allowed) +- Path pattern ( query usage: source_path LIKE :path ) + +Depending on the settings, the query will look like: + +- :sql:`protected = 0 AND (hitcount < :hitCount) AND (createdon < :age) AND (source_host IN (:domains))` +- :sql:`protected = 0 AND (hitcount < 30) AND (createdon < 123456789) AND (source_host IN ('example.org', 'example.com'))` + +.. tip:: + + A new boolean flag "protected" has been introduced, which will be added as a pre-condition to all queries. + This flag can be set for any redirect to prevent deletion in the cleanup process. + +For the CLI command, the same options exist: + +- :bash:`bin/typo3 redirects:cleanup --domain foo.com --domain bar.com --age 90 --hitCount 100 --path "/foo/bar%" --statusCode 302 --statusCode 303` +- :bash:`bin/typo3 redirects:cleanup -d foo.com -d bar.com -a 90 -c 100 -p "/foo/bar%" -s 302 -s 303` + +The options of this command in detail: + +- `-d, --domain[=DOMAIN] Cleanup redirects matching provided domain(s) (multiple values allowed)` +- `-s, --statusCode[=STATUSCODE] Cleanup redirects matching provided status code(s) (multiple values allowed)` +- `-a, --days[=DAYS] Cleanup redirects older than provided number of days` +- `-c, --hitCount[=HITCOUNT] Cleanup redirects matching hit counts lower than given number` +- `-p, --path[=PATH] Cleanup redirects matching given path (as database like expression)` + +.. index:: Backend, CLI, Frontend, ext:redirects diff --git a/Documentation/Changelog/11.0/Feature-91719-CustomErrorMessagesInRegularExpressionValidator.rst b/Documentation/Changelog/11.0/Feature-91719-CustomErrorMessagesInRegularExpressionValidator.rst new file mode 100644 index 0000000..74d283e --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91719-CustomErrorMessagesInRegularExpressionValidator.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _feature-91719: + +===================================================================== +Feature: #91719 - Custom error messages in RegularExpressionValidator +===================================================================== + +See :issue:`91719` + +Description +=========== + +The :php:`RegularExpressionValidator` can now return a custom validation error message +to help the user providing valid input. + + +Impact +====== + +A new option :php:`errorMessage` has been introduced to the validator. + +Example: + +.. code-block:: php + + class MyModel extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + /** + * @var string + * @TYPO3\CMS\Extbase\Annotation\Validate( + * "RegularExpression", + * options={ + * "regularExpression": "/^SO[0-9]$/", + * "errorMessage": "explain how to provide a valid value" + * } + * ) + */ + protected $customField; + } + +It is also possible to provide a translation key to render a localized message: + +.. code-block:: php + + class MyModel extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + /** + * @var string + * @TYPO3\CMS\Extbase\Annotation\Validate( + * "RegularExpression", + * options={ + * "regularExpression": "/^SO[0-9]$/", + * "errorMessage": "LLL:EXT:my_extension/path/to/xlf:translation.key" + * } + * ) + */ + protected $customField; + } + +If no :php:`errorMessage` is provided, the default message will be displayed in case of a validation error. + +.. index:: Frontend, ext:extbase diff --git a/Documentation/Changelog/11.0/Feature-91738-IntroduceWrapperForSessionStorage.rst b/Documentation/Changelog/11.0/Feature-91738-IntroduceWrapperForSessionStorage.rst new file mode 100644 index 0000000..0a6a41a --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91738-IntroduceWrapperForSessionStorage.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-91738: + +====================================================== +Feature: #91738 - Introduce wrapper for sessionStorage +====================================================== + +See :issue:`91738` + +Description +=========== + +TYPO3 now ships a new module acting as wrapper for :js:`sessionStorage`. It +behaves similar to :js:`localStorage`, except that the stored data is dropped +after the browser session has ended. + + +Impact +====== + +The module :js:`TYPO3/CMS/Core/Storage/BrowserSession` is available to be used +to store data in the :js:`sessionStorage`. + +API Methods +----------- + +* `get(key)` To fetch the data behind the key. +* `set(key, value)` To set/override a key with any arbitrary content. +* `isset(key)` (bool) checks if the key is in use. +* `unset(key)` To remove a key from the storage. +* `clear()` to empty all data inside the storage. +* `unsetByPrefix(prefix)` to empty all data inside the storage with their keys starting with a prefix + +.. index:: JavaScript, ext:core diff --git a/Documentation/Changelog/11.0/Feature-91810-IntroduceLit-htmlAndLit-elementAsClient-sideTemplatingEngine.rst b/Documentation/Changelog/11.0/Feature-91810-IntroduceLit-htmlAndLit-elementAsClient-sideTemplatingEngine.rst new file mode 100644 index 0000000..bcc162e --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91810-IntroduceLit-htmlAndLit-elementAsClient-sideTemplatingEngine.rst @@ -0,0 +1,166 @@ +.. include:: /Includes.rst.txt + +.. _feature-91810: + +===================================================================================== +Feature: #91810 - Introduce lit-html and lit-element as client-side templating engine +===================================================================================== + +See :issue:`91810` + +Description +=========== + +To avoid custom jQuery template building a new slim client-side templating +engine lit-html_ together with lit-element_ is introduced. The modules +are available via the umbrella javascript module `lit`. + +This templating engine supports conditions, iterations, events, virtual DOM, +data-binding and mutation/change detections in templates. + +.. _lit-html: https://lit-html.polymer-project.org/ +.. _lit-element: https://lit-element.polymer-project.org/ + + +Impact +====== + +Individual client-side templates can be processed in JavaScript directly +using modern web technologies like template-strings_ and template-elements_. + +.. _template-strings: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals +.. _template-elements: https://developer.mozilla.org/de/docs/Web/HTML/Element/template + +Rendering is handled by the AMD-modules `lit-html` and `lit-element`. +Please consult the `lit-html` template-reference_ and lit-element-guide_ for more +information. + +.. _template-reference: https://lit-html.polymer-project.org/guide/template-reference +.. _lit-element-guide: https://lit-element.polymer-project.org/guide + +Examples +======== + +Variable assignment +------------------- + +.. code-block:: ts + + import {html, render} from 'lit'; + + const value = 'World'; + const target = document.getElementById('target'); + render(html`<div>Hello ${value}!</div>`, target); + +.. code-block:: html + + <div>Hello World!</div> + +Unsafe tags would have been encoded (e.g. :html:`<b>World</b>` +as :html:`<b>World</b>`). + + +Condition and iteration +----------------------- + +.. code-block:: ts + + import {html, render} from 'lit'; + import {classMap} from 'lit/directives/class-map.js'; + + const items = ['a', 'b', 'c'] + const classes = { list: true }; + const target = document.getElementById('target'); + const template = html` + <ul class=${classMap(classes)}"> + ${items.map((item: string, index: number): string => { + return html`<li>#${index+1}: ${item}</li>` + })} + </ul> + `; + render(template, target); + +.. code-block:: html + + <ul class="list"> + <li>#1: a</li> + <li>#2: b</li> + <li>#3: c</li> + </ul> + +The :js:`${...}` literal used in template tags can basically contain any +JavaScript instruction - as long as their result can be casted to `string` +again or is of type `lit.TemplateResult`. This allows to +make use of custom conditions as well as iterations: + +* condition: :js:`${condition ? thenReturn : elseReturn}` +* iteration: :js:`${array.map((item) => { return item; })}` + + +Events +------ + +Events can be bound using the `@` attribute prefix. + +.. code-block:: ts + + import {html, render} from 'lit'; + + const value = 'World'; + const target = document.getElementById('target'); + const template = html` + <div @click="${(evt: Event): void => { console.log(value); })}"> + Hello ${value}! + </div> + `; + render(template, target); + +The result won't look much different from the first example - however the +custom attribute :html:`@click` will be transformed into an according event +listener bound to the element where it has been declared. + +Custom HTML elements +-------------------- + +A web component based on the W3C custom elements (web-components_) specification +can be implemented using the `LitElement` base class. + +.. code-block:: ts + + import {LitElement, html} from 'lit'; + import {customElement, property} from 'lit/decorators'; + + @customElement('my-element') + class MyElement extends LitElement { + + // Declare observed properties + @property() + value: string = 'awesome'; + + // Avoid Shadow DOM so global styles apply to the element contents + createRenderRoot(): Element|ShadowRoot { + return this; + } + + // Define the element's template + render() { + return html`<p>Hello ${this.value}!</p>`; + } + } + +.. code-block:: html + + <my-element value="World"></my-element> + +This is rendered as: + +.. code-block:: html + + <my-element value="World"> + <p>Hello world!</p> + </my-element> + +.. _web-components: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements + + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-91859-AllowSelectCheckBoxGroupsToBeInitiallyExpanded.rst b/Documentation/Changelog/11.0/Feature-91859-AllowSelectCheckBoxGroupsToBeInitiallyExpanded.rst new file mode 100644 index 0000000..d9c88a8 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91859-AllowSelectCheckBoxGroupsToBeInitiallyExpanded.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-91859: + +====================================================================== +Feature: #91859 - Allow SelectCheckBox groups to be initially expanded +====================================================================== + +See :issue:`91859` + +Description +=========== + +A new TCA setting `expandAll` has been added to FormEngine type `select` with +renderType `selectCheckBox`. It allows to define the initial display behavior +for grouped checkboxes. + +By adding the new setting :php:`'expandAll' => true`, all select groups are +initially expanded. + +Please note, that the new setting is placed in :php:`['config']['appearance']` +and is not a top level configuration key. Therefore the full path is: +:php:`$GLOBALS['TCA'][$table]['columns'][$field]['config']['appearance']['expandAll']` + + +Example +======= + +.. code-block:: php + + 'select_checkbox' => [ + 'label' => 'select_checkbox - expandAll', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'appearance' => [ + 'expandAll' => true + ], + 'items' => [ + ['group 1', '--div--'], + ['check 1', 1], + ['check 2', 2], + ['check 3', 3], + ['group 2', '--div--'], + ['check 4', 4], + ['check 5', 5] + ] + ] + ] + + +Impact +====== + +It's now possible to initially expand all checkbox groups. Integrators can +therefore provide their editors with all the choices at once, without +having to open each select group individually. The possibility to close each +group remains unchanged for the editor. + +.. index:: TCA diff --git a/Documentation/Changelog/11.0/Feature-91890-ColumnOrderingInRedirectsOverview.rst b/Documentation/Changelog/11.0/Feature-91890-ColumnOrderingInRedirectsOverview.rst new file mode 100644 index 0000000..3369966 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-91890-ColumnOrderingInRedirectsOverview.rst @@ -0,0 +1,17 @@ +.. include:: /Includes.rst.txt + +.. _feature-91890: + +=========================================================================== +Feature: #91890 - Allow ordering of displayed columns in redirects overview +=========================================================================== + +See :issue:`91890` + +Description +=========== + +The redirects module now allows editors to sort the displayed redirects by +available columns. + +.. index:: Backend, ext:redirects diff --git a/Documentation/Changelog/11.0/Feature-92022-ShowWeekNumbersInDateTimePickerForEditors.rst b/Documentation/Changelog/11.0/Feature-92022-ShowWeekNumbersInDateTimePickerForEditors.rst new file mode 100644 index 0000000..3b8528c --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92022-ShowWeekNumbersInDateTimePickerForEditors.rst @@ -0,0 +1,20 @@ + +.. include:: /Includes.rst.txt + +.. _feature-92022: + +================================================================== +Feature: #92022 - Show week numbers in DateTimePicker for editors +================================================================== + +See :issue:`92022` + +Description +=========== + +In addition to the days of the week, the DateTimePicker now also shows the calendar weeks. +This is helpful for editors to find the desired date more quickly using a calendar week. + +The DateTimePicker is used in the TCA forms, for example, to define the start and end date for content or pages. + +.. index:: Backend, JavaScript diff --git a/Documentation/Changelog/11.0/Feature-92334-X-Redirect-ByHeaderForPagesWithRedirectTypes.rst b/Documentation/Changelog/11.0/Feature-92334-X-Redirect-ByHeaderForPagesWithRedirectTypes.rst new file mode 100644 index 0000000..b22ce4d --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92334-X-Redirect-ByHeaderForPagesWithRedirectTypes.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-92334: + +==================================================================== +Feature: #92334 - X-Redirect-By Header for pages with redirect types +==================================================================== + +See :issue:`92334` + +Description +=========== + +The following page types trigger a redirect: + +- Shortcut +- Mountpoint pages which should be overlaid but accessed directly +- Link to external URL + +Those redirects will now send an additional HTTP Header `X-Redirect-By`, stating what type of page triggered the redirect. +By enabling the new global option :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['exposeRedirectInformation']` the header will also contain the page ID. +As this exposes internal information about the TYPO3 system publicly, it should only be enabled for debugging purposes. + +For shortcut and mountpoint pages: :: + + X-Redirect-By: TYPO3 Shortcut/Mountpoint + # exposeRedirectInformation is enabled + X-Redirect-By: TYPO3 Shortcut/Mountpoint at page with ID 123 + +For *Links to External URL*: :: + + X-Redirect-By: TYPO3 External URL + # exposeRedirectInformation is enabled + X-Redirect-By: TYPO3 External URL at page with ID 456 + +Impact +====== + +The header `X-Redirect-By` makes it easier to understand why a redirect happens when checking URLs, e.g. by using `curl`: :: + + curl -I 'https://my-typo3-site.com/examples/pages/link-to-external-url/' + + HTTP/1.1 303 See Other + Date: Thu, 17 Sep 2020 17:45:34 GMT + X-Redirect-By: TYPO3 External URL at page with ID 12 + X-TYPO3-Parsetime: 0ms + location: https://typo3.org + Cache-Control: max-age=0 + Expires: Thu, 17 Sep 2020 17:45:34 GMT + X-UA-Compatible: IE=edge + Content-Type: text/html; charset=UTF-8 + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/11.0/Feature-92337-AllowTranslatableLabelsForBookmarkGroups.rst b/Documentation/Changelog/11.0/Feature-92337-AllowTranslatableLabelsForBookmarkGroups.rst new file mode 100644 index 0000000..5c21372 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92337-AllowTranslatableLabelsForBookmarkGroups.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-92337: + +=============================================================== +Feature: #92337 - Allow translatable labels for bookmark groups +=============================================================== + +See :issue:`92337` + +Description +=========== + +The user TSconfig option :typoscript:`options.bookmarkGroups` allows to configure the bookmark +groups that can be accessed by the user. In addition to that, it's also possible +to define custom labels for each group as simple :php:`string`. Extended TSconfig +syntax now allows the LLL prefix for the use of language labels. + + +Example +======= + +.. code-block:: typoscript + + options.bookmarkGroups.2 = LLL:EXT:sitepackage/Resources/Private/Language/locallang_be.xlf:bookmarkGroups.2 + + +Impact +====== + +It is now possible to use custom language labels for bookmark groups. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-92366-ShowFragmentsInPreviewOfInputLinkElement.rst b/Documentation/Changelog/11.0/Feature-92366-ShowFragmentsInPreviewOfInputLinkElement.rst new file mode 100644 index 0000000..3187c56 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92366-ShowFragmentsInPreviewOfInputLinkElement.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-92366: + +=============================================================== +Feature: #92366 - Show fragments in preview of InputLinkElement +=============================================================== + +See :issue:`92366` + +Description +=========== + +The rendertype `InputLinkElement` renders a preview of the link since :issue:`28171`. + +If a link to a page contains a fragment, this information has been added to the preview. + + +Impact +====== + +Editors are able to see the anchor information of a link without toggling the wizard. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-92423-EnablePlaceholderConfigForCkeditor.rst b/Documentation/Changelog/11.0/Feature-92423-EnablePlaceholderConfigForCkeditor.rst new file mode 100644 index 0000000..1334077 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92423-EnablePlaceholderConfigForCkeditor.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-92423: + +======================================================== +Feature: #92423 - Enable placeholder config for ckeditor +======================================================== + +See :issue:`92423` + +Description +=========== + +After the update of ckeditor to version 4.15.0, a new ckeditor plugin, called +**Editor Placeholder**, is now available. More information along with an example +can be found in the official documentation_. + +The placeholder configuration of TCA type `text` is now fed into the ckeditor plugin: + +.. code-block:: php + + 'bodytext' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.text', + 'config' => [ + 'placeholder' => 'This is a placeholder', + 'type' => 'text', + 'enableRichtext' => true, + ] + ] + + +Impact +====== + +Editors can now be supported by providing a placeholder text also for the ckeditor. + +.. _documentation: https://ckeditor.com/docs/ckeditor4/latest/examples/editorplaceholder.html + +.. index:: Backend, ext:rte_ckeditor diff --git a/Documentation/Changelog/11.0/Feature-92457-ImprovedExtensionRepositoryAPI.rst b/Documentation/Changelog/11.0/Feature-92457-ImprovedExtensionRepositoryAPI.rst new file mode 100644 index 0000000..2087043 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92457-ImprovedExtensionRepositoryAPI.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _feature-92457: + +=================================================== +Feature: #92457 - Improved Extension Repository API +=================================================== + +See :issue:`92457` + +Description +=========== + +In previous TYPO3 Installations, connecting to a different repository type +than the "official" TER_ (TYPO3 Extension Repository), for downloading +publicly available third-party extensions, the Extension Manager component +of TYPO3 Core used a non-documented API to connect to this endpoint. + +In the past, there were even mirrors available, which is not practical +in the current internet world anymore. + +In order to be more flexible in the future, all functionality has now been +encapsulated into a single API called "Extension Remotes". These are adapters +to fetch a list of extensions via the :php:`ListableRemoteInterface`, or to +download an extension via the :php:`ExtensionDownloaderRemoteInterface`. + +This way it is possible to adapt any kind of remote, where as the existing +concrete implementation is now built into a configuration, rather than the +database for "repositories". + +It is also still possible to add new remotes, disable registered remotes +or change the default remote. + +Custom remote configuration can be added in the +:file:`Configuration/Services.yaml` of the corresponding extension. + +.. code-block:: yaml + + extension.remote.myremote: + class: 'TYPO3\CMS\Extensionmanager\Remote\TerExtensionRemote' + arguments: + $identifier: 'myremote' + $options: + remoteBase: 'https://my_own_remote/' + tags: + - name: 'extension.remote' + default: true + +Using :yaml:`default: true`, "myremote" will be used as the default remote. + +To disable an already registered remote, :yaml:`enabled: false` can be set. + +It is also possible to use custom remote implementations to not have to deal +with `t3x` files anymore. + +.. code-block:: yaml + + extension.remote.myremote: + class: 'Vendor\SitePackage\Remote\MyRemote' + arguments: + $identifier: 'myremote' + tags: + - name: 'extension.remote' + default: true + +Please note that :php:`Vendor\SitePackage\Remote\MyRemote` must implement +:php:`ExtensionDownloaderRemoteInterface` to be registered as remote. + +Furthermore setting :yaml:`default: true` only works if the defined service +implements :php:`ListableRemoteInterface`. + + +Impact +====== + +Because of the removed mirror functionality and the encapsulation +of the TER API into one concrete implementation, it is much easier +to extend the Extension Manager functionality for third-party usages. + +This is only relevant for non-composer-mode installations, +as composer-based installations use the download functionality +of composer. + +.. _TER: https://extensions.typo3.org/ + +.. index:: ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Feature-92462-AddOptionalDefaultValuesArgumentToNewRecordViewHelpers.rst b/Documentation/Changelog/11.0/Feature-92462-AddOptionalDefaultValuesArgumentToNewRecordViewHelpers.rst new file mode 100644 index 0000000..0351173 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92462-AddOptionalDefaultValuesArgumentToNewRecordViewHelpers.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-92462: + +================================================================================ +Feature: #92462 - Add optional "defaultValues" argument to newRecord ViewHelpers +================================================================================ + +See :issue:`92462` + +Description +=========== + +A new optional argument :html:`defaultValues` is added to the :html:`be:uri.newRecord` and +:html:`be:link.newRecord` ViewHelpers. The new argument can contain default values for +fields of the new record. FormEngine automatically fills the given default values +into the corresponding fields. + +The syntax is: :html:`{tableName: {fieldName: 'value'}}`. + +Please note that the given default values are added to the url as :html:`GET` parameters +and therefore override default values defined in FormDataProviders or TSconfig. + + +Impact +====== + +It is now possible to assign default values to fields of new records using the +`defaultValues` argument in the `be:uri.newRecord` and `be:link.newRecord` ViewHelpers. + + +Example +======= + +Link to create a new `tt_content` record on page 17 with a default value for field `header`: + +.. code-block:: xml + + <be:link.newRecord table="tt_content" pid="17" defaultValues="{tt_content: {header: 'value'}}" returnUrl="foo/bar"> + New record + </be:link.newRecord> + +Output: + +.. code-block:: html + + <a href="/typo3/index.php?route=/record/edit&edit[tt_content][17]=new&returnUrl=foo/bar&defVals[tt_content][header]=value"> + New record + </a> + +.. index:: Backend, Fluid, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-92486-AddFieldControlToFile_collectionsOfTt_content.rst b/Documentation/Changelog/11.0/Feature-92486-AddFieldControlToFile_collectionsOfTt_content.rst new file mode 100644 index 0000000..9ac5318 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92486-AddFieldControlToFile_collectionsOfTt_content.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-92486: + +===================================================================== +Feature: #92486 - Add field control to file_collections of tt_content +===================================================================== + +See :issue:`92486` + +Description +=========== + +The TCA configuration of the field `file_collections` of `tt_content` has been improved by adding +the field control `addRecord`. + + +Impact +====== + +The new field control allows editors to create a new file collection record without leaving the +content element of type "uploads". + +.. index:: Backend, TCA, ext:frontend diff --git a/Documentation/Changelog/11.0/Feature-92522-ShowTableAndFieldNamesInExtlowlevel.rst b/Documentation/Changelog/11.0/Feature-92522-ShowTableAndFieldNamesInExtlowlevel.rst new file mode 100644 index 0000000..36d5a06 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92522-ShowTableAndFieldNamesInExtlowlevel.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-92522: + +============================================================ +Feature: #92522 - Show table and field names in ext:lowlevel +============================================================ + +See :issue:`92522` + +Description +=========== + +If the configuration `['BE']['debug']` is enabled and the current user is an +administrator, the name of a DB table or DB field is appended to the select +options in the "Full Search" module of ext:lowlevel. + + +Impact +====== + +This simplifies working with and debugging problems inside the full search of +ext:lowlevel, as developers usually know the DB table and field names better +than their labels configured in TCA. + +.. index:: Backend, ext:lowlevel diff --git a/Documentation/Changelog/11.0/Feature-92531-ImprovedEmailValidation.rst b/Documentation/Changelog/11.0/Feature-92531-ImprovedEmailValidation.rst new file mode 100644 index 0000000..9c1e81c --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92531-ImprovedEmailValidation.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-92531: + +=========================================== +Feature: #92531 - Improved Email Validation +=========================================== + +See :issue:`92531` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::validEmail()` is used to +validate a given email address through the core and TYPO3 extensions. + +The validation can now be configured by providing the used validators in +the :file:`LocalConfiguration.php` or :file:`AdditionalConfiguration.php`: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['MAIL']['validators'] = [ + \Egulias\EmailValidator\Validation\RFCValidation::class, + \Egulias\EmailValidator\Validation\DNSCheckValidation::class + ]; + +By default, the validator :php:`\Egulias\EmailValidator\Validation\RFCValidation` +is used. The following validators are available by default: + +- :php:`\Egulias\EmailValidator\Validation\DNSCheckValidation` +- :php:`\Egulias\EmailValidator\Validation\SpoofCheckValidation` +- :php:`\Egulias\EmailValidator\Validation\NoRFCWarningsValidation` + +Additionally it is possible to provide an own implementation by implementing the +interface :php:`\Egulias\EmailValidator\Validation\EmailValidation`. + +If multiple validators are provided, each validator must return `TRUE`. + + +Impact +====== + +Using additional validators can help to identify if a provided email address is +valid or not. + +.. index:: LocalConfiguration, PHP-API, ext:core diff --git a/Documentation/Changelog/11.0/Feature-92538-ShowExtensionConstraintsInExtensionManager.rst b/Documentation/Changelog/11.0/Feature-92538-ShowExtensionConstraintsInExtensionManager.rst new file mode 100644 index 0000000..d6b2740 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92538-ShowExtensionConstraintsInExtensionManager.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-92538: + +================================================================= +Feature: #92538 - Show extension constraints in extension manager +================================================================= + +See :issue:`92538` + +Description +=========== + +The extension manager features an "all versions" view, where integrators can +access detailed information about an extension including all available versions +similar to the detail page of an extension in TER_ (TYPO3 Extension Repository). +This view now also displays the constraints ('depends', 'suggests', 'conflicts') +of the extension. + +You find this view by opening the Extension Manager Module, selecting +'Get extensions' from the module drop down, then click on the extension name +that you want to scrutinize. + +If the extension does not define any of the constraints mentioned above, +the view does not differ from the current state. + +Furthermore constraints, which do not match the current TYPO3 or PHP version, +are displayed with a warning about the incompatibility. + + +Impact +====== + +It's now possible to see extension constraints directly in the extension manager +"all versions" view of an extension via 'Get extensions' list. + +.. _TER: https://extensions.typo3.org/ + +.. index:: Backend, ext:extensionmanager diff --git a/Documentation/Changelog/11.0/Feature-92562-FrontendGroupsResolvedDirectlyAfterTheFrontendUserItself.rst b/Documentation/Changelog/11.0/Feature-92562-FrontendGroupsResolvedDirectlyAfterTheFrontendUserItself.rst new file mode 100644 index 0000000..f31f24e --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92562-FrontendGroupsResolvedDirectlyAfterTheFrontendUserItself.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-92562: + +================================================================================== +Feature: #92562 - Frontend groups resolved directly after the Frontend User itself +================================================================================== + +See :issue:`92562` + +Description +=========== + +For legacy purposes, the valid frontend user groups were added while resolving +the root line in TypoScriptFrontendController. This is much later in the frontend +request process than the preparation of the Frontend User, which is resolved by +the session or form credentials. + +There are several reasons for the historic behavior: + +* Special functionality like "pages.fe_login_mode" which can override groups based on the current root line +* Previewing frontend user groups via the Admin Panel for backend users + +However, this historic behavior led to inconsistencies, especially with the +Context API to retrieve the correct usergroups in PSR-15 middlewares to build custom APIs. + +In addition, this functionality is now extracted from TSFE and into the middleware, +further decoupling the User authentication from the TSFE object. + + +Impact +====== + +When the PSR-15 middleware is setting up the FrontendUserAuthentication object +at a very early stage of the frontend request, the groups are resolved directly +afterwards, leaving the FrontendUserAuthentication object in a consistent state +for further middlewares to work with the appropriate groups. + +Please note that any existing custom AuthenticationService for resolving frontend +user groups, which might rely on a valid TSFE object will have to be evaluated +if it still works in TYPO3 v11. + +Also: Further middlewares and TSFE itself can still override the assigned groups +due to previewing behavior. + +.. index:: ext:frontend diff --git a/Documentation/Changelog/11.0/Feature-92616-BootstrapV5.rst b/Documentation/Changelog/11.0/Feature-92616-BootstrapV5.rst new file mode 100644 index 0000000..6314afc --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92616-BootstrapV5.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-92616: + +============================== +Feature: #92616 - Bootstrap v5 +============================== + +See :issue:`92616` + +Description +=========== + +TYPO3s Backend is powered with Bootstrap v5 (https://getbootstrap.com). + +Previous versions ran with Bootstrap v3. A lots of changes and +improvements are included, especially regarding simplification +of building Layouts and Containers. + +Backend modules which relied on Twitter Bootstrap v3 markup, +or functionality in JavaScript, will have a slightly different +and improved look & feel. + + +Impact +====== + +It is important during updates of custom backend modules +to study the Bootstrap Migration guidelines and adapt accordingly: + +* https://getbootstrap.com/docs/4.5/migration/ +* https://getbootstrap.com/docs/5.0/migration/ + +TYPO3 Core still includes some legacy styling and functionality +to provide compatibility, but backend markup and code will +be adapted during further TYPO3 v11 development to simplify +the HTML and CSS code shipped with TYPO3 Core. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-92815-IntroduceForwardResponseForExtbase.rst b/Documentation/Changelog/11.0/Feature-92815-IntroduceForwardResponseForExtbase.rst new file mode 100644 index 0000000..811d953 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92815-IntroduceForwardResponseForExtbase.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _feature-92815: + +======================================================= +Feature: #92815 - Introduce ForwardResponse for extbase +======================================================= + +See :issue:`92815` + +Description +=========== + +Since TYPO3 11.0, extbase controller actions can and should return PSR-7 compatible response objects. +To allow the initiation of forwarding to another controller action class :php:`TYPO3\CMS\Extbase\Http\ForwardResponse` has been introduced. + +Minimal example: + +.. code-block:: php + + <?php + + use Psr\Http\Message\ResponseInterface; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + use TYPO3\CMS\Extbase\Http\ForwardResponse; + + class FooController extends ActionController + { + public function listAction(): ResponseInterface + { + // do something + + return new ForwardResponse('show'); + } + } + + +Example that shows the full api: + +.. code-block:: php + + <?php + + use Psr\Http\Message\ResponseInterface; + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + use TYPO3\CMS\Extbase\Http\ForwardResponse; + + class FooController extends ActionController + { + public function listAction(): ResponseInterface + { + // do something + + return (new ForwardResponse('show')) + ->withControllerName('Bar') + ->withExtensionName('Baz') + ->withArguments(['foo' => 'bar']) + ; + } + } + +Impact +====== + +Class :php:`TYPO3\CMS\Extbase\Http\ForwardResponse` allows users to initiate forwarding to other controller actions with a PSR-7 compatible response object. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/11.0/Feature-92884-ApplicationsImplementPSR-15RequestHandlerInterface.rst b/Documentation/Changelog/11.0/Feature-92884-ApplicationsImplementPSR-15RequestHandlerInterface.rst new file mode 100644 index 0000000..9d50a45 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92884-ApplicationsImplementPSR-15RequestHandlerInterface.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-92884: + +======================================================================= +Feature: #92884 - Applications implement PSR-15 RequestHandlerInterface +======================================================================= + +See :issue:`92884` + +Description +=========== + +The TYPO3 core has three application classes: frontend, backend and install tool. +Those are the main entry points to retrieve a PSR-7 response from a PSR-7 request. +These application classes now implement the PSR-15 :php:`RequestHandlerInterface`. + + +Impact +====== + +Implementing the interface increases interoperability with third party applications +and allows feeding a PSR-7 request to one of the three applications and +retrieve a PSR-7 response. + +Within TYPO3, a first usage is the testing framework: A functional backend test +can call the frontend application to verify if content is rendered as expected. +At the moment, the TYPO3 internal state is still a bit tricky, though. There are +various places that for instance park state in static class properties which can +not be reset easily. Those areas are "dirty" after a request has been handled. +Additionally, TYPO3 still alters various :php:`$GLOBALS` while handling a request. +The testing framework works around that at the moment. + +While the situation will improve over time, third party applications should handle +this feature with care for the time being and may need to take care of additional +state clearing steps. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.0/Feature-92929-ExtendableConfigurationModule.rst b/Documentation/Changelog/11.0/Feature-92929-ExtendableConfigurationModule.rst new file mode 100644 index 0000000..90ff4ff --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92929-ExtendableConfigurationModule.rst @@ -0,0 +1,150 @@ +.. include:: /Includes.rst.txt + +.. _feature-92929: + +================================================= +Feature: #92929 - Extendable configuration module +================================================= + +See :issue:`92929` + +Description +=========== + +Since a long time, the configuration module in EXT:lowlevel was the first +stop for integrators when it came to validation of the global configuration. +The module displays all relevant global variables such as +:php:`TYPO3_CONF_VARS`, :php:`TCA` and many more, in a tree format which is +easy to browse through. Over time this module got extended to also display +the configuration of newly introduced features like the middleware stack or +the event listeners. + +To make this module even more powerful, a dedicated API was introduced which +allows extension authors to extend the module so they can expose their own +configurations. + +By the nature of the new API it is even possible to not just add new +configuration but to also disable existing, if not needed in the specific +installation. + +So, how does it work? +********************** + +Each "provider", responsible for one configuration, is registered as a so-called +"configuration module provider". This is done in the corresponding +:file:`Services.yaml` file of each extension. Each provider is tagged and will +then automatically be registered. Therefore, the provider class must implement +the :php:`ProviderInterface` which requires some methods to be present in the +provider class so they can be called from within the module. + +The registration of such a provider looks like the following: + +.. code-block:: yaml + + myextension.configuration.module.provider.myconfiguration: + class: 'Vendor\Extension\ConfigurationModuleProvider\MyProvider' + tags: + - name: 'lowlevel.configuration.module.provider' + identifier: 'myProvider' + before: 'beUserTsConfig' + after: 'pagesTypes' + +A new service with a freely selectable name is defined by specifying the +provider class to be used. Further, the new service must be tagged with the +:yaml:`lowlevel.configuration.module.provider` tag. Arbitrary attributes +can be added to this tag. However, some are reserved and required for internal +processing. For example, the :yaml:`identifier` attribute is mandatory and must be +unique. Using the :yaml:`before` and :yaml:`after` attributes, it is possible to specify +the exact position on which the configuration will be displayed in the module +menu. + +The provider class has to implement some methods, required by the interface. +A full implementation would look like this: + +.. code-block:: php + + <?php + + use TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\ProviderInterface; + + class MyProvider implements ProviderInterface + { + protected string $identifier; + + public function __invoke(array $attributes): self + { + $this->identifier = $attributes['identifier']; + return $this; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getLabel(): string + { + return 'My custom configuration'; + } + + public function getConfiguration(): array + { + return $myCustomConfiguration; + } + } + +The :php:`__invoke()` method is called from the provider registry and provides +all attributes, defined in the :file:`Services.yaml`. This can be used to set +and initialize class properties like the `$identifier` which can then be returned +by the required method :php:`getIdentifier()`. The :php:`getLabel()` method is +called by the configuration module when creating the module menu. And finally, +the :php:`getConfiguration()` method has to return the configuration as an +:php:`array` to be displayed in the module. + +There is also the abstract class +:php:`TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\AbstractProvider` in place +which already implements the required methods except :php:`getConfiguration`. +Please note, when extending this class, the attribute `label` is expected in the +`__invoke()` method and must therefore be defined in the :file:`Services.yaml`. +Either a static text or a locallang label can be used. + +Since the registration uses the Symfony service container and provides all +attributes using :php:`__invoke()`, it is even possible to use DI with +constructor arguments in the provider classes. + +If you just want to display a custom configuration from the `$GLOBALS` array, +you can also use the already existing +:php:`TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\GlobalVariableProvider`. +Simply define the key to be exposed using the `globalVariableKey` attribute. + +This could look like this: + +.. code-block:: yaml + + myextension.configuration.module.provider.myconfiguration: + class: 'TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\GlobalVariableProvider' + tags: + - name: 'lowlevel.configuration.module.provider' + identifier: 'myConfiguration' + label: 'My global var' + globalVariableKey: 'MY_GLOBAL_VAR' + +To disable an already registered configuration simply add the :yaml:`disabled: true` +attribute. For example, if you intend to disable the :php:`TCA_DESCR` key you can use: + +.. code-block:: yaml + + lowlevel.configuration.module.provider.tcadescr: + class: TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\GlobalVariableProvider + tags: + - name: 'lowlevel.configuration.module.provider' + disabled: true + +Impact +====== + +It is now possible to extend the configuration module for custom configurations +and to manage the available options for the module by disabling any provider +shipped by core or another third-party extension. + +.. index:: Backend, PHP-API, ext:lowlevel diff --git a/Documentation/Changelog/11.0/Feature-92984-PSR-7RequestAvailableInFrontendContentObjects.rst b/Documentation/Changelog/11.0/Feature-92984-PSR-7RequestAvailableInFrontendContentObjects.rst new file mode 100644 index 0000000..6f58b82 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-92984-PSR-7RequestAvailableInFrontendContentObjects.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _feature-92984: + +==================================================================== +Feature: #92984 - PSR-7 Request available in Frontend ContentObjects +==================================================================== + +See :issue:`92984` + +Description +=========== + +The main Request object of a web-based PHP process is now handed into all +:php:`ContentObjects` and :php:`ContentObjectRenderer` classes. + +In addition, any kind of "userFunc" methods initiated from :php:`ContentObjectRenderer`, +basically all custom Frontend PHP code, now receives the request object that was +handed in as third method argument. + +The :php:`ContentObjectRenderer` API now has a :php:`getRequest()` method. + +Example: + +.. code-block:: typoscript + + page.10 = USER + page.10.userFunc = MyVendor\MyPackage\Frontend\MyClass->myMethod + +.. code-block:: php + + <?php + + namespace MyVendor\MyPackage\Frontend; + + class MyClass + { + + public function myMethod(string $content, array $configuration, ServerRequestInterface $request) + { + $myValue = $request->getQueryParams()['myGetParameter']; + $normalizedParams = $request->getAttribute('normalizedParams'); + } + } + +This functionality should be used in PHP code related to Frontend code instead of +the superglobal variables like :php:`$_GET` / :php:`$_POST` / :php:`$_SERVER`, or TYPO3s +API methods :php:`GeneralUtility::_GP()` and :php:`GeneralUtility::getIndpEnv()`. + +Impact +====== + +Any kind of custom Content Object in PHP code can now access the PSR-7 Request +object to fetch information about the current request, making TYPO3 Frontend +aware of PSR-7 standardized request objects. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/11.0/Feature-93011-Authentication-relatedCookiesAreAttachedToPSR-7Responses.rst b/Documentation/Changelog/11.0/Feature-93011-Authentication-relatedCookiesAreAttachedToPSR-7Responses.rst new file mode 100644 index 0000000..8de7326 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-93011-Authentication-relatedCookiesAreAttachedToPSR-7Responses.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-93011: + +================================================================================ +Feature: #93011 - Authentication-related cookies are attached to PSR-7 Responses +================================================================================ + +See :issue:`93011` + +Description +=========== + +Cookies, used to keep the session identifiers for Frontend sessions +and Backend user sessions, were previously added natively via PHP +:php:`header()` and :php:`setcookie()` at the very beginning +of the User Authentication workflow, although this did not allow +for later-on manipulation of these HTTP response headers, as they were +emitted directly via PHP when calling the native PHP functions. + +TYPO3 now attaches the cookie information for the user session information to +the PSR-7 Responses, by default in a PSR-15 middleware. + + +Impact +====== + +It is now possible to attach the cookies to a PSR-7 Response via +:php:`$GLOBALS[BE_USER]->appendCookieToResponse()`, which is especially handy +in custom middlewares that have custom endpoints when using other PHP +frameworks via the PSR-15 middleware stack. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/11.0/Feature-93023-IntroduceUserSessionAndUserSessionManager.rst b/Documentation/Changelog/11.0/Feature-93023-IntroduceUserSessionAndUserSessionManager.rst new file mode 100644 index 0000000..5e381f0 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-93023-IntroduceUserSessionAndUserSessionManager.rst @@ -0,0 +1,161 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Feature-93023-IntroduceUserSessionAndUserSessionManager: + +============================================================== +Feature: #93023 - Introduce UserSession and UserSessionManager +============================================================== + +See :issue:`93023` + +Description +=========== + +As described in :ref:`changelog-Deprecation-93023-ReworkedSessionHandling` +the whole session handling in the TYPO3 Core was restructured by moving it +out of the user authentication objects into dedicated classes, namely +:php:`UserSession` and :php:`UserSessionManager`. + +The :php:`UserSession` object contains of all necessary information +regarding a users session, for website visitors with session data (e.g. +shopping basket for anonymous / not-logged-in users), for frontend users as well as +authenticated backend users. These are for example the session id, +the session data, if a session was updated, if the session is anonymous, +or if it is marked permanent and so on. This replaces the so called +:php:`sessionRecord` which was an :php:`array` used in the user authentication objects. + +This means, there is now a proper object which can be used to change and +retrieve information in an object-oriented way. It also features a +:php:`toArray()` function to obtain these information in the "old" format. + +Using the static factory methods :php:`createFromRecord()` and +:php:`createNonFixated()` one can easily create a new session object. + +Public Methods within `UserSession` +----------------------------------- + ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| Method | Return type | Description | ++=====================+=============+===================================================================================+ +| getIdentifier() | String | Returns the session id. This is the :php:`ses_id` respectively the | +| | | :php:`AbstractUserAuthentication->id`. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| getUserId() | Int or NULL | Returns the user id the session belongs to. Can also return `0` or NULL | +| | | which indicates an anonymous session. This is the :php:`ses_userid`. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| getLastUpdated() | Int | Returns the timestamp of the last session data update. This is the | +| | | :php:`ses_tstamp`. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| set($key, $value) | Void | Set or update session data value for a given key. It's also internally used | +| | | if calling :php:`AbstractUserAuthentication->setSessionData()`. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| get($key) | Mixed | Returns the session data for the given key or NULL if the key does not | +| | | exist. It's internally used if calling | +| | | :php:`AbstractUserAuthentication->getSessionData()`. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| getData() | Array | Returns the whole data array. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| hasData() | Bool | Checks whether the session has some data assigned. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| overrideData($data) | Void | Overrides the whole data array. Can also be used to unset the array. This | +| | | also sets the :php:`$wasUpdated` pointer to :php:`TRUE` | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| dataWasUpdated() | Bool | Checks whether the session data has been updated. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| isAnonymous() | Bool | Check if the user session is an anonymous one. This means, the session does | +| | | not belong to a logged-in user. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| getIpLock() | string | Returns the ipLock state of the session | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| isNew() | Bool | Checks whether the session is new. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| isPermanent() | Bool | Checks whether the session was marked as permanent on creation. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| needsUpdate() | Bool | Checks whether the session has to be updated. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ +| toArray() | Array | Returns the session and its data as array in the old :php:`sessionRecord` format. | ++---------------------+-------------+-----------------------------------------------------------------------------------+ + +It should however be always considered to use the :php:`UserSessionManager` +for creating new sessions since this manager acts as the main factory for user +sessions and handles all necessary tasks like fetching, evaluating +and persisting them. Effectively encapsulating all calls to the +:php:`SessionManager` which is used for the Session Backend. + +The :php:`UserSessionManager` can be retrieved using it's static factory +method :php:`create()`. + +As already mentioned you can then use the :php:`UserSessionManager` to work +with user sessions. A couple of public methods are available. + +Public Methods within `UserSessionManager` +------------------------------------------ + ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| Method | Description | ++===============================================================+=======================================================================+ +| createFromRequestOrAnonymous($request, $cookieName) | Creates and returns a session from the given request. If the given | +| | :php:`cookieName` can not be obtained from the request an anonymous | +| | session will be returned. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| createFromGlobalCookieOrAnonymous($cookieName) | Creates and returns a session from a global cookie (:php:`$_COOKIE`). | +| | If no cookie can be found for the given name, an anonymous session | +| | will be returned. | +| | It is recommended to use the PSR-7 Request based method instead, | +| | as this method is scheduled for removal in TYPO3 v13.0. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| createAnonymousSession() | Creates and returns an anonymous session object (not persisted). | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| createSessionFromStorage($sessionId) | Creates and returns a new session object for a given session id. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| hasExpired($session) | Checks whether a given user session object has expired. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| willExpire($session, $gracePeriod) | Checks whether a given user session will expire within the given | +| | grace period. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| fixateAnonymousSession($session, $isPermanent) | Persists an anonymous session without a user logged in, in order to | +| | store session data between requests. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| elevateToFixatedUserSession($session, $userId, $isPermanent) | Removes existing entries, creates and returns a new user session | +| | object. See regenerateSession() below. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| regenerateSession($sessionId, $sessionRecord, $anonymous) | Regenerates the given session. This method should be used whenever a | +| | user proceeds to a higher authorization level, e.g. when an | +| | anonymous session is now authenticated. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| updateSessionTimestamp($session) | Updates the session timestamp for the given user session if the | +| | session is marked as "needs update" (which means the current | +| | timestamp is greater than "last updated + a specified gracetime"). | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| isSessionPersisted($session) | Checks whether a given session is already persisted. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| removeSession($session) | Removes a given session from the session backend. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| updateSession($session) | Updates the session data + timestamp in the session backend. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ +| collectGarbage(garbageCollectionProbability) | Calls the session backends :php:`collectGarbage()` method. | ++---------------------------------------------------------------+-----------------------------------------------------------------------+ + + +Impact +====== + +The user authentication classes such as +:php:`BackendUserAuthentication`, :php:`FrontendUserAuthentication` and their abstract parent class +:php:`AbstractUserAuthentication`, do now not longer +directly manage the corresponding user session. Therefore these objects +do not longer include the session data and do not know about the specific +session backend implementation. + +The main benefit is the centralized handling of sessions via the new +:php:`UserSession` object which contains of all relevant information +and the :php:`UserSessionManager`. Latter should be used as factory +to create new sessions for various use-cases. + +Related +======= + +- :ref:`changelog-Breaking-93023-ReworkedSessionHandling` +- :ref:`changelog-Deprecation-93023-ReworkedSessionHandling` + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.0/Feature-93048-IntroduceBackendURLRewrites.rst b/Documentation/Changelog/11.0/Feature-93048-IntroduceBackendURLRewrites.rst new file mode 100644 index 0000000..19f4bee --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-93048-IntroduceBackendURLRewrites.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _changelog-Feature-93048-IntroduceBackendURLRewrites: + +================================================ +Feature: #93048 - Introduce Backend URL rewrites +================================================ + +See :issue:`93048` + +Description +=========== + +The TYPO3 backend does now feature URL rewrites which allows +the use of human readable urls. This will enable TYPO3 +to introduce deep-linking functionality in the future. By +that, it will be possible to share URLs, which directly link +to a specific module or even a specific record, in the backend. + +Example +------- + +.. code-block:: none + + // Before + https://example.com/typo3/index.php?route=%2Fmain + + // After + https://example.com/typo3/main + + +This feature is enabled by default and will work as soon as +the necessary rewrite rule is added in the webserver configuration. +See: :ref:`changelog-Breaking-93048-BackendURLRewrites` for more +details about this. + +To generate human readable urls for custom backend modules and routes, +extension authors can use the public :php:`UriBuilder` API. + + +Impact +====== + +TYPO3 now builds human readable urls for the backend by default. +Extension authors also automatically benefit form this when +using the public :php:`UriBuilder` API. + + +Related +======= + +- :ref:`changelog-Breaking-93048-BackendURLRewrites` + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-93056-NewEventAfterRetrievingUserGroupsRecursively.rst b/Documentation/Changelog/11.0/Feature-93056-NewEventAfterRetrievingUserGroupsRecursively.rst new file mode 100644 index 0000000..022fbff --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-93056-NewEventAfterRetrievingUserGroupsRecursively.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-93056: + +==================================================================== +Feature: #93056 - New Event after retrieving user groups recursively +==================================================================== + +See :issue:`93056` + +Description +=========== + +When user groups are loaded, for example when a backend editors groups and permissions +are calculated, a new PSR-14 event :php:`AfterGroupsResolvedEvent` is fired. + + +Impact +====== + +This Event contains a list of retrieved groups from the database, which can +be modified (e.g. adding more groups when a particular user or a user from a +given location is logged in) via Event listeners. + +This event acts as a substitution for the removed TYPO3 Hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['fetchGroups_postProcessing']`. + +.. index:: PHP-API, ext:backend diff --git a/Documentation/Changelog/11.0/Feature-93063-FlashMessagesAreStoredInSessionAsJsonSerializable.rst b/Documentation/Changelog/11.0/Feature-93063-FlashMessagesAreStoredInSessionAsJsonSerializable.rst new file mode 100644 index 0000000..319e1f1 --- /dev/null +++ b/Documentation/Changelog/11.0/Feature-93063-FlashMessagesAreStoredInSessionAsJsonSerializable.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-93063: + +========================================================================= +Feature: #93063 - FlashMessages are stored in session as JsonSerializable +========================================================================= + +See :issue:`93063` + +Description +=========== + +FlashMessages which are used to show information across backend +modules and frontend plugins / forms, are mostly stored in the +session data of a user session. + +They are now stored as json_encoded data, using the already existing +:php:`JsonSerializable` functionality of AbstractMessage. + + +Impact +====== + +This way, the FlashMessage objects are only built when they are +needed and not on every PHP call the user session is started, +making e.g. AJAX calls a tiny bit faster. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.0/Important-89938-RemovedDeadCodeFromExtbasePersistence.rst b/Documentation/Changelog/11.0/Important-89938-RemovedDeadCodeFromExtbasePersistence.rst new file mode 100644 index 0000000..396e365 --- /dev/null +++ b/Documentation/Changelog/11.0/Important-89938-RemovedDeadCodeFromExtbasePersistence.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _important-89938: + +============================================================== +Important: #89938 - Removed dead code from Extbase persistence +============================================================== + +See :issue:`89938` + +Description +=========== + +The following public methods have been removed from Extbase persistence: + +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend->getSession()` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend->getQomFactory()` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Backend->getReflectionService()` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper->isPersistableProperty()` +- :php:`TYPO3\CMS\Core\Session\SessionManager->replaceReconstitutedEntity()` +- :php:`TYPO3\CMS\Core\Session\SessionManager->isReconstitutedEntity()` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbBackend->getMaxValueFromTable()` +- :php:`TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbBackend->getRowByIdentifier()` + + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.0/Important-91123-AvoidUsingBackendUtilityViewOnClick.rst b/Documentation/Changelog/11.0/Important-91123-AvoidUsingBackendUtilityViewOnClick.rst new file mode 100644 index 0000000..c38df58 --- /dev/null +++ b/Documentation/Changelog/11.0/Important-91123-AvoidUsingBackendUtilityViewOnClick.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _important-91123: + +=========================================================== +Important: #91123 - Avoid using BackendUtility::viewOnClick +=========================================================== + +See :issue:`91123` + +Description +=========== + +:php:`BackendUtility::viewOnClick()` is not used anymore in TYPO3 core to +reduce the amount of inline JavaScript being generated in the backend user +interface. :php:`\TYPO3\CMS\Backend\Routing\PreviewUriBuilder` should be +used instead. + +Probably :php:`BackendUtility::viewOnClick()` will be deprecated in TYPO3 v11 +and finally removed in TYPO3 v12.0 - for TYPO3 v10 LTS it's still available. + +The following example demonstrates how implementations can be adjusted to +make use of the new functionality without using inline JavaScript. + +.. code-block:: php + + $onclick = \TYPO3\CMS\Backend\Utility\BackendUtility::viewOnClick( + $pageId, $backPath, $rootLine, $section, + $viewUri, $getVars, $switchFocus + ); + $serializedAttributes = \TYPO3\CMS\Core\Utility\GeneralUtility::implodeAttributes([ + 'href' => '#', + 'onclick' => $onclick, + ], true); + $html = '<a ' . $serializedAttributes . '>...</a>'; + +Above code snipped can be migrated to the following, given that RequireJS module +:js:`TYPO3/CMS/Backend/ActionDispatcher` is loaded (which basically is the case +in most backend modules). + +.. code-block:: php + + $attributes = \TYPO3\CMS\Backend\Routing\PreviewUriBuilder::create($pageId, $viewUri) + ->withRootLine($rootLine) + ->withSection($section) + ->withAdditionalQueryParameters($getVars) + ->buildDispatcherDataAttributes([ + \TYPO3\CMS\Backend\Routing\PreviewUriBuilder::OPTION_SWITCH_FOCUS => $switchFocus, + ]); + $serializedAttributes = \TYPO3\CMS\Core\Utility\GeneralUtility::implodeAttributes([ + 'href' => '#', + 'data-dispatch-action' => $attributes['data-dispatch-action'], + 'data-dispatch-args' => $attributes['data-dispatch-args'], + ], true); + $html = '<a ' . $serializedAttributes . '>...</a>'; + +Generated :php:`$attributes` can be used directly of course, the example above +was used to actually show the result and existence of those new data-attributes. + + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.0/Important-91888-SystemExtensionAboutMergedIntoBackendSystemExtension.rst b/Documentation/Changelog/11.0/Important-91888-SystemExtensionAboutMergedIntoBackendSystemExtension.rst new file mode 100644 index 0000000..f727b0a --- /dev/null +++ b/Documentation/Changelog/11.0/Important-91888-SystemExtensionAboutMergedIntoBackendSystemExtension.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _important-91888: + +=================================================================================== +Important: #91888 - System extension "about" merged into "backend" system extension +=================================================================================== + +See :issue:`91888` + +Description +=========== + +The system extension "about" is removed, and all functionality is migrated into the main backend extension. + +The system extension was an addition providing the default module when logging in until TYPO3 v10, unless the Dashboard extension is installed. + +The functionality is kept the same, however TYPO3 users upgrading to TYPO3 v11 should be aware +that checks for the extension (via :php:`ExtensionManagementUtility::isLoaded('about')`) will return false, even though all functionality is kept. + +When upgrading TYPO3 installation to TYPO3 v11 in composer mode, +it is recommended to first call :bash:`composer remove typo3/cms-about` +on the Command Line before running any :bash:`composer update` or :bash:`composer require` command. + +.. index:: CLI, PHP-API, ext:about diff --git a/Documentation/Changelog/11.0/Important-91953-JQueryUpdatedTo35x.rst b/Documentation/Changelog/11.0/Important-91953-JQueryUpdatedTo35x.rst new file mode 100644 index 0000000..9608487 --- /dev/null +++ b/Documentation/Changelog/11.0/Important-91953-JQueryUpdatedTo35x.rst @@ -0,0 +1,18 @@ +.. include:: /Includes.rst.txt + +.. _important-91953: + +=========================================== +Important: #91953 - jQuery updated to 3.5.x +=========================================== + +See :issue:`91953` + +Description +=========== + +jQuery has been updated to version 3.5.x in the TYPO3 backend. This branch +introduced a few deprecations, please check http://blog.jquery.com/2020/04/10/jquery-3-5-0-released/ +for further details. + +.. index:: Backend, JavaScript, ext:core diff --git a/Documentation/Changelog/11.0/Important-92736-ReturnTimestampAsIntegerInDateTimeAspect.rst b/Documentation/Changelog/11.0/Important-92736-ReturnTimestampAsIntegerInDateTimeAspect.rst new file mode 100644 index 0000000..a20c58f --- /dev/null +++ b/Documentation/Changelog/11.0/Important-92736-ReturnTimestampAsIntegerInDateTimeAspect.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _important-92736: + +================================================================= +Important: #92736 - Return timestamp as integer in DateTimeAspect +================================================================= + +See :issue:`92736` + +Description +=========== + +The :php:`DateTimeAspect`, introduced to supersede the use of superglobals like +:php:`$GLOBALS['EXEC_TIME'],` can be used to retrieve the current timestamp. + +.. code-block:: php + + $context = GeneralUtility::makeInstance(Context::class); + + // Used instead of $GLOBALS['EXEC_TIME'] + $currentTimestamp = $context->getPropertyFromAspect('date', 'timestamp'); + +This timestamp is now correctly returned as :php:`int` instead of :php:`string`. + +Therefore, extension authors should check if they currently rely on receiving +the timestamp as :php:`string` and if so, adjust the consuming code accordingly. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.0/Important-92870-AlwaysUseFluidBasedPageModule.rst b/Documentation/Changelog/11.0/Important-92870-AlwaysUseFluidBasedPageModule.rst new file mode 100644 index 0000000..8a10271 --- /dev/null +++ b/Documentation/Changelog/11.0/Important-92870-AlwaysUseFluidBasedPageModule.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-92870: + +====================================================== +Important: #92870 - Always use Fluid based page module +====================================================== + +See :issue:`92870` + +Description +=========== + +With :issue:`90348` a completely rewritten page module has been added as +a replacement for the :php:`PageLayoutView`. As this replacement is stable +enough, the feature toggle to switch between the different implementations +has been removed. + + +Impact +====== + +The feature `fluidBasedPageModule` is now always enabled. Extension authors +can therefore remove any check regarding this feature as it will always return +:php:`true`. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.0/Important-92996-PropertiesAndMethodsInActionControllerMarkedInternal.rst b/Documentation/Changelog/11.0/Important-92996-PropertiesAndMethodsInActionControllerMarkedInternal.rst new file mode 100644 index 0000000..0697d0f --- /dev/null +++ b/Documentation/Changelog/11.0/Important-92996-PropertiesAndMethodsInActionControllerMarkedInternal.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _important-92996: + +============================================================================== +Important: #92996 - Properties and methods in ActionController marked internal +============================================================================== + +See :issue:`92996` + +Description +=========== + +Several properties and methods of class :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController` are marked internal +since they are meant to be helper methods for the initialization of the controller and to be called action. +All mentioned properties and methods remain as is until TYPO3 12.0. From then on, they may vanish without deprecation and/or replacement. + +Injected services that will be removed from the ActionController can then be manually injected by the user if needed. + +The following `properties` are marked `@internal`. + +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$reflectionService` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$cacheService` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$hashService` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$viewResolver` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$actionMethodName` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$signalSlotDispatcher` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$objectManager` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$validatorResolver` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$controllerContext` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$configurationManager` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController::$propertyMapper` + + +The following `methods` are marked `@internal`. + +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectConfigurationManager()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectObjectManager()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectSignalSlotDispatcher()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectValidatorResolver()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectViewResolver()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectReflectionService()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectCacheService()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectHashService()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->injectPropertyMapper()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->initializeActionMethodArguments()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->initializeActionMethodValidators()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->initializeControllerArgumentsBaseValidators()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->processRequest()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->renderAssetsForRequest()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->resolveActionMethodName()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->callActionMethod()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->resolveView()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->setViewConfiguration()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->getViewProperty()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->clearCacheOnError()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->addErrorFlashMessage()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->getErrorFlashMessage()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->forwardToReferringRequest()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->getFlattenedValidationErrorMessage()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->buildControllerContext()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->addBaseUriIfNecessary()` +- :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController->mapRequestArgumentsToControllerArguments()` + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/11.0/Important-93121-WorkspaceRecordsAreDiscarded.rst b/Documentation/Changelog/11.0/Important-93121-WorkspaceRecordsAreDiscarded.rst new file mode 100644 index 0000000..48f8e4e --- /dev/null +++ b/Documentation/Changelog/11.0/Important-93121-WorkspaceRecordsAreDiscarded.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _important-93121: + +=================================================== +Important: #93121 - Workspace records are discarded +=================================================== + +See :issue:`93121` + +Description +=========== + +A record in workspaces that has been +changed in comparison to live - if it is a new, a moved +or a changed workspace record - is subject to change in deletion behavior: +When a user in a non-live workspace uses the delete button +(waste bin symbol in list or page module) on a record that has a workspace +overlay, those records are discarded now. + +Technically, the record in question, plus directly attached 'child' records +like inline relations are now fully deleted from the database with this +operation. They are not 'soft-deleted' anymore, as it happens with records +of soft-delete-enabled tables in a live workspace. + +This is good and bad from a UX point of view: The delete behavior in workspaces +page and list module and the 'discard' behavior in workspace module are now +identical, which simplifies things for users. On the other hand, discarded +workspace records can not be 'undeleted' anymore using the recycler module. +The recycler and history modules however did not work well with workspaces, +only very simple scenarios did sometimes lead to the expected result. The +recycler module is now hidden for users in non-live workspace. + +Note there is a second scenario: When deleting a record in page or list module +that has NOT been changed in this workspace, this record is marked as +to be deleted in live during publish. Technically a 'delete placeholder' +is created in this case. This important difference is currently not reflected +well in page and list module. Further TYPO3 v11 changes will improve this +situation usability wise. The change of the delete behavior allows us to +work in this area to ultimately end up with a satisfying user experience. + +.. index:: Backend, Database, ext:workspaces diff --git a/Documentation/Changelog/11.0/Index.rst b/Documentation/Changelog/11.0/Index.rst new file mode 100644 index 0000000..bb248b0 --- /dev/null +++ b/Documentation/Changelog/11.0/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-0: + +11.0 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +^^^^^^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Breaking-* + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.1/Deprecation-92628-LoginLogoWithoutAltText.rst b/Documentation/Changelog/11.1/Deprecation-92628-LoginLogoWithoutAltText.rst new file mode 100644 index 0000000..ce12267 --- /dev/null +++ b/Documentation/Changelog/11.1/Deprecation-92628-LoginLogoWithoutAltText.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92628: + +================================================= +Deprecation: #92628 - Login Logo without Alt-Text +================================================= + +See :issue:`92628` + +Description +=========== + +The configuration of the extension "backend" has now the possibility to +provide an alt-text for a custom login logo. + +As an alt-text is needed for accessibility reasons, not setting an alt-text has been marked as +deprecated. + + +Impact +====== + +Not configuring an alt-text will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All instances that have defined a custom login logo are affected. + + +Migration +========= + +Configure an alt-text for your custom login logo. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.1/Deprecation-93149-T3EditorModuleReplacedByReplacedByCodeMirrorElement.rst b/Documentation/Changelog/11.1/Deprecation-93149-T3EditorModuleReplacedByReplacedByCodeMirrorElement.rst new file mode 100644 index 0000000..81f0550 --- /dev/null +++ b/Documentation/Changelog/11.1/Deprecation-93149-T3EditorModuleReplacedByReplacedByCodeMirrorElement.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93149: + +============================================================================== +Deprecation: #93149 - T3Editor JavaScript module replaced by CodeMirrorElement +============================================================================== + +See :issue:`93149` + +Description +=========== + +The T3Editor - that offers code editing capabilities for TCA +:php:`renderType=t3editor` fields - has been refactored into a custom HTML +element :html:`<typo3-t3editor-codemirror>`. +The element is provided by the new JavaScript module +js:`TYPO3/CMS/T3editor/Element/CodeMirrorElement`. + + +Impact +====== + +Using :html:`<textarea class="t3editor">..</textarea>` will work as before. +The new custom element will automatically be used, but a deprecating warning +will be logged to the browser console. + + +Affected Installations +====================== + +TYPO3 installations that use the T3Editor library in custom extensions, which +is very unlikely. + + +Migration +========= + +Use the new :js:`TYPO3/CMS/T3editor/Element/CodeMirrorElement` module and adapt +your markup to read: + +.. code-block:: html + + <typo3-t3editor-codemirror mode="..." addons="[..]" options="{..}"> + <textarea name="foo">..</textarea> + </typo3-t3editor-codemirror> + +Please make sure to drop the t3editor class from the textarea. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.1/Deprecation-93454-RenameSortableToSortablejs.rst b/Documentation/Changelog/11.1/Deprecation-93454-RenameSortableToSortablejs.rst new file mode 100644 index 0000000..5365b8e --- /dev/null +++ b/Documentation/Changelog/11.1/Deprecation-93454-RenameSortableToSortablejs.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93454: + +=================================================== +Deprecation: #93454 - Rename Sortable to sortablejs +=================================================== + +See :issue:`93454` + +Description +=========== + +Due to importing TypeScript declarations of SortableJS, it's required to make +the library available as :js:`sortablejs`. The previously used name :js:`Sortable` is +still available, but has been marked as deprecated. + + +Impact +====== + +There is no direct impact, as we cannot intercept loading the module to log a +deprecation message. + + +Affected Installations +====================== + +Every 3rd party extension using :js:`SortableJS` is affected. + + +Migration +========= + +Change the import of the library to :js:`sortablejs`. + +.. index:: JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.1/Deprecation-93506-JQueryInTooltips.rst b/Documentation/Changelog/11.1/Deprecation-93506-JQueryInTooltips.rst new file mode 100644 index 0000000..e734acf --- /dev/null +++ b/Documentation/Changelog/11.1/Deprecation-93506-JQueryInTooltips.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93506: + +======================================== +Deprecation: #93506 - jQuery in tooltips +======================================== + +See :issue:`93506` + +Description +=========== + +Passing jQuery objects to the methods :js:`show()` and :js:`hide()` of the +module :file:`TYPO3/CMS/Backend/Tooltip` has been marked as deprecated. + + +Impact +====== + +Passing jQuery objects to the aforementioned methods will log a deprecation message in +the browser's console. + + +Affected Installations +====================== + +All 3rd party extensions passing jQuery objects to either :js:`show()` or +:js:`hide()` are affected. + + +Migration +========= + +Either pass a single :js:`HTMLElement` (e.g. from +:js:`document.querySelector('#my-element')`) or a :js:`NodeList` (e.g. from +:js:`document.querySelectorAll('.my-element')`) to :js:`show()` or :js:`hide()`. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-78036-SynchronizeFolderRelationsAfterRename.rst b/Documentation/Changelog/11.1/Feature-78036-SynchronizeFolderRelationsAfterRename.rst new file mode 100644 index 0000000..4fe390f --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-78036-SynchronizeFolderRelationsAfterRename.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-78036: + +=========================================================== +Feature: #78036 - Synchronize folder relations after rename +=========================================================== + +See :issue:`78036` + +Description +=========== + +TYPO3 features the File module where editors and integrators can manage +all their media assets in a structured way. Certainly, one essential +task is to rename folders from time to time. Since folders are sometimes +referenced in other records, e.g. file collections or file mounts, these +relations did previously break after a folder was renamed, because the +reference index does not contain these relations. + +Therefore, TYPO3 does now automatically synchronize all references of +a folder when it is renamed. This is done by registering event listeners +for the :php:`AfterFolderRenamedEvent` event. This event is dispatched as +soon as a folder was successfully renamed. + +To be able to automatically replace the old folder name with the new one, +the mentioned event is extended for another property :php:`$sourceFolder`. +This property can be retrieved using the public :php:`getSourceFolder()` +method. + +Note that the synchronization is always performed, as soon as a folder +was renamed. This does not only apply to the File module, but for every +:php:`ResourceFactory->renameFolder()` call, since the event is being +dispatched in this method. + + +Impact +====== + +All :sql:`sys_filemounts` and :sql:`sys_file_collection` records which +reference a renamed folder are now automatically synchronized. + +The :php:`AfterFolderRenamedEvent` event now features a new property +:php:`$sourceFolder`. Extension authors can use this event to add +further synchronization for their custom records. + +.. index:: Backend, FAL, ext:core diff --git a/Documentation/Changelog/11.1/Feature-78760-ResizableNavigationComponent.rst b/Documentation/Changelog/11.1/Feature-78760-ResizableNavigationComponent.rst new file mode 100644 index 0000000..9c9820e --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-78760-ResizableNavigationComponent.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-78760: + +================================================ +Feature: #78760 - Resizable Navigation Component +================================================ + +See :issue:`78760` + +Description +=========== + +The Navigation Component in TYPO3's backend, which shows e.g. +the Page Tree or the folder tree (within the file list module), +can be resized via Drag&Drop or via a button, which is layered +within the Navigation Component itself. A similar functionality +was previously put in the top bar on the left, for mobile devices, +but was removed in favor of this new solution. + +The size of the navigation component is now stored in the users' +"uc" configuration to be persistent during various logins and +kept for multiple sessions. + + +Impact +====== + +TYPO3 now allows to not just resize the pagetree component, +but any navigation component (just like iframes). + +When the component is collapsed, an icon is shown to indicate that +the navigation can be re-opened. + +This makes it easier for editors to have a distraction-free +management interface when needed. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-89509-DataProcessorToResolveFlexFormData.rst b/Documentation/Changelog/11.1/Feature-89509-DataProcessorToResolveFlexFormData.rst new file mode 100644 index 0000000..8c8bc47 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-89509-DataProcessorToResolveFlexFormData.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _feature-89509: + +========================================================= +Feature: #89509 - Data Processor to resolve FlexForm data +========================================================= + +See :issue:`89509` + +Description +=========== + +TYPO3 offers "FlexForms", which can be used to store data within an XML +structure inside a single DB column. Since this information could also be +relevant in the view, a new data processor +:php:`TYPO3\CMS\Frontend\DataProcessing\FlexFormProcessor` is added. It +converts the FlexForm data of a given field into a Fluid readable array. + +Options +------- + +:`fieldName`: Field name of the column the FlexForm data is stored in (default: :sql:`pi_flexform`). +:`as`: The variable to be used within the result (default: :php:`flexFormData`). + +Example of a minimal TypoScript configuration +--------------------------------------------- + +.. code-block:: typoscript + + 10 = TYPO3\CMS\Frontend\DataProcessing\FlexFormProcessor + +The converted array can be accessed within the Fluid template +with the :html:`{flexFormData}` variable. + +Example of an advanced TypoScript configuration +----------------------------------------------- + +.. code-block:: typoscript + + 10 = TYPO3\CMS\Frontend\DataProcessing\FlexFormProcessor + 10 { + fieldName = my_flexform_field + as = myOutputVariable + } + +The converted array can be accessed within the Fluid template +with the :html:`{myOutputVariable}` variable. + +Example with a custom sub processor +------------------------------------ + +.. code-block:: typoscript + + 10 = TYPO3\CMS\Frontend\DataProcessing\FlexFormProcessor + 10 { + fieldName = my_flexform_field + as = myOutputVariable + dataProcessing { + 10 = Vendor\MyExtension\DataProcessing\CustomFlexFormProcessor + } + } + + +Impact +====== + +It's now possible to access the FlexForm data of a field in a +readable way in the Fluid template. + +.. index:: Fluid, TypoScript, Frontend diff --git a/Documentation/Changelog/11.1/Feature-92338-AllowLinkTextWrappingInTypolinkViewhelper.rst b/Documentation/Changelog/11.1/Feature-92338-AllowLinkTextWrappingInTypolinkViewhelper.rst new file mode 100644 index 0000000..824a30a --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-92338-AllowLinkTextWrappingInTypolinkViewhelper.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-92338: + +================================================================ +Feature: #92338 - Allow link text wrapping in TypolinkViewhelper +================================================================ + +See :issue:`92338` + +Description +=========== + +Using the :html:`f:link.typolink` ViewHelper for generating links to internal +pages does now allow to wrap the automatically rendered link title, which +is usually the page title of the target page. + +Therefore a new argument :html:`textWrap` is available, which can be used to +define the :typoscript:`wrap` setting for the typolink. + +Defining :html:`<f:link.typolink parameter="123" textWrap="<span>|</span>"/>` +will generate :html:`<a href="some/site"><span>My page title</span></a>`. + +.. note:: + + When adding additional classes to the :html:`textWrap`, ensure quotes are correctly + escaped: :html:`<f:link.typolink parameter="123" textWrap="<span class=\"my-class\">|</span>"/>`. + +If :html:`textWrap` is set, the typolink option :php:`ATagBeforeWrap` is automatically +enabled, because the :typoscript:`wrap` should only be applied to the link text. Every +other use case can be handled in the fluid template itself. + + +Impact +====== + +It's now possible with the :html:`f:link.typolink` ViewHelper, to wrap the +automatically generated link text, e.g. when linking to an internal page. + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/11.1/Feature-92628-AddAltTextToLoginLogo.rst b/Documentation/Changelog/11.1/Feature-92628-AddAltTextToLoginLogo.rst new file mode 100644 index 0000000..22bf5f0 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-92628-AddAltTextToLoginLogo.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-92628: + +============================================ +Feature: #92628 - Add Alt-Text To Login Logo +============================================ + +See :issue:`92628` + +Description +=========== + +The configuration of the extension "backend" has now the possibility to +provide an alt-text for a custom login logo. + +In the module "Admin tools > Settings" go to card "Extension Configuration" +and open the dialog. Select extension "backend" and fill in the field +"Logo Alt-Text" on the "Login" tab. You can also set :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['backend']['loginLogoAlt']`. + + +Impact +====== + +Setting the alt-text enhances the accessibility of the login page. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-92704-ImproveKeyboardNavigationForModuleMenus.rst b/Documentation/Changelog/11.1/Feature-92704-ImproveKeyboardNavigationForModuleMenus.rst new file mode 100644 index 0000000..151dde3 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-92704-ImproveKeyboardNavigationForModuleMenus.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-92704: + +============================================================== +Feature: #92704 - Improve keyboard navigation for module menus +============================================================== + +See :issue:`92704` + +Description +=========== + +The module menu implements the keyboard navigation suggested +by the ARIA Best Practices 1.1 for roles :html:`menubar` and :html:`menu`. +The first level menu has a :html:`menubar` role, the second level +submenus have a :html:`menu` role. The buttons have the :html:`menuitem` +role. Both the :html:`menubar` and the :html:`menu` are oriented +vertically for assistive technology matching the visual +representation which affects the keyboard navigation. + +Space/Enter shows the module unless the item has a submenu. +Space/Enter and Right Arrow open a submenu and move focus to +the first item. + +Up/Down Arrow and Home/End navigate within the current +level of the menu. +Ctrl + Home/End navigate within the first level of the menu +(extension of the ARIA pattern). + +Left/Right Arrow moves to the parent items predecessor/successor +when on a submodule item. The submenu will not be closed +(deviation from the ARIA pattern). + +Escape moves to the parent item of a submodule item. +The submenu will not be closed (deviation from the ARIA pattern). + +Tab and Shift + Tab move to the next item outside of the +module menu. + +The help menu implements the keyboard navigation suggested +by the ARIA Best Practices 1.1 for the role :html:`menu`. This +is the same as the module menu but limited to a single level. + + +Impact +====== + +The main module menu and the help menu are now usable with keyboard alone. +This includes users that access the backend with a screen reader or other +assistive technology. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-92942-AllowIconOverlayForNewContentElementWizardElements.rst b/Documentation/Changelog/11.1/Feature-92942-AllowIconOverlayForNewContentElementWizardElements.rst new file mode 100644 index 0000000..d41d077 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-92942-AllowIconOverlayForNewContentElementWizardElements.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-92942: + +========================================================================= +Feature: #92942 - Allow icon overlay for newContentElementWizard elements +========================================================================= + +See :issue:`92942` + +Description +=========== + +The new Content Element wizard within the page module now allows +to define an icon overlay for each wizard element using the new +TSconfig option :typoscript:`iconOverlay` next to a defined :typoscript:`iconIdentifier`. + +This is especially useful for custom content elements that use the +same :typoscript:`iconIdentifier` several times, but still have to be differentiated. + +The full configuration path is +:typoscript:`mod.wizards.newContentElement.wizardItems.*.elements.*.iconOverlay`. + +An example configuration could look like this: + +.. code-block:: typoscript + + mod.wizards.newContentElement.wizardItems { + common.elements { + my_element { + iconIdentifier = content-my-icon + iconOverlay = content-my-icon-overlay + title = LLL:EXT:my_extension/Resources/Private/Language/ContentTypes.xlf:my_element_title + description = LLL:EXT:my_extension/Resources/Private/Language/ContentTypes.xlf:my_element_description + tt_content_defValues { + CType = my_element + } + } + } + } + + +Impact +====== + +It's now possible to define an :html:`iconOverlay` next to an :html:`iconIdentifier` +for newContentElementWizard elements. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-93117-AddResetButtonToBackendUserModuleFilter.rst b/Documentation/Changelog/11.1/Feature-93117-AddResetButtonToBackendUserModuleFilter.rst new file mode 100644 index 0000000..f73a3d6 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-93117-AddResetButtonToBackendUserModuleFilter.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-93117: + +================================================================ +Feature: #93117 - Add reset button to Backend User module filter +================================================================ + +See :issue:`93117` + +Description +=========== + +The backend user module provides a filter functionality with a +couple of options to filter for. The filter state (selected options) +is also saved in the backend user settings, which means, +the filter state will remain after switching to another module. + +Since there are a lot of filter options which previously had to be +reset one by one, a new reset button is now introduced. This button +allows to reset the whole filter at once. + + +Impact +====== + +It's now possible to reset the whole backend user module filter +at once, using the new reset button. + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/11.1/Feature-93174-LazyConsoleCommandList.rst b/Documentation/Changelog/11.1/Feature-93174-LazyConsoleCommandList.rst new file mode 100644 index 0000000..f541ea7 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-93174-LazyConsoleCommandList.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _feature-93174: + +=========================================== +Feature: #93174 - Lazy console command list +=========================================== + +See :issue:`93174` + +Description +=========== + +The TYPO3 command line utility :bash:`typo3/sysext/core/bin/typo3` has been adapted to +avoid instantiating all available console commands during the execution of the +default :bash:`typo3 list` command. + +This enables commands to inject dependencies that require a fully booted system, +or a database connection, without causing the console command list to break or +slow down. + +Options +------- + +New tag properties for the :yaml:`console.command` dependency injection tag +have been added. The properties control the appearance of console commands in the +list output. + +:`description`: The description of the command (default: `''`). +:`hidden`: Command will be hidden from `list` if `true` (default: `false`). + +Example of a command registration that includes a description +------------------------------------------------------------- + +The command list requires the description to be set next to the command +name in :file:`Services.yaml` in order for descriptions to be shown: + +.. code-block:: yaml + + # Configuration/Services.yaml + services: + My\Namespace\Command\ExampleCommand: + tags: + - name: 'console.command' + command: 'my:example' + description: 'An example command that demonstrates some stuff' + # not required, defaults to false + hidden: false + + +Migration +========= + +Extension authors should add the :yaml:`description` property to existing +:yaml:`console.command` dependency injection tags. +The call to :php:`$this->setDescription()` in :php:`Command::configure()` should +be removed, as the description, as defined in :file:`Services.yaml`, will be +injected into the command. + + +Impact +====== + +Extensions authors are now able to inject arbitrary dependencies in console +commands, without impacting the loading of the command list. + +Integrators profit from a stable command list that is fast and always available, +even if a command is not instantiable or if it inadvertently contains too much +logic inside the command constructor. + +.. index:: CLI, ext:core diff --git a/Documentation/Changelog/11.1/Feature-93426-SVG-basedTreeForFolderNavigationWithFilter.rst b/Documentation/Changelog/11.1/Feature-93426-SVG-basedTreeForFolderNavigationWithFilter.rst new file mode 100644 index 0000000..4b175f2 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-93426-SVG-basedTreeForFolderNavigationWithFilter.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _feature-93426: + +================================================================== +Feature: #93426 - SVG-based Tree for Folder Navigation with Filter +================================================================== + +See :issue:`93426` + +Description +=========== + +The "File" module area (with the "File List" module) has a completely +rewritten Navigation Component called :html:`FileStorageTree`. + +This Navigation Component is based on the same functionality +as the Page Tree - a SVG-based tree - and also offers lazy loading +of multiple nesting levels. + +The previous implementation was based on an iframe with much +effort to load pure HTML instead of using SVGs. Since the file list component +was the last occurrence of using the iframe technology for Navigation +Components, this functionality will be marked as deprecated in later TYPO3 v11 releases. + +The main benefit of the Folder Navigation based on the SVG tree is the enhanced +loading functionality. This way, the Folder Navigation has the exact same +look&feel as the Page Tree, and also now contains an always-enabled filter +on top of the Component, just as the Page Tree Navigation Component. + + +Impact +====== + +The navigation state of the component is stored similarly +to the Page Tree as both components benefit from sharing code. + +The filter inside the Folder Navigation allows to search +for a folder or storage name, and even file names (no search through meta-data). +Users can filter for e.g. ".pdf" to show all available folders where +PDF files are stored. + +Extension Authors who want to use a file-related navigation component in +their own extension can do this by specifying the :php:`navigationComponentId` + +.. code-block:: php + + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addModule( + 'random', + 'filerelatedmodule', + 'top', + null, + [ + 'navigationComponentId' => 'TYPO3/CMS/Backend/Tree/FileStorageTreeContainer', + 'routeTarget' => \MyVendor\MyExtension\Controller\FileRelatedController::class . '::indexAction', + 'access' => 'user,group', + 'name' => 'myext_file', + 'icon' => 'EXT:myextension/Resources/Public/Icons/module-file-related.svg', + 'labels' => 'LLL:EXT:myextension/Resources/Private/Language/Modules/file_related.xlf' + ] + ); + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-93455-BackendRoutesRestrictedToSpecifiedHTTPMethods.rst b/Documentation/Changelog/11.1/Feature-93455-BackendRoutesRestrictedToSpecifiedHTTPMethods.rst new file mode 100644 index 0000000..2b25391 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-93455-BackendRoutesRestrictedToSpecifiedHTTPMethods.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-93455: + +===================================================================== +Feature: #93455 - Backend Routes restricted to specified HTTP methods +===================================================================== + +See :issue:`93455` + +Description +=========== + +Individual Backend Routes in TYPO3 Backend can now be configured +to only apply for specific HTTP methods (e.g. GET or POST). + +This way, custom Backend routes can be limited to only allow +submitted form content to be delivered via HTTP POST for example. + +The underlying symfony routing component, which is already used +in TYPO3 Backend for routing through the proper API, is handling the +restriction to the HTTP method / verb automatically. + + +Impact +====== + +Any Backend route, configured in extensions via +:file:`EXT:my_extension/Configuration/Backend/Routes.php` +and :file:`EXT:my_extension/Configuration/Backend/AjaxRoutes.php` +has a new, optional property :php:`methods`, which expects an array +to set one or more HTTP verbs, such as :html:`GET`, :html:`POST`, :html:`PUT` or :html:`DELETE`. + +If no property is given, no restriction to a HTTP method is set. + +Example: + +.. code-block:: php + + return [ + 'my_route' => [ + 'path' => '/benni/my-route', + 'methods' => ['POST'], + 'target' => MyVendor\MyPackage\Controller\MyRouteController::class . '::submitAction' + ] + ]; + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/11.1/Feature-93526-MultiFactorAuthentication.rst b/Documentation/Changelog/11.1/Feature-93526-MultiFactorAuthentication.rst new file mode 100644 index 0000000..b2cb2b5 --- /dev/null +++ b/Documentation/Changelog/11.1/Feature-93526-MultiFactorAuthentication.rst @@ -0,0 +1,281 @@ + +.. include:: /Includes.rst.txt + +.. _feature-93526: + +============================================= +Feature: #93526 - Multi-Factor Authentication +============================================= + +See :issue:`93526` + +Description +=========== + +TYPO3 is now capable of authentication via multiple factors, in short +"multi-factor authentication" or "MFA". This is sometimes also referred to +"2FA" as a 2-Factor Authentication process, where - in order to log in - the +user needs + +1) "something you know" (= the password) and +2) "something you own" (= an authenticator device, or an authenticator app + on mobile phones or desktop devices). + +Read more about the concepts of `MFA on Wikipedia <https://en.wikipedia.org/wiki/Multi-factor_authentication>`_. + +TYPO3 ships with some built-in MFA providers by default. But more importantly, +TYPO3 now provides an API to allow extension authors to integrate their own +MFA providers. + +The API is designed in a way to allow providers to be used for TYPO3 Backend +Authentication or Frontend Authentication with a multi-factor step in-between. + +TYPO3 Core currently provides the integration for the TYPO3 Backend, but will +fully support multi-factor authentication for the Frontend in future releases. + +Impact +====== + +Managing MFA providers is currently accessible via the User Settings module in +the new tab called "Account security", which was previously called just +"Password". The Account security tab displays the current state, if MFA can +be configured or is already activated. + +By default, the new field is displayed for every backend user. It is possible to +disable it for specific users via userTSconfig: + +.. code-block:: typoscript + + setup.fields.mfaProviders.disabled = 1 + +Included MFA providers +---------------------- + +TYPO3 Core includes two MFA providers: + +1. Time-based one-time password (TOTP) + +The most common MFA implementation. A QR-code is scanned (or alternatively, +a shared secret can be entered) to connect an Authenticator app such as Google +Authenticator, Microsoft Authenticator, 1Password, Authly or others to the +system and then synchronize a token, which changes every 30 seconds. + +On each log-in, after successfully entering the password, the six-digit code +shown by the Authenticator App must be entered. + +2. Recovery codes + +This is a special provider which can only be activated if at least one other +provider is active, as it's only meant as a fallback provider, in case the +authentication credentials for the "main" provider(s) are lost. It is encouraged +to activate this provider, and keep the codes at a safe place. + +Setting up MFA for a backend user +--------------------------------- + +Each provider is displayed with its icon, the name and a short description in +the MFA configuration module. In case a provider is active this is indicated by +a corresponding label, next to the providers' title. The same goes for a locked +provider - an active provider, which can currently not be used since the +provider specific implementation detected some unusual behaviour, e.g. to many +false authentication attempts. Furthermore does the configured default provider +indicate this state with a "star" icon, next to the providers title. + +Each inactive provider contains a "Setup" button which opens the corresponding +configuration view. This view can be different depending on the MFA provider. + +Each active provider contains an "Edit / Change" button, which allows to adjust +the providers' settings. This view allows for example to set a provider as the +default (primary) provider, to be used on authentication. Note that the +default provider setting will be automatically applied on activation of the +first provider or in case it is the recommended provider for this user. + +In case the provider is locked, the "Edit / Change" button changes its button +title to "Unlock". This button can therefore be used to unlock the provider. +This, depending on the provider to unlock, may require further actions by the +user. + +The "Deactivate" button can be used to deactivate the provider. This will, +depending on the provider, usually also completely remove all provider specific +settings. + +Another view is the "Authentication view", which is displayed as soon as a user +with at least one active provider has successfully passed the username and +password mask. + +As for the other views, it is up to the specific provider, used for the current +multi-factor authentication attempt, what content is displayed in this view. +In any case, if the user has further active providers, the view displays them +as "Alternative providers" in the footer. So the user can switch between all +activated providers on every authentication attempt. + +All providers need to define a locking functionality. In case of the TOTP +and recovery code providers, this e.g. includes an attempts count. Therefore, +these providers are locked in case a wrong OTP was entered three times in a +row. The attempts count is automatically reset as soon as a correct OTP is +entered or the user unlocks the provider in the backend. + +All Core providers also feature the "Last used" and "Last updated" information +which can be retrieved in the "Edit / Change" view. + +**Administration of users' MFA providers** + +If a user is not able to access the backend anymore, e.g. because all of their +active providers are locked, MFA needs to be disabled by an administrator for +this specific user. + +Administrators are able to manage users' MFA providers in the corresponding +user record. The new `Multi-factor authentication` field displays a +list of active providers and a button to deactivate MFA for the user, or +only a specific MFA provider. + +Note that all of these deactivate buttons are executed immediately, after +confirming the appearing dialog, and can't be undone. + +The backend users listing in the backend user module also displays whether MFA +is enabled or currently locked, for each user. This allows an administrator a +quick glance of the MFA usage of their users. + +Via the System => Configuration admin module, it's possible to get an overview +of all currently registered providers in the installation. This is especially +helpful to find out the exact provider identifier, needed for some +userTSconfig options. + +Configuration +------------- + +**Enforcing MFA for users** + +It seems reasonable to require MFA for specific users or user groups. This can +be achieved with :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['requireMfa']` which +allows 5 options: + +* `0`: Do not require multi-factor authentication (default) +* `1`: Require multi-factor authentication for all users +* `2`: Require multi-factor authentication only for non-admin users +* `3`: Require multi-factor authentication only for admin users +* `4`: Require multi-factor authentication only for system maintainers + +To set this requirement only for a specific user or user group, a new +userTSconfig option :typoscript:`auth.mfa.required` is introduced. The +userTSconfig option overrules the global configuration. + +.. code-block:: typoscript + + auth.mfa.required = 1 + +.. note:: + + As soon as MFA is required, the corresponding user is no longer able to + access the backend, until at least one MFA provider is activated. After + the users' primary authentication details (e.g. username+password) were + successfully validated, a redirect to a dedicated endpoint is performed. + On this endpoint, the user can choose and set up one of the available MFA + providers. It's therefore also important for administrators to check if + users, which are required to set up MFA, are allowed to choose at least + one provider. Have a look at the next section about configuring "allowed + providers". + +**Allowed provider** + +It is possible to only allow a subset of the available providers for some users +or user groups. + +A new configuration option "Allowed multi-factor authentication providers" is +available in the user groups record in the "Access List" tab. + +There may surely be use cases in which just a single provider should be +disallowed for a specific user, which is however configured to be allowed in +one of the assigned user groups. Therefore, the new userTSconfig option +:typoscript:`auth.mfa.disableProviders` can be used. It overrules the +configuration from the "Access List", which means if a provider is allowed in +"Access List" but disallowed via userTSconfig, it will be disallowed for the +user or user group the TSconfig applies to. This does not affect the remaining +allowed providers from the "Access List". + +.. code-block:: typoscript + + auth.mfa.disableProviders := addToList(totp) + +**Recommended provider** + +To recommend a specific provider, :php:`$GLOBALS['TYPO3_CONF_VARS]['BE]['recommendedMfaProvider']` +can be used and is set to :php:`totp` (Time-based one-time password) by default. + +To set a recommended provider on a per user or user group basis, the new +userTSconfig option :typoscript:`auth.mfa.recommendedProvider` can be used, +which overrules the global configuration. + +.. code-block:: typoscript + + auth.mfa.recommendedProvider = totp + +TYPO3 Integration and API +------------------------- + +.. important:: + + The MFA API is still experimental and subject to change until v11 LTS, + since we are looking forward to receive feedback, especially for custom + use-cases, the API is not capable yet. + +To register a custom MFA provider, the provider class has to implement the new +:php:`MfaProviderInterface`, shipped via a third-party extension. The provider +then has to be configured in the extensions' :file:`Services.yaml` or +:file:`Services.php` file with the :yaml:`mfa.provider` tag. + +.. code-block:: yaml + + Vender\Extension\Authentication\Mfa\MyProvider: + tags: + - name: mfa.provider + identifier: 'my-provider' + title: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.title' + description: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.description' + setupInstructions: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:myProvider.setupInstructions' + icon: 'tx-extension-provider-icon' + +This will register the provider `MyProvider` with the `my-provider` identifier. +To change the position of your provider the :yaml:`before` and :yaml:`after` +arguments can be useful. This can be needed if you e.g. like your provider to +show up prior to any other provider in the MFA configuration module. The +ordering is also taken into account in the authentication step while logging +in. Note that the user defined default provider will always take precedence. + +If you don't want your provider to be selectable as a default provider, set the +:yaml:`defaultProviderAllowed` argument to `false`. + +You can also completely deactivate existing providers with: + +.. code-block:: yaml + + TYPO3\CMS\Core\Authentication\Mfa\Provider\TotpProvider: ~ + +The :php:`MfaProviderInterface` contains a lot of methods to be implemented by +the providers. This can be split up into state-providing ones, +e.g. :php:`isActive` or :php:`isLocked` and functional ones, +e.g. :php:`activate` or :php:`update`. + +Their exact task is explained in the corresponding PHPDoc of the Interface files +and the Core MFA provider implementations. + +All of these methods are receiving either the current PSR-7 Request object, the +:php:`MfaProviderPropertyManager` or both. The :php:`MfaProviderPropertyManager` +can be used to retrieve and update the provider specific properties and +also contains the :php:`getUser` method, providing the current user object. + +To store provider specific data, the MFA API uses a new database field +:sql:`mfa`, which can be freely used by the providers. The field contains a +JSON encoded Array with each provider as array key. Common properties of such +provider array could be `active` or `lastUsed`. Since the information is stored +in either the :sql:`be_users` or the :sql:`fe_users` table, the context is +implicit. Same goes for the user the providers deal with. It is important to +have such a generic field so providers are able to store arbitrary data TYPO3 +does not need to know about. + +To retrieve and update the providers data, the already mentioned +:php:`MfaProviderPropertyManager`, which is automatically passed to all +necessary provider methods, should be used. It is highly discouraged +to directly access the :sql:`mfa` database field. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/11.1/Index.rst b/Documentation/Changelog/11.1/Index.rst new file mode 100644 index 0000000..bc35869 --- /dev/null +++ b/Documentation/Changelog/11.1/Index.rst @@ -0,0 +1,42 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-1: + +11.1 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +^^^^^^^^^^^^^^^^ + +None since TYPO3 v11.0 release. + +.. attention:: + + After TYPO3 v11.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v11.0 release on the way to LTS. + +Features +^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* diff --git a/Documentation/Changelog/11.2/Deprecation-92494-ExtbaseEnvironmentService.rst b/Documentation/Changelog/11.2/Deprecation-92494-ExtbaseEnvironmentService.rst new file mode 100644 index 0000000..5081417 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-92494-ExtbaseEnvironmentService.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92494: + +================================================ +Deprecation: #92494 - Extbase EnvironmentService +================================================ + +See :issue:`92494` + +Description +=========== + +The extbase class :php:`TYPO3\CMS\Extbase\Service\EnvironmentService` is an API +for TYPO3's legacy constant :php:`TYPO3_MODE`. That constant has been marked as +deprecated in v11 and superseded by core API class +:php:`TYPO3\CMS\Core\Http\ApplicationType`, which relies on a PSR-7 request +to determine frontend or backend mode. The :php:`EnvironmentService` has now +been marked as deprecated as a logical follow-up to these works. + + +Impact +====== + +Using the class will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Even though :php:`TYPO3\CMS\Extbase\Service\EnvironmentService` is :php:`@internal`, some extensions +may still rely on it. The extension scanner will find usages. + + +Migration +========= + +Instances with extensions using that class should either make their code agnostic +to frontend or backend mode, or use :php:`ApplicationType`. Code examples can +be found in the `Changelog`_ file. + +.. _`Changelog`: https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/11.0/Deprecation-92947-DeprecateTYPO3_MODEAndTYPO3_REQUESTTYPEConstants.html + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.2/Deprecation-92992-HookT3libclasst3lib_parsehtml_procphptransformation.rst b/Documentation/Changelog/11.2/Deprecation-92992-HookT3libclasst3lib_parsehtml_procphptransformation.rst new file mode 100644 index 0000000..b1c97f5 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-92992-HookT3libclasst3lib_parsehtml_procphptransformation.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92992: + +================================================================== +Deprecation: #92992 - Hook t3lib_parsehtml_proc.php:transformation +================================================================== + +See :issue:`92992` + +Description +=========== + +Since the deprecation of several internal functions in the +:php:`TYPO3\CMS\Core\Html\RteHtmlParser` in TYPO3 10.2 (:ref:`Deprecation: +#86440 - Internal Methods and properties within RteHtmlParser <changelog:deprecation-86440>`) +the hook :php:`t3lib/class.t3lib_parsehtml_proc.php:transformation` became quite useless. + +It is therefore marked as deprecated and will be removed with TYPO3 v12. + +Impact +====== + +Calling the hook will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations with extensions installed that implement the hook. + + +Migration +========= + +Migrate to use the public API only and use other options (such as +:php:`allowAttributes`) in order to only run certain instructions on the :php:`RteHtmlParser` object. + +.. index:: RTE, NotScanned, ext:core diff --git a/Documentation/Changelog/11.2/Deprecation-93726-DeprecatedTypoScriptParserRelatedProperties.rst b/Documentation/Changelog/11.2/Deprecation-93726-DeprecatedTypoScriptParserRelatedProperties.rst new file mode 100644 index 0000000..2dffbd5 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-93726-DeprecatedTypoScriptParserRelatedProperties.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93726: + +==================================================================== +Deprecation: #93726 - Deprecated TypoScriptParser related properties +==================================================================== + +See :issue:`93726` + +Description +=========== + +A cleanup of the backend 'Template' module leads to a deprecation +of some TypoScript parser related class properties: + +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->breakPointLN` +* :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->parentObject` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->ext_constants_BRP` +* :php:`TYPO3\CMS\Core\TypoScript\TemplateService->ext_config_BRP` + + +Impact +====== + +The properties are not handled any longer and will be dropped with TYPO3 v12. + + +Affected Installations +====================== + +It is very unlikely extensions used these properties since they were specific +to the backend 'Template' module and of little use otherwise. + +The extension scanner will still find usages except the :php:`parentObject` +since this property name is too generic and would trigger too many false +positive matches. + + +Migration +========= + +The functionality of these properties has been dropped. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.2/Deprecation-93837-SpecialPropertyOfTCATypeSelect.rst b/Documentation/Changelog/11.2/Deprecation-93837-SpecialPropertyOfTCATypeSelect.rst new file mode 100644 index 0000000..5a4d91e --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-93837-SpecialPropertyOfTCATypeSelect.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93837: + +========================================================= +Deprecation: #93837 - special property of TCA type select +========================================================= + +See :issue:`93837` + +Description +=========== + +The :php:`special` property of TCA type :php:`select` was introduced to enrich the +items array with dynamic values, e.g. the available site languages or +page types. + +Since this usually is exactly what an :php:`itemsProcFunc` does, all +those options are migrated to such functions, removing complexity +from the TCA :php:`select` type. As these options are mainly for internal +use in the backend user and backend usergroup records, the new +:php:`itemsProcFunc` functions are marked as :php:`@internal`. This means, they +are not considered public API and therefore not part of TYPO3s backwards +compatibility promise. + +The only option which is considered public API is :php:`special=languages`, +which was already migrated to the new TCA type :php:`language` in :issue:`57082`. + +Impact +====== + +Using the TCA property :php:`special` inside the :php:`[columns][config]` +section of columns with TCA type :php:`select` triggers a PHP :php:`E_USER_DEPRECATED` error. + +When extending :php:`AbstractItemProvider` and directly calling +:php:`addItemsFromSpecial()`, also a PHP :php:`E_USER_DEPRECATED` error will be raised. +The extension scanner will also detect such calls. + +Affected Installations +====================== + +All installations using the :php:`special` property with TCA type :php:`select` or +directly calling :php:`AbstractItemProvider->addItemsFromSpecial()`. + +Migration +========= + +While it's very unlikely that the :php:`special` property with another option +than :php:`languages` is used in custom extension code, you nevertheless have to +replace them with a :php:`itemsProcFunc` in such case. Either by creating +your own implementation or by copying the one from Core. Have a look at the +:php:`index_config` TCA configuration in EXT:indexed_search how this can be +achieved. You can also find detailed information about :php:`itemsProcFunc` +in the documentation_. + +.. _documentation: https://docs.typo3.org/m/typo3/reference-tca/main/en-us/ColumnsConfig/CommonProperties/ItemsProcFunc.html + +.. index:: Backend, TCA, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.2/Deprecation-93899-FormEnginesRequestConfirmationOnFieldChange.rst b/Documentation/Changelog/11.2/Deprecation-93899-FormEnginesRequestConfirmationOnFieldChange.rst new file mode 100644 index 0000000..dfb0618 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-93899-FormEnginesRequestConfirmationOnFieldChange.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93899: + +=================================================================== +Deprecation: #93899 - FormEngine's requestConfirmationOnFieldChange +=================================================================== + +See :issue:`93899` + +Description +=========== + +FormEngine's JavaScript method :js:`FormEngine.requestConfirmationOnFieldChange()` +to register and trigger the update request when a field's value changes, has +been marked as deprecated. + + +Impact +====== + +Calling the method :js:`FormEngine.requestConfirmationOnFieldChange()` will +trigger a deprecation warning in the browser's console. + + +Affected Installations +====================== + +Any 3rd party extension using the aforementioned method is affected. + + +Migration +========= + +There is no migration available. If a field is properly configured to update the +FormEngine after updating a field, a LitElement is rendered into DOM which will +handle the update request. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.2/Deprecation-93944-FileTreeAsIframeMigratedToSVG-basedTree.rst b/Documentation/Changelog/11.2/Deprecation-93944-FileTreeAsIframeMigratedToSVG-basedTree.rst new file mode 100644 index 0000000..eb11a69 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-93944-FileTreeAsIframeMigratedToSVG-basedTree.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93944: + +==================================================================== +Deprecation: #93944 - File Tree as iframe migrated to SVG-based tree +==================================================================== + +See :issue:`93944` + +Description +=========== + +When registered backend modules have the legacy-tree navigation frame +:html:`file_navframe` set via the module configuration +:php:`navigationFrameModule`, the modules are now using the new SVG-based Folder +tree view as component (non-iFrame). + + +Impact +====== + +Modules still registering the old :html:`file_navframe` option via +Module Configuration :php:`navigationFrameModule` will automatically be migrated +to the new Component, and a PHP :php:`E_USER_DEPRECATED` error will be triggered. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions having backend modules +using the filelist navigation frame (folder-based tree). + +Modules that use the implicit main module configuration and are +located directly within the "File" module are not affected. + + +Migration +========= + +Change the affected code in your ext_tables.php: + +:php:`'navigationFrameModule' => 'file_navframe'` + +to + +:php:`'navigationComponentId' => 'TYPO3/CMS/Backend/Tree/FileStorageTreeContainer'` + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.2/Deprecation-93975-TBE_EDITORfieldChanged.rst b/Documentation/Changelog/11.2/Deprecation-93975-TBE_EDITORfieldChanged.rst new file mode 100644 index 0000000..791a078 --- /dev/null +++ b/Documentation/Changelog/11.2/Deprecation-93975-TBE_EDITORfieldChanged.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-93975: + +=============================================== +Deprecation: #93975 - TBE_EDITOR.fieldChanged() +=============================================== + +See :issue:`93975` + +Description +=========== + +The JavaScript function :js:`TBE_EDITOR.fieldChanged()` is a precursor of the +rewritten FormEngine that started with TYPO3 v7 already. +Now, FormEngine has proper change handling which renders the function +:js:`TBE_EDITOR.fieldChanged()` obsolete, thus this function became marked as +deprecated. + + +Impact +====== + +Using :js:`TBE_EDITOR.fieldChanged()` will trigger a deprecation entry in the +browser's console. + + +Affected Installations +====================== + +Every installation with 3rd-party extensions installed using this function is +affected. + + +Migration +========= + +It is possible to trigger the :js:`change` event on the given field, if +FormEngine is unable to detect changes automatically. + +Example: + +.. code-block:: javascript + + // Previous invocation + TBE_EDITOR.fieldChanged('table', 'field_name', 42); + + // Migrate to event-based handling + document + .querySelector('[name="data[table][field_name][42]"]') + .dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.2/Feature-57082-NewTCATypeLanguage.rst b/Documentation/Changelog/11.2/Feature-57082-NewTCATypeLanguage.rst new file mode 100644 index 0000000..d87cb31 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-57082-NewTCATypeLanguage.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _feature-57082: + +========================================= +Feature: #57082 - New TCA type "language" +========================================= + +See :issue:`57082` + +Description +=========== + +A new TCA field type called :php:`language` has been added to TYPO3 Core. Its main +purpose is to simplify the TCA language configuration. It therefore supersedes +the :php:`special=languages` option of TCA columns with :php:`type=select` as well as the +now mis-use of the :php:`foreign_table` option, being set to :sql:`sys_language`. + +Since the introduction of site configurations and the corresponding site +languages back in v9, the :sql:`sys_language` table was not longer the only source +of truth regarding available languages. The languages available for a record are +defined by the associated site configuration. + +Therefore, the new field allows to finally decouple the available site +languages from the :sql:`sys_language` table. This effectively reduces quite an +amount of code and complexity, since no relations have to be fetched and +processed anymore. This also makes the :sql:`sys_refindex` table a bit smaller, +since no entries have to be added for this relation anymore. To clean up your +existing reference index, you might use the CLI command +:php:`bin/typo3 referenceindex:update`. + +Another pain point was the special :php:`-1` language which always had to be added +to each TCA configuration manually. Thus, a lot of different implementations +of this special case could be found in one and the same TYPO3 installation. + +The new TCA type now automatically displays all available languages for the +current context (the corresponding site configuration) and also automatically +adds the special :php:`-1` language for all record types, except :sql:`pages`. + +.. code-block:: php + + // Before + + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'sys_language', + 'items' => [ + ['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.allLanguages', -1], + ['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value', 0] + ], + 'default' => 0 + ] + + // After + + 'config' => [ + 'type' => 'language' + ] + + +.. code-block:: php + + // Before + + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'special' => 'languages', + 'items' => [ + [ + 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.allLanguages', + -1, + 'flags-multiple' + ], + ], + 'default' => 0, + ] + + // After + + 'config' => [ + 'type' => 'language' + ] + + +Since the new TCA type is mostly based on the :php:`type=select` internally, most +of the associated TCA and TSconfig options can still be applied. This includes +e.g. the :php:`selectIcons` field wizard, as well as the :typoscript:`keepItems` +and :typoscript:`removeItems` page TSconfig options. + +In records on root level (:sql:`pid=0`) or on a page, outside of a site context, +all languages from all site configurations are displayed in the new field. + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adaption has to take place. Columns defined as +:php:`$TCA['ctrl']['languageField']`, as well as all columns using the +:php:`special=languages` option in combination with :php:`type=select` are +affected. + +Note that the migration resets the whole :php:`config` array to use the new TCA +type. Custom setting such as field wizards are not evaluated until the TCA +configuration is adapted. + +.. index:: Backend, PHP-API, TCA, ext:core diff --git a/Documentation/Changelog/11.2/Feature-73176-FilterableTreesInRecordSelectorsAndLinkPickers.rst b/Documentation/Changelog/11.2/Feature-73176-FilterableTreesInRecordSelectorsAndLinkPickers.rst new file mode 100644 index 0000000..949fdd5 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-73176-FilterableTreesInRecordSelectorsAndLinkPickers.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-73176: + +======================================================================= +Feature: #73176 - Filterable Trees in Record Selectors and Link Pickers +======================================================================= + +See :issue:`73176` + +Description +=========== + +TYPO3's Page Tree, which was reworked in TYPO3 v9 to be powered by SVG rendering, +and the Folder Tree in the File list module, which was also migrated to SVG +rendering in TYPO3 v11.1, have been integrated in the so-called Record +Selectors / File Selector ("Element Browser") and Link Pickers of TYPO3 +backend. + +The Record Selectors are used when e.g. choosing a :guilabel:`Target Page` for a +:guilabel:`Shortcut Page`, or selecting a :guilabel:`Storage Page` in a plugin. + +The file selectors are used when choosing a file for an IRRE-based FAL-based +file reference. + +Link Pickers are used when linking to a specific page, content element, file, +folder or custom records, such as news ("related news" in EXT:news). + +All of these components within TYPO3 backend are now powered by SVG-based +tree renderings. In addition, this means they ship with the same feature-set +as the main navigation components, such as: + +* A filter within the items of a tree (for folder-based trees, this means, + searching for file names within a folder is also possible) +* A JSON-based AJAX-loading functionality for fetching just parts of the tree, + keeping the same expand/collapse state as the main tree +* The Page Tree's "Temporary Mount Point" feature has the same functionality + and styling as the main navigation component +* Resizing and expand/collapse of the tree area in all modals +* Keyboard navigation within the tree components + +The newly added tree components have the following addons: + +* When selecting (Record Selector) or linking to a specific page or folder, the + item can be selected by a specific "link" action on the right hand of the tree. +* Only showing specific mount points configurable via + TSconfig :typoscript:`options.pageTree.altElementBrowserMountPoints` +* The content area (for selecting a record on a specific page) is dynamically + loaded via AJAX and loads much faster than before + + +Impact +====== + +All page- and folder-based trees are now completely streamlined all over TYPO3's +backend, in terms of UX and code / implementation. + +The overall UX feels much faster for every editor of TYPO3, and the consistency +makes TYPO3 more intuitive with an improved search/filter and record selector. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.2/Feature-89762-AddPaginationForFormManagement.rst b/Documentation/Changelog/11.2/Feature-89762-AddPaginationForFormManagement.rst new file mode 100644 index 0000000..f664497 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-89762-AddPaginationForFormManagement.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-89762: + +=================================================== +Feature: #89762 - Add pagination for FormManagement +=================================================== + +See :issue:`89762` + +Description +=========== + +To enhance usability, a pagination is now integrated in the +form management module. Thus, the listing is now limited to 20 forms +per page. + +Impact +====== + +You'll notice a pagination above and below the forms listing in the +form management module, which can be used to navigate through the +forms listing. + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/11.2/Feature-93188-PossibilityToDisableHreflangPerPage.rst b/Documentation/Changelog/11.2/Feature-93188-PossibilityToDisableHreflangPerPage.rst new file mode 100644 index 0000000..7ff96af --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93188-PossibilityToDisableHreflangPerPage.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-93188: + +========================================================== +Feature: #93188 - Possibility to disable hreflang per page +========================================================== + +See :issue:`93188` + +Description +=========== + +Although it should not be needed to disable the hreflang generation, people might +have a reason to disable it. If for some reason Core does not +render the proper hreflang tags and also the :php:`ModifyHrefLangTagsEvent` PSR-14 event +is not enough, you are now able to disable the generation of the hreflang tags +via TypoScript. This can be done per page or part of your tree depending on where +you set the configuration. + +To disable the hreflang generation, you can add the following line to your +TypoScript setup. + +.. code-block:: typoscript + + config.disableHrefLang = 1 + + +Impact +====== + +If the option is set to :typoscript:`1`, hreflang generation will be skipped. + +.. index:: Frontend, TypoScript, ext:seo diff --git a/Documentation/Changelog/11.2/Feature-93209-FALAddGetFileToTYPO3CMSCoreResourceFolder.rst b/Documentation/Changelog/11.2/Feature-93209-FALAddGetFileToTYPO3CMSCoreResourceFolder.rst new file mode 100644 index 0000000..e55f445 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93209-FALAddGetFileToTYPO3CMSCoreResourceFolder.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-93209: + +========================================================================== +Feature: #93209 - FAL: Add getFile() to TYPO3\\CMS\\Core\\Resource\\Folder +========================================================================== + +See :issue:`93209` + +Description +=========== + +The FAL :php:`\TYPO3\CMS\Core\Resource\Folder` object now contains a new +convenience method :php:`getFile()`. + +The :php:`\TYPO3\CMS\Core\Resource\FolderInterface` does not contain the +definition yet, as this would be a breaking change, thus, a comment is added to +make sure the interface gets this addition in TYPO3 v12 as well. + +Impact +====== + +When dealing as a developer with FAL Folder objects, the method +:php:`$folder->getFile("filename.ext")` can now be used instead of +:php:`$folder->getStorage()->getFileInFolder("filename.ext", $folder)`. + +.. index:: FAL, ext:core diff --git a/Documentation/Changelog/11.2/Feature-93591-AllowGroupIdLookupInConditionsWithArrayOperator.rst b/Documentation/Changelog/11.2/Feature-93591-AllowGroupIdLookupInConditionsWithArrayOperator.rst new file mode 100644 index 0000000..2c334a9 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93591-AllowGroupIdLookupInConditionsWithArrayOperator.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-93591: + +========================================================================= +Feature: #93591 - Allow group id lookup in conditions with array operator +========================================================================= + +See :issue:`93591` + +Description +=========== + +In the backend and frontend the array of user group ids of the current backend user +is now available as :typoscript:`backend.user.userGroupIds`. + +In the frontend the array of user group ids of the current frontend user is available +as :typoscript:`frontend.user.userGroupIds`. + + +Impact +====== + +This allows for a native Symfony Expression Syntax in TypoScript conditions, e.g. + +.. code-block:: typoscript + + [4 in frontend.user.userGroupIds] + + [2 in backend.user.userGroupIds] + +With this syntax you can match backend user groups in the frontend without +a "like" expression on the comma-separated list of user group ids. + +.. index:: Backend, Frontend, TSConfig, TypoScript, ext:backend, ext:frontend diff --git a/Documentation/Changelog/11.2/Feature-93606-PossibilityToDisableCanonicalPerPage.rst b/Documentation/Changelog/11.2/Feature-93606-PossibilityToDisableCanonicalPerPage.rst new file mode 100644 index 0000000..10ff7be --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93606-PossibilityToDisableCanonicalPerPage.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-93606: + +=========================================================== +Feature: #93606 - Possibility to disable canonical per page +=========================================================== + +See :issue:`93606` + +Description +=========== + +Although it should not be needed to disable the generation of :html:`canonical`, people might +have a reason to disable it. If for some reason Core does not +render the proper canonical tag and also the :php:`ModifyUrlForCanonicalTagEvent` PSR-14 event +is not enough, you are now able to disable the generation of the canonical tag +via TypoScript. This can be done per page or part of your tree depending on where +you set the configuration. + +To disable the canonical generation, you can add the following line to your +TypoScript setup. + +.. code-block:: typoscript + + config.disableCanonical = 1 + + +Impact +====== + +If the option is set to :typoscript:`1`, the canonical generation will be skipped. + +.. index:: Frontend, TypoScript, ext:seo diff --git a/Documentation/Changelog/11.2/Feature-93651-ProvideListOfAvailableSystemLocales.rst b/Documentation/Changelog/11.2/Feature-93651-ProvideListOfAvailableSystemLocales.rst new file mode 100644 index 0000000..8e48d60 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93651-ProvideListOfAvailableSystemLocales.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-93651: + +========================================================== +Feature: #93651 - Provide list of available system locales +========================================================== + +See :issue:`93651` + +Description +=========== + +Every language of a site requires at least one locale which is used to format times, dates, +currencies and other locale-dependent values. Additional locales can be added as fallback +locales (comma separated). + +The site configuration form for site languages provides the available locales as a select field, +enabling easy selection of a value, rather than typing in the expected one, which might or might not +be available. + + +Impact +====== + +Providing a list of available locales makes it faster and less error prone to setup a site and its +languages. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.2/Feature-93663-BackendUsersPreferredUILanguageStoredAsDBField.rst b/Documentation/Changelog/11.2/Feature-93663-BackendUsersPreferredUILanguageStoredAsDBField.rst new file mode 100644 index 0000000..bd6fca0 --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93663-BackendUsersPreferredUILanguageStoredAsDBField.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _feature-93663: + +========================================================================= +Feature: #93663 - Backend user's preferred UI language stored as DB field +========================================================================= + +See :issue:`93663` + +Description +=========== + +In previous TYPO3 versions, administrators could create new backend +users and select from the list of all supported TYPO3-internal +languages for their backend language (= all labels from +XLIFF files). This information was stored in the database field +:sql:`be_users.lang` and was only used on the first login of a user +into TYPO3 backend. + +The backend users themselves could use the :guilabel:`User settings` module +to change the UI language to their preferred language, based on the +available language packs in the system. + +This information was then stored in the user's :sql:`uc` (user configuration), +an arbitrary settings field. + +This approach - built over 18 years ago without any significant +changes ever since - had several downsides: + +* The database field :sql:`be_users.lang` was not really needed +* Administrators did not see available language packs when changing the language +* Administrators could only change an editor's preferred language by + switching to the user (:guilabel:`Switch User Mode`). +* Administrators could not filter / sort editors to see what languages the + users had chosen +* Fetching the user's preferred language always meant to fetch the whole + :sql:`uc` information and unpack it. +* The preferred language was only selected if the user had logged in for + the first time to initialize the :sql:`uc` values. + +Instead, TYPO3 now keeps the current language preference in the +database field :sql:`be_users.lang`, allowing both editors and administrators +to access the same value for fetching this information. + + +Impact +====== + +When the user changes their language in the user settings module, +the database record gets updated, and it is clear where this information is +stored. It is now the same logic when an administrator updates the editor's +record via FormEngine. + +The value is now always filled, and if English is chosen, the value +is set to the string :php:`default` (instead of an empty value). + +An upgrade wizard migrates existing :sql:`uc` values into the database +fields. The :sql:`uc` entry :sql:`user->uc['lang']` is kept in sync for +backwards-compatibility. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.2/Feature-93794-OverrideTCADescriptionWithTSconfig.rst b/Documentation/Changelog/11.2/Feature-93794-OverrideTCADescriptionWithTSconfig.rst new file mode 100644 index 0000000..96d272b --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93794-OverrideTCADescriptionWithTSconfig.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-93794: + +======================================================== +Feature: #93794 - Override TCA description with TSconfig +======================================================== + +See :issue:`93794` + +Description +=========== + +The TCA description, introduced in :issue:`85410`, allows to define a description +for a TCA field, next to its label. Since the purpose of a field may change +depending on the current page, it is now possible to override the TCA +description property with page TSconfig. + +.. code-block:: typoscript + + TCEFORM.aTable.aField.description = override description + +As already known from other properties, this can also be configured for a +specific language. + +.. code-block:: typoscript + + TCEFORM.aTable.aField.description.de = override description for DE + +The option can be used on a per record type basis, too. + +.. code-block:: typoscript + + TCEFORM.aTable.aField.types.aType.description = override description for aType + +Also referencing language labels is supported. + +.. code-block:: typoscript + + TCEFORM.aTable.aField.description = LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:override_description + +.. note:: + + The new option can not only be used to override an existing property, + but also to set a description for a field, that has not yet been + configured a description in TCA. + +.. index:: Backend, TCA, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.2/Feature-93857-ResizableNavigationComponentForAllElementRecordSelectors.rst b/Documentation/Changelog/11.2/Feature-93857-ResizableNavigationComponentForAllElementRecordSelectors.rst new file mode 100644 index 0000000..1f0e59d --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93857-ResizableNavigationComponentForAllElementRecordSelectors.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-93857: + +=================================================================================== +Feature: #93857 - Resizable navigation component for all element / record selectors +=================================================================================== + +See :issue:`93857` + +Description +=========== + +The newly introduced possibility to resize and/or collapse the +navigation frame (e.g. Page Tree) in the main backend has been additionally +added to all Element Browser / Record Selectors, and Link Picker selections. + +All modal areas with a Page Tree or File-based Folder Tree now +contain the same feature-set of collapsing / resizing, except +that the width is not installation-wide but is kept for the +main navigation area (initially 300 pixels) in a different place (set in the +backend user's :sql:`uc` :php:`navigation.width` property) than for the element browsers +modal areas (initially 250 pixels, set in the backend user's :sql:`uc` +:php:`selector.navigation.width` property). + +A custom Lit-based web component is added, which is now re-used +in various places, and uses the same markup in all contexts. + + +Impact +====== + +Any backend user is now able to resize / collapse the navigation +area in the Record Selectors / Element Browser shipped with TYPO3 Core. + +.. index:: Backend, JavaScript, ext:recordlist diff --git a/Documentation/Changelog/11.2/Feature-93908-Image-decoding-attribute.rst b/Documentation/Changelog/11.2/Feature-93908-Image-decoding-attribute.rst new file mode 100644 index 0000000..58f07bd --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93908-Image-decoding-attribute.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-93908: + +================================================== +Feature: #93908 - Add decoding attribute to images +================================================== + +See :issue:`93908` + +Description +=========== + +TYPO3 now supports the :html:`decoding` HTML attribute in :html:`<img>` +tags. + +Supported browsers choose to decode these images asynchronously to not +prevent presentation of other content. This has an effect of presenting +non-image content faster. However, the image content is missing on screen until +the decode finishes. Once the decode is finished, the screen is updated with the +image. + +The configuration option is available via TypoScript constants and +can be easily adjusted via the TypoScript Constant Editor in the Template +module. The default value is an empty string. + +Impact +====== + +TYPO3 frontend decodes images in content elements asynchronously by default +when using TYPO3 templates from Fluid Styled Content. + +Using the TypoScript constant :typoscript:`styles.content.image.imageDecoding`, +the behavior can be modified generally to be either set to :typoscript:`sync`, :typoscript:`async` +:typoscript:`auto` or to an empty value which removes the property. + +The Fluid :php:`ImageViewHelper` and :php:`MediaViewHelper` have the possibility to set this +attribute via :html:`<f:image src="{fileObject}" decoding="async">` +and :html:`<f:media file="{fileObject}" decoding="async">`. + +.. index:: Frontend, ext:fluid_styled_content diff --git a/Documentation/Changelog/11.2/Feature-93988-BackendModuleURLsReflectIntoBrowserAddressbar.rst b/Documentation/Changelog/11.2/Feature-93988-BackendModuleURLsReflectIntoBrowserAddressbar.rst new file mode 100644 index 0000000..c232a8e --- /dev/null +++ b/Documentation/Changelog/11.2/Feature-93988-BackendModuleURLsReflectIntoBrowserAddressbar.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-93988: + +====================================================================== +Feature: #93988 - Backend module URLs reflect into browser address bar +====================================================================== + +See :issue:`93988` + +Description +=========== + +Backend module URLs are now reflected into the browser address bar, whenever a +backend module or a FormEngine record is opened. + +The given URL can be bookmarked or shared with other editors and allows to +re-open the TYPO3 backend with the given context. + +A custom Lit-based web component router is added which reflects module URLs +into the browser address bar and at the same time prepares for native web +components to be used as future iframe module alternatives. + + +Impact +====== + +Editors can share links to certain records or include these in bug reports. + +This feature is enabled for all modules. For non-module routes this feature +will only work if configured via `Routes.php` by adding a `redirect` section: + +.. code-block:: php + + 'redirect' => [ + 'enable' => true, + // Transferred parameters when redirecting + 'parameters' => [ + 'my-parameter-name' => true + ] + ], + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.2/Important-93398-PossibilityToIgnoreSubmittedValuesInHiddenViewHelper.rst b/Documentation/Changelog/11.2/Important-93398-PossibilityToIgnoreSubmittedValuesInHiddenViewHelper.rst new file mode 100644 index 0000000..fc935a6 --- /dev/null +++ b/Documentation/Changelog/11.2/Important-93398-PossibilityToIgnoreSubmittedValuesInHiddenViewHelper.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-93398: + +============================================================================== +Important: #93398 - Possibility to ignore submitted values in HiddenViewHelper +============================================================================== + +See :issue:`93398` + +Description +=========== + +A new argument :php:`respectSubmittedDataValue` is added to Fluid's +:php:`HiddenViewHelper` view helper. It allows to enable or disable the usage of +previously submitted values for the corresponding field. This is especially +useful if dealing with sub requests, e.g. when a :php:`\TYPO3\CMS\Extbase\Http\ForwardResponse` is +being dispatched within Extbase. + +Example +======= + +.. code-block:: html + + <f:form.hidden property="hiddenProperty" value="{form.hiddenProperty}" respectSubmittedDataValue="false"/> + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/11.2/Index.rst b/Documentation/Changelog/11.2/Index.rst new file mode 100644 index 0000000..82e74d4 --- /dev/null +++ b/Documentation/Changelog/11.2/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-2: + +============ +11.2 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v11.0 release. + +.. attention:: + + After TYPO3 v11.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v11.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.3/Deprecation-91806-BackendUtilityViewOnClick.rst b/Documentation/Changelog/11.3/Deprecation-91806-BackendUtilityViewOnClick.rst new file mode 100644 index 0000000..91e0420 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-91806-BackendUtilityViewOnClick.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91806: + +================================================ +Deprecation: #91806 - BackendUtility viewOnClick +================================================ + +See :issue:`91806` + +Description +=========== + +Method :php:`BackendUtility::viewOnClick()` is discouraged to be used +due to its inline JavaScript and has been deprecated now. + + +Impact +====== + +Using the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Extensions calling this static method will be affected. The extension +scanner will find usages as strong match. + + +Migration +========= + +The :php:`\TYPO3\CMS\Backend\Routing\PreviewUriBuilder` should be used +instead as described in +:doc:`/Changelog/11.0/Important-91123-AvoidUsingBackendUtilityViewOnClick`. + +.. index:: Backend, JavaScript, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.3/Deprecation-94058-JavaScriptGoToModule.rst b/Documentation/Changelog/11.3/Deprecation-94058-JavaScriptGoToModule.rst new file mode 100644 index 0000000..a20d3bd --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94058-JavaScriptGoToModule.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94058: + +============================================= +Deprecation: #94058 - JavaScript goToModule() +============================================= + +See :issue:`94058` + +Description +=========== + +One of the most prominent inline JavaScript functions +:js:`goToModule()` has been deprecated in favor of a streamlined +ActionHandler API for JavaScript. + + +Impact +====== + +When using the internal backend module entry objects via `setOnClick` and +`getOnClick` methods, PHP deprecation warnings are now triggered. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions referencing these methods. + + +Migration +========= + +Use the following HTML code to replace the inline :js:`goToModule()` +call to for example link to the page module: + +.. code-block:: html + + <a href="#" + data-dispatch-action="TYPO3.ModuleMenu.showModule" + data-dispatch-args-list="web_layout" + > + Go to page module + </a> + +Inside actual JavaScript code, you can replace calls to :js:`goToModule()` +(or :js:`top.goToModule()`) like this: + +.. code-block:: js + :caption: Example for TYPO3 v12+ + + // Utilize imports rather than straight usage of TYPO3.ModuleMenu.App.showModule() + import ModuleMenu from '@typo3/backend/module-menu.js'; + + ModuleMenu.App.showModule('web_layout') + +.. code-block:: js + :caption: Example for TYPO3 v11 + + TYPO3.ModuleMenu.App.showModule('web_layout') + +.. index:: JavaScript, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.3/Deprecation-94115-ParameterTypeEvaluationViaDocBlockComments.rst b/Documentation/Changelog/11.3/Deprecation-94115-ParameterTypeEvaluationViaDocBlockComments.rst new file mode 100644 index 0000000..de29280 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94115-ParameterTypeEvaluationViaDocBlockComments.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94115: + +===================================================================== +Deprecation: #94115 - Parameter type evaluation via DocBlock comments +===================================================================== + +See :issue:`94115` + +Description +=========== + +Extbase had a long support for detecting the actual +target type of a method argument by parsing the DocBlock +annotations like + +.. code-block:: php + + /** + * @param \MyVendor\MyExtension\MyModel $item + */ + public function myAction($item); + +However, since PHP 7 supports to define the target type +by specifying the type directly in the language, which is +much faster, the "legacy" way of handling type detection for +arguments are marked as deprecated. + + +Impact +====== + +When a DocBlock annotation like :php:`@param \MyClass $item` is added, but +the actual type is not added to the method argument via native PHP type +declarations, a deprecation message is now triggered. + + +Affected Installations +====================== + +TYPO3 installations with custom Extbase extensions which +were never upgraded to support latest PHP language constructs. + + +Migration +========= + +Use native PHP type declarations instead - this can be achieved since TYPO3 v10: + +.. code-block:: php + + public function myAction(\MyVendor\MyExtension\MyModel $item); + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94137-SwitchBehaviorOfArrayUtilityarrayDiffAssocRecursive.rst b/Documentation/Changelog/11.3/Deprecation-94137-SwitchBehaviorOfArrayUtilityarrayDiffAssocRecursive.rst new file mode 100644 index 0000000..6c1f9a2 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94137-SwitchBehaviorOfArrayUtilityarrayDiffAssocRecursive.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94137: + +================================================================================ +Deprecation: #94137 - Switch behavior of ArrayUtility::arrayDiffAssocRecursive() +================================================================================ + +See :issue:`94137` + +Description +=========== + +Despite its name, the method +:php:`\TYPO3\CMS\Core\Utility\ArrayUtility::arrayDiffAssocRecursive()` +mimics the behavior of :php:`array_diff_key()` and not of +:php:`array_diff_assoc()`. + + +Impact +====== + +The method has been adjusted to act like :php:`array_diff_assoc()`. As this is +considered being a breaking change, the behavior must be enabled explicitly by +passing a third parameter :php:`$useArrayDiffAssocBehavior` being true. If the +argument is either omitted or :php:`false`, the old behavior is kept but a +deprecation warning will be thrown. + + +Affected Installations +====================== + +Every 3rd party extension using +:php:`\TYPO3\CMS\Core\Utility\ArrayUtility::arrayDiffAssocRecursive()` +without its third argument being :php:`true` is affected. + + +Migration +========= + +To keep the previous :php:`array_diff_key()` based behavior, use the introduced +method :php:`\TYPO3\CMS\Core\Utility\ArrayUtility::arrayDiffKeyRecursive()`. +To make use of the :php:`array_diff_assoc()` based behavior, which will become +the default behavior in TYPO3 v12, pass :php:`true` as the third argument. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94165-SysLanguageDatabaseTable.rst b/Documentation/Changelog/11.3/Deprecation-94165-SysLanguageDatabaseTable.rst new file mode 100644 index 0000000..e8dd4ae --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94165-SysLanguageDatabaseTable.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94165: + +======================================== +Deprecation: #94165 - sys_language table +======================================== + +See :issue:`94165` + +Description +=========== + +Since the introduction of site handling back in TYPO3 v9, available +languages and their associated information, for example locale, ISO code or the +navigation title are configured in the site configurations. As a consequence, +the :sql:`sys_language` table just duplicated this information and is therefore +now deprecated. + +The Core internally does not longer rely on this table but fetches +necessary information from the site languages instead. This means, there +won't be any relation to the :sql:`sys_language` table, which allows to +define any kind of "ID" for the :php:`languageField` field of records, which +is usually :sql:`sys_language_uid`. + +Also the site languages, used in site configurations, are now completely +independent of any :sql:`sys_language` record. Previously, when using the +site module to create or edit a site configuration, site languages could +only be added when a corresponding :sql:`sys_language` record existed. +This has now changed. The site configurations' `languages` field now +features a :guilabel:`Create new language` button, which allows to create a new +site language for this site configuration. Such newly created site +language will then also be available in the selector box of all other +site configurations. The ID for a new site language is always created +automatically (auto-increment). When selecting this site language in +another site configuration, most of the fields will now be prefilled. + +.. note:: + + When creating the first site configuration of a new installation, the + languages selector box is empty, as new languages must be created via + the :guilabel:`Create new language` button first. However, a default + language (ID=0) record will always be added automatically. + +Impact +====== + +Currently there is no direct impact. However, if your code relies on TYPO3 +processing :sql:`sys_language`, you might have to adapt those places to use +site languages instead. + +Affected Installations +====================== + +All installations which rely on TYPO3 processing the :sql:`sys_language` +table. For example for fetching available languages and their related +information. + +Migration +========= + +Adapt your code to always use site languages for fetching and processing +language related information. + +For example, use the new TCA type `language`, introduced in :issue:`57082`, +instead of :php:`foreign_table => sys_language` for selecting a records' +language. + +.. index:: Database, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94193-PublicUrlWithRelativePathsInFALAPI.rst b/Documentation/Changelog/11.3/Deprecation-94193-PublicUrlWithRelativePathsInFALAPI.rst new file mode 100644 index 0000000..595786c --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94193-PublicUrlWithRelativePathsInFALAPI.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94193: + +================================================================ +Deprecation: #94193 - Public URLs with relative paths in FAL API +================================================================ + +See :issue:`94193` + +Description +=========== + +The public FAL API for accessing the public url of a FAL object, +for example :php:`\TYPO3\CMS\Core\Resource\FileReference` or +:php:`\TYPO3\CMS\Core\Resource\Folder`, previously allowed to +retrieve the relative path instead of the absolute path. This could +be achieved by setting :php:`$relativeToCurrentScript` to :php:`true` +while calling :php:`getPublicUrl()`. + +FAL is only able to build relative links for local drivers. Other drivers +would still return the absolute URL, which has often led to unexpected +side effects. + +Since both, frontend (site handling) and backend (url routing) are meanwhile +fully capable of supporting absolute URLs, :php:`$relativeToCurrentScript` +is now deprecated and will be removed in TYPO3 v12. + +This also affects the :php:`isRelativeToCurrentScript()` method in the +:php:`GeneratePublicUrlForResourceEvent` event, as well as the +:php:`OnlineMediaHelperInterface`. + +Impact +====== + +Calling :php:`getPublicUrl()` on a FAL object, for example +:php:`\TYPO3\CMS\Core\Resource\FileReference` or +:php:`\TYPO3\CMS\Core\Resource\Folder`, with :php:`$relativeToCurrentScript` +set to :php:`true` +will trigger a PHP :php:`E_USER_DEPRECATED` error. The extension scanner +will detect such calls. + +Accessing :php:`isRelativeToCurrentScript()` on +:php:`GeneratePublicUrlForResourceEvent` will trigger a PHP +:php:`E_USER_DEPRECATED` error. The extension scanner will detect +such calls. + +Manually calling :php:`getPublicUrl()` on an :php:`OnlineMediaHelper`, +for example :php:`YoutubeHelper`, will not trigger a PHP :php:`E_USER_DEPRECATED` +error, but the extension scanner will detect such calls. + +Affected Installations +====================== + +All installations which set :php:`$relativeToCurrentScript` to :php:`true` +when calling :php:`getPublicUrl()` on a FAL object, for example +:php:`\TYPO3\CMS\Core\Resource\FileReference` or +:php:`\TYPO3\CMS\Core\Resource\Folder`. + +All installations which manually call :php:`getPublicUrl()` on an +:php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelper`, +for example :php:`\TYPO3\CMS\Core\Resource\Rendering\YoutubeRenderer`. + +All installation which access :php:`isRelativeToCurrentScript()` on the +:php:`\TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent` event. + +Migration +========= + +Remove the :php:`$relativeToCurrentScript` parameter from all calls to +:php:`getPublicUrl()` on FAL objects, for example +:php:`\TYPO3\CMS\Core\Resource\FileReference` or +:php:`\TYPO3\CMS\Core\Resource\Folder`. + +Remove the :php:`$relativeToCurrentScript` parameter from all manual calls +to :php:`getPublicUrl()` on a +:php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelper`, +for example :php:`\TYPO3\CMS\Core\Resource\Rendering\YoutubeRenderer`. + +Remove all calls to +:php:`\TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent->isRelativeToCurrentScript()`. + +.. index:: FAL, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94209-BackendModuleLayoutViewHelpers.rst b/Documentation/Changelog/11.3/Deprecation-94209-BackendModuleLayoutViewHelpers.rst new file mode 100644 index 0000000..5a850c2 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94209-BackendModuleLayoutViewHelpers.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94209: + +====================================================== +Deprecation: #94209 - Backend ModuleLayout ViewHelpers +====================================================== + +See :issue:`94209` + +Description +=========== + +The following Fluid ViewHelpers have been deprecated: + +* :html:`be:moduleLayout` +* :html:`be:moduleLayout.menu` +* :html:`be:moduleLayout.menuItem` +* :html:`be:moduleLayout.button.linkButton` +* :html:`be:moduleLayout.button.shortcutButton` + +These ViewHelpers partially mimic their counterparts of the PHP based +:php:`ModuleTemplate` API. They were previously used in backend modules +when the 'doc header' handling was done in Fluid. + +The ViewHelpers however relied on knowledge that shouldn't be the scope +of a view component, especially variables like the current action +and controller had to be assigned to the view in many cases. + +Additionally, those ViewHelpers were only a sub set of the ModuleTemplate +functionality and created a second API for the same problem domain and +various scenarios like good shortcut implementation and main drop down +state were hard to solve when using these ViewHelpers. + + +Impact +====== + +Using these ViewHelpers will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Some extensions with backend modules may use these ViewHelpers. Searching +templates for string :php:`be:moduleLayout` should reveal usages. Extensions +extending the PHP classes are found by the extension scanner as a weak match. + + +Migration +========= + +In general, extensions using these ViewHelpers should switch to using the +PHP API based on class :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate`, +usually initialized by class +:php:`\TYPO3\CMS\Backend\Template\ModuleTemplateFactory` instead. +All Core extensions that render backend +modules provide usage examples and the fluent API is quite straight +forward. + +Using the :html:`be:moduleLayout` ViewHelper always rendered FlashMessages +from the queue :php:`'extbase.flashmessages.' . $pluginNamespace` on top of the +content area. You can either use the :html:`f:flashMessages` ViewHelper +or :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate::setFlashMessageQueue()` +as replacements. + +For Extbase base backend modules, the 'doc header' should be handled within +controller actions, while the module body is rendered +by the Fluid view component. + +In case an extension heavily relies on the deprecated ViewHelpers and the +functionality should be kept with as little work as possible, the easiest +way is of course to simply copy the according ViewHelpers to the extension +directly and to just adapt the namespace in templates accordingly. + + +.. index:: Backend, Fluid, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/11.3/Deprecation-94223-ExtbaseRequest-getBaseUri.rst b/Documentation/Changelog/11.3/Deprecation-94223-ExtbaseRequest-getBaseUri.rst new file mode 100644 index 0000000..1a64b55 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94223-ExtbaseRequest-getBaseUri.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94223: + +=================================================== +Deprecation: #94223 - Extbase Request->getBaseUri() +=================================================== + +See :issue:`94223` + +Description +=========== + +To further prepare Extbase towards PSR-7 compatible requests, the +Extbase :php:`TYPO3\CMS\Extbase\Mvc\Request` has to be streamlined. + +Method :php:`getBaseUri()` has been deprecated and shouldn't be +used any longer. + + +Impact +====== + +Using the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +This getter is probably used rather seldom in extensions since both +frontend and backend take care of base URI's in many cases already. +The extension scanner will find remaining usages. + + +Migration +========= + +When :php:`getBaseUri()` is called in extensions, it is most likely +in a view related component. Since Fluid ViewHelpers currently still +don't receive an instance of the native PSR-7 request (which will change), +a typical substitution of this getter looks like this for now: + +.. code-block:: php + + // @todo Adapt this example as soon as ViewHelpers receive a ServerRequestInterface + $request = $GLOBALS['TYPO3_REQUEST']; + $normalizedParams = $request->getAttribute('normalizedParams'); + $baseUri = $normalizedParams->getSiteUrl(); + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94225-FbecontainerViewHelper.rst b/Documentation/Changelog/11.3/Deprecation-94225-FbecontainerViewHelper.rst new file mode 100644 index 0000000..221e680 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94225-FbecontainerViewHelper.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94225: + +=============================================== +Deprecation: #94225 - f:be.container ViewHelper +=============================================== + +See :issue:`94225` + +Description +=========== + +The :html:`<f:be.container>` ViewHelper has been deprecated. + +This backend-module-related ViewHelper was pretty useless since +it mostly provides the same functionality as :html:`<f:be.pageRenderer>`, +with the additional opportunity to render an empty doc header. + + +Impact +====== + +Using the ViewHelper in Fluid templates will trigger a PHP +:php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +The limited functionality of the ViewHelper likely leads to little +usage numbers. +Searching extensions for the string html:`<f:be.container>` should +reveal any usages. + + +Migration +========= + +When this ViewHelper is used to register additional backend module +resources like CSS or JavaScript, :html:`<f:be.pageRenderer>` can be +used as drop-in replacement. + +If the ViewHelper is used to additionally render an empty ModuleTemplate, +this part should be moved to a controller instead. Simple example for an +extbase controller: + +.. code-block:: php + + $moduleTemplate->setContent($view->render()); + return $this->htmlResponse($moduleTemplate->renderContent()); + +In case your controller does not extend :php:`ActionController`, use +the PSR-17 interfaces for generating the response: + +.. code-block:: php + + $moduleTemplate->setContent($view->render()); + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($this->streamFactory->createStream($moduleTemplate->renderContent()); + +.. index:: Backend, Fluid, NotScanned, ext:fluid diff --git a/Documentation/Changelog/11.3/Deprecation-94227-FbaseViewHelper.rst b/Documentation/Changelog/11.3/Deprecation-94227-FbaseViewHelper.rst new file mode 100644 index 0000000..ceaeb4c --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94227-FbaseViewHelper.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94227: + +======================================= +Deprecation: #94227 - f:base ViewHelper +======================================= + +See :issue:`94227` + +Description +=========== + +The :html:`<f:base>` ViewHelper isn't suitable in almost all use cases +and has been deprecated: In most cases the :php:`PageRenderer` takes care of the +main :html:`<head>` markup, directly, or indirectly via TypoScript :html:`config.baseURL`. + + +Impact +====== + +Using the ViewHelper in Fluid templates will log a deprecation warning +and the ViewHelper will be dropped with v12. + + +Affected Installations +====================== + +The limited use of the ViewHelper likely leads to little usage numbers. +Searching extensions for the string html:`<f:base>` should +reveal any usages. + + +Migration +========= + +The easiest solution is to simply copy PHP class +:php:`TYPO3\CMS\Fluid\ViewHelpers\BaseViewHelper` to the consuming extension, +giving the ViewHelper a happy life in an extension specific namespace. + + +.. index:: Fluid, NotScanned, ext:fluid diff --git a/Documentation/Changelog/11.3/Deprecation-94228-DeprecateExtbaseRequestGetRequestUri.rst b/Documentation/Changelog/11.3/Deprecation-94228-DeprecateExtbaseRequestGetRequestUri.rst new file mode 100644 index 0000000..6ca541f --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94228-DeprecateExtbaseRequestGetRequestUri.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94228: + +===================================================== +Deprecation: #94228 - Extbase request getRequestUri() +===================================================== + +See :issue:`94228` + +Description +=========== + +To further prepare Extbase towards PSR-7 compatible requests, the +Extbase :php:`TYPO3\CMS\Extbase\Mvc\Request` has to be streamlined. + +Method :php:`getRequestUri()` has been deprecated and shouldn't be +used any longer. + + +Impact +====== + +Using the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Extbase based extensions may use this method. The extension scanner +will find usages as weak match. + + +Migration +========= + +When :php:`getRequestUri()` is called in extensions, the same information +can be retrieved from the native PSR-7 request. At the moment, this is usually +only available using :php:`$GLOBALS['TYPO3_REQUEST']`, but this will change +when the Extbase request is compatible with PSR-7 ServerRequestInterface. +A substitution looks like this for now: + +.. code-block:: php + + // @todo Adapt this example as soon as Extbase Request implements ServerRequestInterface + $request = $GLOBALS['TYPO3_REQUEST']; + $normalizedParams = $request->getAttribute('normalizedParams'); + $requestUrl = $normalizedParams->getRequestUrl(); + + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94231-DeprecateExtbaseInvalidRequestMethodException.rst b/Documentation/Changelog/11.3/Deprecation-94231-DeprecateExtbaseInvalidRequestMethodException.rst new file mode 100644 index 0000000..88cb67c --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94231-DeprecateExtbaseInvalidRequestMethodException.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94231: + +=========================================================== +Deprecation: #94231 - Extbase InvalidRequestMethodException +=========================================================== + +See :issue:`94231` + +Description +=========== + +To further prepare towards PSR-7 Requests in Extbase, the +:php:`TYPO3\CMS\Extbase\Mvc\Request` has to be streamlined. + +Therefore, the internal method :php:`setMethod()` has been removed. +This method previously threw the :php:`InvalidRequestMethodException`. +Since this was the only usage and the exception is not used within +TYPO3 / Extbase anymore, the exception is deprecated. + +Impact +====== + +Using :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestMethodException` +in custom extension code is discouraged since it will be removed with TYPO3 +v12 and is also no longer thrown by TYPO3. + +Affected Installations +====================== + +Extbase based extensions may manually throw or catch +:php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestMethodException`. +The extension scanner will find those usages. + +Migration +========= + +All usages of :php:`TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestMethodException` +in custom extension code, which is very unlikely, have to be replaced with a +custom exception, if needed at all. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94252-DeprecatedGeneralUtilitycompileSelectedGetVarsFromArray.rst b/Documentation/Changelog/11.3/Deprecation-94252-DeprecatedGeneralUtilitycompileSelectedGetVarsFromArray.rst new file mode 100644 index 0000000..7e09ec1 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94252-DeprecatedGeneralUtilitycompileSelectedGetVarsFromArray.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94252: + +===================================================================== +Deprecation: #94252 - GeneralUtility::compileSelectedGetVarsFromArray +===================================================================== + +See :issue:`94252` + +Description +=========== + +In our effort to reduce usages of :php:`GeneralUtility::_GP()`, the +:php:`GeneralUtility` method :php:`compileSelectedGetVarsFromArray` is +deprecated, since it internally calls :php:`GeneralUtility::_GP()` instead +of accessing the PSR-7 Request. The method was furthermore only used once +in the Core since it's internal logic can easily be implemented on a case +by case basis. + +Impact +====== + +Using the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All TYPO3 installations calling this method in custom code are affected. +The extension scanner will find such usages as strong match. + + +Migration +========= + +Usages of the method in custom extension code have to be replaced +with a custom implementations, preferably using the PSR-7 Request. + +See: :php:`\TYPO3\CMS\Backend\Controller\EditDocumentController->compileStoreData()` +for an example on how such migration could look like. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94272-DeprecatedApplication-runCallback.rst b/Documentation/Changelog/11.3/Deprecation-94272-DeprecatedApplication-runCallback.rst new file mode 100644 index 0000000..0074dd5 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94272-DeprecatedApplication-runCallback.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94272: + +=============================================== +Deprecation: #94272 - Application->run callback +=============================================== + +See :issue:`94272` + +Description +=========== + +Since the introduction of the :php:`\TYPO3\CMS\Core\Core\ApplicationInterface` +in :issue:`67808` (TYPO3 v7), which serves as a wrapper for setting up the bootstrap and +calling the request, it was possible to run either the console, the frontend +or the backend by calling :php:`run()` on the corresponding Application class. + +The :php:`run()` method also featured the possibility to provide a :php:`callback` +as first argument. This was mainly introduced, since no proper solution for +sub requests existed at that time. Since :issue:`83725`, the callback is not +longer necessary as such functionality can be handled by a PSR-15 +middleware. + +Therefore, the :php:`$execute` argument of :php:`ApplicationInterface->run()` +has been deprecated and will be removed in v12. + +Impact +====== + +Calling :php:`\TYPO3\CMS\Core\Core\ApplicationInterface->run()` with the +first argument :php:`$execute` set, triggers a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All installations which manually call +:php:`\TYPO3\CMS\Core\Core\ApplicationInterface->run()`, +while providing a callback as first argument. The extension scanner +will find those usages as weak match. + +Migration +========= + +Instances with extensions calling +:php:`\TYPO3\CMS\Core\Core\ApplicationInterface->run()` with a callback +as first argument need to be adapted. If possible use PSR-15 middlewares +instead. + +Console commands do not feature PSR-15 middlewares. Therefore, the callback +has to be replaced by separate chained post-processing commands. + +.. index:: CLI, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94309-DeprecatedGeneralUtilitystdAuthCode.rst b/Documentation/Changelog/11.3/Deprecation-94309-DeprecatedGeneralUtilitystdAuthCode.rst new file mode 100644 index 0000000..1076713 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94309-DeprecatedGeneralUtilitystdAuthCode.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94309: + +================================================= +Deprecation: #94309 - GeneralUtility::stdAuthCode +================================================= + +See :issue:`94309` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::stdAuthCode()` +has not been used within the Core since at least v9. It internally fiddles +with the `encryptionKey` while using :php:`md5()`. Furthermore, the default +length of 8 chars could easily lead to hash collisions. The TYPO3 Core already +provides :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::hmac()` for such +purposes, which is using `sha1` with a length of 40. Therefore, +:php:`stdAuthCode()` has been deprecated and will be removed in TYPO3 v12. + +Impact +====== + +Calling the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All TYPO3 installations calling this method in custom code. The extension +scanner will find all usages as strong match. + +Migration +========= + +Replace all usages of the method in custom extension code by either using +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::hmac()` or by a custom +implementation. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94311-DeprecatedGeneralUtilityrmFromList.rst b/Documentation/Changelog/11.3/Deprecation-94311-DeprecatedGeneralUtilityrmFromList.rst new file mode 100644 index 0000000..890e1fc --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94311-DeprecatedGeneralUtilityrmFromList.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94311: + +================================================ +Deprecation: #94311 - GeneralUtility::rmFromList +================================================ + +See :issue:`94311` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::rmFromList()` has not +been used in the Core since v10. The method has now been deprecated +in :php:`\TYPO3\CMS\Core\Utility\GeneralUtility` and will be removed in +TYPO3 v12. + +Impact +====== + +Calling the method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All TYPO3 installations calling this method in custom code. The extension +scanner will find all such usages as strong match. + + +Migration +========= + +Replace all usages of the method in your extension code. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94313-ClassAbstractService.rst b/Documentation/Changelog/11.3/Deprecation-94313-ClassAbstractService.rst new file mode 100644 index 0000000..947460f --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94313-ClassAbstractService.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94313: + +=========================================== +Deprecation: #94313 - AbstractService class +=========================================== + +See :issue:`94313` + +Description +=========== + +The :php:`TYPO3\CMS\Core\Service\AbstractService` class is part of the ancient +:ref:`Service API <t3coreapi:services-developer-service-api>`. +This API did not really prevail, except it's usage for the authentication process. + +Since the authentication service related functionality was already +decoupled over the last years, the :php:`AbstractService` got finally +unused in Core since :issue:`88646`. Therefore it has now been marked +as deprecated. + +Impact +====== + +Extending this class does *not* raise a deprecation error level log entry. +The class contains only a `@deprecated` class annotation. Extension classes +can still extend this class in v11 without impact, it will raise a PHP fatal +error in v12, when the class is dropped. + + +Affected Installations +====================== + +As mentioned, the Service API never found many usages in casual extensions. +It is therefore pretty unlikely that well maintained projects are affected. +The extension scanner will find any class usages as a strong match. + +Migration +========= + +Remove any usage of this class in your extension. In case you currently +extend :php:`AbstractService` for use in an authentication service, which +might be the most common scenario, you have to change your service class +to extend from :php:`AbstractAuthenticationService` instead. + +In case you currently extend :php:`AbstractService` for another kind of +service, which is rather unlikely, you have to implement the necessary +methods in your service class yourself. Please see `Service Implementation +<https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/Services/Developer/ServiceApi.html#service-implementation>`__ +for more details about the required methods. However, even better would be to +completely migrate away from the Service API (look for :php:`GeneralUtility::makeInstanceService()`), +since the Core will deprecate these related methods as well. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94316-DeprecatedHTTPHeaderManipulatingMethodsFromHttpUtility.rst b/Documentation/Changelog/11.3/Deprecation-94316-DeprecatedHTTPHeaderManipulatingMethodsFromHttpUtility.rst new file mode 100644 index 0000000..e22d23e --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94316-DeprecatedHTTPHeaderManipulatingMethodsFromHttpUtility.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94316: + +======================================================================= +Deprecation: #94316 - HTTP header manipulating methods from HttpUtility +======================================================================= + +See :issue:`94316` + +Description +=========== + +In order to properly handle PSR-7 response objects, explicit :php:`die()` +or :php:`exit()` calls, as well as directly manipulating HTTP headers with +:php:`header()` should be avoided. Therefore following methods from +:php:`\TYPO3\CMS\Core\Utility\HttpUtility` have been marked as deprecated: + +* :php:`redirect()` +* :php:`setResponseCode()` +* :php:`setResponseCodeAndExit()` + +The TYPO3 Core already provides a couple of possibilities to properly handle +such events in a PSR-7 conform way. Most of the time, a proper PSR-7 response +can be passed back to the call stack (request handler). Unfortunately there +might still be some places, inside the call stack, where it's not possible to +directly return a PSR-7 response. In such case, the +:php:`\TYPO3\CMS\Core\Http\PropagateResponseException` +could be thrown. It will automatically be caught by a PSR-15 middleware and the +given PSR-7 response will then directly be returned, making any :php:`die()` +or :php:`exit()` call obsolete. + +The usage is as following: + +.. code-block:: php + + // Before + HttpUtility::redirect('https://example.com', HttpUtility::HTTP_STATUS_303); + + // After + + // Inject PSR-17 ResponseFactoryInterface + public function __construct(ResponseFactoryInterface $responseFactory) + { + $this->responseFactory = $responseFactory + } + + // Create redirect response + $response = $this->responseFactory + ->createResponse(303) + ->withAddedHeader('location', 'https://example.com') + + // Return Response directly + return $reponse; + + // or throw PropagateResponseException + throw new PropagateResponseException($response); + +.. note:: + + Throwing exceptions for returning an immediate PSR-7 Response is considered + as an intermediate solution only, until it's possible to return PSR-7 + responses in every relevant place. Therefore, the exception is marked + as :php:`@internal` and will most likely vanish again in the future. + +Impact +====== + +Calling one of those methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All TYPO3 installations calling those methods in custom code. The extension +scanner will find all usages as strong match. + +Migration +========= + +Replace all occurrences in custom extension code. Therefore, create a redirect +response with the PSR-17 ResponseFactoryInterface, and pass it back to the call +stack (request handler). In case, it's not possible to directly return a PSR-7 +Response, you can use the :php:`\TYPO3\CMS\Core\Http\PropagateResponseException` +as an intermediate solution. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Deprecation-94317-ExtformFinisherImplementations.rst b/Documentation/Changelog/11.3/Deprecation-94317-ExtformFinisherImplementations.rst new file mode 100644 index 0000000..750c751 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94317-ExtformFinisherImplementations.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94317: + +======================================================= +Deprecation: #94317 - ext:form Finisher implementations +======================================================= + +See :issue:`94317` + +Description +=========== + +In preparation of the Extbase ObjectManager deprecation in favor of +symfony dependency injection, some details of EXT:form finishers had +to be adapted: In contrast to Extbase object management, symfony DI does +not support prototype classes with a mixture of manual constructor arguments, +plus dependency injection via other constructor arguments or inject methods. + +The EXT:form finishers based on :php:`TYPO3\CMS\Form\Domain\Finishers\FinisherInterface` +relied on this and had to be adapted: The default constructor argument +:php:`$finisherIdentifier` has been dropped, so finisher implementations can +keep using dependency injection. + + +Impact +====== + +A compatibility layer detects non-adapted finishers and falls back to +initialization using Extbase ObjectManager. This will will trigger a +PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +In general only instances with custom form based on EXT:form are affected, and +only if they implement custom finishers. + +Most custom finishers probably extend :php:`TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher`. +Those are only affected if they override :php:`__construct()` or use or manipulate +properties :php:`$finisherIdentifier` or :php:`$shortFinisherIdentifier` in +:php:`inject*()` or :php:`injectObject()` methods. This is rather unlikely. + +Custom finishers that do not extend :php:`TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher` +are affected. + + +Migration +========= + +Custom finishers should extend :php:`TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher`. + +If they must implement :php:`__construct()`, they should not expect :php:`$finisherIdentifier` +to be hand over as first argument and must not call :php:`parent::construct()` anymore. + +Custom finishers must not rely on :php:`$finisherIdentifier` or :php:`$shortFinisherIdentifier` +being set in early methods like :php:`__construct()`, :php:`inject*()` and :php:`injectObject()`, +and must not set these properties. + +Custom finishers must implement method :php:`setFinisherIdentifier()`, this method will +be added to :php:`TYPO3\CMS\Form\Domain\Finishers\FinisherInterface` in TYPO3 v12. + +Custom finishers must not use class property :php:`$objectManager` since this will vanish +in v12. This will affect more API cases and will have a dedicated deprecation file +with more details, though. + +.. index:: PHP-API, NotScanned, ext:form diff --git a/Documentation/Changelog/11.3/Deprecation-94351-ExtextbaseStopActionException.rst b/Documentation/Changelog/11.3/Deprecation-94351-ExtextbaseStopActionException.rst new file mode 100644 index 0000000..87471f1 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94351-ExtextbaseStopActionException.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94351: + +===================================================== +Deprecation: #94351 - ext:extbase StopActionException +===================================================== + +See :issue:`94351` + +Description +=========== + +To further prepare towards clean PSR-7 request / response handling in +Extbase, the Extbase internal exception +:php:`TYPO3\CMS\Extbase\Mvc\Exception\StopActionException` +has been deprecated. + + +Impact +====== + +No deprecation is logged, but the :php:`StopActionException` will be +removed in v12 as breaking change. Extension developers with Extbase +based controllers can prepare in v11 towards this. + + +Affected Installations +====================== + +Extensions with Extbase controllers that throw :php:`StopActionException` or +use methods :php:`redirect` or :php:`redirectToUri` from Extbase +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` +are affected. + + +Migration +========= + +As a goal, Extbase actions will *always* return a +:php:`\Psr\Http\Message\ResponseInterface` +in v12. v11 prepares towards this, but still throws the :php:`StopActionException` +in :php:`redirectToUri`. Developers should prepare towards this. + +Example before: + +.. code-block:: php + + public function fooAction() + { + $this->redirect('otherAction'); + } + +Example compatible with v10, v11 and v12 - IDE's and static code analyzers +may complain in v10 and v11, though: + +.. code-block:: php + + public function fooAction(): ResponseInterface + { + // A return is added! + return $this->redirect('otherAction'); + } + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94367-ExtbaseReferringRequest.rst b/Documentation/Changelog/11.3/Deprecation-94367-ExtbaseReferringRequest.rst new file mode 100644 index 0000000..a6ccfb7 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94367-ExtbaseReferringRequest.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94367: + +============================================== +Deprecation: #94367 - Extbase ReferringRequest +============================================== + +See :issue:`94367` + +Description +=========== + +To further prepare Extbase towards PSR-7 compatible requests, Extbase class +:php:`TYPO3\CMS\Extbase\Mvc\Web\ReferringRequest` has been deprecated. + + +Impact +====== + +Creating an instance of :php:`ReferringRequest` a PHP :php:`E_USER_DEPRECATED` +error. + + +Affected Installations +====================== + +:php:`ReferringRequest` has been mostly Extbase internal and rarely used in +Extbase extensions, probably only in cases where +:php:`ActionController->forwardToReferringRequest()` is overridden. +The extension scanner will find usages with a strong match. + +Migration +========= + +Extbase internally, :php:`ReferringRequest` has only been used to +immediately create a :php:`ForwardResponse` from it. Consuming extensions +should follow his approach and create a :php:`ForwardResponse` directly. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94377-ExtbaseObjectManager-getEmptyObject.rst b/Documentation/Changelog/11.3/Deprecation-94377-ExtbaseObjectManager-getEmptyObject.rst new file mode 100644 index 0000000..f987698 --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94377-ExtbaseObjectManager-getEmptyObject.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94377: + +=========================================================== +Deprecation: #94377 - Extbase ObjectManager->getEmptyObject +=========================================================== + +See :issue:`94377` + +Description +=========== + +Extbase has the odd behavior that +:php:`\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface` objects - +typically classes in :file:`Classes/Domain/Model` of Extbase enabled +extensions - don't call :php:`__construct` when the persistence layer +"thaws" a model from database - typically when an Extbase +:php:`Domain/Repository` uses a :php:`->findBy*` method. + +As a side-effect of switching away from Extbase :php:`ObjectManager` towards +symfony based dependency injection, this behavior will change in TYPO3 v12: +Method :php:`__construct()` will be called in v12 when the :php:`DataMapper` +creates model instances from database rows. + + +Impact +====== + +There is no impact in TYPO3 v11 and no deprecation log entry is raised. +However, extension developers *should* prepare toward this change in v11 +to avoid any impact of a breaking change in v12. + + +Affected Installations +====================== + +Extbase extensions having domain models that implement :php:`__construct()` +are affected. It is rather unlikely this has any impact on the behavior +of the extension, though. + +Additionally, calls to API method +:php:`TYPO3\CMS\Extbase\Object\ObjectManager->getEmptyObject()` should be +avoided since it will vanish in v12. The vast majority of extensions will +not do this, though. The extension scanner will find candidates. + + +Migration +========= + +No migration possible. Simply expect that :php:`__construct()` of a domain +model will be called in v12 when a domain repository :php:`findBy` method +directly or indirectly reconstitutes a model object from a database row. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94394-ExtbaseRequestSetDispatchedAndIsDispatched.rst b/Documentation/Changelog/11.3/Deprecation-94394-ExtbaseRequestSetDispatchedAndIsDispatched.rst new file mode 100644 index 0000000..085a68e --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94394-ExtbaseRequestSetDispatchedAndIsDispatched.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94394: + +======================================================================== +Deprecation: #94394 - Extbase Request setDispatched() and isDispatched() +======================================================================== + +See :issue:`94394` + +Description +=========== + +To further prepare towards PSR-7 requests in Extbase, the two +methods :php:`TYPO3\CMS\Extbase\Mvc\Request->setDispatched()` and +:php:`TYPO3\CMS\Extbase\Mvc\Request->isDispatched()` have been +marked as deprecated. + + +Impact +====== + +Using the methods is discouraged. The Extbase dispatcher still +recognizes them and acts accordingly, the methods do **not** raise +a deprecation level log entry, though. + + +Affected Installations +====================== + +Some Extbase based extensions may use :php:`setDispatched()`, but +it's rather unlikely since that flag has been mostly used internally +through existing helper methods in Extbase controllers. + +The extension scanner will find possible candidates. + + +Migration +========= + +Action dispatching in Extbase now depends on the returned response: + +* A casual 2xx Response from a controller action that for instance contains HTML + or Json stops Extbase dispatching, the response is later returned to the client. + +* An Extbase :php:`ForwardResponse` instructs the dispatcher to dispatch + internally to another controller action. + +* A 3xx :php:`RedirectResponse` stops dispatching and is returned to the client + to initiate some client redirect. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.3/Deprecation-94414-DeprecateLanguageServiceContainerEntry.rst b/Documentation/Changelog/11.3/Deprecation-94414-DeprecateLanguageServiceContainerEntry.rst new file mode 100644 index 0000000..bc0810a --- /dev/null +++ b/Documentation/Changelog/11.3/Deprecation-94414-DeprecateLanguageServiceContainerEntry.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94414: + +===================================================== +Deprecation: #94414 - LanguageService container entry +===================================================== + +See :issue:`94414` + +Description +=========== + +Instances of :php:`TYPO3\CMS\Core\Localization\LanguageService` require +custom initialization with a language key and additionally depend on Core services. +:php:`TYPO3\CMS\Core\Localization\LanguageServiceFactory` has therefore +previously been introduced in order to manage this initialization. +This replaced prior used instantiation via +:php:`TYPO3\CMS\Core\Localization\LanguageService::create()` or +:php:`GeneralUtility::makeInstance(LanguageService::class)`. + + +Impact +====== + +Injecting :php:`TYPO3\CMS\Core\Localization\LanguageService` or creating +instances via :php:`GeneralUtility::makeInstance(LanguageService::class)`, +:php:`LanguageService::create()`, :php:`LanguageService::createFromUserPreferences()` +or :php:`LanguageService::createFromSiteLanguage()` will trigger a +PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Extensions injecting :php:`TYPO3\CMS\Core\Localization\LanguageService` +or creating custom instances via :php:`GeneralUtility::makeInstance(LanguageService::class)` +or :php:`TYPO3\CMS\Core\Localization\LanguageService::create()`. + +This is relatively unlikely since most usages are bootstrap related and +extensions usually access the prepared LanguageService via :php:`GLOBALS['LANG']` +in normal cases. + +Usages of :php:`LanguageService::create()`, :php:`LanguageService::createFromUserPreferences()` +and :php:`LanguageService::createFromSiteLanguage()` are be found by the extension scanner +as strong match. + + +Migration +========= + +The factory :php:`TYPO3\CMS\Core\Localization\LanguageServiceFactory` +should be injected and used instead. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.3/Feature-89507-AddDescriptionForTCAPalettes.rst b/Documentation/Changelog/11.3/Feature-89507-AddDescriptionForTCAPalettes.rst new file mode 100644 index 0000000..ed27706 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-89507-AddDescriptionForTCAPalettes.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _feature-89507: + +================================================== +Feature: #89507 - Add description for TCA palettes +================================================== + +See :issue:`89507` + +Description +=========== + +A new TCA property :php:`description` on palettes entry level has been +introduced. If provided, the FormEngine will render its value below the +palette label, similar to the TCA field description. The value data +type is the same as for the palette label: a localized string. This +additional help text can therefore be used to clarify some field +usages directly in the UI. + +.. note:: + + In contrast to the palette label, the description property can not + be overwritten on a record type basis. + +Example usage: + +.. code-block:: php + + 'types' => [ + '0' => [ + 'showitem' => ' + --div--;palette, + --palette--;;palette_1, + ' + ] + ], + + 'palettes' => [ + 'palette_1' => [ + 'label' => 'palette_1', + 'description' => 'palette_1_description', + 'showitem' => 'palette_field_1, palette_field_2, palette_field_3', + ], + ], + + +Impact +====== + +Integrators now have the ability to add additional information +to TCA palettes, supporting editors on their daily work. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-89700-ShowLayoutsInTheWebInfoModule.rst b/Documentation/Changelog/11.3/Feature-89700-ShowLayoutsInTheWebInfoModule.rst new file mode 100644 index 0000000..a23bb99 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-89700-ShowLayoutsInTheWebInfoModule.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-89700: + +===================================================== +Feature: #89700 - Show layouts in the Web Info module +===================================================== + +See :issue:`89700` + +Description +=========== + +It's now possible to get an overview of the configured backend and +frontend layouts of a page in the :guilabel:`Web->Info` module. Therefore a +new entry "Layouts" is available for the page tree overview. + +Besides the "Backend Layout (this page only)", the "Backend Layout (subpages of this page)" +and the "Layout" fields, which do all just display the title of the current +field value, an additional field "Actual backend layout" is displayed. This +field contains the title of the backend layout, which is actually used for +the page. If set, this is the same as "Backend Layout (this page only)". +Otherwise, it contains the inherited layout from a parent page, which +defined "Backend Layout (subpages of this page)". + +This is especially useful for editors to determine the actually used +backend layout, which was previously often difficult. For example in +installations with large page trees and highly developed inheritance. + +In case the current field value is invalid, e.g. referencing a non-existent +backend layout, this is now also shown to the editor. + +Impact +====== + +The :guilabel:`Web->Info` module now contains a new page tree overview type, which +contains the layout related fields as well as an additional field, +displaying the actually used backend layout for the corresponding page. + +.. index:: Backend, TSConfig, ext:core diff --git a/Documentation/Changelog/11.3/Feature-92358-AddGetModuleTemplateToPageLayoutController.rst b/Documentation/Changelog/11.3/Feature-92358-AddGetModuleTemplateToPageLayoutController.rst new file mode 100644 index 0000000..486f5f7 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-92358-AddGetModuleTemplateToPageLayoutController.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-92358: + +================================================================= +Feature: #92358 - Add getModuleTemplate() to PageLayoutController +================================================================= + +See :issue:`92358` + +Description +=========== + +The :php:`TYPO3\CMS\Backend\Controller\PageLayoutController` features +two hooks for manipulating the "Page" module. :php:`drawHeaderHook` and +:php:`drawFooterHook`. Those hooks already +receive the parent object :php:`PageLayoutController`. Since the calling +code expects the hooks to return additional content, it was previously +not possible to change other parts of the module, for example the module header. + +To give developers more possibilities in manipulating the "Page" module, +using the mentioned hooks, the parent object now contains a new getter +method :php:`getModuleTemplate()`. It can for example be used to add an +additional button to the modules' button bar. + +.. code-block:: php + + public function drawHeaderHook(array $parameters, PageLayoutController $parentObject) + { + $moduleTemplate = $parentObject->getModuleTemplate(); + $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar(); + + $linkButton = $buttonBar + ->makeLinkButton() + ->setHref('/typo3/some/url') + ->setTitle('My custom button') + ->setClasses('custom-link-class') + ->setIcon($moduleTemplate->getIconFactory()->getIcon('actions-link', Icon::SIZE_SMALL)); + + $buttonBar->addButton($linkButton); + } + +Impact +====== + +When using either the :php:`drawHeaderHook` or the :php:`drawFooterHook` of the +:php:`PageLayoutController`, the provided parent object now contains +the :php:`getModuleTemplate()` method, which can be used to retrieve +the corresponding :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate` instance. +This provides more flexibility to third party code manipulating the "Page" +module view. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-92518-DownloadAndFilenameOptionsAddedToFileDumpController.rst b/Documentation/Changelog/11.3/Feature-92518-DownloadAndFilenameOptionsAddedToFileDumpController.rst new file mode 100644 index 0000000..df3271e --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-92518-DownloadAndFilenameOptionsAddedToFileDumpController.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-92518: + +=========================================================================== +Feature: #92518 - Download and filename options added to FileDumpController +=========================================================================== + +See :issue:`92518` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Controller\FileDumpController` has been extended with +the parameters :php:`dl` and :php:`fn`. + +* :php:`dl`: Force download of the file +* :php:`fn`: Use an alternative filename + +See the following example on how to create a URI including the new parameters: + +.. code-block:: php + + // use TYPO3\CMS\Core\Utility\GeneralUtility; + // use TYPO3\CMS\Core\Utility\PathUtility; + // use TYPO3\CMS\Core\Core\Environment; + $queryParameterArray = [ + 'eID' => 'dumpFile', + 't' => 'f', + 'f' => $resourceObject->getUid(), + 'dl' => true, + 'fn' => 'alternative-filename.jpg' + ]; + $queryParameterArray['token'] = + GeneralUtility::hmac( + implode('|', $queryParameterArray), + 'resourceStorageDumpFile' + ); + + $publicUrl = + GeneralUtility::locationHeaderUrl( + PathUtility::getAbsoluteWebPath(Environment::getPublicPath() . '/index.php') + ); + $publicUrl .= '?' . http_build_query($queryParameterArray, '', '&', PHP_QUERY_RFC3986); + +This will create a URI from a :sql:`sys_file` record and trigger a download of the +file with the alternative filename, using the :html:`Content-Disposition: attachment` +header. + +To ease the use of the file dump functionality, also a new ViewHelper +is added. See :doc:`FileViewHelper <../11.3/Feature-92518-IntroduceFileViewHelper>` +for further information. + +Impact +====== + +The `dumpFile` eID script is now capable of the `dl` parameter, forcing +the download of the corresponding file, as well as the `fn` parameter, +which can be used to define an alternative file name. + +.. index:: FAL, ext:core diff --git a/Documentation/Changelog/11.3/Feature-92518-IntroduceFileViewHelper.rst b/Documentation/Changelog/11.3/Feature-92518-IntroduceFileViewHelper.rst new file mode 100644 index 0000000..61d7edf --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-92518-IntroduceFileViewHelper.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-92518-1668719172: + +========================================== +Feature: #92518 - Introduce FileViewHelper +========================================== + +See :issue:`92518` + +Description +=========== + +With :doc:`#92518 <../11.3/Feature-92518-DownloadAndFilenameOptionsAddedToFileDumpController>`, +the :php:`\TYPO3\CMS\Core\Controller\FileDumpController` has been extended with +new options to force the download of a file, as well as the option to define a +custom filename. + +To ease the use of the file dump functionality, especially the newly introduced +options, a new ViewHelper :php:`TYPO3\CMS\Fluid\ViewHelpers\Link\FileViewHelper` +is added, which allows extension authors to easily create links to both public +and non-public files. + +The usage is as following: + +.. code-block:: html + + <f:link.file file="{file}" download="true" filename="alternative-name.jpg"> + Download file + </f:link.file> + +The above example will create a link to the given file, forcing a direct +download, while using the alternative filename. + +In case the file is publicly accessible, a direct link will be used. Otherwise +the file dump functionality comes into play. + +.. code-block:: html + + <!-- Public file --> + <a href="https://example.com/fileadmin/path/to/file.jpg" + download="alternative-name.jpg" + > + Download file + </a> + + <!-- Non-public file --> + <a href="https://example.com/index.php?eID=dumpFile&t=f&f=123&dl=1&fn=alternative-name.jpg&token=79bce812"> + Download file + </a> + +.. note:: + + The :php:`file` argument accepts a + :php:`\TYPO3\CMS\Core\Resource\FileInterface`. So either a + :php:`\TYPO3\CMS\Core\Resource\File`, + a :php:`\TYPO3\CMS\Core\Resource\FileReference` or a + :php:`\TYPO3\CMS\Core\Resource\ProcessedFile` can be provided. + +.. note:: + + The :html:`filename` argument accepts an alternative filename. In case the + provided filename contains a file extension, this must be the same + as from the :html:`file` object. If file extensions is omitted, the original + file extension is automatically appended to the given filename. + +Impact +====== + +The new ViewHelper allows creating links to files - even non-public ones - +in a straightforward way within Fluid templates. + +.. index:: FAL, Fluid, ext:fluid diff --git a/Documentation/Changelog/11.3/Feature-93114-NativeSupportForLanguageShonaBantuAdded.rst b/Documentation/Changelog/11.3/Feature-93114-NativeSupportForLanguageShonaBantuAdded.rst new file mode 100644 index 0000000..719382d --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93114-NativeSupportForLanguageShonaBantuAdded.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-93114: + +================================================================= +Feature: #93114 - Native support for language Shona (Bantu) added +================================================================= + +See :issue:`93114` + +Description +=========== + +TYPO3 now supports Shona (Bantu language) - the language of the Shona +people of Zimbabwe - out of the box. + +Shona is one of the most widely spoken Bantu languages +(`Shona on Wikipedia <https://en.wikipedia.org/wiki/Shona_language>`__). + +The ISO 639-1 code for Shona is "sn", which is how TYPO3 +is accessing the language internally. + + +Impact +====== + +It is now possible to + +* Fetch translated labels from translations.typo3.org / CrowdIn + automatically within the TYPO3 Backend +* Switch the Backend Interface to Shona language +* Create a new language in a site configuration using Shona +* Create translation files with the "sn" prefix (such as `sn.locallang.xlf`) + to create your own labels + +and TYPO3 will pick Shona as a language just like any other +supported language. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/11.3/Feature-93210-PossibilityToRefreshDashboardWidgets.rst b/Documentation/Changelog/11.3/Feature-93210-PossibilityToRefreshDashboardWidgets.rst new file mode 100644 index 0000000..c67d54e --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93210-PossibilityToRefreshDashboardWidgets.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _feature-93210: + +========================================================== +Feature: #93210 - Possibility to refresh dashboard widgets +========================================================== + +See :issue:`93210` + +Description +=========== + +For some widgets it makes sense for users to be able to refresh the widget +without reloading the complete dashboard. Therefore, a new refresh action +button will be available in the top right corner of widgets, which have the +refresh option enabled. + +To enable the refresh action button, you have to define the +:yaml:`refreshAvailable` option in the :yaml:`$options` part of the widget +registration. Below is an example of a RSS widget with the refresh option enabled. + +.. code-block:: yaml + + dashboard.widget.myOwnRSSWidget: + class: 'TYPO3\CMS\Dashboard\Widgets\RssWidget' + arguments: + $view: '@dashboard.views.widget' + $cache: '@cache.dashboard.rss' + $options: + rssFile: 'https://typo3.org/rss' + lifeTime: 43200 + refreshAvailable: true + tags: + - name: dashboard.widget + identifier: 'myOwnRSSWidget' + groupNames: ‘general’ + title: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:widgets.myOwnRSSWidget.title' + description: 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:widgets.myOwnRSSWidget.description' + iconIdentifier: 'content-widget-rss' + height: 'medium' + width: 'medium' + +.. note:: + + In this example, the TYPO3 Core :php:`TYPO3\CMS\Dashboard\Widgets\RssWidget` + widget class is used. In case you have implemented own widget classes, you + have to add the :php:`getOptions()` method, returning :php:`$this->options`, + to the corresponding classes. Otherwise the refresh option won't have any + effect. The method will anyways be required by the :php:`WidgetInterface` + in TYPO3 v12. + +JavaScript API +============== + +Besides having a refresh action button on widgets, which have the new option +enabled, it is possible for all widgets to dispatch an event, which will cause +the widget being refreshed. This is possible for all widgets on the dashboard +even when the :yaml:`refreshAvailable` option is not defined, or set to `false`. +This will give developers the option to refresh the widgets whenever they think +it is appropriate. + +You therefore need to dispatch the :js:`widgetRefresh` event on the +widget container (the :html:`div` element with the :html:`dashboard-item` class). +You can identify the container by the data attribute :html:`widget-hash`, which +is a unique hash for every widget, even if you have more widgets of the same +type on your dashboard. + +A small example below: + +.. code-block:: javascript + + document + .querySelector('[data-widget-hash="{your-unique-widget-hash}"]') + .dispatchEvent(new Event('widgetRefresh', {bubbles: true})); + + +Impact +====== + +Widgets with the option :yaml:`refreshAvailable` set to `true`, will now +feature a refresh action button in the top right corner of the widget. + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/11.3/Feature-93631-SupportForPHP80.rst b/Documentation/Changelog/11.3/Feature-93631-SupportForPHP80.rst new file mode 100644 index 0000000..2887988 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93631-SupportForPHP80.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-93631: + +===================================== +Feature: #93631 - Support for PHP 8.0 +===================================== + +See :issue:`93631` + +Description +=========== + +The TYPO3 Core is now compatible with PHP 8.0, which is officially supported +with security updates until 26th November 2023. + + +Impact +====== + +TYPO3 can now be installed on systems running with PHP 8.0. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.3/Feature-93668-PossibilityToConfigureSymfonyMailer.rst b/Documentation/Changelog/11.3/Feature-93668-PossibilityToConfigureSymfonyMailer.rst new file mode 100644 index 0000000..a0297fc --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93668-PossibilityToConfigureSymfonyMailer.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-93668: + +========================================================= +Feature: #93668 - Possibility to configure Symfony mailer +========================================================= + +See :issue:`93668` + +Description +=========== + +The install tool has now the possibility to configure the Symfony mailer with +DSN. Symfony provides different mail transports like SMTP, sendmail or many 3rd +party email providers like AWS SES, Gmail, MailChimp, Mailgun and more. You can +find all supported providers in the +`Symfony documentation <https://symfony.com/doc/current/mailer.html>`__. + +In the module :guilabel:`Admin tools > Settings` go to the card +:guilabel:`Configure Installation-Wide Options` and open the dialog. +Select :guilabel:`Mail` and set :php:`[MAIL][transport]` to :php:`dsn`. + +Additionally set :php:`[MAIL][dsn]` like described in the Symfony documentation. + +Examples: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['dsn'] = "smtp://user:pass@smtp.example.com:25"` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['dsn'] = "sendmail://default"` + + +Impact +====== + +If :php:`[MAIL][transport]` is set to :php:`dsn` all mails are sent with your +configured DSN. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.3/Feature-93825-RateLimitingForFailedLogins.rst b/Documentation/Changelog/11.3/Feature-93825-RateLimitingForFailedLogins.rst new file mode 100644 index 0000000..20fcc27 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93825-RateLimitingForFailedLogins.rst @@ -0,0 +1,109 @@ +.. include:: /Includes.rst.txt + +.. _feature-93825: + +================================================= +Feature: #93825 - Rate limiting for failed logins +================================================= + +See :issue:`93825` + +Description +=========== + +The TYPO3 backend and frontend login now uses a rate limiter by default, +which prevents further authentication attempts for an IP address, +if a configurable amount of login attempts is exceeded in a given time. + +The hardcoded wait time of 5 seconds after a failed login has been removed, +since it offers no real protection against brute force attacks and may +result in unwanted side effects. + +.. important:: + + Rate limiters do not provide a useful protection against DoS attacks. + They should be used to limit the amount of requests to certain routes + (for example login or form submission) of an application. + +Impact +====== + +TYPO3 ships with a rate limiter for backend and frontend authentication. +It implements the "Sliding Window Rate Limiter" allowing to define a +maximum amount of login attempts for a given range of time before further login +attempts will be denied for the remote IP address. + +A configurable list of IP addresses allows to exclude certain IP addresses or IP +address blocks from being rate limited. + +The rate limiter utilizes the TYPO3 caching framework as storage for +rate limiter states. The rate limiter takes care of garbage collection +for affected cache tables on every login request. + +Note, that clearing the system cache will purge all limiter states. + +Backend login +------------- + +The rate limiter :php:`Symfony\Component\RateLimiter\LoginRateLimiter` +for the TYPO3 backend login is enabled by +default and configured with the following default values: + +* Maximum 5 login attempts for a timeframe of 15 minutes +* No IP address excluded + +When the maximum amount of login attempts has exceeded, a +:php:`\TYPO3\CMS\Core\RateLimiter\RequestRateLimitedException` +exception is thrown. The exception implements +:php:`\TYPO3\CMS\Core\Error\Http\AbstractClientErrorException` +resulting in a user readable error message together with a 403 HTTP status code. + +Configuration +~~~~~~~~~~~~~ + +The rate limiter for the TYPO3 backend can be configured using the +Settings module or the Install tool. The following new configuration +values are available: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['loginRateLimit'] = 5; + $GLOBALS['TYPO3_CONF_VARS']['BE']['loginRateLimitInterval'] = '15 minutes'; + $GLOBALS['TYPO3_CONF_VARS']['BE']['loginRateLimitIpExcludeList'] = ''; + +Setting `[BE][loginRateLimit] = 0` will disable rate limiting. The same +applies, if `[BE][loginRateLimitIpExcludeList] = '*'` is configured. + +The provided defaults for `[BE][loginRateLimitInterval]` can be customized +in `AdditionalConfiguration.php` by configuring a date/time string following +PHP relative formats. + +Frontend login +-------------- + +The rate limiter for the TYPO3 frontend login is enabled by +default and configured with the following default values: + +* Maximum 10 login attempts for a timeframe of 15 minutes +* No IP address exclude list + +When the maximum amount of login attempts is exceeded, a +:php:`\TYPO3\CMS\Core\RateLimiter\RequestRateLimitedException` +exception is thrown. The exception implements +:php:`\TYPO3\CMS\Core\Error\Http\AbstractClientErrorException` +resulting in a user readable error message together with a 403 HTTP status code. + +Configuration +~~~~~~~~~~~~~ + +Configuration is similar to the rate limiter for the backend login. + +The following new configuration values are available: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['FE']['loginRateLimit'] = 10; + $GLOBALS['TYPO3_CONF_VARS']['FE']['loginRateLimitInterval'] = '15 minutes'; + $GLOBALS['TYPO3_CONF_VARS']['FE']['loginRateLimitIpExcludeList'] = ''; + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/11.3/Feature-93835-AddErrorForPropertyFunctionForAbstractValidator.rst b/Documentation/Changelog/11.3/Feature-93835-AddErrorForPropertyFunctionForAbstractValidator.rst new file mode 100644 index 0000000..23c36ae --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93835-AddErrorForPropertyFunctionForAbstractValidator.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-93835: + +==================================================================== +Feature: #93835 - AddErrorForProperty function for AbstractValidator +==================================================================== + +See :issue:`93835` + +Description +=========== + +When validating Extbase models, it could be helpful to assign the encountered +error to a certain property. This is already possible by using +:php:`$this->result->forProperty($propertyPath)->addError($error);`. This +method however is cumbersome and requires knowledge about the result object. +To ease the pain for developers, a convenience method :php:`addErrorForProperty` +is now available. + +Use it like this in a validator class: + +.. code-block:: php + + public function isValid(): void + { + // validation + $this->addErrorForProperty( + 'object.property.name', + $this->translateErrorMessage( + 'validator.errormessage', + 'my-ext' + ), + // tstamp_of_now_as_errorcode + 123456789 + ); + } + + +Impact +====== + +The new method enables developers adding custom error messages to validation +results of properties in a convenient way. + +.. index:: ext:extbase diff --git a/Documentation/Changelog/11.3/Feature-93921-SharingBackendLinks.rst b/Documentation/Changelog/11.3/Feature-93921-SharingBackendLinks.rst new file mode 100644 index 0000000..2d236b4 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-93921-SharingBackendLinks.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _feature-93921: + +======================================= +Feature: #93921 - Sharing backend links +======================================= + +See :issue:`93921` + +Description +=========== + +With the introduction of backend URL rewrites in :issue:`93048` and +the backend module web component router in :issue:`93988`, it's finally +possible to share backend URLs between each other. + +To ease the use of this, the +:php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton` is +extended for +a new option :php:`$copyUrlToClipboard`, which defaults to :php:`true`. +This option extends the shortcut button in the module header of a backend +module. Therefore, the button's icon is also changed. On click, a dropdown +opens, including the additional possibility to copy the current backend URL +directly to the operating system's clipboard, next to the already existing +bookmark option. + +For the dropdown button, a new icon :php:`share-alt` is registered, which can +be used through the :php:`IconFactory`. + +In case you are using the shortcut API in your custom backend module and +don't want to use this additional option, you can disabled it by setting +:php:`$shortcutButton->setCopyUrlToClipboard(false)`. If disabled, the +shortcut button is rendered with the same behaviour as before. + +.. note:: + + Since both ViewHelpers, :html:`<be:moduleLayout.button.shortcutButton>` + as well as :html:`<f:be.buttons.shortcut>` are deprecated, the new option + is not available for those. In case you are currently using one of those + ViewHelpers, but still want to profit from the new option in your custom + backend modules, you have to create the shortcut button in the corresponding + controller using the shortcut API. This will anyways be required in TYPO3 v12. + +Besides the new option for the :php:`ShortcutButton`, a new constant +:php:`SHAREABLE_URL` is available in the :php:`UriBuilder`. It can be +used as value for the :php:`$referenceType` parameter, which is available +for most of the "buildUri" methods, for example :php:`UriBuilder->buildUriFromRoute()`. + +.. code-block:: php + + $uri = $uriBuilder->buildUriFromRoute($routeName, $arguments, UriBuilder::SHAREABLE_URL); + +The above example will return an absolute URL without the automatically +created token parameter. + +Impact +====== + +If the new option is enabled, instead of the shortcut button, a dropdown +menu is displayed in the module header, including two options: + +* Option to add a shortcut to the current page +* Option to copy the URL of the current page to the operating system's clipboard + +When setting :php:`UriBuilder::SHAREABLE_URL` as :php:`$referenceType` in +one of the "buildUri" methods supporting this parameter, a shareable URL +will be returned. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-94081-TCAReadOnlyForT3editor.rst b/Documentation/Changelog/11.3/Feature-94081-TCAReadOnlyForT3editor.rst new file mode 100644 index 0000000..1566c44 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94081-TCAReadOnlyForT3editor.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-94081: + +=========================================== +Feature: #94081 - TCA readOnly for t3editor +=========================================== + +See :issue:`94081` + +Description +=========== + +The TCA :php:`'type' => 'text'` (textarea) based FormEngine +render type :php:`'renderType' => 't3editor'` now supports the +:php:`'readOnly' => true` option. If set, syntax highlighting +is applied as usual, but the corresponding text can not be edited. + +Example: + +.. code-block:: php + + 't3editor_2' => [ + 'label' => 't3editor_2', + 'description' => 'readOnly=true', + 'config' => [ + 'type' => 'text', + 'renderType' => 't3editor', + 'format' => 'html', + 'readOnly' => true, + ], + ], + + +Impact +====== + +This minor feature allows rendering highlighted code without the edit option. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-94143-DisplayCreationDateOfRedirects.rst b/Documentation/Changelog/11.3/Feature-94143-DisplayCreationDateOfRedirects.rst new file mode 100644 index 0000000..310f8ac --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94143-DisplayCreationDateOfRedirects.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-94143: + +==================================================== +Feature: #94143 - Display creation date of redirects +==================================================== + +See :issue:`94143` + +Description +=========== + +The EXT:redirects system extension provides a straightforward way of managing +redirects within a TYPO3 installation. The corresponding backend module +can be used to filter, create and analyse those redirects. + +Measuring the redirects performance is possible via the "Redirects hit count" +feature, which - if enabled - displays the amount of hits for each redirect +in the listing. More detailed information, for example the last hit, are +available in the records' "Statistics" tab. + +This tab is now extended to also display the creation date of the redirect. +This is especially useful to set the amount of hits in relation to the period +of the redirect existence. + +.. note:: + + The creation date will only be shown if the "Redirects hit count" + feature is enabled, see: + :doc:`#83677 <../9.1/Feature-83677-GloballyDisableenableRedirectHitStatistics>`. + +Impact +====== + +The creation date of a redirect is now shown in the :guilabel:`Statistics` tab +of the record's editing mask. + +.. index:: Backend, TCA, ext:redirects diff --git a/Documentation/Changelog/11.3/Feature-94206-AddExcludePagesRecursiveOptionToSitemapGeneration.rst b/Documentation/Changelog/11.3/Feature-94206-AddExcludePagesRecursiveOptionToSitemapGeneration.rst new file mode 100644 index 0000000..65cca1c --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94206-AddExcludePagesRecursiveOptionToSitemapGeneration.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-94206: + +============================================================================ +Feature: #94206 - Add excludePagesRecursive option to XML sitemap generation +============================================================================ + +See :issue:`94206` + +Description +=========== + +With this option you can exclude pages recursively in the XML sitemap: + +.. code-block:: typoscript + + plugin.tx_seo { + config { + xmlSitemap { + sitemaps { + pages { + config { + # comma-separated list of page UIDs which should be excluded recursively + excludePagesRecursive = 2,3 + } + } + } + } + } + } + +Impact +====== + +The new option enables integrators to easily exclude pages recursively in the XML sitemap + +.. index:: ext:seo diff --git a/Documentation/Changelog/11.3/Feature-94210-InformationAboutInheritedBackendLayout.rst b/Documentation/Changelog/11.3/Feature-94210-InformationAboutInheritedBackendLayout.rst new file mode 100644 index 0000000..0009f9b --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94210-InformationAboutInheritedBackendLayout.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-94210: + +============================================================ +Feature: #94210 - Information about inherited backend layout +============================================================ + +See :issue:`94210` + +Description +=========== + +When editing a page record, the field :php:`pages.backend_layout_next_level` can +be used to apply a backend layout to all subpages. + +This can make it difficult for the editor to determine the currently applied +backend layout. To help the editor in case of an inherited layout a message +is now displayed below the :php:`pages.backend_layout` field label via a +new FormEngine field information. + + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-94218-SelectableColumnsPerTableInRecordList.rst b/Documentation/Changelog/11.3/Feature-94218-SelectableColumnsPerTableInRecordList.rst new file mode 100644 index 0000000..074b440 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94218-SelectableColumnsPerTableInRecordList.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-94218: + +============================================================= +Feature: #94218 - Selectable columns per table in record list +============================================================= + +See :issue:`94218` + +Description +=========== + +The Record List (commonly known from the list module) previously allowed to +select specific columns for a table at the bottom of the module via the +so-called "field selector". + +This approach had several drawbacks: + +* UX-wise the selection was not directly visible for users, as the component was + separated at the module page at the bottom +* Only possible to select fields explicitly in the "single-table view" + +Instead, this feature - the column selector - is now available at all times in +the title row of each table, regardless of the single-table-view, making it +much more appealing and prominent to use for editors. + +This feature is active by default, and can be disabled via UserTSconfig per +table or completely for a specific user or usergroup. + +Use cases / examples via UserTSconfig: + +.. code-block:: typoscript + + # disable the column selector for tt_content + mod.web_list.table.tt_content.displayColumnSelector = 0 + + # disable the column selector completely + mod.web_list.displayColumnSelector = 0 + + # Disable the column selector everywhere except for a specific table + mod.web_list.displayColumnSelector = 0 + mod.web_list.table.sys_category.displayColumnSelector = 1 + + +Impact +====== + +The field selector at the bottom is not available anymore, +it has been replaced by a dropdown selector at the top of each table. + +.. index:: Backend, TSConfig, ext:recordlist diff --git a/Documentation/Changelog/11.3/Feature-94345-AutoDetectEventTypes.rst b/Documentation/Changelog/11.3/Feature-94345-AutoDetectEventTypes.rst new file mode 100644 index 0000000..9194d96 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94345-AutoDetectEventTypes.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-94345: + +========================================= +Feature: #94345 - Auto-detect event types +========================================= + +See :issue:`94345` + +Description +=========== + +If no "event" tag is specified on an event listener in Services.yaml, the +event is automatically derived from the event method itself using reflection. + + +Impact +====== + +In the vast majority of cases, the "event" tag on an event listener in Services.yaml +is no longer necessary. + +Given this example event listener implementation: + +.. code-block:: php + + final class CategoryPermissionsAspect + { + public function addUserPermissionsToCategoryTreeData(ModifyTreeDataEvent $event): void + { + // ... + } + } + +With this registration: + +.. code-block:: yaml + + TYPO3\CMS\Backend\Security\CategoryPermissionsAspect: + tags: + - name: event.listener + identifier: 'backend-user-permissions' + method: 'addUserPermissionsToCategoryTreeData' + event: TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent + + +The :yaml:`event:` tag can be omitted, since it's automatically read from +the method signature :php:`addUserPermissionsToCategoryTreeData(ModifyTreeDataEvent $event)` +of the listener implementation: + +.. code-block:: yaml + + TYPO3\CMS\Backend\Security\CategoryPermissionsAspect: + tags: + - name: event.listener + identifier: 'backend-user-permissions' + method: 'addUserPermissionsToCategoryTreeData' + + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.3/Feature-94374-CreateNewFilemountViaFoldersContextMenu.rst b/Documentation/Changelog/11.3/Feature-94374-CreateNewFilemountViaFoldersContextMenu.rst new file mode 100644 index 0000000..c84388f --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94374-CreateNewFilemountViaFoldersContextMenu.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-94374: + +===================================================================== +Feature: #94374 - Create new file mount via the folder's context menu +===================================================================== + +See :issue:`94374` + +Description +=========== + +The :php:`sys_filemounts` records are an important feature, which +allows administrators to restrict their users to specific folders +in a file storage. + +The workflow however was always to first create the folder in the +"Filelist" module and afterwards switch to the list module to create +a new :php:`sys_filemounts` record for this folder. This furthermore +always required the administrator to select both, the storage and +the previously created folder, in the new record. + +To ease the use for administrators, the context menu of folders is +extended with a new option "New Filemount". Using this option opens +the FormEngine with a new :php:`sys_filemounts` record, having the +correct storage and folder prefilled. + +Impact +====== + +It is now possible to create new file mounts directly in the Filelist +module, using the new "New Filemount" option in the folder's context +menu. This option also prefills the new record with the correct storage +and folder values. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/11.3/Feature-94390-DropdownForRecordListAndFileListInFavorOfExtendedView.rst b/Documentation/Changelog/11.3/Feature-94390-DropdownForRecordListAndFileListInFavorOfExtendedView.rst new file mode 100644 index 0000000..7f065c0 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94390-DropdownForRecordListAndFileListInFavorOfExtendedView.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-94390: + +================================================================================== +Feature: #94390 - Dropdown for record list and file list in favor of Extended View +================================================================================== + +See :issue:`94390` + +Description +=========== + +The option "Extended View", which was used in the TYPO3 Backend +modules :guilabel:`Web => List` and :guilabel:`File => Filelist` to show +additional icons, has been removed in favor of a dropdown with all items which is +always available. + + +Impact +====== + +This change is added as a user experience improvement over an additional +configuration option to give editors a unified experience, as +the additional menu with alternative items is common in other +web applications. + +The TSconfig options `options.file_list.enableDisplayBigControlPanel` +and `mod.web_list.enableDisplayBigControlPanel` have no effect anymore, +because the checkboxes are removed. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-94411-RecordlistDownloadSettings.rst b/Documentation/Changelog/11.3/Feature-94411-RecordlistDownloadSettings.rst new file mode 100644 index 0000000..13b5198 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94411-RecordlistDownloadSettings.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-94411: + +=============================================== +Feature: #94411 - Record list download settings +=============================================== + +See :issue:`94411` + +Description +=========== + +In :issue:`94366`, the record download functionality in the List +module was improved. Since then, the download could be triggered via a +button in each table's header and no longer just in the single table view. + +The download however did still not allow to adjust any settings, such as +the definition of a custom filename. Furthermore, only CSV was available +as possible download format. + +Therefore, and to further improve the already existing record download +functionality, the download button in the table's header does not longer +trigger the download directly, but opens a modal with various adjustable +download settings such as: + +* Selection of columns to download: All columns or selected columns +* Selection of the record values format: Either raw database values + or processed (resolved) values +* Definition of a custom filename +* Selection of the download format (for example CSV) + +Also download format specific options are available, for example selection of +the delimiter for CSV downloads. + +In case your installation already defines related TSconfig options +(for example :typoscript:`mod.web_list.csvDelimiter`), they will be added +as default value to the configuration modal. + +Besides introducing those settings, also JSON is now available as +an alternative download format, including a format specific option, +which allows to define additional meta information to be included in +the download. + +Impact +====== + +It's now possible to configure the download of records in the +record list. Furthermore, the new format option :php:`json` is available. + +.. index:: Backend, ext:recordlist diff --git a/Documentation/Changelog/11.3/Feature-94428-ExtbaseRequestImplementsServerRequestInterface.rst b/Documentation/Changelog/11.3/Feature-94428-ExtbaseRequestImplementsServerRequestInterface.rst new file mode 100644 index 0000000..81f67e8 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94428-ExtbaseRequestImplementsServerRequestInterface.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-94428: + +=================================================================== +Feature: #94428 - Extbase Request implements ServerRequestInterface +=================================================================== + +See :issue:`94428` + +Description +=========== + +The Extbase :php:`TYPO3\CMS\Extbase\Mvc\Request` now implements +the PSR-7 :php:`ServerRequestInterface` and thus holds all request +related information of the main Core request in addition to the +plugin namespace specific Extbase arguments. + + +Impact +====== + +This allows getting information of the main request especially within +Extbase controllers from :php:`$this->request`. + +Developers of Fluid ViewHelpers can now retrieve the main PSR-7 request +in many contexts from :php:`$renderingContext->getRequest()`, in addition +to the Extbase specific information specified by +:php:`TYPO3\CMS\Extbase\Mvc\Request\RequestInterface`. + +Note that with future patches, the request assigned to ViewHelper +:php:`RenderingContext` may NOT implement Extbase +:php:`TYPO3\CMS\Extbase\Mvc\Request\RequestInterface` anymore, and +only PSR-7 :php:`ServerRequestInterface`. This will be the case when the +ViewHelper is not called from within an Extbase plugin, but when Fluid +is started as "standalone view" in non-extbase based plugins: Often in +backend scenarios like toolbars, doc headers, non-extbase modules, etc. +Extensions should thus test for instance of Extbase :php:`RequestInterface` +if they don't know the context and rely on Extbase specific request data. + + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/11.3/Feature-94447-NativeSupportForLanguageWelshAdded.rst b/Documentation/Changelog/11.3/Feature-94447-NativeSupportForLanguageWelshAdded.rst new file mode 100644 index 0000000..2f32887 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94447-NativeSupportForLanguageWelshAdded.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-94447: + +========================================================= +Feature: #94447 - Native support for language Welsh added +========================================================= + +See :issue:`94447` + +Description +=========== + +TYPO3 now supports Welsh (historically known as "Cymbric"). Welsh is part +of the Celtic language family - is the official language in Wales, which is +part of the United Kingdom. + +The ISO 639-1 code for Welsh is "cy", which is how TYPO3 +is accessing the language internally. + + +Impact +====== + +It is now possible to + +* Fetch translated labels from translations.typo3.org / CrowdIn automatically + within the TYPO3 Backend +* Switch the backend interface to Welsh language +* Create a new language in a site configuration using Welsh +* Create translation files with the "cy" prefix (such as `cy.locallang.xlf`) + to create your own labels + +and TYPO3 will pick Welsh as a language just like any other supported language. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/11.3/Feature-94452-ImprovedMulti-SelectionInFileSelection.rst b/Documentation/Changelog/11.3/Feature-94452-ImprovedMulti-SelectionInFileSelection.rst new file mode 100644 index 0000000..d584023 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94452-ImprovedMulti-SelectionInFileSelection.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-94452: + +============================================================ +Feature: #94452 - Improved multi-selection in file selection +============================================================ + +See :issue:`94452` + +Description +=========== + +The file selector, which is used in TYPO3 Backend, to choose one +or multiple files to be connected via :sql:`sys_file_reference` in the +FormEngine, has been improved to have a better visual option +when selecting multiple records. + +Previously, there was a checkbox button at the end of each file row. The +checkbox is now re-ordered and moved to the beginning +of each row and is now based on our TYPO3 Icon Set. + +In addition, the view is more compact and when selecting multiple +items there is an option to select all items, no items or to toggle the +selection. The :guilabel:`Import selection` button now has a visual text next to +the icon, making it clearer what this button does. + + +Impact +====== + +Selection of files is now quicker to grasp for editors working +with files. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.3/Feature-94474-ImprovedShowColumnsSelectionInRecordList.rst b/Documentation/Changelog/11.3/Feature-94474-ImprovedShowColumnsSelectionInRecordList.rst new file mode 100644 index 0000000..a4831f4 --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94474-ImprovedShowColumnsSelectionInRecordList.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-94474: + +================================================================ +Feature: #94474 - Improved show columns selection in record list +================================================================ + +See :issue:`94474` + +Description +=========== + +Since :issue:`94218`, the column selector in the record list, +formerly known as "field selector", is available for each +individual record type in its table header. When accessing +the selector, a dropdown opened, displaying all available +columns. + +This was already a huge improvement, as the selection was +now directly bound to the corresponding table and was +always available, not only in the "single-table view". + +However, there were still some drawbacks, especially the fact +that the dropdown solution could lead to confusion in case a +record contains a couple of columns with long labels. Therefore, +the column selection has been improved and is now not longer +opened in a dropdown, but lives in a clear and large enough modal. + +In the new modal, besides the columns to select, there are three +new options available: + +* Option to select all columns +* Option to unselect all columns +* Option to toggle (invert) the current selection + +Those options are also fixed at the top, so they are always +visible, even for records with a lot of columns, for example `pages`. + +Management fields, such as `uid` or `cr_date` are now displayed +with human-readable labels, making them more useful for editors. +Especially because those labels are not only used in the selector, +but are now also displayed in the record list table header. + +Furthermore, the columns are now sorted lexically, while always +enabled columns, such as the record title, are always at the top +and all columns, not having a label, are added at the end of the list. + +The checkboxes are improved in their size and appearance. Instead of +the usual "check" icon, an "eye" icon is used, making the intention +clear. + +Impact +====== + +The column selector of each table in the record list now opens +a modal with improved selection functionality and an overall +improved UX. + +.. index:: Backend, ext:recordlist diff --git a/Documentation/Changelog/11.3/Feature-94524-EditMetadataForAFileViaTheContextMenu.rst b/Documentation/Changelog/11.3/Feature-94524-EditMetadataForAFileViaTheContextMenu.rst new file mode 100644 index 0000000..fb9793f --- /dev/null +++ b/Documentation/Changelog/11.3/Feature-94524-EditMetadataForAFileViaTheContextMenu.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-94524: + +=============================================================== +Feature: #94524 - Edit metadata for a file via the context menu +=============================================================== + +See :issue:`94524` + +Description +=========== + +Editing metadata of files is an important task for editors. +To ease the use, the context menu for files has been extended +by a new option :guilabel:`Edit metadata of this file`, which can be +used to direly jump into the corresponding editing mask. + +Impact +====== + +It is now possible to edit a file's metadata directly via the +context menu. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/11.3/Important-91496-ChangesToPasswordResetFunctionality.rst b/Documentation/Changelog/11.3/Important-91496-ChangesToPasswordResetFunctionality.rst new file mode 100644 index 0000000..11cd97c --- /dev/null +++ b/Documentation/Changelog/11.3/Important-91496-ChangesToPasswordResetFunctionality.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _important-91496: + +=========================================================== +Important: #91496 - Changes to password reset functionality +=========================================================== + +See :issue:`91496` + +Description +=========== + +For various reasons, administrators may disable the password reset functionality, +introduced in :issue:`89513`, by setting +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset']= false`. +If disabled, TYPO3 Backend users are not able to initiate the +password reset process anymore. + +This however previously also disabled the password reset cli command as well as +the reset password action in the backend user module. Since it is a valid +use case for administrators to disallow a password reset initiated by users on +the login screen, they might still need the possibility to do so, on their own. + +Therefore, some things changed in the password reset implementation: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset']` now only + affects the password reset functionality on the login screen +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins']` is + unchanged and affects all places +* Initiating the password reset on CLI is now always enabled, while still + taking the `passwordResetForAdmins` option into account +* Initiating the password reset in the backend can now be disabled separately + with a new user TSconfig option :typoscript:`options.passwordReset` + +For compatibility reasons, the new user TSconfig option defaults to :php:`true`. +To completely disable the password reset in the backend for all users, you can +set the user TSconfig globally in your :php:`ext_localconf.php`: + +.. code-block:: php + + // use \TYPO3\CMS\Core\Utility\ExtensionManagementUtility; + ExtensionManagementUtility::addUserTSConfig( + 'options.passwordReset = 0' + ); + +If required, this can of course still be overwritten on a per user basis +in the corresponding :guilabel:`TSconfig` field. + +.. index:: Backend, CLI, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.3/Important-94312-RemovedBEloginSecurityLevelAndFEloginSecurityLevelOptions.rst b/Documentation/Changelog/11.3/Important-94312-RemovedBEloginSecurityLevelAndFEloginSecurityLevelOptions.rst new file mode 100644 index 0000000..69a3a13 --- /dev/null +++ b/Documentation/Changelog/11.3/Important-94312-RemovedBEloginSecurityLevelAndFEloginSecurityLevelOptions.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _important-94312: + +=================================================================================== +Important: #94312 - Removed BE/loginSecurityLevel and FE/loginSecurityLevel options +=================================================================================== + +See :issue:`94312` + +Description +=========== + +The `FE/loginSecurityLevel` and `BE/loginSecurityLevel` options were used to +define the security level of the backend and frontend login. Since dropping +the two possibilities `challenged` and `superchallenged` in v7, `rsa` and +`normal` were the only two valid values left. + +The `rsa` value however also became more or less obsolete, after dropping +`EXT:rsaauth` from Core in :issue:`87470`. Setting `rsa` therefore only had +effect in case the standalone `friendsoftypo3/rsaauth` extension was installed. + +Finally, with :issue:`94279` also the support for the standalone +`friendsoftypo3/rsaauth` was abandoned, making the `loginSecurityLevel` +option superfluous, as `normal` was left as the only valid option. + +Therefore, both options `FE/loginSecurityLevel` and `BE/loginSecurityLevel` +have been removed. As a result and to follow our backwards-compatibility promise, +all authentication services will still receive the `$passwordTransmissionStrategy` +argument in their :php:`processLoginData()` method, which however will now +always be `normal`. + +Impact +====== + +The options have been removed from the TYPO3's default configuration. +When those options have been set in your :php:`LocalConfiguration.php` +or :php:`AdditionalConfiguration.php` files, they are automatically +removed when accessing the Install Tool or System Maintenance area. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.3/Important-94315-UseProperPSR-3LoggingMessagesAndContext.rst b/Documentation/Changelog/11.3/Important-94315-UseProperPSR-3LoggingMessagesAndContext.rst new file mode 100644 index 0000000..963d34c --- /dev/null +++ b/Documentation/Changelog/11.3/Important-94315-UseProperPSR-3LoggingMessagesAndContext.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _important-94315: + +================================================================= +Important: #94315 - Use proper PSR-3 logging messages and context +================================================================= + +See :issue:`94315` + +Description +=========== + +The v11 Core is looking into proper PSR-3 Logging implementation again. When +analyzing the current situation, we realized many Core logging calls were +using messages that violated the PSR-3 +`placeholder specification <https://www.php-fig.org/psr/psr-3/>`__. + +The v11 Core fixed all places, but it's likely extensions have this issue, +too. Extension developers should have a look at their logger calls and adapt +them if necessary. + +Typical call before: + +.. code-block:: php + + $this->logger->alert( + 'Password reset requested for email "' . + $emailAddress . '" . but was requested too many times.' + ); + +Correct call: + +.. code-block:: php + + $this->logger->alert( + 'Password reset requested for email {email} but was requested too many times.', + ['email' => $emailAddress] + ); + +First argument is :php:`message`, second (optional) argument is :php:`context`. +A message can use :php:`{placeholders}`. + +All Core provided log writers will substitute placeholders in the message +with data from the context array, if a context array key with same name exists. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.3/Index.rst b/Documentation/Changelog/11.3/Index.rst new file mode 100644 index 0000000..d09c992 --- /dev/null +++ b/Documentation/Changelog/11.3/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-3: + +============ +11.3 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v11.0 release. + +.. attention:: + + After TYPO3 v11.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v11.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.4/Deprecation-85613-CategoryRegistry.rst b/Documentation/Changelog/11.4/Deprecation-85613-CategoryRegistry.rst new file mode 100644 index 0000000..c1e34a5 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-85613-CategoryRegistry.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-85613: + +======================================= +Deprecation: #85613 - Category Registry +======================================= + +See :issue:`85613` + +Description +=========== + +With :issue:`94622` the new TCA type `category` has been introduced +as a replacement for the :php:`\TYPO3\CMS\Core\Category\CategoryRegistry`. +Therefore, the :php:`CategoryRegistry` together with +:php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::makeCategorizable()` +method have been marked as deprecated and will be removed in TYPO3 v12. + +The main reasons for this replacement are: + +* Using a dedicated type is more intuitive and consistent +* No more :file:`TCA/Overrides` are necessary for defining category fields +* The new implementation is state of the art (e.g. direct usage of + the Doctrine API for automatically adding the database columns) + + +Impact +====== + +Defining category fields for tables with +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultCategorizedTables']` or +by calling :php:`ExtensionManagementUtility::makeCategorizable()` will +trigger a PHP :php:`E_USER_DEPRECATED` error. + +The extension scanner will furthermore detect any call to +:php:`ExtensionManagementUtility::makeCategorizable()` and +:php:`CategoryRegistry` as strong match and any usage of +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultCategorizedTables']` +as weak match. + + +Affected Installations +====================== + +All installations registering category fields using +:php:`ExtensionManagementUtility::makeCategorizable()` or defining +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultCategorizedTables']`. + +Furthermore, all installations, which directly access the :php:`CategoryRegistry`. + + +Migration +========= + +Directly define category fields in the corresponding TCA, using the :php:`category` +TCA type. Have a look at the corresponding +:doc:`changelog <../11.4/Feature-94622-NewTCATypeCategory>`, for code +examples. + +.. index:: PHP-API, TCA, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94619-ExtbaseObjectManager.rst b/Documentation/Changelog/11.4/Deprecation-94619-ExtbaseObjectManager.rst new file mode 100644 index 0000000..336ad7f --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94619-ExtbaseObjectManager.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94619: + +=========================================== +Deprecation: #94619 - Extbase ObjectManager +=========================================== + +See :issue:`94619` + +Description +=========== + +The Extbase ObjectManager as the legacy core object lifecycle and +dependency injection solution has been marked discouraged with TYPO3 v10 and +its introduction of the Symfony based dependency injection solution already. + +TYPO3 v11 no longer uses the Extbase ObjectManager - only in a couple +of places as fallback for third party extensions. The entire construct has now +been marked as deprecated and will be removed with v12: + +* :php:`TYPO3\CMS\Extbase\Object\ObjectManagerInterface` - Main interface +* :php:`TYPO3\CMS\Extbase\Object\ObjectManager` - Main implementation +* :php:`TYPO3\CMS\Extbase\Object\Container\Container` - Internal lifecycle management +* :php:`TYPO3\CMS\Extbase\Object\Exception` - Base exception +* :php:`TYPO3\CMS\Extbase\Object\Exception\CannotBuildObjectException` - Detail exception +* :php:`TYPO3\CMS\Extbase\Object\Container\Exception\CannotReconstituteObjectException` - Detail exception +* :php:`TYPO3\CMS\Extbase\Object\Container\Exception\UnknownObjectException` - Detail exception +* :php:`TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException` - Detail exception, obsolete + by deprecation of Extbase signal slot dispatcher already. +* :php:`TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException` - Detail exception, obsolete + by deprecation of Extbase signal slot dispatcher already. + + +Impact +====== + +Directly or indirectly calling :php:`\TYPO3\CMS\Extbase\Object\ObjectManager->get()` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Extensions that have been properly cleaned up for TYPO3 v10 compatibility are not affected. + +Extensions still relying on Extbase ObjectManager are strongly encouraged to +switch to :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance()` and +Symfony based DI instead. + +The extension scanner will find usages of the above classes and interfaces and shows +them as deprecated with a strong match. + + +Migration +========= + +Documentation of migration paths have been established with TYPO3 v10 +documentation already. The :ref:`TYPO3 explained dependency injection section<t3coreapi:DependencyInjection>` +and the :ref:`ObjectManager->get() v10 changelog entry <changelog-Deprecation-90803-ObjectManagerGet>` +are especially helpful. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.4/Deprecation-94654-GenericExtbaseDomainClasses.rst b/Documentation/Changelog/11.4/Deprecation-94654-GenericExtbaseDomainClasses.rst new file mode 100644 index 0000000..d73ad1a --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94654-GenericExtbaseDomainClasses.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94654: + +==================================================== +Deprecation: #94654 - Generic Extbase domain classes +==================================================== + +See :issue:`94654` + +Description +=========== + +Most Extbase "generic" domain model and repositories have been marked as deprecated: +They are opinionated implementations and can't be "correct" since the +domains they are used in are unique. + +The following classes have been marked as deprecated: + +* :php:`TYPO3\CMS\Extbase\Domain\Model\BackendUser` +* :php:`TYPO3\CMS\Extbase\Domain\Model\BackendUserGroup` +* :php:`TYPO3\CMS\Extbase\Domain\Model\FrontendUser` +* :php:`TYPO3\CMS\Extbase\Domain\Model\FrontendUserGroup` +* :php:`TYPO3\CMS\Extbase\Domain\Repository\BackendUserGroupRepository` +* :php:`TYPO3\CMS\Extbase\Domain\Repository\BackendUserRepository` +* :php:`TYPO3\CMS\Extbase\Domain\Repository\CategoryRepository` +* :php:`TYPO3\CMS\Extbase\Domain\Repository\FrontendUserGroupRepository` +* :php:`TYPO3\CMS\Extbase\Domain\Repository\FrontendUserRepository` + + +Impact +====== + +Using or extending the above classes is deprecated since TYPO3 v11. +They will be removed with TYPO3 v12. + + +Affected Installations +====================== + +Various Extbase based extensions may use or extend the classes. The +extension scanner will find usages with a strong match. + + +Migration +========= + +The migration paths are usually straight forward. + +Extensions that extend the repository classes should extend Extbase +:php:`TYPO3\CMS\Extbase\Persistence\Repository` instead and maybe copy +body methods like :php:`initializeObject()` if given and not overridden +already. + +Extensions that use the Extbase repositories directly should copy the +class to their extension namespace and use the own ones instead. + +Extensions that extend the model classes should extend +:php:`TYPO3\CMS\Extbase\DomainObject\AbstractEntity` instead and copy +the properties, getters and setters they need from the Extbase classes. +Those copied properties may need database mapping entries, which can +be copied from :file:`EXT:extbase/Configuration/Extbase/Persistence/Classes.php`. + +Extensions that use the Extbase models directly should copy the class +to their extension namespace, ideally strip them down to what the extension +actually needs, and copy the needed mapping information from +:file:`EXT:extbase/Configuration/Extbase/Persistence/Classes.php`. + +No database update of existing rows should be needed when transferring +the models to an own namespace, since none of the Extbase models +configured a :php:`recordType` in the mapping file at +:file:`EXT:extbase/Configuration/Extbase/Persistence/Classes.php`. + + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.4/Deprecation-94664-PdoCacheBackend.rst b/Documentation/Changelog/11.4/Deprecation-94664-PdoCacheBackend.rst new file mode 100644 index 0000000..d697cd0 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94664-PdoCacheBackend.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94664: + +======================================= +Deprecation: #94664 - Pdo cache backend +======================================= + +See :issue:`94664` + +Description +=========== + +The Caching Framework backend implementation :php:`TYPO3\CMS\Core\Cache\Backend\PdoBackend` +is superseded by the :php:`TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend` since +introduction of Doctrine DBAL. There is little reason to use :php:`PdoBackend` instead +of the :php:`Typo3DatabaseBackend` and the latter is optimized much better. + +:php:`PdoBackend` has thus been marked as deprecated and should not be used anymore. + + +Impact +====== + +The implementation has been marked as deprecated, usages trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Some instances *may* use this cache backend, but chances are low. This can +be verified in the backend "Configuration" module, section "TYPO3_CONF_VARS", +searching for string "PdoBackend". + + +Migration +========= + +TYPO3 cache backend configuration is usually done in :file:`LocalConfiguration.php`. +Affected instances should switch to :php:`Typo3DatabaseBackend` and eventually update +database schema. + +:file:`LocalConfiguration.php` example before: + +.. code-block:: php + + 'SYS' => [ + 'caching' => [ + 'cacheConfigurations' => [ + 'aCache' => [ + 'backend' => 'TYPO3\\CMS\\Core\\Cache\\Backend\\PdoBackend', + ... + +:file:`LocalConfiguration.php` example after: + +.. code-block:: php + + 'SYS' => [ + 'caching' => [ + 'cacheConfigurations' => [ + 'aCache' => [ + 'backend' => 'TYPO3\\CMS\\Core\\Cache\\Backend\\Typo3DatabaseBackend', + ... + + +In case this cache backend is still used for whatever reason and can't be dropped +easily, the class should be copied to an own extension having an own namespace. The +instance configuration needs to be adapted accordingly. Note there is an additional +schema definition file in :file:`EXT:core/Resources/Private/Sql/Cache/Backend/PdoBackendCacheAndTags.sql`, +that should be copied along the way with it's location being updated in the cache class. + +.. index:: LocalConfiguration, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94665-WincacheCacheBackend.rst b/Documentation/Changelog/11.4/Deprecation-94665-WincacheCacheBackend.rst new file mode 100644 index 0000000..4a6e82e --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94665-WincacheCacheBackend.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94665: + +============================================ +Deprecation: #94665 - Wincache cache backend +============================================ + +See :issue:`94665` + +Description +=========== + +The Caching Framework backend implementation :php:`TYPO3\CMS\Core\Cache\Backend\WincacheBackend` +is not maintained since Microsoft dropped its support: A PHP 7.4 compatible version +came long after PHP 7.4 release and there are no PHP 8.0 works in sight. This backend +in general found relatively little use and can be substituted with the well +maintained ApcuBackend key/value store on Windows platforms. + +:php:`WincacheBackend` has been marked as deprecated and should not be used anymore. + + +Impact +====== + +The implementation has been marked as deprecated, usages trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Some instances hosted on Windows platform *may* use this cache backend. This can be +verified in the backend "Configuration" module, section "TYPO3_CONF_VARS", searching +for string "WincacheBackend". + + +Migration +========= + +TYPO3 cache backend configuration is usually done in :file:`LocalConfiguration.php`. +Affected instances could switch to :php:`ApcuBackend` if the :php:`apcu` PHP module +is loaded, or alternatively to some other backend like :php:`RedisBackend`, +:php:`MemcachedBackend` or :php:`Typo3DatabaseBackend`, depending on the specific +cache size and usage characteristics. + + +:file:`LocalConfiguration.php` example before: + +.. code-block:: php + + 'SYS' => [ + 'caching' => [ + 'cacheConfigurations' => [ + 'aCache' => [ + 'backend' => 'TYPO3\\CMS\\Core\\Cache\\Backend\\WincacheBackend', + ... + +:file:`LocalConfiguration.php` example after: + +.. code-block:: php + + 'SYS' => [ + 'caching' => [ + 'cacheConfigurations' => [ + 'aCache' => [ + 'backend' => 'TYPO3\\CMS\\Core\\Cache\\Backend\\ApcuBackend', + ... + + +In case this cache backend is still used for whatever reason and can't be dropped +easily, the class should be copied to an own extension having an own namespace. The +instance configuration needs to be adapted accordingly. + +.. index:: LocalConfiguration, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94684-GeneralUtilityShortMD5.rst b/Documentation/Changelog/11.4/Deprecation-94684-GeneralUtilityShortMD5.rst new file mode 100644 index 0000000..9cd8c72 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94684-GeneralUtilityShortMD5.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94684: + +================================================ +Deprecation: #94684 - GeneralUtility::shortMD5() +================================================ + +See :issue:`94684` + +Description +=========== + +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility\GeneralUtility::shortMD5()` is a +shorthand method to create an MD5 string trimmed to a defined length, by default +10 characters. + +Such shortened checksums are highly susceptible to collisions, thus this method +has been marked as deprecated. + + +Impact +====== + +Calling :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::shortMD5()` will trigger a +PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any extension using :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::shortMD5()` is +affected. The extension scanner will find usages of that method. + + +Migration +========= + +Use the native :php:`md5()` function to create checksums. In conjunction with +:php:`substr()` the old behavior can be recovered: :php:`substr(md5($string), 0, 10)`. + +If checksums are stored in the database, adapt the respective +:file:`ext_tables.sql` file to use :sql:`VARCHAR(32)` for the affected database +fields. + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94687-SoftReferenceIndex.rst b/Documentation/Changelog/11.4/Deprecation-94687-SoftReferenceIndex.rst new file mode 100644 index 0000000..3331d52 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94687-SoftReferenceIndex.rst @@ -0,0 +1,207 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94687: + +================================================== +Deprecation: #94687 - Deprecate SoftReferenceIndex +================================================== + +See :issue:`94687` + +Description +=========== + +The :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex` class combined all core +soft reference parser implementations into one class. Each and every parser +had its own method residing in one class. It is now possible to define +a dedicated class for each parser, as a result :php:`SoftReferenceIndex` is not +needed anymore and has been therefore marked as deprecated. + +The related method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::softRefParserObj()` +was used to get the according soft reference parser object and was basically a +factory method. This logic has been moved into +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserFactory`. +:php:`BackendUtility::softRefParserObj` has been marked as internal in TYPO3 v11 +already. To ease migration, the old static method is still in place and triggers +a PHP :php:`E_USER_DEPRECATED` error when called. + +Another tightly coupled method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::explodeSoftRefParserList()`, +which was used to parse the comma separated list of soft reference parsers +and return them as an array, has now also been marked as deprecated. It was mostly used +for internal purposes. The corresponding logic now resides in the +:php:`getParsersBySoftRefParserList` method of +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserFactory`. + +All soft reference parsers are now required to implement the +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. +Not doing so will trigger a PHP :php:`E_USER_DEPRECATED` error. In TYPO3 v12 this will throw an exception. + +Impact +====== + +The following class is marked as deprecated. Instantiating this class will +trigger a PHP :php:`E_USER_DEPRECATED` error. + +* :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex` + +The following methods are marked as deprecated. Calling these methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + +* :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::softRefParserObj()` +* :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::explodeSoftRefParserList()` + +Soft reference parsers must implement +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. +Otherwise a PHP :php:`E_USER_DEPRECATED` error will be triggered and an exception will be thrown +in TYPO3 v12. + +Affected Installations +====================== + +* All installations registering user-defined soft reference parsers not + implementing :php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. +* All installations calling any of the above-mentioned methods. +* All installations, which are using :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex` + directly. + +Migration +========= + +Among other methods +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface` +ensures the method :php:`parse` is implemented. The previously used method name +:php:`findRef()` can be simply renamed to :php:`parse()`. The first 4 parameters +:php:`$table`, :php:`$field`, :php:`$uid` and :php:`$content` stay the same, as +well as the seventh (now fifth) and last parameter :php:`$structurePath`. The +remaining two parameters :php:`$spKey` (now :php:`$parserKey`) and +:php:`$spParams` (now :php:`$parameters`) have to be set by the +:php:`setParserKey()` method, in case they are needed. The key can be retrieved +by using the :php:`getParserKey()` method. + +The return type has been changed to an instance of +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserResult`. It +provides as static factory method simply called :php:`create()`. It expects the +`content` part of the old array as the first parameter and the `elements` part +as the second. If there are no matches, one can simply call +:php:`SoftReferenceParserResult::createWithoutMatches()`. + +If needed, one could also extend +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\AbstractSoftReferenceParser`. +This abstract class comes with the helper method :php:`makeTokenID()` (originally +in :php:`TYPO3\CMS\Core\Database\SoftReferenceIndex`) and a new method +:php:`setTokenIdBasePrefix`, which sets the concatenated string for the property +:php:`tokenID_basePrefix`. + +Example before: + +.. code-block:: php + + class MySoftReferenceParser implements SingletonInterface + { + public function findRef($table, $field, $uid, $content, $spKey, $spParams, $structurePath = '') + { + ... + + if (!empty($elements)) { + $resultArray = [ + 'content' => $content, + 'elements' => $elements + ]; + return $resultArray; + } + + return null; + } + } + +Example after: + +.. code-block:: php + + class MySoftReferenceParser implements SoftReferenceParserInterface + { + protected string $parserKey = ''; + protected array $parameters = []; + + public function parse(string $table, string $field, int $uid, string $content, string $structurePath = ''): SoftReferenceParserResult + { + ... + + if (!empty($elements)) { + return SoftReferenceParserResult::create( + $content, + $elements + ); + } + return SoftReferenceParserResult::createWithoutMatches(); + } + + /** + * @param string $parserKey The softref parser key. + * @param array $parameters Parameters of the softlink parser. Basically this is the content inside optional []-brackets after the softref keys. Parameters are exploded by "; + */ + public function setParserKey(string $parserKey, array $parameters): void + { + $this->parserKey = $parserKey; + $this->parameters = $parameters; + } + + public function getParserKey(): string + { + return $this->parserKey; + } + } + + +Instead of calling :php:`BackendUtility::softRefParserObj()` one should now create +an instance of :php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserFactory`. +This factory has a method: :php:`getSoftReferenceParser()`, which expects the +soft reference key as first argument (just like the BackendUtility method). + +Example before: + +.. code-block:: php + + $softRefObj = BackendUtility::softRefParserObj('typolink'); + +Example after: + +.. code-block:: php + + $softReferenceParserFactory = GeneralUtility::makeInstance(SoftReferenceParserFactory::class); + $softReferenceParser = $softReferenceParserFactory->getSoftReferenceParser('typolink'); + +The method :php:`BackendUtility::explodeSoftRefParserList()` should be replaced by +instantiating :php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserFactory` +and calling :php:`getParsersBySoftRefParserList()`. This method expects the +:php:`$parserList` as first argument, same as in the :php:`BackendUtility` +The second argument is a fallback configuration array for softref parsers. +This method returns an iterable of +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. + +Example before: + +.. code-block:: php + + $softRefs = BackendUtility::explodeSoftRefParserList($conf['softref']); + + foreach ($softRefs as $spKey => $spParams) { + $softRefObj = BackendUtility::softRefParserObj($spKey); + $resultArray = $softRefObj->findRef($table, $field, $idRecord, $valueField, $spKey, $softRefParams); + } + +Example after: + +.. code-block:: php + + foreach ($softReferenceParserFactory->getParsersBySoftRefParserList($conf['softref'], $softRefParams) as $softReferenceParser) { + $parserResult = $softReferenceParser->parse($table, $field, $idRecord, $valueField); + } + + +Related +======= + +* :doc:`RegisterSoftReferenceParsersViaDI (Feature) <Feature-94741-RegisterSoftReferenceParsersViaDI>` +* :doc:`RegisterSoftReferenceParsersViaDI (Deprecation) <Deprecation-94741-RegisterSoftReferenceParsersViaDI>` + +.. index:: Backend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94741-RegisterSoftReferenceParsersViaDI.rst b/Documentation/Changelog/11.4/Deprecation-94741-RegisterSoftReferenceParsersViaDI.rst new file mode 100644 index 0000000..7891179 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94741-RegisterSoftReferenceParsersViaDI.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94741: + +=========================================================== +Deprecation: #94741 - Register SoftReference parsers via DI +=========================================================== + +See :issue:`94741` + +Description +=========== + +The former way of registering soft reference parsers in the global array +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['softRefParser']` +has been marked as deprecated. + + +Impact +====== + +Registering soft reference parsers in the global array will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All installations, that register user-defined soft reference parsers in the +global array +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['softRefParser']`. + + +Migration +========= + +Use the new way of registering soft reference parsers by dependency injection +in the corresponding `Configuration/Services.(yaml|php)` file of your extension. + +Before: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['softRefParser']['your_key'] = \VENDOR\Extension\SoftReference\YourSoftReferenceParser::class; + +After: + +.. code-block:: yaml + + VENDOR\Extension\SoftReference\YourSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: your_key + +.. note:: + + If a parser is registered in both ways with the same key, the registration + in the global array takes precedence to ensure backwards-compatibility. + + To ensure compatibility with TYPO3 v10-v12, it is recommended to register + both places at the same time. + +Related +======= + +* :doc:`RegisterSoftReferenceParsersViaDI (Feature) <Feature-94741-RegisterSoftReferenceParsersViaDI>` +* :doc:`SoftReferenceIndex (Deprecation) <Deprecation-94687-SoftReferenceIndex>` + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94762-DeprecateJavaScriptTopfsModState.rst b/Documentation/Changelog/11.4/Deprecation-94762-DeprecateJavaScriptTopfsModState.rst new file mode 100644 index 0000000..43764ae --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94762-DeprecateJavaScriptTopfsModState.rst @@ -0,0 +1,76 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94762: + +========================================================== +Deprecation: #94762 - Deprecate JavaScript top.fsMod state +========================================================== + +See :issue:`94762` + +Description +=========== + +The JavaScript object :js:`top.fsMod` manages the "state" for page-tree and +file-tree related contexts in the backend user-interface like this: + +* :js:`top.fsMod.recentIds.web` contained the current ("recent") + page or file related identifier details were shown for +* :js:`top.fsMod.navFrameHighlightedID.web` contained the currently + selected identifier that was highlighted in page-tree or file-tree +* :js:`top.fsMod.currentBank` contained the current mount point or + file mount ("bank") used in page-tree or file-tree + +To get rid of inline JavaScript and reduce usage of JavaScript :js:`top.*`, +mentioned :js:`top.fsMod` has been marked as deprecated and replaced by new component +:js:`TYPO3/CMS/Backend/Storage/ModuleStateStorage`. + +Impact +====== + +As fall-back, reading from :js:`top.fsMod` is still possible, changing +data will cause a JavaScript exception. + +Affected Installations +====================== + +Sites using custom modifications for JavaScript aspects in the backend user +interface relying on :js:`top.fsMod`. + +Migration +========= + +New :js:`ModuleStorage` component is capable of providing similar behavior, +corresponding state is written to `sessionStorage` and available for current +client user session (per browser tab). + +.. code-block:: javascript + + import {ModuleStateStorage} from '../Storage/ModuleStateStorage'; + let identifier: string, selection: string|null, mount: string|null; + + // reading state + // ------------- + + const currentState = ModuleStateStorage.current('web'); + + identifier = top.fsMod.recentIds.web; // deprecated + identifier = currentState.identifier; // replacement + + selection = top.fsMod.navFrameHighlightedID.web; // deprecated + selection = currentState.selection; // replacement + + mount = top.fsMod.currentBank; // deprecated + mount = currentState.mount; // replacement + + // updating state + // -------------- + + // ModuleStateStorage.update(module, identifier, selected, mount?) + ModuleStateStorage.update('web', 123, true, '0'); + + // ModuleStateStorage.updateWithCurrentMount(module, identifier, selected) + ModuleStateStorage.updateWithCurrentMount('web', 123, true); + + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.4/Deprecation-94902-LowerCamelCaseOptionsOfExtImpExpCommands.rst b/Documentation/Changelog/11.4/Deprecation-94902-LowerCamelCaseOptionsOfExtImpExpCommands.rst new file mode 100644 index 0000000..977d8f0 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94902-LowerCamelCaseOptionsOfExtImpExpCommands.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94902: + +============================================================================= +Deprecation: #94902 - Deprecate lowerCamelCase options of EXT:impexp commands +============================================================================= + +See :issue:`94902` + +Description +=========== + +The CLI commands :bash:`impexp:export` and :bash:`impexp:import` offered +lowerCamelCased options, while the other TYPO3 Core commands offer lowercase +options only. The lowercase option aliases were introduced in both commands and +the lowerCamelCased options were marked as deprecated and will be removed in +TYPO3 v12. + + +Impact +====== + +If the CLI commands :bash:`impexp:export` or :bash:`impexp:import` are +executed with lowerCamelCased options, a PHP :php:`E_USER_DEPRECATED` error is +raised. + + +Affected Installations +====================== + +Any TYPO3 installation using lowerCamelCased options with commands +:bash:`impexp:export` or :bash:`impexp:import`. + + +Migration +========= + +Switch to the lower-cased option aliases: + +1. :bash:`impexp:export --includeRelated` => :bash:`impexp:export --include-related` +2. :bash:`impexp:export --includeStatic` => :bash:`impexp:export --include-static` +3. :bash:`impexp:export --excludeDisabledRecords` => :bash:`impexp:export --exclude-disabled-records` +4. :bash:`impexp:export --excludeHtmlCss` => :bash:`impexp:export --exclude-html-css` +5. :bash:`impexp:export --saveFilesOutsideExportFile` => :bash:`impexp:export --save-files-outside-export-file` +6. :bash:`impexp:import --updateRecords` => :bash:`impexp:import --update-records` +7. :bash:`impexp:import --ignorePid` => :bash:`impexp:import --ignore-pid` +8. :bash:`impexp:import --forceUid` => :bash:`impexp:import --force-uid` +9. :bash:`impexp:import --importMode` => :bash:`impexp:import --import-mode` +10. :bash:`impexp:import --enableLog` => :bash:`impexp:import --enable-log` + +.. index:: CLI, NotScanned, ext:impexp diff --git a/Documentation/Changelog/11.4/Deprecation-94953-EditPanelRelatedFrontendFunctionality.rst b/Documentation/Changelog/11.4/Deprecation-94953-EditPanelRelatedFrontendFunctionality.rst new file mode 100644 index 0000000..a82ccf3 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94953-EditPanelRelatedFrontendFunctionality.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94953: + +=============================================================== +Deprecation: #94953 - Edit panel related frontend functionality +=============================================================== + +See :issue:`94953` + +Description +=========== + +With the extraction of the "feedit" extension from TYPO3 core in v10 a +couple of TypoScript related properties have been rendered unused. Extensions +that provide a frontend editing approach should implement these on their own. + +The following TypoScript properties have been marked as deprecated and +will be removed in TYPO3 v12: + +* :typoscript:`stdWrap.editPanel` +* :typoscript:`stdWrap.editPanel.` +* :typoscript:`stdWrap.editIcons` +* :typoscript:`stdWrap.editIcons.` +* :typoscript:`EDITPANEL` content object + +Related PHP code has been marked as deprecated: + +* Method :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_editIcons()` - scanned +* Method :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_editPanel()` - scanned +* Method :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->editPanel()` - scanned +* Method :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->editIcons()` - scanned +* Method :php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->isDisabled()` - not scanned +* Class :php:`TYPO3\CMS\Frontend\ContentObject\EditPanelContentObject` - scanned +* Hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/classes/class.frontendedit.php']` - scanned, logged +* Property :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController.php->displayEditIcons` - scanned +* Property :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController.php->displayFieldEditIcons` - scanned +* Method :php:`TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_getEditPanel()` - scanned, logged +* Method :php:`TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_getEditIcon()` - scanned, logged +* Property :php:`TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_EPtemp_cObj` - scanned + + +Impact +====== + +Some of the method usages will trigger a PHP :php:`E_USER_DEPRECATED` error upon use. The +core extension EXT:fluid_styled_content still sets stdWrap.editPanel and +stdWrap.editIcons properties for content elements, so the known frontend editing +related extensions EXT:feedit and EXT:frontend_editing will continue to work +in v11. Those properties will be removed with v12. + + +Affected Installations +====================== + +Instances that use frontend editing extensions - most notably EXT:feedit or +EXT:frontend_editing - may see deprecated functionality being logged. The +extension scanner will find PHP usages. Using the TypoScript properties is +not logged. + + +Migration +========= + +Frontend editing related extensions like EXT:feedit and EXT:frontend_editing +should no longer rely on core provided preparation. The stdWrap functionality +can be integrated with stdWrap related hooks, the `EDITPANEL` cObj can be registered +as extension provided content object, which obsoleted the use of the +:php:`typo3/classes/class.frontendedit.php` hook. + +.. index:: Frontend, PHP-API, TypoScript, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/11.4/Deprecation-94956-PublicCObj.rst b/Documentation/Changelog/11.4/Deprecation-94956-PublicCObj.rst new file mode 100644 index 0000000..b87e992 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94956-PublicCObj.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94956: + +================================== +Deprecation: #94956 - Public $cObj +================================== + +See :issue:`94956` + +Description +=========== + +Frontend plugins receive an instance of :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer` when +called via :php:`ContentObjectRenderer->callUserFunction()`. This is +typically the case for plugins called as :typoscript:`USER` or indirectly +as :typoscript:`USER_INT` type. + +The instance of :php:`ContentObjectRenderer` has previously been set by +declaring a public (!) property :php:`cObj` in the consuming class. + +Handing a :php:`ContentObjectRenderer` instance around this way is hard to +follow and has thus been deprecated: Declaring :php:`public $cObj` should +be avoided. Frontend plugins that need the current :php:`ContentObjectRenderer` +should have a public :php:`setContentObjectRenderer()` method instead. + + +Impact +====== + +Declaring :php:`public $cObj` in a class called by +:php:`ContentObjectRenderer->callUserFunction()` triggers a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Frontend extension classes that neither extend :php:`TYPO3\CMS\Frontend\Plugin\AbstractPlugin` +("pibase") nor Extbase :php:`TYPO3\CMS\Extbase\Mvc\Controller\ActionController` +and have a public property :php:`cObj` are affected. + + +Migration +========= + +When instantiating the frontend plugin, :php:`ContentObjectRenderer->callUserFunction()` +now checks for a public method :php:`setContentObjectRenderer()` to explicitly set +an instance of the :php:`ContentObjectRenderer`. + +Many plugins may not need this instance at all. If the ContentObjectRenderer instance +used within the plugin does not rely on further ContentObjectRenderer state, for instance +if it only calls :php:`stdWrap()` or similar without using state like :typoscript:`LOAD_REGISTER`, +the :php:`cObj` class property should be avoided and an own instance of ContentObjectRenderer +should be created. + +Classes that do rely on current ContentObjectRenderer state should adapt their code. + +Before:: + + class Foo + { + public $cObj; + } + + +After:: + + class Foo + { + protected $cObj; + + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + } + + +.. index:: Frontend, PHP-API, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.4/Deprecation-94957-TypoScriptFrontendController-cObjectDepthCounter.rst b/Documentation/Changelog/11.4/Deprecation-94957-TypoScriptFrontendController-cObjectDepthCounter.rst new file mode 100644 index 0000000..99d19bb --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94957-TypoScriptFrontendController-cObjectDepthCounter.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94957: + +======================================================================= +Deprecation: #94957 - TypoScriptFrontendController->cObjectDepthCounter +======================================================================= + +See :issue:`94957` + +Description +=========== + +The :php:`TypoScriptFrontendController` contains a property to prevent endless +recursion of content objects during frontend rendering. With TypoScript +becoming less complex, this check becomes obsolete. To reduce dependencies +between :php:`TypoScriptFrontendController` and :php:`ContentObjectRenderer`, +the handling has been removed and property :php:`TypoScriptFrontendController->cObjectDepthCounter` +has been marked as deprecated. + + +Impact +====== + +If a TypoScript setup somehow manages to create a recursion, PHP will now stop +with a fatal PHP nesting level error at some point, instead TYPO3 frontend +rendering silently stopping. + + +Affected Installations +====================== + +Instances using property :php:`TypoScriptFrontendController->cObjectDepthCounter` +are affected. That property has been handled mostly internally, this case is unlikely. +The extension scanner will find usages with a weak match. + + +Migration +========= + +Drop usages of property :php:`TypoScriptFrontendController->cObjectDepthCounter`, +it is unused within TYPO3 v11. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.4/Deprecation-94958-ContentObjectRendererProperties.rst b/Documentation/Changelog/11.4/Deprecation-94958-ContentObjectRendererProperties.rst new file mode 100644 index 0000000..2bfe69d --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94958-ContentObjectRendererProperties.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94958: + +====================================================== +Deprecation: #94958 - ContentObjectRenderer properties +====================================================== + +See :issue:`94958` + +Description +=========== + +A couple of outdated and mostly unused properties of class +:php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer` have been marked +as deprecated: + +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->align` - Unused +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->oldData` - Unused +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->alternativeData` - Never set, only output during debug +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->currentRecordTotal` - Set, but never used +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->recordRegister` - Unused + + +Impact +====== + +Those properties did not have a purpose. Extensions shouldn't see +negative impact. + + +Affected Installations +====================== + +Instances with extensions that set or read these properties may be affected. +This is rather unlikely. The extension scanner finds candidates. + + +Migration +========= + +Drop usages. The properties will vanish in v12. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.4/Deprecation-94959-ContentObjectRendererConstructorInStandaloneView.rst b/Documentation/Changelog/11.4/Deprecation-94959-ContentObjectRendererConstructorInStandaloneView.rst new file mode 100644 index 0000000..1184327 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94959-ContentObjectRendererConstructorInStandaloneView.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94959: + +========================================================================= +Deprecation: #94959 - ContentObjectRenderer constructor in StandaloneView +========================================================================= + +See :issue:`94959` + +Description +=========== + +The :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer` constructor +argument of :php:`TYPO3\CMS\Fluid\View\StandaloneView` has been marked as +deprecated. The TYPO3 core never used this optional argument and +it added a hard dependency to Extbase classes from StandaloneView, which should +be avoided. + +The :php:`ContentObjectRenderer` instance within :php:`StandaloneView` has been used to update +the Extbase :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager` singleton, +even though Extbase bootstrap already sets the current ContentObjectRenderer to +:php:`ConfigurationManager`. + + +Impact +====== + +Extensions creating instances of :php:`StandaloneView` and handing over an +instance of :php:`ContentObjectRenderer` as constructor argument will see a PHP :php:`E_USER_DEPRECATED` error raised. + + +Affected Installations +====================== + +Most instances are probably not affected by this change since handing over +the constructor argument is rather unusual. + + +Migration +========= + +Do not hand over an instance of :php:`ContentObjectRenderer` when creating an +instance of :php:`StandaloneView`. + +.. index:: Fluid, PHP-API, NotScanned, ext:fluid diff --git a/Documentation/Changelog/11.4/Deprecation-94979-UsingCacheManagerOrDatabaseConnectionsDuringTYPO3Bootstrap.rst b/Documentation/Changelog/11.4/Deprecation-94979-UsingCacheManagerOrDatabaseConnectionsDuringTYPO3Bootstrap.rst new file mode 100644 index 0000000..c2fd054 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94979-UsingCacheManagerOrDatabaseConnectionsDuringTYPO3Bootstrap.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94979: + +======================================================================================= +Deprecation: #94979 - Using CacheManager or Database Connections during TYPO3 bootstrap +======================================================================================= + +See :issue:`94979` + +Description +=========== + +TYPO3 now triggers a PHP :php:`E_USER_DEPRECATED` error if extension authors +or site admins have code in their :file:`ext_localconf.php`, +:file:`Configuration/TCA/*` configuration files or :file:`ext_tables.php`, that +calls the :php:`\TYPO3\CMS\Core\Cache\CacheManager` or interacts with the database. + +This is important for extension authors as TYPO3 will become +stricter in the future in terms of booting up TYPO3's Core Configuration, making +typical requests much faster, as all configuration can be cached away. When +using TYPO3 in a build environment, this will also lead to possibilities to +pre-warmup caches during the build phase of a new deployment. + + +Impact +====== + +Accessing the database and utilizing the Cache Manager in +these files will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with extensions using Cache Manager +or Database Connections in their configuration files (see above). + + +Migration +========= + +Use proper places to initialize extensions, and only when +needed to reduce the general time to boot up TYPO3's configuration. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-94991-ExtbaseAbstractView.rst b/Documentation/Changelog/11.4/Deprecation-94991-ExtbaseAbstractView.rst new file mode 100644 index 0000000..38c1d1d --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94991-ExtbaseAbstractView.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94991: + +========================================== +Deprecation: #94991 - Extbase AbstractView +========================================== + +See :issue:`94991` + +Description +=========== + +To simplify and streamline Fluid view related class inheritance, +the Extbase class :php:`TYPO3\CMS\Extbase\Mvc\View\AbstractView` +has been marked as deprecated and will be removed in TYPO3 v12. + + +Impact +====== + +Extending the class should be avoided. Consuming classes should +directly implement :php:`TYPO3\CMS\Extbase\Mvc\View\ViewInterface` +instead. + + +Affected Installations +====================== + +Instances with own Extbase view classes that extend :php:`AbstractView` +are affected, but this is rather uncommon. The extension scanner will +find class usages as a strong match. + + +Migration +========= + +Affected Extbase view classes should implement :php:`ViewInterface` instead +and not extend :php:`AbstractView` anymore. The most simple solution is to +copy the interface implementation from the deprecated :php:`AbstractView` class. + +.. index:: Fluid, PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.4/Deprecation-94996-InComposerModeAllExtensionsShouldBeInstalledWithComposer.rst b/Documentation/Changelog/11.4/Deprecation-94996-InComposerModeAllExtensionsShouldBeInstalledWithComposer.rst new file mode 100644 index 0000000..7e99840 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-94996-InComposerModeAllExtensionsShouldBeInstalledWithComposer.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94996: + +======================================================================================== +Deprecation: #94996 - In Composer Mode, all Extensions should be installed with Composer +======================================================================================== + +See :issue:`94996` + +Description +=========== + +Having extensions within :file:`typo3conf/ext` in Composer mode, which have not +been installed with Composer, has been marked as deprecated. + +TYPO3 Extensions are Composer packages and therefore Composer mechanisms should +be used to install them properly in the project, and not placed manually in their +target location :file:`typo3conf/ext` + + +Impact +====== + +A PHP :php:`E_USER_DEPRECATED` error is raised for any extension that is not +installed with Composer, if the instance is composer based. + + +Affected Installations +====================== + +Composer based TYPO3 projects, that have extensions directly in :file:`typo3conf/ext`, +for instance under version control. + + +Migration +========= + +Composer based TYPO3 projects, that have extensions directly in :file:`typo3conf/ext` +under version control, should migrate them to be installed using the Composer path +repository mechanism: + + +.. code-block:: json + + { + "repositories": [ + { + "type": "path", + "url": "./packages/*/" + }, + ], + "require": { + "my/example-extension": "@dev", + } + } + + +Now, when `example-extension` is located in :file:`packages/example-extension`, it is picked +up by composer and symlinked into :file:`typo3conf/ext/example_extension`. + +.. index:: CLI, NotScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-95003-ExtbaseViewInterfaceCanRender.rst b/Documentation/Changelog/11.4/Deprecation-95003-ExtbaseViewInterfaceCanRender.rst new file mode 100644 index 0000000..0b76b6a --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95003-ExtbaseViewInterfaceCanRender.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95003: + +======================================================= +Deprecation: #95003 - Extbase ViewInterface canRender() +======================================================= + +See :issue:`95003` + +Description +=========== + +To streamline and simplify Fluid view related classes, the +Extbase related :php:`TYPO3\CMS\Extbase\Mvc\View\ViewInterface` +method :php:`canRender()` has been dropped from the interface. + +Impact +====== + +The method should not be used anymore. Implementations in consuming +view classes are kept in TYPO3 v11 but have been marked as deprecated and +trigger a PHP :php:`E_USER_DEPRECATED` error upon usage. + + +Affected Installations +====================== + +Method :php:`canRender()` had limited use within Extbase, it is rather +unlikely many instances with extensions using the method exist. It's +purpose was to check for Fluid template existence before calling +:php:`$view->render()`, but all existing view implementations throw an +exception during :php:`render()` if a template path can't be resolved. + + +Migration +========= + +Do not call :php:`canRender()` on template view instances, but let +:php:`render()` throw :php:`\TYPO3Fluid\Fluid\View\Exception\InvalidTemplateResourceException` on error +instead. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/11.4/Deprecation-95005-ExtbaseEmptyView.rst b/Documentation/Changelog/11.4/Deprecation-95005-ExtbaseEmptyView.rst new file mode 100644 index 0000000..8040422 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95005-ExtbaseEmptyView.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95005: + +======================================= +Deprecation: #95005 - Extbase EmptyView +======================================= + +See :issue:`95005` + +Description +=========== + +To further clean up and streamline Fluid view related functionality, the +Extbase related view class :php:`TYPO3\CMS\Extbase\Mvc\View\EmptyView` +has been marked as deprecated. + + +Impact +====== + +Using :php:`EmptyView` has been marked as deprecated and trigger a PHP :php:`E_USER_DEPRECATED` error upon use. + + +Affected Installations +====================== + +The class has been unused within TYPO3 core since its introduction in TYPO3 4.5. +It is rather unlikely instances have extensions using the class. The extension +scanner finds usages with a strong match. + + +Migration +========= + +If rendering "nothing" by a view instance is needed for whatever reason, the +same result can be achieved with a :php:`TYPO3\CMS\Fluid\View\StandaloneView` +view instance by setting :php:`$view->setTemplateSource('')` and calling +:php:`$view->render()`. But it's of course quicker to simply not render +anything at all. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/11.4/Deprecation-95009-PassingTypoScriptConfigurationAsConstructorArgumentToExceptionHandler.rst b/Documentation/Changelog/11.4/Deprecation-95009-PassingTypoScriptConfigurationAsConstructorArgumentToExceptionHandler.rst new file mode 100644 index 0000000..d6bb674 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95009-PassingTypoScriptConfigurationAsConstructorArgumentToExceptionHandler.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95009: + +=================================================================================================== +Deprecation: #95009 - Passing TypoScript configuration as constructor argument to Exception handler +=================================================================================================== + +See :issue:`95009` + +Description +=========== + +With :typoscript:`config.contentObjectExceptionHandler` it's possible to +adjust the exception handler behavior of the frontend. It's even possible +to use an own exception handler class. Previously, the TypoScript configuration +was therefore passed to the exception handler via a constructor argument. This +has now been deprecated to allow the use of DI. + +The configuration will now be passed using the new :php:`setConfiguration()` +method. This method will be enforced by the :php:`ExceptionHandlerInterface` +in TYPO3 v12. + +Impact +====== + +Using a custom exception handler, while not implementing the :php:`setConfiguration()` +method will trigger a deprecation log entry. The method will be enforced +in TYPO3 v12. + +Affected Installations +====================== + +All installations defining a custom exception handler via the TypoScript +configuration :typoscript:`config.contentObjectExceptionHandler`, while +not implementing the :php:`setConfiguration()` method. + +Migration +========= + +Remove the :php:`$configuration` argument from the constructor of any +custom exception handler class and implement the :php:`setConfiguration()` +method instead. + +.. index:: Frontend, PHP-API, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.4/Deprecation-95011-VariousGlobalJavaScriptFunctionsAndVariables.rst b/Documentation/Changelog/11.4/Deprecation-95011-VariousGlobalJavaScriptFunctionsAndVariables.rst new file mode 100644 index 0000000..7047a72 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95011-VariousGlobalJavaScriptFunctionsAndVariables.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95011: + +======================================================================= +Deprecation: #95011 - Various global JavaScript functions and variables +======================================================================= + +See :issue:`95011` + +Description +=========== + +The following globally available variables in TYPO3 Backend's JavaScript code have been marked as deprecated: + +* :js:`top.currentSubScript` +* :js:`top.currentModuleLoaded` +* :js:`top.nextLoadModuleUrl` + +In addition the global JavaScript function :js:`jump()` has +been marked as deprecated as well. + +This functionality has been around for a very long time, and +is superseded by TYPO3's Module Menu Component (since 4.5) and the newly introduced Backend Routing Component in JavaScript +since TYPO3 v11. + + +Impact +====== + +The variables will work and be filled as expected in TYPO3 v11, but will not be available anymore in TYPO3 v12. + +Calling :js:`jump()` will trigger a JavaScript warning in ones' +browser console. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions which utilize Backend +JavaScript and using the legacy functionality, which is highly +unlikely. + + +Migration +========= + +Use the ModuleMenu JavaScript API or the Router API to find out the current module or go to a specific route: + +.. code-block:: js + + const router = document.querySelector('typo3-backend-module-router'); + router.setAttribute('endpoint', url); + router.setAttribute('module', moduleName); + + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.4/Deprecation-95037-RootUidRelatedSettingOfTrees.rst b/Documentation/Changelog/11.4/Deprecation-95037-RootUidRelatedSettingOfTrees.rst new file mode 100644 index 0000000..f9b3106 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95037-RootUidRelatedSettingOfTrees.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95037: + +====================================================== +Deprecation: #95037 - rootUid related setting of trees +====================================================== + +See :issue:`95037` + +Description +=========== + +The setting :php:`rootUid` used in FormEngine's :php:`treeConfig` is superseded by +:php:`startingPoints` and has been marked as deprecated. + +In :php:`TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider` the +following methods have been marked as deprecated: + +* :php:`setRootUid()` +* :php:`getRootUid()` + + +Impact +====== + +Using `treeConfig/rootUid` in TCA will trigger a TCA migration to +`treeConfig/startingPoints` and raise a PHP :php:`E_USER_DEPRECATED` error. + +The same applies to the according page TSconfig option. + +The extension scanner detects any call to :php:`setRootUid()` +or :php:`getRootUid()` as weak match. + + +Affected Installations +====================== + +All extensions defining `rootUid` in their `TCA` or `TSconfig` are affected. +Furthermore all extensions directly calling one of the mentioned methods in +:php:`TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider`. + + +Migration +========= + +The setting `treeConfig/rootUid` can be migrated to `treeConfig/startingPoints` +passing the value as string, since `treeConfig/startingPoints` takes a +comma-separated value. The methods :php:`setRootUid()` and :php:`getRootUid()` +can be replaced by their successors :php:`setStartingPoints()` and +:php:`getStartingPoints()`. + +.. index:: Backend, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/11.4/Deprecation-95062-SkipSortingArgumentOfRelationHandler-writeForeignField.rst b/Documentation/Changelog/11.4/Deprecation-95062-SkipSortingArgumentOfRelationHandler-writeForeignField.rst new file mode 100644 index 0000000..51931a6 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95062-SkipSortingArgumentOfRelationHandler-writeForeignField.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95062: + +=================================================================================== +Deprecation: #95062 - $skipSorting argument of RelationHandler->writeForeignField() +=================================================================================== + +See :issue:`95062` + +Description +=========== + +To further clean up :php:`TYPO3\CMS\Core\DataHandling\DataHandler`, the unused +internal property :php:`callFromImpExp` has been removed. Its single usage has +been the 4th argument of :php:`TYPO3\CMS\Core\Database\RelationHandler->writeForeignField()`. +Handing over this argument to :php:`RelationHandler->writeForeignField()` has been +marked as deprecated. + + +Impact +====== + +Calling :php:`TYPO3\CMS\Core\Database\RelationHandler->writeForeignField()` with +4th argument triggers a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +It is unlikely instances contain extensions using the above argument, since +it carried a core internal information tailored for EXT:impexp specific needs. +The extension scanner will find usages as weak match. + + +Migration +========= + +No migration available. Consuming extensions should drop that argument. +Calling RelationHandler->writeForeignField() with non-default true as fourth +argument skipped some relation-sorting related code, which should be avoided. + +.. index:: Database, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-95065-HookExtTablesInclusion-PostProcessing.rst b/Documentation/Changelog/11.4/Deprecation-95065-HookExtTablesInclusion-PostProcessing.rst new file mode 100644 index 0000000..7e16fa3 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95065-HookExtTablesInclusion-PostProcessing.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95065: + +============================================================ +Deprecation: #95065 - Hook extTablesInclusion-PostProcessing +============================================================ + +See :issue:`95065` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['extTablesInclusion-PostProcessing']` +which is executed after :file:`ext_tables.php` files are included has been marked +as deprecated. + +The accompanied PHP interface for such hooks +:php:`TYPO3\CMS\Core\Database\TableConfigurationPostProcessingHookInterface` is +marked as deprecated as well. + + +Impact +====== + +If a hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +Migrate to PSR-14 events, mainly the newly introduced :php:`\TYPO3\CMS\Core\Core\Event\BootCompletedEvent` and +the existing :php:`\TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent` +depending on the use-case. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-95077-FilelistEditIconsHook.rst b/Documentation/Changelog/11.4/Deprecation-95077-FilelistEditIconsHook.rst new file mode 100644 index 0000000..dac0e41 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95077-FilelistEditIconsHook.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95077-1668719172: + +============================================ +Deprecation: #95077 - Filelist editIconsHook +============================================ + +See :issue:`95077` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['fileList']['editIconsHook']` +which is executed in the :php:`FileList` class to manipulate the icons, used +for the edit control section in the files and folders listing, has been marked as +deprecated. + +The accompanied PHP interface for such hooks +:php:`TYPO3\CMS\Filelist\FileListEditIconHookInterface` has been marked +as deprecated as well. + +Impact +====== + +If a hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. +The extension scanner also detects any usage +of the deprecated interface as strong, and the definition of the +hook as weak match. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Migrate to the newly introduced :php:`\TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent` PSR-14 event. + +.. index:: PHP-API, FullyScanned, ext:filelist diff --git a/Documentation/Changelog/11.4/Deprecation-95080-FileDumpCheckFileAccessHook.rst b/Documentation/Changelog/11.4/Deprecation-95080-FileDumpCheckFileAccessHook.rst new file mode 100644 index 0000000..5db916a --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95080-FileDumpCheckFileAccessHook.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95077: + +=================================================== +Deprecation: #95077 - FileDump CheckFileAccess hook +=================================================== + +See :issue:`95077` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['FileDumpEID.php']['checkFileAccess']` +which is executed in the :php:`\TYPO3\CMS\Core\Controller\FileDumpController` class, enabling third-party +code to perform additional access / security checks before dumping the requested +file, has been marked as deprecated. + +The accompanied PHP interface for the hook +:php:`TYPO3\CMS\Core\Resource\Hook\FileDumpEIDHookInterface` has been marked +as deprecated as well. + +Impact +====== + +If a hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. +The extension scanner also detects any usage +of the deprecated interface as strong, and the definition of the +hook as weak match. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Migrate to the newly introduced :php:`\TYPO3\CMS\Core\Resource\Event\ModifyFileDumpEvent` PSR-14 event. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-95083-BackendToolbarCacheActionsHook.rst b/Documentation/Changelog/11.4/Deprecation-95083-BackendToolbarCacheActionsHook.rst new file mode 100644 index 0000000..93218da --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95083-BackendToolbarCacheActionsHook.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95083: + +======================================================= +Deprecation: #95083 - Backend toolbar CacheActions hook +======================================================= + +See :issue:`95083` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['additionalBackendItems']['cacheActions']` +which can be used to modify the cache actions, shown in the TYPO3 Backend +top toolbar, has been marked as deprecated. + +The accompanied PHP interface for the hook +:php:`TYPO3\CMS\Backend\Toolbar\ClearCacheActionsHookInterface` has been +marked as deprecated as well. + +Impact +====== + +If the hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. +The extension scanner also detects any usage +of the deprecated interface as strong, and the definition of the +hook as weak match. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Migrate to the newly introduced :php:`\TYPO3\CMS\Backend\Backend\Event\ModifyClearCacheActionsEvent` PSR-14 event. + +.. index:: PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.4/Deprecation-95089-ExtendedFileUtilityProcessDataHook.rst b/Documentation/Changelog/11.4/Deprecation-95089-ExtendedFileUtilityProcessDataHook.rst new file mode 100644 index 0000000..cc01f81 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95089-ExtendedFileUtilityProcessDataHook.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95089: + +========================================================== +Deprecation: #95089 - ExtendedFileUtility ProcessData hook +========================================================== + +See :issue:`95089` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_extfilefunc.php']['processData']` +which can be used to execute additional tasks, after a file operation has +been performed, has been marked as deprecated. + +The accompanied PHP interface for the hook +:php:`TYPO3\CMS\Core\Utility\File\ExtendedFileUtilityProcessDataHookInterface` +has been marked as deprecated as well. + +Impact +====== + +If the hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. +The extension scanner also detects any usage +of the deprecated interface as strong, and the definition of the +hook as weak match. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Migrate to the newly introduced :php:`AfterFileCommandProcessedEvent` PSR-14 event. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Deprecation-95105-DatabaseRecordListHooks.rst b/Documentation/Changelog/11.4/Deprecation-95105-DatabaseRecordListHooks.rst new file mode 100644 index 0000000..18771f6 --- /dev/null +++ b/Documentation/Changelog/11.4/Deprecation-95105-DatabaseRecordListHooks.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95105: + +============================================== +Deprecation: #95105 - DatabaseRecordList hooks +============================================== + +See :issue:`95105` + +Description +=========== + +The TYPO3 hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/class.db_list_extra.inc']['actions']` +which is used in the :php:`DatabaseRecordList` class for modifying the +behavior of each table listing, has been marked as deprecated. + +Using this hook always required to implement the :php:`\TYPO3\CMS\Recordlist\RecordList\RecordListHookInterface`, +which then required the corresponding hook class to implement four different +hook methods, even if only one of them was needed. + +Furthermore are those methods no longer sufficient since e.g. the "controls" +and "clip" sections were merged together already. Therefore, also the +accompanied PHP interface :php:`TYPO3\CMS\Recordlist\RecordList\RecordListHookInterface` +has been marked as deprecated. + +Impact +====== + +If the hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` error is triggered. The extension scanner also detects any usage +of the deprecated interface as strong, and the definition of the +hook as weak match. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Migrate to the corresponding RecordList PSR-14 events: + +- `\TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent` +- `\TYPO3\CMS\Recordlist\Event\ModifyRecordListHeaderColumnsEvent` +- `\TYPO3\CMS\Recordlist\Event\ModifyRecordListRecordActionsEvent` + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.4/Feature-71775-HtmlParserAllowsSrcset.rst b/Documentation/Changelog/11.4/Feature-71775-HtmlParserAllowsSrcset.rst new file mode 100644 index 0000000..7672364 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-71775-HtmlParserAllowsSrcset.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-71775: + +========================================== +Feature: #71775 - HtmlParser allows srcset +========================================== + +See :issue:`71775` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Html\HtmlParser` - most commonly used when rendering RTE fields +in the frontend - now handles the :html:`srcset` attribute. + +A casual use case for this are responsive images: + +.. code-block:: html + + <picture> + <source media="(max-width: 799px)" srcset="small-image.jpg"> + <source media="(min-width: 800px)" srcset="larger-image.jpg"> + </picture> + + +Impact +====== + +Using :html:`source` tag with :html:`srcset` attribute is allowed and +:html:`srcset` values are prefixed correctly. + +.. index:: Frontend, RTE, ext:core diff --git a/Documentation/Changelog/11.4/Feature-84115-DoctrineDBAL-NotInSetForExpressions.rst b/Documentation/Changelog/11.4/Feature-84115-DoctrineDBAL-NotInSetForExpressions.rst new file mode 100644 index 0000000..ff63e27 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-84115-DoctrineDBAL-NotInSetForExpressions.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-84115: + +============================================================ +Feature: #84115 - Doctrine DBAL - notInSet() for expressions +============================================================ + +See :issue:`84115` + +Description +=========== + +TYPO3's Database Abstraction Layer supports a wide range of +cross-RDBMS-functionality to limit SELECT statements via +the ExpressionBuilder. + +When using :php:`\TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder` +for comma-separated lists, the call :php:`inSet()` can be used to detect database rows +which include a value in a comma-separated list, such as +:sql:`pages.fe_group` where the UIDs of allowed frontend user groups +are stored. + +The method :php:`notInSet()` has been added to TYPO3's DBAL ExpressionBuilder, +which works as the opposite functionality: +"Get all rows where a certain value is NOT in the list of comma-separated values". + + +Impact +====== + +It is now possible to use :php:`notInSet()` via Doctrine DBAL +Expression Builder for SQLite, MySQL/MariaDB, PostgreSQL and MSSQL Backends. + +Example: + +.. code-block:: php + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('fe_users'); + $result = $queryBuilder + ->select('*') + ->from('fe_users') + ->where( + $queryBuilder->expr()->notInSet('usergroup', '5') + ) + ->execute(); + + +This queries all frontend users which do not directly belong +to usergroup of with uid "5". + +Please note that this functionality is for extension authors +and their usage should be thought-through properly, as queries such as "Show me all results where the usergroup has NO access to" isn't a use-case for `notInSet()`. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/11.4/Feature-84184-ShowColumnsSelectionInFilelist.rst b/Documentation/Changelog/11.4/Feature-84184-ShowColumnsSelectionInFilelist.rst new file mode 100644 index 0000000..7d1e0c2 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-84184-ShowColumnsSelectionInFilelist.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-84184: + +==================================================== +Feature: #84184 - Show columns selection in filelist +==================================================== + +See :issue:`84184` + +Description +=========== + +The column selector, introduced in :issue:`94218` and improved +in :issue:`94474`, is now also available in the filelist module. + +As already known from the recordlist, it can be used to manage the fields, +displayed for each file / folder, while containing convenience actions, +such as "filter", "check all / none" and "toggle selection". + +The fields to be selected are a combination of special fields, such as +`references` or `read/write` permissions, the corresponding `sys_file` +record fields, as well as all available `sys_file_metadata` fields. + +Administrators can manage whether the column selection is available +for their users with a new User TSconfig option: + +.. code-block:: typoscript + + # disable the column selector + options.file_list.displayColumnSelector = 0 + + +Impact +====== + +It's now possible to manage the displayed fields for files / folders +in the filelist module, using the columns selection component. + +.. index:: Backend diff --git a/Documentation/Changelog/11.4/Feature-84718-AddCLIExportCommandToImpExpExtension.rst b/Documentation/Changelog/11.4/Feature-84718-AddCLIExportCommandToImpExpExtension.rst new file mode 100644 index 0000000..da3e131 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-84718-AddCLIExportCommandToImpExpExtension.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _feature-84718: + +====================================================== +Feature: #84718 - Add CLI export command to EXT:impexp +====================================================== + +See :issue:`84718` + +Description +=========== + +The new CLI command + +- :bash:`impexp:export` + +was added as the missing twin of the existing CLI command :bash:`impexp:import`. + +The export command can be executed via + +.. code-block:: bash + + typo3/sysext/core/bin/typo3 impexp:export [options] [--] [<filename>] + +and exports the entire TYPO3 page tree - or parts of it - to a data file of +format XML or T3D, which can be used for import into any TYPO3 instance or +as initial page tree of a :ref:`distribution <t3coreapi:distribution>`. + +The export can be fine-tuned through the complete set of options already +available in the export view of the TYPO3 backend: + +.. code-block:: bash + + Arguments: + filename The filename to export to (without file extension) + + Options: + --type[=TYPE] The file type (xml, t3d, t3d_compressed). [default: "xml"] + --pid[=PID] The root page of the exported page tree. [default: -1] + --levels[=LEVELS] The depth of the exported page tree. "-2": "Records on this page", "-1": "Expanded tree", "0": "This page", "1": "1 level down", .. "999": "Infinite levels". [default: 0] + --table[=TABLE] Include all records of this table. Examples: "_ALL", "tt_content", "sys_file_reference", etc. (multiple values allowed) + --record[=RECORD] Include this specific record. Pattern is "{table}:{record}". Examples: "tt_content:12", etc. (multiple values allowed) + --list[=LIST] Include the records of this table and this page. Pattern is "{table}:{pid}". Examples: "sys_language:0", etc. (multiple values allowed) + --includeRelated[=INCLUDERELATED] Include record relations to this table, including the related record. Examples: "_ALL", "sys_category", etc. (multiple values allowed) + --includeStatic[=INCLUDESTATIC] Include record relations to this table, excluding the related record. Examples: "_ALL", "sys_language", etc. (multiple values allowed) + --exclude[=EXCLUDE] Exclude this specific record. Pattern is "{table}:{record}". Examples: "fe_users:3", etc. (multiple values allowed) + --excludeDisabledRecords Exclude records which are handled as disabled by their TCA configuration, e.g. by fields "disabled", "starttime" or "endtime". + --excludeHtmlCss Exclude referenced HTML and CSS files. + --title[=TITLE] The meta title of the export. + --description[=DESCRIPTION] The meta description of the export. + --notes[=NOTES] The meta notes of the export. + --dependency[=DEPENDENCY] This TYPO3 extension is required for the exported records. Examples: "news", "powermail", etc. (multiple values allowed) + --saveFilesOutsideExportFile Save files into separate folder instead of including them into the common export file. Folder name pattern is "{filename}.files". + +Impact +====== + +Exporting a TYPO3 page tree without time limit is now possible via CLI. + +Repeated exports with the same configuration become easily documentable and +applicable - for example during distribution development. + +.. index:: CLI, ext:impexp diff --git a/Documentation/Changelog/11.4/Feature-90197-IntroduceCacheFlushConsoleCommand.rst b/Documentation/Changelog/11.4/Feature-90197-IntroduceCacheFlushConsoleCommand.rst new file mode 100644 index 0000000..d6a8643 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-90197-IntroduceCacheFlushConsoleCommand.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-90197: + +======================================================= +Feature: #90197 - Introduce cache:flush console command +======================================================= + +See :issue:`90197` + +Description +=========== + +It is now possible to flush TYPO3 caches using the command line. + +The administrator can use the following CLI command: + +.. code-block:: bash + + ./typo3/sysext/core/bin/typo3 cache:flush + +Specific cache groups can be defined via the group option. +The usage is described as: + +.. code-block:: bash + + cache:flush [--group <all|system|di|pages|…>] + +All available cache groups can be supplied as option. The command defaults to +flush all available cache groups as the install tool does. + +Extensions that register custom caches may listen to the +via :php:`TYPO3\CMS\Core\Cache\Event\CacheFlushEvent`, but usually the +cache flush via CacheManager groups will suffice. + +Impact +====== + +It is often required to clear caches during deployment of TYPO3 instance +updates, in order for content changes to become active. + +TYPO3 caches can now be flushed in release postparatory steps. The integrator +may decide to flush all caches (common practice with `EXT:typo3_console`) or +may alternatively flush selected groups (e.g. 'pages') in case the `cache:warmup` +(see :issue:`93436`) command is used as companion in release preparatory steps. + +Deployment steps could then be: + +* Release preparation: + + * git-checkout/rsync your codebase (on CI or on live system) + * `composer install` (on CI or on live system) + * `vendor/bin/typo3 cache:warmup --group system` (*only* on the live system) + +* Change release symlink to the new release folder +* Release postparation + + * `vendor/bin/typo3 cache:flush --group pages` + +.. index:: CLI, ext:core diff --git a/Documentation/Changelog/11.4/Feature-90336-CKEditorAutolinkingUsesHttpsByDefault.rst b/Documentation/Changelog/11.4/Feature-90336-CKEditorAutolinkingUsesHttpsByDefault.rst new file mode 100644 index 0000000..00d5807 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-90336-CKEditorAutolinkingUsesHttpsByDefault.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-90336: + +============================================================ +Feature: #90336 - CKEditor Autolinking uses https by default +============================================================ + +See :issue:`90336` + +Description +=========== + +TYPO3 ships with a CKEditor plugin called "autolinking", which +automatically converts typed text within a RTE to an external URL. + +When typing `www.typo3.org` this is automatically converted to +an absolute external link, which previously used `http://` as +schema. + +Nowadays, over 90% of the web is served via the https protocol +and secure connections via SSL/TLS, where it is safe to +use secure-by-default links. + +When not specifically using a schema as prefix for an autolinking +URL, CKEditor now uses `https` instead of `http` as schema by default. + + +Impact +====== + +When typing a URL like www.typo3.org in the RTE and the autolinking +plugin is activated, the default schema used is now `https` instead +of `http` for any new links. + +However, it is - as before - fully possible to manually change a +link to use the `http://` schema instead. + +.. index:: RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/11.4/Feature-90347-EnableRecursiveTransformationOfPropertiesInJsonView.rst b/Documentation/Changelog/11.4/Feature-90347-EnableRecursiveTransformationOfPropertiesInJsonView.rst new file mode 100644 index 0000000..f148c27 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-90347-EnableRecursiveTransformationOfPropertiesInJsonView.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-90347: + +=========================================================================== +Feature: #90347 - Enable recursive transformation of properties in JsonView +=========================================================================== + +See :issue:`90347` + +Description +=========== + +The Extbase :php:`\TYPO3\CMS\Extbase\Mvc\View\JsonView` is now able to resolve +recursive properties of objects, e.g. directories containing directories or +comments containing comments as replies. + +Examples: + +1. This is for 1:1 relations, where a comment has at most 1 comment. + +.. code-block:: php + + $configuration = [ + 'comment' => [ + '_recursive' => ['comment'] + ] + ]; + + +2. This is for the more common 1:n relation in which you have lists of sub objects. + +.. code-block:: php + + $configuration = [ + 'directories' => [ + '_descendAll' => [ + '_recursive' => ['directories'] + ], + ] + ]; + +You can put all the other configuration like :php:`_only` or :php:`_exclude` at the same +level as :php:`_recursive` and the view will apply this for all levels. + +Impact +====== + +Developers can now use the :php:`_recursive` property in the :php:`JsonView` +configuration in order to resolve recursive properties instead of defining each +level manually. + +.. index:: ext:extbase diff --git a/Documentation/Changelog/11.4/Feature-90548-DownloadMultipleFilesAndFoldersInFilelist.rst b/Documentation/Changelog/11.4/Feature-90548-DownloadMultipleFilesAndFoldersInFilelist.rst new file mode 100644 index 0000000..a570b26 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-90548-DownloadMultipleFilesAndFoldersInFilelist.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-90548: + +================================================================= +Feature: #90548 - Download multiple files and folders in filelist +================================================================= + +See :issue:`90548` + +Description +=========== + +From time to time, editors might need to download files and folders, +which are stored in the TYPO3 installation. Therefore, the filelist +module has been improved to provide a couple of possibilities for +downloading the stored files and folders. + +The action bar on the top of the listing now features the "Download" +option. It is shown, as soon as a file or folder is selected. It can +therefore be used to download a specific selection of files and folders. + +The "Download" option has furthermore been added to the context menu as +well as the secondary menu. Those options can be used to download +a single file or folder. + +Administrators can furthermore specify, which file extensions are allowed +for their users to be downloaded. Therefore, following user TSconfig is +available, expecting a comma-separated list of file extensions: + +.. code-block:: typoscript + + # Either an allow list + options.file_list.fileDownload.allowedFileExtensions = png,svg,pdf + + # or a deny list + options.file_list.fileDownload.disallowedFileExtensions = yaml,exe,html + +It's also possible to completely disable the file download for users: + +.. code-block:: typoscript + + options.file_list.fileDownload.enabled = 0 + +.. note:: + + When downloading folders, all readable subfolders and their files + are included in the generated ZIP file as well. + +Impact +====== + +It's now possible to download files and folders in the filelist module. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/11.4/Feature-91021-FilterByStageInWorkspacesModule.rst b/Documentation/Changelog/11.4/Feature-91021-FilterByStageInWorkspacesModule.rst new file mode 100644 index 0000000..6a590f8 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-91021-FilterByStageInWorkspacesModule.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-91021: + +====================================================== +Feature: #91021 - Filter by stage in Workspaces Module +====================================================== + +See :issue:`91021` + +Description +=========== + +When reviewing staged changes in a larger workspace environment, +editors who need to review changes or send them to the next +stage now have an additional filter dropdown in the Workspaces +module to only show records that are in a specific stage. + + +Impact +====== + +A new dropdown to narrow down lot of records has been added +to the Workspaces administration module, which is automatically +populated with available stages for the specific workspace. + +.. index:: Backend, ext:workspaces diff --git a/Documentation/Changelog/11.4/Feature-92460-SplitDefaultFromAllLanguagesInPageModule.rst b/Documentation/Changelog/11.4/Feature-92460-SplitDefaultFromAllLanguagesInPageModule.rst new file mode 100644 index 0000000..54db66d --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-92460-SplitDefaultFromAllLanguagesInPageModule.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-92460: + +================================================================= +Feature: #92460 - Split default from all languages in page module +================================================================= + +See :issue:`92460` + +Description +=========== + +The view "Languages" in the page module allows to display content elements of +the default language next to the ones of the selected language. + +If the default language is chosen, instead of rendering all content elements +of all languages, now only the content elements of the default language are +rendered. + +If an editor requires to see all content elements of all languages, the +option "All languages" can be selected. + + +Impact +====== + +Having many languages and many content elements can be a performance issue +in the page module which is now fixed. + +Additionally the language view is now consistent with the column view. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-93197-ResolveCollectionTypesOfNon-persistentObjects.rst b/Documentation/Changelog/11.4/Feature-93197-ResolveCollectionTypesOfNon-persistentObjects.rst new file mode 100644 index 0000000..b07ca77 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-93197-ResolveCollectionTypesOfNon-persistentObjects.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-93197: + +==================================================================== +Feature: #93197 - Resolve collection types of non-persistent objects +==================================================================== + +See :issue:`93197` + +Description +=========== + +Collection types are used to define a specific class that should be within +Extbase's :php:`\TYPO3\CMS\Extbase\Persistence\ObjectStorage` class. + +Example: + +.. code-block:: php + + /** + * @param ObjectStorage<Item> $items + */ + public function setItems(ObjectStorage $items): void + { + $this->items = $items; + } + +These docblocks are analyzed so the :php:`PropertyMapper` knows how to map +incoming requests. This mapping already works for persistent objects +(domain models). As non-persistent objects are also used for property mapping, +namely DTOs (data transfer objects), this now works for them, too. + +Impact +====== + +Developers can use collection types in docblock annotations for non-persistent +objects. The collection type is considered while property mapping requests. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/11.4/Feature-93436-IntroduceCacheWarmupConsoleCommand.rst b/Documentation/Changelog/11.4/Feature-93436-IntroduceCacheWarmupConsoleCommand.rst new file mode 100644 index 0000000..e9f1400 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-93436-IntroduceCacheWarmupConsoleCommand.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _feature-93436: + +======================================================== +Feature: #93436 - Introduce cache:warmup console command +======================================================== + +See :issue:`93436` + +Description +=========== + +It is now possible to warmup TYPO3 caches using the command line. + +The administrator can use the following CLI command: + +.. code-block:: bash + + ./typo3/sysext/core/bin/typo3 cache:warmup + +Specific cache groups can be defined via the group option. +The usage is described as: + +.. code-block:: bash + + cache:warmup [--group <all|system|di|pages|…>] + +All available cache groups can be supplied as option. The command defaults to +warm all available cache groups. + +Extensions that register custom caches are encouraged to implement cache warmers +via :php:`TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent`. + +Note: TYPO3 frontend caches will not be warmed by TYPO3 core, such functionality +could be added by third party extensions with the help of +:php:`TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent`. + +Impact +====== + +It is common practice to clear all caches during deployment of TYPO3 instance +updates. This means that the first request after a deployment usually takes +a major amount of time and blocks other requests due to cache-locks. + +TYPO3 caches can now be warmed during deployment in release preparatory steps in +symlink based deployment/release procedures. This enables fast first requests +with all (or at least system) caches being prepared and warmed. + +Caches are often filesystem relevant (filepaths are calculated into cache +hashes), therefore cache warmup should only be performed on the live system, +in the *final* folder of a new release, and ideally before switching +to that new release (via symlink switch). Note that caches that have be +pre-created in CI will likely be useless as cache hashes will not match. + +To summarize: Cache warmup is to be used during deployment, on the live system +server, inside the new release folder and before switching the new release live. + +Deployment steps are: + +* Release preparation: + + * git-checkout/rsync your codebase (on CI or on live system) + * `composer install` (on CI or on live system) + * `vendor/bin/typo3 cache:warmup --group system` (*only* on the live system) + +* Change release symlink to the new release folder +* Release postparation + + * Clear only the page related caches (e.g. via database truncate or an + upcoming `cache:flush` command) + +The conceptional idea is to warmup all file-related caches *before* (symlink) +switching to a new release and to *only* flush database and frontend (shared) +caches after the symlink switch. Database warmup could be implemented with +the help of the :php:`TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent` as an +additionally functionality by third party extensions. + +Note that file-related caches (summarized into the group "system") can safely be +cleared before doing a release switch, as it is recommended to keep file caches +per release. In other words, share :file:`var/session`, :file:`var/log`, +:file:`var/lock` and :file:`var/charset` between releases, but keep +:file:`var/cache` be associated only with one release. + +.. index:: CLI, ext:core diff --git a/Documentation/Changelog/11.4/Feature-94402-GenerateErrorPagesViaTYPO3-internalSubRequest.rst b/Documentation/Changelog/11.4/Feature-94402-GenerateErrorPagesViaTYPO3-internalSubRequest.rst new file mode 100644 index 0000000..7828562 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94402-GenerateErrorPagesViaTYPO3-internalSubRequest.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-94402: + +===================================================================== +Feature: #94402 - Generate error pages via TYPO3-internal sub-request +===================================================================== + +See :issue:`94402` + +Description +=========== + +Error pages (such as 404 - not found, or 403 - access denied) may now be generated +via a TYPO3-internal sub-request instead of an external HTTP +request (cURL over Guzzle). + +This feature is disabled by default, as there are some cases where stateful information +is not correctly reset for the subrequest. It may be enabled on an experimental +basis via a feature flag called `subrequestPageErrors` in the "Settings" +module. + +This change will default to enabled in a future version once all stateful services +are identified and removed. + +Impact +====== + +Generating error pages internally reduces the server load drastically, and +solves various issues when dealing with load-balanced systems where the hostname +of the server might not match the public-facing server ("front server"). + +However, in some cases there might be problems with third-party extensions +that override super globals (e.g. :php:`$_GET` and :php:`$_POST`), where the option could be +disabled. There are also some remaining cases in core of stateful services that, +in some configurations, result in incorrect error pages being generated. For that +reason the feature defaults to disabled for now. + +This feature is only relevant for site configurations loading error pages +from a different Page ID. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/11.4/Feature-94406-OverrideFileFolderTCAConfigurationWithTSconfig.rst b/Documentation/Changelog/11.4/Feature-94406-OverrideFileFolderTCAConfigurationWithTSconfig.rst new file mode 100644 index 0000000..72dbbad --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94406-OverrideFileFolderTCAConfigurationWithTSconfig.rst @@ -0,0 +1,110 @@ +.. include:: /Includes.rst.txt + +.. _feature-94406: + +===================================================================== +Feature: #94406 - Override fileFolder TCA configuration with TSconfig +===================================================================== + +See :issue:`94406` + +Description +=========== + +The special `fileFolder configuration options <https://docs.typo3.org/m/typo3/reference-tca/main/en-us/ColumnsConfig/Type/Select/Properties/FileFolder.html#filefolder>`__ +for TCA columns of type :php:`select` can be used to fill a select field with files +(images / icons) from a defined folder. This is really handy, e.g. for selecting +predefined icons from a corporate icon set. However, in installations with +multiple sites, such icon sets usually differ from site to site. + +Therefore, the :php:`fileFolder` configuration can now be overridden with page +TSconfig, allowing administrators to easily handle those situations by e.g. +using different folders or allowing different file extensions, per site. + +To streamline both, the TCA configuration and the corresponding overrides, +the :php:`fileFolder` configuration options have been moved into a dedicated sub +array :php:`fileFolderConfig`, some options have been renamed: + +* :php:`fileFolder` option :php:`folder` +* :php:`fileFolder_extList` to :php:`allowedExtensions` +* :php:`fileFolder_recursions` to :php:`depth` + +A TCA migration wizard is available, showing where adjustments have to take place. + +Before: + +.. code-block:: php + + 'aField' => [ + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'fileFolder' => 'EXT:my_ext/Resources/Public/Icons', + 'fileFolder_extList' => 'svg', + 'fileFolder_recursions' => 1, + ] + ] + +After: + +.. code-block:: php + + 'aField' => [ + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'fileFolderConfig' => [ + 'folder' => 'EXT:styleguide/Resources/Public/Icons', + 'allowedExtensions' => 'svg', + 'depth' => 1, + ] + ] + ] + + +Thus, the following TSconfig options can be used to overriding their +TCA counterpart: + +.. code-block:: typoscript + + config.fileFolderConfig.folder + config.fileFolderConfig.allowedExtensions + config.fileFolderConfig.depth + +As already known from TCEFORM, those options can be used on various levels + +On table level: + +.. code-block:: typoscript + + TCEFORM.myTable.myField.config.fileFolderConfig.folder + +On table and record type level: + +.. code-block:: typoscript + + TCEFORM.myTable.myFiled.types.myType.config.fileFolderConfig.folder + +On flex form field level: + +.. code-block:: typoscript + + TCEFORM.myTable.pi_flexform.my_ext_pi1.sDEF.myField.config.fileFolderConfig.folder + +.. note:: + + Except :typoscript:`config.fileFolderConfig.folder`, the new options can not + only be used to override an existing property, but also to define + one, which has not yet been configured in TCA. + +Impact +====== + +It's now possible to override the TCA :php:`fileFolder` configuration options +with page TSconfig, allowing administrators to manipulate the available +items on a page basis. + +The :php:`fileFolder` TCA configuration is furthermore streamlined and now +encapsulated in a dedicated sub array :php:`fileFolderConfig`. + +.. index:: Backend, TCA, TSConfig, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94489-FilterForRedirectsNeverHit.rst b/Documentation/Changelog/11.4/Feature-94489-FilterForRedirectsNeverHit.rst new file mode 100644 index 0000000..219d82d --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94489-FilterForRedirectsNeverHit.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-94489: + +================================================ +Feature: #94489 - Filter for redirects never hit +================================================ + +See :issue:`94489` + +Description +=========== + +Since :issue:`89115` TYPO3 is able to automatically create redirects +whenever an editor changes a slug of a page. This is a really handy feature. +However on large sites this could quickly lead to a lot of redirects, which +may will never be used by any website visitor. + +To support editors by managing their redirects, a new filter option +:guilabel:`Never hit` has been added to the Redirects modules' filter. +Activating this option therefore filters the list for redirects, which +where never hit before. + +.. note:: + + The filter option will only be available, if the "Redirects hit count" + feature is enabled, see: + :doc:`#83677 <../9.1/Feature-83677-GloballyDisableenableRedirectHitStatistics>`. + +Impact +====== + +A new filter option :guilabel:`Never hit` is available in the Redirects +module, allowing editors to filter for redirects, which were never hit before. + +.. index:: Backend, ext:redirects diff --git a/Documentation/Changelog/11.4/Feature-94577-ClearIndexed_searchDocumentsWhenContentIsChanged.rst b/Documentation/Changelog/11.4/Feature-94577-ClearIndexed_searchDocumentsWhenContentIsChanged.rst new file mode 100644 index 0000000..41eeaf9 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94577-ClearIndexed_searchDocumentsWhenContentIsChanged.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-94577: + +======================================================================== +Feature: #94577 - Clear indexed_search documents when content is changed +======================================================================== + +See :issue:`94577` + +Description +=========== + +A new Extension Configuration setting `deleteFromIndexAfterEditing` has been added +to the extension `indexed_search`. + +If enabled and a page or its content is edited, :php:`DataHandler` triggers a hook +to remove the page and its content from the search index. + + +Impact +====== + +A separate index and clearing it is always a tradeoff between having wrong content +and no content in the search result. The index is filled by website visitors or bots +calling the page in the frontend or by using the 3rd party extension crawler_. + +If the setting is enabled and the page is not yet re-indexed, **no** content will +be shown in the search result, no matter if the editor just fixed one tiny typo in a content element. + +If the feature flag is disabled, the editor needs to manually clear the index. + +.. _crawler: https://extensions.typo3.org/extension/crawler + +.. index:: Backend, ext:indexed_search diff --git a/Documentation/Changelog/11.4/Feature-94590-AllowIconIdentifiersInReportModuleRegistration.rst b/Documentation/Changelog/11.4/Feature-94590-AllowIconIdentifiersInReportModuleRegistration.rst new file mode 100644 index 0000000..dfd65ab --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94590-AllowIconIdentifiersInReportModuleRegistration.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-94590: + +====================================================================== +Feature: #94590 - Allow icon identifiers in report module registration +====================================================================== + +See :issue:`94590` + +Description +=========== + +To further streamline the usage of the Icon Registry, the reports registration +array now allows to define icon identifiers for the :php:`icon` key. Absolute +paths and paths with `EXT:` prefix are still possible. + +Example: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['tx_reports']['status'] = [ + 'title' => 'LLL:EXT:reports/Resources/Private/Language/locallang_reports.xlf:status_report_title', + 'icon' => 'module-reports', // Icon identifiers are now possible here. + 'description' => 'LLL:EXT:reports/Resources/Private/Language/locallang_reports.xlf:status_report_description', + 'report' => \TYPO3\CMS\Reports\Report\Status\Status::class + ]; + +Impact +====== + +Developers are now able to provide icon identifiers in the reports module +registration array. + +.. index:: Backend, PHP-API, ext:reports diff --git a/Documentation/Changelog/11.4/Feature-94622-NewTCATypeCategory.rst b/Documentation/Changelog/11.4/Feature-94622-NewTCATypeCategory.rst new file mode 100644 index 0000000..db140d1 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94622-NewTCATypeCategory.rst @@ -0,0 +1,128 @@ +.. include:: /Includes.rst.txt + +.. _feature-94622: + +========================================= +Feature: #94622 - New TCA type "category" +========================================= + +See :issue:`94622` + +Description +=========== + +A new TCA field type called :php:`category` has been added to TYPO3 Core. +Its main purpose is to simplify the TCA configuration when adding a category +tree to a record. It therefore supersedes the :php:`\TYPO3\CMS\Core\Category\CategoryRegistry` as well +as the :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility->makeCategorizable()`, which required +creating a "TCA overrides" file. + +Both, the :php:`CategoryRegistry` as well as +:php:`ExtensionManagementUtility->makeCategorizable()` are going to be +deprecated in the future. + +While using the new type, TYPO3 takes care of generating the necessary TCA +configuration and also adds the database column automatically. Developers +only have to configure the TCA column and add it to the desired record types. + +.. code-block:: php + + $GLOBALS['TCA'][$myTable]['columns']['categories'] = [ + 'config' => [ + 'type' => 'category' + ] + ]; + + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes($myTable, 'categories'); + +The above example does not contain the new option :php:`relationship` +since the default is :php:`manyToMany`. All possible values are: + +* :php:`oneToOne`: Stores the uid of the selected category. When using this + relationship, :php:`maxitems=1` will automatically be added to the column configuration +* :php:`oneToMany`: Stores the uids of selected categories in a comma-separated list +* :php:`manyToMany` (default): Uses the intermediate table :sql:`sys_category_record_mm` + and only stores the categories count on the local side. This is the use case, + which was previously accomplished using :php:`ExtensionManagementUtility->makeCategorizable()`. + +This means, the new type can not only be used with :php:`relationship=manyToMany` as +a replacement for :php:`makeCategorizable` but can be used for other use +cases too. In case a category tree is required, only allowing one category +to be selected, the necessary configuration reduces to + +.. code-block:: php + + $GLOBALS['TCA'][$myTable]['columns']['mainCategory'] = [ + 'config' => [ + 'type' => 'category', + 'relationship' => 'oneToOne' + ] + ]; + +All other relevant options, e.g. :php:`maxitems=1`, are being set automatically. + +Besides :php:`type` and :php:`relationship`, following type specific options +are available: + +* :php:`default` +* :php:`exclusiveKeys`: As known from :php:`renderType=selectTree` +* :php:`treeConfig`: As known from :php:`renderType=selectTree` + +It's possible to use TSconfig options, such as +:typoscript:`removeItems`. However, adding static items with TSconfig is not +implemented for this type. For such special cases, please continue using TCA +type :php:`select`. + +The Override matrix - specifying the options which can be overridden in +TSconfig - is extended for the new type. Following options can be overridden: + +* :php:`size` +* :php:`maxitems` +* :php:`minitems` +* :php:`readOnly` +* :php:`treeConfig` + +.. note:: + + It's still possible to configure a category tree with :php:`type=select` + and :php:`renderType=selectTree`. This configuration will still work, but + could in most cases be simplified, using the new :php:`category` TCA type. + +Flexform usage +-------------- + +It's also possible to use the new type in flexform data structures. However, +due to some limitations in flexform, the "manyToMany" relationship is not +supported. Therefore, the default relationship - used if none is defined - +is "oneToMany". This is anyways the most common use case for flexforms, +as it's not important to look from the other side "which flexform elements +reference this category". An example of the "oneToMany" use case is EXT:news, +which allows to only display news of specific categories in the list view. + +.. code-block:: xml + + <T3DataStructure> + <ROOT> + <TCEforms> + <sheetTitle>aTitle</sheetTitle> + </TCEforms> + <type>array</type> + <el> + <categories> + <TCEforms> + <config> + <type>category</type> + </config> + </TCEforms> + </categories> + </el> + </ROOT> + </T3DataStructure> + +Impact +====== + +It's now possible to simplify the TCA configuration for category fields, +using the new TCA type :php:`category`. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94623-Tt_contentImagesAssetsMediaShowPossibleLocalizationRecords.rst b/Documentation/Changelog/11.4/Feature-94623-Tt_contentImagesAssetsMediaShowPossibleLocalizationRecords.rst new file mode 100644 index 0000000..3c55ab6 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94623-Tt_contentImagesAssetsMediaShowPossibleLocalizationRecords.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-94623: + +================================================================================== +Feature: #94623 - tt_content images, assets, media showPossibleLocalizationRecords +================================================================================== + +See :issue:`94623` + +Description +=========== + +When a default language content element is localized to another language +in "connected" / "translation" mode (as opposed to "copy"), relations like +images and assets connected to the default language record are localized as well. + +When the default language element is later changed and additional images, assets +or media relations are added, the localized content element now shows those new +default language relations as shadowed box and allows to localize them with one click. + + +Impact +====== + +This is a usability improvement for editors, who now see which tt_content +relations of casual elements like "Image" and "Media" are missing when +editing localizations. They can localize those with one click. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94653-AutocompleteAttributeForPasswordViewHelper.rst b/Documentation/Changelog/11.4/Feature-94653-AutocompleteAttributeForPasswordViewHelper.rst new file mode 100644 index 0000000..b506ea2 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94653-AutocompleteAttributeForPasswordViewHelper.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-94653: + +=============================================================== +Feature: #94653 - Autocomplete attribute for PasswordViewHelper +=============================================================== + +See :issue:`94653` + +Description +=========== + +Since password managers are frequently used by end users nowadays, +a password field can define the :html:`autocomplete` attribute, +which informs the users' password manager how to fill the corresponding +field. For example, creating a new password or filling in the current password. + +See `MDN Allowing autocomplete`_ for a full list of possible attribute values. + +To ease the use for integrators and developers, the attribute can now +directly be added as tag attribute to the :php:`PasswordViewHelper`. + +Example: + +.. code-block:: html + + <f:form.password name="newPassword" value="" autocomplete="new-password" /> + + <!-- Output --> + + <input type="password" name="myNewPassword" value="" autocomplete="new-password" /> + + +Impact +====== + +It's now possible to specify the :html:`autocomplete` attribute for the password +field through the :php:`PasswordViewHelper`. + +.. _MDN Allowing autocomplete: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password#allowing_autocomplete + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/11.4/Feature-94662-AddPlaceholderForSiteConfigurationInForeignTableWhere.rst b/Documentation/Changelog/11.4/Feature-94662-AddPlaceholderForSiteConfigurationInForeignTableWhere.rst new file mode 100644 index 0000000..715ad91 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94662-AddPlaceholderForSiteConfigurationInForeignTableWhere.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-94662: + +=============================================================================== +Feature: #94662 - Add placeholder for site configuration in foreign_table_where +=============================================================================== + +See :issue:`94662` + +Description +=========== + +The :php:`foreign_table_where` setting in TCA allows some old marker-based +placeholder to customize the query. The best place to define site-dependent +settings is the site configuration, which now can be used within +:php:`foreign_table_where`. + +To access a configuration value the following syntax is available: + +* `###SITE:<KEY>###` - <KEY> is your setting name from site config e.g. `###SITE:rootPageId###` +* `###SITE:<KEY>.<SUBKEY>###` - an array path notation is possible. e.g. `###SITE:mySetting.categoryPid###` + +Example: +-------- + +.. code-block:: php + + ... + 'fieldConfiguration' => [ + 'foreign_table_where' => ' AND ({#sys_category}.{#uid} = ###SITE:rootPageId### OR {#sys_category}.{#pid} = ###SITE:mySetting.categoryPid###) ORDER BY {#sys_category}.{#title} ASC', + ], + ... + +.. index:: Backend, FlexForm, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94680-ShowColumnsSelectorFilter.rst b/Documentation/Changelog/11.4/Feature-94680-ShowColumnsSelectorFilter.rst new file mode 100644 index 0000000..ddb2a5f --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94680-ShowColumnsSelectorFilter.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-94680: + +============================================== +Feature: #94680 - Show columns selector filter +============================================== + +See :issue:`94680` + +Description +=========== + +In :issue:`94474`, the column selector in the record list, formerly known +as "field selector", got improved by adding a couple of actions, such +as "check all" and by moving the selection into a modal instead of a dropdown. +However, since there are tables, e.g. :sql:`pages` or :sql:`tt_content`, which +contain a lot of columns, it could sometimes still be unnecessarily hard +to find a specific column in such a list. + +Therefore, the columns selectors' action bar has been extended for +a new filter, which can be used to quickly find the desired column +in such large lists. + +When the filter is active - at least one character was entered - all other +actions are bound to the current filter result. This means, when using the +"check all" action, while the list is filtered, the action is only applied +to the currently visible items. This comes in handy in case a group of +columns, sharing the same name (e.g. "backend layouts" in :sql:`pages`) should +be selected. + + +Impact +====== + +It's now possible to filter the list of columns in the "Show column selector" +of the recordlist module. + +.. index:: Backend, ext:recordlist diff --git a/Documentation/Changelog/11.4/Feature-94692-RegisteringIconsViaServiceContainer.rst b/Documentation/Changelog/11.4/Feature-94692-RegisteringIconsViaServiceContainer.rst new file mode 100644 index 0000000..5e76ec5 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94692-RegisteringIconsViaServiceContainer.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-94692-1657826754: + +========================================================= +Feature: #94692 - Registering Icons via Service Container +========================================================= + +See :issue:`94692` + +Description +=========== + +Extensions can now register their custom icons via +a configuration file placed in :file:`Configuration/Icons.php` of their +extension directory, e.g. :file:`typo3conf/ext/my_extension/Configuration/Icons.php`. + +Each file needs to return a flat PHP configuration array, with +custom options used for the IconRegistry to register a new icon. + +Example: + +.. code-block:: php + + <?php + return [ + 'myicon' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:my_extension/Resources/Public/Icons/myicon.svg' + ], + 'anothericon' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:my_extension/Resources/Public/Icons/anothericon.svg' + ], + ... + ]; + + +Impact +====== + +Using the new approach improves the loading speed of every request +as the registration can be handled at once and cached +during warmup of the core caches. + +In addition, extension authors' :file:`ext_localconf.php` files are +drastically reduced, as extension authors have a better overview +and a better separation of concerns when registering custom +functionality. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-94741-RegisterSoftReferenceParsersViaDI.rst b/Documentation/Changelog/11.4/Feature-94741-RegisterSoftReferenceParsersViaDI.rst new file mode 100644 index 0000000..954e0c4 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94741-RegisterSoftReferenceParsersViaDI.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-94741: + +======================================================= +Feature: #94741 - Register SoftReference parsers via DI +======================================================= + +See :issue:`94741` + +Description +=========== + +Parsers for :ref:`soft references <t3coreapi:soft-references>` can now be +registered via dependency injection in the corresponding +:file:`Configuration/Services.(yaml|php)` file of your extension. This is done +by tagging your class with the new tag name :yaml:`softreference.parser` and +providing the parser key for the attribute :yaml:`parserKey`. + +Example: + +.. code-block:: yaml + + VENDOR\Extension\SoftReference\YourSoftReferenceParser: + tags: + - name: softreference.parser + parserKey: your_key + +In addition, parsers now have to implement +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. +This interface describes the :php:`parse()` method, which is very similar to the +old method :php:`findRef()`. The difference is that :php:`$parserKey` (former +known as :php:`$spKey`) and :php:`$parameters` (former known as +:php:`$spParams`) can now be optionally set with the :php:`setParserKey()` method. +The key can be retrieved with the :php:`getParserKey()` method. + +The return type has also been changed to +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserResult`. +This model holds the former result array key entries :php:`content` and +:php:`elements` as properties and has appropriate getter methods for them. It +should be created by its own factory method +:php:`SoftReferenceParserResult::create()`, which expects both above-mentioned +arguments to be provided. If the result is empty, +:php:`SoftReferenceParserResult::createWithoutMatches()` should be used instead. + +Impact +====== + +Developers can register their user-defined soft reference parsers in their +:file:`Configuration/Services.(yaml|php)` file. In addition, parser have to +implement the new interface +:php:`TYPO3\CMS\Core\DataHandling\SoftReference\SoftReferenceParserInterface`. + + +Related +======= + +* :doc:`RegisterSoftReferenceParsersViaDI (Deprecation) <Deprecation-94741-RegisterSoftReferenceParsersViaDI>` +* :doc:`SoftReferenceIndex (Deprecation) <Deprecation-94687-SoftReferenceIndex>` + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-94765-IntroduceShowNewRecordLinkOption.rst b/Documentation/Changelog/11.4/Feature-94765-IntroduceShowNewRecordLinkOption.rst new file mode 100644 index 0000000..fa54381 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94765-IntroduceShowNewRecordLinkOption.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-94765: + +==================================================== +Feature: #94765 - Introduce showNewRecordLink option +==================================================== + +See :issue:`94765` + +Description +=========== + +Previously, it was not possible to disable the "new record" link in +TCA :php:`inline` elements, without simultaneously also disabling either the +"+" button in each inline records' header (using +:php:`['appearance']['enabledControls']['new']`) or all other +"level links" (using :php:`['appearance']['levelLinksPosition'] = 'none'`). + +To allow integrators to disable this link without any further side +effects, the option :php:`showNewRecordLink` has been introduced +to TCA type :php:`inline`. + +With this introduction, the already mentioned +:php:`['appearance']['enabledControls']['new']` option does from now on +only manage the display of the "+" button of each inline record and does +not longer affect the "New record" link. + +Furthermore the :php:`['appearance']['levelLinksPosition']` option does +no longer support `none` as value. This option should only be used to +position the level links, not to hide them. This can be +achieved by setting the corresponding link specific options +:php:`showAllLocalizationLink`, :php:`showSynchronizationLink` and +:php:`showNewRecordLink` to :php:`false`. A TCA migration is in place, +replacing all TCA configurations, using the +:php:`['appearance']['levelLinksPosition']` option with `none` as value +and showing where code adaptations need to take place. + +If not set, the new :php:`showNewRecordLink` option defaults to :php:`true`. + +An example to disable the "New record" button: + +.. code-block:: php + + 'inlineField' => [ + 'label' => 'Inline without New record link', + 'config' => [ + 'type' => 'inline', + 'appearance' => [ + 'showNewRecordLink' => false, + ], + ], + ], + +Impact +====== + +It's now possible to disable the "New record" link of TCA :php:`inline` elements +without any side effects. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94819-ImprovedWorkspacesModule.rst b/Documentation/Changelog/11.4/Feature-94819-ImprovedWorkspacesModule.rst new file mode 100644 index 0000000..e17c946 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94819-ImprovedWorkspacesModule.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-94819: + +============================================ +Feature: #94819 - Improved Workspaces module +============================================ + +See :issue:`94819` + +Description +=========== + +The workspaces module has been improved in usability: + +For the initial loading of the module, the AJAX request has +to process less data as all information is already loaded with +the module. + +A loading indicator is now visible during AJAX requests to +show editors that there is work in progress. + +A dropdown is now used to choose between multiple workspaces, +which is especially useful when having multiple workspaces. + +Administrators can edit workspace settings directly +from the module's docheader area. + + +Impact +====== + +The overall user experience has been improved and administrators +do not need to use the list module to manage workspaces anymore. + +.. index:: Backend, ext:workspaces diff --git a/Documentation/Changelog/11.4/Feature-94889-AddResultOptionToTypolinkReturnLastParameter.rst b/Documentation/Changelog/11.4/Feature-94889-AddResultOptionToTypolinkReturnLastParameter.rst new file mode 100644 index 0000000..41f3eb2 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94889-AddResultOptionToTypolinkReturnLastParameter.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-94889: + +====================================================================== +Feature: #94889 - Add "result" option to typolink returnLast parameter +====================================================================== + +See :issue:`94889` + +Description +=========== + +This change introduces a new :php:`\TYPO3\CMS\Frontend\Typolink\LinkResult` object along with an +interface, containing the base result of a generated link by TypoLink. + +This object should contain all information needed to put together +an :html:`<a>` tag or return a URL in the future. + +For the time being this new class is used to build links from +:php:`\TYPO3\CMS\Frontend\Typolink\AbstractTypolinkBuilder` implementations, and in addition +should be able to be returned fully by :typoscript:`typolink` in the future. + +In addition, this object helps to build links needed +for e.g. JSON responses to contain all information +of the link to be serialized. + + +Impact +====== + +This feature allows user to handle link's data in more consistent way, also +simplifies typolink handling in different outputs than HTML, like i.e. JSON + +.. index:: PHP-API, TypoScript, ext:frontend diff --git a/Documentation/Changelog/11.4/Feature-94906-MultiRecordSelectionInFilelist.rst b/Documentation/Changelog/11.4/Feature-94906-MultiRecordSelectionInFilelist.rst new file mode 100644 index 0000000..da8c903 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94906-MultiRecordSelectionInFilelist.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _feature-94906: + +==================================================== +Feature: #94906 - Multi record selection in filelist +==================================================== + +See :issue:`94906` + +Description +=========== + +With :issue:`94452` the file list in the file selector has been improved +by introducing an optimized way of selecting the files to attach to a record. + +Those optimizations have now also been added to the filelist module. The +checkboxes, previously only used for adding files / folders to the +clipboard, are now always shown in front of each file / folder and are +now independent of the current clipboard mode. Furthermore, the +convenience actions such as "check all", "uncheck all" and "toggle +selection" are now available in the filelist, too. + +By decoupling the selection from the clipboard logic, it is +now possible to directly work with the current selection without the +need to transfer it to the clipboard first. This means, editing or +deleting multiple files is now directly possible without any clipboard +interaction. The available actions appear once an element has been +selected. + +As mentioned above, the "Edit marked" action has been added to the +filelist, which might already be known from the recordlist module. +This action allows to edit the :sql:`sys_file_metadata` records of +all selected files at once. + +Impact +====== + +Selection of files and folders is now quicker to grasp for editors working +in the filelist module. It is also possible to directly +execute actions, e.g. editing metadata of selected files, without +transferring them to the clipboard first. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/11.4/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst b/Documentation/Changelog/11.4/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst new file mode 100644 index 0000000..8a3a918 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94944-KeyboardShortcutsForMultiRecordSelection.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-94944: + +=============================================================== +Feature: #94944 - Keyboard shortcuts for multi record selection +=============================================================== + +See :issue:`94944` + +Description +=========== + +To further increase the usability in the Backend, the multi record selection, +introduced with :issue:`94906`, has been extended for keyboard shortcuts. + +The shortcuts can be used in every module, which implements the multi record +selection. You can recognize this by the dropdown menu in the first header +column of the record listing. + +In such module, when clicking on a checkbox while holding the + +* `shift` key: All records in the range of the last clicked checkbox and the current one are checked / unchecked + +* `option` (macOS) or `ctrl` (Windows / Linux) key: The current selection is toggled (inverted) + + +Impact +====== + +The multi record selection now also features keyboard shortcuts to further +increase the usability of this component. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94966-ShowDebuggerInApplicationInformation.rst b/Documentation/Changelog/11.4/Feature-94966-ShowDebuggerInApplicationInformation.rst new file mode 100644 index 0000000..d31b6b1 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94966-ShowDebuggerInApplicationInformation.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-94966: + +========================================================== +Feature: #94966 - Show debugger in Application Information +========================================================== + +See :issue:`94966` + +Description +=========== + +The "Application Information" menu is now able to show an enabled debugger and +its version, if available. Supported debuggers are xdebug and Zend Debugger at +the moment. + + +Impact +====== + +If a debugger is activated and can be determined via :php:`extension_loaded()`, +the "Application Information" will show such an activated debugger. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-94996-ConsiderAllComposerInstalledExtensionsAsActive.rst b/Documentation/Changelog/11.4/Feature-94996-ConsiderAllComposerInstalledExtensionsAsActive.rst new file mode 100644 index 0000000..ac1d52b --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-94996-ConsiderAllComposerInstalledExtensionsAsActive.rst @@ -0,0 +1,101 @@ +.. include:: /Includes.rst.txt + +.. _feature-94996: + +====================================================================== +Feature: #94996 - Consider all Composer installed extensions as active +====================================================================== + +See :issue:`94996` + +Description +=========== + +All TYPO3 extensions installed with Composer are now considered to be active and +therefore can and will interact with TYPO3 API. + +At Composer install time a persistent artifact is created, holding the information +which extensions are installed and the path where these reside. This makes +the :file:`typo3conf/PackageStates.php` file obsolete and it is neither created nor +evaluated anymore. + +For Composer based installs the artifact is located at +:file:`vendor/typo3/PackageArtifact.php`. This file must be deployed +together with all other Composer dependencies. In a TYPO3v11 sprint this file was located at +:file:`var/build/PackageArtifact.php` which did need a special handling and caused +some issues for example on platform.sh, which were solved by storing it in the vendor folder. + +Any extension present in the :file:`typo3conf/ext` folder, but not installed by Composer, +will still be considered and marked as part of TYPO3 packages when executing +:bash:`composer install`. The only requirement here is, that such extensions need a +:file:`composer.json` file nonetheless. +Note this behaviour is deprecated and will be removed with TYPO3 v12. + +Because all extensions present in the system are considered to be active, +the Extension Manager UI is adapted to not allow changing the active state of +extensions anymore for composer based instances. Respectively the commands +:bash:`extension:activate` and :bash:`extension:deactivate` are disabled in Composer managed +systems as well. + +A new command :bash:`extension:setup` is introduced, which supersedes both the extension +manager UI as well as the activate/deactivate commands. It performs all steps that +were performed during activation and deactivation (the active-state is of course not changed). + +With the command :bash:`extension:setup` *all* extensions are set up in terms of +database schema changes, static data import, distribution files imports, etc. +As example, requiring an additional extension and then using this command will +create database tables or additional database fields the extension provides. + +Any installed Composer package that defines an `extra.typo3/cms` section in +their :file:`composer.json` file will be considered a TYPO3 extension and will +have full access to the TYPO3 API. + +However, because these Composer packages reside in the :file:`vendor` folder, they can +not deliver public resources. This remains exclusive for TYPO3 extensions +installed into :file:`typo3conf/ext` for now - those composer packages that not only +have a `extra.typo3/cms` section, but are also of type `typo3-cms-extension`. + +Impact +====== + +In Composer mode this has the following impact: + +The :file:`PackageStates.php` file is completely ignored. When migrating projects that +still have this file e.g. under version control, it is recommended to remove this file. + +Projects with extensions that reside directly in :file:`typo3conf/ext`, and which therefore +are not installed with Composer, should consider migrating them to a local path repository. +In any case, such extensions now require to have a :file:`composer.json` file. This file +can be created by using the according UI in the Extension Manager. + +When working on a Composer based project and adding new extensions via the Composer +cli tool during development, all added extensions are considered active automatically, +but are not yet set up in terms of database schema changes for example. The TYPO3 cli +command :bash:`extension:setup` needs to be executed additionally. :bash:`extension:setup` can and +should also be used, when deploying a TYPO3 project to make sure database schema is up to date. + +The Composer root project package will be recognized as a TYPO3 extension as well, if it provides a +`extra.typo3/cms` section in its `composer.json`, as mentioned above. Because this package, +like packages in the `vendor` folder isn't accessible by the web server, +the root package can not deliver public resources as well. + +However, when extensions are used as root package for testing (e.g., for running unit, +functional or integration tests in a CI pipeline) **and** these extensions have files in the `Resources/Public` directory, +a symlink in the `typo3conf/ext` directory is automatically created. +Additionally the package path is adapted to be inside `typo3conf/ext`. +This allows TYPO3 to properly calculate URLs for public resources of this extension. + +If the root package isn't of type `typo3-cms-extension` or does not have a `Resources/Public` directory +the absolute path to the extension remains the original path to the composer root directory +and no symlink is created. + +This special behaviour for root packages of type `typo3-cms-extension` +is introduced as a temporary fix to ease extension testing. It is explicitly **NOT** +recommended to use such a setup in production. + +The :file:`ext_emconf.php` file of extensions is now obsolete and therefore completely ignored +in Composer based instances. Make sure the information in the :file:`composer.json` file is in +sync with the one in your :file:`ext_emconf.php` file in case you want to provide one for +compatibility with non Composer mode. + +.. index:: CLI, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95034-SelectRowByMouseClick.rst b/Documentation/Changelog/11.4/Feature-95034-SelectRowByMouseClick.rst new file mode 100644 index 0000000..37a8192 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95034-SelectRowByMouseClick.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-95034: + +============================================================ +Feature: #95034 - List views: Select a row by clicking on it +============================================================ + +See :issue:`95034` + +Description +=========== + +The multi record selection, introduced in :issue:`94906`, has been +improved for a convenience method, making the selection of rows +more pleasant. It's now possible to select a row by +simply clicking anywhere on it. Certainly, this does not influence +any other action on this row, e.g. a link or a button. Only if the +click event is on the row itself, e.g. any empty space, the automatic +selection is performed. + +Besides selecting a single row, also the keyboard actions, introduced +in :issue:`94944`, can be used while clicking on the row, allowing to +further optimize workflows. + +Impact +====== + +It's now possible to select a row by clicking anywhere on it. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-95035-CollapseAllForLargeTrees.rst b/Documentation/Changelog/11.4/Feature-95035-CollapseAllForLargeTrees.rst new file mode 100644 index 0000000..de38235 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95035-CollapseAllForLargeTrees.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-95035: + +================================================ +Feature: #95035 - "Collapse all" for large trees +================================================ + +See :issue:`95035` + +Description +=========== + +A new button "Collapse all" is added for all SVG-based trees, +which is helpful for installations with a lot of pages or folders, +and editors can quickly get an overview again of the entry points. + +The feature collapses all pages / folders inside the tree +except for the items on the root level. + +This feature is now also available in all Record Selector and +Link Picker with SVG Trees as they all are based on the same +code base. + + +Impact +====== + +A new icon is shown in the Toolbar of all trees to select additional actions. + +The "Refresh tree" button is now moved to the additional options +dropdown as well to clean up the Interface and enhance User Experience. + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-95037-NewStartingPointsSettingForFormEngineTreeConfig.rst b/Documentation/Changelog/11.4/Feature-95037-NewStartingPointsSettingForFormEngineTreeConfig.rst new file mode 100644 index 0000000..f8af891 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95037-NewStartingPointsSettingForFormEngineTreeConfig.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-95037: + +====================================================================== +Feature: #95037 - New startingPoints setting for FormEngine treeConfig +====================================================================== + +See :issue:`95037` + +Description +=========== + +The TCA option :php:`treeConfig` used in :php:`renderType=selectTree` and :php:`type=category` has a new +setting :php:`startingPoints` that allows to set multiple records as roots for tree +records. + + +Impact +====== + +The setting takes a CSV value, e.g. `2,3,4711`, which takes records of the pids +`2`, `3` and `4711` into account and creates a tree of these records. + +Additionally, each value used in :php:`startingPoints` may be fed from a site +configuration by using the :php:`###SITE:###` syntax. + +Example: + +.. code-block:: yaml + + # Site config + base: / + rootPageId: 1 + categories: + root: 123 + + +.. code-block:: php + + // Example TCA config + 'config' => [ + 'treeConfig' => [ + 'startingPoints' => '1,2,###SITE:categories.root###', + ], + ], + +This will evaluate to :php:`'startingPoints' => '1,2,123'`. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-95044-SupportAutowiredLoggerInterfaceInjection.rst b/Documentation/Changelog/11.4/Feature-95044-SupportAutowiredLoggerInterfaceInjection.rst new file mode 100644 index 0000000..a3643cd --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95044-SupportAutowiredLoggerInterfaceInjection.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-95044: + +============================================================= +Feature: #95044 - Support autowired LoggerInterface injection +============================================================= + +See :issue:`95044` + +Description +=========== + +Logger instances may be required to be available during object +construction, LoggerAwareInterface isn't an option in that case. +Therefore :php:`\Psr\Log\LoggerInterface` as constructor argument +is now autowired (if the service is configured to use autowiring) +and instantiated with an object-specific logger. + + +Impact +====== + +Services are no longer required to use +:php:`\Psr\Log\LoggerAwareInterface` and :php:`\Psr\Log\LoggerAwareTrait`, +but can add a constructor argument :php:`\Psr\Log\LoggerInterface` instead. + +Example: + +.. code-block:: php + + use Psr\Log\LoggerInterface; + + class MyClass { + private LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) { + $this->logger = $logger; + } + } + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95061-AutoCreationOfMMTablesFromTCA.rst b/Documentation/Changelog/11.4/Feature-95061-AutoCreationOfMMTablesFromTCA.rst new file mode 100644 index 0000000..e89f159 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95061-AutoCreationOfMMTablesFromTCA.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _feature-95061: + +===================================================== +Feature: #95061 - Auto creation of MM tables from TCA +===================================================== + +See :issue:`95061` + +Description +=========== + +TCA table column fields that define :php:`['config']['MM']` can omit specification of the +intermediate mm table layout in :file:`ext_tables.sql`. The TYPO3 database analyzer +takes care of proper schema definition. + +This feature has been implemented to simplify developers life and to enable the TYPO3 +core to handle those schema details since many extensions tend to specify incomplete +or broken mm table schema definitions when dealing with this complex area. + +Extensions are strongly encouraged to drop :file:`ext_tables.sql` :sql:`CREATE TABLE` +definitions for those intermediate tables referenced by :php:`TCA` table columns. Dropping +these definitions allows the core to adapt and migrate definitions if needed. + +Impact +====== + +Extension developers don't need to deal with :file:`ext_tables.sql` definitions of +"mm" tables anymore. The TYPO3 schema analyzer creates the intermediate schema depending +on :php:`TCA` field definition. The schema analyzer tries to apply default specifications +if possible. Single :file:`ext_tables.sql` definitions take precedence, though. + +In practice, suppose the "local" side of a mm table is defined as such in TCA: + +.. code-block:: php + + ... + 'columns' => [ + ... + 'myField' => [ + 'label' => 'myField', + 'config' => [ + 'type' => 'group', + 'foreign_table' => 'tx_myextension_myfield_child', + 'MM' => 'tx_myextension_myfield_mm', + ] + ], + ... + ], + ... + +Until now, a schema definition similar to this had to be in place in :file:`ext_tables.sql`: + +.. code-block:: sql + + CREATE TABLE tx_myextension_myfield_mm ( + uid_local int(11) DEFAULT '0' NOT NULL, + uid_foreign int(11) DEFAULT '0' NOT NULL, + sorting int(11) DEFAULT '0' NOT NULL, + + KEY uid_local (uid_local), + KEY uid_foreign (uid_foreign) + ); + +This section can and should be dropped. Indicators a schema definition is affected by this: + +* A table column TCA config defines :php:`MM` with :php:`type='select'`, :php:`type='group'` + or :php:`type='inline'`. +* The "MM" intermediate table has *no* TCA table definition (!). +* :file:`ext_tables.sql` specifies a table with fields :sql:`uid_local` and :sql:`uid_foreign`. + +The schema analyzer takes care of further possible fields apart from :sql:`uid_local` and +:sql:`uid_foreign`, like :sql:`sorting`, :sql:`sorting_foreign`, :sql:`tablenames`, +:sql:`fieldname` and :sql:`uid` if necessary, depending on "local" side of the TCA definition. + +In general, in case an extension got that definition right up until now, the schema analyzer +should not drop or add any additional fields automatically when removing these sections from +:file:`ext_tables.sql`. Developers are strongly encouraged to drop affected :sql:`CREATE TABLE` +definitions from :file:`ext_tables.sql` and to verify the install tool schema migrator acts as +expected. The core takes care of these specifications from now on and may add adaptions or migrations +to streamline further details in the future. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95065-NewPSR-14BootCompletedEvent.rst b/Documentation/Changelog/11.4/Feature-95065-NewPSR-14BootCompletedEvent.rst new file mode 100644 index 0000000..9a68ae1 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95065-NewPSR-14BootCompletedEvent.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-95065: + +=============================================== +Feature: #95065 - New PSR-14 BootCompletedEvent +=============================================== + +See :issue:`95065` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Core\Event\BootCompletedEvent` has been added to TYPO3 +Core. This event is fired on every request when TYPO3 has been +fully booted, right after all configuration files have been added. + +This new Event complements the :php:`\TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent` which +is executed after TCA configuration has been assembled. + +Registration of the Event in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Bootstrap\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/my-listener' + +.. code-block:: php + + class MyEventListener { + public function __invoke(BootCompletedEvent $e): void + { + // do your magic + } + } + + +Impact +====== + +Use cases for this event is to alter or to boot up extensions' +code which needs to be executed at any time, and needs +TYPO3's full configuration including all loaded extensions. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95068-MultiRecordSelectionInRecordlist.rst b/Documentation/Changelog/11.4/Feature-95068-MultiRecordSelectionInRecordlist.rst new file mode 100644 index 0000000..f69a701 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95068-MultiRecordSelectionInRecordlist.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _feature-95068: + +====================================================== +Feature: #95068 - Multi record selection in recordlist +====================================================== + +See :issue:`95068` + +Description +=========== + +With :issue:`94906` the multi record selection component has been +introduced to TYPO3. Next to proper keyboard support, it enables +editors to easily select multiple records with a couple of convenience +methods, such as "select all", "toggle selection" or "select range". + +This component has now also been added to the :guilabel:`Web > List` +module. Therefore the "clipboard" column has been removed. All clipboard +actions, e.g. "paste content", have been moved to the multi record selection +action bar in the table header. Because the component is not bound to the +clipboard functionality, editors are now able to perform actions, such +as editing or deleting multiple records, without moving them to the +clipboard first. + +As already known from other modules, the available actions (edit, copy, +delete, etc.) are shown in the table header, as soon as one record is +selected. An exception is the "Edit this field" button, displayed next +to each column header, which represents a real database field (only in +the "single table view"). It can be used to edit a single field for all +displayed records. This button now also respects the current selection, +making it possible to edit a single field for only a specific selection +of records. + +Manipulating the displayed actions in the table header is still possible +using the `\TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent` +PSR-14 event. + +In case you are still using the TSconfig option +:typoscript:`showClipControlPanelsDespiteOfCMlayers`, which is rather +unlikely as it wasn't properly respected in latest versions at all, +you should remove it now, since it is no longer evaluated due to +the removal of the clipboard column. + + +Impact +====== + +Editing multiple records in the :guilabel:`Web > List` module has been +improved and is now no longer bound to the clipboard functionality. + +Setting the TSconfig option :typoscript:`showClipControlPanelsDespiteOfCMlayers` +has no effect anymore. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-95077-NewPSR-14ProcessFileListActionsEvent.rst b/Documentation/Changelog/11.4/Feature-95077-NewPSR-14ProcessFileListActionsEvent.rst new file mode 100644 index 0000000..a725842 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95077-NewPSR-14ProcessFileListActionsEvent.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-95077-1668719172: + +======================================================== +Feature: #95077 - New PSR-14 ProcessFileListActionsEvent +======================================================== + +See :issue:`95077` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Configuration\Event\ProcessFileListActionsEvent` has been added to +TYPO3 Core. This event is fired after generating the actions for the +files and folders listing in the :guilabel:`File > Filelist` module. + +Registration of the Event in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\FileList\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/filelist/my-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent; + + class MyEventListener { + + public function __invoke(ProcessFileListActionsEvent $event): void + { + // do your magic + } + + } + +Impact +====== + +This event can be used to manipulate the icons, used for the edit control +section in the files and folders listing within the :guilabel:`File > Filelist` +module. + +.. index:: PHP-API, ext:filelist diff --git a/Documentation/Changelog/11.4/Feature-95079-SupportPHP8StyleChannelAttributeForLoggerInjection.rst b/Documentation/Changelog/11.4/Feature-95079-SupportPHP8StyleChannelAttributeForLoggerInjection.rst new file mode 100644 index 0000000..c1aa71e --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95079-SupportPHP8StyleChannelAttributeForLoggerInjection.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _feature-95079: + +============================================================================ +Feature: #95079 - Support PHP 8 style Channel attribute for logger injection +============================================================================ + +See :issue:`95079` + +Description +=========== + +Services are now able to control the component name that an +injected logger is created with. +This allows to group logs of related classes and is basically +a channel system as often used in monolog. + +The :php:`\TYPO3\CMS\Core\Log\Channel` attribute is supported for constructor +argument injection as a class and parameter specific attribute and for +:php:`\Psr\Log\LoggerAwareInterface` dependency injection services as a class attribute. + +This feature is only available with PHP 8. +The channel attribute will be gracefully ignored in PHP 7, +and the classic component name will be used instead. + + +Registration via class attribute for :php:`\Psr\Log\LoggerInterface` injection: + +.. code-block:: php + + use Psr\Log\LoggerInterface; + use TYPO3\CMS\Core\Log\Channel; + #[Channel('security')] + class MyClass + { + private LoggerInterface $logger; + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + // do your magic + } + } + +Registration via parameter attribute for :php:`LoggerInterface` injection, +overwrites possible class attributes: + +.. code-block:: php + + use Psr\Log\LoggerInterface; + use TYPO3\CMS\Core\Log\Channel; + class MyClass + { + private LoggerInterface $logger; + public function __construct( + #[Channel('security')] + LoggerInterface $logger + ) { + $this->logger = $logger; + // do your magic + } + } + + +Registration via class attribute for :php:`LoggerAwareInterface` services. + +.. code-block:: php + + use Psr\Log\LoggerAwareInterface; + use Psr\Log\LoggerAwareTrait; + use TYPO3\CMS\Core\Log\Channel; + #[Channel('security')] + class MyClass implements LoggerAwareInterface + { + use LoggerAwareTrait; + } + + +Impact +====== + +It is now possible to group several classes into channels, regardless of the +PHP namespace. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95080-NewPSR-14ModifyFileDumpEvent.rst b/Documentation/Changelog/11.4/Feature-95080-NewPSR-14ModifyFileDumpEvent.rst new file mode 100644 index 0000000..ec01bfd --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95080-NewPSR-14ModifyFileDumpEvent.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _feature-95077: + +================================================ +Feature: #95077 - New PSR-14 ModifyFileDumpEvent +================================================ + +See :issue:`95077` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Resource\Event\ModifyFileDumpEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Core\Controller\FileDumpController` and allows extensions +to perform additional access / security checks before dumping a file. The +event does not only contain the file to dump but also the PSR-7 Request. + +In case the file dump should be rejected, the event has to set a PSR-7 +:php:`\Psr\Http\Message\ResponseInterface`, usually with a `403` status code. +This will then immediately stop the propagation. + +With the new event, it's not only possible to reject the file dump request, +but also to replace the file, which should be dumped. + +Registration of the Event in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Resource\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/resource/my-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Core\Resource\Event\ModifyFileDumpEvent; + + class MyEventListener { + + public function __invoke(ModifyFileDumpEvent $event): void + { + // do magic here + } + + } + +Impact +====== + +This event can be used to modify the file dump request, by either +adding an alternative response or by replacing the file being dumped. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95083-NewPSR-14ModifyClearCacheActionsEvent.rst b/Documentation/Changelog/11.4/Feature-95083-NewPSR-14ModifyClearCacheActionsEvent.rst new file mode 100644 index 0000000..af99072 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95083-NewPSR-14ModifyClearCacheActionsEvent.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _feature-95083: + +========================================================= +Feature: #95083 - New PSR-14 ModifyClearCacheActionsEvent +========================================================= + +See :issue:`95083` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Backend\Event\ModifyClearCacheActionsEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Backend\Backend\ToolbarItems\ClearCacheToolbarItem` +class and allows extensions to modify the clear cache actions, shown +in the TYPO3 Backend top toolbar. + +The event can be used to change or remove existing clear cache +actions, as well as to add new actions. Therefore the event also +contains, next to the usual "getter" and "setter", the convenience +method :php:`add` for the :php:`cacheActions` and +:php:`cacheActionIdentifiers` arrays. + +Registration of the Event in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Toolbar\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/toolbar/my-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Backend\Event\ModifyClearCacheActionsEvent; + + class MyEventListener { + + public function __invoke(ModifyClearCacheActionsEvent $event): void + { + // do magic here + } + + } + +Impact +====== + +This event can be used to modify the clear cache actions, shown in the +TYPO3 Backend top toolbar. + +.. index:: PHP-API, ext:backend diff --git a/Documentation/Changelog/11.4/Feature-95089-NewPSR-14AfterFileCommandProcessedEvent.rst b/Documentation/Changelog/11.4/Feature-95089-NewPSR-14AfterFileCommandProcessedEvent.rst new file mode 100644 index 0000000..23d85ed --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95089-NewPSR-14AfterFileCommandProcessedEvent.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _feature-95089: + +=========================================================== +Feature: #95089 - New PSR-14 AfterFileCommandProcessedEvent +=========================================================== + +See :issue:`95089` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Resource\Event\AfterFileCommandProcessedEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Core\Utility\File\ExtendedFileUtility` +class and allows extensions to execute additional tasks, after a file +operation has been performed. + +The event features the following methods: + +- :php:`getCommand()`: Returns the command array while the array key is the performed action and the value is the command data ("cmdArr") +- :php:`getResult()`: Returns the operation result, which could e.g. be an uploaded or changed :php:`File` or a :php:`boolean` for the "delete" action +- :php:`getConflictMode()`: The conflict mode for the performed operation, e.g. "rename" or "cancel" + +Registration of the Event in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\File\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/file/my-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Core\Resource\Event\AfterFileCommandProcessedEvent; + + class MyEventListener { + + public function __invoke(AfterFileCommandProcessedEvent $event): void + { + // do magic here + } + + } + +Impact +====== + +This event can be used to perform additional tasks for specific file commands. +For example, trigger a custom indexer after a file has been uploaded. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Feature-95105-NewPSR-14DatabaseRecordListEvents.rst b/Documentation/Changelog/11.4/Feature-95105-NewPSR-14DatabaseRecordListEvents.rst new file mode 100644 index 0000000..2c83314 --- /dev/null +++ b/Documentation/Changelog/11.4/Feature-95105-NewPSR-14DatabaseRecordListEvents.rst @@ -0,0 +1,133 @@ +.. include:: /Includes.rst.txt + +.. _feature-95105: + +====================================================== +Feature: #95105 - New PSR-14 DatabaseRecordList events +====================================================== + +See :issue:`95105` + +Description +=========== + +A couple of new PSR-14 events for the :php:`\TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList` class +have been added to TYPO3 Core. They are mainly a direct replacement for +the hook methods, defined in the +:php:`\TYPO3\CMS\Recordlist\RecordList\RecordListHookInterface`, while +their functionality is improved and extended. + +The new events can be used to modify the behaviour of each table listing, +which means they can be used to either add, change or even remove columns +and actions. + +Following events have been added: + +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent` +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListHeaderColumnsEvent` +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListRecordActionsEvent` + +They all behave in the same way. There is always the subject, e.g. the +record actions or the header columns, together with information like the +current table, the current :php:`DatabaseRecordList` instance and the +current record or the record uids. The subject is therefore equipped +with the usual CRUD methods like :php:`set`, :php:`get` or :php:`remove`. This makes +working with those values much more pleasant. See the below code examples +on how those can be used. Some events also feature additional methods +to influence e.g. the table header attributes or the label, which is +being displayed in case no actions are available for the current user. + +An example registration of the events in your extensions' :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\RecordList\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/recordlist/my-event-listener' + method: 'modifyRecordActions' + - name: event.listener + identifier: 'my-package/recordlist/my-event-listener' + method: 'modifyHeaderColumns' + - name: event.listener + identifier: 'my-package/recordlist/my-event-listener' + method: 'modifyTableActions' + +The corresponding event listener class: + +.. code-block:: php + + use Psr\Log\LoggerInterface; + use TYPO3\CMS\Recordlist\Event\ModifyRecordListHeaderColumnsEvent; + use TYPO3\CMS\Recordlist\Event\ModifyRecordListRecordActionsEvent; + use TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent; + + class MyEventListener { + + protected LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function modifyRecordActions(ModifyRecordListRecordActionsEvent $event): void + { + $currentTable = $event->getTable(); + + // Add a custom action for a custom table in the secondary action bar, before the "move" action + if ($currentTable === 'my_custom_table' && !$event->hasAction('myAction')) { + $event->setAction( + '<button>My Action</button>', + 'myAction', + 'secondary', + 'move' + ); + } + + // Remove the "viewBig" action in case more than 4 actions exist in the group + if (count($event->getActionGroup('secondary')) > 4 && $event->hasAction('viewBig')) { + $event->removeAction('viewBig'); + } + + // Move the "delete" action after the "edit" action + $event->setAction('', 'delete', 'primary', '', 'edit'); + } + + public function modifyHeaderColumns(ModifyRecordListHeaderColumnsEvent $event): void + { + // Change label of "control" column + $event->setColumn('Custom Controls', '_CONTROL_'); + + // Add a custom class for the table header row + $event->setHeaderAttributes(['class' => 'my-custom-class']); + } + + public function modifyTableActions(ModifyRecordListTableActionsEvent $event): void + { + // Remove "edit" action and log, if this failed + $actionRemoved = $event->removeAction('unknown'); + if (!$actionRemoved) { + $this->logger->warning('Action "unknown" could not be removed'); + } + + // Add a custom clipboard action after "copyMarked" + $event->setAction('<button>My action</button>', 'myAction', '', 'copyMarked'); + + // Set a custom label for the case, no actions are available for the user + $event->setNoActionLabel('No actions available due to missing permissions.'); + } + + } + + +Please have a look at the concrete implementation for a list of all +available methods and their functionalities. + +Impact +====== + +The new PSR-14 events can be used to modify various parts within the +RecordList module in an object-oriented way. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Important-90264-InitializeDatepickerJSInExternalFile.rst b/Documentation/Changelog/11.4/Important-90264-InitializeDatepickerJSInExternalFile.rst new file mode 100644 index 0000000..853bdbc --- /dev/null +++ b/Documentation/Changelog/11.4/Important-90264-InitializeDatepickerJSInExternalFile.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-90264: + +============================================================= +Important: #90264 - Initialize datepicker JS in external file +============================================================= + +See :issue:`90264` + +Description +=========== + +The initialization of the datepicker has been moved into an external +file residing in :file:`EXT:form/Resources/Public/JavaScript/Frontend/DatePicker.js`. +Some installations might restrict requesting public resources from :file:`/typo3/`. +Therefore, a new YAML configuration has been introduced: + +:yaml:`TYPO3.CMS.Form.prototypes.standard.formElementsDefinition.DatePicker.properties.datePickerInitializationJavaScriptFile` + +That way, integrators are able to move the file to a different folder +which is publicly accessible. + +.. index:: ext:form diff --git a/Documentation/Changelog/11.4/Important-92202-RemoveExcludeFromImportantFields.rst b/Documentation/Changelog/11.4/Important-92202-RemoveExcludeFromImportantFields.rst new file mode 100644 index 0000000..00e9734 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-92202-RemoveExcludeFromImportantFields.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-92202: + +======================================================== +Important: #92202 - Remove exclude from important fields +======================================================== + +See :issue:`92202` + +Description +=========== + +To simplify the setup of permissions, the following fields are now shown always to every editor: + +* Field "colPos" from table "tt_content" +* Field "slug" from table "pages" + +If the fields should be hidden, either the setting :php:`'exclude' => true` can be set in your +site package extension or the following TsConfig can be used: + +.. code-block:: typoscript + + TCEFORM.pages.slug.disabled = 1 + TCEFORM.tt_content.colPos.disabled = 1 + +.. index:: Backend, ext:core, TCA diff --git a/Documentation/Changelog/11.4/Important-94280-MoveContentsOfExtPhpIntoLocalScopes.rst b/Documentation/Changelog/11.4/Important-94280-MoveContentsOfExtPhpIntoLocalScopes.rst new file mode 100644 index 0000000..b0cec70 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94280-MoveContentsOfExtPhpIntoLocalScopes.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _important-94280: + +==================================================================== +Important: #94280 - Move contents of ext_*.php into global namespace +==================================================================== + +See :issue:`94280` + +Description +=========== + +When warming up caches, the code of the files :file:`ext_localconf.php` and +:file:`ext_tables.php` are now scoped into the global namespace. + +.. warning:: + + The content of such :file:`ext_*.php` files **must not** be wrapped in a + local namespace by extension authors. This will result in nested namespaces + and therefore cause PHP errors only solvable by clearing the caches via + Install Tool! + + +Example code from the cache file: + +.. code-block:: php + + /** + * Extension: frontend + * File: /var/www/html/public/typo3/sysext/frontend/ext_localconf.php + */ + + namespace { + // Content of EXT:frontend/ext_localconf.php + } + +Having a namespace allows extension authors to import classes by the +keyword :php:`use`. + +Example :file:`ext_localconf.php`: + +.. code-block:: php + + <?php + + use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; + + defined('TYPO3') or die(); + + ExtensionManagementUtility::addUserTSConfig(' + options.saveDocView = 1 + options.saveDocNew = 1 + options.saveDocNew.pages = 0 + options.saveDocNew.sys_file = 0 + options.saveDocNew.sys_file_metadata = 0 + options.disableDelete.sys_file = 1 + '); + + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Important-94615-FluidViewHelpersFlinkexternalAndFuriexternalUseHttpsByDefault.rst b/Documentation/Changelog/11.4/Important-94615-FluidViewHelpersFlinkexternalAndFuriexternalUseHttpsByDefault.rst new file mode 100644 index 0000000..2d74903 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94615-FluidViewHelpersFlinkexternalAndFuriexternalUseHttpsByDefault.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _important-94615: + +============================================================================================== +Important: #94615 - Fluid view helpers f:link.external and f:uri.external use https by default +============================================================================================== + +See :issue:`94615` + +Description +=========== + +When using the Fluid view helpers :html:`f:uri.external` or :html:`f:link.external` without +an explicitly specified scheme, the target link now uses :html:`https` instead of :html:`http`. + +Given the following Fluid snippets: + +.. code-block:: html + + <f:link.external uri="www.some-domain.tld">some content</f:link.external> + <f:uri.external uri="www.some-domain.tld" /> + +The result before: + +.. code-block:: html + + <a href="http://www.some-domain.tld">some content</a> + http://www.some-domain.tld + +New result: + +.. code-block:: html + + <a href="https://www.some-domain.tld">some content</a> + https://www.some-domain.tld + +If the new default can not be used, the :html:`http` scheme needs to be specified. Examples: + +.. code-block:: html + + <f:link.external uri="http://www.some-domain.tld">some content</f:link.external> + <f:link.external uri="www.some-domain.tld" defaultScheme="http">some content</f:link.external> + <f:uri.external uri="http://www.some-domain.tld" /> + <f:uri.external uri="www.some-domain.tld" defaultScheme="http" /> + + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/11.4/Important-94697-QuoteDatabaseIdentifiersWhenUsedInsteadOfGloballyUpfront.rst b/Documentation/Changelog/11.4/Important-94697-QuoteDatabaseIdentifiersWhenUsedInsteadOfGloballyUpfront.rst new file mode 100644 index 0000000..af06568 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94697-QuoteDatabaseIdentifiersWhenUsedInsteadOfGloballyUpfront.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _important-94697: + +==================================================================================== +Important: #94697 - Quote database identifiers when used instead of globally upfront +==================================================================================== + +See :issue:`94697` + +Description +=========== + +When using :php:`TCA` keys that contain SQL fragments like :php:`foreign_table_where`, +:php:`MM_table_where` and :php:`search.andWhere`, it is important to use a special syntax +for SQL field names to stay DBAL compatible. + +See :doc:`#81751 <../8.7.x/Important-81751-DbalCompatibleQuotingInTca>` for details. +It boils down to: Use :sql:`{#colPos}=0` instead of :sql:`colPos=0` to stay DBAL +compatible. The core then takes care field names are properly quoted for the +specific DBMS that is used. + +This quoting preparation has been performed during TCA cache warmup until now. +This had the main disadvantage that this early boostrap step already needs a +working database connection. The core however plans to introduce features to +allow cache warmups as separate step in CI/CD systems. Those usually don't have +the target database available, and in general it's ugly that an early warmup +needs a database connection. + +Therefore, the field name quoting of SQL fragments is now no longer performed +during TCA cache warmup, but instead directly done in places where those +TCA keys are used to create the final queries. + +Since extensions might rely on identifiers within these settings being properly +quoted, a feature flag called `runtimeDbQuotingOfTcaConfiguration` is introduced +to revert to the old behaviour with TYPO3 v11. + +Extension authors who access these TCA properties, which is quite unlikely, +can use the feature flag to support both variants to ensure compatibility +between TYPO3 v10, v11 and TYPO3 v12. + +Starting with TYPO3 v12.0, this feature flag will be enabled at all times. + +.. index:: Database, TCA, ext:core diff --git a/Documentation/Changelog/11.4/Important-94830-UpdateEguliasemail-validator.rst b/Documentation/Changelog/11.4/Important-94830-UpdateEguliasemail-validator.rst new file mode 100644 index 0000000..ae65429 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94830-UpdateEguliasemail-validator.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _important-94830: + +================================================== +Important: #94830 - Update egulias/email-validator +================================================== + +See :issue:`94830` + +Description +=========== + +The package `egulias/email-validator` has been updated from version 2.1.25 to 3.1.1. + +The validation of emails which are using edge cases of the RFC might change. + +The full changelog is available at https://github.com/egulias/EmailValidator/blob/3.x/CHANGELOG.md + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/11.4/Important-94876-RemoveNon-XMLTextValidatorFromFormEditor.rst b/Documentation/Changelog/11.4/Important-94876-RemoveNon-XMLTextValidatorFromFormEditor.rst new file mode 100644 index 0000000..858a41b --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94876-RemoveNon-XMLTextValidatorFromFormEditor.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _important-94876: + +==================================================================== +Important: #94876 - Remove "Non-XML text" validator from form editor +==================================================================== + +See :issue:`94876` + +Description +=========== + +The "Non-XML text" validator has been removed from the UI of the form editor. +Here's why: + +* The validator has a very specific purpose since it is only useful for values + which are output in an HTML context without escaping. By default, this is never + the case in TYPO3 thanks to the automatic escaping in Fluid. +* The form editor is meant to be used by editors and integrators where the + most use cases involve output of form values within TYPO3 (website / mail). This + validator does not serve any purpose then. +* The form editor should be uncluttered and stripped from too technical and + complex concepts which this validator belongs to. + +If there are already text validators within a form definition, the UI keeps the +corresponding validator editors. I.e. the form editor will display them. In newly +created forms, the text validator can no longer be added by default. + +If you want to re-add this validator just extend your own form configuration. +The following example adds the "Non-XML text" validator to the form element +`Text`. The path :yaml:`TYPO3.CMS.Form.prototypes.standard.formElementsDefinition.Text.formEditor.editors.900` +contains the definition for the validators. We are adding the validator with the key +`100` to not interfere with keys already taken by the core (`10` to `90`). + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Text: + formEditor: + editors: + 900: + selectOptions: + 100: + value: Text + label: formEditor.elements.TextMixin.editor.validators.Text.label + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/11.4/Important-94889-LinkBuilderbuildNowReturnsArrayLinkResultInterface.rst b/Documentation/Changelog/11.4/Important-94889-LinkBuilderbuildNowReturnsArrayLinkResultInterface.rst new file mode 100644 index 0000000..bc17948 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-94889-LinkBuilderbuildNowReturnsArrayLinkResultInterface.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _important-94889: + +======================================================================================== +Important: #94889 - AbstractTypoLinkBuilder::build now returns array|LinkResultInterface +======================================================================================== + +See :issue:`94889` + +Description +=========== + +The method signature of :php:`\TYPO3\CMS\Frontend\Typolink\AbstractTypoLinkBuilder` has changed, as +:php:`array` return type has been removed, thus loosening the inheritance +criteria for TYPO3 v11. + +In TYPO3 v12 :php:`AbstractTypoLinkBuilder` will have a +:php:`\TYPO3\CMS\Frontend\Typolink\LinkResultInterface` return type. + +Extensions using this class can stay compatible with two major TYPO3 LTS +versions by doing the following: + +* Keeping an :php:`array` return type to stay compatible with + TYPO3 v10 and TYPO3 v11. +* Using the :php:`LinkResultInterface` return type to stay compatible with + TYPO3 v11 and TYPO3 v12+. + +.. index:: Frontend, PHP-API, TypoScript, ext:frontend diff --git a/Documentation/Changelog/11.4/Important-95647-ComposerInstallationsAndExtensionUsage.rst b/Documentation/Changelog/11.4/Important-95647-ComposerInstallationsAndExtensionUsage.rst new file mode 100644 index 0000000..7de5839 --- /dev/null +++ b/Documentation/Changelog/11.4/Important-95647-ComposerInstallationsAndExtensionUsage.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _important-95647: + +============================================================== +Important: #95647 - Composer installations and extension usage +============================================================== + +See :issue:`95647` + +Description +=========== + +With :issue:`94996` the behavior for Composer-based installations has changed. + +Importance of :file:`ext_emconf.php` file +----------------------------------------- + +The :file:`ext_emconf.php` file which is located in the extensions' base folder, +is not evaluated anymore in Composer-based installations. This means, the +ordering of the extensions and their dependencies are now loaded from the +:file:`composer.json` file, instead of :file:`ext_emconf.php`. + +For non-Composer installation ("Classic Mode") the `ext_emconf.php` file is the +source of truth for required dependencies and the loading order of active +extensions. + +Extension authors should ensure that the information in the :file:`composer.json` +file is in sync with the one in the extensions' :file:`ext_emconf.php` file. +This is especially important regarding constraints like `depends` , `conflicts` +and `suggests`. Use the equivalent settings in :file:`composer.json` `require`, +`conflict` and `suggest` to set dependencies and ensure a specific loading order. + +It is recommended to keep :file:`ext_emconf.php` and :file:`composer.json` in +any public extension that is published to TYPO3 Extension Repository (TER), and +to ensure optimal compatibility with Composer-based installations and Classic +mode. + +Removal of :file:`PackageStates.php` +------------------------------------ + +The :file:`typo3conf/PackageStates.php` file is not evaluated anymore in +Composer-based installations. When updating TYPO3 installations that still +contain this file e.g. under version control, the file can safely be removed. + +Use the TYPO3 CLI command :bash:`extension:setup` to set up all extensions +available in Composer. + +Package information (like paths or extension meta data) is still stored in and evaluated from +a file in Composer's :file:`vendor` folder. This file is written after Composer dumps autoload information. +Make sure all files from that (:file:`vendor`) folder are transferred during a deployment. +This means no special action compared to previous TYPO3 versions is required regarding the :file:`vendor` folder +with TYPO3 11 LTS. + +.. Important:: + TYPO3 version 11.5.0 to 11.5.2 stored package information in :file:`var/build/` folder, + which previously required this folder to be transferred as well during a deployment. + This is not required any more now. Transferring the :file:`vendor` folder is sufficient now. + +All extensions are always active +-------------------------------- + +All extensions and their dependant extensions required via Composer in a +Composer-based TYPO3 installation are **always** activated. It is not possible +to disable an extension by using the Extension Manager anymore. + +The TYPO3 CLI command :bash:`extension:setup` can be used after each +`composer require` or `composer update` command to update the database schema +and other important actions usually done when previously activating an extension +in the Extension Manager. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/11.4/Index.rst b/Documentation/Changelog/11.4/Index.rst new file mode 100644 index 0000000..28323b8 --- /dev/null +++ b/Documentation/Changelog/11.4/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-4: + +============ +11.4 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v11.0 release. + +.. attention:: + + After TYPO3 v11.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v11.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.5.x/Deprecation-95800-GeneratingPublicURLForPrivateAssetFiles.rst b/Documentation/Changelog/11.5.x/Deprecation-95800-GeneratingPublicURLForPrivateAssetFiles.rst new file mode 100644 index 0000000..1441393 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Deprecation-95800-GeneratingPublicURLForPrivateAssetFiles.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95800: + +============================================================================= +Deprecation: #95800 - Deprecate generating public URL for private asset files +============================================================================= + +See :issue:`95800` + +Description +=========== + +Since TYPO3 6, extensions have been restructured to have public asset files in Resources/Public folder only. + +Unfortunately having public assets in extensions located in other folders never was deprecated. This is now done. + + +Impact +====== + +Public assets of extensions (files that should be delivered by the web server) MUST be located in Resources/Public folder of the extension, otherwise a deprecation message is now emitted once a URL to such asset is resolved. + + +Affected Installations +====================== + +Installations having extensions activated, that have public asset files in other locations than Resources/Public. + + +Migration +========= + +Extension authors should move all public assets to Resources/Public folder + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst b/Documentation/Changelog/11.5.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst new file mode 100644 index 0000000..aa47a3c --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _important-100889-1690476872: + +======================================================================= +Important: #100889 - Allow insecure site resolution by query parameters +======================================================================= + +See :issue:`100889` + +.. important:: + This change was introduced as part of the + `TYPO3 12.4.4 and 11.5.30 security releases <https://typo3.org/security/advisory/typo3-core-sa-2023-003>`__. + +Description +=========== + +Resolving sites by the `id` and `L` HTTP query parameters is now denied by +default. However, it is still allowed to resolve a particular page by, for +example, "example.org" - as long as the page ID `123` is in the scope of the +site configured for the base URL "example.org". + +The new feature flag +`security.frontend.allowInsecureSiteResolutionByQueryParameters` - which is +disabled per default - can be used to reactivate the previous behavior: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.allowInsecureSiteResolutionByQueryParameters'] = true; + + +Impact +====== + +Resolving a page via query parameters is now restricted to the specific +site where the page is located. + +Affected installations +====================== + +Installations which resolve pages from one domain via another domain. + +.. index:: Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst b/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst new file mode 100644 index 0000000..4715511 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _important-102799-1707403491: + +=========================================================================================== +Important: #102799 - TYPO3_CONF_VARS.GFX.processor_stripColorProfileParameters option added +=========================================================================================== + +See :issue:`102799` + +Description +=========== + +The string-based configuration option +:php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand']` +has been superseded by +:php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters']` +for security reasons. + +The former option expected a string of command line parameters. The defined +parameters had to be shell-escaped beforehand, while the new option expects an +array of strings that will be shell-escaped by TYPO3 when used. + +The existing configuration will continue to be supported. Still, it is suggested +to use the new configuration format, as the Install Tool is adapted to allow +modification of the new configuration option only: + +.. code-block:: php + + // Before + $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand'] = '+profile \'*\''; + + // After + $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters'] = [ + '+profile', + '*' + ]; + + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-102800-FileAbstractionLayerEnforcesAbsolutePathsToMatchProjectRootOrLockRootPath.rst b/Documentation/Changelog/11.5.x/Important-102800-FileAbstractionLayerEnforcesAbsolutePathsToMatchProjectRootOrLockRootPath.rst new file mode 100644 index 0000000..0eff41b --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-102800-FileAbstractionLayerEnforcesAbsolutePathsToMatchProjectRootOrLockRootPath.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _important-102800-1707409544: + +========================================================================================================= +Important: #102800 - File Abstraction Layer enforces absolute paths to match project root or lockRootPath +========================================================================================================= + +See :issue:`102800` + +Description +=========== + + +The File Abstraction Layer Local Driver has been adapted to verify whether a +given absolute file path is allowed in order to prevent access to files outside +the project root or to the additional root path restrictions defined in +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath']`. + +The option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath']` has been +extended to support an array of root path prefixes to allow for multiple storages +to be listed. Beware that trailing slashes are enforced automatically. + +It is suggested to use the new array-based syntax, which will be applied automatically +once this setting is updated via Install Tool Configuration Wizard: + +.. code-block:: php + + // Before + $GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath'] = '/var/extra-storage'; + + // After + $GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath'] = [ + '/var/extra-storage1/', + '/var/extra-storage2/', + ]; + + +.. index:: FAL, LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-103306-FrameGETParameterInTx_cms_showpicEIDDisabled.rst b/Documentation/Changelog/11.5.x/Important-103306-FrameGETParameterInTx_cms_showpicEIDDisabled.rst new file mode 100644 index 0000000..37e39c7 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-103306-FrameGETParameterInTx_cms_showpicEIDDisabled.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _important-103306-1714976257: + +======================================================================= +Important: #103306 - Frame GET parameter in tx_cms_showpic eID disabled +======================================================================= + +See :issue:`103306` + +Description +=========== + +The show image controller (eID `tx_cms_showpic`) lacks a cryptographic +HMAC-signature on the frame HTTP query parameter (e.g. +`/index.php?eID=tx_cms_showpic?file=3&...&frame=12345`). +This allows adversaries to instruct the system to produce an arbitrary number of +thumbnail images on the server side. + +To prevent uncontrolled resource consumption, the frame HTTP query parameter is +now ignored, since it could not be used by core APIs. + +The new feature flag +`security.frontend.allowInsecureFrameOptionInShowImageController` — which is +disabled per default — can be used to reactivate the previous behavior: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.allowInsecureFrameOptionInShowImageController'] = true; + + +.. index:: Frontend, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.5.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst b/Documentation/Changelog/11.5.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst new file mode 100644 index 0000000..ab4a453 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst @@ -0,0 +1,21 @@ +.. include:: /Includes.rst.txt + +.. _important-92020-1668719172: + +=============================================================================== +Important: #92020 - New API entry point available at https://get.typo3.org/api/ +=============================================================================== + +See :issue:`92020` + +Description +=========== + +The core version service now uses the new entry point of the REST API +available via https://get.typo3.org/api. + +The old entry point is still available but should not be longer used. + +For more information see `https://get.typo3.org/api/doc <https://get.typo3.org/api/doc>`_. + +.. index:: ext:install diff --git a/Documentation/Changelog/11.5.x/Important-93635-AddMailConfigurationForSettingSmtpDomain.rst b/Documentation/Changelog/11.5.x/Important-93635-AddMailConfigurationForSettingSmtpDomain.rst new file mode 100644 index 0000000..d7413e5 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-93635-AddMailConfigurationForSettingSmtpDomain.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _important-93635: + +================================================================== +Important: #93635 - Add mail configuration for setting smtp domain +================================================================== + +See :issue:`93635` + +Description +=========== + +Some smtp-relay-server require to set the domain under which the sender is +sending an email. As default the EsmtpTransport from Symfony will use the current +domain/IP of the host or container. This will be sufficient for the most of the +servers but some servers requires a valid domain is passed. If this isn't done, +sending emails via such servers will fail. + +Setting a valid smtp domain can be achieved by setting +:php:`['MAIL']['transport_smtp_domain']` in the LocalConfiguration.php. +This will set the given domain to the EsmtpTransport agent an send the +correct EHLO-command to the relay-server. + +Configuration Example for GSuite. + +.. code-block:: php + + return [ + //.... + 'MAIL' => [ + 'defaultMailFromAddress' => 'webserver@example.com', + 'defaultMailFromName' => 'SYSTEMMAIL', + 'transport' => 'smtp', + 'transport_smtp_domain' => 'example.com', + 'transport_smtp_encrypt' => '', + 'transport_smtp_password' => '', + 'transport_smtp_server' => 'smtp-relay.gmail.com:587', + 'transport_smtp_username' => '', + ], + //.... + ]; + +Impact +====== + +Now it is possible to set the smtp mail domain which is required for +some relay-server. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst b/Documentation/Changelog/11.5.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst new file mode 100644 index 0000000..2596e9a --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _important-94951-1655368665: + +=================================================================== +Important: #94951 - Restrict export functionality to allowed users +=================================================================== + +See :issue:`94951` + +.. important:: + This change was introduced as part of the + `TYPO3 11.5.11 and 10.4.29 security release <https://typo3.org/security/advisory/typo3-core-sa-2022-001>`__. + +Description +=========== + +The export functionality has the following security drawbacks: + +* Export for editors is not limited on field level +* The :guilabel:`Save to filename` functionality saves to a shared folder, + which other editors with different access rights may have access to. + +Both issues are not easy to resolve and also the target +audience for the Import/Export functionality are mainly +TYPO3 admins. + +Impact +====== + +The export functionality is restricted +to TYPO3 admin users and to users, who explicitly have +access through the new user TSConfig setting +:typoscript:`options.impexp.enableExportForNonAdminUser`. + +Affected installations +====================== + +Installations with EXT:impexp installed where non-admin users need to use the +export functionality. + +Migration +========= + +If non-admin users should be able to use the export tool, set the +following user TSconfig: + +.. code-block:: typoscript + :caption: EXT:my_sitepackage/Configuration/TSconfig/allusers.tsconfig + + options.impexp.enableExportForNonAdminUser = 1 + +.. index:: Backend, TSConfig, NotScanned, ext:impexp diff --git a/Documentation/Changelog/11.5.x/Important-96332-ExtbaseValidatorsCanUseDependencyInjection.rst b/Documentation/Changelog/11.5.x/Important-96332-ExtbaseValidatorsCanUseDependencyInjection.rst new file mode 100644 index 0000000..52f38b1 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-96332-ExtbaseValidatorsCanUseDependencyInjection.rst @@ -0,0 +1,145 @@ +.. include:: /Includes.rst.txt + +.. _important-96332: + +=================================================================== +Important: #96332 - Extbase Validators can use dependency injection +=================================================================== + +See :issue:`96332` + +Description +=========== + +In contrast to what has been outlined with :doc:`this changelog <../11.0/Breaking-92238-ServiceInjectionInExtbaseValidators>`, +Extbase validators can use dependency injection in v11 again. + +Using dependency injection in Extbase validators is possible again, and it +will be available as standard functionality in TYPO3 v12. + +All options below are only needed for extensions that really need to find fully compatible +ways for dependency injection in their validators. In case single extensions have already been adapted +to use the strategy from :doc:`the breaking changelog <../11.0/Breaking-92238-ServiceInjectionInExtbaseValidators>`, +no further adaption is needed. + +Extensions still have to apply some manual code changes to single validators +if they should be dependency injection aware, though: Validators that implement +method :php:`setOptions()` can use :php:`__construct()` or :php:`inject*` methods +for dependency injection. Method :php:`setOptions()` will be added to :php:`ValidatorInterface` +in v12 as mandatory method, and :php:`AbstractValidator` will implement it. Extensions +with dependency injection-aware validators additionally need to set the class +:yaml:`public: true` and :yaml:`shared: false` in :file:`Services.yaml`. This +will be done automatically in v12. + +.. note:: + + All standard validators of EXT:extbase and EXT:form will be marked :php:`final` in TYPO3 v12. + Extension authors should consider this when refactoring validators in TYPO3 v11 already. + +A typical Extbase validator that uses dependency injection in v10 and extends :php:`AbstractValidator` +looks like this in v10: + +.. code-block:: php + + class MyCustomValidator extends AbstractValidator + { + public function injectSomething(Something $something) + { + $this->something = $something; + } + } + +An extension that keeps dependency injection in v11 can now look like this: + +.. code-block:: php + + class MyCustomValidator extends AbstractValidator + { + public function injectSomething(Something $something) + { + $this->something = $something; + } + + public function setOptions(array $options): void + { + // This method is upwards compatible with TYPO3 v12, it will be implemented + // by AbstractValidator in v12 directly and is part of v12 ValidatorInterface. + // @todo: Remove this method when v11 compatibility is dropped. + $this->initializeDefaultOptions($options); + } + } + +An extension that keeps compatibility with v10 and v11 at the same time and needs +dependency injection for custom validators, may need an additional quirk to retain v10 +compatibility. It looks like this: + +.. code-block:: php + + class MyCustomValidator extends AbstractValidator + { + public function injectSomething(Something $something) + { + $this->something = $something; + } + + public function __construct(array $options = []) { + // Retain v10 compatibility if the validator has options. This is + // especially important if there are *mandatory* options, otherwise + // option initialization will be called twice in v11, which may fail. + // @todo: Remove this method when v10 compatibility is dropped. + if ((new Typo3Version())->getMajorVersion() < 11) { + parent::__construct($options); + } + } + + public function setOptions(array $options): void + { + // This method is upwards compatible with TYPO3 v12, it will be implemented + // by AbstractValidator in v12 directly and is part of v12 ValidatorInterface. + // @todo: Remove this method when v11 compatibility is dropped. + $this->initializeDefaultOptions($options); + } + } + +Extensions compatible with v11 and v12 can streamline the code like this: + +.. code-block:: php + + class MyCustomValidator extends AbstractValidator + { + public function __construct(Something $something) { + $this->something = $something; + } + + public function setOptions(array $options): void + { + // @todo: Remove this method when v11 compatibility is dropped. + $this->initializeDefaultOptions($options); + } + } + +The v12 and above version of this validator can then looks like this: + +.. code-block:: php + + class MyCustomValidator extends AbstractValidator + { + public function __construct(private readonly Something $something) { + } + } + +In all of the above cases, whenever Extbase validators need native dependency injection +without manual :php:`GeneralUtility::makeInstance()` calls for their dependencies, and +if TYPO3 v11 should be supported, these validators must set :yaml:`public: true` and +:yaml:`shared: false` in :file:`Services.yaml`: + +.. code-block:: yaml + + # This is obsolete when the extension does not support TYPO3 v11 anymore. + # @todo: Remove this when v11 compatibility is dropped. + MyVendor\MyExtension\Validation\Validator\MyCustomValidator: + public: true + shared: false + + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/11.5.x/Important-97111-DefaultURIScheme.rst b/Documentation/Changelog/11.5.x/Important-97111-DefaultURIScheme.rst new file mode 100644 index 0000000..dbd0bd1 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-97111-DefaultURIScheme.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-97111-1657214952: + +====================================== +Important: #97111 - Default URI scheme +====================================== + +See :issue:`97111` + +Description +=========== + +Several places in the TYPO3 core fall back to using `http` as a protocol for +links in case none was given. In order to adjust this behavior the new +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme']` setting has been +introduced, which uses `http` as default. + +In order to adjust the default protocol, one has to add the following +assignment to their :file:`typo3conf/LocalConfiguration.php` settings: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme'] = 'https'` + +.. index:: LocalConfiguration, RTE, ext:core diff --git a/Documentation/Changelog/11.5.x/Important-97950-NewIconOptionInLoginProviders.rst b/Documentation/Changelog/11.5.x/Important-97950-NewIconOptionInLoginProviders.rst new file mode 100644 index 0000000..415ca1b --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-97950-NewIconOptionInLoginProviders.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _important-97950-1657892101: + +================================================================== +Important: #97950 - New "iconIdentifier" option in login providers +================================================================== + +See :issue:`97950` + +Description +=========== + +A new option :php:`iconIdentifier` is added to login providers, which accepts +any icon that's available in the Icon Registry. + +Example: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['loginProviders'][1433416747] = [ + 'provider' => UsernamePasswordLoginProvider::class, + 'sorting' => 50, + 'iconIdentifier' => 'actions-key', + 'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:login.link', + ]; + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/11.5.x/Important-98122-FixFeloginVariableNameInTypoScriptSetup.rst b/Documentation/Changelog/11.5.x/Important-98122-FixFeloginVariableNameInTypoScriptSetup.rst new file mode 100644 index 0000000..121d558 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-98122-FixFeloginVariableNameInTypoScriptSetup.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-98122-1671636081: + +================================================================= +Important: #98122 - Fix felogin variable name in TypoScript setup +================================================================= + +See :issue:`98122` + +Description +=========== + +The showForgotPasswordLink setting was renamed to showForgotPassword during the +refactoring to fluid templates. It is now also renamed in the TypoScript setup. + +The TypoScript constant name is not changed to keep compatibility. + +Migration +========= + +Use :typoscript:`plugin.tx_felogin_login.settings.showForgotPassword` instead of +:typoscript:`plugin.tx_felogin_login.settings.showForgotPasswordLink` in TypoScript setup. +And :typoscript:`styles.content.loginform.showForgotPassword` instead of +:typoscript:`styles.content.loginform.showForgotPasswordLink` in TypoScript constants. + +.. index:: TypoScript, ext:felogin diff --git a/Documentation/Changelog/11.5.x/Important-98960-DefaultTypeDefinitionOfCustomContentTypes.rst b/Documentation/Changelog/11.5.x/Important-98960-DefaultTypeDefinitionOfCustomContentTypes.rst new file mode 100644 index 0000000..814de88 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Important-98960-DefaultTypeDefinitionOfCustomContentTypes.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _important-98960-1667212946: + +=================================================================== +Important: #98960 - Default type definition of custom Content Types +=================================================================== + +See :issue:`98960` + +Description +=========== + +Due to the deprecation of Switchable Controller Actions for Extbase, it is +recommended to use custom content types as plugins. When using Extbase's API +:php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin()` with +the 5th argument being set to +:php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT` +or TYPO3's native API :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPlugin()` +with the second argument being set to `CType`, the entered icon identifier +is now automatically added as `typeicon_classes` for the given `CType` and +the `TCA` types definition (`showitem`) of the default `header` type is +automatically applied, so extension authors do not need to add all default +fields anymore. + +Note +---- + +These defaults are only applied if they are not set manually, so the changes +are optional defaults. + +In addition, the API method :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes()` +now also allows to add custom fields after a palette, so common variants such +as the `Plugin` tab can be added after a specific palette. + +Example +------- + +Example for a custom Extbase plugin with TYPO3's Core "felogin" extension +in `EXT:felogin/Configuration/TCA/Overrides/tt_content.php`: + +.. code-block:: php + :caption: EXT:felogin/Configuration/TCA/Overrides/tt_content.php + + call_user_func(static function () { + $contentTypeName = 'felogin_login'; + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( + 'Felogin', + 'Login', + 'LLL:EXT:felogin/Resources/Private/Language/Database.xlf:tt_content.CType.felogin_login.title', + 'mimetypes-x-content-login', + 'forms' + ); + + // Add the FlexForm for the new content type + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPiFlexFormValue( + '*', + 'FILE:EXT:felogin/Configuration/FlexForms/Login.xml', + $contentTypeName + ); + + // Add the FlexForm to the showitem list + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes( + 'tt_content', + '--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.plugin, pi_flexform', + $contentTypeName, + 'after:palette:headers' + ); + }); + +It is configured to be a content element with its own ctype by having the 5th +parameter set to :php:`ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT`. + +.. code-block:: php + :caption: EXT:felogin/ext_localconf.php + :emphasize-lines: 14 + + use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; + + ExtensionUtility::configurePlugin( + 'Felogin', + 'Login', + [ + LoginController::class => 'login, overview', + PasswordRecoveryController::class => 'recovery,showChangePassword,changePassword', + ], + [ + LoginController::class => 'login, overview', + PasswordRecoveryController::class => 'recovery,showChangePassword,changePassword', + ], + ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + +.. index:: Backend, TCA, ext:core diff --git a/Documentation/Changelog/11.5.x/Index.rst b/Documentation/Changelog/11.5.x/Index.rst new file mode 100644 index 0000000..7a38578 --- /dev/null +++ b/Documentation/Changelog/11.5.x/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-5-x: + +============== +11.5.x Changes +============== + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +================ + +None since TYPO3 v11.5.0 LTS release. + +.. attention:: + + Breaking changes are not planned after the TYPO3 v11.5.0 LTS release. + +Features +======== + +None since TYPO3 v11.5.0 LTS release. + +.. attention:: + + New features are not planned after the TYPO3 v11.5.0 LTS release. + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/11.5/Deprecation-91787-DeprecateInlineJavaScriptInFieldChangeFunc.rst b/Documentation/Changelog/11.5/Deprecation-91787-DeprecateInlineJavaScriptInFieldChangeFunc.rst new file mode 100644 index 0000000..36e81cd --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-91787-DeprecateInlineJavaScriptInFieldChangeFunc.rst @@ -0,0 +1,154 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91787: + +========================================================== +Deprecation: #91787 - Inline JavaScript in fieldChangeFunc +========================================================== + +See :issue:`91787` + +Description +=========== + +Custom :php:`FormEngine` nodes allow to use internal property :php:`fieldChangeFunc` +to add or modify client-side JavaScript behavior when field values are changed. + +In the past these declarations basically were inline JavaScript, provided in +PHP and forwarded to the browser via HTML :html:`onchange` or :html:`onclick` +event attributes. In favor of introducing content security policy headers and +to reduce inline JavaScript, those functionality shall be defined in a +structured way & custom client-side behavior shall be provided by corresponding +JavaScript modules instead. + +As a result, :php:`fieldChangeFunc` declarations are not using plain inline +JavaScript (as scalar :php:`string`) anymore, but make use of corresponding objects +implementing new :php:`\TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeInterface`. +This interface provides both a new structured and declarative approach via +`JSON` - but also allows to fallback to legacy inline JavaScript in case it +is required in combination with legacy 3rd party extensions. + +Using :php:`fieldChangeFunc` with scalar :php:`string` values has been marked as deprecated and has to +be substituted with specific implementations of :php:`OnFieldChangeInterface`. + + +Impact +====== + +Using :php:`fieldChangeFunc` with scalar :php:`string` values will trigger a +PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Installations implementing custom :php:`FormEngine` components (wizards, nodes, +render-types, ...) that provide inline JavaScript using :php:`fieldChangeFunc`. + +.. code-block:: php + + // examples + $this->data['parameterArray']['fieldChangeFunc']['example'] = "alert('demo');"; + $parameterArray['fieldChangeFunc']['example'] = "alert('demo');"; + + +Migration +========= + +The following steps provide a brief overview of the new components in order to +avoid inline JavaScript. A complete and installable example is available with +`ext:demo_91787 <https://github.com/ohader/demo_91787>`__. + + +PHP :php:`OnFieldChangeInterface` instance +------------------------------------------ + +.. code-block:: php + + <?php + namespace TYPO3\Example; + + use TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeInterface; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + class AlertOnFieldChange implements OnFieldChangeInterface + { + protected string $value = 'demo'; + public function __toString(): string + { + // provides `alert('demo')` as plain inline JavaScript + return sprintf( + 'alert(%s)', + // always make sure to encode data, mitigating XSS + GeneralUtility::quoteJSvalue($this->value) + ); + } + public function toArray(): array + { + // provides structured representation + return [ + // handler `name` as registered with `FormEngine.js` + 'name' => 'example-alert', + // fixed `data` segment + 'data' => [ + // ... can contain any arbitrary & custom payload + 'value' => $this->value, + ] + ]; + } + } + + +PHP :php:`FormEngine` consumer +------------------------------ + +.. code-block:: php + + <?php + namespace TYPO3\Example; + + use TYPO3\CMS\Backend\Form\Element\InputTextElement; + use TYPO3\CMS\Core\Page\PageRenderer; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // just extending `input` TCA render-type, to keep it simple + class ConsumingElement extends InputTextElement + { + public function render() + { + // uses custom `OnFieldChangeInterface` implementation from above + // (whenever the value of this field is changed, an alert message shall be shown) + $this->data['parameterArray']['fieldChangeFunc']['example'] = new AlertOnFieldChange(); + // side-note: before having `OnFieldChangeInterface`, it looked like this using inline code + // $this->data['parameterArray']['fieldChangeFunc']['example'] = "alert('demo');"; + + $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); + // registers RequireJS module to register & handle that `fieldChangeFunc` instruction + // (JavaScript module is loaded from `ext:example/Resources/Public/JavaScript/Demo.js`) + $pageRenderer->loadRequireJsModule('TYPO3/CMS/Example/Demo'); + + // just use parent method to render that `<input type="text">` field + return parent::render(); + } + } + + +JavaScript :js:`FormEngine` registration +---------------------------------------- + +JavaScript module :js:`TYPO3/CMS/Example/Demo` is fetched via RequireJS from +resource path :file:`ext:example/Resources/Public/JavaScript/Demo.js`. + +.. code-block:: javascript + + define(['TYPO3/CMS/Backend/FormEngine'], (FormEngine) => { + FormEngine.registerOnFieldChangeHandler( + // `example-alert` as defined in `name` segment from PHP `AlertOnFieldChange::toArray()` + 'example-alert', + // `data` segment from PHP `AlertOnFieldChange::toArray()` + (data) => { alert(data.title); } + ); + }) + + +.. index:: Backend, JavaScript, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-91814-DeprecateAbstractControlsetOnClick.rst b/Documentation/Changelog/11.5/Deprecation-91814-DeprecateAbstractControlsetOnClick.rst new file mode 100644 index 0000000..dde6192 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-91814-DeprecateAbstractControlsetOnClick.rst @@ -0,0 +1,155 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-91814: + +================================================= +Deprecation: #91814 - AbstractControl::setOnClick +================================================= + +See :issue:`91814` + +Description +=========== + +In favor of allowing `Content-Security-Policy` HTTP headers, inline JavaScript +invocation via :php:`\TYPO3\CMS\Backend\Template\Components\AbstractControl::setOnClick` +has been marked as deprecated. Existing instructions can be migrated using existing JavaScript +helpers :js:`GlobalEventHandler` or :js:`ActionDispatcher` and their capabilities to provide +similar functionality using :html:`data-` attributes. + +There might be scenarios that require a custom JavaScript module handling +specific use cases that are not covered by mentioned JavaScript helpers. + + +Impact +====== + +Using affected PHP methods (see section below) will trigger PHP :php:`E_USER_DEPRECATED` errors. + + +Affected Installations +====================== + +All sites using 3rd party extensions that are using following methods directly +or in inherited class implementations: + +* :php:`\TYPO3\CMS\Backend\Template\Components\AbstractControl->setOnClick` +* :php:`\TYPO3\CMS\Backend\Template\Components\AbstractControl->getOnClick` + + +Migration +========= + +Mentioned JavaScript helpers cover most common use cases by using :html:`data-` +attributes instead of :html:`onclick` event attributes with corresponding HTML +elements. + +* consider replacing simple :html:`<a ... onclick="window.location.href=[URI]"` + with plain HTML links like :html:`<a href="[URI]">` +* replacing :php:`BackendUtility::viewOnClick`, + :doc:`see documentation & examples <../11.0/Important-91123-AvoidUsingBackendUtilityViewOnClick>` +* using :html:`data-` attributes for :js:`GlobalEventHandler` and :js:`ActionDispatcher`, + :doc:`see documentation & examples <../10.4.x/Important-91117-UseGlobalEventHandlerAndActionDispatcherInsteadOfInlineJS>` + + +Example #1: open a new window/tab +--------------------------------- + +* taken from extension `dce` +* see `corresponding pull-request <https://bitbucket.org/ArminVieweg/dce/pull-requests/97/task-avoid-using-abstractcontrol>`__ + +.. code-block:: php + + $button->setOnClick( + 'window.open(\'' . $this->getDceEditLink($contentUid) . '\', \'editDcePopup\', ' . + '\'height=768,width=1024,status=0,menubar=0,scrollbars=1\')' + ); + +Code block above being substituted with :js:`ActionDispatcher` capabilities, +using :html:`data-dispatch-action` and :html:`data-dispatch-args` HTML attributes: + +.. code-block:: php + + $button->setDataAttributes([ + 'dispatch-action' => 'TYPO3.WindowManager.localOpen', + // JSON encoded representation of JavaScript function arguments + // (HTML attributes are encoded in \TYPO3\CMS\Backend\Template\Components\Buttons\LinkButton) + 'dispatch-args' => GeneralUtility::jsonEncodeForHtmlAttribute([ + $this->getDceEditLink($contentUid), + 'editDcePopup', + 'height=768,width=1024,status=0,menubar=0,scrollbars=1', + ], false) + ]); + + +Example #2: preview page in frontend +------------------------------------ + +* taken from extension `wizard_crpagetree` +* see `corresponding pull-request <https://github.com/liayn/t3ext-wizard_crpagetree/pull/8>`__ + +.. code-block:: php + + $viewButton = $buttonBar->makeLinkButton() + // @deprecated setOnClick + ->setOnClick(BackendUtility::viewOnClick($pageUid, '', BackendUtility::BEgetRootLine($pageUid))) + ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage')) + ->setIcon($iconFactory->getIcon('actions-view-page', Icon::SIZE_SMALL)) + ->setHref('#'); + +Code block above being substituted with :php:`\TYPO3\CMS\Backend\Routing\PreviewUriBuilder` +based on :js:`ActionDispatcher` capabilities, using :html:`data-dispatch-action` and +:html:`data-dispatch-args` HTML attributes: + +.. code-block:: php + + $previewDataAttributes = PreviewUriBuilder::create($pageUid) + ->withRootLine(BackendUtility::BEgetRootLine($pageUid)) + ->buildDispatcherDataAttributes(); + $viewButton = $buttonBar->makeLinkButton() + // substituted with HTML data attributes + ->setDataAttributes($previewDataAttributes ?? []) + ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage')) + ->setIcon($iconFactory->getIcon('actions-view-page', Icon::SIZE_SMALL)) + ->setHref('#'); + + +Example #3: confirmation dialog +------------------------------- + +* taken form extension `news` +* see `corresponding pull-request <https://github.com/georgringer/news/pull/1585>`__ +* side-note: There was a bug in extension `news`, examples below have been adjusted + to show how the scenario probably would have been before, using :js:`confirm()` + +.. code-block:: php + + $pasteTitle = 'Paste from Clipboard'; + $confirmMessage = GeneralUtility::quoteJSvalue('Shall we paste the record?'); + $viewButton = $buttonBar->makeLinkButton() + ->setHref($clipBoard->pasteUrl('', $this->pageUid)) + // @deprecated inline JavaScript requesting user confirmation + ->setOnClick('return confirm(' . $confirmMessage . ')') + ->setTitle($pasteTitle) + ->setIcon($this->iconFactory->getIcon('actions-document-paste-into', Icon::SIZE_SMALL)); + +Code block above being substituted with capabilities of modal dialog handling +and functionalities of the Bootstrap framework. + +.. code-block:: php + + $pasteTitle = 'Paste from Clipboard'; + $confirmMessage = 'Shall we paste the record?'; + $viewButton = $buttonBar->makeLinkButton() + ->setHref($clipBoard->pasteUrl('', $this->pageUid)) + // using CSS class to trigger confirmation in modal box + ->setClasses('t3js-modal-trigger') + ->setDataAttributes([ + 'title' => $pasteTitle, + 'bs-content' => $confirmMessage, + ]) + ->setTitle($pasteTitle) + ->setIcon($this->iconFactory->getIcon('actions-document-paste-into', Icon::SIZE_SMALL)); + + +.. index:: Backend, JavaScript, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-94094-NavigationFrameModuleInModuleRegistration.rst b/Documentation/Changelog/11.5/Deprecation-94094-NavigationFrameModuleInModuleRegistration.rst new file mode 100644 index 0000000..447b053 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-94094-NavigationFrameModuleInModuleRegistration.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94094: + +================================================================== +Deprecation: #94094 - navigationFrameModule in Module Registration +================================================================== + +See :issue:`94094` + +Description +=========== + +TYPO3 allowed for each module to include an iFrame for the navigation area with +the option :php:`navigationFrameModule` and :php:`navigationFrameModuleParameters`. +Since TYPO3 4.5 it was possible to also use a JavaScript component instead +via :php:`navigationComponentId`. + +TYPO3 v11 allows to use Web Components for the :php:`navigationComponentId` option, +and all Core-based navigation components have been migrated to Lit-based +Web Components. + +With this technology, TYPO3 does not need to handle iFrames for +the navigation area anymore, which is why the feature, together +with the option :php:`navigationFrameModule` has been marked as deprecated. + + +Impact +====== + +TYPO3 installations with third-party extensions registering +custom navigation iFrames will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with third-party extensions shipping modules +with a custom navigation iFrame. + + +Migration +========= + +Migration should be done by using Web Components, as this is much +faster and allows for better interoperability due to less usages of iFrames. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-94791-GeneralUtilityminifyJavaScript.rst b/Documentation/Changelog/11.5/Deprecation-94791-GeneralUtilityminifyJavaScript.rst new file mode 100644 index 0000000..0d11fe8 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-94791-GeneralUtilityminifyJavaScript.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94791: + +======================================================== +Deprecation: #94791 - GeneralUtility::minifyJavaScript() +======================================================== + +See :issue:`94791` + +Description +=========== + +The static method :php:`TYPO3\CMS\Core\Utility\GeneralUtility::minifyJavaScript()` +has been marked as deprecated. + +Back in TYPO3 4.x times, the "jsmin" library was used to minify +JavaScript, however as this became more flexible, a hook was +introduced, and then "jsmin" was removed again. Since then, +the hook to minify inline JavaScript is used in PageRenderer, +and should rather be moved into the :php:`ResourceCompressor` functionality, +where it resides now. + +The hook itself works exactly as before. + + +Impact +====== + +Calling the method will trigger a PHP :php:`E_USER_DEPRECATED` error. Extension +scanner will detect calls as strong match. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions calling this method, +which is highly unlikely. + +Custom extensions using this hook will still work as before without any changes. + + +Migration +========= + +As this method was used to only trigger a hook, it is recommended +to use the :php:`PageRenderer` and :php:`ResourceCompressor` API instead, removing +any direct calls to this method. + +If still needed, extension authors can also copy the hook call +execution to use the hook logic, which is not recommended though. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95041-DeprecateFuriemailView-helper.rst b/Documentation/Changelog/11.5/Deprecation-95041-DeprecateFuriemailView-helper.rst new file mode 100644 index 0000000..66e4940 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95041-DeprecateFuriemailView-helper.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95041: + +=============================================== +Deprecation: #95041 - <f:uri.email> view-helper +=============================================== + +See :issue:`95041` + +Description +=========== + +Fluid view-helper :html:`<f:uri.email email="{email}">` was used in combination +with :typoscript:`config.spamProtectEmailAddresses` settings during frontend rendering +and returned corresponding :js:`javascript:linkTo_UnCryptMailto(...)` inline +JavaScript URI. In case spam-protections is not configured, this view-helper +just passed through the given email address. + +In favor of allowing more content security policy scenarios, :js:`URI` +is not used anymore per default. As a result, :html:`<f:uri.email>` +view-helper became obsolete. The view-helper will be removed with TYPO3 v12.0. + + +Impact +====== + +Using :html:`<f:uri.email>` view-helper will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +All projects using :html:`<f:uri.email email="{email}">` or +:html:`{email -> f:uri.email(email:email)}` view-helper invocations in their Fluid templates. + + +Migration +========= + +In case :typoscript:`config.spamProtectEmailAddresses` is used, make use of +:html:`<f.link.email email="{email}">` view-helper which returns the +complete :html:`<a>` tag like this: + +.. code-block:: html + + <a href="#" data-mailto-token="ocknvq,hqqBdct0vnf" + data-mailto-vector="1">user(at)my.example(dot)com</a> + +In case spam-protected is not used or not useful (for example in backend user +interface), view-helper invocation can be omitted completely. + + +.. index:: Fluid, Frontend, FullyScanned, ext:fluid diff --git a/Documentation/Changelog/11.5/Deprecation-95139-ExtbaseControllerContext.rst b/Documentation/Changelog/11.5/Deprecation-95139-ExtbaseControllerContext.rst new file mode 100644 index 0000000..c924fc8 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95139-ExtbaseControllerContext.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95139: + +=============================================== +Deprecation: #95139 - Extbase ControllerContext +=============================================== + +See :issue:`95139` + +Description +=========== + +The Extbase related class :php:`TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext` +has been used in the past to transfer data between Extbase controllers and Fluid +views. It has been superseded by class :php:`TYPO3\CMS\Fluid\Core\Rendering\RenderingContext` +with various preparation patches. To further decouple Fluid from Extbase, class +:php:`ControllerContext` has been marked as deprecated. + + +Impact +====== + +Accessing :php:`ControllerContext` and consuming information carried in it has +been marked as deprecated. The class will be removed in TYPO3 v12. The object is bound +to various Fluid view related classes and all occurrences have been marked with +an :php:`@deprecated` annotation. + +To retain backwards compatibility, accessing :php:`ControllerContext` does not +actively trigger a PHP :php:`E_USER_DEPRECATED` error in most cases, though. + + +Affected Installations +====================== + +Instances with extensions that access :php:`ControllerContext` are affected. This +typically affects extensions which provide own view-helpers. The extension scanner +should find possible matches. + + +Migration +========= + +Two getters of the class have already been marked as deprecated with previous patches, namely +:php:`->getUriBuilder()` as documented with :php:`->getFlashMessageQueue()`. Classes +should inject instances of these objects instead, or should :php:`makeInstance()` them. + +Method :php:`getRequest()` is available in controllers directly, and view-helpers +receive the current request by calling :php:`RenderingContext->getRequest()`. + +Method :php:`getArguments()` returns the Extbase :php:`Arguments` created by the +:php:`ActionController`. The getter has become mostly useless within Fluid context +since argument validation of forms is abstracted differently since various core versions. +If that object construct is still needed, it should be transferred differently to +consuming classes, for instance by assigning it as variable to the view and accessing +it in a view-helper using the variable container. In many cases it should be sufficient +to directly work with the request object instead. + + +.. index:: Fluid, PHP-API, PartiallyScanned, ext:extbase diff --git a/Documentation/Changelog/11.5/Deprecation-95164-ExtbackendBackendTemplateView.rst b/Documentation/Changelog/11.5/Deprecation-95164-ExtbackendBackendTemplateView.rst new file mode 100644 index 0000000..4940cb6 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95164-ExtbackendBackendTemplateView.rst @@ -0,0 +1,95 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95164: + +===================================================== +Deprecation: #95164 - ext:backend BackendTemplateView +===================================================== + +See :issue:`95164` + +Description +=========== + +To simplify and align the view part of Extbase-based backend module controller code with +non-Extbase based controllers, class :php:`TYPO3\CMS\Backend\View\BackendTemplateView` +has been marked as deprecated and will be removed in TYPO3 v12. + +This follows the general Core strategy to have document header related code +using the :php:`ModuleTemplate` class structure within controllers directly, while +Extbase views render only the main body part. + + +Impact +====== + +Extensions should switch away from using :php:`BackendTemplateView`. By hiding an +instance of :php:`ModuleTemplate` class, :php:`BackendTemplateView` basically added +a no longer needed level of indirection to code that should be located directly +within controller actions. + +Together with the TYPO3 v11 requirement within Extbase controller actions to return +responses directly, combined with Extbase Request object now implementing the PSR-7 +ServerRequestInterface, and with the deprecation of other doc header related Fluid +View helpers, Extbase controller action becomes much more obvious code wise. + + +Affected installations +====================== + +Instances with extensions using class :php:`BackendTemplateView` are affected. +Candidates are typically Extbase based extensions that deliver backend modules. +The extension scanner will find usages as strong match. + + +Migration +========= + +A transition away from :php:`BackendTemplateView` should be usually pretty straight: +Instead of retrieving a :php:`ModuleTemplate` instance from the view, the +:php:`ModuleTemplateFactory` should be injected and an instance retrieved using +:php:`create()`. + +A typical scenario before: + +.. code-block:: php + + class MyController extends ActionController + { + protected $defaultViewObjectName = BackendTemplateView::class; + + public function myAction(): ResponseInterface + { + $this->view->assign('someVar', 'someContent'); + $moduleTemplate = $this->view->getModuleTemplate(); + // Adding title, menus, buttons, etc. using $moduleTemplate ... + return $this->htmlResponse(); + } + } + +Dropping :php:`BackendTemplateView` leads to code similar to this: + +.. code-block:: php + + class MyController extends ActionController + { + protected ModuleTemplateFactory $moduleTemplateFactory; + + public function __construct( + ModuleTemplateFactory $moduleTemplateFactory, + ) { + $this->moduleTemplateFactory = $moduleTemplateFactory; + } + + public function myAction(): ResponseInterface + { + $this->view->assign('someVar', 'someContent'); + $moduleTemplate = $this->moduleTemplateFactory->create($this->request); + // Adding title, menus, buttons, etc. using $moduleTemplate ... + $moduleTemplate->setContent($this->view->render()); + return $this->htmlResponse($moduleTemplate->renderContent()); + } + } + + +.. index:: Backend, Fluid, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-95200-DeprecateRequireJSCallbacksAsInlineJavaScript.rst b/Documentation/Changelog/11.5/Deprecation-95200-DeprecateRequireJSCallbacksAsInlineJavaScript.rst new file mode 100644 index 0000000..88348f1 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95200-DeprecateRequireJSCallbacksAsInlineJavaScript.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95200: + +============================================================== +Deprecation: #95200 - RequireJS callbacks as inline JavaScript +============================================================== + +See :issue:`95200` + +Description +=========== + +Custom :php:`FormEngine` components allowed to load RequireJS modules +with arbitrary inline JavaScript to initialize those modules. In favor +of introducing content security policy headers, the amount of inline +JavaScript shall be reduced and replaced by corresponding declarations. + +Using callback functions has been marked as deprecated and shall be replaced by new +:php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` declarations. In +:php:`FormEngine`, loading RequireJS module via arrays has been marked as deprecated and +has to be migrated as well. + + +Impact +====== + +Using :php:`$resultArray['requireJsModules']` with scalar :php:`string` values will +trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Installations implementing custom :php:`FormEngine` components and loading +RequireJS modules via :php:`$resultArray['requireJsModules']` are affected. + + +Migration +========= + +New :php:`JavaScriptModuleInstruction` allows to declare the following +aspects when loading RequireJS modules: + +* :php:`$instruction = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Module')` + creates corresponding loading instruction that can be enriched with + following declarations +* :php:`$instruction->assign(['key' => 'value'])` allows to assign key-value pairs + directly to the loaded RequireJS module object or instance +* :php:`$instruction->invoke('method', 'value-a', 'value-b')` allows to invoke + a particular method of the loaded RequireJS instance with given argument values +* :php:`$instruction->instance('value-a', 'value-b')` allows to invoke the + constructor of the loaded RequireJS class with given argument values + +Initializations other than the provided aspects have to be implemented in +custom module implementations, for example triggered by corresponding on-ready handlers. + +Example in :php:`FormEngine` component +-------------------------------------- + +.. code-block:: php + + $resultArray['requireJsModules'][] = ['TYPO3/CMS/Backend/FormEngine/Element/InputDateTimeElement' => ' + function(InputDateTimeElement) { + new InputDateTimeElement(' . GeneralUtility::quoteJSvalue($fieldId) . '); + }' + ]; + +... has to be migrated to the following ... + +.. code-block:: php + + // use use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS( + 'TYPO3/CMS/Backend/FormEngine/Element/InputDateTimeElement' + )->instance($fieldId); + +:php:`JavaScriptModuleInstruction` forwards arguments as `JSON` data - and thus +handles proper context-aware encoding implicitly (:php:`GeneralUtility::quoteJSvalue` +and similar custom encoding can be omitted in this case). + + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-95219-TypoScriptFrontendController-ATagParams.rst b/Documentation/Changelog/11.5/Deprecation-95219-TypoScriptFrontendController-ATagParams.rst new file mode 100644 index 0000000..da2ad44 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95219-TypoScriptFrontendController-ATagParams.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95219: + +============================================================== +Deprecation: #95219 - TypoScriptFrontendController->ATagParams +============================================================== + +See :issue:`95219` + +Description +=========== + +The public property :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->ATagParams` +has been marked as deprecated. + +It was used in the past as a copy of the value +:php:`TypoScriptFrontendController->config[config][ATagParams]`, +which should be used instead. + +There is no need to use such a (less prominent) configuration option in a +separate public property, as it needs to be kept in sync with the +actual configuration option. + +The second argument of the related method +:php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getATagParams()` +called :php:`$addGlobal` is also marked as deprecated, and will have no effect +anymore in TYPO3 v12. + +Impact +====== + +Accessing, setting or writing this property will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Calling :php:`ContentObjectRenderer->getATagParams()` +with a second argument set to false will trigger a PHP :php:`E_USER_DEPRECATED` error +as well. + + +Affected Installations +====================== + +TYPO3 installations with third-party-extensions accessing, or +writing this property directly within PHP, or calling :php:`getATagParams()` +directly, which is highly unlikely. + + +Migration +========= + +All calls of :php:`$GLOBALS['TSFE']->ATagParams` can be replaced +with :php:`$GLOBALS['TSFE']->config['config']['ATagParams'] ?? ''`. + +.. index:: Frontend, PHP-API, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/11.5/Deprecation-95222-ExtbaseViewInterface.rst b/Documentation/Changelog/11.5/Deprecation-95222-ExtbaseViewInterface.rst new file mode 100644 index 0000000..bdd2dbf --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95222-ExtbaseViewInterface.rst @@ -0,0 +1,93 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95222: + +=========================================== +Deprecation: #95222 - Extbase ViewInterface +=========================================== + +See :issue:`95222` + +Description +=========== + +To further streamline Fluid view-related class inheritance and dependencies, +the interface :php:`TYPO3\CMS\Extbase\Mvc\View\ViewInterface` has been marked +as deprecated and will be removed in TYPO3 v12. + +Impact +====== + +This deprecation has minimal impact on TYPO3 v11: + +* The interface remains available in the Core without triggering + a E_USER_DEPRECATED warning. +* ViewInterface primarily differs from other view-related classes by + requiring an implementation of :php:`initializeView()`, a method that was + never actively used within TYPO3's Core. This method should not be confused + with :php:`initializeView()` in Extbase controllers, which is frequently + implemented by developers and serves a different purpose. The removal + of :php:`initializeView()` only affects view-related logic and does not + impact controller initialization. +* Another deviation is the method :php:`setControllerContext()`, which is + also deprecated because :php:`ControllerContext` itself is marked + as deprecated. + +Affected Installations +====================== + +The extension scanner will detect usages of Extbase :php:`ViewInterface` as a +strong match. + +Migration +========= + +Adjusting initializeView() method signature in controllers +---------------------------------------------------------- + +Some extensions may rely on :php:`ViewInterface` type hints, particularly in +the :php:`initializeView()` method of Extbase action controllers. The default +implementation of :php:`initializeView()` in :php:`ActionController` is empty. + +In TYPO3 v12: + +* This empty method will be removed from :php:`ActionController`. +* However, if an :php:`initializeView()` method exists in a subclass of + :php:`ActionController`, it will still be called. +* Extension authors should not call :php:`parent::initializeView($view)`, as + this parent method will no longer exist. +* The method signature should be updated to prevent PHP + contravariance violations: + +Old: + +.. code-block:: php + + protected function initializeView(ViewInterface $view) + +New: + +.. code-block:: php + + protected function initializeView($view) + +Replacing ViewInterface +----------------------- + +Instead of using :php:`\TYPO3\CMS\Extbase\Mvc\View\ViewInterface`, extension +authors should switch to: + +* :php:`\TYPO3\CMS\Fluid\View\StandaloneView` — typically in + non-Extbase-related classes. +* :php:`\TYPO3Fluid\Fluid\View\ViewInterface` — for a more + generic replacement. + +Handling Custom Views +--------------------- + +If an extension defines a custom view implementing :php:`ViewInterface`, note +that auto-configuration based on this interface will be removed in TYPO3 v12. +As a result, manual service configuration in :file:`Services.yaml` may +be necessary. + +.. index:: Fluid, PHP-API, FullyScanned, ext:fluid diff --git a/Documentation/Changelog/11.5/Deprecation-95235-PublicGetterOfServicesInModuleTemplate.rst b/Documentation/Changelog/11.5/Deprecation-95235-PublicGetterOfServicesInModuleTemplate.rst new file mode 100644 index 0000000..5886503 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95235-PublicGetterOfServicesInModuleTemplate.rst @@ -0,0 +1,87 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95235: + +================================================================= +Deprecation: #95235 - Public getter of services in ModuleTemplate +================================================================= + +See :issue:`95235` + +Description +=========== + +The public methods :php:`getIconFactory` and :php:`getPageRenderer` +in :php:`TYPO3\CMS\Backend\Template\ModuleTemplate` have been marked as deprecated, +since using this getters only hides the dependencies to those services. + +Impact +====== + +Calling either :php:`getIconFactory` or :php:`getPageRenderer` will +trigger a PHP :php:`E_USER_DEPRECATED` error. The extension scanner also detects +such calls as weak match. + +Affected Installations +====================== + +All installations calling the methods in custom extension code. + +Migration +========= + +Inject the corresponding services :php:`TYPO3\CMS\Core\Imaging\IconFactory` +and :php:`TYPO3\CMS\Core\Page\PageRenderer` directly in your class. + +A current Extbase backend controller might look like: + +.. code-block:: php + + class MyController extends ActionController + { + protected ModuleTemplateFactory $moduleTemplateFactory; + + public function __construct(ModuleTemplateFactory $moduleTemplateFactory) + { + $this->moduleTemplateFactory = $moduleTemplateFactory; + } + + public function myAction(): ResponseInterface + { + $moduleTemplate = $this->moduleTemplateFactory->create($this->request); + $moduleTemplate->getPageRenderer()->loadRequireJsModule('Vendor/Extension/MyJsModule'); + $moduleTemplate->setContent($moduleTemplate->getIconFactory()->getIcon('some-icon', Icon::SIZE_SMALL)->render()); + return $this->htmlResponse($moduleTemplate->renderContent()); + } + } + +This should be migrated to: + +.. code-block:: php + + class MyController extends ActionController + { + protected ModuleTemplateFactory $moduleTemplateFactory; + protected IconFactory $iconFactory; + protected PageRenderer $pageRenderer; + + public function __construct( + ModuleTemplateFactory $moduleTemplateFactory, + IconFactory $iconFactory, + PageRenderer $pageRenderer + ) { + $this->moduleTemplateFactory = $moduleTemplateFactory; + $this->iconFactory = $iconFactory; + $this->pageRenderer = $pageRenderer; + } + + public function myAction(): ResponseInterface + { + $moduleTemplate = $this->moduleTemplateFactory->create($this->request); + $this->pageRenderer->loadRequireJsModule('Vendor/Extension/MyJsModule'); + $moduleTemplate->setContent($this->iconFactory->getIcon('some-icon', Icon::SIZE_SMALL)->render()); + return $this->htmlResponse($moduleTemplate->renderContent()); + } + } + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-95254-TwoFlexFormToolsMethods.rst b/Documentation/Changelog/11.5/Deprecation-95254-TwoFlexFormToolsMethods.rst new file mode 100644 index 0000000..1ba3282 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95254-TwoFlexFormToolsMethods.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95254: + +=============================================== +Deprecation: #95254 - Two FlexFormTools methods +=============================================== + +See :issue:`95254` + +Description +=========== + +Two detail methods of class :php:`TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools` +have been marked as deprecated: + +* :php:`FlexFormTools->getArrayValueByPath()` +* :php:`FlexFormTools->setArrayValueByPath()` + + +Impact +====== + +Calling the methods will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Some instances may contain extensions calling above methods. The extension +scanner will find usages as weak match. + + +Migration +========= + +The methods can be substituted with two counterparts from +:php:`TYPO3\CMS\Core\Utility\ArrayUtility`. They exist since TYPO3 v7 already. Their +signature is slightly different, but usages should be simple to adapt: + +.. code-block:: php + + // use TYPO3\CMS\Core\Utility\ArrayUtility; + // before + $value = $flexFormTools->getArrayValueByPath('search/path', $searchArray); + // after + $value = ArrayUtility::getValueByPath($searchArray, 'search/path'); + + // before + $flexFormTools->setArrayValueByPath('set/path', $dataArray, $value); + // after + $dataArray = ArrayUtility::setValueByPath($dataArray, 'set/path', $value); + + +.. index:: FlexForm, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95257-GeneralUtilityisFirstPartOfStr.rst b/Documentation/Changelog/11.5/Deprecation-95257-GeneralUtilityisFirstPartOfStr.rst new file mode 100644 index 0000000..f62a683 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95257-GeneralUtilityisFirstPartOfStr.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95257: + +======================================================== +Deprecation: #95257 - GeneralUtility::isFirstPartOfStr() +======================================================== + +See :issue:`95257` + +Description +=========== + +The helper method +:php:`TYPO3\CMS\Core\Utility\GeneralUtility\GeneralUtility::isFirstPartOfStr()` +has been marked as deprecated, as the newly available PHP built-in +function :php:`str_starts_with()` can be used instead, which +supports proper typing and is faster on PHP 8.0. + +For PHP 7.4 installations, the dependency `symfony/polyfill-php80` +adds the PHP function in lower PHP environments, which the TYPO3 +Core ships as dependency. + + +Impact +====== + +Calling :php:`GeneralUtility::isFirstPartOfStr()` will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations using this TYPO3 API function - either via +extensions or in their own site-specific code. An analysis +via TYPO3's extension scanner will show any matches. + + +Migration +========= + +Replace all calls of :php:`GeneralUtility::isFirstPartOfStr()` with +:php:`str_starts_with()` to avoid deprecation warnings and to keep +your code up-to-date. + +See `php.net: str-starts-with <https://www.php.net/manual/en/function.str-starts-with.php>`_ for further syntax. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95261-PublicMethodsInSectionMarkupGeneratedEvents.rst b/Documentation/Changelog/11.5/Deprecation-95261-PublicMethodsInSectionMarkupGeneratedEvents.rst new file mode 100644 index 0000000..af87c69 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95261-PublicMethodsInSectionMarkupGeneratedEvents.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95261: + +===================================================================== +Deprecation: #95261 - Public methods in SectionMarkupGenerated events +===================================================================== + +See :issue:`95261` + +Description +=========== + +In TYPO3 v10, a new page module has been introduced. In this version, +administrators could choose between those two approaches by using a feature +toggle. This toggle has been removed in TYPO3 v11, making the +:php:`TYPO3\CMS\Backend\View\PageLayoutView` +unused. Two events, introduced in :issue:`88921`, however exposed this class. + +Therefore the public methods :php:`getPageLayoutView()` and +:php:`getLanguageId()` of the :php:`BeforeSectionMarkupGeneratedEvent` +and :php:`AfterSectionMarkupGeneratedEvent` have been marked as deprecated. + +Impact +====== + +Calling those methods in event listeners will trigger a PHP :php:`E_USER_DEPRECATED` error. +The extension scanner also detects those calls as weak match. + +Affected installations +====================== + +All installations using one of the mentioned methods are affected. + +Migration +========= + +Access necessary information using the new methods :php:`getPageLayoutContext()` +and :php:`getRecords()`. + +Examples for retrieving information with the new methods: + +.. code-block:: php + + // Get the language id of the column + $event->getPageLayoutContext()->getSiteLanguage()->getLanguageId(); + + // Get records of the column + $event->getRecords(); + + // Get the page record of the column + $event->getPageLayoutContext()->getPageRecord(); + + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-95275-RelationHandler-remapMM.rst b/Documentation/Changelog/11.5/Deprecation-95275-RelationHandler-remapMM.rst new file mode 100644 index 0000000..19f6917 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95275-RelationHandler-remapMM.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95275: + +================================================ +Deprecation: #95275 - RelationHandler->remapMM() +================================================ + +See :issue:`95275` + +Description +=========== + +Method :php:`TYPO3\CMS\Core\Database\RelationHandler->remapMM()` has been +marked as deprecated and will be removed with TYPO3 v12. + + +Impact +====== + +Calling above method will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected installations +====================== + +It is highly unlikely instances are affected: The method handles a detail +related to workspaces publishing and is of little use in third party extensions. +The extension scanner will find usages as weak match. + + +Migration +========= + +No direct substitution available, the method has been integrated into +:php:`TYPO3\CMS\Core\DataHandling\DataHandler`. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95293-StringUtilitystartsWithAndStringUtilityendsWith.rst b/Documentation/Changelog/11.5/Deprecation-95293-StringUtilitystartsWithAndStringUtilityendsWith.rst new file mode 100644 index 0000000..04676a2 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95293-StringUtilitystartsWithAndStringUtilityendsWith.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95293: + +=============================================================================== +Deprecation: #95293 - StringUtility::beginsWith() and StringUtility::endsWith() +=============================================================================== + +See :issue:`95293` + +Description +=========== + +The helper methods :php:`StringUtility::beginsWith()` and +:php:`StringUtility::endsWith()` have been marked as deprecated, as the newly +available PHP-built in functions :php:`str_starts_with()` and +:php:`str_ends_with()` can be used instead, which support proper typing and +is faster on PHP 8.0. + +For PHP 7.4 installations, the dependency `symfony/polyfill-php80` adds the +PHP functions in lower PHP environments, which TYPO3 Core ships as dependency +since TYPO3 v10 LTS. + + +Impact +====== + +Calling :php:`StringUtility::beginsWith()` or :php:`StringUtility::endsWith()` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations using these TYPO3 API functions - either via extensions or +in their own site-specific code. An analysis via TYPO3's extension scanner +will show any matches. + + +Migration +========= + +Replace all calls of :php:`StringUtility::beginsWith()` with +:php:`str_starts_with()` and :php:`StringUtility::endsWith()` +with :php:`str_ends_with()` to avoid deprecation warnings and to keep your +code up-to-date. + +See `php.net: str-starts-with <https://www.php.net/manual/en/function.str-starts-with.php>`_ +and `php.net: str-ends-with <https://www.php.net/manual/en/function.str-ends-with.php>`_ +for further syntax. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95317-LegacySyntaxForIRRELocalizeSynchronizeCommandInDataHandler.rst b/Documentation/Changelog/11.5/Deprecation-95317-LegacySyntaxForIRRELocalizeSynchronizeCommandInDataHandler.rst new file mode 100644 index 0000000..cd3f21a --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95317-LegacySyntaxForIRRELocalizeSynchronizeCommandInDataHandler.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95317: + +======================================================================================== +Deprecation: #95317 - Legacy syntax for IRRE localize synchronize command in DataHandler +======================================================================================== + +See :issue:`95317` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\DataHandling\DataHandler` +command :php:`inlineLocalizeSynchronize` now +triggers a PHP :php:`E_USER_DEPRECATED` error if the incoming command payload is sent +as comma-separated list rather than an array. + +The array allows to synchronize/localize multiple values at once, +which is preferred since TYPO3 v7.6, and used in TYPO3 properly +since then. + + +Impact +====== + +Calling DataHandler :php:`process_cmdmap` with an incoming +command for :php:`inlineLocalizeSynchronize` with a payload +of comma-separated values will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom code related to DataHandler +and modifying the :php:`inlineLocalizeSynchronize` command, +which is highly unlikely. This only affects special +handling of inline configuration fields. + + +Migration +========= + +See :doc:`changelog <../7.6/Important-71126-AllowToDefineMultipleInlineLocalizeSynchronizeCommands>` +for further information on how to migrate your incoming +DataHandler command. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95318-TypoScriptParseFuncsword.rst b/Documentation/Changelog/11.5/Deprecation-95318-TypoScriptParseFuncsword.rst new file mode 100644 index 0000000..1a986dc --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95318-TypoScriptParseFuncsword.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95318: + +================================================ +Deprecation: #95318 - TypoScript parseFunc.sword +================================================ + +See :issue:`95318` + +Description +=========== + +The TypoScript option :typoscript:`parseFunc.sword` allows to wrap +search words (such as defined via GET parameter :html:`sword_list%5B%5D=MySearchText`) +in a special wrap when :html:`no_cache=1` is set. This functionality has been marked as +deprecated as this feature only works in non_cached environments, which +is not a recommended solution by TYPO3. + +Since this behavior is enabled by default, it is highly recommended to avoid +this in general, which can be achieved by disabling the :html:`no_cache=1` GET parameter +in :file:`DefaultConfiguration.php`. + +Also, such an option within :typoscript:`parseFunc` does not cover all cases to highlight +a search word, such as in headlines or HTML content which is not rendered +via :typoscript:`parseFunc`. + + +Impact +====== + +Websites called via `https://example.com/?no_cache=1&sword_list%5B%5D=MySearchText` +and a custom sword wrap will trigger a PHP :php:`E_USER_DEPRECATED` error. + +As this feature is seldom used and only configured with indexed +search as desired functionality, deprecations are only triggered +when explicitly configured. + +In addition, this feature only works if :typoscript:`disableNoCacheParameter` +is disabled or :typoscript:`config.no_cache = 1` is explicitly set via TypoScript +which is also not recommended in production. + + +Affected Installations +====================== + +TYPO3 installations actively using the GET argument :html:`sword_list` and have +:html:`no_cache` as allowed GET argument enabled, usually in cases where indexed +search is in use. + + +Migration +========= + +It is recommended to implement this functionality on the client-side via +JavaScript as a custom solution, when this feature is needed. + +Setting :typoscript:`lib.parseFunc.sword` to an empty string will actively +disable the functionality and not trigger a PHP :php:`E_USER_DEPRECATED` error as well. + +Setting :typoscript:`lib.parseFunc.sword = <span class="ce-sword">|</span>` +will also not trigger a PHP :php:`E_USER_DEPRECATED` error for TYPO3 v11. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.5/Deprecation-95320-VariousMethodArgumentsInAuthenticationObjects.rst b/Documentation/Changelog/11.5/Deprecation-95320-VariousMethodArgumentsInAuthenticationObjects.rst new file mode 100644 index 0000000..020fc89 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95320-VariousMethodArgumentsInAuthenticationObjects.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95320: + +======================================================================== +Deprecation: #95320 - Various method arguments in Authentication objects +======================================================================== + +See :issue:`95320` + +Description +=========== + +The following methods of the classes +:php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` and +:php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication` have their +first argument been marked as deprecated: + +* :php:`AbstractUserAuthentication->writeUC()` +* :php:`AbstractUserAuthentication->unpack_uc()` +* :php:`BackendUserAuthentication->backendCheckLogin()` + +The following method has its third argument marked as deprecated: + +* :php:`BackendUserAuthentication->isInWebMount()` + + +Impact +====== + +Calling these methods with an explicit argument of the deprecated +arguments given will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected installations +====================== + +TYPO3 installations with custom extensions calling these methods +with the deprecated arguments which is highly unlikely. + + +Migration +========= + +Call :php:`AbstractUserAuthentication->writeUC()` without a +method argument. If you need to explicitly set a custom UC value +which is not :php:`AbstractUserAuthentication->uc`, you can set this via +:php:`AbstractUserAuthentication->uc = $myValue;` in the +line before. + +Call :php:`AbstractUserAuthentication->unpack_uc()` without an +method argument. If you need to explicitly set a custom UC value +which is not :php:`AbstractUserAuthentication->uc`, you can set this via +:php:`AbstractUserAuthentication->uc = $myValue;` in the +line before. + +Call :php:`BackendUserAuthentication->backendCheckLogin()` without +an argument but wrap this call in a :php:`try {} catch (\Throwable $e)` if +you need the old behavior and want to avoid a deprecation +message. + +Call :php:`BackendUserAuthentication->isInWebMount()` without the +third argument and check for the return value of being :php:`null` +which is the equivalent of the expected :php:`RuntimeException` being +thrown when the third argument was set to :php:`true`. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95322-LegacyElementBrowserLogic.rst b/Documentation/Changelog/11.5/Deprecation-95322-LegacyElementBrowserLogic.rst new file mode 100644 index 0000000..7bbbf82 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95322-LegacyElementBrowserLogic.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95322: + +================================================== +Deprecation: #95322 - Legacy Element Browser logic +================================================== + +See :issue:`95322` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/browse_links.php']['browserRendering']` +has been marked as deprecated as it has been superseded by the :php:`ElementBrowser` API, +introduced in TYPO3 v7.6. + +Calling the backend routing endpoint "wizard_element_browser" +called via :html:`?mode=wizard` or :html:`?mode=rte` has been marked as deprecated. + + +Impact +====== + +Calling the backend routing endpoint "wizard_element_browser" +called via :html:`?mode=wizard` or :html:`?mode=rte` will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Accessing the Element Browser with a registered hook will also +trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with legacy code (such as an old element +browser hook) or with old links to "wizard_element_browser" +prior to TYPO3 v8 which hasn't been updated yet. + + +Migration +========= + +Use the Element Browser API, introduced in TYPO3 v7.6 instead of the +deprecated hook +`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/browse_links.php']['browserRendering']`. + +Instead of referencing "wizard_element_browser" for accessing +the wizard, the link wizard with BE Routing Endpoint "wizard_link" +should be used. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:recordlist diff --git a/Documentation/Changelog/11.5/Deprecation-95326-VariousGetInstanceStaticMethodsOnSingletonInterfaces.rst b/Documentation/Changelog/11.5/Deprecation-95326-VariousGetInstanceStaticMethodsOnSingletonInterfaces.rst new file mode 100644 index 0000000..2878e57 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95326-VariousGetInstanceStaticMethodsOnSingletonInterfaces.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95326: + +==================================================================================== +Deprecation: #95326 - Various "getInstance()" static methods on singleton interfaces +==================================================================================== + +See :issue:`95326` + +Description +=========== + +A few classes within TYPO3 Core have a static method :php:`getInstance()` +which acts as a wrapper for the constructor which originally was meant as +a performance improvement as pseudo-singleton concept in TYPO3 v6. + +With dependency injection, these classes can be injected or instantiated +directly without any performance penalties. + +Therefore the following methods have been marked as deprecated: + +* :php:`TYPO3\CMS\Core\Resource\Index\ExtractorRegistry::getInstance()` +* :php:`TYPO3\CMS\Core\Resource\Index\FileIndexRepository::getInstance()` +* :php:`TYPO3\CMS\Core\Resource\Index\MetaDataRepository::getInstance()` +* :php:`TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry::getInstance()` +* :php:`TYPO3\CMS\Core\Resource\Rendering\RendererRegistry::getInstance()` +* :php:`TYPO3\CMS\Core\Resource\TextExtraction\TextExtractorRegistry::getInstance()` +* :php:`TYPO3\CMS\Form\Service\TranslationService::getInstance()` +* :php:`TYPO3\CMS\T3editor\Registry\AddonRegistry::getInstance()` +* :php:`TYPO3\CMS\T3editor\Registry\ModeRegistry::getInstance()` + + +Impact +====== + +Calling the methods directly in third-party PHP code will trigger a PHP +:php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +Any TYPO3 installation with custom PHP code calling the methods are affected. + + +Migration +========= + +Check :guilabel:`Admin Tools > Upgrade > Scan Extension Files` if your +installation is affected and replace calls with constructor injections via +dependency injection if possible, or use +:php:`GeneralUtility::makeInstance()` instead. + +.. index:: PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95343-LegacyHookForNewContentElementWizard.rst b/Documentation/Changelog/11.5/Deprecation-95343-LegacyHookForNewContentElementWizard.rst new file mode 100644 index 0000000..6de48f5 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95343-LegacyHookForNewContentElementWizard.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95343: + +================================================================ +Deprecation: #95343 - Legacy hook for new content element wizard +================================================================ + +See :issue:`95343` + +Description +=========== + +The hook :php:`$GLOBALS['TBE_MODULES_EXT']['xMOD_db_new_content_el']['addElClasses']` +which has been used primarily back in TYPO3 v4.x times with the extension +kickstarter for pi-based plugins has been marked as deprecated. + + +Impact +====== + +When an extension is registering a hook, and the +:guilabel:`Create new content element` wizard is called, a PHP :php:`E_USER_DEPRECATED` error is triggered. + + +Affected installations +====================== + +TYPO3 installations with third-party extensions using this hook. + + +Migration +========= + +The alternative hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms']['db_new_content_el']['wizardItemsHook']` +can be used instead, which allows to modify and add wizard items +as well. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/11.5/Deprecation-95349-TypoScriptPageincludeCSSincludeCSSLibsimport.rst b/Documentation/Changelog/11.5/Deprecation-95349-TypoScriptPageincludeCSSincludeCSSLibsimport.rst new file mode 100644 index 0000000..17849e1 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95349-TypoScriptPageincludeCSSincludeCSSLibsimport.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95349: + +======================================================================= +Deprecation: #95349 - TypoScript: page.includeCSS/includeCSSLibs.import +======================================================================= + +See :issue:`95349` + +Description +=========== + +The option to use the :css:`@import` syntax for including +external CSS files through TypoScript has been marked as deprecated. + +This was possible through: + +.. code-block:: typoscript + + page = PAGE + page.includeCSSLibs.file1 = fileadmin/benni.css + page.includeCSSLibs.file1.import = 1 + +Through the "import = 1" option the output was + +.. code-block:: html + + <style> + @import url('fileadmin/benni.css'); + </style> + + +Impact +====== + +A PHP :php:`E_USER_DEPRECATED` error is triggered when having the :typoscript:`import = 1` +flag enabled in TypoScript on :typoscript:`includeCSS` or +:typoscript:`includeCSSLibs` properties. + + +Affected installations +====================== + +TYPO3 installations with the TypoScript settings + +:typoscript:`page.includeCSS.aFile.import = 1` +:typoscript:`page.includeCSSLibs.aFile.import = 1` + +enabled are affected. + + +Migration +========= + +Using the :html:`<link>` tag syntax, which is the de-facto standard syntax these days, +allows to load a file directly when interpreting the HTML of the +browser, instead of first interpreting the HTML, then the CSS +and have a blocking call to an external URL to continue interpreting the CSS. + +It is recommended to use the :html:`<link>` tag or create an inlineCSS TypoScript +manually to load such a file with the :css:`@import` syntax. + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.5/Deprecation-95351-CustomJSWindowOptionsInHMENUSettings.rst b/Documentation/Changelog/11.5/Deprecation-95351-CustomJSWindowOptionsInHMENUSettings.rst new file mode 100644 index 0000000..1446596 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95351-CustomJSWindowOptionsInHMENUSettings.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95351: + +=============================================================== +Deprecation: #95351 - Custom JSWindow options in HMENU settings +=============================================================== + +See :issue:`95351` + +Description +=========== + +The common HMENU settings for each HMENU level :typoscript:`JSWindow` (including +sub-properties) and :typoscript:`target` with a value such as +:typoscript:`target = 200x300`, to be set on for example TMENU properties +have been marked as deprecated. + +Examples: + +.. code-block:: php + + page.123 = HMENU + page.123.1 = TMENU + page.123.1.JSWindow = 1 + page.123.1.JSWindow.params = width=200,height=300,status=0,menubar=0 + + page.123 = HMENU + page.123.1 = TMENU + page.123.1.target = 200x300 + + +Impact +====== + +Calling a frontend page with a HMENU and JSwindow popups will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with a HMENU and JSwindow settings which are configured +via TypoScript, which is highly unlikely. + + +Migration +========= + +Use an external JavaScript file with an event listener to achieve the same +functionality. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/11.5/Deprecation-95367-GeneralUtilityisAbsPath.rst b/Documentation/Changelog/11.5/Deprecation-95367-GeneralUtilityisAbsPath.rst new file mode 100644 index 0000000..35fec61 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95367-GeneralUtilityisAbsPath.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95367: + +================================================= +Deprecation: #95367 - GeneralUtility::isAbsPath() +================================================= + +See :issue:`95367` + +Description +=========== + +The low-level TYPO3 API method +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::isAbsPath()` +has been marked as deprecated. + + +Impact +====== + +Calling the method in your own PHP code will trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected Installations +====================== + +TYPO3 installations with custom extensions calling this PHP +method are affected. You can check if you are affected via the Extension +Scanner tool provided in the Install Tool. + + +Migration +========= + +Replace any calls to :php:`GeneralUtility::isAbsPath()` with +the exact equivalent :php:`TYPO3\CMS\Core\Utility\PathUtility::isAbsolutePath()` +which checks for the same input. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Deprecation-95395-GeneralUtilityIsAllowedHostHeaderValueAndTrustedHostsPatternConstants.rst b/Documentation/Changelog/11.5/Deprecation-95395-GeneralUtilityIsAllowedHostHeaderValueAndTrustedHostsPatternConstants.rst new file mode 100644 index 0000000..4385cc9 --- /dev/null +++ b/Documentation/Changelog/11.5/Deprecation-95395-GeneralUtilityIsAllowedHostHeaderValueAndTrustedHostsPatternConstants.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95395: + +==================================================================================================== +Deprecation: #95395 - GeneralUtility::isAllowedHostHeaderValue() and TRUSTED_HOSTS_PATTERN constants +==================================================================================================== + +See :issue:`95395` + +Description +=========== + +The PHP method +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::isAllowedHostHeaderValue()` +and the PHP constants +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_ALLOW_ALL` +and +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_SERVER_NAME` +have been deprecated. + + +Impact +====== + +A deprecation will be logged in TYPO3 v11 if +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::isAllowedHostHeaderValue()` is +used. It is unlikely for extensions to have used this as the host header +is checked for every frontend and backend request anyway. + +Usage of the constants will cause a PHP error "Undefined class constant" in +TYPO3 v12, the method +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::isAllowedHostHeaderValue()` will be +dropped without replacement. + + +Affected Installations +====================== + +Installations using the constants instead of static strings or +that call the method explicitly – which is unlikely. + + +Migration +========= + +Use :php:`'.*'` instead of +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_ALLOW_ALL` +and :php:`'SERVER_NAME'` instead of +:php:`TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_SERVER_NAME`. + +Don't use :php:`TYPO3\CMS\Core\Utility\GeneralUtility::isAllowedHostHeaderValue()`. + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/11.5/Feature-94868-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst b/Documentation/Changelog/11.5/Feature-94868-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst new file mode 100644 index 0000000..55b99cf --- /dev/null +++ b/Documentation/Changelog/11.5/Feature-94868-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _feature-94868: + +=========================================================================== +Feature: #94868 - Introduce Bootstrap 5 compatible and accessible templates +=========================================================================== + +See :issue:`94868` + +Description +=========== + +Until now, CSS classes of the frontend templates and partials of the Form +Framework were not consistently included in the form configuration. So far +some classes were present in the form configuration, others were hardcoded +only in the Fluid templates. + +This situation has now been fixed for the Bootstrap 5 compatible template +variants stored in :file:`EXT:form/Resources/Private/Frontend/Version2`. +All CSS classes are consistently defined in the form configuration. + +This simplifies the integration of the frontend. The change makes it easier for +integrators to make upgrades of the frontend framework. In most cases, it is +now no longer necessary to override a Fluid template for changes to classes. +Instead, it is only necessary to add the appropriate CSS classes to the +form configuration. + +In order not to be breaking, by default the templates are still rendered +as they used to be. + +To use the new Bootstrap 5 compatible templates the form rendering option +:yaml:`templateVariant` must be set from :yaml:`version1` to :yaml:`version2` in your form setup: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Form: + renderingOptions: + templateVariant: version2 + +The CSS classes for the Bootstrap 5 compatible templates are defined +in the variant with the name :yaml:`template-variant` for each form element. +This is an example of the text element: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Text: + variants: + - + identifier: template-variant + condition: 'getRootFormProperty("renderingOptions.templateVariant") == "version2"' + properties: + containerClassAttribute: 'form-element form-element-text mb-3' + elementClassAttribute: form-control + labelClassAttribute: form-label + +To be able to access the configuration of the root element (type "Form") +in conditions, a new function :php:`getRootFormProperty()` has been introduced, +which can be used to access the properties of the "Form" element. +In the context of the "template-variant" variants this is used to determine +the template variant defined on the "Form" element in order to change the +CSS configuration properties or to add new ones. + +In the course of Bootstrap 5 compatibility two new breakpoints "xl" and +"xxl" were added to the grid configuration which are also available in the +form editor. + +.. index:: Frontend, ext:form diff --git a/Documentation/Changelog/11.5/Feature-95176-IntroduceFtransformhtmlView-helper.rst b/Documentation/Changelog/11.5/Feature-95176-IntroduceFtransformhtmlView-helper.rst new file mode 100644 index 0000000..e000a46 --- /dev/null +++ b/Documentation/Changelog/11.5/Feature-95176-IntroduceFtransformhtmlView-helper.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _feature-95176: + +========================================================== +Feature: #95176 - Introduce <f:transform.html> view helper +========================================================== + +See :issue:`95176` + +Description +=========== + +Using Fluid view-helper :html:`<f:format.html>` provides capabilities to +resolve `t3://` URIs, which is used in backend contexts as well. Internally +:html:`<f:format.html>` relies on an existing frontend context, with +corresponding TypoScript configuration in :typoscript:`lib.parseFunc` being given. + +In order to separate concerns better, a new :html:`<f:transform.html>` +view helper has been introduced + +* to be used in frontend and backend context without relying on TypoScript, +* to avoid mixing parsing, sanitization and transformation concerns in + previously used :php:`ContentObjectRenderer::parseFunc` method of the + frontend rendering process. + +Impact +====== + +Individual TYPO3 link handlers (like `t3://` URIs) can be resolved and +substituted without relying on TypoScript configuration and without mixing +concerns in :php:`ContentObjectRenderer::parseFunc` by using Fluid view-helper +:html:`<f:transform.html>`. + +Syntax +------ + +:html:`<f:transform.html selector="[ node.attr, node.attr ]" onFailure="[ behavior ]">` + +* `selector`: (optional) comma separated list of node attributes to be considered, + for example `subjects="a.href,a.data-uri,img.src"` (default `a.href`) +* `onFailure` (optional) corresponding behavior, in case transformation failed, for example + URI was invalid or could not be resolved properly (default `removeEnclosure`). + Based on example :html:`<a href="t3://INVALID">value</a>`. corresponding results + of each behavior would be like this: + + + `removeEnclosure`: :html:`value` (removed enclosing tag) + + `removeTag`: :html:`` (removed tag, incl. child nodes) + + `removeAttr`: :html:`<a>value</a>` (removed attribute) + + `null`: :html:`<a href="t3://INVALID">value</a>` (unmodified, as given) + +Example +------- + +.. code-block:: html + + <f:transform.html selector="a.href,div.data-uri"> + <a href="t3://page?uid=1" class="page">visit</a> + <div data-uri="t3://page?uid=1" class="page trigger">visit</div> + </f:transform.html> + +... will be resolved and transformed to the following markup ... + +.. code-block:: html + + <a href="https://typo3.localhost/" class="page">visit</a> + <div data-uri="https://typo3.localhost/" class="page trigger">visit</div> + +.. index:: Backend, Fluid, Frontend, ext:fluid diff --git a/Documentation/Changelog/11.5/Feature-95364-EventToModifyFrontendUserGroupsWithoutAuthentication.rst b/Documentation/Changelog/11.5/Feature-95364-EventToModifyFrontendUserGroupsWithoutAuthentication.rst new file mode 100644 index 0000000..d3a2c49 --- /dev/null +++ b/Documentation/Changelog/11.5/Feature-95364-EventToModifyFrontendUserGroupsWithoutAuthentication.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-95364: + +============================================================================= +Feature: #95364 - Event to modify frontend user groups without authentication +============================================================================= + +See :issue:`95364` + +Description +=========== + +Prior to TYPO3 v11, the "getGroupsFE" authentication service +allowed to add and manipulate frontend user groups to be attached +to a FrontendUserAuthentication request during runtime. + +Extensions use this approach to attach certain properties for +customization (for example country or region of a website user) +dynamically for a specific request. + +This functionality was removed during the refactoring of the +authentication services (see :issue:`93108`). + +A new Event :php:`ModifyResolvedFrontendGroupsEvent` has now been +introduced to modify user groups, even if there is no +authenticated user in place. + + +Impact +====== + +Use the new PSR-14 event to attach frontend user groups dynamically +during a frontend request. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/11.5/Important-95261-NewPublicMethodsInSectionMarkupGeneratedEvents.rst b/Documentation/Changelog/11.5/Important-95261-NewPublicMethodsInSectionMarkupGeneratedEvents.rst new file mode 100644 index 0000000..c2dcac7 --- /dev/null +++ b/Documentation/Changelog/11.5/Important-95261-NewPublicMethodsInSectionMarkupGeneratedEvents.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _important-95261: + +======================================================================= +Important: #95261 - New public methods in SectionMarkupGenerated events +======================================================================= + +See :issue:`95261` + +Description +=========== + +With :issue:`88921`, two new events had been introduced. Those can be used to +add additional content to the columns in the page layout module. Due to the +different approach and the different code base of the new Fluid-based page +module, transforming the backend to always use the new approach in TYPO3 v11 +also required to extend those events for two new public methods. + +The new :php:`getPageLayoutContext()` should be used as a direct replacement +for the deprecated :php:`getPageLayoutView()` method, as it contains nearly +the same information, except for the records of the current column. This +information can from now on be retrieved using the new :php:`getRecords()` +method. + +.. note:: + + Due to the nature of the new Fluid-based page module, the content + added through the events is now always displayed. Previously this + was only possible in the columns mode. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/11.5/Important-95298-FluidViewhelpersWillBeDeclaredFinalInV12.rst b/Documentation/Changelog/11.5/Important-95298-FluidViewhelpersWillBeDeclaredFinalInV12.rst new file mode 100644 index 0000000..07a86dc --- /dev/null +++ b/Documentation/Changelog/11.5/Important-95298-FluidViewhelpersWillBeDeclaredFinalInV12.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _important-95298: + +=================================================================== +Important: #95298 - Fluid ViewHelpers will be declared final in v12 +=================================================================== + +See :issue:`95298` + +Description +=========== + +This is a notice for an upcoming change in TYPO3 v12: + +All Fluid ViewHelper classes delivered by Core extensions will be declared +:php:`final` in TYPO3 v12, third party extensions can no longer extend them +with own variants. + +The Core takes this step to clarify that single ViewHelpers are not part of the +PHP API, their internal handling may change any time, which is not considered +breaking. Fluid delivers a series of abstract classes to provide base functionality +for common ViewHelper needs. Those can be used by third party ViewHelpers if +not marked :php:`@internal`. TYPO3 v12 will fine-tune these abstracts and may +extract specific ViewHelper code to abstracts if the code is generally useful +for extension developers with own view-helpers. + +Using ViewHelpers provided by Core extensions in Fluid templates is of course +fine as long as they are not marked :php:`@internal`. Arguments to casual ViewHelpers +are considered API and are subject of the general Core deprecation strategy. In +general, the base extensions `EXT:fluid`, `EXT:core`, `EXT:frontend` and `EXT:backend` +deliver various general purpose ViewHelpers that can be used, while specific extensions +like `EXT:beuser` add :php:`@internal` ViewHelpers that should not be used in own templates. + +Developers are encouraged to adapt own ViewHelpers towards this change with +TYPO3 v11 compatible extensions already, it will simplify compatibility with TYPO3 v12 later. + +.. index:: Fluid, PHP-API, ext:fluid diff --git a/Documentation/Changelog/11.5/Important-95384-TCAInternal_typedbOptionalForTypegroup.rst b/Documentation/Changelog/11.5/Important-95384-TCAInternal_typedbOptionalForTypegroup.rst new file mode 100644 index 0000000..734b0ad --- /dev/null +++ b/Documentation/Changelog/11.5/Important-95384-TCAInternal_typedbOptionalForTypegroup.rst @@ -0,0 +1,21 @@ +.. include:: /Includes.rst.txt + +.. _important-95384: + +================================================================ +Important: #95384 - TCA internal_type=db optional for type=group +================================================================ + +See :issue:`95384` + +Description +=========== + +The TCA option :php:`internal_type` of TCA type :php:`group` defines which type +of record can be referenced. Valid values are :php:`folder` and :php:`db`. + +Since :php:`db` is the most common use case, TYPO3 now uses this as default. +Extension authors can therefore remove the :php:`internal_type=db` option +from TCA type :php:`group` fields. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/11.5/Index.rst b/Documentation/Changelog/11.5/Index.rst new file mode 100644 index 0000000..1792293 --- /dev/null +++ b/Documentation/Changelog/11.5/Index.rst @@ -0,0 +1,54 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-11-5: + +============ +11.5 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +================ + +None since TYPO3 v11.0 release. + +.. attention:: + + After TYPO3 v11.0, only new functionality with a solid migration path can be added on top, + with aiming for as little as possible breaking changes after the initial v11.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.rst b/Documentation/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.rst new file mode 100644 index 0000000..d7dc4a1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-87616: + +======================================================= +Breaking: #87616 - Removed hook for altering page links +======================================================= + +See :issue:`87616` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typolinkProcessing']['typolinkModifyParameterForPageLinks']` +has been removed in favor of a new PSR-14 event :php:`TYPO3\CMS\Frontend\Event\ModifyPageLinkConfigurationEvent`. + +The event is called after TYPO3 has already prepared some functionality +within the :php:`PageLinkBuilder`. This therefore allows to modify more +properties, if needed. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v12.0+. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-87616-PSR-14EventForModifyingPageLinkGeneration>` +to allow greater influence in the functionality. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-90044-ConfigspamProtectEmailAddressesWithOptionAsciiRemoved.rst b/Documentation/Changelog/12.0/Breaking-90044-ConfigspamProtectEmailAddressesWithOptionAsciiRemoved.rst new file mode 100644 index 0000000..6df0e90 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-90044-ConfigspamProtectEmailAddressesWithOptionAsciiRemoved.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-90044: + +=============================================================================== +Breaking: #90044 - config.spamProtectEmailAddresses with option "ascii" removed +=============================================================================== + +See :issue:`90044` + +Description +=========== + +The TypoScript setting :typoscript:`config.spamProtectEmailAddresses` set to `ascii` has no +effect anymore as the ASCII-encryption feature has been removed. + +The option changed any links to emails like `href="mailto:benni@example.com"` +to point to the ASCII-encoded equivalent. Since all browsers (and most bots/crawlers) +do this automatically and instantly this feature has no spam-protection +relevance anymore. + +Impact +====== + +Setting the option to `ascii` has no effect anymore, which is the same as not +setting the option at all. However, in case the option is set to `ascii` a +PHP :php:`E_USER_DEPRECATED` error is raised. + +Affected Installations +====================== + +TYPO3 installations having this option set in their TypoScript setup. + +Migration +========= + +In case you still want to keep an email SPAM protection around, it is recommended +to set the option :typoscript:`config.spamProtectEmailAddresses` to a numeric value between +`-10` and `10`. + +Alternatively, there is an extension called `emailobfuscator` available in the +TYPO3 Extension Repository, which also aims to achieve a similar behaviour. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-92508-RemovedHookForFilteringHMENUItems.rst b/Documentation/Changelog/12.0/Breaking-92508-RemovedHookForFilteringHMENUItems.rst new file mode 100644 index 0000000..846cb68 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-92508-RemovedHookForFilteringHMENUItems.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-92508: + +========================================================= +Breaking: #92508 - Removed hook for filtering HMENU items +========================================================= + +See :issue:`92508` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/tslib/class.tslib_menu.php']['filterMenuPages']` +has been removed in favor of a new PSR-14 event :php:`TYPO3\CMS\Frontend\Event\FilterMenuItemsEvent`. + +The event is called with all menu items instead of operating on +one single item. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v12.0+. + +Affected Installations +====================== + +TYPO3 installations with custom menus using this hook. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-92508-PSR-14EventForModifyingMenuItems>` +to allow greater influence in the functionality. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-93182-ChangedFileExtensionForGzipCompressedFiles.rst b/Documentation/Changelog/12.0/Breaking-93182-ChangedFileExtensionForGzipCompressedFiles.rst new file mode 100644 index 0000000..2ffd890 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-93182-ChangedFileExtensionForGzipCompressedFiles.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-93182-1651654104: + +=================================================================== +Breaking: #93182 - Changed file extension for gzip compressed files +=================================================================== + +See :issue:`93182` + +Description +=========== + +When using file compression for resources such as JavaScript or +StyleSheets via :php:`$GLOBALS[TYPO3_CONF_VARS][FE][compressionLevel]` or +:php:`$GLOBALS[TYPO3_CONF_VARS][BE][compressionLevel]` the generated files are +now written via the file extension ".gz" instead of ".gzip" in previous versions. + +TYPO3 follows the de-facto standard for compressed assets, +as ".gz" is much more widespread than ".gzip" file extensions +(see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types). + +Impact +====== + +Compressed resources are now generated and served via ".gz". + +Affected installations +====================== + +TYPO3 installations setting the global configuration option. + +Migration +========= + +Adapt possible :file:`.htaccess` or other webserver configuration files +by replacing ".gzip" with ".gz" if this feature is activated. + +.. index:: Backend, Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-94117-RegisterExtbaseTypeConvertersAsServices.rst b/Documentation/Changelog/12.0/Breaking-94117-RegisterExtbaseTypeConvertersAsServices.rst new file mode 100644 index 0000000..87b4758 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-94117-RegisterExtbaseTypeConvertersAsServices.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _breaking-94117: + +=============================================================== +Breaking: #94117 - Register Extbase type converters as services +=============================================================== + +See :issue:`94117` + +Description +=========== + +Extbase type converters are used to convert from a simple type to an +object or another simple type. The registration of those type converters +is no longer done via :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()`, +but via container services in the extension's :file:`Services.yaml` file. + +As a side effect, the type converter configuration such as `sourceType` or +`targetType` has been moved from the :php:`TypeConverterInterface` to the +service container configuration. + +Impact +====== + +Type converters registered via :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()` +are no longer evaluated. + +The :php:`TypeConverterInterface` does no longer define the configuration +related methods: + +- :php:`getSupportedSourceTypes()` +- :php:`getSupportedTargetType()` +- :php:`getPriority()` +- :php:`canConvertFrom()` + +Affected Installations +====================== + +All installations that do not register type converters via :php:`Services.yaml`. + +All installations, which rely on the configuration related methods, being +defined in the :php:`TypeConverterInterface`. + +Migration +========= + +Remove registration via :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()` +from your :file:`ext_localconf.php` file and register the type converters +in your :php:`Services.yaml` instead. See :doc:`changelog <../12.0/Feature-94117-ImproveExtbaseTypeConverterRegistration>` +for an example. + +Remove any call to the configuration related methods, see the +:doc:`deprecation changelog <../12.0/Deprecation-94117-RegisterExtbaseTypeConvertersAsServices>` +for more information. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst b/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst new file mode 100644 index 0000000..068bdc0 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-94243-SendUserSessionCookiesAsHash-signedJWT.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-94243-1664786038: + +=============================================================== +Breaking: #94243 - Send user session cookies as hash-signed JWT +=============================================================== + +See :issue:`94243` + +Description +=========== + +`JSON Web Tokens (JWT) <https://jwt.io/>`__ are used to transport user session +identifiers in `be_typo_user` and `fe_typo_user` cookies. Using JWT's `HS256` +(HMAC signed based on SHA256) allows to determine whether a session cookie is +valid before comparing with server-side stored session data. This enhances the +overall performance a bit, since sessions cookies would be checked for every +request to TYPO3's backend and frontend. + +JWT handling in PHP is provided by 3rd party package +`firebase/php-jwt <https://packagist.org/packages/firebase/php-jwt>`__. + + +Impact +====== + +Session cookies `be_typo_user` and `fe_typo_user` can be pre-validated without +querying the database, which can filter invalid requests and might reduce the +enhances the overall performance a bit. + +As a consequence session tokens are not sent "as is" anymore, but are +wrapped in a corresponding JWT message, which contains the following payload: + +* `identifier` reflects the actual session identifier +* `time` reflects the time of creating the cookie (RFC 3339 format) + + +Affected installations +====================== + +All instances using TYPO3 v12 and having custom implementations handling `be_typo_user` +and `fe_typo_user` cookie values. + + +Migration +========= + +Custom implementations handling `be_typo_user` or `fe_typo_user` cookies, +have to use the introduced method :php:`\TYPO3\CMS\Core\Session\UserSession::getJwt()` +instead of existing :php:`\TYPO3\CMS\Core\Session\UserSession::getIdentifier()`. + + +.. index:: Backend, Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-95132-SetPasswordForgotHashBasedOnUserUidInExtfelogin.rst b/Documentation/Changelog/12.0/Breaking-95132-SetPasswordForgotHashBasedOnUserUidInExtfelogin.rst new file mode 100644 index 0000000..d21a576 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-95132-SetPasswordForgotHashBasedOnUserUidInExtfelogin.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-95132-1659375274: + +============================================================================ +Breaking: #95132 - Set password forgot hash based on user uid in ext:felogin +============================================================================ + +See :issue:`95132` + +Description +=========== + +The signature of the :php:`sendRecoveryEmail()` function in the +:php:`TYPO3\CMS\FrontendLogin\Service\RecoveryService` has changed. The function +now requires 2 arguments in order to support scenarios for multi-site TYPO3 +setups with multiple storage folders for users with the same email address. + +Additionally, the :php:`RecoveryService` class now does not implement +:php:`TYPO3\CMS\FrontendLogin\Service\RecoveryServiceInterface` any more, since +the interface has been removed. + +Impact +====== + +3rd party extensions implementing :php:`RecoveryService` have to be adapted +manually to support the new function signature. + +3rd party extensions implementing :php:`RecoveryServiceInterface` have to be +adapted manually to extend :php:`RecoveryService` instead. + +Affected installations +====================== + +3rd party extensions implementing :php:`RecoveryService` and +:php:`RecoveryServiceInterface`. + +Migration +========= + +Custom implementations of :php:`RecoveryService` must be adopted to support the new +function signature :php:`sendRecoveryEmail(array $userData, string $hash)`. + +Custom implementations of :php:`RecoveryServiceInterface` must be adopted to +extend :php:`RecoveryService` instead. + +.. index:: Frontend, PHP-API, NotScanned, ext:felogin diff --git a/Documentation/Changelog/12.0/Breaking-96041-ToolbarItemsRegisterByTag.rst b/Documentation/Changelog/12.0/Breaking-96041-ToolbarItemsRegisterByTag.rst new file mode 100644 index 0000000..de85fac --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96041-ToolbarItemsRegisterByTag.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96041: + +================================================= +Breaking: #96041 - Toolbar items: Register by tag +================================================= + +See :issue:`96041` + +Description +=========== + +Toolbar items implementing :php:`\TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface` are now automatically +registered by adding the tag :yaml:`backend.toolbar.item`, if :yaml:`autoconfigure` +is enabled in :file:`Services.yaml`. + +Impact +====== + +The registration via :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['toolbarItems']` isn't evaluated anymore. + +Affected Installations +====================== + +Every extension, that adds toolbar items via :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['toolbarItems']` +in its :file:`ext_localconf.php` file. + +Migration +========= + +Remove :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['toolbarItems']` from your :file:`ext_localconf.php` file. +If :yaml:`autoconfigure` is not enabled in your :file:`Configuration/Services.(yaml|php)`, add the tag :yaml:`backend.toolbar.item` to your toolbar item class. + +Example: + +.. code-block:: yaml + + VENDOR\Extension\ToolbarItem\YourAdditionalToolbarItem: + tags: + - name: backend.toolbar.item + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96044-HardenMethodSignatureOfLogicalAndAndLogicalOr.rst b/Documentation/Changelog/12.0/Breaking-96044-HardenMethodSignatureOfLogicalAndAndLogicalOr.rst new file mode 100644 index 0000000..eb47a3b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96044-HardenMethodSignatureOfLogicalAndAndLogicalOr.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96044: + +========================================================================== +Breaking: #96044 - Harden method signature of logicalAnd() and logicalOr() +========================================================================== + +See :issue:`96044` + +Description +=========== + +The method signature of :php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface::logicalAnd()` +and :php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface::logicalOr()` has changed. +As a consequence the method signature of :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Query::logicalAnd()` +and :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Query::logicalOr()` has changed as well. + +Both methods do no longer accept an array as first parameter. + +Both methods do indeed accept an infinite number of further constraints. + +The :php:`logicalAnd()` method does now reliably return an instance of +:php:`\TYPO3\CMS\Extbase\Persistence\Generic\Qom\AndInterface` instance +while the :php:`logicalOr()` method returns a :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Qom\OrInterface` +instance. + +Impact +====== + +This change impacts all usages of said methods with just one array parameter containing all constraints. + +Affected Installations +====================== + +All installations that passed all constraints as array. + +Migration +========= + +The migration is the same for :php:`logicalAnd()` and :php:`logicalOr()` +since their method signature is the same. The upcoming example will show a +migration for a :php:`logicalAnd()` call. + +**Example**: + +.. code-block:: php + + $query = $this->createQuery(); + $query->matching($query->logicalAnd([ + $query->equals('propertyName1', 'value1'), + $query->equals('propertyName2', 'value2'), + $query->equals('propertyName3', 'value3'), + ])); + +In this case an array is used as one and only method argument. The migration is +easy and quickly done. Simply don't use an array: + +.. code-block:: php + + $query = $this->createQuery(); + $query->matching($query->logicalAnd( + $query->equals('propertyName1', 'value1'), + $query->equals('propertyName2', 'value2'), + $query->equals('propertyName3', 'value3'), + )); + +Alternatively you can use the spread operator :php:`...` to expand your array to arguments: + +.. code-block:: php + + $query = $this->createQuery(); + $arrayOfConditions = []; + $arrayOfConditions[] = $query->equals('propertyName1', 'value1'); + $arrayOfConditions[] = $query->equals('propertyName2', 'value2'); + $arrayOfConditions[] = $query->equals('propertyName3', 'value3'); + $query->matching($query->logicalAnd(...$arrayOfConditions)); + +.. tip:: + + Wrapping the array to spread into :php:`logicalAnd()` using :php:`array_values()` avoids + spreading associative array element keys as as named arguments, for example: + :php:`$query->matching($query->logicalAnd(...array_values($arrayOfConditions)));` + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-96094-ModuleIconsRemoved.rst b/Documentation/Changelog/12.0/Breaking-96094-ModuleIconsRemoved.rst new file mode 100644 index 0000000..fcd3ec9 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96094-ModuleIconsRemoved.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96094: + +======================================= +Breaking: #96094 - Module icons removed +======================================= + +See :issue:`96094` + +Description +=========== + +The following module icons are removed as they are not needed anymore +by TYPO3 itself. You can find the according icon identifier in parenthesis. + +* :file:`EXT:backend/Resources/Public/Icons/module-about.svg` (`module-about`) +* :file:`EXT:backend/Resources/Public/Icons/module-contentelements.svg` (`module-contentelements`) +* :file:`EXT:backend/Resources/Public/Icons/module-cshmanual.svg` (`module-cshmanual`) +* :file:`EXT:backend/Resources/Public/Icons/module-page.svg` (`module-page`) +* :file:`EXT:backend/Resources/Public/Icons/module-sites.svg` (`module-sites`) +* :file:`EXT:backend/Resources/Public/Icons/module-templates.svg` (`module-templates`) +* :file:`EXT:backend/Resources/Public/Icons/module-urls.svg` (`module-urls`) +* :file:`EXT:belog/Resources/Public/Icons/module-belog.svg` (`module-belog`) +* :file:`EXT:beuser/Resources/Public/Icons/module-beuser.svg` (`module-beuser`) +* :file:`EXT:beuser/Resources/Public/Icons/module-permission.svg` (`module-permission`) +* :file:`EXT:extensionmanager/Resources/Public/Icons/module-extensionmanager.svg` (`module-extensionmanager`) +* :file:`EXT:filelist/Resources/Public/Icons/module-filelist.svg` (`module-filelist`) +* :file:`EXT:form/Resources/Public/Icons/module-form.svg` (`module-form`) +* :file:`EXT:indexed_search/Resources/Public/Icons/module-indexed_search.svg` (`module-indexed_search`) +* :file:`EXT:info/Resources/Public/Icons/module-info.svg` (`module-info`) +* :file:`EXT:lowlevel/Resources/Public/Icons/module-config.svg` (`module-config`) +* :file:`EXT:lowlevel/Resources/Public/Icons/module-dbint.svg` (`module-dbint`) +* :file:`EXT:recordlist/Resources/Public/Icons/module-list.svg` (`module-list`) +* :file:`EXT:recycler/Resources/Public/Icons/module-recycler.svg` (`module-recycler`) +* :file:`EXT:reports/Resources/Public/Icons/module-reports.svg` (`module-reports`) +* :file:`EXT:scheduler/Resources/Public/Icons/module-scheduler.svg` (`module-scheduler`) +* :file:`EXT:setup/Resources/Public/Icons/module-setup.svg` (`module-setup`) +* :file:`EXT:tstemplate/Resources/Public/Icons/module-tstemplate.svg` (`module-tstemplate`) +* :file:`EXT:viewpage/Resources/Public/Icons/module-viewpage.svg` (`module-viewpage`) +* :file:`EXT:workspaces/Resources/Public/Icons/module-workspaces.svg` (`module-workspaces`) + +Impact +====== + +The mentioned icons are removed, any usage by path will result in a broken +image. + +Affected Installations +====================== + +Third-party TYPO3 extensions using these icons. + +Migration +========= + +Use the already available icon identifiers from `TYPO3.Icons <https://typo3.github.io/TYPO3.Icons/>`_. +The module icons are all registered automatically by the IconRegistry. +In Fluid you can render them by calling :html:`<core:icon identifier="module-icon">`. +In case you need the SVG file directly, download it from the above-mentioned +icon repository page. + +.. index:: Backend, NotScanned diff --git a/Documentation/Changelog/12.0/Breaking-96107-DeprecatedFunctionalityRemoved.rst b/Documentation/Changelog/12.0/Breaking-96107-DeprecatedFunctionalityRemoved.rst new file mode 100644 index 0000000..77b4f2f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96107-DeprecatedFunctionalityRemoved.rst @@ -0,0 +1,446 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96107: + +=================================================== +Breaking: #96107 - Deprecated functionality removed +=================================================== + +See :issue:`96107` + +Description +=========== + +The following PHP classes that have previously been marked as deprecated for v11 and were now removed: + +- :php:`\TYPO3\CMS\Backend\View\BackendTemplateView` +- :php:`\TYPO3\CMS\Core\Cache\Backend\PdoBackend` +- :php:`\TYPO3\CMS\Core\Cache\Backend\WincacheBackend` +- :php:`\TYPO3\CMS\Core\Category\CategoryRegistry` +- :php:`\TYPO3\CMS\Core\Database\QueryGenerator` +- :php:`\TYPO3\CMS\Core\Database\QueryView` +- :php:`\TYPO3\CMS\Core\Database\SoftReferenceIndex` +- :php:`\TYPO3\CMS\Core\Service\AbstractService` +- :php:`\TYPO3\CMS\Extbase\Annotation\Inject` +- :php:`\TYPO3\CMS\Extbase\Configuration\Exception\ParseErrorException` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\BackendUser` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\BackendUserGroup` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\FrontendUser` +- :php:`\TYPO3\CMS\Extbase\Domain\Model\FrontendUserGroup` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\BackendUserGroupRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\BackendUserRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\CategoryRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FrontendUserGroupRepository` +- :php:`\TYPO3\CMS\Extbase\Domain\Repository\FrontendUserRepository` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\InvalidRequestMethodException` +- :php:`\TYPO3\CMS\Extbase\Mvc\Exception\StopActionException` +- :php:`\TYPO3\CMS\Extbase\Mvc\View\AbstractView` +- :php:`\TYPO3\CMS\Extbase\Mvc\View\EmptyView` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\ReferringRequest` +- :php:`\TYPO3\CMS\Extbase\Object\Container\Container` +- :php:`\TYPO3\CMS\Extbase\Object\Container\Exception\UnknownObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\Exception` +- :php:`\TYPO3\CMS\Extbase\Object\Exception\CannotBuildObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\Exception\CannotReconstituteObjectException` +- :php:`\TYPO3\CMS\Extbase\Object\ObjectManager` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidNumberOfConstraintsException` +- :php:`\TYPO3\CMS\Extbase\Service\EnvironmentService` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Dispatcher` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException` +- :php:`\TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException` +- :php:`\TYPO3\CMS\Frontend\ContentObject\EditPanelContentObject` + +The following PHP classes have been declared final: + +- All Fluid ViewHelpers + +The following PHP interfaces that have previously been marked as deprecated for v11 and were now removed: + +- :php:`\TYPO3\CMS\Backend\Toolbar\ClearCacheActionsHookInterface` +- :php:`\TYPO3\CMS\Core\Database\TableConfigurationPostProcessingHookInterface` +- :php:`\TYPO3\CMS\Core\Resource\Hook\FileDumpEIDHookInterface` +- :php:`\TYPO3\CMS\Core\Utility\File\ExtendedFileUtilityProcessDataHookInterface` +- :php:`\TYPO3\CMS\Extbase\Mvc\View\ViewInterface` +- :php:`\TYPO3\CMS\Extbase\Object\ObjectManagerInterface` +- :php:`\TYPO3\CMS\Extbase\Persistence\ForwardCompatibleQueryInterface` +- :php:`\TYPO3\CMS\Extbase\Persistence\ForwardCompatibleQueryResultInterface` +- :php:`\TYPO3\CMS\Filelist\FileListEditIconHookInterface'` +- :php:`\TYPO3\CMS\Recordlist\RecordList\RecordListHookInterface` + +The following PHP interfaces changed: + +- :php:`\TYPO3\CMS\Core\Collection\CollectionInterface` (no longer extends \Serializable) +- :php:`\TYPO3\CMS\Core\Resource\FolderInterface` (method :php:`getFile()` added) +- :php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface` (method :php:`setType()` added) +- :php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface->logicalAnd` (all arguments are now type hinted as `ConstraintInterface`) +- :php:`\TYPO3\CMS\Extbase\Persistence\QueryInterface->logicalOr` (all arguments are now type hinted as `ConstraintInterface`) +- :php:`\TYPO3\CMS\Extbase\Persistence\QueryResultInterface` (method :php:`setQuery()` added) +- :php:`\TYPO3\CMS\Form\Domain\Finishers\FinisherInterface` (method :php:`setFinisherIdentifier()` added) +- :php:`\TYPO3\CMS\Frontend\ContentObject\Exception\ExceptionHandlerInterface` (method :php:`setConfiguration()` added) + +The following PHP class methods that have previously been marked as deprecated for v11 and were now removed: + +- :php:`\TYPO3\CMS\Backend\Form\FormDataProvider\AbstractItemProvider->addItemsFromSpecial()` +- :php:`\TYPO3\CMS\Backend\Template\Components\AbstractControl->getOnClick'()` +- :php:`\TYPO3\CMS\Backend\Template\Components\AbstractControl->setOnClick'()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->getIconFactory()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->getPageRenderer()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->setNavigationFrameScript()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->getNavigationFrameScript()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->setNavigationFrameScriptParameters()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->getNavigationFrameScriptParameters()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->setOnClick()` +- :php:`\TYPO3\CMS\Backend\Domain\Module\BackendModule->getOnClick()` +- :php:`\TYPO3\CMS\Backend\View\Event\AbstractSectionMarkupGeneratedEvent->getPageLayoutView()` +- :php:`\TYPO3\CMS\Backend\View\Event\AbstractSectionMarkupGeneratedEvent->getLanguageId()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->createSessionId()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->fetchUserSession()` +- :php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools->getArrayValueByPath()` +- :php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools->setArrayValueByPath()` +- :php:`\TYPO3\CMS\Core\Database\ReferenceIndex->disableRuntimeCache()` +- :php:`\TYPO3\CMS\Core\Database\ReferenceIndex->enableRuntimeCache()` +- :php:`\TYPO3\CMS\Core\Database\RelationHandler->setUpdateReferenceIndex()` +- :php:`\TYPO3\CMS\Core\Database\RelationHandler->remapMM()` +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->fixVersioningPid()` +- :php:`\TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent->isRelativeToCurrentScript()` +- :php:`\TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider->getRootUid()` +- :php:`\TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider->setRootUid()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->buildControllerContext()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->getControllerContext()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->forward()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Request->getBaseUri()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Request->getRequestUri()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Request->isDispatched()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Request->setDispatched()` +- :php:`\TYPO3\CMS\Extbase\Mvc\View\JsonView->setControllerContext()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->setAddQueryStringMethod()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->getLanguageMode()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->setLanguageMode()` +- :php:`\TYPO3\CMS\Fluid\Core\Rendering\RenderingContext->getControllerContext()` +- :php:`\TYPO3\CMS\Fluid\Core\Rendering\RenderingContext->setControllerContext()` +- :php:`\TYPO3\CMS\Fluid\View\AbstractTemplateView->setControllerContext()` +- :php:`\TYPO3\CMS\Form\Domain\Renderer\AbstractElementRenderer->setControllerContext()` +- :php:`\TYPO3\CMS\Form\Domain\Renderer\RendererInterface->setControllerContext()` +- :php:`\TYPO3\CMS\Form\Domain\Runtime\FormRuntime->getControllerContext()` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->editIcons()` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->editPanel()` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->isDisabled()` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_editIcons()` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->stdWrap_editPanel()` +- :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_getEditPanel()` +- :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_getEditIcon()` + +The following PHP static class methods that have previously been marked as deprecated for v11 and were now removed: + +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::explodeSoftRefParserList()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::fixVersioningPid()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::softRefParserObj()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::viewOnClick` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService::create()` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService::createFromSiteLanguage()` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService::createFromUserPreferences()` +- :php:`\TYPO3\CMS\Core\Resource\Index\ExtractorRegistry::getInstance()` +- :php:`\TYPO3\CMS\Core\Resource\Index\FileIndexRepository::getInstance()` +- :php:`\TYPO3\CMS\Core\Resource\Index\MetaDataRepository::getInstance()` +- :php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry::getInstance()` +- :php:`\TYPO3\CMS\Core\Resource\Rendering\RendererRegistry::getInstance()` +- :php:`\TYPO3\CMS\Core\Resource\TextExtraction\TextExtractorRegistry::getInstance()` +- :php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->doSyntaxHighlight()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::makeCategorizable()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::compileSelectedGetVarsFromArray()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::hideIfNotTranslated()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::hideIfDefaultLanguage()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::isAbsPath()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::isAllowedHostHeaderValue()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::isFirstPartOfStr()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::minifyJavaScript()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::rmFromList()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::shortMD5()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::stdAuthCode()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::uniqueList()` +- :php:`\TYPO3\CMS\Core\Utility\HttpUtility::redirect()` +- :php:`\TYPO3\CMS\Core\Utility\HttpUtility::setResponseCode()` +- :php:`\TYPO3\CMS\Core\Utility\HttpUtility::setResponseCodeAndExit()` +- :php:`\TYPO3\CMS\Core\Utility\StringUtility::beginsWith()` +- :php:`\TYPO3\CMS\Core\Utility\StringUtility::endsWith()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::getControllerClassName()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::resolveVendorFromExtensionAndControllerClassName()` +- :php:`\TYPO3\CMS\Form\Service\TranslationService::getInstance()` +- :php:`\TYPO3\CMS\T3editor\Registry\AddonRegistry::getInstance()` +- :php:`\TYPO3\CMS\T3editor\Registry\ModeRegistry::getInstance()` + +The following PHP class methods changed signature according to previous deprecations in v11 at the end of the argument list: + +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->unpack_uc()` (argument 1 removed) +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->writeUC()` (argument 1 removed) +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->start()` (argument 1 always required) +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->checkAuthentication()` (argument 1 always required) +- :php:`\TYPO3\CMS\Core\Authentication\CommandLineUserAuthentication->checkAuthentication()` (argument 1 always required) +- :php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->isInWebMount()` (argument 3 removed) +- :php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->backendCheckLogin()` (argument 1 removed) +- :php:`\TYPO3\CMS\Core\Core\ApplicationInterface->run()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Database\RelationHandler->writeForeignField()` (argument 4 removed) +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\File->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\FileInterface->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\FileReference->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\Folder->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\InaccessibleFolder->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile->getPublicUrl()` (argument 1 is removed) +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorage->getPublicUrl()` (argument 2 is removed) +- :php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperInterface->getPublicUrl()` (argument 2 is removed) +- :php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\VimeoHelper->getPublicUrl()` (argument 2 is removed) +- :php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\YouTubeHelper->getPublicUrl()` (argument 2 is removed) +- :php:`\TYPO3\CMS\Extbase\Core\Bootstrap->run()` (optional third argument is now required) +- :php:`\TYPO3\CMS\Fluid\View\StandaloneView->__construct()` (optional constructor argument is removed) +- :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->fetchGroupData()` (argument 1 always required) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->ATagParams()` (argument 2 is removed) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getConfigArray()` (argument 1 is always required) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->determineId()` (argument 1 is always required) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->INTincScript()` (argument 1 is always required) +- :php:`\TYPO3\CMS\Frontend\Typolink\AbstractTypolinkBuilder->build()` (return type is now of LinkResultInterface) + +The following PHP static class methods changed signature according to previous deprecations in v11 at the end of the argument list: + +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::wrapClickMenuOnIcon()` (arguments 5, 6 and 7 are removed) +- :php:`\TYPO3\CMS\Core\Utility\ArrayUtility::arrayDiffAssocRecursive()` (argument 3 is removed) + +The following PHP class methods changed signature according to previous deprecations in v11 and are now type hinted: + +- :php:`\TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder->literal()` (second argument requires an integer) +- :php:`\TYPO3\CMS\Core\Database\Query\QueryBuilder->quote()` (second argument requires an integer) +- :php:`\TYPO3\CMS\Core\TimeTracker\TimeTracker->setTSlogMessage()` (second argument requires a string) +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->getIcon()` (first argument is now type hinted `array`) +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Query->logicalAnd()` (all arguments are now type hinted as `ConstraintInterface`) +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Query->logicalOr()` (all arguments are now type hinted as `ConstraintInterface`) +- :php:`\TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList->linkUrlMail()` (all arguments are now type hinted as `string`) + +The following PHP class methods changed signature according to previous deprecations: + +- :php:`\TYPO3\CMS\Core\Controller\ErrorPageController->errorAction()` (the third argument :php:`$severity` is removed) + +The following class properties have been removed: + +- :php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->breakPointLN` +- :php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser->parentObject` +- :php:`\TYPO3\CMS\Core\TypoScript\TemplateService->ext_constants_BRP` +- :php:`\TYPO3\CMS\Core\TypoScript\TemplateService->ext_config_BRP` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->controllerContext` +- :php:`\TYPO3\CMS\Extbase\Mvc\View\JsonView->controllerContext` +- :php:`\TYPO3\CMS\Fluid\Core\Rendering\RenderingContext->controllerContext` +- :php:`\TYPO3\CMS\Fluid\View\AbstractTemplateView->controllerContext` +- :php:`\TYPO3\CMS\Form\Domain\Renderer\AbstractElementRenderer->controllerContext` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->align` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->oldData` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->alternativeData` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->currentRecordTotal` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->recordRegister` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->ATagParams` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->cObjectDepthCounter` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->displayEditIcons` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->displayFieldEditIcons` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sWordRegex` (internal, but public) +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->sWordList` (internal, but public) +- :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin->pi_EPtemp_cObj` + +The following class properties have been changed: + +- :php:`\TYPO3\CMS\Core\TimeTracker\TimeTracker->wrapError` (does not contain numeric keys anymore) +- :php:`\TYPO3\CMS\Core\TimeTracker\TimeTracker->wrapIcon` (does not contain numeric keys anymore) + +The following class methods visibility have been changed to protected: + +- :php:`\TYPO3\CMS\Core\DataHandling\SoftReference\TypolinkSoftReferenceParser->getTypoLinkParts()` +- :php:`\TYPO3\CMS\Core\DataHandling\SoftReference\TypolinkSoftReferenceParser->setTypoLinkPartsElement()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::resolveControllerAliasFromControllerClassName()` + +The following class properties visibility have been changed to protected: + +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->cObj` +- :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin->cObj` + +The following ViewHelpers have been changed or removed: + +- :html:`<be:moduleLayout>` removed +- :html:`<be:moduleLayout.menu>` removed +- :html:`<be:moduleLayout.menuItem>` removed +- :html:`<be:moduleLayout.button.linkButton>` removed +- :html:`<be:moduleLayout.button.shortcutButton>` removed +- :html:`<f:base>` removed +- :html:`<f:be.container>` removed +- :html:`<f:uri.email>` removed +- :html:`<f:form>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:link.action>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:link.page>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:link.typolink>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:uri.action>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:uri.page>` (:php:`addQueryStringMethod` argument removed) +- :html:`<f:uri.typolink>` (:php:`addQueryStringMethod` argument removed) + +The following TypoScript options have been removed or adapted: + +- `config.sword_standAlone` +- `config.sword_noMixedCase` +- `_parseFunc.sword` +- `EDITPANEL` content object +- `mod.linkvalidator.linkhandler.reportHiddenRecords` +- `page.includeCSS.myfile*.import` +- `page.includeCSSLibs.myfile*.import` +- `plugin.tx_indexedsearch.settings.forwardSearchWordsInResultLink` +- `plugin.tx_indexedsearch.settings.forwardSearchWordsInResultLink.no_cache` +- `stdWrap.editPanel` +- `stdWrap.editPanel.` +- `stdWrap.editIcons` +- `stdWrap.editIcons.` +- `TMENU.JSWindow` +- `TMENU.JSWindow.params` + +The following constants have been dropped: + +- :php:`TYPO3_branch` +- :php:`TYPO3_MODE` +- :php:`TYPO3_REQUESTTYPE` +- :php:`TYPO3_REQUESTTYPE_AJAX` +- :php:`TYPO3_REQUESTTYPE_BE` +- :php:`TYPO3_REQUESTTYPE_CLI` +- :php:`TYPO3_REQUESTTYPE_FE` +- :php:`TYPO3_REQUESTTYPE_INSTALL` +- :php:`TYPO3_version` + +The following class constants have been dropped: + +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_ALLOW_ALL` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::ENV_TRUSTED_HOSTS_PATTERN_SERVER_NAME` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::NEW_PLACEHOLDER_VERSION` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::MOVE_PLACEHOLDER` + +The following global option handling have been dropped and are ignored: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultCategorizedTables']` + +The following hooks have been removed: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['additionalBackendItems']['cacheActions']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['FileDumpEID.php']['checkFileAccess']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['fileList']['editIconsHook']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['extTablesInclusion-PostProcessing']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawHeaderHook']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['recordlist/Modules/Recordlist/index.php']['drawFooterHook']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_extfilefunc.php']['processData']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_parsehtml_proc.php']['transformation']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/browse_links.php']['browserRendering']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/class.db_list_extra.inc']['actions']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/classes/class.frontendedit.php']` +- :php:`$GLOBALS['TBE_MODULES_EXT']['xMOD_db_new_content_el']['addElClasses']` + +The following single field configurations have been removed from TCA: + +- :php:`special` (for TCA type :php:`select`) +- :php:`treeConfig.rootUid` (for TCA renderType :php:`selectTree` and :php:`category`) + +The following single field configurations have been removed from :php:`$GLOBALS['TYPO3_USER_SETTINGS']`: + +- :php:`confirmData.jsCodeAfterOk` +- :php:`onClick` +- :php:`onClickLabels` + +The following features are now always enabled: + +- `runtimeDbQuotingOfTcaConfiguration` +- `subrequestPageErrors` +- `yamlImportsFollowDeclarationOrder` + +The following features have been removed: + +- Extbase switchable controller actions +- Upgrade wizard "Migrate felogin plugins to use prefixed FlexForm keys" +- Upgrade wizard "Migrate felogin plugins to use Extbase CType" +- Upgrade wizard "Install extension 'feedit' from TER" +- Upgrade wizard "Install extension 'sys_action' from TER" +- Upgrade wizard "Install extension "taskcenter" from TER" +- Row upgrader "Workspace 'pid -1' migration" + +The following fallbacks have been removed: + +- Usage of the :html:`t3js-toggle-new-content-element-wizard` class to trigger the new content element wizard +- Usage of the :php:`DataHandler->inlineLocalizeSynchronize()` functionality without an array as input argument +- The :php:`route` parameter is no longer added to backend URLs +- Extensions, which are located in `typo3conf/ext`, but not installed by Composer, are no longer evaluated for installations in "Composer mode" +- Extbase no longer accepts :php:`MyVendor.` prefixed :php:`MyExtensionName` as first argument in + :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin()`, :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin()` + and :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule()` and controller class names must be registered + with their fully qualified name. +- Extbase no longer determines types from doc block annotations for dependency injection methods and actions with validators, + defined types in method signatures must be used. +- Accessing Core related caches with :php:`cache_` prefix has been removed. +- Accessing :php:`\TYPO3\CMS\Frontend\Typolink\LinkResult` properties as arrays - ArrayAccess functionality removed + +The following database tables have been removed: + +- :sql:`sys_language` + +The following global JavaScript variables have been removed: + +- :js:`top.currentSubScript` +- :js:`top.fsMod` +- :js:`top.nextLoadModuleUrl` + +The following global JavaScript functions have been removed: + +- :js:`top.goToModule()` +- :js:`top.jump()` + +The following JavaScript functions have been removed: + +- :js:`FormEngine.requestConfirmationOnFieldChange()` +- :js:`TBE_EDITOR.fieldChanged()` + +The following JavaScript methods behaviour has changed: + +- :js:`show()` and :js:`hide()` of :js:`TYPO3/CMS/Backend/Tooltip` do no longer allow JQuery objects passed as first argument +- :js:`FormEngine.setSelectOptionFromExternalSource()` does no longer allow JQuery objects passed as sixth argument +- :js:`DateTimePicker.initialize()` always requires an :js:`HTMLInputElement` to be passed as first argument + +The following JavaScript modules have been removed: + +- :js:`TYPO3/CMS/Backend/SplitButtons` +- :js:`TYPO3/CMS/Core/Ajax/ResponseError` +- :js:`TYPO3/CMS/T3editor/T3editor` + +The following RequireJS module names have been removed: + +- :js:`Sortable` + +The following module configuration have been removed: + +- :php:`navFrameScript` +- :php:`navFrameScriptParam` +- :php:`navigationFrameModule` (Extbase) + +The following command line options have been removed: + +- :bash:`impexp:export --includeRelated` +- :bash:`impexp:export --includeStatic` +- :bash:`impexp:export --excludeDisabledRecords` +- :bash:`impexp:export --excludeHtmlCss` +- :bash:`impexp:export --saveFilesOutsideExportFile` +- :bash:`impexp:import --updateRecords` +- :bash:`impexp:import --ignorePid` +- :bash:`impexp:import --forceUid` +- :bash:`impexp:import --importMode` +- :bash:`impexp:import --enableLog` + +The following dependency injection container entries have been removed: + +- `\TYPO3\CMS\Core\Localization\LanguageService` +- `\TYPO3\CMS\Fluid\Core\Rendering\RenderingContext` +- `\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController` + +Impact +====== + +Using above removed functionality will most likely raise PHP fatal level errors, +may change website output or crashes browser JavaScript. + +.. index:: Backend, CLI, FlexForm, Fluid, Frontend, JavaScript, LocalConfiguration, PHP-API, TCA, TSConfig, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/12.0/Breaking-96149-EXTformEmailFinisherAlwaysUsesFluidEmail.rst b/Documentation/Changelog/12.0/Breaking-96149-EXTformEmailFinisherAlwaysUsesFluidEmail.rst new file mode 100644 index 0000000..c8919bc --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96149-EXTformEmailFinisherAlwaysUsesFluidEmail.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96149: + +================================================================ +Breaking: #96149 - EXT:form EmailFinisher always uses FluidEmail +================================================================ + +See :issue:`96149` + +Description +=========== + +In recent versions, the :php:`EmailFinisher` of EXT:form allowed sending +emails with either :php:`StandaloneView` or via :php:`FluidEmail`, which +has been introduced in TYPO3 v10. The :php:`StandaloneView` option has +therefore now been removed together with the :file:`Html.html` and +:file:`Plaintext.html` templates. + +Impact +====== + +Since the EXT:form :php:`EmailFinisher` is now always using :php:`FluidEmail` +for sending emails, the :yaml:`templatePathAndFilename` is not evaluated +anymore. For forms, which still define custom templates with this option, +a fallback kicks in, sending the emails with the default EXT:form +:php:`FluidEmail` templates. + +Also the :yaml:`useFluidEmail` configuration option, previously used to +allow a smooth migration path is now obsolete and can safely be removed +from any form finisher configuration. + +Affected Installations +====================== + +Installations, which have not yet switched to :php:`FluidEmail`, while using +custom email templates, configured with :yaml:`templatePathAndFilename`. + +Migration +========= + +In case you use custom email templates, replace :yaml:`templatePathAndFilename` +with the :yaml:`templateName` and :yaml:`templateRootPaths` options. Also +make sure, you have separate template files for the used formats, e.g. +:file:`ContactForm.html` and :file:`ContactForm.txt`. + +.. index:: YAML, NotScanned, ext:form diff --git a/Documentation/Changelog/12.0/Breaking-96154-DeprecatedShortcutAPIFunctionalityRemoved.rst b/Documentation/Changelog/12.0/Breaking-96154-DeprecatedShortcutAPIFunctionalityRemoved.rst new file mode 100644 index 0000000..8114b3c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96154-DeprecatedShortcutAPIFunctionalityRemoved.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96154: + +================================================================ +Breaking: #96154 - Deprecated Shortcut API functionality removed +================================================================ + +See :issue:`96154` + +Description +=========== + +In TYPO3 v11 the Shortcut API was reworked to clean up the codebase and +to align with the new Backend routing. Therefore, previously deprecated +functionality has now been removed. + +The following methods have been removed: + +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->makeShortcutIcon()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->makeShortcutUrl()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getGetVariables()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getModuleName()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->getSetVariables()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setGetVariables()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setModuleName()` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton->setSetVariables()` + +The following ViewHelper has been removed: + +- :html:`<f:be.buttons.shortcut>` + +The following functionality has been removed: + +- The automatic fallback, calculating a title for a new shortcut, based on the module +- The automatic fallback, calculating a description for existing shortcuts, based on the module +- The automatic fallback, determining the route identifier, based on the route path +- The automatic fallback, determining the route identifier, based on the module name +- The automatic fallback, determining the route identifier, based on the route parameter + +Impact +====== + +Calling one of the removed methods or using the ViewHelper will most likely +raise a PHP fatal level error. + +When using an existing shortcut without a title, the fallback "Shortcut" +will be displayed. + +When adding a :php:`ShortcutButton`, without providing a valid route +identifier or a display name, an exception will be triggered. + +Affected Installations +====================== + +All installations using one of the mentioned methods or the ViewHelper. + +All installations relying on one or multiple of the mentioned fallbacks. + +Migration +========= + +Remove any usage to the mentioned methods or the ViewHelper. + +Properly add the :php:`ShortcutButton` with the required information. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96158-RemoveSupportForInlineJavaScriptInFieldChangeFunc.rst b/Documentation/Changelog/12.0/Breaking-96158-RemoveSupportForInlineJavaScriptInFieldChangeFunc.rst new file mode 100644 index 0000000..59dfe01 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96158-RemoveSupportForInlineJavaScriptInFieldChangeFunc.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96158: + +========================================================================== +Breaking: #96158 - Remove support for inline JavaScript in fieldChangeFunc +========================================================================== + +See :issue:`96158` + +Description +=========== + +Custom :php:`FormEngine` nodes allow to use internal property `fieldChangeFunc` +to add or modify client-side JavaScript behavior when field values are changed. +Through TYPO3 v11 it was possible to directly use inline JavaScript that was +assigned as plain :php:`string` type. With TYPO3 v12.0 inline JavaScript is +not supported anymore - values assigned to `fieldChangeFunc` items have to +implement :php:`\TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeInterface` +which allows to declare the behavior in a structured way. + +Impact +====== + +Assigning scalar values to `fieldChangeFunc` items - without using +:php:`\TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeInterface` - is not +supported anymore and will lead to PHP type errors. + +Affected Installations +====================== + +Installations implementing custom :php:`FormEngine` components (wizards, nodes, +render-types, ...) that provide inline JavaScript using `fieldChangeFunc`. + +.. code-block:: php + + // examples + $this->data['parameterArray']['fieldChangeFunc']['example'] = "alert('demo');"; + $parameterArray['fieldChangeFunc']['example'] = "alert('demo');"; + +Migration +========= + +:doc:`Previous deprecation ChangeLog documentation <../11.5/Deprecation-91787-DeprecateInlineJavaScriptInFieldChangeFunc>` +provided migration details already. A complete and installable example is available with +`ext:demo_91787 <https://github.com/ohader/demo_91787>`__ as well. + +The provided code examples are supposed to work with TYPO3 v11 and v12, easing +the migration path for extension maintainers. The crucial point is to use +:php:`\TYPO3\CMS\Backend\Form\Behavior\OnFieldChangeInterface` which still +would inline JavaScript as a fallback in TYPO3 v11. + +Thus, basically scalar assignments like... + +.. code-block:: php + + // examples + $this->data['parameterArray']['fieldChangeFunc']['example'] = "alert('demo');"; + $parameterArray['fieldChangeFunc']['example'] = "alert('demo');"; + +... have to be replaced by custom :php:`OnFieldChangeInterface` instances... + +.. code-block:: php + + // examples + $this->data['parameterArray']['fieldChangeFunc']['example'] = new AlertOnFieldChange('demo'); + $parameterArray['fieldChangeFunc']['example'] = new AlertOnFieldChange('demo'); + +.. index:: Backend, JavaScript, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96205-RemovalOfLastRelativeToCurrentScriptRemains.rst b/Documentation/Changelog/12.0/Breaking-96205-RemovalOfLastRelativeToCurrentScriptRemains.rst new file mode 100644 index 0000000..0d3a1a1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96205-RemovalOfLastRelativeToCurrentScriptRemains.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96205: + +================================================================== +Breaking: #96205 - Removal of last relativeToCurrentScript remains +================================================================== + +See :issue:`96205` + +Description +=========== + +Due to the removal of relative paths in the FAL API (:issue:`95027` and +:issue:`96201`) the :php:`$usedPathsRelativeToCurrentScript` argument in +media renderers :php:`render()` method got obsolete. The same applies to +the :php:`$relativeToCurrentScript` argument of :php:`Avatar->getUrl()`. + +Therefore, :php:`$usedPathsRelativeToCurrentScript` is removed as last +argument from following PHP class methods: + +- :php:`\TYPO3\CMS\Core\Resource\Rendering\AudioTagRenderer->render()` +- :php:`\TYPO3\CMS\Core\Resource\Rendering\FileRendererInterface->render()` +- :php:`\TYPO3\CMS\Core\Resource\Rendering\VideoTagRenderer->render()` +- :php:`\TYPO3\CMS\Core\Resource\Rendering\VimeoRenderer->render()` +- :php:`\TYPO3\CMS\Core\Resource\Rendering\YoutubeRenderer->render()` + +Further is :php:`$relativeToCurrentScript` removed as last argument +from :php:`\TYPO3\CMS\Backend\Backend\Avatar->getUrl()`. + +Impact +====== + +Passing the removed argument to one of the mentioned methods does +no longer have any effect. + +Affected Installations +====================== + +Installations, passing the removed argument to one of the mentioned +methods, which is rather unlikely as those methods are usually not +called by extension code directly. + +Migration +========= + +Remove the corresponding argument from the methods. + +.. index:: FAL, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96212-AltTextIsEnforcedForCustomLoginLogos.rst b/Documentation/Changelog/12.0/Breaking-96212-AltTextIsEnforcedForCustomLoginLogos.rst new file mode 100644 index 0000000..6f0d64e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96212-AltTextIsEnforcedForCustomLoginLogos.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96212: + +============================================================== +Breaking: #96212 - Alt text is enforced for custom login logos +============================================================== + +See :issue:`96212` + +Description +=========== + +To improve the accessibility of the login screen, the :html:`alt` attribute +has been added to the login logo in :issue:`92628`. In case installations use +a custom login logo, configured in :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['backend']['loginLogo']`, +it had also been possible to add a corresponding "alt" text for it with +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['backend']['loginLogoAlt']`. + +In case a custom logo was used, but no custom "alt" text configured, the +:html:`alt` attribute was omitted. This has changed. The :html:`alt` +attribute is now always added to the login logo. In case a custom logo is +used, but no custom "alt" text defined, TYPO3 now automatically falls back +to a default "alt" text. + +Impact +====== + +The :html:`alt` attribute is now enforced for the login logo. + +Affected Installations +====================== + +All installations using a custom login logo, while not defining a +corresponding "alt" text. + +Migration +========= + +Add a corresponding "alt" text with +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['backend']['loginLogoAlt']`. + +.. note:: + + Those settings are also available in the backend extension configuration + :guilabel:`Admin Tools -> Settings -> Configure extensions -> backend` + +.. index:: Backend, LocalConfiguration, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96221-DenyInlineJavaScriptInFormEnginesRequireJsModules.rst b/Documentation/Changelog/12.0/Breaking-96221-DenyInlineJavaScriptInFormEnginesRequireJsModules.rst new file mode 100644 index 0000000..99a28e1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96221-DenyInlineJavaScriptInFormEnginesRequireJsModules.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96221: + +========================================================================== +Breaking: #96221 - Deny inline JavaScript in FormEngine's requireJsModules +========================================================================== + +See :issue:`96221` + +Description +=========== + +Custom :php:`FormEngine` components allowed to load RequireJS modules +with arbitrary inline JavaScript to initialize those modules. In favor +of introducing content security policy headers, the amount of inline +JavaScript shall be reduced and replaced by corresponding declarations. + +Using callback functions as inline JavaScript is not possible anymore, +initializations have to be declared using an instance of +:php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction`. + +Impact +====== + +Using inline JavaScript to initialize RequireJS modules in `FormEngine`, +like shown in the example below, will throw a corresponding +:php:`\LogicException`. + +.. code-block:: php + + $resultArray['requireJsModules'][] = ['TYPO3/CMS/Backend/FormEngine/Element/InputDateTimeElement' => ' + // inline JavaScript code to initialize `InputDateTimeElement` + function(InputDateTimeElement) { + new InputDateTimeElement(' . GeneralUtility::quoteJSvalue($fieldId) . '); + }' + ]; + +Affected Installations +====================== + +All instances that are using RequireJS modules with custom initializations +as inline JavaScript in `FormEngine`. + +Migration +========= + +:doc:`Previous deprecation ChangeLog documentation <../11.5/Deprecation-95200-DeprecateRequireJSCallbacksAsInlineJavaScript>` +provided migration details already. + +The following snippet shows the migrated source code of shown above - using +:php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` instead of inline JavaScript. + +.. code-block:: php + + // use use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + $resultArray['requireJsModules'][] = JavaScriptModuleInstruction::forRequireJS( + 'TYPO3/CMS/Backend/FormEngine/Element/InputDateTimeElement' + )->instance($fieldId); + +:php:`JavaScriptModuleInstruction` forwards arguments as `JSON` data - and thus +handles proper context-aware encoding implicitly (:php:`GeneralUtility::quoteJSvalue` +and similar custom encoding can be omitted in this case). + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96222-AddGetOptionsToWidgetInterface.rst b/Documentation/Changelog/12.0/Breaking-96222-AddGetOptionsToWidgetInterface.rst new file mode 100644 index 0000000..5865e55 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96222-AddGetOptionsToWidgetInterface.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96222: + +====================================================== +Breaking: #96222 - Add getOptions() to WidgetInterface +====================================================== + +See :issue:`96222` + +Description +=========== + +With :issue:`93210` the dashboard was extended for the functionality to +refresh single widgets. This also required to extend the :php:`WidgetInterface`. +To stick to TYPO3's backwards compatibility promise, the new method was +commented out and instead a :php:`methodExists()` check performed. + +This now has changed. The check is removed and the :php:`WidgetInterface` +now forces the presence of the :php:`getOptions()` method in all widgets. + +Impact +====== + +All dashboard widgets are now forced to implement the :php:`getOptions()` +method, returning the widget options. Otherwise this will cause a PHP +fatal error. + +Affected Installations +====================== + +All installations using custom dashboard widgets. + +Migration +========= + +Add the :php:`getOptions()` method to all of your custom widget classes. + +.. code-block:: php + + public function getOptions(): array + { + return $this->options; + } + +.. index:: Backend, PHP-API, NotScanned, ext:dashboard diff --git a/Documentation/Changelog/12.0/Breaking-96263-RemoveJQueryPromiseSupportForAJAXRequests.rst b/Documentation/Changelog/12.0/Breaking-96263-RemoveJQueryPromiseSupportForAJAXRequests.rst new file mode 100644 index 0000000..406d42a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96263-RemoveJQueryPromiseSupportForAJAXRequests.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96263: + +================================================================== +Breaking: #96263 - Remove jQuery promise support for AJAX requests +================================================================== + +See :issue:`96263` + +Description +=========== + +With :issue:`89738`, a polyfill for jQuery promises was introduced to ease the +migration of :js:`$.ajax()` to our AJAX request API. + +The polyfilled methods :js:`done()` and :js:`fail()` are now removed. + +Impact +====== + +Relying on the existence of the polyfill will trigger JavaScript errors. + +Affected Installations +====================== + +All extensions using the polyfilled methods are affected. + +Migration +========= + +For success handling, replace :js:`done()` with :js:`then()`. + +Example: + +.. code-block:: js + + // Polyfill + new AjaxRequest('/foobar/baz').get().done(function(response) { + // do stuff + }); + + // Native + new AjaxRequest('/foobar/baz').get().then(async function(response) { + // do stuff + }); + +For error handling, replace :js:`fail()` with :js:`catch()`. + +Example: + +.. code-block:: js + + // Polyfill + new AjaxRequest('/foobar/baz').get().fail(function() { + // oh noes + }); + + // Native + new AjaxRequest('/foobar/baz').get().catch(function() { + // oh noes + }); + +.. index:: JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96287-DoctrineDBALv3.rst b/Documentation/Changelog/12.0/Breaking-96287-DoctrineDBALv3.rst new file mode 100644 index 0000000..91c8a7c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96287-DoctrineDBALv3.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96287: + +=================================== +Breaking: #96287 - Doctrine DBAL v3 +=================================== + +See :issue:`96287` + +Description +=========== + +TYPO3 v12.0 has updated its Database Abstraction package based on Doctrine +DBAL to the next major version Doctrine DBAL v3. + +Impact +====== + +Doctrine DBAL 3 has undergone major refactorings internally by separating +Doctrine's internal driver logic from PHP's native PDO functionality. + +See https://www.doctrine-project.org/2021/03/29/dbal-2.13.html and +https://www.doctrine-project.org/2020/11/17/dbal-3.0.0.html +for more details. + +In addition, most database APIs which TYPO3 provides as wrappers around +the existing functionality is already available in TYPO3 v11 and +continue to work in TYPO3 v12. + +Affected Installations +====================== + +TYPO3 installations with custom third-party extensions using TYPO3's +Database Abstraction functionality, or extensions using +the Doctrine DBAL API directly. + +Migration +========= + +Read Doctrine's migration paths (see links above) to migrate any existing +code. + +The main change for 95% of the developers are, that queries and database result-sets +now have more explicit APIs when querying the database. + +Examples: + +.. code-block:: php + + $result = $queryBuilder + ->select(...) + ->from(...) + // use executeQuery() instead of execute() + ->executeQuery(); + +:php:`$result` is now of type :php:`\Doctrine\DBAL\Result`, and not of type +:php:`\Doctrine\DBAL\Statement` anymore, which allows to fetch rows / columns via +new and more speaking methods: + +* :php:`->fetchAllAssociative()` instead of :php:`->fetchAll()` +* :php:`->fetchAssociative()` - instead of :php:`->fetch()` +* :php:`->fetchOne()` - instead of :php:`->fetchColumn(0)` + +The method :php:`executeQuery` - available in the QueryBuilder and +the Connection class is now in for select/count queries and returns a Result +object directly, whereas :php:`executeStatement()` is used for insert / update / delete +statements, returning an integer - the number of affected rows. + +Use both methods instead of the previous :php:`execute()` method, +which is still available for backwards-compatibility. + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96291-DisallowDBConnectionBeforeTCAIsLoaded.rst b/Documentation/Changelog/12.0/Breaking-96291-DisallowDBConnectionBeforeTCAIsLoaded.rst new file mode 100644 index 0000000..00d7e5d --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96291-DisallowDBConnectionBeforeTCAIsLoaded.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96291: + +============================================================== +Breaking: #96291 - Disallow DB connection before TCA is loaded +============================================================== + +See :issue:`96291` + +Description +=========== + +Accessing the database API before TCA is loaded +is considered to be a logic mistake, as TCA is required +to generate the expected database schema. + +Impact +====== + +Extensions that access the TYPO3 database API in +:file:`ext_localconf.php` files or TCA files will not work +any more, because TYPO3 will throw an exception in this case. + +Affected Installations +====================== + +TYPO3 installations with third-party extensions, +that access database API before TCA is loaded. + +Migration +========= + +Database API can be accessed earliest in the +:php:`\TYPO3\CMS\Core\Core\Event\BootCompletedEvent`. + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96333-AutoConfigurationOfContextMenuItemProviders.rst b/Documentation/Changelog/12.0/Breaking-96333-AutoConfigurationOfContextMenuItemProviders.rst new file mode 100644 index 0000000..19dd08e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96333-AutoConfigurationOfContextMenuItemProviders.rst @@ -0,0 +1,123 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96333: + +=================================================================== +Breaking: #96333 - Auto configuration of ContextMenu item providers +=================================================================== + +See :issue:`96333` + +Description +=========== + +ContextMenu item providers, implementing :php:`\TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface` +are now automatically registered by adding the :yaml:`backend.contextmenu.itemprovider` +tag, if :yaml:`autoconfigure` is enabled in :file:`Services.yaml`. The new +:php:`\TYPO3\CMS\Backend\ContextMenu\ItemProviders\ItemProvidersRegistry` then +automatically receives those services and registers them. + +All Core item providers extend the :php:`AbstractProvider` class, which is +usually also used by extensions. Due to the auto configuration, the context +information (table, record identifier and context) is no longer passed to the +:php:`__construct()`, but instead to the new :php:`setContext()` method. + +The :php:`setContext()` method is therefore required for all item providers. + +Impact +====== + +The registration via :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['ContextMenu']['ItemProviders']` +isn't evaluated anymore. + +The item providers are retrieved from the container and are no longer +instantiated while passing context information as constructor arguments. +The context information is now passed to :php:`setContext()`. + +Affected Installations +====================== + +All extensions, registering custom ContextMenu item providers. + +All extensions, extending :php:`AbstractProvider` and overwriting the +:php:`__construct()` method. + +All extensions, not extending :php:`AbstractProvider`, but implementing +:php:`\TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface` directly. + +Migration +========= + +Remove :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['ContextMenu']['ItemProviders']` +from your :file:`ext_localconf.php` file. If :yaml:`autoconfigure` is +not enabled in your :file:`Configuration/Services.(yaml|php)` file, +manually configure your item providers with the +:yaml:`backend.contextmenu.itemprovider` tag. + +If your item providers extend :php:`AbstractProvider` and overwrite the +:php:`__construct()` method, adjust the signature like shown below: + +.. code-block:: php + + // Before + + class MyItemProvider extends AbstractProvider { + + public function __construct(string $table, string $identifier, string $context = '') + { + parent::__construct($table, $identifier, $context); + + // My custom code + } + } + + // After + + class MyItemProvider extends AbstractProvider { + + public function __construct() + { + parent::__construct(); + + // My custom code + } + } + +In case you rely on the arguments, previously passed to :php:`__construct()`, +you can override the new :php:`setContext()` method, which is executed +prior to any other action like :php:`canHandle()`. + +.. code-block:: php + + // Before + + class MyItemProvider extends AbstractProvider { + + public function __construct(string $table, string $identifier, string $context = '') + { + parent::__construct($table, $identifier, $context); + + if ($table === 'my_table') { + // Do something + } + } + + // After + + class MyItemProvider extends AbstractProvider { + + public function setContext(string $table, string $identifier, string $context = ''): void + { + parent::setContext($table, $identifier, $context); + + if ($table === 'my_table') { + // Do something + } + } + } + +In case your item provider does not extend :php:`AbstractProvider`, but instead +implements the :php:`\TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface` +directly, add the new :php:`setContext()` to the item provider. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96351-UnusedTemplateService-updateRootlineDataMethodRemoved.rst b/Documentation/Changelog/12.0/Breaking-96351-UnusedTemplateService-updateRootlineDataMethodRemoved.rst new file mode 100644 index 0000000..5ba40e8 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96351-UnusedTemplateService-updateRootlineDataMethodRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96351: + +============================================================================ +Breaking: #96351 - Unused TemplateService->updateRootlineData method removed +============================================================================ + +See :issue:`96351` + +Description +=========== + +The PHP method :php:`TemplateService->updateRootlineData()` has been removed. + +It was used as a workaround to update the fetched rootline with +translated pages until TYPO3 v10. This was necessary because the +TypoScript information contained the language information, and +then the page translations were loaded accordingly. + +Since TYPO3 v11 the language is resolved earlier, at the same +time as the page ID, and the mechanism became obsolete. + +Impact +====== + +Calling the method in PHP will throw a fatal PHP error, as the method does not exist anymore. + +Affected Installations +====================== + +TYPO3 installations, mainly legacy installations with legacy +extensions using this method to boot up their own TypoScript +parsing. + +Migration +========= + +Calling this method is not needed anymore and can be removed +from the affected code. + +.. index:: Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96501-PrefixLocalAnchorsOptionInHTMLParserRemoved.rst b/Documentation/Changelog/12.0/Breaking-96501-PrefixLocalAnchorsOptionInHTMLParserRemoved.rst new file mode 100644 index 0000000..9959c4f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96501-PrefixLocalAnchorsOptionInHTMLParserRemoved.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96501: + +================================================================== +Breaking: #96501 - prefixLocalAnchors option in HTMLParser removed +================================================================== + +See :issue:`96501` + +Description +=========== + +The property :php:`prefixLocalAnchors` in TypoScript's HTMLParser +is removed without substitution. + +This is a leftover from times before there was Site Handling +and absolute URLs, related to :typoscript:`config.prefixLocalAnchors` which was +removed in TYPO3 v8. + +The option has many side-effects such as relying on the request +when parsing HTML (which behaves differently in TYPO3 Backend +and in Frontend). + +Impact +====== + +Setting this TypoScript option has no effect anymore. + +Affected Installations +====================== + +TYPO3 installation having TypoScript configured with this +option activated. + +Migration +========= + +None. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96517-TMENUcollapseTyposcriptRemoved.rst b/Documentation/Changelog/12.0/Breaking-96517-TMENUcollapseTyposcriptRemoved.rst new file mode 100644 index 0000000..34005a8 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96517-TMENUcollapseTyposcriptRemoved.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96517: + +==================================================== +Breaking: #96517 - TMENU.collapse TypoScript removed +==================================================== + +See :issue:`96517` + +Description +=========== + +The :typoscript:`collapse` TypoScript property of :typoscript:`TMENU` is removed +without substitution. + +When set, active :typoscript:`TMENU` items previously linked to their parent page, +which was primarily a use case for :typoscript:`GMENU_LAYERS`, which was +removed in TYPO3 v6.0. + +Impact +====== + +Setting this TypoScript option has no effect anymore. + +Affected Installations +====================== + +TYPO3 installations with :typoscript:`TMENU` definitions having this option +set which is highly unlikely. + +Migration +========= + +Use a custom user function or the PSR-14 :php:`FilterMenuItemsEvent` event to modify +the menu items. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96518-Ext_typoscript_txtFilesNotIncludedAnymore.rst b/Documentation/Changelog/12.0/Breaking-96518-Ext_typoscript_txtFilesNotIncludedAnymore.rst new file mode 100644 index 0000000..b49b25b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96518-Ext_typoscript_txtFilesNotIncludedAnymore.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96518: + +================================================================== +Breaking: #96518 - ext_typoscript_*.txt files not included anymore +================================================================== + +See :issue:`96518` + +Description +=========== + +In previous TYPO3 versions, files named :file:`ext_typoscript_setup.txt` and +:file:`ext_typoscript_constants.txt` which could be placed into an extension's +root folder, were automatically included for all TypoScript evaluations. + +This functionality stopped working, as the file ending `.typoscript` +has been unified since TYPO3 v8. + +Impact +====== + +Contents of these files are not evaluated for TypoScript anymore. + +Affected Installations +====================== + +TYPO3 installations with custom extensions including such files. + +Migration +========= + +Rename the files to :file:`ext_typoscript_setup.typoscript` and +:file:`ext_typoscript_constants.typoscript` which ensures compatibility +with all supported TYPO3 versions. + +The file extension `.typoscript` was used since TYPO3 v8 and both versions (.txt +and .typoscript) have been working side-by-side since TYPO3 v8. + +.. index:: TypoScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96520-EnforceNon-emptyConfigurationInCObjparseFunc.rst b/Documentation/Changelog/12.0/Breaking-96520-EnforceNon-emptyConfigurationInCObjparseFunc.rst new file mode 100644 index 0000000..3e2eb5e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96520-EnforceNon-emptyConfigurationInCObjparseFunc.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96520: + +===================================================================== +Breaking: #96520 - Enforce non-empty configuration in cObj::parseFunc +===================================================================== + +See :issue:`96520` + +Description +=========== + +Invoking :php:`ContentObjectRenderer::parseFunc` without configuration +or TypoScript reference is not possible anymore and in general did not +make much sense. + +Calling this method without any instructions led to various +side-effects, e.g. unintentionally enforcing `typo3/html-sanitizer`. +This problem was amplified when using :html:`<f:format.html parseFuncTSPath="">` +with an explicitly empty reference which actually did not do anything +and behaved the same as :html:`<f:format.raw>`. + +This change enforces that parseFunc is only invoked with actual +instructions. An empty configuration will throw a :php:`\LogicException` and +requires corresponding source code or Fluid templates to be adjusted. + +Impact +====== + +Still invoking :php:`ContentObjectRenderer::parseFunc` without configuration +will throw a :php:`\LogicException` in the frontend rendering process. + +Affected Installations +====================== + +All installations using one of the following examples + +PHP +--- + +.. code-block:: php + + /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */ + $cObj->parseFunc($content, []); + $cObj->parseFunc($content, [], ''); + $cObj->parseFunc($content, [], '< null.this.does.not.exist'); + +TypoScript +---------- + +.. code-block:: typoscript + + # `1` is considered a TypoScript reference which + # most probably does not exist + stdWrap.parseFunc = 1 + + # non-existing TypoScript reference leading to empty configuration + stdWrap.parseFunc =< null.this.does.not.exist + +Fluid Templates +--------------- + +.. code-block:: html + + <!-- empty TypoScript reference leading to empty configuration --> + <f:format.html parseFuncTSPath="">{content}</f:format.html> + + <!-- non-existing TypoScript reference leading to empty configuration --> + <f:format.html parseFuncTSPath="null.this.does.not.exist">{content}</f:format.html> + +Migration +========= + +Invocations of `parseFunc` in PHP and TypoScript without using +any configuration or TypoScript reference have to be removed. + +In Fluid templates :html:`<f:format.html parseFuncTSPath="">` +has the same effect as :html:`<f:format.raw>` which can be used +as replacement. However content is used "as-is" without further +sanitizing against cross-site scripting. + +In case of the need for just replacing links with typolink, +it is recommended to use :html:`<f:transform.html>` ViewHelper. + +Thus, any occurrence of the new :php:`\LogicException` mentioned above, +is also an indicator of some missing processing that has been unseen in +custom source code or template instructions. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96522-ConfigdisablePageExternalUrlRemoved.rst b/Documentation/Changelog/12.0/Breaking-96522-ConfigdisablePageExternalUrlRemoved.rst new file mode 100644 index 0000000..d0f4648 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96522-ConfigdisablePageExternalUrlRemoved.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96522: + +======================================================== +Breaking: #96522 - config.disablePageExternalUrl removed +======================================================== + +See :issue:`96522` + +Description +=========== + +The TypoScript setting :typoscript:`config.disablePageExternalUrl` has been removed. + +In previous versions, it allowed to have third-party extensions such as +"jumpurl" handle the redirect, and/or do tracking like extensions "sys_stat" +did back in 2006. TYPO3 Core did not do a redirect itself then when this +option was activated. + +Impact +====== + +This option is removed, meaning that TYPO3 Core will always handle a deep link +to a page with an external URL as a redirect, which has been the default +behaviour for TYPO3 installations anyways. + +Affected Installations +====================== + +TYPO3 installations explicitly setting this option, which is highly unlikely, +as modern solutions - even jumpurl - use middlewares already since TYPO3 v9. + +Migration +========= + +Migrate to a PSR-15 middleware in your own extension to mimic the same behavior, +if this option was actually useful for anybody in recent years. + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96526-RemovedHooksForModifyingPageModuleContent.rst b/Documentation/Changelog/12.0/Breaking-96526-RemovedHooksForModifyingPageModuleContent.rst new file mode 100644 index 0000000..2a3d1e9 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96526-RemovedHooksForModifyingPageModuleContent.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96526: + +================================================================== +Breaking: #96526 - Removed hooks for modifying page module content +================================================================== + +See :issue:`96526` + +Description +=========== + +The previously available hooks to modify the header +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawHeaderHook']` +and footer :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawFooterHook']` +content of the page module have been removed in favor of a new PSR-14 +event :php:`TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent`. + +The public method :php:`PageLayoutController->getModuleTemplate()` has been +removed as well, since it was only used for the removed hooks. + +Impact +====== + +Registering any of the mentioned hooks does no longer have any +effect in TYPO3 v12.0+. The extension scanner will detect usages +as strong match. + +The method :php:`PageLayoutController->getModuleTemplate()` is no longer +available and will therefore lead to PHP errors when called from extension +code. The extension scanner will detect usages as weak match. + +Affected Installations +====================== + +TYPO3 installations using one of the mentioned hooks or calling +:php:`PageLayoutController->getModuleTemplate()` in custom extension +code. + +Migration +========= + +Replace the hooks with the new PSR-14 +:doc:`ModifyPageLayoutContentEvent <../12.0/Feature-96526-PSR-14EventForModifyingPageModuleContent>` event. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96550-TYPO3_CONF_VARSSYSUSdateFormatRemoved.rst b/Documentation/Changelog/12.0/Breaking-96550-TYPO3_CONF_VARSSYSUSdateFormatRemoved.rst new file mode 100644 index 0000000..cb473c0 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96550-TYPO3_CONF_VARSSYSUSdateFormatRemoved.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96550: + +================================================================= +Breaking: #96550 - TYPO3_CONF_VARS['SYS']['USdateFormat'] removed +================================================================= + +See :issue:`96550` + +Description +=========== + +The TYPO3 configuration had a boolean toggle +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['USdateFormat']` that +changed the date rendering from "day-month-year" to "month-day-year" +in a couple of places in the backend - most prominently when editing records. + +This configuration conflicts with option +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy']`, which is a broader approach +to configure a system wide date rendering format, especially in combination with option +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm']`. + +To streamline date and time rendering in the backend and to eventually implement +a user-based and timezone-aware solution, option :php:`USdateFormat` has been +removed in favor of :php:`ddmmyy` from the configuration and is ignored now. + +Impact +====== + +Backend users of instances with this option set to :php:`true` will experience +swapped day and month rendering when editing records in the backend. + +The option is removed automatically from :file:`LocalConfiguration.php` +when upgrading to TYPO3 v12. + +Affected Installations +====================== + +Instances having :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['USdateFormat']` set to +default :php:`false` see no difference and are not affected. Instances with this +option set to :php:`true` are affected. + +The extension scanner will find matching candidates in case the option is used +in extensions. + +Migration +========= + +Extensions accessing :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['USdateFormat']` +should assume :php:`false` to keep compatibility with previous TYPO3 versions, +and should phase out the option usage. + +.. index:: Backend, LocalConfiguration, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96553-TYPO3V12SystemRequirements.rst b/Documentation/Changelog/12.0/Breaking-96553-TYPO3V12SystemRequirements.rst new file mode 100644 index 0000000..843fb60 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96553-TYPO3V12SystemRequirements.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96553: + +================================================ +Breaking: #96553 - TYPO3 v12 system requirements +================================================ + +See :issue:`96553` + +Description +=========== + +The minimum PHP version required to run TYPO3 version v12 has been defined as 8.1. + +TYPO3 v12 supports these database products and versions: + +* MySQL 8.0 or higher +* MariaDB 10.3 or higher +* PostgreSQL 10.0 or higher +* SQLite 3.8.3 or higher +* Support for Microsoft SQL Server in any version is discontinued + +Impact +====== + +The TYPO3 Core codebase and extensions tailored for v12 and above can use +features implemented with PHP up to and including 8.1. Running TYPO3 v12 with older PHP +versions or database engines will trigger fatal errors. + +Affected Installations +====================== + +Hosting a TYPO3 instance based on version 12 may require an update of the +PHP platform and the database engine. + +Migration +========= + +TYPO3 v11 supports PHP 8.1 and database engines required by v12. This allows upgrading +the platform in a first step and upgrading to TYPO3 v12 in a second step. + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96575-UpdateToCodeMirror6.rst b/Documentation/Changelog/12.0/Breaking-96575-UpdateToCodeMirror6.rst new file mode 100644 index 0000000..a980d8a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96575-UpdateToCodeMirror6.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96575-1663324432: + +========================================= +Breaking: #96575 - Update to CodeMirror 6 +========================================= + +See :issue:`96575` + +Description +=========== + +TYPO3 Core now ships with CodeMirror v6. +CodeMirror is used as editor in the TYPO3 Backend for editing +HTML records, TypoScript templates and plaintext files in the file module. + +Impact +====== + +Existing CodeMirror v5 addons and modes need to be adapted for CodeMirror v6 +which brings a completely rewritten plugin infrastructure. + +Affected installations +====================== + +TYPO3 Installations with third-party extensions that register +custom CodeMirror addons or modes. + +Migration +========= + +Please consult https://codemirror.net/docs/migration/ for details on +CodeMirror migration itself. + +The TYPO3 integration has been adapted to reflect the changed modes and +addons in the `T3editor` configuration files: + +Adapt the mode configuration in :file:`Configuration/Backend/T3editor/Modes.php` +to use :php:`JavaScriptModuleInstruction` statements that pick ES6 module +for a specific language mode. A RequireJS module like +:js:`codemirror/mode/css/css` is now shipped in `@codemirror/lang-css`: + +.. code-block:: php + + use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + + return [ + 'css' => [ + 'module' => JavaScriptModuleInstruction::create('@codemirror/lang-css', 'css')->invoke(), + 'extensions' => ['css'], + ], + ]; + +Addons no longer bring :php:`cssFiles` or :php:`options`, but only consist +of a :php:`module` and an optional :php:`keymap` statement, as the `options` +interface is gone in CodeMirror v6 and stylesheets are to be embedded into +JavaScript. + +See following example for the registration of the history addon via +:file:`Configuration/Backend/T3editor/Modes.php`: + +.. code-block:: php + + use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + + return [ + 'history' => [ + 'module' => JavaScriptModuleInstruction::create('@codemirror/commands', 'history')->invoke(), + 'keymap' => JavaScriptModuleInstruction::create('@codemirror/commands', 'historyKeymap'), + ], + ]; + +.. index:: Backend, JavaScript, NotScanned, ext:t3editor diff --git a/Documentation/Changelog/12.0/Breaking-96604-RemovedModuleTemplate-addJavaScriptCode.rst b/Documentation/Changelog/12.0/Breaking-96604-RemovedModuleTemplate-addJavaScriptCode.rst new file mode 100644 index 0000000..222c402 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96604-RemovedModuleTemplate-addJavaScriptCode.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96604: + +============================================================== +Breaking: #96604 - Removed ModuleTemplate->addJavaScriptCode() +============================================================== + +See :issue:`96604` + +Description +=========== + +The backend module related class method :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->addJavaScriptCode()` +has been removed. + +The method allowed to add JavaScript inline code to the document +body of backend modules. This collides with `Content-Security-Policy` HTTP headers +and needs to be avoided. + +The method has been marked :php:`@internal` in late TYPO3 v11 development and +has been removed in v12. + +Impact +====== + +Calling the method in an instance triggers a fatal PHP error. + +Affected Installations +====================== + +The extension scanner finds usage candidates as weak match. In general, +instances with extensions that come with own backend modules may be affected. + +Migration +========= + +There are various ways to migrate away from inline JavaScript in backend modules, a modern TYPO3 v12 +solution is :doc:`JavaScript ES6 modules <Feature-96510-InfrastructureForJavaScriptModulesAndImportmaps>`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96616-RemoveFrontendLoginModeForPages.rst b/Documentation/Changelog/12.0/Breaking-96616-RemoveFrontendLoginModeForPages.rst new file mode 100644 index 0000000..df374e1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96616-RemoveFrontendLoginModeForPages.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96616: + +======================================================= +Breaking: #96616 - Remove Frontend Login Mode for pages +======================================================= + +See :issue:`96616` + +Description +=========== + +In order to reduce complexity for frontend requests, +the rarely used `frontend user login mode` functionality has been removed. + +It previously allowed to define branches, which should behave as if a user +or usergroup was not logged in, even though a user was kept logged in as the cookie was +not removed during such a request. This feature was only introduced back in 2004 by Kasper +to overcome caching issues on typo3.org and is considered an edge-case feature, +which is better suited in an extension solved via a PSR-15 middleware nowadays. + +As a consequence, next to the corresponding DB / TCA field :php:`pages.fe_login_mode` +the following public methods have been removed: + +- :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->hideActiveLogin()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkIfLoginAllowedInBranch()` + +Additionally, the following TypoScript configuration has no effect anymore: + +- :typoscript:`config.sendCacheHeaders_onlyWhenLoginDeniedInBranch` + +Impact +====== + +The functionality is no longer part of TYPO3 Core. Calling the related methods +in custom extension code will lead to a fatal PHP error. The extension scanner +will detect usages as weak match. + +Affected Installations +====================== + +TYPO3 installations currently using the functionality or calling the +mentioned methods in custom extension code, which is very unlikely. + +This can be checked by searching for database records in the DB "pages" +table having "fe_login_mode > 0". + +Migration +========= + +Remove any usage of the mentioned methods. + +In case you currently rely on the functionality, use the upgrade wizard +provided by the install tool to fetch and load the public `fe_login_mode` +extension from `TER <https://extensions.typo3.org/extension/fe_login_mode>`_. +This extension provides the same functionality using a PSR-15 middleware. + +.. index:: Frontend, TCA, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96641-TypoLinkRelatedHooksRemoved.rst b/Documentation/Changelog/12.0/Breaking-96641-TypoLinkRelatedHooksRemoved.rst new file mode 100644 index 0000000..168f84a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96641-TypoLinkRelatedHooksRemoved.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96641: + +================================================= +Breaking: #96641 - TypoLink related hooks removed +================================================= + +See :issue:`96641` + +Description +=========== + +Following hooks, related to link generation with TYPO3's Frontend +Link building technique `typoLink`, have been removed in favor of +the new PSR-14 events :php:`\TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent`: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typoLink_PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['getATagParamsPostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['urlProcessing']['urlProcessors']` + +Especially the latter functionality was not available +for all link types (only mail, file + external links). + +At the same time, some external links and mail links were not +using `typoLink`, because internally the method :php:`$cObj->http_makelinks()` +had been used. + +This architectural design flaw had been solved by introducing +a unified Link Generation API ("LinkFactory"). + +Impact +====== + +Using these hooks in extensions has no effect anymore in TYPO3 v12+. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using these hooks for +modifying links. The extension scanner in the Upgrade module / Install +tool will show affected occurrences. + +Migration +========= + +In order to make TYPO3 extensions compatible with TYPO3 v11 and +TYPO3 v12 simultaneously, the new PSR-14 event :php:`AfterLinkIsGeneratedEvent` +should be added in addition to the existing hooks. + +The new :doc:`PSR-14 event <../12.0/Feature-96641-NewPSR-14EventForModifyingLinks>` +contains all information about the link result and the configuration itself. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96659-RegistrationOfCObjectsViaTYPO3_CONF_VARS.rst b/Documentation/Changelog/12.0/Breaking-96659-RegistrationOfCObjectsViaTYPO3_CONF_VARS.rst new file mode 100644 index 0000000..9ae776e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96659-RegistrationOfCObjectsViaTYPO3_CONF_VARS.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96659: + +=============================================================== +Breaking: #96659 - Registration of cObjects via TYPO3_CONF_VARS +=============================================================== + +See :issue:`96659` + +Description +=========== + +Since TYPO3 v12.0. custom Content Objects such as `TEXT` or `HMENU` +are registered via the service configuration. + +The previous way of registering custom Content Objects via +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['ContentObjects']` +added in TYPO3 v7.2 (see :issue:`64386`) has been removed. + +Impact +====== + +TYPO3 installations using the previous way of registering custom or overridden +Content Objects will not return the rendered frontend output for this specific +Content Object anymore, which is a very rare case. + +Affected Installations +====================== + +TYPO3 installations with extensions registering custom Content Objects. + +Migration +========= + +Extensions registering custom Content Objects should now use the service +configuration: + +.. code-block:: yaml + + MyCompany\MyPackage\ContentObject\CustomContentObject: + tags: + - name: frontend.contentobject + identifier: 'MY_OBJ' + +Extensions can be made compatible with TYPO3 v7 - v12 by keeping the "old" +way of registration in :file:`ext_localconf.php` and additionally add the new +registration way, without any further changes. + +.. index:: Frontend, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96708-RemovedSupportForAccesskeysInHMENU.rst b/Documentation/Changelog/12.0/Breaking-96708-RemovedSupportForAccesskeysInHMENU.rst new file mode 100644 index 0000000..05da99c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96708-RemovedSupportForAccesskeysInHMENU.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96708: + +========================================================== +Breaking: #96708 - Removed support for accesskeys in HMENU +========================================================== + +See :issue:`96708` + +Description +=========== + +TYPO3's built-in support for menu generation, adding :html:`accesskey` +HTML attributes to menu items has been removed. + +As stated by various sources such as + +* https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey#accessibility_concerns +* https://webaim.org/standards/wcag/checklist#:~:text=accesskey%20should%20typically%20be%20avoided + +this feature should only be used by explicitly defining access keys when +a use-case is given. + +TYPO3 menus previously used a random link title as an access key, when the +TypoScript property :typoscript:`HMENU.accessKey = 1` was set. + +Along with the accessKey functionality, the public property +:php:`TypoScriptFrontendController->accessKey` has been removed. + +Impact +====== + +Setting the TypoScript option has no effect anymore. + +Accessing the removed public property will trigger a PHP warning. The +extension scanner will detect usages as weak match. + +Affected Installations +====================== + +TYPO3 installations using the :typoscript:`accessKey` feature of HMENU or +accessing the :php:`accessKey` property of :php:`TypoScriptFrontendController`. + +TYPO3 installations using the global :html:`accesskey` HTML attribute in +their own code will still work as before. + +Migration +========= + +Using the :html:`accesskey` HTML attribute should be avoided in general, but +if needed, integrators should add it to their templates in a sensible way, +depending on the accessibility needs. + +.. index:: Frontend, TypoScript, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96726-RequestHandlerFunctionalityOfExtbaseRemoved.rst b/Documentation/Changelog/12.0/Breaking-96726-RequestHandlerFunctionalityOfExtbaseRemoved.rst new file mode 100644 index 0000000..36a714e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96726-RequestHandlerFunctionalityOfExtbaseRemoved.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96726: + +================================================================== +Breaking: #96726 - RequestHandler functionality of Extbase removed +================================================================== + +See :issue:`96726` + +Description +=========== + +Extbase - TYPO3's MVC system has had a way to define "RequestHandlers", which +were primarily introduced back in 2010 to distinguish between Frontend Plugins, +Backend Modules, CLI commands and Fluid Widgets. + +In TYPO3 v8, CLI commands have been migrated to Symfony Console. + +Since TYPO3 v9, Backend Requests are not using RequestHandlers anymore. + +In TYPO3 v10, the registration of custom RequestHandlers has been moved from +TypoScript to PHP files (during build time). + +In TYPO3 v11, Fluid Widgets have been removed. + +The only available support is for Frontend requests (plugin), which could have +been overridden by custom implementations. + +From TYPO3 v12.0 onwards, Extbase Bootstrap for plugins is now calling the +Extbase dispatcher directly, without loading possible RequestHandlers anymore. + +This change removes a layer for each request of a plugin, and thus, a layer +of indirection. + +It is not possible anymore to implement custom RequestHandlers, as all related +functionality has been removed. + +Impact +====== + +Registration of custom RequestHandlers will not have any effect anymore. + +Affected Installations +====================== + +TYPO3 installations with extensions registering custom Extbase RequestHandlers. +This can be checked if an extension provides a +:file:`Configuration/Extbase/RequestHandlers.php` file or using the +extension scanner, which will report any usage of the now removed +:php:`\TYPO3\CMS\Extbase\Mvc\RequestHandlerInterface`. + +Migration +========= + +It is recommended to avoid custom RequestHandlers, as their use case is +limited. For TYPO3 v12-only support, custom RequestHandlers and their +implementation can be fully removed and developed differently. + +For Frontend plugins, it is still possible to use a different bootstrap +than the :php:`\TYPO3\CMS\Extbase\Core\Bootstrap` class, via TypoScript. + +For backend modules, custom :php:`routeTargets` can be defined in the +module registration concept. + +Using the Decorator pattern is usually good practice to achieve such +functionality. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-96733-RemovedSupportForModuleHandlingBasedOnTBE_MODULES.rst b/Documentation/Changelog/12.0/Breaking-96733-RemovedSupportForModuleHandlingBasedOnTBE_MODULES.rst new file mode 100644 index 0000000..b30ce26 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96733-RemovedSupportForModuleHandlingBasedOnTBE_MODULES.rst @@ -0,0 +1,113 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96733: + +=========================================================================== +Breaking: #96733 - Removed support for module handling based on TBE_MODULES +=========================================================================== + +See :issue:`96733` + +Description +=========== + +In previous TYPO3 versions, all available Backend modules were stored in the +global array :php:`$TBE_MODULES`. + +Next to a very scattered and dated API to work with this array, it was still +possible to modify entries of modules through this global array. + +With the introduction of the new Module Registration API, the global array is +not filled anymore since TYPO3 v12.0. + +In addition, any previous functionality related to handling of the global array +has been removed. + +The main and foremost important previous API piece +:php:`TYPO3\CMS\Backend\Module\ModuleLoader` has been removed completely as it +was usually populated with data of `$TBE_MODULES`. + +The PHP classes + +* :php:`TYPO3\CMS\Backend\Domain\Model\Module\BackendModule` +* :php:`TYPO3\CMS\Backend\Domain\Repository\Module\BackendModuleRepository` +* :php:`TYPO3\CMS\Backend\Module\ModuleStorage` + +which were related to building the Module Menu on the left side +of the TYPO3 Backend have been removed as well. The new API based +on the :php:`ModuleProvider` takes care of permission handling +and returns objects of :php:`ModuleInterface`, the +rendering is now based on a well-defined OOP-based approach, which +is used throughout all places in TYPO3 Backend in a unified way. + +As for TYPO3 Backend Modules, based on Extbase, their additional information +(allowed controllers and actions) was previously stored in a different +global array +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['modules'][$pluginName]['controllers']` +which has been merged with the Module Registry API and has been removed as well. + +Because the registration of modules is now done in the extension's +:file:`Configuration/Backend/Modules.php` file, the following +API methods do no longer have any effect and will be removed in +TYPO3 v13.0: + +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addModule()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addNavigationComponent()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addCoreNavigationComponent()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule()` + +The User TSconfig :typoscript:`options.hideModules.[moduleGroup]` has been +removed. All modules are registered with a unique identifier. Therefore, the +TSconfig :typoscript:`options.hideModules` should be used for all modules +directly. This still allows to hide a whole group, e.g. `web`, next to +regular modules, such as `web_layout`. + +Impact +====== + +Accessing or manipulating the now non-existent :php:`$GLOBALS[TBE_MODULES]` +array will result in a PHP warning. + +Referencing any of the removed PHP classes will result in a PHP fatal error. + +Using one of the mentioned API methods won't have any effect. + +Affected Installations +====================== + +TYPO3 installations working with hooks or events effectively reading or +manipulating the global array `$TBE_MODULES` or accessing any of the removed +PHP classes / methods by third-party extensions. + +Any occurrences can be detected via the Extension Scanner. + +Migration +========= + +Migrate to the new Module Registration API, and use the :php:`ModuleProvider` +class to get allowed modules and work with the objects. The current module +information (an implementation of :php:`ModuleInterface`) is stored in a +TYPO3 Backend request within the `module` option of a TYPO3 Backend route, +which can be accessed via :php:`$request->getAttribute('route')->getOption('module')`. + +As soon as the new TYPO3 :php:`BackendModuleValidator` PSR-15 middleware +has validated the module for the current user, the :php:`ModuleInterface` +object is also added to the current request and can then be accessed +via :php:`$request->getAttribute('module')` in custom middlewares or +components. + +.. note:: + + With the new module registration, the module identifier is also used + as the route identifier. Therefore, the `moduleName` option is removed + from the TYPO3 backend route object. + +The registration has to be moved from :file:`ext_tables.php` to the +:file:`Configuration/Backend/Modules.php` file. See the +:doc:`feature changelog <../12.0/Feature-96733-NewBackendModuleRegistrationAPI>` +for more information regarding the new registration. + +Instead of :typoscript:`options.hideModules.web = layout`, use +:typoscript:`options.hideModules = web_layout`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96806-RemovedHookForModifyingButtonBar.rst b/Documentation/Changelog/12.0/Breaking-96806-RemovedHookForModifyingButtonBar.rst new file mode 100644 index 0000000..9595883 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96806-RemovedHookForModifyingButtonBar.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96806: + +======================================================== +Breaking: #96806 - Removed hook for modifying button bar +======================================================== + +See :issue:`96806` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend\Template\Components\ButtonBar']['getButtonsHook']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-96806-PSR-14EventForModifyingButtonBar>` +as a direct replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96812-NoFrontendTypoScriptBasedTemplateOverridesInTheBackend.rst b/Documentation/Changelog/12.0/Breaking-96812-NoFrontendTypoScriptBasedTemplateOverridesInTheBackend.rst new file mode 100644 index 0000000..329e2cf --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96812-NoFrontendTypoScriptBasedTemplateOverridesInTheBackend.rst @@ -0,0 +1,256 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96812: + +================================================================================= +Breaking: #96812 - No Frontend TypoScript based template overrides in the backend +================================================================================= + +See :issue:`96812` + +Description +=========== + +A couple of Core extensions with backend module controllers allowed overriding Fluid +templates using frontend TypoScript. The two documented extensions are EXT:dashboard +and the backend page module. The Extbase based backend extensions EXT:belog, EXT:beuser +and EXT:extensionmanager allowed this implicitly too, but this detail has never +been directly documented. + +This functionality has been removed: All Core extensions, and in general all extensions +that switch to the :doc:`simplified backend templating <Feature-96730-SimplifiedExtbackendModuleTemplateAPI>` +no longer use the frontend TypoScript based override approach. This has been superseded +by a general override strategy based on TSconfig, as described in :doc:`this changelog +entry <Feature-96812-OverrideBackendTemplatesWithTSconfig>`. + +This change became necessary since configuring backend modules via frontend TypoScript +is flawed by design: It on one hand forces backend modules to parse the full frontend +TypoScript, which is a general performance penalty in the backend - the backend then +scales with the amount of frontend TypoScript. Also, the implementation is based on +the Extbase ConfigurationManager, which leads to the situation that casual non-Extbase +backend modules have an indirect dependency to lots of Extbase code. But most importantly, +frontend TypoScript is always bound to a page record. There is no concept in the frontend +for the "root" page zero, since that page can not be rendered in the frontend. In the backend +however, we have many modules that are not within page context: In general all modules that +do not have a page tree. This gives the "Use frontend TypoScript to configure backend modules" +approach some hard headaches: It forces the ConfigurationManager to still select "some" page as +frontend TypoScript entry point. In practice, the first non-hidden tree-level-one page +that has a sys_template record is selected. This strategy is both ugly and troublesome, +and leads to the situation that backend module configuration had to be bound to this first page, +which could easily explode when for instance pages are resorted - apart from the fact that +this scenario is hard to understand and to debug. + +Impact +====== + +The combination of performance drawbacks, the tight Extbase coupling, and the +"which frontend TypoScript should be parsed for page zero?" problematic leads to the decision +to phase out the "frontend TypoScript for backend module configuration" approach that +Extbase brought in. + +One part of this process is a generic backend approach to :doc:`override backend templates +using TSconfig <Feature-96812-OverrideBackendTemplatesWithTSconfig>`. This has impact +on EXT:dashboard widgets and page module template overrides. + +Affected Installations +====================== + +Instances with extensions that configure own EXT:dashboard widgets or override templates +of existing dashboard widgets using Frontend TypoScript are affected, as well as instances +that override page module templates as described in :doc:`this changelog entry <../10.3/Feature-90348-NewFluid-basedReplacementForPageLayoutView>`. + +Migration +========= + +Page module template overrides +------------------------------ + +An instance sets frontend TypoScript like this: + +.. code-block:: typoscript + + module.tx_backend.view.templateRootPaths.1644483508 = EXT:myext/Resources/Private/Templates/ + module.tx_backend.view.partialRootPaths.1644483508 = EXT:myext/Resources/Private/Partials/ + +If extension "myext" now delivered a template file such as :file:`Resources/Private/Templates/PageLayout/PageLayout.html`, +that template file was used for rendering the page module instead of the default template. + +As described in this :doc:`changelog <Feature-96812-OverrideBackendTemplatesWithTSconfig>`, +the new definition is now done using TSconfig. The extension "myext" with Composer name "myvendor/myext" can +deliver a :file:`Configuration/page.tsconfig` file (see :doc:`changelog <Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions>`) +with the below content to substitute the old definition and keep overriding template files at the current position: + +.. code-block:: typoscript + + # Pattern: templates."composer-name"."something-unique" = "overriding-extension-composer-name":"entry-path" + templates.typo3/cms-backend.1644483508 = myvendor/myext:Resources/Private + +EXT:dashboard +------------- + +Required changes regarding existing template overrides of the dashboard extension and the +dashboard widget registration itself are a bit broader. Let's look at this in detail: + +Templating +.......... + +An extension delivers this TypoScript: + +.. code-block:: typoscript + + module.tx_dashboard { + view { + templateRootPaths { + 1644485473 = EXT:myext/Resources/Private/Templates/Dashboard/Widgets/ + } + } + } + +This instructed the dashboard widget renderer to look up widget templates in this +path, too. The new registration for extension "myext" with Composer name "myvendor/myext" +using file :file:`Configuration/page.tsconfig` +(see :doc:`changelog <Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions>`) +could look like this: + +.. code-block:: typoscript + + # Pattern: templates.typo3/cms-dashboard."something-unique" = "overriding-extension-composer-name":"entry-path" + templates.typo3/cms-dashboard.1644485473 = myvendor/myext:Resources/Private + +A widget template is then put to :file:`Resources/Private/Templates/Dashboard/Widgets/MyExtensionWidget.html`. +Extensions that want to stay compatible with both TYPO3 Core v11 and v12 should simply define both the +old way and the new way. + +Widget registration using Services.yaml +....................................... + +This part (changing :file:`Services.yaml` and widgets PHP code) is not strictly needed +for extensions that configure and deliver own widgets. Extension that work with TYPO3 +v11 just work in v12 as well. However, the registration and PHP code changed a bit, +extensions that want to stay deprecation log free with v12 should adapt. The changes +outlined below will be mandatory with v13. + +The registration of widgets using :file:`Services.yaml` should be changed a bit. It +was previously documented that widgets can inject an instance of :php:`StandaloneView`. +This approach was flawed: The :php:`StandaloneView` has an internal dependency to the +current PSR-7 request. The request is not available via dependency injection since it is +a heavily stateful runtime dependency. Injecting a view that depends on request is thus +a violation and only worked with EXT:dashboard because :php:`StandaloneView` hides that +dependency internally and creates a new request on the fly, which is a hack in that +implementation that should be avoided. + +The view based on EXT:core :php:`ViewInterface` with its factory for backend views based +on EXT:backend :php:`BackendViewFactory` makes the dependency to the request object explicit. +As such, a "prepared" view can not be injected using DI anymore. + +This has impact on both the PHP implementation of widgets, as well as the widget +dependency injection configuration. + +Let's say a widget has been registered like this: + +.. code-block:: yaml + + # This is defined in EXT:dashboard Services.yaml already, extensions + # must not define this in their Services.yaml files again. + dashboard.views.widget: + class: 'TYPO3\CMS\Fluid\View\StandaloneView' + public: true + factory: ['TYPO3\CMS\Dashboard\Views\Factory', 'widgetTemplate'] + + # This is your custom widget registration in your extensions Services.yaml + dashboard.widget.sysLogErrors: + class: 'TYPO3\CMS\Dashboard\Widgets\BarChartWidget' + arguments: + $dataProvider: '@TYPO3\CMS\Dashboard\Widgets\Provider\SysLogErrorsDataProvider' + $view: '@dashboard.views.widget' + $buttonProvider: '@TYPO3\CMS\Dashboard\Widgets\Provider\SysLogButtonProvider' + tags: + ... + +The important line is :yaml:`$view: '@dashboard.views.widget'`: This instructs the DI +to inject an instance of :php:`StandaloneView` using the EXT:dashboard :php:`Factory::widgetTemplate()` +method for argument :php:`$view`. The :yaml:`dashboard.views.widget` is deprecated since +TYPO3 Core v12 and should not be used anymore. It logs a deprecation message upon use +during build-time and will be removed in v13 together with the :php:`Factory`. + +The new registration should be adapted to this, simply removing the :php:`$view` argument: + +.. code-block:: yaml + + # This is your custom widget registration in your extensions Services.yaml + dashboard.widget.sysLogErrors: + class: 'TYPO3\CMS\Dashboard\Widgets\BarChartWidget' + arguments: + $dataProvider: '@TYPO3\CMS\Dashboard\Widgets\Provider\SysLogErrorsDataProvider' + $buttonProvider: '@TYPO3\CMS\Dashboard\Widgets\Provider\SysLogButtonProvider' + tags: + ... + +Now the PHP implementation. The above example references the :php:`BarChartWidget` class +to take care of rendering. The class looked like this before (shortened): + +.. code-block:: php + + class BarChartWidget implements WidgetInterface + { + public function __construct( + private readonly WidgetConfigurationInterface $configuration, + private readonly ChartDataProviderInterface $dataProvider, + private readonly StandaloneView $view, + private readonly $buttonProvider = null, + private readonly array $options = [] + ) { + } + + public function renderWidgetContent(): string + { + $this->view->setTemplate('Widget/ChartWidget'); + $this->view->assignMultiple([...]); + return $this->view->render(); + } + } + +Since :php:`StandaloneView` should not be injected anymore, we now inject the +:php:`BackendViewFactory` instead and create a view using the factory in +:php:`renderWidgetContent()`. The factory :php:`create()` method needs the request +object. To get this, widgets should now implement :php:`RequestAwareWidgetInterface`, +the EXT:dashboard framework will then :php:`setRequest()` the current request to the widget +immediately after widget instantiation. The new code thus looks like this: + +.. code-block:: php + + class BarChartWidget implements WidgetInterface, RequestAwareWidgetInterface + { + private ServerRequestInterface $request; + + public function __construct( + private readonly WidgetConfigurationInterface $configuration, + private readonly ChartDataProviderInterface $dataProvider, + private readonly BackendViewFactory $backendViewFactory, + private readonly $buttonProvider = null, + private readonly array $options = [] + ) { + } + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } + + public function renderWidgetContent(): string + { + // The second argument is the Composer 'name' of the extension that adds the widget. + // It is needed to instruct BackendViewFactory to look up templates in this package + // next to the default location 'typo3/cms-dashboard', too. + $view = $this->backendViewFactory->create($this->request, ['typo3/cms-dashboard', 'myVendor/myPackage']); + $view->assignMultiple([...]); + return $view->render('Widget/ChartWidget'); + } + } + +The actual implementation in TYPO3 v12 is still slightly different to keep +compatibility with extensions that re-use Core widgets and need v11 and v12 +compatibility at the same time. Those Core classes will be adapted in v13 +to the above outline version, though. + +.. index:: Backend, TSConfig, TypoScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96829-RemovedBackendUtility-getFuncInput.rst b/Documentation/Changelog/12.0/Breaking-96829-RemovedBackendUtility-getFuncInput.rst new file mode 100644 index 0000000..fd94541 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96829-RemovedBackendUtility-getFuncInput.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96829: + +========================================================= +Breaking: #96829 - Removed BackendUtility->getFuncInput() +========================================================= + +See :issue:`96829` + +Description +=========== + +Method :php:`BackendUtility::getFuncInput()` is incompatible with +`Content-Security-Policy` HTTP headers due to its onchange JavaScript +handler, and has been removed. + +Impact +====== + +Instances with extensions using the method will raise a fatal +PHP error upon use. + +Affected Installations +====================== + +The method is part of very old-school backend module code and of limited use. +TYPO3 Core code does not use it since at least v9, it is relatively unlikely +backend modules of extensions still use this method. The extension scanner +finds usages with a strong match. + +Migration +========= + +No direct migration available. The input field HTML should most likely be inlined +to a template and eventual JavaScript events should be handled with a JavaScript +module. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96831-EnforceHTMLSanitizerDuringFrontendRendering.rst b/Documentation/Changelog/12.0/Breaking-96831-EnforceHTMLSanitizerDuringFrontendRendering.rst new file mode 100644 index 0000000..db3c92b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96831-EnforceHTMLSanitizerDuringFrontendRendering.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96831: + +=================================================================== +Breaking: #96831 - Enforce HTML sanitizer during frontend rendering +=================================================================== + +See :issue:`96831` + +Description +=========== + +TYPO3 security fix `TYPO3-CORE-SA-2021-013 <https://typo3.org/security/advisory/typo3-core-sa-2021-013>`_ +introduced Composer package `typo3/html-sanitizer` to mitigate cross-site scripting vulnerabilities in +rich-text content. In order to relax the strict invocation, a corresponding feature flag has been added +in a follow-up release - which only was a temporary solution. + +The feature flag `security.frontend.htmlSanitizeParseFuncDefault` is dropped, and content processing via +TypoScript :typoscript:`stdWrap.parseFunc` now enables HTML sanitization per default in case it has not been +disabled explicitly in corresponding invocation. + +Sites that used a version prior to TYPO3 v12.0 received a corresponding deprecation message already. + +Impact +====== + +Rich-text content processed with TypoScript :typoscript:`stdWrap.parseFunc` is HTML sanitized per default. +Feature flag `security.frontend.htmlSanitizeParseFuncDefault` does not have any effect anymore. + +Affected Installations +====================== + +All scenarios that use TypoScript :typoscript:`stdWrap.parseFunc`, a direct invocation via PHP of +:php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::parseFunc()` or Fluid +view-helper :html:`<f:format.html>`. + +Migration +========= + +The following documents already tackled and described the scenario and implications: + +* :doc:`9.5.x: Important: #94484 - Introduce HTML Sanitizer <../9.5.x/Important-94484-IntroduceHTMLSanitizer>` +* :doc:`12.0: Breaking: #96520 - Enforce non-empty configuration in cObj::parseFunc <Breaking-96520-EnforceNon-emptyConfigurationInCObjparseFunc>` + +.. index:: Frontend, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96835-HttpsAsDefaultSchemeInPageRouter.rst b/Documentation/Changelog/12.0/Breaking-96835-HttpsAsDefaultSchemeInPageRouter.rst new file mode 100644 index 0000000..b6a6f35 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96835-HttpsAsDefaultSchemeInPageRouter.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96835: + +======================================================== +Breaking: #96835 - https as default scheme in PageRouter +======================================================== + +See :issue:`96835` + +Description +=========== + +The fallback scheme in :php:`\TYPO3\CMS\Core\Routing\PageRouter::generateUri()` is set to `https` instead of `http` when linking to other pages. + +Impact +====== + +If the site configuration does not provide a scheme but only a domain (e.g. `www.domain.tld`), the scheme is set to `https`. + +Affected Installations +====================== + +All installations which use a site configuration without providing a scheme and which must not be delivered through `https`. + +Migration +========= + +If `https` can't be used, the entry point must define the scheme, e.g. `http://www.domain.tld`. + +.. index:: Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96874-CKEditor-relatedPluginsAndConfiguration.rst b/Documentation/Changelog/12.0/Breaking-96874-CKEditor-relatedPluginsAndConfiguration.rst new file mode 100644 index 0000000..f977ffb --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96874-CKEditor-relatedPluginsAndConfiguration.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96874-1664488429: + +============================================================= +Breaking: #96874 - CKEditor-related plugins and configuration +============================================================= + +See :issue:`96874` + +Description +=========== + +TYPO3 v12 ships with CKEditor 5, which is a completely new, rewritten editor +compared to CKEditor 4 which was shipped since TYPO3 v8. + +Any kind of plugin, which was written for CKEditor 4 is not compatible anymore. + +In addition, since CKEditor 5 does not grant HTML input the same way as before. + +Impact +====== + +Any plugin in CKEditor will not be loaded anymore in TYPO3 v12. + +It might be possible to have data loss when editing and saving data, +especially since some configuration formats have been changed. + +Affected installations +====================== + +TYPO3 installations with custom extensions extending CKEditor with plugins, +or relying on a specific logic to save data with specific contents +(such as additional allowed HTML tags). + +Migration +========= + +In general, it is advised to read the `CKEditor 4 to 5 migration <https://ckeditor.com/docs/ckeditor5/latest/installation/getting-started/migration-from-ckeditor-4.html#plugins>`__ +to understand the conceptual changes, also related to plugins. + +Writing a custom plugin for CKEditor 5 can be done in TypeScript or JavaScript, +using the `CKEditor 5 plugin system <https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/plugins.html>`__. + +Example - A timestamp plugin :js:`@my-vendor/my-package/timestamp-plugin.js` +which adds a toolbar item to add the current timestamp into the editor. + +.. code-block:: javascript + + import { Plugin } from '@ckeditor/ckeditor5-core'; + import { ButtonView } from '@ckeditor/ckeditor5-ui'; + + export class Timestamp extends Plugin { + static pluginName = 'Timestamp'; + + init() { + const editor = this.editor; + + // The button must be registered among the UI components of the editor + // to be displayed in the toolbar. + editor.ui.componentFactory.add(Timestamp.pluginName, () => { + // The button will be an instance of ButtonView. + const button = new ButtonView(); + + button.set({ + label: 'Timestamp', + withText: true + }); + + // Execute a callback function when the button is clicked + button.on('execute', () => { + const now = new Date(); + + // Change the model using the model writer + editor.model.change(writer => { + + // Insert the text at the user's current position + editor.model.insertContent(writer.createText(now.toString())); + }); + }); + + return button; + }); + } + } + +In the RTE configuration, this then needs to be added like this: + +.. code-block:: yaml + + editor: + config: + importModules: + - { module: '@my-vendor/my-package/timestamp-plugin.js', exports: ['Timestamp'] } + toolbar: + items: + - bold + - italic + - '|' + - clipboard + - undo + - redo + - '|' + - timestamp + +.. index:: RTE, NotScanned, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.0/Breaking-96879-RemovedHookGetCacheTimeout.rst b/Documentation/Changelog/12.0/Breaking-96879-RemovedHookGetCacheTimeout.rst new file mode 100644 index 0000000..e6ac0ef --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96879-RemovedHookGetCacheTimeout.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96879: + +=================================================== +Breaking: #96879 - Hook "get_cache_timeout" removed +=================================================== + +See :issue:`96879` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['get_cache_timeout']` +used in TYPO3 Frontend for changing the cache timeout of a page stored in the +TYPO3 "pages" cache has been removed. + +Impact +====== + +If an extension has registered a hook in :file:`ext_localconf.php` it will not +be executed anymore in TYPO3 v12 or later. + +Affected Installations +====================== + +TYPO3 installations using this hook in custom extensions. + +Migration +========= + +Use the newly introduced PSR-14 event :ref:`ModifyCacheLifetimeForPageEvent <feature-96879-1663513042>` +and register a custom event listener. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96889-RequirePHPMbstringAndIntl.rst b/Documentation/Changelog/12.0/Breaking-96889-RequirePHPMbstringAndIntl.rst new file mode 100644 index 0000000..df2b221 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96889-RequirePHPMbstringAndIntl.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96889: + +================================================ +Breaking: #96889 - Require PHP mbstring and intl +================================================ + +See :issue:`96889` + +Description +=========== + +The two PHP extensions :php:`mbstring` and :php:`intl` are required to +be loaded when running TYPO3 v12. + +:php:`mbstring` is a common extension that is either compiled directly +into PHP or available as standard package in all distributions and +operating systems. Similar for :php:`intl`. While there are symfony +packages that mimic these extensions partially if not available, these +"polyfill" packages are slower, and most importantly, they implement only +parts of the native extensions. To further improve TYPO3 character set +and internationalization handling, the system needs the full functionality. + +Impact +====== + +System environments not providing these PHP extensions may fail. + +Affected Installations +====================== + +The install tool "Environment Status" and the reports module notify +about missing PHP extensions, and it is shown during the installation process. + +Migration +========= + +Provide the extensions in the PHP. + +A debian / ubuntu based Linux host typically install such packages with +a command similar to this: + +.. code-block:: bash + + sudo apt install php8.1-mbstring php8.1-intl + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96899-DisplayWarningMessagesHookRemoved.rst b/Documentation/Changelog/12.0/Breaking-96899-DisplayWarningMessagesHookRemoved.rst new file mode 100644 index 0000000..264482c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96899-DisplayWarningMessagesHookRemoved.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96899: + +======================================================== +Breaking: #96899 - "displayWarningMessages" hook removed +======================================================== + +See :issue:`96899` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['displayWarningMessages']` +has been removed in favor of a new PSR-14 event +:php:`\TYPO3\CMS\Backend\Controller\Event\ModifyGenericBackendMessagesEvent`. + +The hook was used to display messages in the About module. + +Impact +====== + +Registered hooks are not executed anymore. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook, which is very +unlikely. The extension scanner will report possible usages. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <Feature-96899-NewPSR-14EventModifyGenericBackendMessagesEvent>` +as a direct replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-96904-ExtreportsReportsDoNotReceiveParentObject.rst b/Documentation/Changelog/12.0/Breaking-96904-ExtreportsReportsDoNotReceiveParentObject.rst new file mode 100644 index 0000000..7fecdc6 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96904-ExtreportsReportsDoNotReceiveParentObject.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96904: + +=================================================================== +Breaking: #96904 - ext:reports reports do not receive parent object +=================================================================== + +See :issue:`96904` + +Description +=========== + +Extensions that add own reports in EXT:reports do not receive an instance +of :php:`TYPO3\CMS\Reports\Controller\ReportController` as constructor +argument anymore. + +Handing over "parent object" to single reports as manual constructor argument +was pretty much useless since all state in :php:`ReportController` +is :php:`protected`. Not having this constructor argument has the advantage +that reports can now use dependency injection. + +Impact +====== + +Extensions that register own reports to the EXT:reports extension and type hint +:php:`ReportController` as constructor argument will trigger a fatal PHP error +since that argument is no longer provided by the API. + +Affected Installations +====================== + +Instances with extensions that add own reports to EXT:reports may be affected. + +Migration +========= + +Do not expect to retrieve an instance of :php:`ReportController` as constructor +argument anymore. Code before: + +.. code-block:: php + + class MyClass implements ReportInterface + { + public function __construct(ReportController $reportController) + { + // ... + } + } + +.. code-block:: php + + class MyClass implements ReportInterface + { + // No manual constructor argument anymore, but have a dependency injection as example. + public function __construct(private readonly SomeDependency $someDependency) + { + } + } + +Single reports are currently instantiated using :php:`GeneralUtility::makeInstance()`. +To use dependency injection in own reports, a report class thus needs to be defined +:yaml:`public: true` in a :file:`Configuration/Services.yaml` file. This may change with +further TYPO3 v12 development if the reports registration is changed, though. If in doubt, +just try to go without :yaml:`public: true`. If this leads to a fatal PHP error, add it. + +.. index:: Backend, PHP-API, NotScanned, ext:reports diff --git a/Documentation/Changelog/12.0/Breaking-96935-RegisterLinkvalidatorLinktypesViaServiceConfiguration.rst b/Documentation/Changelog/12.0/Breaking-96935-RegisterLinkvalidatorLinktypesViaServiceConfiguration.rst new file mode 100644 index 0000000..699bee7 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96935-RegisterLinkvalidatorLinktypesViaServiceConfiguration.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96935: + +============================================================================= +Breaking: #96935 - Register linkvalidator linktypes via service configuration +============================================================================= + +See :issue:`96935` + +Description +=========== + +Linkvalidator `linktypes` are now registered via service configuration, also see +:doc:`feature changelog <Feature-96935-NewRegistrationForLinkvalidatorLinktype>`. +Therefore the registration via +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['checkLinks']` +has been removed. + +Additionally, to be able to use autoconfiguration, the `linktype` identifier +has to be provided by the service directly using the :php:`getIdentifier()` +method, which is now required by the :php:`LinktypeInterface`. + +In case a custom `linktype` extends +:php:`\TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype`, +only the class property `$identifier` has to be set, e.g. +:php:`protected string $identifier = 'my_linktype';`. + +Impact +====== + +Registration of custom `linktypes` via +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['checkLinks']` +is not evaluated anymore. + +The :php:`LinktypeInterface` is extended for +:php:`public function getIdentifier(): string`. + +Affected Installations +====================== + +All TYPO3 installations using the old registration. + +All TYPO3 installations with custom `linktypes`, not implementing +:php:`public function getIdentifier(): string`. + +Migration +========= + +Remove :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['checkLinks']` +from your :file:`ext_localconf.php` file. + +If :yaml:`autoconfigure` is not enabled in your :file:`Configuration/Services.(yaml|php)`, +add the tag :yaml:`linkvalidator.linktype` manually to your `linktype` service. + +.. code-block:: yaml + + Vendor\Extension\Linktype\MyCustomLinktype: + tags: + - name: linkvalidator.linktype + +Additionally, make sure to either implement +:php:`public function getIdentifier(): string` or, in case your `linktype` extends +:php:`AbstractLinktype`, to set the `$identifier` class property. + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:linkvalidator diff --git a/Documentation/Changelog/12.0/Breaking-96968-HookHeaderNoCacheRemoved.rst b/Documentation/Changelog/12.0/Breaking-96968-HookHeaderNoCacheRemoved.rst new file mode 100644 index 0000000..d8812d4 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96968-HookHeaderNoCacheRemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96968: + +=============================================== +Breaking: #96968 - Hook "headerNoCache" removed +=============================================== + +See :issue:`96968` + +Description +=========== + +The previous TYPO3 Hook "headerNoCache" registered via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['headerNoCache']` +has been removed in favor of a new PSR-14 event +:php:`ShouldUseCachedPageDataIfAvailableEvent`. + +Impact +====== + +Hooks in third-party extensions will not be executed anymore. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +Register a new PSR-14 event listener for +:ref:`ShouldUseCachedPageDataIfAvailableEvent <feature-96968-1663513232>` +in the extension's :file:`Services.yaml` to keep TYPO3 v12+ compatibility. + +Extensions can then provide compatibility with TYPO3 v11 and TYPO3 v12 at +the same time. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96982-RemovedSupportForGlobalExtensions.rst b/Documentation/Changelog/12.0/Breaking-96982-RemovedSupportForGlobalExtensions.rst new file mode 100644 index 0000000..78c4cfd --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96982-RemovedSupportForGlobalExtensions.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96982: + +======================================================== +Breaking: #96982 - Removed support for global extensions +======================================================== + +See :issue:`96982` + +Description +=========== + +Historically, TYPO3 installations could load extensions from :file:`typo3/ext/` +where developers and site administrators could share extensions through +multiple installations on the same server via symlinks. + +This feature was possible by enabling +:php:`$GLOBALS[TYPO3_CONF_VARS][EXT][allowGlobalInstall]` which was disabled +by default since TYPO3 4.0, as using this feature had several downsides with +Non-Composer based installations. Features such as "Automatic Updates" are +not possible having this functionality enabled. + +In Composer-based installations, this functionality was never supported in +a proper way. + +This functionality including the feature toggle have been removed in TYPO3 v12.0. + +Impact +====== + +Extensions within the folder :file:`typo3/ext/` will be ignored in TYPO3 v12.0 +and will be automatically disabled. + +The global option to enable this feature will be removed from +:file:`LocalConfiguration.php` automatically once the Install Tool / Maintenance module +is loaded the next time, if the option is activated. + +Affected Installations +====================== + +TYPO3 installations having the global option enabled, and have loaded extensions +in :file:`typo3/ext/`, which is unlikely in 2022. + +Migration +========= + +It is recommended to either migrate to Composer Mode, or to use symlinks +into :file:`typo3conf/ext/` (Local Extensions) to load the same extension for +multiple TYPO3 installations at once. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96983-TableColumnSubType.rst b/Documentation/Changelog/12.0/Breaking-96983-TableColumnSubType.rst new file mode 100644 index 0000000..71dbb11 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96983-TableColumnSubType.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96983: + +===================================== +Breaking: #96983 - TableColumnSubType +===================================== + +See :issue:`96983` + +Description +=========== + +The class :php:`TYPO3\CMS\Core\Type\Enumeration\TableColumnSubType` has been +removed. It has no use anymore, since TCA option `internal_type` is not +evaluated. It was set for the Extbase class :php:`ColumnMap`, but even there it +had no direct usage. + +Impact +====== + +In the rare case, that the class :php:`TableColumnSubType` is used in +custom code, it will result in a PHP fatal error. + +Affected Installations +====================== + +All installations that use :php:`TableColumnSubType` directly in their custom +code. + +Migration +========= + +There is no migration, since this enumeration has no use. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-96988-GlobalOptionAllowLocalInstallRemoved.rst b/Documentation/Changelog/12.0/Breaking-96988-GlobalOptionAllowLocalInstallRemoved.rst new file mode 100644 index 0000000..e0c6d40 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96988-GlobalOptionAllowLocalInstallRemoved.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96988: + +============================================================ +Breaking: #96988 - Global Option "allowLocalInstall" removed +============================================================ + +See :issue:`96988` + +Description +=========== + +In previous TYPO3 version it was possible to disable the functionality to +install extensions from :file:`typo3conf/ext/`. + +This was done by setting the global option +:php:`$GLOBALS['TYPO3_CONF_VARS']['EXT']['allowLocalInstall']` to false. + +The usefulness of this functionality was only a side-effect and has lost it even +more after the rise of the Composer Mode for TYPO3 Core. + +In addition, this option is only useful in the Extension Manager which is now +protected with access for only "System Maintainers", only giving special users +the power to modify the extension installation process, making TYPO3 more +flexible than 15 years ago. + +Impact +====== + +Toggling the option (which was enabled by default) has no effect anymore. It is +now always possible to install an extension available in :file:`typo3conf/ext/` +for system maintainers with the Extension Manager module for Non-Composer Mode +TYPO3 installations. + +Affected Installations +====================== + +TYPO3 Installations in Non-Composer Mode having this option turned off, which +is very rare. + +Migration +========= + +It is recommended to set proper access rights and only give users +"System Maintainer" access which should modify the list of active extensions. + +.. index:: Backend, FullyScanned, ext:extensionmanager diff --git a/Documentation/Changelog/12.0/Breaking-96996-HookCheckEnableFieldsRemoved.rst b/Documentation/Changelog/12.0/Breaking-96996-HookCheckEnableFieldsRemoved.rst new file mode 100644 index 0000000..8da707f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96996-HookCheckEnableFieldsRemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96996: + +=================================================== +Breaking: #96996 - Hook "checkEnableFields" removed +=================================================== + +See :issue:`96996` + +Description +=========== + +The previous TYPO3 Hook "hook_checkEnableFields" registered via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_checkEnableFields']` +has been removed in favor of a new PSR-14 event +:php:`TYPO3\CMS\Core\Domain\Access\RecordAccessGrantedEvent`. + +Impact +====== + +Hooks in third-party extensions will not be executed anymore. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. The +extension scanner will notify about usages. + +Migration +========= + +Register a new PSR-14 event listener for :ref:`RecordAccessGrantedEvent <feature-96996-1663513388>` +in the extension's :file:`Services.yaml` to keep TYPO3 v12+ compatibility. + +Extensions can then provide compatibility with TYPO3 v11 and TYPO3 v12 at +the same time. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-96998-ExtbaseValidatorInterfaceChanged.rst b/Documentation/Changelog/12.0/Breaking-96998-ExtbaseValidatorInterfaceChanged.rst new file mode 100644 index 0000000..c40b45b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-96998-ExtbaseValidatorInterfaceChanged.rst @@ -0,0 +1,124 @@ +.. include:: /Includes.rst.txt + +.. _breaking-96998: + +====================================================== +Breaking: #96998 - Extbase validator interface changed +====================================================== + +See :issue:`96998` + +Description +=========== + +The Extbase related interface :php:`TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface` +has been changed by requiring :php:`setOptions()` method and being more strict in general. + +Additionally, :php:`TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator` signatures +have been hardened. + +Furthermore, all default validators delivered by EXT:extbase and EXT:form are declared final. + +Following this, the framework no longer hands over :php:`options` array as constructor +argument, and no abstract implements :php:`__construct()` anymore. Classes that implement +:php:`ValidatorInterface` are automatically set "public" and "not-shared" by the framework, +they do not need to set this themselves. See the +:doc:`preparation in TYPO3 v11 <../11.5.x/Important-96332-ExtbaseValidatorsCanUseDependencyInjection>` +for more details on this. As a result, Extbase validators can now use dependency injection. + +Impact +====== + +This has impact on custom Extbase validators which *may* need to adapt their method signatures. +Extensions that don't follow this in TYPO3 v12 may trigger fatal PHP errors. + +Affected Installations +====================== + +Extensions with custom validators may be affected. In general, all extension classes that +directly implement :php:`ValidatorInterface` or extend :php:`AbstractValidator` may be +affected. The extension scanner can not find affected extensions, but IDE's should +show violating classes. + +Migration +========= + +The most casual case is that custom extension validators simply extend :php:`AbstractValidator`. +Those just have to adjust their :php:`isValid()` method signature to :php:`isValid($value): void` to +keep TYPO3 v11 & v12 compatibility. Read on for rare cases where this is not sufficient. + +First, it is no longer allowed to extend specific validators of EXT:extbase and EXT:form. +Those are "leaf" classes, and extensions should not extend them, giving the Core more +freedom to change those classes if needed. Extensions should instead extend the provided +abstract classes like :php:`AbstractValidator` to implement own validators. + +Since most custom validators inherit :php:`AbstractValidator`, the most important change +for these validator is a return type change of :php:`isValid()`: + +.. code-block:: php + + public function isValid(mixed $value): void + +Extensions that need to stay compatible with v11 (PHP 7.4) and v12, will thus typically +use a signature like below: Set the return type constraint, but omit the 'mixed' argument type: + +.. code-block:: php + + public function isValid($value): void + +With a closer look at the :php:`ValidatorInterface`, the v11 version +effectively looks like this: + +.. code-block:: php + + interface ValidatorInterface + { + public function validate($value); + public function getOptions(); + } + +This has been changed in v12 to this: + +.. code-block:: php + + interface ValidatorInterface + { + public function validate(mixed $value): Result; + public function setOptions(array $options): void; + public function getOptions(): array; + } + +In any case, custom validators must implement :php:`setOptions()` now. The +:php:`AbstractValidator` does that automatically, so this has little impact since +most custom validators will extend :php:`AbstractValidator` anyways. + +Extensions tailored for TYPO3 v12 and above simply implement these. Extensions that +need to keep compatibility with v11 and v12 need to adjust some additional type juggling. +In general, implementing classes can *relax* method argument types (e.g. avoid :php:`mixed` +to stay PHP 7.4 compatible), but *must follow* more restricted return type constraints of +younger interfaces. + +A v11 & v12 compatible method signature looks like this (avoiding the :php:`mixed` keyword +on :php:`validate`): + +.. code-block:: php + + class MyValidator implements ValidatorInterface + { + public function setOptions(array $options): void + { + // ... + } + + public function validate($value): Result + { + // ... + } + + public function getOptions(): array + { + return $this->options; + } + } + +.. index:: PHP-API, NotScanned, ext:extbase, ext:form diff --git a/Documentation/Changelog/12.0/Breaking-97065-TYPO3FrontendAlwaysRenderedInUTF-8.rst b/Documentation/Changelog/12.0/Breaking-97065-TYPO3FrontendAlwaysRenderedInUTF-8.rst new file mode 100644 index 0000000..6a1874e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97065-TYPO3FrontendAlwaysRenderedInUTF-8.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97065: + +========================================================== +Breaking: #97065 - TYPO3 Frontend always rendered in UTF-8 +========================================================== + +See :issue:`97065` + +Description +=========== + +For historical reasons, it was possible to change the actual rendering charset +of TYPO3's Frontend Output to a specific character set, and also to modify the +"renderCharset", which was removed in TYPO3 v8.0. Since TYPO3 v6, the default +rendering output was set to "utf-8", and nowadays, it has become a niche +to change the output rendering charset to a different value than UTF-8. + +For this reason, the TypoScript setting :typoscript:`config.metaCharset` has no effect +anymore as all rendering for Frontend is "utf-8" and not changeable anymore. + +If this TypoScript setting was set to "utf-8" in previous installations, +this line could have been removed anyways already. + +The public PHP property :php:`TypoScriptFrontendController->metaCharset` is +removed, along with the public method +:php:`TypoScriptFrontendController->convOutputCharset()`. + +Impact +====== + +TYPO3 installations with a different setting than "utf-8" will now output +"utf-8" output at all times. + +TYPO3 extensions accessing the removed property will trigger a PHP warning, or +calling the removed method :php:`convOutputCharset()` will see a fatal PHP error. + +Affected Installations +====================== + +TYPO3 installations using :typoscript:`config.metaCharset` set to a value other than +`utf-8`, or accessing the removed property or method. The Extension Scanner +in the Install Tool will detect usages of the removed property and method. + +Migration +========= + +TYPO3 Installations with a different charset than UTF-8 should convert their own +content in a custom middleware, as this specific use-case is not supported by +TYPO3 Core anymore. + +TYPO3 installations with TypoScript option set :typoscript:`config.metaCharset = utf-8` can +remove the TypoScript line in previous supported TYPO3 versions. + +Any usage of the removed property / method should be removed. + +.. index:: Frontend, PHP-API, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97091-TSFE-clear_previewHasBeenRemoved.rst b/Documentation/Changelog/12.0/Breaking-97091-TSFE-clear_previewHasBeenRemoved.rst new file mode 100644 index 0000000..fd58a1d --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97091-TSFE-clear_previewHasBeenRemoved.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97091: + +======================================================= +Breaking: #97091 - TSFE->clear_preview has been removed +======================================================= + +See :issue:`97091` + +Description +=========== + +The method :php:`clear_preview` of the :php:`TypoScriptFrontendController` has been removed. + +Impact +====== + +Calling the method will result in a PHP Fatal Error. + +Affected Installations +====================== + +All installations calling the :php:`clear_preview` method + +Migration +========= + +Build your own :php:`clear_preview` method: + +.. code-block:: php + + $context = GeneralUtility::makeInstance(Context::class); + $GLOBALS['SIM_EXEC_TIME'] = $GLOBALS['EXEC_TIME']; + $GLOBALS['SIM_ACCESS_TIME'] = $GLOBALS['ACCESS_TIME']; + $context->setAspect( + 'frontend.preview', + GeneralUtility::makeInstance(PreviewAspect::class) + ); + $context->setAspect( + 'date', + GeneralUtility::makeInstance( + DateTimeAspect::class, + (new \DateTimeImmutable())->setTimestamp($GLOBALS['SIM_EXEC_TIME']) + ) + ); + $context->setAspect( + 'visibility', + GeneralUtility::makeInstance(VisibilityAspect::class) + ); + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97126-RemoveTCEformsArrayKeyInFlexForm.rst b/Documentation/Changelog/12.0/Breaking-97126-RemoveTCEformsArrayKeyInFlexForm.rst new file mode 100644 index 0000000..ea99624 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97126-RemoveTCEformsArrayKeyInFlexForm.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97126: + +======================================================== +Breaking: #97126 - Remove TCEforms array key in FlexForm +======================================================== + +See :issue:`97126` + +Description +=========== + +As a result of :doc:`#97126 <../12.0/Deprecation-97126-TCEformsRemovedInFlexForm>` +the `TCEforms` key has been removed from the FlexForm array. Code, that deals +with the FlexForm result array directly and accesses this key, may break. + +Impact +====== + +In rare cases, where custom extensions deal with the parsed FlexForm array +structure directly and relying on the presence of the :php:`TCEforms` key, an +undefined array key warning may appear and the logic won't work any longer. + +Affected Installations +====================== + +All installations, which deal with parsed FlexForm arrays directly and using the +:php:`TCEforms` key. + +This **may** happen when: + +* Using :php:`FlexFormTools->parseDataStructureByIdentifier()` directly +* Using or extending a :php:`FormDataProvider` and accessing the :php:`$result['processedTca']` array + +.. note:: + + Since a long time, the :php:`TCEforms` key has already been removed in the + :php:`TcaFlexPrepare` FormDataProvider. It is advised to set this provider + as a dependency, when relying on prepared FlexForm TCA, in custom providers. + +Migration +========= + +Search your PHP code for the presence of the string `TCEforms` inside of arrays +and remove it. In case you need to support two TYPO3 versions simultaneously, +check if the key exists or not and adjust your array access accordingly. + +Real world example from EXT:news: + +Before: + +.. code-block:: php + + if (!empty($categoryRestriction) && isset($structure['sheets']['sDEF']['ROOT']['el']['settings.categories'])) { + $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['TCEforms']['config']['foreign_table_where'] = $categoryRestriction . $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['TCEforms']['config']['foreign_table_where']; + } + +After: + +.. code-block:: php + + if (!empty($categoryRestriction) && isset($structure['sheets']['sDEF']['ROOT']['el']['settings.categories'])) { + $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['config']['foreign_table_where'] = $categoryRestriction . $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['config']['foreign_table_where']; + } + +Supporting both TYPO3 v11 and v12+: + +.. code-block:: php + + if (!empty($categoryRestriction) && isset($structure['sheets']['sDEF']['ROOT']['el']['settings.categories'])) { + if (isset($structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['TCEforms'])) { + $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['TCEforms']['config']['foreign_table_where'] = $categoryRestriction . $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['TCEforms']['config']['foreign_table_where']; + } else { + $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['config']['foreign_table_where'] = $categoryRestriction . $structure['sheets']['sDEF']['ROOT']['el']['settings.categories']['config']['foreign_table_where']; + } + } + +.. index:: FlexForm, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97131-RemovedCLICommandsRelatedToFilesInUploadsFolder.rst b/Documentation/Changelog/12.0/Breaking-97131-RemovedCLICommandsRelatedToFilesInUploadsFolder.rst new file mode 100644 index 0000000..3d980ef --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97131-RemovedCLICommandsRelatedToFilesInUploadsFolder.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97131: + +=========================================================================== +Breaking: #97131 - Removed CLI commands related to files in uploads/ folder +=========================================================================== + +See :issue:`97131` + +Description +=========== + +Historically, TYPO3 managed its actual files in a folder called +:file:`uploads/` until the `File Abstraction Layer` has been introduced +in TYPO3 v6.0. The old compatibility layer using :file:`uploads/` as +file storage folder has been removed in TYPO3 v10. + +TYPO3 still has had some CLI commands which have now been +removed as they do not serve any use anymore: + +* cleanup:multiplereferencedfiles +* cleanup:lostfiles +* cleanup:missingfiles + +Impact +====== + +Calling the CLI commands will result in an CLI exit code > 0, +as they have been removed. + +Affected Installations +====================== + +TYPO3 installations still having CLI tools using the CLI commands, +which serve no purpose anymore. + +Migration +========= + +None. + +.. index:: CLI, NotScanned, ext:lowlevel diff --git a/Documentation/Changelog/12.0/Breaking-97135-RemovedSupportForModuleHandlingBasedOnTBE_MODULES_EXT.rst b/Documentation/Changelog/12.0/Breaking-97135-RemovedSupportForModuleHandlingBasedOnTBE_MODULES_EXT.rst new file mode 100644 index 0000000..4beeed5 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97135-RemovedSupportForModuleHandlingBasedOnTBE_MODULES_EXT.rst @@ -0,0 +1,127 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97135: + +=============================================================================== +Breaking: #97135 - Removed support for module handling based on TBE_MODULES_EXT +=============================================================================== + +See :issue:`97135` + +Description +=========== + +Previously it had been possible to add additional functionality to TYPO3 +backend modules, such as :guilabel:`Web > Info` or :guilabel:`Web > Template`, +using the :php:`ExtensionManagementUtility::insertModuleFunction()` API method, +which attached a new entry to the global :php:`TBE_MODULES_EXT` array. + +Since the introduction of the new +:doc:`Module Registration API <Feature-96733-NewBackendModuleRegistrationAPI>`, +all modules are registered in the dedicated :file:`Configuration/Backend/Modules.php` +configuration file. Additional modules, or "third-level modules" are now also +registered via the new mechanism. + +Therefore, the :php:`$GLOBALS['TBE_MODULES_EXT']` has been removed, while the +corresponding :php:`ExtensionManagementUtility::insertModuleFunction()` API +method has no effect. + +The related page TSconfig options :typoscript:`mod.web_info.menu.function` +as well as :typoscript:`mod.web_ts.menu.function` have been removed in favor +of the existing :typoscript:`hideModules` user TSconfig option and the module +access logic, which due to the new registration, now also covers those modules. + +Additionally, the following hooks have been removed, because their use cases +does no longer exist: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController']['newStandardTemplateView']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Tstemplate\Controller\TypoScriptTemplateModuleController']['newStandardTemplateHandler']` + +Impact +====== + +The global :php:`TBE_MODULES_EXT` array does no longer exist and the +:php:`ExtensionManagementUtility::insertModuleFunction()` API method no +longer has any effect. + +The page TSconfig options :typoscript:`mod.web_info.menu.function` +and :typoscript:`mod.web_ts.menu.function` are no longer evaluated. + +Using one of mentioned, :php:`TypoScriptTemplateModuleController` related +hooks does no longer have any effect. + +Affected Installations +====================== + +All installations using the global :php:`TBE_MODULES_EXT` array or +calling :php:`ExtensionManagementUtility::insertModuleFunction()` in +custom extension code. + +All installations using one of the removed page TSconfig options or one +of the removed hooks. + +Migration +========= + +Register your "third-level" module in our extension's +:file:`Configuration/Backend/Modules.php` file. + +Previous configuration in :file:`ext_tables.php`: + +.. code-block:: php + + ExtensionManagementUtility::insertModuleFunction( + 'web_info', + MyAdditonalInfoModuleController::class, + '', + 'LLL:EXT:extkey/Resources/Private/Language/locallang.xlf:mod_title' + ); + +Will now be registered in :file:`Configuration/Backend/Modules.php`: + +.. code-block:: php + + 'web_info_additional' => [ + 'parent' => 'web_info', + 'access' => 'user', + 'path' => '/module/web/info/additional', + 'iconIdentifier' => 'module-my-icon-identifier', + 'labels' => [ + 'title' => 'LLL:EXT:extkey/Resources/Private/Language/locallang.xlf:mod_title', + ], + 'routes' => [ + '_default' => [ + 'target' => MyAdditonalInfoModuleController::class . '::handleRequest', + ], + ], + ], + +To hide a "third-level" module in the doc header menu, use the user TSconfig +:typoscript:`options.hideModules` option: + +.. code-block:: typoscript + :caption: **Page** TSconfig + + # before + mod.web_info.menu.function.TYPO3\CMS\Info\Controller\TranslationStatusController = 0 + + +.. code-block:: typoscript + :caption: **User** TSconfig + + # after + options.hideModules := addToList(web_info_translations) + +.. note:: + + While the previously used TSconfig options `mod.*.menu.function` are bound + to a page is the new `options.hideModules` option based on user and user + group level. This allows greater influence and furthermore allows to hide + any module, even if the module is not connected to a page. + +Additionally, use the module access logic to restrict access to those modules. + +Remove any registration of the mentioned hooks. There is no direct migration, +since the use cases for those hooks do no longer exist. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97174-RemovedHookForModifyingInfoModuleFooterContent.rst b/Documentation/Changelog/12.0/Breaking-97174-RemovedHookForModifyingInfoModuleFooterContent.rst new file mode 100644 index 0000000..f8496e8 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97174-RemovedHookForModifyingInfoModuleFooterContent.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97174: + +======================================================================== +Breaking: #97174 - Removed hook for modifying info module footer content +======================================================================== + +See :issue:`97174` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/web_info/class.tx_cms_webinfo.php']['drawFooterHook']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Info\Controller\Event\ModifyInfoModuleContentEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97174-PSR-14EventForModifyingInfoModuleContent>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:info diff --git a/Documentation/Changelog/12.0/Breaking-97187-RemovedHookForModifyingLinkExplanation.rst b/Documentation/Changelog/12.0/Breaking-97187-RemovedHookForModifyingLinkExplanation.rst new file mode 100644 index 0000000..65baec2 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97187-RemovedHookForModifyingLinkExplanation.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97187: + +============================================================== +Breaking: #97187 - Removed hook for modifying link explanation +============================================================== + +See :issue:`97187` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Backend\Form\Event\ModifyLinkExplanationEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97187-PSR-14EventForModifyingLinkExplanation>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97188-RegisterElementBrowsersViaServiceConfiguration.rst b/Documentation/Changelog/12.0/Breaking-97188-RegisterElementBrowsersViaServiceConfiguration.rst new file mode 100644 index 0000000..b5b39a5 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97188-RegisterElementBrowsersViaServiceConfiguration.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97188: + +====================================================================== +Breaking: #97188 - Register element browsers via service configuration +====================================================================== + +See :issue:`97188` + +Description +=========== + +The `element browsers` in EXT:backend are now registered via service +configuration, see the :doc:`feature changelog <Feature-97188-NewRegistrationForElementBrowsers>`. +Therefore the registration via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ElementBrowsers']` +has been removed. + +Additionally, to be able to use autoconfiguration, the `element browser` +identifier has to be provided by the service directly using the +:php:`getIdentifier()` method, which is now required by the +:php:`ElementBrowserInterface`. + +In case a custom `element browser` extends +:php:`\TYPO3\CMS\Backend\Browser\AbstractElementBrowser`, +only the class property `$identifier` has to be set, e.g. +:php:`protected string $identifier = 'my_browser';`. + +Impact +====== + +Registration of custom `element browsers` via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ElementBrowsers']` +is not evaluated anymore. + +The :php:`ElementBrowserInterface` is extended for +:php:`public function getIdentifier(): string`. + +Affected Installations +====================== + +All TYPO3 installations using the old registration. + +All TYPO3 installations with custom `element browsers`, not implementing +:php:`public function getIdentifier()`. + +Migration +========= + +Remove :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ElementBrowsers']` +from your :file:`ext_localconf.php` file. + +If :yaml:`autoconfigure` is not enabled in your :file:`Configuration/Services.(yaml|php)`, +add the tag :yaml:`recordlist.elementbrowser` manually to your `element browser` service. + +.. code-block:: yaml + + Vendor\Extension\Recordlist\MyBrowser: + tags: + - name: recordlist.elementbrowser + +Additionally, make sure to either implement +:php:`public function getIdentifier(): string` or, in case your `element browser` +extends :php:`AbstractElementBrowser`, to set the `$identifier` class property. + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97201-RemovedHookForNewContentElementWizard.rst b/Documentation/Changelog/12.0/Breaking-97201-RemovedHookForNewContentElementWizard.rst new file mode 100644 index 0000000..7358a47 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97201-RemovedHookForNewContentElementWizard.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97201: + +============================================================== +Breaking: #97201 - Removed hook for new content element wizard +============================================================== + +See :issue:`97201` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms']['db_new_content_el']['wizardItemsHook']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent`. + +Additionally, the :php:`params` property of a wizard item has been +removed, since it just duplicated the default values, configured with +:php:`tt_content_defValues` and therefore previously required extension +authors to provide the same information twice in two different formats. + +.. note:: + + The public methods :php:`getPageInfo()`, :php:`getColPos()`, + :php:`getSysLanguage()` and :php:`getUidPid()` have been removed + from the internal :php:`NewContentElementController` class, since + they were only added for the use in the now removed hook. This + information is now directly available in the new PSR-14 event. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v12.0+. + +The :php:`params` property on a wizard item is no longer evaluated. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +TYPO3 installations setting the :php:`params` property on a wizard item. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97201-PSR-14EventForModifyingNewContentElementWizardItems>` +to allow greater influence in the functionality. + +Migrate the :php:`params` property to :php:`tt_content_defValues` or just +remove :php:`params` in case the information had already been configured +for both properties. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97210-TypesAddedToMethodSignaturesOrClassProperties.rst b/Documentation/Changelog/12.0/Breaking-97210-TypesAddedToMethodSignaturesOrClassProperties.rst new file mode 100644 index 0000000..37e6040 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97210-TypesAddedToMethodSignaturesOrClassProperties.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97210: + +======================================================================= +Breaking: #97210 - Types added to method signatures or class properties +======================================================================= + +See :issue:`97210` + +Description +=========== + +The following PHP classes have had parameter and return types added to some or +all of their method signatures. The types are consistent with existing +docblock-documented type expectations and existing behavior. + +- :php:`\TYPO3\CMS\Adminpanel\ModuleApi\ModuleDataStorageCollection` +- :php:`\TYPO3\CMS\Backend\Tree\SortedTreeNodeCollection` +- :php:`\TYPO3\CMS\Backend\Tree\TreeNodeCollection` +- :php:`\TYPO3\CMS\Core\Collection\AbstractRecordCollection` +- :php:`\TYPO3\CMS\Core\LinkHandling\FileLinkHandler` +- :php:`\TYPO3\CMS\Core\Log\LogRecord` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue` +- :php:`\TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider` +- :php:`\TYPO3\CMS\Core\Resource\Collection\AbstractFileCollection` +- :php:`\TYPO3\CMS\Core\Resource\MetaDataAspect` +- :php:`\TYPO3\CMS\Core\Resource\Search\Result\EmptyFileSearchResult` +- :php:`\TYPO3\CMS\Core\Routing\SiteRouteResult` +- :php:`\TYPO3\CMS\Core\Utility\ArrayUtility` +- :php:`\TYPO3\CMS\Core\Utility\ClassNamingUtility` +- :php:`\TYPO3\CMS\Core\Utility\CsvUtility` +- :php:`\TYPO3\CMS\Core\Utility\CommandUtility` +- :php:`\TYPO3\CMS\Core\Utility\DebugUtility` +- :php:`\TYPO3\CMS\Core\Utility\DiffUtility` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility` +- :php:`\TYPO3\CMS\Core\Utility\MailUtility` +- :php:`\TYPO3\CMS\Core\Utility\MathUtility` +- :php:`\TYPO3\CMS\Core\Utility\PathUtility` +- :php:`\TYPO3\CMS\Core\Utility\RootlineUtility` +- :php:`\TYPO3\CMS\Core\Utility\StringUtility` +- :php:`\TYPO3\CMS\Core\Utility\VersionNumberUtility` +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\Arguments` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy` +- :php:`\TYPO3\CMS\Extbase\Persistence\ObjectStorage` +- :php:`\TYPO3\CMS\Extbase\Persistence\QueryResult` + +The following PHP classes have added public class property types: + +- :php:`\TYPO3\CMS\Core\Utility\DiffUtility` + +Impact +====== + +Calling any of these methods with incompatible types now throws a :php:`\TypeError`, +especially if the calling code is within :php:`declare(strict_types=1);` context. +Before the result of such method calls was undefined or inconsistent. + +Affected Installations +====================== + +Code routines that are passing an invalid type will need to ensure they pass a correct type. + +If a code file is running with :php:`declare(strict_types=1);`, that includes, for instance, +passing a numeric string to a method that expects an int or float. Those will need to be +properly cast before being passed. + +The extension scanner will not find affected extensions. + +Migration +========= + +Any code that is already passing the expected type to these methods will be unaffected. +Code that is passing an incorrect type will need to pass the correct type, possibly +including an explicit cast. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97214-UseUploadedFileObjectsInsteadOf_FILES.rst b/Documentation/Changelog/12.0/Breaking-97214-UseUploadedFileObjectsInsteadOf_FILES.rst new file mode 100644 index 0000000..dfd3bc2 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97214-UseUploadedFileObjectsInsteadOf_FILES.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97214: + +============================================================== +Breaking: #97214 - Use UploadedFile objects instead of $_FILES +============================================================== + +See :issue:`97214` + +Description +=========== + +The TYPO3 request already contains a "disentangled" array of UploadedFile +objects. With this change, these UploadedFile objects are now used instead +of the superglobal :php:`$_FILES` in Extbase requests. + +Additionally, the FAL ResourceStorage has been adjusted for handling +UploadedFile objects and the ExtensionManager upload handling has been +adjusted. + +The next step would be to further adjust FAL to use only PSR provided +methods for handling uploaded files and implementing an API for file +uploads in Extbase. + +Impact +====== + +The global :php:`$_FILES` object is not used in Extbase or the extension +manager anymore, instead the PSR request is used. + +Affected Installations +====================== + +All installations extending the TYPO3 Core ResourceStorage object and +overwriting the :php:`addUploadedFile` method. + +Migration +========= + +Extension authors extending the TYPO3 Core resource storage and implementing +their own handling of :php:`addUploadedFile` need to allow objects of type +:php:`UploadedFile` in addition to the old array from global :php:`$_FILES`. + +To do so, switch the type annotation to :php:`array|UploadedFile` and add code that +handles :php:`UploadedFile` objects and arrays. + +Example +^^^^^^^ + +.. code-block:: php + + if ($uploadedFileData instanceof UploadedFile) { + $localFilePath = $uploadedFileData->getTemporaryFileName(); + if ($targetFileName === null) { + $targetFileName = $uploadedFileData->getClientFilename(); + } + $size = $uploadedFileData->getSize(); + } else { + $localFilePath = $uploadedFileData['tmp_name']; + if ($targetFileName === null) { + $targetFileName = $uploadedFileData['name']; + } + $size = $uploadedFileData['size']; + } + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-97230-RemovedHookForModifyingImageManipulationPreviewUrl.rst b/Documentation/Changelog/12.0/Breaking-97230-RemovedHookForModifyingImageManipulationPreviewUrl.rst new file mode 100644 index 0000000..b9a104b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97230-RemovedHookForModifyingImageManipulationPreviewUrl.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97230: + +============================================================================ +Breaking: #97230 - Removed hook for modifying image manipulation preview URL +============================================================================ + +See :issue:`97230` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend/Form/Element/ImageManipulationElement']['previewUrl']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Backend\Form\Event\ModifyImageManipulationPreviewUrlEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97230-PSR-14EventForModifyingImageManipulationPreviewUrl>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97231-RemovedHookForManipulatingInlineElementControls.rst b/Documentation/Changelog/12.0/Breaking-97231-RemovedHookForManipulatingInlineElementControls.rst new file mode 100644 index 0000000..c83e737 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97231-RemovedHookForManipulatingInlineElementControls.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97231: + +======================================================================== +Breaking: #97231 - Removed hook for manipulating inline element controls +======================================================================== + +See :issue:`97231` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tceforms_inline.php']['tceformsInlineHook']` +has been removed in favor of the new PSR-14 events :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementEnabledControlsEvent` +and :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementControlsEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v12.0+. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 events <../12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls>` +to allow greater influence in the functionality. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97243-RemoveGlobalJQueryAccessViaWindow.rst b/Documentation/Changelog/12.0/Breaking-97243-RemoveGlobalJQueryAccessViaWindow.rst new file mode 100644 index 0000000..0a1d105 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97243-RemoveGlobalJQueryAccessViaWindow.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97243: + +=========================================================== +Breaking: #97243 - Remove global jQuery access via window.$ +=========================================================== + +See :issue:`97243` + +Description +=========== + +The global :js:`window.$` accessor to the jQuery instance is now no longer +provided. + +Global jQuery usage has been deprecated in :issue:`86438` with the suggestion to +use JavaScript modules instead. With the integration of browser native ES6 +modules jQuery should now be loaded as a regular module. + +Impact +====== + +Loading the ES6 'jquery' module no longer has side effects, as the global +scope :js:`window` is no longer polluted by writing to the property :js:`$`. +This renders any :js:`jQuery.noConflict()` workarounds unneeded. + +Affected Installations +====================== + +All installations that use `$` to invoke jQuery in inline JavaScripts or +custom JavaScript modules that miss to define their jQuery import, and +implicitly used the global before. + +Migration +========= + +Migrate to ES6 JavaScript modules and use :js:`import $ from 'jquery';` instead. + +.. index:: Backend, JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97265-SimplifiedAccessModeSystem.rst b/Documentation/Changelog/12.0/Breaking-97265-SimplifiedAccessModeSystem.rst new file mode 100644 index 0000000..43c606a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97265-SimplifiedAccessModeSystem.rst @@ -0,0 +1,117 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97265: + +================================================ +Breaking: #97265 - Simplified access mode system +================================================ + +See :issue:`97265` + +Description +=========== + +In preparation of a deployable backend access rights system based on +configuration files, some rarely used details of the permission system +have been streamlined and simplified: + +* The global configuration option :php:`TYPO3_CONF_VARS['BE']['explicitADmode']` + has been removed and is not evaluated anymore. + +* The only valid value for TCA config option :php:`authMode` on :php:`'type' => 'select'` + fields is now :php:`explicitAllow`. The values :php:`explicitDeny` and :php:`individual` + are invalid and no longer evaluated. + +* With removal of :php:`authMode' => 'individual'` for TCA select fields, the sixth + :php:`items` option is obsolete and removed. The values :php:`EXPL_ALLOW` and + :php:`EXPL_DENY` are without any effect. + +* Handling of TCA config option :php:`authMode_enforce` has been removed. + +* The fourth tuple of :sql:`be_groups` field :sql:`explicit_allowdeny` that was + previously set to either :sql:`ALLOW` or :sql:`DENY` is removed. + +* The fourth argument on :php:`BackendUserAuthentication->checkAuthMode()` has + been removed. + +Impact +====== + +Using any of the above removed options will trigger a PHP :php:`E_USER_DEPRECATED` error. +Using :php:`explicitDeny` and :php:`individual` as value for TCA config option +:php:`authMode` is no longer supported by the system and may need manual +adaptions. Accessing :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode']` +may lead to a PHP warning level error. + +Affected Installations +====================== + +* Instances with extensions using :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode']`. + The extension scanner will typically find affected instances. + +* Instances with TCA select fields using :php:`'authMode' => 'explicitDeny'`. + +* Instances with TCA select fields using :php:`'authMode' => 'individual'` and select + items being set to :php:`EXPL_ALLOW` or :php:`EXPL_DENY`. This is a very rarely used + option and it's unlikely modern extensions use this in practice: There is not a single + extension in the TER using this option combination and it's unlikely to be used in + custom extensions, either. + +* Instances manually dealing with the :sql:`explicit_allowdeny` of table :sql:`be_groups` + may be affected if they expect the fourth field being set to :sql:`ALLOW` or :sql:`DENY`. + This is unlikely since the Core provides an API for this field using + :php:`BackendUserAuthentication->checkAuthMode()`. + +* Instances calling :php:`BackendUserAuthentication->checkAuthMode()` with four instead of + three arguments. The extension scanner will find usages as weak match. + +* Instances using :php:`authMode_enforce` for :php:`'type' => 'select'` fields. + +Migration +========= + +The majority of instances does not need to take care of anything. The values of the database +field :sql:`explicit_allowdeny` for table :sql:`be_groups` are updated with an upgrade wizard. +This should be executed. The following parts of this section outline options for rare cases +if specific seldom used options are used. + +Accessing explicitADmode +------------------------ + +The handling of :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode']` has been changed as +if it is always set to :php:`explicitAllow`. Extensions should not assume this global array +key being set anymore since TYPO3 Core v12. Extensions that need to stay compatible with v11 +and v12 should fall back: :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode'] ?? 'explicitAllow'`. + +Using authMode_enforce='strict' +------------------------------- + +Extensions with select fields using :php:`authMode` previously had different handling +if :php:`authMode_enforce => 'strict'` has been set: Let's say an editor accesses a record +with an :php:`authMode` field being set to a value it has no access to. With :php:`authMode_enforce` +*not* being set to :php:`strict`, the editor was still able to edit the record and set the value +to something it had access to. With :php:`authMode_enforce` being set to :php:`strict`, the editor +was not allowed to access the record. This has been streamlined: The backend interface no longer +renders those records for the editor and an "access denied" message is rendered instead. To +prevent this, a group this editor is member of needs to be adapted to allow access to this +particular value in the "Explicitly allow field values" (:sql:`explicit_allowdeny`) field. + +Using authMode='explicitDeny' +----------------------------- + +The "deny list" approach for single field values has been removed, the only allowed option +for :php:`authMode` is :php:`explicitAllow`. Extensions using config value :php:`explicitDeny` +should be adapted to switch to :php:`explicitAllow` instead. The upgrade wizard +"Migrate backend groups "explicit_allowdeny" field to simplified format." that transfers +existing :sql:`be_groups` rows to the new format *drops* any :sql:`DENY` fields and instructs +admins to set new access rights of affected backend groups. + +Using authMode='individual' +--------------------------- + +Handling of :php:`authMode` being set to :php:`individual` has been fully dropped. There is +no Core-provided alternative. This has been an obscure setting since ever and there is no +direct migration. Extension that rely on this handling need to find a substitution based on +Core hooks, Core events or other existing Core API functionality. + +.. index:: Backend, Database, LocalConfiguration, PHP-API, TCA, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst b/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst new file mode 100644 index 0000000..598b2f9 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97305-IntroduceCSRF-likeLoginToken.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97305-1664100009: + +================================================== +Breaking: #97305 - Introduce CSRF-like login token +================================================== + +See :issue:`97305` + +Description +=========== + +:php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` requires a +CSRF-like request-token to continue with the authentication process and to +create an actual server-side user session. + +The request-token has to be submitted by one of these ways: + +* HTTP body, e.g. in `<form>` via parameter `__request_token` +* HTTP header, e.g. in XHR via header `X-TYPO3-RequestToken` + +Impact +====== + +Core user authentication is protected by a CSRF-like request-token, to +mitigate `Login CSRF <https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html>`__. + +Custom implementations for login templates or client-side authentication +handling have to be adjusted to submit the required request-token. + +Affected installations +====================== + +Sites having custom implementations for login templates or client-side authentication. + +Migration +========= + +The :php:`\TYPO3\CMS\Core\Security\RequestToken` signed with a :php:`\TYPO3\CMS\Core\Security\Nonce` +needs to be sent as JSON Web Token (JWT) to the server-side application handling of +the Core user authentication process. The scope needs to be :php:`core/user-auth/be` +or :php:`core/user-auth/fe` - depending on whether authentication is applied in +the website's backend or frontend context. + +Example for overridden backend login HTML template (`ext:backend`) +------------------------------------------------------------------ + +.. code-block:: diff + + --- a/typo3/sysext/backend/Resources/Private/Layouts/Login.html + +++ b/typo3/sysext/backend/Resources/Private/Layouts/Login.html + <input type="hidden" name="redirect_url" value="{redirectUrl}" /> + <input type="hidden" name="loginRefresh" value="{loginRefresh}" /> + +<input type="hidden" name="{requestTokenName}" value="{requestTokenValue}" /> + +Example for overridden frontend login HTML template (`ext:felogin`) +------------------------------------------------------------------- + +.. code-block:: diff + + --- a/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html + +++ b/typo3/sysext/felogin/Resources/Private/Templates/Login/Login.html + -<f:form target="_top" fieldNamePrefix="" action="login"> + +<f:form target="_top" fieldNamePrefix="" action="login" requestToken="{requestToken}"> + +More details are explained in corresponding documentation on +:ref:`Feature #87616: Introduce CSRF-like request-token handling <feature-97305-1664099950>`. + +.. index:: Backend, Fluid, Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97312-RemoveContextSensitiveHelp.rst b/Documentation/Changelog/12.0/Breaking-97312-RemoveContextSensitiveHelp.rst new file mode 100644 index 0000000..33ed95e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97312-RemoveContextSensitiveHelp.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97312: + +================================================ +Breaking: #97312 - Remove context sensitive help +================================================ + +See :issue:`97312` + +Description +=========== + +The arguments for removing context sensitive help were: + +* The help was not really context sensitive, it only relied on tablename + and fieldname, if a field was used for different purposes in different + content types, the CSH always showed the same help +* There was outdated information in CSH (e.g. Screenshots form TYPO3 4.x) + and nobody is available to update the information +* Some CSH descriptions explained the same content with different words + which is confusing (e.g. tt_content - CType > Title: "Type" > CSH + Tooltip: "Select the kind of Page Content this element represents. + New options will appear when you save the record.") +* Many CSH texts did not provide useful additional information + (e.g. tt_content - header > Title: "Header" > CSH Tooltip: + "Enter header text for the Content Element.") +* The available online documentation https://docs.typo3.org/ improved + a lot and helps better than the CSH in most cases, as it is up to date +* CSH was hidden for most users, as it was only available by clicking on + a label (no hint that help was available without hovering the label by + mouse, not available for keyboard users) +* `description` is available for explanations when they are required. + Adding relevant information as `description` will help everyone as it is + visible. +* The removal was already proposed in 2019 (see + https://decisions.typo3.org/t/drop-context-sensitive-help-in-core/511) + and most arguments against removal can be solved using the `description` + or by linking to the official documentation +* Removal of CSH also removed a lot of outdated files (and results in + smaller footprint of the TYPO3 Core package) + +The route `help_cshmanual_popup` has been removed. + +Help buttons :php:`Components\Buttons\Action\HelpButton` only return an +empty string and trigger a deprecation warning. + +The CSH descriptions are not loaded any longer for tables. + +All labels are adjusted to not contain :html:`<abbr>` tags inside any longer. + +The method :php:`cshItem()` of +:php:`TYPO3\CMS\Backend\Utility\BackendUtility` always returns an empty +string and triggers a deprecation warning. + +The TYPO3 Manual menu item has been removed and a link to the +TYPO3 Online Documentation has been added to the menu. + +The backend display related TCA option +:php:`$GLOBALS['TCA'][my_table]['interface']['always_description']` +is not evaluated anymore. + +Impact +====== + +The context sensitive help is removed completely and only loading help +items for SelectCheckboxElements is still supported. + +Affected Installations +====================== + +All installations that use CSH for own fields. + +Migration +========= + +Important CSH texts need to be migrated to a TCA :php:`description`, to +make the information available for all users. + +An example for a TCA description is the :php:`protected` column in the +:php:`sys_redirect` TCA. + +The TCA option :php:`['interface']['always_description']` can be removed from +any TCA definition. + +.. index:: Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97320-RegisterReportAndStatusViaServiceConfiguration.rst b/Documentation/Changelog/12.0/Breaking-97320-RegisterReportAndStatusViaServiceConfiguration.rst new file mode 100644 index 0000000..94054a9 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97320-RegisterReportAndStatusViaServiceConfiguration.rst @@ -0,0 +1,159 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97320: + +======================================================================= +Breaking: #97320 - Register Report and Status via Service Configuration +======================================================================= + +See :issue:`97320` + +Description +=========== + +The `reports` and `status` in EXT:reports are now registered via service +configuration, see the :doc:`feature changelog <Feature-97320-NewRegistrationForReportsAndStatus>`. +Therefore the registration via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']` +has been removed. + +Additionally, to be able to use autoconfiguration, the following interfaces have been extended: + +- :php:`TYPO3\CMS\Reports\ReportInterface`: :php:`getIdentifier`, :php:`getIconIdentifier`, :php:`getTitle`, :php:`getDescription` +- :php:`TYPO3\CMS\Reports\StatusProviderInterface`: :php:`getLabel` + +Impact +====== + +Registration of custom `reports` via :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']` +are not evaluated anymore. + +Registration of custom `status` via :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['tx_reports']['status']['providers']` +are not evaluated anymore. + +:php:`ReportInterface` and :php:`StatusProviderInterface`: are extended by the mentioned methods. If the required methods are not implemented it will lead to fatal errors. + +Affected Installations +====================== + +All TYPO3 installations using the old registration. + +All TYPO3 installations with custom `reports`, not implementing :php:`public function getIdentifier()`, +:php:`public function getIconIdentifier()`, :php:`public function getTitle()`, :php:`public function getDescription()` + +All TYPO3 installations with custom `status`, not implementing +:php:`public function getLabel()` + +Migration +========= + +By implementing the required methods of the interfaces, the custom reports are fully backwards compatible. + +If TYPO3 v12+ is the only supported version, the configuration :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']` from the :file:`ext_localconf.php` file can be removed as well. + +Report +------ + +If :yaml:`autoconfigure` is not enabled in your :file:`Configuration/Services.(yaml|php)`, +add the tag :yaml:`reports.report` manually to your `reports` service. + +.. code-block:: yaml + + Vendor\Extension\Report\MyReport: + tags: + - name: reports.report + +The old registration can be removed, if support for TYPO3 v11 or lower is not +necessary. + +.. code-block:: php + + // Before in ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['extension']['general'] = [ + 'title' => 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:title', + 'description' => 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:description', + 'icon' => 'EXT:extension/Resources/Public/Icons/Extension.svg', + 'report' => \Vendor\Extension\Report::class + ]; + +Additionally, make sure to implement all methods of :php:`TYPO3\CMS\Reports\ReportInterface`. + +.. code-block:: php + + // Changes for the report + + class Report implements ReportInterface + { + public function getReport(): string + { + return 'Full report'; + } + + public function getIdentifier(): string + { + return 'general'; + } + + public function getTitle(): string + { + return 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:title'; + } + + public function getDescription(): string + { + return 'LLL:EXT:extension/Resources/Private/Language/locallang.xlf:description'; + } + + public function getIconIdentifier(): string + { + return 'module-reports'; + } + } + +Refer to the :ref:`Icon API <feature-94692-1657826754>` +on how to register the icon. + +Status +------ + +If :yaml:`autoconfigure` is not enabled in your :file:`Configuration/Services.(yaml|php)`, +add the tag :yaml:`reports.status` manually to your `status` service. + +.. code-block:: yaml + + Vendor\Extension\Status\MyStatus: + tags: + - name: reports.report + +The old registration can be removed, if support for TYPO3 v11 or lower is not +necessary. + +.. code-block:: php + + // Before in ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['tx_reports']['status']['providers']['label'] = [ + \Vendor\Extension\Status::class, + ]; + +Additionally, make sure to implement all methods of :php:`TYPO3\CMS\Reports\StatusProviderInterface`. + +.. code-block:: php + + // Changes for the Status + + class Status implements StatusProviderInterface + { + public function getStatus(): array + { + return []; + } + + public function getLabel(): string + { + return 'label'; + } + } + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:reports diff --git a/Documentation/Changelog/12.0/Breaking-97358-RemovedEvalintFromTCATypeDatetime.rst b/Documentation/Changelog/12.0/Breaking-97358-RemovedEvalintFromTCATypeDatetime.rst new file mode 100644 index 0000000..f7ff9f7 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97358-RemovedEvalintFromTCATypeDatetime.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97358: + +============================================================ +Breaking: #97358 - Removed eval=int from TCA type "datetime" +============================================================ + +See :issue:`97358` + +Description +=========== + +With :issue:`97232` the new TCA type :php:`datetime` has been introduced. To +further improve the usage of the new dedicated TCA type and to further reduce +complexity in the configuration, the :php:`eval=int` option has now been +removed as well. All TCA type :php:`datetime` fields, which do not use a +native database type (:php:`dbType`) are now always handled with :php:`int`. + +It is therefore recommended to represent them by an :sql:`integer` database +field. To allow negative timestamps - used for dates before 1970 - the +:sql:`integer` database fields are required to be defined as :sql:`signed`. +This means, the :sql:`unsigned` definition must be omitted. + +.. note:: + + TYPO3 automatically creates database fields for all TCA type + :php:`datetime` columns, if those are not already manually + defined in the corresponding extension's :file:`ext_tables.sql` file. + +Impact +====== + +All TCA :php:`datetime` fields are now always handled with :php:`int`, as long +as no native database type is used. + +TCA type :php:`datetime` was the last TCA type using :php:`eval=int`. +Therefore, the :php:`int` option is no longer evaluated by neither FormEngine +nor :php:`DataHandler`. This means, custom FormEngine elements, which do +currently rely on this option being evaluated in any way, have to implement +the necessary functionality by themselves now. + +Affected Installations +====================== + +All installations which use TCA type :php:`datetime` columns +without a native database type (:php:`dbType`). Also installations, using +a non :php:`int` default value in TCA. + +All installations, relying on evaluation of the :php:`eval=int` option +for their custom FormEngine elements. + +Migration +========= + +Remove :php:`eval=int` from any TCA column of type :php:`datetime`. + +Migrate necessary functionality, related to TCA option :php:`eval=int`, +to your custom extension code, since FormEngine does no longer evaluate +this option. + +Migrate :php:`default` values for TCA type :php:`datetime` fields +to :php:`int` (e.g. `''` to `0`). + +Migrate corresponding database fields to :sql:`integer` where applicable. + +.. code-block:: sql + + # Before + CREATE TABLE tx_ext_my_table ( + datetime text + ); + + # After + CREATE TABLE tx_ext_my_table ( + datetime int(11) DEFAULT '0' NOT NULL, + ); + +.. note:: + + In case the corresponding TCA field defines :php:`eval=null`, the + :sql:`NOT NULL` definition must be omitted. + +.. note:: + + In case you don't need any manual configuration (e.g. a special default + value), you can omit the definition of the database field, since TYPO3 + automatically creates those fields for TCA type :php:`datetime` columns. + +.. index:: Backend, Database, PHP-API, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97449-RemovedHookForModifyingFlexFormParsing.rst b/Documentation/Changelog/12.0/Breaking-97449-RemovedHookForModifyingFlexFormParsing.rst new file mode 100644 index 0000000..04be384 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97449-RemovedHookForModifyingFlexFormParsing.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97449: + +=============================================================== +Breaking: #97449 - Removed hook for modifying flex form parsing +=============================================================== + +See :issue:`97449` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][FlexFormTools::class]['flexParsing']`, +supporting the four hook methods + +- :php:`getDataStructureIdentifierPreProcess` +- :php:`getDataStructureIdentifierPostProcess` +- :php:`parseDataStructureByIdentifierPreProcess` +- :php:`parseDataStructureByIdentifierPostProcess` + +has been removed in favor of four new dedicated :doc:`PSR-14 events <../12.0/Feature-97449-PSR-14EventsForModifyingFlexFormParsing>`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the PSR-14 events as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97450-RemovedHookForModifyingVersionDifferences.rst b/Documentation/Changelog/12.0/Breaking-97450-RemovedHookForModifyingVersionDifferences.rst new file mode 100644 index 0000000..969a057 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97450-RemovedHookForModifyingVersionDifferences.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97450: + +================================================================= +Breaking: #97450 - Removed hook for modifying version differences +================================================================= + +See :issue:`97450` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['workspaces']['modifyDifferenceArray']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Workspaces\Event\ModifyVersionDifferencesEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97450-PSR-14EventForModifyingVersionDifferences>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97451-RemoveBackendControllerPageHooks.rst b/Documentation/Changelog/12.0/Breaking-97451-RemoveBackendControllerPageHooks.rst new file mode 100644 index 0000000..6dccf67 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97451-RemoveBackendControllerPageHooks.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97451: + +======================================================= +Breaking: #97451 - Removed BackendController page hooks +======================================================= + +See :issue:`97451` + +Description +=========== + +The hooks :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['constructPostProcess']`, +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['renderPreProcess']`, and +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['renderPostProcess']` have +been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent`. + +Additionally, the :php:`BackendController->addCss()` method has been removed without replacement, +as it is no longer used. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hooks are removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97451-PSR-14EventsForBackendPageController>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97452-RemovedEditFileControllerHooks.rst b/Documentation/Changelog/12.0/Breaking-97452-RemovedEditFileControllerHooks.rst new file mode 100644 index 0000000..1772edf --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97452-RemovedEditFileControllerHooks.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97452: + +=================================================== +Breaking: #97452 - Removed EditFileController hooks +=================================================== + +See :issue:`97452` + +Description +=========== + +The hooks :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/file_edit.php']['preOutputProcessingHook']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/file_edit.php']['postOutputProcessingHook']` +have been removed, since adjusting the generated content can be achieved using template overrides +and modifying the form data, used to generate the edit file form, can be done +using the PSR-14 :php:`TYPO3\CMS\Filelist\Event\ModifyEditFileFormDataEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in TYPO3 v12.0+. +The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using these hook in custom extension code. This is +pretty unlikely, since both hooks were of limited use. + +Migration +========= + +The form data modification, allowed by :php:`preOutputProcessingHook`, can be +achieved with the new :ref:`PSR-14 ModifyEditFileFormDataEvent <feature-98521-1664890745>`. + +The content manipulation :php:`postOutputProcessingHook` hook can be substituted with a template override +as outlined in :ref:`this changelog entry <feature-96812>`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97454-RemoveLinkBrowserHooks.rst b/Documentation/Changelog/12.0/Breaking-97454-RemoveLinkBrowserHooks.rst new file mode 100644 index 0000000..b8f9e0c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97454-RemoveLinkBrowserHooks.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97454-1657327622: + +============================================= +Breaking: #97454 - Removed Link Browser hooks +============================================= + +See :issue:`97454` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['LinkBrowser']['hooks']` +with its two functions :php:`modifyLinkHandlers()` and +:php:`modifyAllowedItems()` has been removed in favor of two new PSR-14 events +:php:`\TYPO3\CMS\Backend\Controller\Event\ModifyLinkHandlersEvent` +and :php:`\TYPO3\CMS\Backend\Controller\Event\ModifyAllowedItemsEvent`. + +.. seealso:: + + * :ref:`feature-97454-1657327622` + * :ref:`t3coreapi:modifyLinkHandlers` + * :ref:`t3coreapi:ModifyLinkHandlersEvent` + * :ref:`t3coreapi:ModifyAllowedItemsEvent` + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook in custom extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :ref:`PSR-14 event <feature-97454-1657327622>` +as an improved replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97530-IndexedSearchOptionSearchSkipExtendToSubpagesCheckingRemoved.rst b/Documentation/Changelog/12.0/Breaking-97530-IndexedSearchOptionSearchSkipExtendToSubpagesCheckingRemoved.rst new file mode 100644 index 0000000..a247475 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97530-IndexedSearchOptionSearchSkipExtendToSubpagesCheckingRemoved.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97530-1651500260: + +=================================================================================== +Breaking: #97530 - Indexed Search option searchSkipExtendToSubpagesChecking removed +=================================================================================== + +See :issue:`97530` + +Description +=========== + +The TypoScript property :typoscript:`searchSkipExtendToSubpagesChecking` +related to Indexed Search query results has been removed. + +Setting the option made Indexed Search bypass the check for validating pages +related to TYPO3's :php:`extendToSubpages` Core feature. However, since the +:php:`extendToSubpages` functionality has now been optimized via an alternative +to :php:`getTreeList()`, the option is removed. + +Impact +====== + +Setting the option +:typoscript:`plugin.tx_indexedsearch.settings.searchSkipExtendToSubpagesChecking` +has no effect anymore. + +All search requests within indexed search will now respect the +:php:`extendToSubpages` flag. + +Affected installations +====================== + +TYPO3 installations using Indexed Search having this option set. + +Migration +========= + +If you still encounter using indexed search related to :php:`extendToSubpages` it is +recommended to extend Indexed Search queries with custom hooks to manipulate +the search query. + +.. index:: TypoScript, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/12.0/Breaking-97550-TypoScriptOptionConfigdisableCharsetHeaderRemoved.rst b/Documentation/Changelog/12.0/Breaking-97550-TypoScriptOptionConfigdisableCharsetHeaderRemoved.rst new file mode 100644 index 0000000..59cd08a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97550-TypoScriptOptionConfigdisableCharsetHeaderRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97550-1651697278: + +======================================================================== +Breaking: #97550 - TypoScript option config.disableCharsetHeader removed +======================================================================== + +See :issue:`97550` + +Description +=========== + +The TypoScript flag :typoscript:`config.disableCharsetHeader` has been completely removed +from TYPO3 Core. + +This option was used to avoid sending HTTP headers of type `Content-Type` to +the client. This flag was mainly used to overcome a technical limitation to +override the Content-Type information back in TYPO3 v4.x. + +Impact +====== + +TYPO3 now always sends the `Content-Type` header to the client in the TYPO3 +Frontend. + +Affected installations +====================== + +TYPO3 installations having this option enabled via TypoScript. + +Migration +========= + +It is not needed to set this option. Even when Extbase plugins return JSON-based +Responses, the Content-Type header is already modified. + +In special cases, when custom headers are required, it is possible to modify +the headers via a PHP-based PSR-15 middleware, or via TypoScript with +"config.additionalHeaders". + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97605-RemoveFieldResizeTextareas_MaxHeightFromUserSettings.rst b/Documentation/Changelog/12.0/Breaking-97605-RemoveFieldResizeTextareas_MaxHeightFromUserSettings.rst new file mode 100644 index 0000000..59c4791 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97605-RemoveFieldResizeTextareas_MaxHeightFromUserSettings.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97605-1652214290: + +============================================================================ +Breaking: #97605 - Remove field resizeTextareas_MaxHeight from user settings +============================================================================ + +See :issue:`97605` + +Description +=========== + +The field :php:`resizeTextareas_MaxHeight` with the label *Maximum height of text areas in pixels* has been removed. + +The impact of the field is low and its removal simplifies the user settings module. + +Impact +====== + +The height of textareas is the same for every user. + +Affected installations +====================== + +Every TYPO3 installation. + +Migration +========= + +There is no migration available. If this feature is needed, the rendering of a field can be modified by a custom :php:`FormElement`. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97701-RemovedTsConfigOptionDisableNewContentElementWizard.rst b/Documentation/Changelog/12.0/Breaking-97701-RemovedTsConfigOptionDisableNewContentElementWizard.rst new file mode 100644 index 0000000..19d8b3c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97701-RemovedTsConfigOptionDisableNewContentElementWizard.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97701-1655154047: + +========================================================================= +Breaking: #97701 - TSconfig option disableNewContentElementWizard removed +========================================================================= + +See :issue:`97701` + +Description +=========== + +The TSconfig option :typoscript:`mod.web_layout.disableNewContentElementWizard` +has been used to explicitly disable the content element wizard. When set, +a new Content Element of type "Text" was created by default, which was then +changed to a different Content Type. + +Along with this the option :typoscript:`mod.newContentElementWizard.override` has +been removed, as it served a similar purpose to override the route name itself. + +Impact +====== + +Both TSconfig options have no effect anymore. TYPO3 behaves as if the options +were never set. + +Affected installations +====================== + +TYPO3 installations having one of these options explicitly enabled. + +Migration +========= + +Remove the TSconfig settings as they have no effect anymore. + +Instead, use other TSconfig options to adapt the "New Content Element Wizard" +to your needs. You can find according examples in +:file:`EXT:frontend/Configuration/page.tsconfig`. + +It is also possible to create a custom backend route in your extension code +to reimplement both functionalities in a custom TYPO3 Extension, if this option +is still relevant for you. + +If you overwrite the Fluid template :file:`EXT:backend/Resources/Private/Partials/PageLayout/Record.html` +you have to adjust your template accordingly and remove the "if" condition +checking for `{item.column.context.drawingConfiguration.showNewContentWizard}`. + +.. index:: Backend, Fluid, TSConfig, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-97729-SupportAttributeApprovedInXlfFiles.rst b/Documentation/Changelog/12.0/Breaking-97729-SupportAttributeApprovedInXlfFiles.rst new file mode 100644 index 0000000..cc4bb36 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97729-SupportAttributeApprovedInXlfFiles.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97729-1654627167: + +========================================================== +Breaking: #97729 - Respect attribute approved in XLF files +========================================================== + +See :issue:`97729` + +Description +=========== + +The new option :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['lang']['requireApprovedLocalizations']` +controls whether only approved translations are taken into account when parsing XLF files. + +This option is enabled by default for new and existing TYPO3 installations. + +Impact +====== + +If set to `true` - which is the default value - only approved translations are used. +Any non-approved translation will be ignored. +If the attribute approved is omitted, the translation is still taken into account. + +.. code-block:: xml + + <trans-unit id="label2" approved="yes"> + <source>This is label #2</source> + <target>Ceci est le libellé no. 2</target> + </trans-unit> + +Affected installations +====================== + +All TYPO3 translations using translations from XLF files. + +Migration +========= + +Either set :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['lang']['requireApprovedLocalizations']` +to `false` or add `approved="yes"` to all translations. + +.. index:: Backend, Fluid, Frontend, TCA, TypoScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97737-Page-relatedHooksInTSFERemoved.rst b/Documentation/Changelog/12.0/Breaking-97737-Page-relatedHooksInTSFERemoved.rst new file mode 100644 index 0000000..3d4c52a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97737-Page-relatedHooksInTSFERemoved.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97737-1654595331: + +===================================================== +Breaking: #97737 - Page-related hooks in TSFE removed +===================================================== + +See :issue:`97737` + +Description +=========== + +The following hooks, which were executed during the process of resolving page +details of a frontend request have been removed: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PreProcessing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['fetchPageId-PostProcessing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_preProcess']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_postProcess']` + +They have been replaced by improved PSR-14 events. + +Impact +====== + +Extensions that hook into these places are not executing the PHP-code anymore. + +Affected installations +====================== + +TYPO3 installations with extensions using one of the hooks. + +Check the "Configuration" module to see if your TYPO3 installation is using +one of the hooks by browsing :php:`$TYPO3_CONF_VARS[SC_OPTIONS]` or using the +Extension Scanner. + +Migration +========= + +The hooks are removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 events <../12.0/Feature-97737-PSR-14EventsWhenPageRootlineInFrontendIsResolved>` + +* :php:`BeforePageIsResolvedEvent` +* :php:`AfterPageWithRootLineIsResolvedEvent` +* :php:`AfterPageAndLanguageIsResolvedEvent` + +as an improved replacement. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97752-MailerAdapterInterfaceRemoved.rst b/Documentation/Changelog/12.0/Breaking-97752-MailerAdapterInterfaceRemoved.rst new file mode 100644 index 0000000..3a975c9 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97752-MailerAdapterInterfaceRemoved.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97752-1654761506: + +================================================= +Breaking: #97752 - MailerAdapterInterface removed +================================================= + +See :issue:`97752` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Mail\MailerAdapterInterface` has been removed, +since the interface became unused in v7 due to removal of Core's +:php:`SwiftMailerAdapter` implementation, which had been used as hook +subscriber in the also removed :php:`MailUtility::mail()` method. + +Impact +====== + +Implementing the interface in custom extension code will trigger +a PHP Error. + +Affected installations +====================== + +All installations implementing the interface in custom extension code, +which is very unlikely. The extension scanner will report any usage as +strong match. + +Migration +========= + +Remove any usage of the interface in extension code. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97787-AbstractMessageGetSeverityReturnsContextualFeedbackSeverity.rst b/Documentation/Changelog/12.0/Breaking-97787-AbstractMessageGetSeverityReturnsContextualFeedbackSeverity.rst new file mode 100644 index 0000000..a4661f1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97787-AbstractMessageGetSeverityReturnsContextualFeedbackSeverity.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97787-1657629392: + +==================================================================================== +Breaking: #97787 - AbstractMessage->getSeverity() returns ContextualFeedbackSeverity +==================================================================================== + +See :issue:`97787` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage` and the extended +class :php:`\TYPO3\CMS\Core\Messaging\FlashMessage` both have a method +:php:`getSeverity()` to return a flash message's severity. The return type of +the method is changed to return an instance of :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity`. + +As this method isn't supposed to be used publicly, it is declared `internal` now. + +Impact +====== + +Relying on the return type of :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage->getSeverity()` +being `int` will throw a :php:`TypeError` exception. + +There is no negative impact in the following cases: + +* Using the severity enum in Fluid for direct rendering +* Using the severity enum in :php:`json_encode()` + +In these cases, the enum's value is automatically used. + +Affected installations +====================== + +All extensions using :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage->getSeverity()` +in PHP are affected, if the integer type is expected. + +Migration +========= + +If the integer type of :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage->getSeverity()` +is expected, use the :php:`value` property of the :php:`ContextualFeedbackSeverity` enum: + +.. code-block:: php + + $flashMessage = new \TYPO3\CMS\Core\Messaging\FlashMessage('This is a message'); + $severityAsInt = $flashMessage->getSeverity()->value; + +The same applies to Fluid template, where the severity is used within another +structure, e.g. as an array key: + +.. code-block:: html + + <div class="x" class="{severityClassMapping.{status.severity.value}}"> + <!-- stuff happens here --> + </div> + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97797-GFXSettingProcessor_path_lzwRemoved.rst b/Documentation/Changelog/12.0/Breaking-97797-GFXSettingProcessor_path_lzwRemoved.rst new file mode 100644 index 0000000..6045d50 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97797-GFXSettingProcessor_path_lzwRemoved.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97797-1655730428: + +========================================================= +Breaking: #97797 - GFX setting processor_path_lzw removed +========================================================= + +See :issue:`97797` + +Description +=========== + +The global configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path_lzw']` +was used to compress GIF and TIFF files with a different ImageMagick version, +as LZW compression was removed from the distributed ImageMagick binaries back in +2004-2006. + +Since then, both GIF and TIFF have had reduced impact on the web we know today. + +For this reason, the value is removed. If GIF compression via LZW is wanted, +it should be pointing to the main `processor_path` setting. + +Impact +====== + +Compression via LZW for GIF files is now only applied when the corresponding +ImageMagick version, found in `processor_path` is supporting LZW compression. + +The GFX setting `processor_path_lzw` is not used anymore, and can safely be +removed. When accessing the Install Tool, the setting is automatically removed +from :file:`LocalConfiguration.php`. + +Affected installations +====================== + +TYPO3 installations actively using GIF compression or GIF thumbnails over PNG +thumbnails (if `GFX/thumbnails_png` is set to false), which might result in +GIF files with a larger file size. + +Migration +========= + +It is recommended to switch to PNG thumbnails (TYPO3 setting `GFX/thumbnails_png`), +or use an ImageMagick version supporting LZW compression for GIF files, if this +functionality is explicitly needed. + +In addition, solutions such as `gifsicle` can be used instead to optimize +GIF images. + +.. index:: Frontend, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97816-NewTypoScriptParserInFrontend.rst b/Documentation/Changelog/12.0/Breaking-97816-NewTypoScriptParserInFrontend.rst new file mode 100644 index 0000000..0d82f87 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97816-NewTypoScriptParserInFrontend.rst @@ -0,0 +1,107 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97816-1664800747: + +==================================================== +Breaking: #97816 - New TypoScript parser in Frontend +==================================================== + +See :issue:`97816` + +Description +=========== + +The rewrite of the TypoScript parser has been enabled for Frontend +rendering. + +See :ref:`breaking-97816-1656350406` and :ref:`feature-97816-1656350667` +for more details on the new parser. + + +Impact +====== + +The change has impact on Frontend caching, hooks, some classes and properties. In detail: + +* Hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Core/TypoScript/TemplateService']['runThroughTemplatesPostProcessing']` + is gone and substituted by :php:`AfterTemplatesHaveBeenDeterminedEvent`. See :ref:`feature-97816-1664801053` for more details. + +* The classes :php:`TYPO3\CMS\Core\TypoScript\TemplateService` and :php:`TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser` + have been marked as deprecated and shouldn't be used anymore. + An instance of :php:`TemplateService` is still kept as property :php:`TypoScriptFrontendController->tmpl` (:php:`$GLOBALS['TSFE']->tmpl) + as backwards compatible layer, and the most important properties within the class, namely especially :php:`TemplateService->setup` is + still set. To avoid using these properties, the Frontend request object will contain this state. + In rare cases, where extensions need to parse TypoScript on their own, they should switch to the Tokenizer and AstBuilder structures + of the new parser. Note these classes are still young and currently marked @internal, the API may still slightly change with further + v12 development. + +* The :php:`pagesection` cache has been removed. This was a helper cache that grew O(n) with the number of + called Frontend pages. The new :php:`typoscript` cache is used instead: This grows only O(n) with the + number of different sys_template and condition combinations and is a filesystem based :php:`PhpFrontend` implementation. + When upgrading, the database tables :sql:`cache_pagesection` and :sql:`cache_pagesections_tags` can be safely removed, the + install tool will also silently remove any existing entries from :file:`settings.php` that reconfigure the cache. + +* The Frontend rendering changed TypoScript cache behavior slightly, which may have an impact on integrators developing + and testing TypoScript in the Frontend. The short version is: When changing :sql:`sys_template` records, changes have + immediate effect, and when changing included TypoScript files, the Frontend browser tab should be reloaded using "shift-reload" + or the browser inspector should be opened and the "Disable cache" toggle turned on. Note when changes like this should + go live for everyone, Frontend caches must still be cleared using the Backend toolbar "Flush frontend caches" to have + an effect on "normal users" without active Backend login. + + Some more details on this: Frontend TypoScript in general now uses caches across multiple pages, which + increases rendering performance. After calling a first page with empty caches, a second page call to a different + page will re-use most, if not all, TypoScript from cache entries created by the first page access. + + This has impact on cache invalidation when developing Frontend TypoScript: + First, changing :sql:`sys_template` records always has immediate effect to all requests, even without clearing caches manually. + The system detects field changes of :sql:`sys_template` changes automatically, reloading a page in the Frontend will trigger + re-calculation of TypoScript and thus re-rendering of the page. Note this is only true for directly loaded :sql:`sys_template` + records. Changes on records included indirectly via the relatively seldom used :sql:`basedOn` field are *not* detected + automatically, and the same systematics as outlined below for file includes kicks in. + + The cache behavior is slightly different for files included using :typoscript:`@import`, :typoscript:`<INCLUDE_TYPOSCRIPT: ...` + and for :sql:`sys_template` records included using the :sql:`basedOn` field. To suppress expensive filesystem calls in production, + the cache layer for included files is more aggressive and does *not* automatically trigger Frontend page re-rendering when included + TypoScript files are changed. There are however some ways to easily work around this as an integrator: When a backend user is + logged in, a Frontend call recognizes this since various functionality is bound to logged in Backend users in the Frontend, most notably + the ability to preview hidden pages or hidden content, and the admin panel functionality. When changing Frontend TypoScript in included + files, being logged in with a Backend user, and then pressing "shift-reload" for the Frontend page, this will trigger "no cache", + which forces content re-rendering including TypoScript re-calculation. Additionally, the "Browser Inspectors" in Chrome and + Firefox both have a "Disable cache" toggle, which sends the same HTTP header as done with "shift-reload", which will *also* + force re-rendering. Note integrators should still "Flush frontend caches" when changes in included TypoScript files should + go-live for all other Frontend requests and thus "normal users" as well. + +* The following properties and methods in :php:`TypoScriptFrontendController` have been set to :php:`@internal` and should not + be used any longer since they may vanish without further notice: + + * :php:`TypoScriptFrontendController->no_cache` + * :php:`TypoScriptFrontendController->tmpl` + * :php:`TypoScriptFrontendController->pageContentWasLoadedFromCache` + * :php:`TypoScriptFrontendController->getFromCache_queryRow()` + * :php:`TypoScriptFrontendController->populatePageDataFromCache()` + * :php:`TypoScriptFrontendController->shouldAcquireCacheData()` + * :php:`TypoScriptFrontendController->acquireLock()` + * :php:`TypoScriptFrontendController->releaseLock()` + +* The following methods in :php:`TypoScriptFrontendController` have been removed: + + * :php:`TypoScriptFrontendController->getHash()` + * :php:`TypoScriptFrontendController->getLockHash()` + * :php:`TypoScriptFrontendController->getConfigArray()` + * :php:`TypoScriptFrontendController->()` + + +Affected installations +====================== + +Many instances will only recognize that the :php:`pagesection` cache is gone and should continue to work. +Instances with extensions that use :php:`TemplateService` or :php:`TypoScriptParser`, or access the +property :php:`TypoScriptFrontendController->tmpl` may need adaptions. + + +Migration +========= + +See the impact description above for some migration hints. + +.. index:: Database, Frontend, PHP-API, TypoScript, LocalConfiguration, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97816-TypoScriptSyntaxChanges.rst b/Documentation/Changelog/12.0/Breaking-97816-TypoScriptSyntaxChanges.rst new file mode 100644 index 0000000..1bbd24a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97816-TypoScriptSyntaxChanges.rst @@ -0,0 +1,326 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97816-1656350406: + +============================================ +Breaking: #97816 - TypoScript syntax changes +============================================ + +See :issue:`97816` + +Description +=========== + +TYPO3 v12 comes with a new TypoScript syntax parser that is more performant, +more robust and allows better tooling in the Backend. + +The new parser is more forgiving in many places, but some seldom used syntax +details have been removed, too. This documentation explains details that may +be breaking for existing instances. + +Also see :ref:`the feature documentation <feature-97816-1656350667>` +for an overview of syntax improvements. + +Impact +====== + +Using one of the constructs below stops working in v12 and needs +TypoScript adaptions. + +Affected installations +====================== + +Instances using TypoScript as outlined below. + +Migration +========= + +Streamlined constants usage +--------------------------- + +It has never been fully documented in which context "constants" :typoscript:`{$foo}` +shall be used and which exact capabilities they have. The main TypoScript constants +documentation within the :ref:`TypoScript Reference <t3tsref:typoscript-syntax-constants>` was partially +outdated, and the :ref:`TSconfig documentation <t3tsref:Syntax>` claimed TSconfig +is not constants aware at all, which isn't fully the case anymore. Let's sort out +some details: + +* Nesting constants is **not** possible and never has been. A construct like + this is invalid syntax and is treated as string literal: :typoscript:`{$foo{$bar}}` + +* Recursive constants were possible with the old parser but are not supported with the new + parser anymore. This was never documented, the Backend Template module never showed them as + resolved, only the Frontend parsed recursive constants. The simple rule is now: Never + access a constant within another constant. Instances using a construct like the below one + need to untie constants. + + .. code-block:: typoscript + + constants: + foo = fooValue + # This does not resolve to "fooValue" but is kept as string literal "{$foo}" + bar = {$foo} + + setup: + # This does NOT resolve to "fooValue", but to the string literal "{$foo}" + myValue = {$bar} + +* Similar to the above restriction, constants can be used in Frontend TypoScript *setup* conditions, + but not in Frontend TypoScript *constants* conditions. As example, a :sql:`sys_template` record with + the below content set in the :sql:`constants` field always evaluates the condition to false: + + .. code-block:: typoscript + + my.constant = myValue + ['{$my.constant}' == 'myValue'] + # Never executed since the above constant usage is NOT substituted to 'myValue' + other.constant = otherValue + [global] + + If something like this is really needed, integrators can potentially work around by accessing + a :ref:`site setting <t3coreapi:sitehandling-settings>` directly in a constants condition: + + .. code-block:: typoscript + + The sites settings.yaml: + some: + setting: someValue + + A sys_template record constants field within this site: + my.constant = myValue + [traverse(site('configuration'), 'settings/some/setting') == 'someValue'] + # This works but is rather ugly to rely on + other.constant = otherValue + [global] + +* Constants are now restricted to "assignments" and "conditions". Using a constant to + substitute an "identifier" / "object path" is no longer allowed. This has never been + clarified in the docs before and instances abusing constants to specify object paths + should be seldom and need to resolve the situation with the new parser now: + + This is supported: + + .. code-block:: typoscript + + # Simple constant usage as assignment value: + foo = {$bar} + # Compiling a value with string literals and constants: + foo = I am {$bar} + # Using a constant in a condition: + [ myValue = {$bar} ] + # Using constant(s) in multiline assignments: + foo ( + I am {$bar} and {$baz} + ) + + These constructs are *not* supported: + + .. code-block:: typoscript + + # Using a constant as object path specification + {$bar} = myValue + # This is an object path specification, too, and not supported: + foo < {$bar} + +* PageTsConfig *does* support constant substitution: Site constants can be used + in PageTsconfig. This has been introduced with TYPO3 v10, see + :ref:`feature-91080-1657827157` for details. + +File includes are always top level +---------------------------------- + +File includes with :typoscript:`@import` and :typoscript:`<INCLUDE_TYPOSCRIPT:` within +curly braces are not relative anymore. A construct like this is invalid: + +.. code-block:: typoscript + + page = PAGE + page { + @import 'EXT:my_extension/Configuration/TypoScript/bar.typoscript' + 20 = TEXT + 20.value = bar + } + +With :file:`EXT:my_extension/Configuration/TypoScript/bar.typoscript` having this content: + +.. code-block:: typoscript + + 10 = TEXT + 10.value = foo + +This *no longer* leads to this TypoScript: + +.. code-block:: typoscript + + page = PAGE + page.10 = TEXT + page.10.value = foo + page.20 = TEXT + page.20.value = bar + +Instead, the following TypoScript will be calculated: + +.. code-block:: typoscript + + page = PAGE + 10 = TEXT + 10.value = foo + 20 = TEXT + 20.value = bar + +This means :typoscript:`@import` and :typoscript:`<INCLUDE_TYPOSCRIPT:` basically break +any curly braces level, resetting current scope to top level. While inclusion of files has +never been documented to be valid within braces assignments, it still worked until TYPO3 v11. +This is now disallowed and must not be used anymore. + +:typoscript:`<INCLUDE_TYPOSCRIPT:` with :typoscript:`DIR:` and relative paths +always assumes the :file:`public/` directory as base directory now. +(Formerly it was relative to the file holding the include statement.) + +@import is more restrictive with wildcards +------------------------------------------ + +The previous implementation of :typoscript:`@import` relied on Symfony Finder. This turned out +to be a performance bottleneck, the new implementation is based on "native" PHP file and directory +lookup logic. For performance, security and best practice considerations, :typoscript:`@import` +is now a bit more restrictive than before, especially with wildcard :typoscript:`*` handling. + +Integrators are encouraged to switch from :typoscript:`<INCLUDE_TYPOSCRIPT:` to +:typoscript:`@import` in TYPO3 v12 projects: The :typoscript:`<INCLUDE_TYPOSCRIPT:` +is more complex and harder to handle, but a bit more permissive. Note :typoscript:`@import` +can be placed within conditions bodies now: :typoscript:`@import` lines are only considered +if the condition matches. This did not work with TYPO3 v11. It is likely that +:typoscript:`<INCLUDE_TYPOSCRIPT:` will be deprecated with TYPO3 v13, integrators +should adapt to :typoscript:`@import` when upgrading to TYPO3 v12 already. + +The following rules apply to :typoscript:`@import`: + +* Files *must* reside in extensions, the lookup pattern *must* start with :typoscript:`EXT` + if absolute. Including TypoScript snippets, for instance, from :file:`fileadmin` is *not* allowed + and never has been for :typoscript:`@import`. + +* File includes *may* be relative to the current file, and *must* be prefixed with :file:`./` + in this case. Subdirectories are allowed, path traversal using :file:`../` is not allowed. + +* Files *must* end with :file:`.typoscript` in frontend TypoScript. With TSconfig, both + :file:`.tsconfig` and :file:`.typoscript` are allowed, but :file:`.tsconfig` should be + preferred. + +* Directory includes are *not* recursive. + +* Directory traversal using :file:`../` is *not* allowed. + +* Wildcards for directories are *not* allowed. This has never been documented as working, and + is considered an unplanned side-effect of Symfony Finder. Few people used this undocumented + feature, it should be possible to restructure existing uses relatively easily. + +* Only a single wildcard :typoscript:`*` is allowed for filename patterns. + +Valid examples: + +.. code-block:: typoscript + + @import 'EXT:my_extension/Configuration/TypoScript/bar.typoscript' + + # Import all files in directory, ending with :file:`.typoscript`, or additionally + # :file:`.tsconfig` in TSconfig scope, in native operating system ascending order. + @import 'EXT:my_extension/Configuration/TypoScript/' + + @import 'EXT:my_extension/Configuration/TypoScript/*.typoscript' + @import 'EXT:my_extension/Configuration/TypoScript/*.setup.typoscript' + + # Import setupFoo.typoscript, setup.foo.typoscript and similar + @import 'EXT:my_extension/Configuration/TypoScript/setup*.typoscript' + @import 'EXT:my_extension/Configuration/TypoScript/setup*' + + # If this is in file 'EXT:my_extension/Configuration/TypoScript/foo.typoscript', + # file 'EXT:my_extension/Configuration/TypoScript/bar.typoscript is included + @import './bar.typoscript` + # Relative sub directories includes are supported + @import './SubDirectory/bar.typoscript` + # Relative sub directories with wildcards are supported, + # this will include ./SubDirectory/foo.typoscript + @import './SubDirectory/*' + +Invalid examples: + +.. code-block:: typoscript + + # fileadmin and friends not allowed + @import 'fileadmin/foo.typoscript' + + # Tries to include foo.txt.typoscript, *not* foo.txt + @import 'EXT:my_extension/Configuration/TypoScript/foo.txt' + + # Directory traversal is not allowed + @import 'EXT:my_extension/Configuration/TypoScript/Foo/../Bar/bar.typoscript' + + # Directory wildcards are not allowed + @import 'EXT:my_extension/Configuration/TypoScript/*/foo.typoscript' + + # Multiple wildcards in filename pattern are not allowed + @import 'EXT:my_extension/Configuration/TypoScript/foo.*.*.typoscript' + + +UTF-8 BOM in TypoScript files +----------------------------- + +The new TypoScript parser no longer ignores `UTF-8 BOM <https://en.wikipedia.org/wiki/Byte_order_mark>`_ +in included files: Having a Byte-order-mark in TypoScript files may create undesired +results. They should be removed. UTF-8 BOM is disallowed in various other languages, +for instance JSON and PHP. The new parser follows here. Modern editors typically don't +add an UTF-8 BOM anymore. + +Instances can check if they use UTF-8 BOM with a Unix shell command: + +.. code-block:: bash + + # find affected files + find . -type f -print0 | xargs -0 -n1 file {} | grep 'UTF-8 Unicode (with BOM)' + # remove UTF-8 BOM from a single file + sed -i '1s/^\xEF\xBB\xBF//' affectedFile.typoscript + +Support for \\n and \\r\\n linebreaks only +------------------------------------------ + +TypoScript sources must terminate single lines with either "\\n" (Unix ending: LineFeed), +or "\\r\\n" (Windows ending: Carriage return, LineFeed). Ancient Mac, prior to Mac OS X +used "\\r" as single linebreak character. This old linebreak type is no longer detected +when parsing TypoScript and may lead to funny results, but chances are very low any +instance is affected by this. + +Operator matching has higher precedence +--------------------------------------- + +The new parser looks for valid operators first, then parses things behind it. +Consider this example: + +.. code-block:: typoscript + + lib.nav.wrap =<ul id="nav">|</ul> + +This is ambiguous: The above :typoscript:`=<ul` could be interpreted both as an +assignment :typoscript:`=` of the value :typoscript:`<ul`, or as a reference +:typoscript:`=<` to the identifier :typoscript:`ul`. + +While the old parser interpreted this as an assignment, the new parser treats it +as a reference. + +The above example aims for an assignment, though, which can be achieved by adding +a whitespace between :typoscript:`=` and :typoscript:`<`: + +.. code-block:: typoscript + + lib.nav.wrap = <ul id="nav">|</ul> + +Frontend TypoScript `temp.` top level object +-------------------------------------------- + +The Frontend TypoScript related top level object :typoscript:`temp` had special +functionality until v12: Any TypoScript defined within was "temporary" at parse time +and unset afterwards. It was not cached and could not be used as reference +(:typoscript:`=<` operator). This special meaning has been removed, the key +:typoscript:`temp` now works just like any other top level key. + + +.. index:: Backend, Frontend, TSConfig, TypoScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-97862-HooksRelatedToGeneratingPageContentRemoved.rst b/Documentation/Changelog/12.0/Breaking-97862-HooksRelatedToGeneratingPageContentRemoved.rst new file mode 100644 index 0000000..9438fb3 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97862-HooksRelatedToGeneratingPageContentRemoved.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97862-1657195630: + +=================================================================== +Breaking: #97862 - Hooks related to generating page content removed +=================================================================== + +See :issue:`97862` + +Description +=========== + +The existing TYPO3 hooks in the process of generating a TYPO3 Frontend page + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-cached']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-all']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['usePageCache']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['insertPageIncache']` + +have been removed. These hooks have been used to execute custom PHP code after +a page is generated in the TYPO3 frontend and ready to be stored in cache. + +Due to the removal of the hooks and the introduction of the new PSR-14 events +the method signature of :php:`TypoScriptFrontendController->generatePage_postProcessing()` +has been changed. The method now requires a :php:`ServerRequestInterface` as first +argument. + +Impact +====== + +Extension code that hooks into these places will not be executed anymore in +TYPO3 v12+. + +Extension code calling :php:`TypoScriptFrontendController->generatePage_postProcessing()` +without providing a :php:`ServerRequestInterface` as first argument +will trigger a PHP `ArgumentCountError`. + +Affected installations +====================== + +TYPO3 installations with custom extensions using these hooks such as static file +generation or modifying the page content cache, which is highly likely in +third-party extensions. The extension scanner will detect usages as +strong match. + +Extensions, manually calling :php:`TypoScriptFrontendController->generatePage_postProcessing()` +without providing a :php:`ServerRequestInterface` as first argument. The +extension scanner will detect usages as weak match. + +Migration +========= + +Use one of the two newly introduced +:doc:`PSR-14 events <../12.0/Feature-97862-NewPSR-14EventsForManipulatingFrontendPageGenerationAndCacheBehaviour>`: + +* :php:`TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent` +* :php:`TYPO3\CMS\Frontend\Event\AfterCachedPageIsPersistedEvent` + +Extensions using the hooks can be made compatible with TYPO3 v11 and TYPO3 v12 +by registering a PSR-14-based event listener while keeping the legacy hook +in place. + +The :php:`AfterCacheableContentIsGeneratedEvent` acts as a replacement for + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-cached']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-all']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['usePageCache']` + +whereas the :php:`AfterCachedPageIsPersistedEvent` is the replacement for + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['insertPageIncache']`. + +Provide a :php:`ServerRequestInterface` as first argument when calling +:php:`TypoScriptFrontendController->generatePage_postProcessing()` in custom +extension code. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97926-ExtbaseQuerySettingsMethodsRemoved.rst b/Documentation/Changelog/12.0/Breaking-97926-ExtbaseQuerySettingsMethodsRemoved.rst new file mode 100644 index 0000000..9ae0ca4 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97926-ExtbaseQuerySettingsMethodsRemoved.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97926-1657726187: + +======================================================== +Breaking: #97926 - Extbase QuerySettings methods removed +======================================================== + +See :issue:`97926` + +Description +=========== + +Extbase's Persistence functionality is basing ORM queries on certain settings +usually fetched from :php:`QuerySettingsInterface`, with a default +implementation :php:`Typo3QuerySettings`. + +The interface itself has changed so that it now requires two new methods: + +:php:`QuerySettingsInterface::getLanguageAspect(): LanguageAspect` +:php:`QuerySettingsInterface::setLanguageAspect(LanguageAspect $aspect)` + +The LanguageAspect covers both the overlay functionality and setting the +language ID. + +For this reason, the following methods are removed from +:php:`QuerySettingsInterface`: + +- :php:`QuerySettingsInterface::getLanguageOverlayMode()` +- :php:`QuerySettingsInterface::setLanguageOverlayMode($languageOverlayMode)` +- :php:`QuerySettingsInterface::getLanguageUid()` +- :php:`QuerySettingsInterface::setLanguageUid($languageUid)` + +All adaptions have been made to the default implementation in +:php:`Typo3QuerySettings`, however the removed methods from the interface are kept +within the implementation to avoid fatal PHP errors. + +Impact +====== + +Any custom implementation of :php:`QuerySettingsInterface` needs to implement +the newly defined methods of the interface. + +Affected installations +====================== + +TYPO3 installations with custom Extbase extensions dealing with QuerySettings +that are adjusted with the methods used above. + +Migration +========= + +Switch the affected extensions via PHP to calling the newly added methods, as this is +how TYPO3 Core behaves the most reliable. + +.. index:: PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-97927-RemovedTypoScriptOptionConfigdoctypeSwitch.rst b/Documentation/Changelog/12.0/Breaking-97927-RemovedTypoScriptOptionConfigdoctypeSwitch.rst new file mode 100644 index 0000000..821dbfd --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97927-RemovedTypoScriptOptionConfigdoctypeSwitch.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97927-1657730964: + +================================================================= +Breaking: #97927 - Removed TypoScript option config.doctypeSwitch +================================================================= + +See :issue:`97927` + +Description +=========== + +Previous TYPO3 versions allowed to set :typoscript:`config.doctypeSwitch` +via TypoScript. + +If this option was set, the order of <?xml...> and <!DOCTYPE...> during the +rendering of a Frontend page was reversed. This was needed in the past for +Internet Explorer to be standards-compliant with XHTML. Otherwise IE's +"Quirks Mode" was used. + +Nowadays, usages for both Internet Explorer (which is not supported anymore) and +XHTML have been low, which is why the option is now removed from TYPO3 Core. + +Impact +====== + +Setting the option :typoscript:`config.doctypeSwitch` has no effect anymore, the +XML declaration and doctype statement are kept as is. + +Affected installations +====================== + +TYPO3 installations with old templates having this TypoScript option set. + +Migration +========= + +It is recommended to avoid using this functionality, and to switch to HTML5. + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-97945-RemovedWorkspaceServiceHooks.rst b/Documentation/Changelog/12.0/Breaking-97945-RemovedWorkspaceServiceHooks.rst new file mode 100644 index 0000000..ac563ab --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-97945-RemovedWorkspaceServiceHooks.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97945: + +================================================= +Breaking: #97945 - Removed WorkspaceService hooks +================================================= + +See :issue:`97945` + +Description +=========== + +The hooks :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Workspaces\Service\WorkspaceService']['hasPageRecordVersions']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Workspaces\Service\WorkspaceService']['fetchPagesWithVersionsInTable']`, +used to manipulate the state of versions for pages and tables have been removed. + +This information has been used to highlight pages in the page tree. This +modification however can now be done using the new PSR-14 +:php:`\TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore since +TYPO3 v12.0. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using these hooks in custom extension code. + +Migration +========= + +The hooks are removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-97945-PSR14AfterPageTreeItemsPreparedEvent>` +as replacement. + +.. index:: Backend, PHP-API, FullyScanned, ext:workspaces diff --git a/Documentation/Changelog/12.0/Breaking-98016-RemovedTypoScriptFunctionHook.rst b/Documentation/Changelog/12.0/Breaking-98016-RemovedTypoScriptFunctionHook.rst new file mode 100644 index 0000000..39cf3f0 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98016-RemovedTypoScriptFunctionHook.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98016-1658731955: + +=================================================== +Breaking: #98016 - Removed TypoScript function hook +=================================================== + +See :issue:`98016` + +Description +=========== + +With the transition to the :ref:`new TypoScript parser <feature-97816-1656350667>`, +the hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc']` +is no longer called. + +This hook has been used to implement own functions for the TypoScript "function" operator :typoscript:`:=`. + +Additional functions can now be implemented using the +:php:`\TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent` +as described in :ref:`this Changelog <feature-98016-1658732423>`. + +Impact +====== + +With the continued implementation of the new TypoScript parser in TYPO3 v12, +registered hook implementations are not executed anymore. The extension scanner +will report possible usages. + +Affected installations +====================== + +Extensions registering own TypoScript function implementations like this: + +.. code-block:: typoscript + + myValue := myCustomFunction(modifierArgument) + +Migration +========= + +Implement the :ref:`new event <feature-98016-1658732423>`. Extensions that want to keep +compatibility with both TYPO3 v11 and v12 can keep the old hook implementation without +further deprecations. + +.. index:: PHP-API, TSConfig, TypoScript, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98024-TCA-option-cruserid-removed.rst b/Documentation/Changelog/12.0/Breaking-98024-TCA-option-cruserid-removed.rst new file mode 100644 index 0000000..d0ab8ef --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98024-TCA-option-cruserid-removed.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98024: + +================================================= +Breaking: #98024 - TCA option `cruser_id` removed +================================================= + +See :issue:`98024` + +Description +=========== + +The TCA option in the :php:`ctrl` section of each TCA table :php:`cruser_id` has been +removed, along with populating this system-related information within DataHandler +and the auto-creation of the database field. + +The setting was used to fill the UID of the Backend User who originally created +the affected row. However, this information is also available through TYPO3's +History functionality, and does not need to be persisted twice. + +Several drawbacks came with this feature, which is why it was removed entirely: + +* Extbase did not support this functionality +* When a record was created via the Frontend in the plugin, the userid was not available + +Information about a record ("Info Popup" or within Workspaces) is now fetched +through TYPO3's History functionality. + +Impact +====== + +When creating new records, the value of the database field is not auto-populated. + +Also, when upgrading to TYPO3 v12, the database field is prepared to be removed. + +The option :php:`$GLOBALS['TCA'][$tableName]['ctrl']['cruser_id']` is also +automatically removed during cache warmup from the final TCA listing. + +Affected Installations +====================== + +TYPO3 installations actively using this field for querying or filling, not using +TYPO3 API, and accessing the database directly. + +Migration +========= + +If the need for the information – that is who created the record – is needed, +use the History functionality to fetch the creation details of a record. + +If this field is actively queried, it is recommended to add this field as a +regular TCA column with a custom hook or PSR-14 event to fill this information. + +.. index:: Database, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98032-SerializableInterfaceFullyRemoved.rst b/Documentation/Changelog/12.0/Breaking-98032-SerializableInterfaceFullyRemoved.rst new file mode 100644 index 0000000..d14750f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98032-SerializableInterfaceFullyRemoved.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98032: + +======================================================= +Breaking: #98032 - Serializable Interface fully removed +======================================================= + +See :issue:`98032` + +Description +=========== + +The :php:`Serializable` interface has been deprecated, and is +slated for removal entirely in PHP 9. The preferred +serialization tool is the :php:`__serialize`/:php:`__unserialize` +method pair. + +All serializable classes in TYPO3 Core already implement +:php:`__serialize`/:php:`__unserialize`, which is automatically +used by PHP in place of :php:`Serializable`. The now-vestigial +:php:`Serializable` references have been removed. + +Impact +====== + +Generally none, unless a text string of an object serialized in TYPO3 v10 +or earlier (using :php:`Serializable`) is deserialized in TYPO3 v12, in +which case it will not deserialize correctly due to the different +string format used by :php:`Serializable`. That is extremely unlikely +to happen. + +The use of :php:`Serializable` in extensions is not recommended anymore, +and will be removed from PHP in version 9. + +Affected Installations +====================== + +None. + +Migration +========= + +None needed. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98069-DebugConsoleRemoved.rst b/Documentation/Changelog/12.0/Breaking-98069-DebugConsoleRemoved.rst new file mode 100644 index 0000000..a08352b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98069-DebugConsoleRemoved.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98069-1659536025: + +======================================= +Breaking: #98069 - DebugConsole removed +======================================= + +See :issue:`98069` + +Description +=========== + +The DebugConsole comes from ExtJS times and was triggered when e.g. a request +failed to give a developer its response, including the stacktrace. Nowadays, +browsers offer a console allowing to investigate requests. Also, PHP debuggers +(e.g. xdebug) are commonly known and used, which makes the DebugConsole obsolete. + +Impact +====== + +Triggering the DebugConsole is not possible anymore. Also, the PHP method +:php:`\TYPO3\CMS\Core\Utility\DebugUtility::debug()` always renders the plain +debug output to the client. + +The 3rd argument :php:`$group` of the method +:php:`\TYPO3\CMS\Core\Utility\DebugUtility::debug()` is removed. + +Affected installations +====================== + +All installations are affected. + +Migration +========= + +No migration is available. + +.. index:: Backend, JavaScript, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98089-RemovedFontAwesome.rst b/Documentation/Changelog/12.0/Breaking-98089-RemovedFontAwesome.rst new file mode 100644 index 0000000..790eedc --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98089-RemovedFontAwesome.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98089-1659734321: + +====================================== +Breaking: #98089 - Removed FontAwesome +====================================== + +See :issue:`98089` + +Description +=========== + +The node package `font-awesome` and the related CSS and font files have been +removed from the TYPO3 backend. This also includes the icon provider class +:php:`\TYPO3\CMS\Core\Imaging\IconProvider\FontawesomeIconProvider`. + +The configuration option :php:`icon-class` of login providers has no effect +anymore. + +Impact +====== + +Using the aforementioned icon provider to register icons is not possible +anymore. Also, any direct usage of :css:`fa-*` classes will not work anymore. + +Affected installations +====================== + +All installations relying on FontAwesome are affected. + +Migration +========= + +Migrate to the `@typo3/icons` package if possible. If the TYPO3 installation +still requires FontAwesome, install the polyfill extension `fontawesome_provider`. + +To install the extension via Composer, run the command +:bash:`composer require friendsoftypo3/fontawesome-provider`. + +The extension will be available in TER soon as `fontawesome_provider <https://extensions.typo3.org/extension/fontawesome_provider>`__. + +.. index:: Backend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98100-CompressionAndConcatenationOfJavaScriptAndCSSFilesForBackendRemoved.rst b/Documentation/Changelog/12.0/Breaking-98100-CompressionAndConcatenationOfJavaScriptAndCSSFilesForBackendRemoved.rst new file mode 100644 index 0000000..68bb65a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98100-CompressionAndConcatenationOfJavaScriptAndCSSFilesForBackendRemoved.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98100-1659877890: + +================================================================================================ +Breaking: #98100 - Compression and Concatenation of JavaScript and CSS files for Backend removed +================================================================================================ + +See :issue:`98100` + +Description +=========== + +Extension `backend` introduced compression and concatenation of CSS and JavaScript +files in version 4.3 due to limitations of Internet Explorer 9 and lower. +Since then, extension `backend` uses JavaScript modules and loading via RequireJS and +ES Modules, as well as CSS compression and concatenation by default during +build time. + +For this reason, this feature is removed from the actual `ResourceCompressor`, +which only works in TYPO3 Frontend rendering now via the common TypoScript +settings. + +Impact +====== + +A custom handler for concatenation and compression of JavaScript and CSS files has +no effect anymore when registered in a third-party extension. + +This could previously be configured via + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['jsConcatenateHandler']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['jsCompressHandler']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['cssConcatenateHandler']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['cssCompressHandler']` + +Additionally, it was previously possible to configure a custom root path +in ResourceCompressor via :php:`setRootPath($rootPath)`, which has been removed +as well. + +Affected installations +====================== + +TYPO3 installations with custom JavaScript and CSS handlers for TYPO3 Backend routines +via custom extensions which is highly unlikely. + +Migration +========= + +None, as component-based CSS files and module-based JavaScript files are loaded already +anyway, and the performance impact of loading multiple files is rather low due +to optimized :file:`.htaccess` configurations already, and through bundling all CSS for +Core in optimized files as well. + +.. index:: LocalConfiguration, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98158-UpdateToSymfony6.rst b/Documentation/Changelog/12.0/Breaking-98158-UpdateToSymfony6.rst new file mode 100644 index 0000000..96c1b0c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98158-UpdateToSymfony6.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98158-1660740286: + +====================================== +Breaking: #98158 - Update to Symfony 6 +====================================== + +See :issue:`98158` + +Description +=========== + +TYPO3 Core now ships with Symfony 6.1. Previously TYPO3 v11 used Symfony Components +in version 5.4. + +Impact +====== + +Some PHP code now might need to consider other types, especially regarding PHP +classes which might be extended or used directly, where types for arguments are used. + +One example is that all CLI Commands (used in custom extensions) now need to define +an :php:`int` as return type of the :php:`execute()` method, otherwise the CLI +command will not be executed anymore. + +Affected installations +====================== + +TYPO3 Installations with extensions making heavy use of Symfony components directly. + +Migration +========= + +Functionality such as the CLI Commands can already put in place for TYPO3 versions prior to +TYPO3 v12. It is recommended to use tools such as Rector to detect possible problems when +having extensions interacting with Symfony Components directly. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98179-RemoveBackendInterfaceSelectorAndConfigurableRedirect.rst b/Documentation/Changelog/12.0/Breaking-98179-RemoveBackendInterfaceSelectorAndConfigurableRedirect.rst new file mode 100644 index 0000000..118be4c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98179-RemoveBackendInterfaceSelectorAndConfigurableRedirect.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98179-1660903844: + +============================================================================== +Breaking: #98179 - Remove backend interface selector and configurable redirect +============================================================================== + +See :issue:`98179` + +Description +=========== + +Previous TYPO3 installations allowed to configure an interface selector in the +backend login that gave the authenticating backend user the possibility to +choose whether to get redirected to frontend or backend by configuring +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['interfaces'] = 'backend,frontend'`. + +If only one option was configured, the redirect to either backend or frontend +was enforced, where `backend` was the default configuration. + +This feature was meaningful once TYPO3 shipped EXT:feedit, but was conceptually +broken ever since, as the matter of fact a TYPO3 installation can contain +multiple site roots was overseen and a user may get redirected to the wrong +frontend. Also, if EXT:adminpanel is not installed, there is no one-click +solution to access the TYPO3 backend. + +Impact +====== + +The configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['interfaces']` +is removed, therefore an authenticated user always gets redirected to the +backend. + +Affected installations +====================== + +All TYPO3 installations relying on this feature are affected. + +Migration +========= + +The extension scanner will find remaining usages of +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['interfaces']`, which can be removed. + +If a TYPO3 project really relies on this feature, create an XCLASS of +:php:`\TYPO3\CMS\Backend\Controller\LoginController`, where also a custom Fluid +template may be used. + +.. note:: + + XCLASSes are not covered by our platform stability promise and may break + anytime without preliminary information! + +.. index:: Backend, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98193-PersistentStorageModuleReturnsPromises.rst b/Documentation/Changelog/12.0/Breaking-98193-PersistentStorageModuleReturnsPromises.rst new file mode 100644 index 0000000..a9de399 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98193-PersistentStorageModuleReturnsPromises.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98193-1661262696: + +============================================================= +Breaking: #98193 - Persistent storage module returns Promises +============================================================= + +See :issue:`98193` + +Description +=========== + +The methods of the JavaScript module :js:`@typo3/backend/storage/persistent` now +return native :js:`Promise` objects where jQuery-based promises were returned +previously. + +This requires migration of any code using the returned jQuery promise. + +This affects the following methods: + +* :js:`set()` +* :js:`addToList()` +* :js:`unset()` + +Impact +====== + +Using callbacks of jQuery-based promises (:js:`done`, :js:`fail` or :js:`always`) +will trigger JavaScript errors, as native :js:`Promise` objects don't know these +callbacks. + +Affected installations +====================== + +All extensions using any of the aforementioned methods and relying on the +returned objects are affected. + +Migration +========= + +In most cases, changing the method name of the callback is sufficient, where the +following rules apply: + ++-----------------------+-----------------+ +| jQuery-based callback | Native callback | ++=======================+=================+ +| done() | then() | ++-----------------------+-----------------+ +| fail() | catch() | ++-----------------------+-----------------+ +| always() | finally() | ++-----------------------+-----------------+ + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98195-RemoveTODAYS_SPECIALErrorConstant.rst b/Documentation/Changelog/12.0/Breaking-98195-RemoveTODAYS_SPECIALErrorConstant.rst new file mode 100644 index 0000000..b69f3f0 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98195-RemoveTODAYS_SPECIALErrorConstant.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98195-1661274247: + +======================================================= +Breaking: #98195 - Remove TODAYS_SPECIAL error constant +======================================================= + +See :issue:`98195` + +Description +=========== + +The constant :php:`TODAYS_SPECIAL` in :php:`\TYPO3\CMS\Core\SysLog\Error` has never been +used in TYPO3 Core and is therefore removed without replacement. + +Impact +====== + +Third-party extensions using the extension will fail with a PHP error. + +Affected installations +====================== + +3rd party extensions who use the :php:`TODAYS_SPECIAL` constant. + +Migration +========= + +3rd party extensions using the :php:`TODAYS_SPECIAL` constant should replace +all usages with the integer value `100` or use a custom class with a user +defined constant. + +.. index:: Backend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98261-RemovedJQueryInPopoverModule.rst b/Documentation/Changelog/12.0/Breaking-98261-RemovedJQueryInPopoverModule.rst new file mode 100644 index 0000000..f6f969f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98261-RemovedJQueryInPopoverModule.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98261-1662389392: + +=================================================== +Breaking: #98261 - Removed jQuery in Popover module +=================================================== + +See :issue:`98261` + +Description +=========== + +The support for jQuery in the module :js:`@typo3/backend/popover` has been +dropped. Passing jQuery elements to the module's methods is not possible anymore. + +This affects the following methods: + +* :js:`popover()` +* :js:`setOptions()` +* :js:`show()` +* :js:`hide()` +* :js:`destroy()` +* :js:`toggle()` + +Impact +====== + +Calling any of the aforementioned methods with passing a jQuery-based object is +undefined and will lead to JavaScript errors. + +Affected installations +====================== + +All 3rd party extensions using the API of the :js:`@typo3/backend/popover` module +are affected. + +Migration +========= + +The method :js:`popover()` accepts either an object of type :js:`HTMLElement` +or a collection of type :js:`NodeList`, where all elements must be of type +:js:`HTMLElement`. + +Any other method accepts objects of type :js:`HTMLElement` only. + +Example: + +.. code-block:: js + + // Before + Popover.popover($('button.popover')); + + // After + Popover.popover(document.querySelectorAll('button.popover')); + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98275-RemovedPreDefinedLinkTitleAttributesInRTELinkBrowser.rst b/Documentation/Changelog/12.0/Breaking-98275-RemovedPreDefinedLinkTitleAttributesInRTELinkBrowser.rst new file mode 100644 index 0000000..3111546 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98275-RemovedPreDefinedLinkTitleAttributesInRTELinkBrowser.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98275-1662540769: + +================================================================================ +Breaking: #98275 - Removed pre-defined link title attributes in RTE link browser +================================================================================ + +See :issue:`98275` + +Description +=========== + +Back in the old HTMLArea it was possible to pre-define a link title in the +`classesAnchor` configuration which got applied after selecting the CSS class +for a link. This feature was migrated to EXT:rte_ckeditor in TYPO3 v8. + +From an SEO and accessibility point of view, this doesn't make much sense as +this would lead to repetitive usage of the same link title, not helping much at +all. + +For those reasons, the possibilities to + +* pre-define a link title +* make the link title field read-only + +have been removed without substitution. + +Impact +====== + +Pre-configuring a link title based on the applied CSS class is not possible +anymore. Also, configuring the link title field to be read-only is not possible +anymore. + +Affected installations +====================== + +All installations configuring :yaml:`classesAnchor.*.linkText` or +:yaml:`buttons.link.properties.title.readOnly` in an RTE configuration file are +affected. + +Migration +========= + +There is no migration available. Removing the obsolete settings +:yaml:`classesAnchor.*.linkText` and :yaml:`buttons.link.properties.title.readOnly` +is recommended. + +.. index:: Backend, RTE, NotScanned, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.0/Breaking-98281-MakeAbstractPluginInternal.rst b/Documentation/Changelog/12.0/Breaking-98281-MakeAbstractPluginInternal.rst new file mode 100644 index 0000000..2935ddb --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98281-MakeAbstractPluginInternal.rst @@ -0,0 +1,157 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98281-1662549900: + +================================================ +Breaking: #98281 - Make AbstractPlugin @internal +================================================ + +See :issue:`98281` + +Description +=========== + +Extending the class :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` is not a +recommended way of developing Frontend plugins anymore. This class is not +maintained anymore and may be removed in future versions without further notice. + +The TypoScript property :typoscript:`plugin.tx_myextension_pi1._DEFAULT_PI_VARS` +has only been used in the class :php:`AbstractPlugin`. It is therefore not public +API anymore. + +Impact +====== + +Plugins based on :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` will +continue to function. However, there will be warnings about using internal +code displayed in most IDEs. + +:typoscript:`_DEFAULT_PI_VARS` has been removed from syntax highlighting as it is +not public API anymore. + +Affected installations +====================== + +All extensions having classes that extend +:php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` are affected. + +Migration +========= + +Remove the dependency of :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin`. If +functionality of this class is still used, copy it into your plugin. + +Example +------- + +Class before migration: + +.. code-block:: php + :caption: EXT:gh_randomcontent/Classes/Plugin/RandomContent.php + + use Psr\Http\Message\ServerRequestInterface; + + class RandomContent extends AbstractPlugin + { + public function main( + string $content, + array $conf, + ServerRequestInterface $request, + ): string + { + $this->conf = $conf; + + // Init FlexForm configuration for plugin + $this->pi_initPIflexForm(); + if ($this->pi_getFFvalue( + $this->cObj->data['pi_flexform'], + 'which_pages', 'sDEF') + ) { + $this->conf['pages'] = $this->pi_getFFvalue( + $this->cObj->data['pi_flexform'], + 'which_pages', 'sDEF' + ); + } + // ... + } + } + +Class after migration: + +.. code-block:: php + :caption: EXT:gh_randomcontent/Classes/Plugin/RandomContent.php + + use Psr\Http\Message\ServerRequestInterface; + + class RandomContent + { + /** + * The back-reference to the mother cObj object set at call time + */ + public $cObj; + + /** + * This setter is called when the plugin is called from UserContentObject (USER) + * via ContentObjectRenderer->callUserFunction(). + * + * @param ContentObjectRenderer $cObj + */ + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + + public function main( + string $content, + array $conf, + ServerRequestInterface $request, + ): string + { + $this->conf = $conf; + + $this->pi_initPIflexForm(); // Init FlexForm configuration for plugin + if ($this->pi_getFFvalue($this->cObj->data['pi_flexform'], + 'which_pages', 'sDEF')) { + $this->conf['pages'] = $this->pi_getFFvalue( + $this->cObj->data['pi_flexform'], + 'which_pages', + 'sDEF' + ); + } + // ... + } + + /** + * Converts $this->cObj->data['pi_flexform'] from XML string to FlexForm array. + * + * @param string $field Field name to convert + */ + public function pi_initPIflexForm($field = 'pi_flexform') + { + // ... + } + + public function pi_getFFvalue( + $T3FlexForm_array, + $fieldName, + $sheet = 'sDEF', + $lang = 'lDEF', + $value = 'vDEF' + ) { + // ... + } + } + +It is also possible to migrate to an Extbase plugin using a controller. +See the :ref:`Extbase documentation, chapter +"Frontend Plugins" <t3coreapi:extbase_registration_of_frontend_plugins>`. + +.. important:: + + The `AbstractPlugin` is :ref:`deprecated <deprecation-100639-1681740974>` + through a follow-up change and will be removed in TYPO3 v13. Executing + a plugin via the TypoScript userFunc (also utilized the `$request` argument for + the `main` method) continues to work, as long as the collection of `AbstractPlugin` + methods are ported to the custom user class. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-98288-UpdatedBackendModalAPI.rst b/Documentation/Changelog/12.0/Breaking-98288-UpdatedBackendModalAPI.rst new file mode 100644 index 0000000..5faf1d3 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98288-UpdatedBackendModalAPI.rst @@ -0,0 +1,133 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98288-1662580832: + +============================================ +Breaking: #98288 - Updated Backend Modal API +============================================ + +See :issue:`98288` + +Description +=========== + +The modal API provided by the module :js:`@typo3/backend/modal.js` has been +adapted to backed by a custom web component and therefore gained an updated, +stateless interface. + +The return type of all :js:`Modal.*` factory methods has been changed from +:js:`JQuery` to :js:`ModalElement`. + +:js:`ModalElement` is a web component which allows to attach modal API +(like modal hiding) directly to the returned object. Usage of globals like +`Modal.currentModal` can thus be avoided when using the returned +:js:`ModalElement`. + +This affects the following methods which now return :js:`ModalElement`: + +* :js:`Modal.confirm()` +* :js:`Modal.loadUrl()` +* :js:`Modal.show()` +* :js:`Modal.advanced()` +* :js:`Modal.setButtons()` +* :js:`Modal.generate()` + +Furthermore the following changes have been applied: + +* The :js:`Button` property `dataAttributes` has been removed without + replacement, as the functionality can be expressed via :js:`Button.name` + or :js:`Button.trigger` and is therefore redundant. + +* The :js:`ajaxTarget` of the modal :js:`Configuration` object has been + dropped, as it was never actually used in TYPO3. Use nested, custom + web components for dynamic ajax loading of modal sub areas. + +* The rendering life cycle has been adapted to synchronize rendering to + the browsers idle callback. That means rendering is delayed and modal content + can not be modified directly after modal creation. + The existing API :js:`Configuration.callback` has to be used instead, but + usage of lit :js:`TemplateResult` without the need for post-processing is + suggested to be used instead. + +* The :js:`bs.modal.*` events are no longer considered API, but remain working + for the time being (as bootstrap modal is still used right now). + These events may be dropped at any time, when the modal component is switched + to shadow dom, or the native `<dialog>` tag. + Therefore :js:`typo3-modal-*` events are to be used instead. + +* The event :js:`modal-destroyed` has been removed. + Use :js:`typo3-modal-hide` or :js:`typo3-modal-hidden` instead. + +* :js:`Modal.currentModal.trigger('modal-dismiss')` has been removed. + Use :js:`ModalElement.hideModal()` instead. + +Impact +====== + +Using jQuery API on :js:`ModalElement` will lead to JavaScript errors as +no jQuery interop is provided. + +Affected installations +====================== + +All 3rd party extensions using the API of the :js:`@typo3/backend/modal.js` +module are affected, if they use the return type of the methods to attach +to events or to customize the modal after creations. + +Migration +========= + +Given the following fully-fledged example of a modal that uses custom buttons, +with custom attributes, triggers and events, they should be migrated away +from :js:`JQuery` to :js:`ModalElement` usage. + +Existing code: + +.. code-block:: javascript + + var configuration = { + buttons: [ + { + text: 'Save changes', + name: 'save', + icon: 'actions-document-save', + active: true, + btnClass: 'btn-primary', + dataAttributes: { + action: 'save' + }, + trigger: function() { + Modal.currentModal.trigger('modal-dismiss'); + } + } + ] + }; + Modal + .advanced(configuration) + .on('hidden.bs.modal', function() { + // do something + }); + +Should be adapted to: + +.. code-block:: javascript + + const modal = Modal.advanced({ + buttons: [ + { + text: 'Save changes', + name: 'save', + icon: 'actions-document-save', + active: true, + btnClass: 'btn-primary', + trigger: function(event, modal) { + modal.hideModal(); + } + } + ] + }); + modal.addEventListener('typo3-modal-hidden', function() { + // do something + }); + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98303-RemovedHooksForLanguageOverlaysInPageRepository.rst b/Documentation/Changelog/12.0/Breaking-98303-RemovedHooksForLanguageOverlaysInPageRepository.rst new file mode 100644 index 0000000..c0f661e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98303-RemovedHooksForLanguageOverlaysInPageRepository.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98303-1662659583: + +======================================================================== +Breaking: #98303 - Removed hooks for language overlays in PageRepository +======================================================================== + +See :issue:`98303` + +Description +=========== + +The hooks + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getRecordOverlay']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getPageOverlay']` + +have been removed in favor of new PSR-14 events. + +In addition, the method :php:`PageRepository->getRecordOverlay()` has been +marked as protected as the new events take place at a slightly different +piece of code. + +Impact +====== + +Extensions using these hooks will have no effect anymore. + +Extensions calling :php:`PageRepository->getRecordOverlay()` will trigger +a deprecation warning. + +Affected installations +====================== + +TYPO3 installations with custom extensions using these hooks. + +Migration +========= + +Migrate to the new :ref:`PSR-14 events <feature-98303-1662659478>`: + +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforeRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\AfterRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforePageLanguageOverlayEvent` + +Extensions using the hooks can be made compatible with TYPO3 v11 and TYPO3 v12 +by registering a PSR-14-based event listener while keeping the legacy hook +in place. + +Extensions calling :php:`PageRepository->getRecordOverlay()` should call +:php:`PageRepository->getLanguageOverlay()` instead. + +The events are now fired for any kind of database table, and are much +more generic, as they contain the full language fallback chain and overlay +behavior. + +.. index:: Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98304-RemovedHookForModifyingEditFormUserAccess.rst b/Documentation/Changelog/12.0/Breaking-98304-RemovedHookForModifyingEditFormUserAccess.rst new file mode 100644 index 0000000..68882ee --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98304-RemovedHookForModifyingEditFormUserAccess.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98304: + +=================================================================== +Breaking: #98304 - Removed hook for modifying edit form user access +=================================================================== + +See :issue:`98304` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/alt_doc.php']['makeEditForm_accessCheck']` +has been removed in favor of a new PSR-14 event +:php:`\TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent`. + +Additionally, the corresponding :php:`TYPO3\CMS\Backend\Form\Exception\AccessDeniedHookException`, +which had been thrown in case a hook denied the user access has been replaced +by the :php:`TYPO3\CMS\Backend\Form\Exception\AccessDeniedListenerException`. + +Impact +====== + +Any hook implementation registered is not executed anymore in +TYPO3 v12.0+. The :php:`AccessDeniedHookException` is not thrown +anymore. The extension scanner will report possible usages. + +Affected Installations +====================== + +All TYPO3 installations using this hook or the exception in custom +extension code. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v11 (using the hook) and v12+ (using the new event). + +Use the :doc:`PSR-14 event <../12.0/Feature-98304-PSR-14EventForModifyingEditFormUserAccess>` +as an improved replacement, providing an object-oriented approach +as well as built-in convenience features and an increased amount +of context information. + +Any usage of the :php:`AccessDeniedHookException` should be replaced by the +:php:`AccessDeniedListenerException`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98308-LegacyHTMLAttributesBorderAndLongdescRemovedFromFrontendRendering.rst b/Documentation/Changelog/12.0/Breaking-98308-LegacyHTMLAttributesBorderAndLongdescRemovedFromFrontendRendering.rst new file mode 100644 index 0000000..78385af --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98308-LegacyHTMLAttributesBorderAndLongdescRemovedFromFrontendRendering.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98308-1662713106: + +============================================================================================= +Breaking: #98308 - Legacy HTML attributes border and longdesc removed from frontend rendering +============================================================================================= + +See :issue:`98308` + +Description +=========== + +The :typoscript:`IMAGE` content object previously supported `longdesc` and `border` attributes +to be set to the :html:`<img>` tag which was composed. The appropriate settings +:typoscript:`longDesc` and :typoscript:`border` within :typoscript:`IMAGE` cObject +have been removed. + +The TypoScript property :typoscript:`config.disableImgBorderAttr` has been removed +as well. + +Also, the :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions` PHP class, which +generated default :html:`<img>` tags via the :php:`imgTag()` method, has been +adapted as the method is removed. + +Impact +====== + +Using the TypoScript settings will have no effect anymore. + +Calling the method :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->imgTag()` +will result in a fatal PHP error. + +Affected installations +====================== + +TYPO3 installation using TypoScript :typoscript:`IMAGE` cObject explicitly +requiring the :typoscript:`border` and :typoscript:`longDesc` attributes. + +Migration +========= + +Instead of border attribute, styling via CSS should be used. + +See https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/border. + +Also use alternative markup for accessibility with the "title" attribute instead +of "longdesc". + +See https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/longDesc +for examples on how to migrate. + +For the removed :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->imgTag()` +method, it is recommended for PHP developers to build the HTML code themselves. + +.. index:: TypoScript, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-98312-TypoScriptSettingPageCSS_inlineStyleRemoved.rst b/Documentation/Changelog/12.0/Breaking-98312-TypoScriptSettingPageCSS_inlineStyleRemoved.rst new file mode 100644 index 0000000..d2500e6 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98312-TypoScriptSettingPageCSS_inlineStyleRemoved.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98312-1662725671: + +================================================================== +Breaking: #98312 - TypoScript setting page.CSS_inlineStyle removed +================================================================== + +See :issue:`98312` + +Description +=========== + +The TypoScript setting :typoscript:`page.CSS_inlineStyle` which was used to +inject an inline CSS string into the TYPO3 Frontend has been removed. + +Impact +====== + +Using this setting has no effect anymore since TYPO3 v12. + +Affected installations +====================== + +TYPO3 installations having this option set in their TypoScript setup. + +Migration +========= + +Use :typoscript:`page.cssInline` instead, which has been around for many +TYPO3 versions already. + +The superior setting :typoscript:`page.cssInline` allows to use +:typoscript:`stdWrap` and :typoscript:`cObject`. + +Example for migration: + +.. code-block:: typoscript + + page.CSS_inlineStyle = a { color: red; } + + page.cssInline.100 = TEXT + page.cssInline.100.value = a { color: red; } + +.. index:: TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-98319-NewFileLocationForLocalConfigurationphpAndAdditionalConfigurationphp.rst b/Documentation/Changelog/12.0/Breaking-98319-NewFileLocationForLocalConfigurationphpAndAdditionalConfigurationphp.rst new file mode 100644 index 0000000..d2996e5 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98319-NewFileLocationForLocalConfigurationphpAndAdditionalConfigurationphp.rst @@ -0,0 +1,96 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98319-1664641595: + +=============================================================================================== +Breaking: #98319 - New file location for LocalConfiguration.php and AdditionalConfiguration.php +=============================================================================================== + +See :issue:`98319` + +Description +=========== + +Every TYPO3 installation requires a mandatory file named :php:`typo3conf/LocalConfiguration.php`. + +This file contains system-wide configuration options such as database credentials, +or paths to image processing details. + +Historically, the very original name was `typo3conf/localconf.php` (this is also +where the extension's file name `ext_localconf.php` comes from). + +This file was renamed in TYPO3 v6.0 to :php:`LocalConfiguration.php` and since +then returns a PHP array with settings then available in :php:`$TYPO3_CONF_VARS`. + +Specific PHP code with additional logic (e.g. context-specific conditions) was +available in :php:`typo3conf/AdditionalConfiguration.php`. + +With TYPO3 v12, the names for both files and their location have been changed. + +The prefix "Local" in :php:`LocalConfiguration.php` originates from the +three-divided location of "System", "Global" and "Local" extensions - the latter +is "specific to a TYPO3 installation" where as other extensions and configuration +could be shared with multiple TYPO3 installations. + +TYPO3 v12 has a strong support for Composer and TYPO3's own code base has +progressed since 2012, when TYPO3 v6.0 was released. TYPO3 Core itself now +only consists of extensions which are available as native Composer packages. +The concept of global extensions has been phased out over the past versions. + +Instead, TYPO3 installations now distinguish between "dependencies" such as +custom extensions, TYPO3 Core extensions or extensions from packagist.org or +TYPO3 Extension Repository, and "project-specific" configuration. This +project-specific configuration - as known from other PHP frameworks - is now +placed in a settings configuration file and additional configuration file. + +Newcomers or users from other PHP projects might understand the concept of a file +with certain settings much better, so the file locations and the file names +have been changed. + +For non-Composer-based installations the file names are: + +* :file:`typo3conf/LocalConfiguration.php` is now available in + :file:`typo3conf/system/settings.php` +* :file:`typo3conf/AdditionalConfiguration.php` is now available in + :file:`typo3conf/system/additional.php` + +Composer-based TYPO3 projects by default have the possibility to place certain +files from outside the document root, and using the document root such as :file:`public/` +as a subfolder. This way, Composer-based TYPO3 projects can restrict direct public +access to such files via the webserver. + +TYPO3 in its Composer Mode already creates a folder named :file:`config/` on the +project root level, where e.g. site configuration is stored. Within the +:file:`config/` folder, the new location is placed. + +* :file:`typo3conf/LocalConfiguration.php` is now available in + :file:`config/system/settings.php` +* :file:`typo3conf/AdditionalConfiguration.php` is now available in + :file:`config/system/additional.php` + +Impact +====== + +TYPO3 automatically moves :file:`typo3conf/LocalConfiguration.php` and +:file:`typo3conf/AdditionalConfiguration.php` to their respective new places on +the first PHP request. The old file is not evaluated anymore, as soon as the file +in the new location is available. + +Affected installations +====================== + +All TYPO3 installations prior to TYPO3 v12. + +Migration +========= + +The configuration files are automatically moved with TYPO3 v12.0 to their new +locations, so no manual process is needed. + +Projects working with a version control system such as Git might need to adapt +their :file:`.gitignore` file or their deployment strategies. + +In addition, TYPO3 projects relying on the file locations and their structures +might need adaptions. + +.. index:: LocalConfiguration, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98370-ExtbaseRequestCleanupAndHardening.rst b/Documentation/Changelog/12.0/Breaking-98370-ExtbaseRequestCleanupAndHardening.rst new file mode 100644 index 0000000..4b8b836 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98370-ExtbaseRequestCleanupAndHardening.rst @@ -0,0 +1,76 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98370-1663513316: + +======================================================== +Breaking: #98370 - Extbase Request cleanup and hardening +======================================================== + +See :issue:`98370` + +Description +=========== + +Extbase :php:`\TYPO3\CMS\Extbase\Mvc\Request` has been turned into a decorator of the +PSR-7 :php:`ServerRequestInterface` :doc:`with Core v11 <../11.3/Feature-94428-ExtbaseRequestImplementsServerRequestInterface>`: +Extbase-based extensions work with the PSR-7 Core Request, Extbase +specific Request state is attached as an attribute to the PSR-7 Request. + +Most of these Extbase-specific attribute properties are now available in Core v12 +by activating the according decorator methods in :php:`\TYPO3\CMS\Extbase\Mvc\RequestInterface`, +which is implemented by :php:`\TYPO3\CMS\Extbase\Mvc\Request`. The :php:`RequestInterface` now +also properly extends PSR-7 :php:`ServerRequestInterface` and is type-hinted within +the Extbase framework. + +PSR-7 interfaces rely on object immutability: A created Request object is never changed, instead +a new object is created and returned when changed. The old fashioned Extbase Request violated this with +various :php:`setXY()` methods. These have been removed, and this is the part that is considered +breaking for consuming extensions. + +Impact +====== + +Extbase-based extensions using static code analyzers like phpstan in CI +should benefit from improved scanner results and can further harden their +codebase. + +Affected installations +====================== + +Extensions that actively manipulate the given Extbase :php:`Request` using setter methods +will trigger fatal PHP "method does not exist" errors. + +Instances with extensions actively creating a :php:`Request` must hand over +a :php:`\Psr\Http\Message\ServerRequestInterface` as constructor argument, it must have +the attribute :php:`extbase` set, which must be an instance of +:php:`\TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters`. + +Migration +========= + +It is relatively seldom that Extbase extensions need to actively manipulate the Extbase +Request since most of that is handled by the Extbase Framework internally for consuming +extensions. + +Extensions that use the :php:`setXY()` methods for whatever reasons have to +change them to their :php:`withXY()` counterparts, though: Nearly all "withers" are now +declared as part of :php:`RequestInterface` and already exist in v11, "setters" can be +migrated quite easily. + +From an Extbase Framework API point of view, extension should *only* rely on :php:`RequestInterface` +methods: The second level :php:`ExtbaseRequestParameters` attribute is considered +:php:`@internal` and extensions shouldn't work with it directly. There are just a couple +of methods used by Extbase Framework based on direct :php:`ExtbaseRequestParameters` manipulation, +most of them are related to the action argument validation and action forwarding behavior of Extbase, +which Extbase extensions in general shouldn't need to deal with themselves. + +When changing from "setters" to "withers", the important key change is that calling a +:php:`withXY()` method *does not* manipulate the existing request, but *returns a new* +instance instead. In practice, if the previous Request object has been set to some other +client object beforehand, and if a new Request is created using a :php:`withXY()` +method, those client objects may need to be updated with the new object. A typical use case +is :php:`$this->view` in a controller class, which may now need :php:`$this->view->setRequest($myNewRequest)` +to receive the new :php:`Request` to work on, and it's most likely also a good idea to update +:php:`$this->request = $myNewRequest` as well. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Breaking-98375-RemovedHooksInPageModule.rst b/Documentation/Changelog/12.0/Breaking-98375-RemovedHooksInPageModule.rst new file mode 100644 index 0000000..2c37baa --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98375-RemovedHooksInPageModule.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98375-1663598608: + +=============================================== +Breaking: #98375 - Removed hooks in Page Module +=============================================== + +See :issue:`98375` + +Description +=========== + +Since TYPO3 v10, TYPO3 Backend's Page Module is based on Fluid and custom +rendering functionality. The internal class "PageLayoutView" is now removed, +along with its interfaces and hooks. + +The following hooks are removed with a PSR-14 equivalent: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['record_is_used']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][PageLayoutView::class]['modifyQuery']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem']` + +The following hooks have been removed without substitution: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter']` + +Existing patterns such as the PreviewRenderer concept can be used instead of +the latter hooks. + +Impact +====== + +Registering one of the hooks above in TYPO3 v12+ has no effect anymore. + +Affected installations +====================== + +TYPO3 installations with modifications to the page module in third-party +extensions via one of the hooks. + +Migration +========= + +Use :php:`TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent` as a +drop-in alternative for :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['record_is_used']` + +Use :php:`TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent` +as a drop-in replacement for :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][PageLayoutView::class]['modifyQuery']` + +Use :php:`TYPO3\CMS\Backend\View\Event\PageContentPreviewRenderingEvent` as a +drop-in replacement for :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem']` + +Extension authors that use these hooks can register a new event listener +and keep the hook registration to stay compatible with TYPO3 v11 and TYPO3 v12 +at the same time. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98377-FluidStandaloneViewDoesNotCreateAnExtbaseRequestAnymore.rst b/Documentation/Changelog/12.0/Breaking-98377-FluidStandaloneViewDoesNotCreateAnExtbaseRequestAnymore.rst new file mode 100644 index 0000000..7cb2af2 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98377-FluidStandaloneViewDoesNotCreateAnExtbaseRequestAnymore.rst @@ -0,0 +1,88 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98377-1663607123: + +================================================================================== +Breaking: #98377 - Fluid StandaloneView does not create an Extbase Request anymore +================================================================================== + +See :issue:`98377` + +Description +=========== + +In our efforts to further speed up, streamline and separate Fluid from Extbase, +the :php:`\TYPO3\CMS\Fluid\View\StandaloneView` has been changed to no longer +create an Extbase Request anymore. + +StandaloneView is typically not used in Extbase context, creating an Extbase +Request at this point was a very unfortunate architectural flaw leading to a +not wanted context switch. + +Not having an Extbase Request within StandaloneView anymore can have impact on +behavior of some Fluid ViewHelpers. + +Impact +====== + +Common usages of StandaloneView are a frontend related :typoscript:`FLUIDTEMPLATE` +content object, plus various usages in non-Extbase extensions like rendering eMails +or similar. Within :typoscript:`FLUIDTEMPLATE`, the current non-Extbase PSR-7 +ServerRequest is actively set to StandaloneView, custom extension usages may need +to :php:`$view->setRequest($request)` explicitly. + +Some ViewHelpers that rely on Extbase functionality throw exceptions when +a Request is not set, or if the Request is not an Extbase Request. Those will +refuse to work for instance when used in a template triggered by a :typoscript:`FLUIDTEMPLATE` +content object. + +Most notably, all :html:`f:form` ViewHelpers are affected of this, plus +eventually custom ViewHelpers that access Extbase specific :php:`Request` +methods. + +Affected installations +====================== + +Instances with extensions using StandaloneView in their code may need attention, +and frontend rendering using :typoscript:`FLUIDTEMPLATE` content objects may need +adaptions if Extbase-only ViewHelpers like :html:`f:form` are used. + +Migration +========= + +Avoiding :html:`f:form` in non-Extbase context +---------------------------------------------- + +The :html:`f:form` ViewHelpers are Extbase specific: They especially take care of +handling Extbase internal fields like :html:`__referrer` and similar. The casual solution +is to switch these usages away from those ViewHelpers, and use the HTML counterparts +directly, for instance using :html:`<input ...>` instead of :html:`<f:form.input ...>`. + +Custom StandaloneView code +-------------------------- + +Extensions that instantiate :php:`StandaloneView` may want to :php:`$view->setRequest($request)` +to hand over the current request to the view, since the request is no longer initialized +automatically. This is needed for ViewHelpers that rely on :php:`$renderingContext->getRequest()`. + +Custom ViewHelpers +------------------ + +Custom ViewHelpers used in :php:`StandaloneView` that call methods from Extbase +:php:`TYPO3\CMS\Extbase\Mvc\Request` which are not part of +:php:`Psr\Http\Message\ServerRequestInterface` will throw fatal PHP errors. + +Possible solutions: + +* Create an Extbase Request within a controller and :php:`setRequest()` it to the + view instance as quick solution. +* Properly boot Extbase using the Extbase Bootstrap to have a fully initialized + Extbase Request in the View. +* Avoid using Extbase specific methods within the ViewHelper by checking if the + incoming request implements Extbase :php:`TYPO3\CMS\Extbase\Mvc\RequestInterface`. + This allows creating "hybrid" ViewHelpers that work in both contexts. +* Avoid using Extbase specific methods within the ViewHelper by fetching data from + the given :php:`Psr\Http\Message\ServerRequestInterface` Request, or it's attached + core attributes. + +.. index:: Fluid, PHP-API, NotScanned, ext:fluid diff --git a/Documentation/Changelog/12.0/Breaking-98437-WorkspaceTSConfigSwapModeAndChangeStageModeRemoved.rst b/Documentation/Changelog/12.0/Breaking-98437-WorkspaceTSConfigSwapModeAndChangeStageModeRemoved.rst new file mode 100644 index 0000000..93a14e2 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98437-WorkspaceTSConfigSwapModeAndChangeStageModeRemoved.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98437-1664187644: + +========================================================================== +Breaking: #98437 - Workspace TSConfig swapMode and changeStageMode removed +========================================================================== + +See :issue:`98437` + +Description +=========== + +The following User TSconfig options related to the system extension "workspaces" +have been removed. + +* :typoscript:`options.workspaces.swapMode` +* :typoscript:`options.workspaces.changeStageMode` + +Impact +====== + +Setting the above options to :typoscript:`any` or :typoscript:`page` in +User TSconfig has no effect anymore: They were used to publish and change +state of more than the selected records in the workspace Backend module, which +was a rather hard to grasp feature for editors and usability wise questionable. + +Affected installations +====================== + +Instances with loaded workspaces extensions using these options in +User TSconfig are affected. + +These options were most likely used very seldom and the implementation has +been at least partially broken since TYPO3 Core v8. + +Migration +========= + +No migration path available. + +.. index:: Backend, TSConfig, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/12.0/Breaking-98441-HookRecStatInfoHooksRemoved.rst b/Documentation/Changelog/12.0/Breaking-98441-HookRecStatInfoHooksRemoved.rst new file mode 100644 index 0000000..eed6c7f --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98441-HookRecStatInfoHooksRemoved.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98441-1664267734: + +================================================== +Breaking: #98441 - Hook "recStatInfoHooks" removed +================================================== + +See :issue:`98441` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['recStatInfoHooks']` +has been removed from TYPO3 Core. + +The hook was used to modify the list of icons in the Page Module and the List module. + +More modern solutions have been in place in previous TYPO3 versions already. + +Impact +====== + +Hook implementations in third-party extensions will be ignored. + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. Affected extensions +can be detected in the Extension Scanner of the Install Tool. + +Migration +========= + +For the page module, the new Fluid-based page module (available since TYPO3 v10), allows +to modify the icon list directly in the template. + +For list module implementations, the PSR-14 event :php:`ModifyRecordListRecordActionsEvent` +can be used instead since TYPO3 v11. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98443-ExtensionRecordlistMergedIntoBackend.rst b/Documentation/Changelog/12.0/Breaking-98443-ExtensionRecordlistMergedIntoBackend.rst new file mode 100644 index 0000000..0f7aff2 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98443-ExtensionRecordlistMergedIntoBackend.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98443-1664275773: + +=========================================================== +Breaking: #98443 - Extension recordlist merged into backend +=========================================================== + +See :issue:`98443` + +Description +=========== + +The TYPO3 Core extension "recordlist" has been integrated into the Core +extension "backend". Extension "recordlist" does not exist anymore, all +existing functionality like the "List module" is available within the "backend" +extension. + +Impact +====== + +When upgrading to TYPO3 Core v12, extension "backend" replaces extension "recordlist" +automatically. + +The following classes have been renamed: + +* :php:`\TYPO3\CMS\Recordlist\Browser\AbstractElementBrowser` to :php:`\TYPO3\CMS\Backend\ElementBrowser\AbstractElementBrowser` +* :php:`\TYPO3\CMS\Recordlist\Browser\DatabaseBrowser` to :php:`\TYPO3\CMS\Backend\ElementBrowser\DatabaseBrowser` +* :php:`\TYPO3\CMS\Recordlist\Browser\ElementBrowserInterface` to :php:`\TYPO3\CMS\Backend\ElementBrowser\ElementBrowserInterface` +* :php:`\TYPO3\CMS\Recordlist\Browser\ElementBrowserRegistry` to :php:`\TYPO3\CMS\Backend\ElementBrowser\ElementBrowserRegistry` +* :php:`\TYPO3\CMS\Recordlist\Browser\FileBrowser` to :php:`\TYPO3\CMS\Backend\ElementBrowser\FileBrowser` +* :php:`\TYPO3\CMS\Recordlist\Browser\FolderBrowser` to :php:`\TYPO3\CMS\Backend\ElementBrowser\FolderBrowser` +* :php:`\TYPO3\CMS\Recordlist\Controller\AbstractLinkBrowserController` to :php:`\TYPO3\CMS\Backend\Controller\AbstractLinkBrowserController` +* :php:`\TYPO3\CMS\Recordlist\Controller\AccessDeniedException` to :php:`\TYPO3\CMS\Backend\Exception\AccessDeniedException` +* :php:`\TYPO3\CMS\Recordlist\Controller\ClearPageCacheController` to :php:`\TYPO3\CMS\Backend\Controller\ClearPageCacheController` +* :php:`\TYPO3\CMS\Recordlist\Controller\ElementBrowserController` to :php:`\TYPO3\CMS\Backend\Controller\ElementBrowserController` +* :php:`\TYPO3\CMS\Recordlist\Controller\RecordListController` to :php:`\TYPO3\CMS\Backend\Controller\RecordListController` +* :php:`\TYPO3\CMS\Recordlist\Controller\RecordDownloadController` to :php:`\TYPO3\CMS\Backend\Controller\RecordListDownloadController` +* :php:`\TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent` to :php:`\TYPO3\CMS\Backend\Controller\Event\RenderAdditionalContentToRecordListEvent` +* :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListHeaderColumnsEvent` to :php:`\TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListHeaderColumnsEvent` +* :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListRecordActionsEvent` to :php:`\TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListRecordActionsEvent` +* :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent` to :php:`\TYPO3\CMS\Backend\RecordList\Event\ModifyRecordListTableActionsEvent` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\AbstractLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\AbstractLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\FileLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\FileLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\FolderLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\FolderLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\LinkHandlerInterface` to :php:`\TYPO3\CMS\Backend\LinkHandler\LinkHandlerInterface` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\MailLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\MailLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\PageLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\PageLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\RecordLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\RecordLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\TelephoneLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\TelephoneLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\LinkHandler\UrlLinkHandler` to :php:`\TYPO3\CMS\Backend\LinkHandler\UrlLinkHandler` +* :php:`\TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList` to :php:`\TYPO3\CMS\Backend\RecordList\DatabaseRecordList` +* :php:`\TYPO3\CMS\Recordlist\RecordList\DownloadRecordList` to :php:`\TYPO3\CMS\Backend\RecordList\DownloadRecordList` +* :php:`\TYPO3\CMS\Recordlist\Tree\View\LinkParameterProviderInterface` to :php:`\TYPO3\CMS\Backend\Tree\View\LinkParameterProviderInterface` +* :php:`\TYPO3\CMS\Recordlist\View\RecordSearchBoxComponent` to :php:`\TYPO3\CMS\Backend\View\RecordSearchBoxComponent` +* :php:`\TYPO3\CMS\Recordlist\View\FolderUtilityRenderer` to :php:`\TYPO3\CMS\Backend\View\FolderUtilityRenderer` + +Affected installations +====================== + +Extension "recordlist" was a hard dependency of a working TYPO3 instance and always +installed. When upgrading to TYPO3 Core v12, the TYPO3 Package Manager will simply +ignore the extension now. + +Extension extending PHP classes or implementing interfaces +of "recordlist" will continue to work, all moved classes and interfaces have been +established as aliases. Extensions should update their dependencies in case they are +extending or implementing specific "recordlist" functionality, the extension scanner +will find possible usages. + +Extensions using the LinkHandler API might need to update corresponding +:typoscript:`TCEMAIN.linkHandler.*` configuration. + +Migration +========= + +The "typo3/cms-recordlist" dependency can be safely removed as Composer dependency: + +.. code-block:: shell + + composer rem typo3/cms-recordlist + +Extensions using classes of extension "recordlist" should use the new classes instead. +Extensions supporting both TYPO3 v11 and v12 can continue to use the old class names +since they have been established as aliases to the new class names. These aliases will +be removed with TYPO3 Core v13. + +Extensions using the :php:`TYPO3\CMS\Recordlist\LinkHandler\RecordLinkHandler` +as :typoscript:`handler` for a custom :typoscript:`linkHandler` should adjust +corresponding TSconfig to use the new class name +:php:`TYPO3\CMS\Backend\LinkHandler\RecordLinkHandler`. Corresponding service +alias will be removed in TYPO3 v13. + +.. index:: Backend, PHP-API, FullyScanned, ext:recordlist diff --git a/Documentation/Changelog/12.0/Breaking-98455-ChangedDefinitionOfFormEngineSuggestItem.rst b/Documentation/Changelog/12.0/Breaking-98455-ChangedDefinitionOfFormEngineSuggestItem.rst new file mode 100644 index 0000000..3143956 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98455-ChangedDefinitionOfFormEngineSuggestItem.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98455-1664350742: + +================================================================ +Breaking: #98455 - Changed definition of FormEngine suggest item +================================================================ + +See :issue:`98455` + +Description +=========== + +The definition of an item used in the FormEngine auto-suggest list has changed. +The following properties are unused now and were therefore removed: + +* `text` - contained a pre-composed text including markup rendered in an result + item +* `style` - was unused already +* `class` - was unused already +* `sprite` - contained a pre-composed markup of the icon being rendered + +The property `icon` is added and is an array containing the `identifier` of an +icon and optionally an `overlay` identifier. + +Example: + +.. code-block:: php + + $icon = $this->iconFactory->getIconForRecord($this->table, $row, Icon::SIZE_SMALL); + $entry = [ + // ... + 'icon' => [ + 'identifier' => $icon->getIdentifier(), + 'overlay' => $icon->getOverlayIcon()?->getIdentifier(), + ], + ]; + +Impact +====== + +The removed properties `text`, `style`, `class`, and `sprite` are ignored if +still supplied by custom suggest wizards. Also, the `icon` property must be provided. + +The removed property `text` also affects potential :php:`renderFunc` callbacks +configured in the field's TCA. + +Affected installations +====================== + +All extensions having a custom suggest wizard or providing a :php:`renderFunc` +are affected. + +Migration +========= + +Albeit rarely used, custom suggest wizards need to adjust the suggest items in +their :php:`queryTable()` method. Potential render methods defined in the TCA +field's :php:`renderFunc` config may not manipulate an entry's `text` property. + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98455-RemovedDevbridge-autocomplete.rst b/Documentation/Changelog/12.0/Breaking-98455-RemovedDevbridge-autocomplete.rst new file mode 100644 index 0000000..48d63d1 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98455-RemovedDevbridge-autocomplete.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98455-1664349649: + +================================================= +Breaking: #98455 - Removed devbridge-autocomplete +================================================= + +See :issue:`98455` + +Description +=========== + +The jQuery library :js:`devbridge-autocomplete` used to provide an auto-suggest +feature has been removed from TYPO3 along with its CSS. + +Impact +====== + +Importing the module :js:`jquery/autocomplete` and calling `.autocomplete()` on +a jQuery object will lead to JavaScript errors. + +Affected installations +====================== + +All extensions relying on :js:`devbridge-autocomplete` are affected. + +Migration +========= + +If absolutely mandatory, install and import :js:`devbridge-autocomplete` in your +extension or site package. + +Otherwise, remove the import of :js:`jquery/autocomplete` from your JavaScript +code and implement an auto-suggest feature on your own by combining the modules +:js:`@typo3/core/event/debounce-event` and :js:`@typo3/core/ajax/ajax-request`. + +Listen on the :js:`input` event on an input field using :js:`DebounceEvent`, +send an AJAX request to the endpoint, and render the returned result list. +Finally, bind a :js:`click` event handler on each result item that executes the +desired action and hides the result list again. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98479-RemovedFileReferenceRelatedFunctionality.rst b/Documentation/Changelog/12.0/Breaking-98479-RemovedFileReferenceRelatedFunctionality.rst new file mode 100644 index 0000000..7dda3bb --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98479-RemovedFileReferenceRelatedFunctionality.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98479-1664622195: + +=============================================================== +Breaking: #98479 - Removed file reference related functionality +=============================================================== + +See :issue:`98479` + +Description +=========== + +With the introduction of the new TCA type :php:`file`, a couple of cross +dependencies have been removed, mainly related to FormEngine. + +The :php:`customControls` hook option is not available for the new +TCA type :php:`file`. It has been replaced by the new PSR-14 +:php:`CustomFileControlsEvent` for this use case. + +The field :sql:`table_local` of table :sql:`sys_file_reference`: is no longer +evaluated by TYPO3 and has therefore been removed. + +The following options are no longer evaluated for TCA type :php:`inline`: + +- :php:`[appearance][headerThumbnail]` +- :php:`[appearance][fileUploadAllowed]` +- :php:`[appearance][fileByUrlAllowed]` + +The following options are no longer evaluated for TCA type :php:`group`: + +- :php:`[appearance][elementBrowserType]` +- :php:`[appearance][elementBrowserAllowed]` + +A TCA migration is in place, removing those values from custom configurations. + +Impact +====== + +Adding custom controls with the :php:`customControls` option does no longer +work for FAL fields. + +Using the :sql:`table_local` field of table :sql:`sys_file_reference` does +no longer work and might lead to database errors. + +Using one of the mentioned :php:`[appearance]` TCA options does no longer +have any effect. + +Affected installations +====================== + +All installations making use of the :php:`customControls` option for FAL +fields, directly using the sql:`table_local` field of table +:sql:`sys_file_reference` or using one of the mentioned :php:`[appearance]` +TCA options for TCA type :php:`inline` and :php:`group` fields. The latter is +rather unlikely because the :php:`[appearance]` options of :php:`group` +had only effect in FAL context and the options have only been set internally +by the :php:`ExtensionManagementUtility->getFileFieldTCAConfig()` API method. + +Migration +========= + +Migrate corresponding user functions for the :php:`customControls` option to +a PSR-14 event listeners of the +:ref:`CustomFileControlsEvent <feature-98479-1664537749>`. + +Remove any usage of the :sql:`table_local` field of +table :sql:`sys_file_reference` in custom extension code. + +Remove the mentioned :php:`[appearance]` TCA options from your custom TCA +configurations. + +.. index:: Backend, Database, FAL, PHP-API, TCA, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Breaking-98480-TCATableSys_templateIsNoLongerWorkspaceAware.rst b/Documentation/Changelog/12.0/Breaking-98480-TCATableSys_templateIsNoLongerWorkspaceAware.rst new file mode 100644 index 0000000..3d1da42 --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98480-TCATableSys_templateIsNoLongerWorkspaceAware.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98480-1664546652: + +======================================================================== +Breaking: #98480 - TCA table "sys_template" is no longer workspace aware +======================================================================== + +See :issue:`98480` + +Description +=========== + +The TCA database table :sql:`sys_template` is no longer workspace aware: +When changing sys_template records in a workspace in the Backend, this has +immediate effect on Live workspace. + +Impact +====== + +Records of the TypoScript table "sys_template" are not available for editors, +this change should not have impact on editors working on content related +workspace records. + +Affected installations +====================== + +Instances with enabled workspaces extension may see an impact: Editing TypoScript +template records immediately affects live. This is a relatively seldom scenario, +an upgrade wizard is in place to set all workspace aware sys_template records to +deleted, preventing them to leak to live. + +Migration +========= + +Do not change :sql:`sys_template` rows in workspaces anymore. Any changes will +be published to live on edit. + +.. index:: Backend, Frontend, TCA, TypoScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98487-GLOBALSPAGES_TYPESRemoved.rst b/Documentation/Changelog/12.0/Breaking-98487-GLOBALSPAGES_TYPESRemoved.rst new file mode 100644 index 0000000..2d9fd8a --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98487-GLOBALSPAGES_TYPESRemoved.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98487-1664575125: + +================================================== +Breaking: #98487 - $GLOBALS['PAGES_TYPES'] removed +================================================== + +See :issue:`98487` + +Description +=========== + +The global array :php:`PAGES_TYPES` has been removed in favor of a new registry +class containing the shared state. + +Impact +====== + +Accessing or modifying :php:`$GLOBALS['PAGES_TYPES']` will have no effect anymore. + +Affected installations +====================== + +TYPO3 installations with custom extensions creating custom TCA records or custom +Page Doktypes. + +Migration +========= + +Use the new :php:`TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry` class to register +custom page types with their dependency what kind of records should be allowed for +creation, or to read the information what record types are allowed on a +specific pages.doktype. The class must be used within :path:`ext_tables.php`. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Breaking-98488-TypolinkOptionAddQueryStringOnlyIncludesResolvedQueryArguments.rst b/Documentation/Changelog/12.0/Breaking-98488-TypolinkOptionAddQueryStringOnlyIncludesResolvedQueryArguments.rst new file mode 100644 index 0000000..91a8c5b --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98488-TypolinkOptionAddQueryStringOnlyIncludesResolvedQueryArguments.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98488-1664578695: + +========================================================================================== +Breaking: #98488 - Typolink option "addQueryString" only includes resolved query arguments +========================================================================================== + +See :issue:`98488` + +Description +=========== + +The Typolink option :typoscript:`typolink.addQueryString` previously set all given +GET parameters to a generated URL that were handed in to the request. + +This option is also used under the hood for the Fluid ViewHelpers +:html:`<f:link.typolink>`, :html:`<f:uri.typolink>`, :html:`<f:link.page>`, +:html:`<f:uri.page>`, :html:`<f:link.action>`, :html:`<f:link.action>` and +:html:`<f:form>`. + +With TYPO3 v9 and routing, this option now only adds the query arguments that +have been resolved during the routing process. This way, additional query arguments +are never added by default. + +Impact +====== + +Setting :typoscript:`typolink.addQueryString = 1` now adds only arguments resolved +by Route Enhancers, any other query arguments are rejected. + +As a consequence, arbitrary query arguments are not reflected in the +canonical link reference anymore. Declaring corresponding route definitions +is required to have those values reflected again. + +Affected installations +====================== + +TYPO3 installations relying on `typolink.addQueryString`. + +Migration +========= + +It is recommended to keep the setting as is, as TYPO3 can identify valid query +arguments via Routing. + +However, to ensure the previous behaviour, the option +:typoscript:`typolink.addQueryString` can be set to `untrusted` to add all given. + +The same value is also possible for the Fluid ViewHelpers +:html:`<f:link.typolink>`, :html:`<f:uri.typolink>`, :html:`<f:link.page>`, +:html:`<f:uri.page>`, :html:`<f:link.action>`, :html:`<f:link.action>` and +:html:`<f:form>`. + +.. index:: Fluid, TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Breaking-98489-RemovalOfSleepTaskAndTestTask.rst b/Documentation/Changelog/12.0/Breaking-98489-RemovalOfSleepTaskAndTestTask.rst new file mode 100644 index 0000000..9661b1e --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98489-RemovalOfSleepTaskAndTestTask.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98489-1664577935: + +==================================================== +Breaking: #98489 - Removal of SleepTask and TestTask +==================================================== + +See :issue:`98489` + +Description +=========== + +Previous TYPO3 installation contained the task `SleepTask` and `TestTask` which +served as examples when scheduler was introduced in 2009. + +The tasks have been removed without substitution. + +Impact +====== + +The scheduler tasks are not available anymore and won't be executed. + +Affected installations +====================== + +All TYPO3 installations relying on these scheduler tasks. + +Migration +========= + +If used, the tasks can be removed within the scheduler module. + +The configuration :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['scheduler']['showSampleTasks']` +is removed automatically. + +.. index:: PHP-API, NotScanned, ext:scheduler diff --git a/Documentation/Changelog/12.0/Breaking-98490-VariousHooksAndMethodsChangedInDatabaseRecordList.rst b/Documentation/Changelog/12.0/Breaking-98490-VariousHooksAndMethodsChangedInDatabaseRecordList.rst new file mode 100644 index 0000000..b0ecf0c --- /dev/null +++ b/Documentation/Changelog/12.0/Breaking-98490-VariousHooksAndMethodsChangedInDatabaseRecordList.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-98490-1664580829: + +========================================================================== +Breaking: #98490 - Various hooks and methods changed in DatabaseRecordList +========================================================================== + +See :issue:`98490` + +Description +=========== + +The following hooks have been removed + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/class.db_list_extra.inc']['getTable']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList']['modifyQuery']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList']['makeSearchStringConstraints']` + +They were mainly used within the list module / record listing or the Element Browser +to modify the database query altering the result set of records rendered. + +Along with the hooks, various method signatures within :php:`DatabaseRecordList` +have been changed. + +* :php:`DatabaseRecordList->getQueryBuilder()` has the arguments `$pageId` + and `$additionalConstraints` removed +* :php:`DatabaseRecordList->getTable()` only uses one argument now +* :php:`DatabaseRecordList->makeSearchString()` is now marked as protected + +Impact +====== + +Extensions adding implementations for these hooks have no effect anymore. + +Extensions making use of calling the PHP methods directly will result in +a fatal PHP error. + +Affected installations +====================== + +TYPO3 installations with third-party extensions modifying the record list via +the hooks above. + +Migration +========= + +Use the new PSR-14 event :php:`ModifyDatabaseQueryForRecordListingEvent` +which serves at the very end to alter the actual QueryBuilder object to modify +the database query before it is executed. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-87616-UnusedInterfaceForTypolinkModifyLinkConfigForPageLinksHook.rst b/Documentation/Changelog/12.0/Deprecation-87616-UnusedInterfaceForTypolinkModifyLinkConfigForPageLinksHook.rst new file mode 100644 index 0000000..b77965d --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-87616-UnusedInterfaceForTypolinkModifyLinkConfigForPageLinksHook.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-87616: + +=================================================================================== +Deprecation: #87616 - Unused Interface for TypolinkModifyLinkConfigForPageLinksHook +=================================================================================== + +See :issue:`87616` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typolinkProcessing']['typolinkModifyParameterForPageLinks']` +required hook implementations to implement :php:`TypolinkModifyLinkConfigForPageLinksHookInterface`. + +Since the mentioned hook is :doc:`removed <../12.0/Breaking-87616-RemovedHookForAlteringPageLinks>`, +the interface is not in use anymore and has been marked as deprecated. + +Impact +====== + +The extension scanner will now notify any extension, which might still use +the PHP interface. + +Affected Installations +====================== + +TYPO3 installations using the PHP interface in custom extension code. + +Migration +========= + +The PHP interface is still available for TYPO3 v12.x, so extensions can +provide a version which is compatible with TYPO3 v11 (using the hook) +and TYPO3 v12.x (using the new PSR-14 ModifyPageLinkConfigurationEvent), +at the same time. + +Remove any usage of the PHP interface and use the new PSR-14 +event to avoid any further problems in TYPO3 v13+. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-92508-UnusedInterfaceForFilterMenuPagesHook.rst b/Documentation/Changelog/12.0/Deprecation-92508-UnusedInterfaceForFilterMenuPagesHook.rst new file mode 100644 index 0000000..5edb0d9 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-92508-UnusedInterfaceForFilterMenuPagesHook.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-92508: + +=============================================================== +Deprecation: #92508 - Unused Interface for filterMenuPages hook +=============================================================== + +See :issue:`92508` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/tslib/class.tslib_menu.php']['filterMenuPages']` +required hook implementations to implement :php:`AbstractMenuFilterPagesHookInterface`. + +Since the mentioned hook is :doc:`removed <../12.0/Breaking-92508-RemovedHookForFilteringHMENUItems>`, +the interface is not in use anymore and has been marked as deprecated. + +Impact +====== + +The extension scanner will now notify any extension, which might +still use the PHP interface. + +Affected Installations +====================== + +TYPO3 installations using the PHP interface in custom extension code. + +Migration +========= + +The PHP interface is still available for TYPO3 v12.x, so extensions can +provide a version which is compatible with TYPO3 v11 (using the hook) +and TYPO3 v12.x (using the new event), at the same time. + +Remove any usage of the PHP interface and use the new PSR-14 +event to avoid any further problems in TYPO3 v13+. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-94117-RegisterExtbaseTypeConvertersAsServices.rst b/Documentation/Changelog/12.0/Deprecation-94117-RegisterExtbaseTypeConvertersAsServices.rst new file mode 100644 index 0000000..f272926 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-94117-RegisterExtbaseTypeConvertersAsServices.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-94117: + +================================================================== +Deprecation: #94117 - Register extbase type converters as services +================================================================== + +See :issue:`94117` + +Description +=========== + +Because Extbase type converters are no longer registered via +:php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()` but +as container services, also the configuration, such as `sourceType` or +`targetType` is now defined in the :file:`Services.yaml`. + +Therefore, the following configuration related properties and methods +of :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter` have +been deprecated: + +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::$sourceTypes` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::$targetType` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::$priority` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::getSupportedSourceTypes()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::getSupportedTargetType()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::getPriority()` +- :php:`\TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter::canConvertFrom()` + +The methods have also been removed from the :php:`TypeConverterInterface`, see +:doc:`changelog <../12.0/Breaking-94117-RegisterExtbaseTypeConvertersAsServices>`. + +Impact +====== + +Since those properties and methods were important for registering and +configuring type converters but are replaced with type converter registrations +in :file:`Services.yaml`, they are now obsolete and without functionality. + +If defined in an own type converter, those properties and methods can be +removed there as well. + +Affected Installations +====================== + +All installations with custom type converters, extending :php:`AbstractTypeConverter` +and relying on those properties and methods. + +Migration +========= + +In custom type converters, drop mentioned properties and methods and don't access +said properties and methods of :php:`AbstractTypeConverter` from outside. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.0/Deprecation-95456-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst b/Documentation/Changelog/12.0/Deprecation-95456-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst new file mode 100644 index 0000000..2f918c2 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-95456-IntroduceBootstrap5CompatibleAndAccessibleTemplates.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-95456: + +===================================================== +Deprecation: #95456 - Deprecate legacy form templates +===================================================== + +See :issue:`95456` + +Description +=========== + +Using the legacy form template / partial variants residing in +:file:`EXT:form/Resources/Private/Frontend/Templates` and +:file:`EXT:form/Resources/Private/Frontend/Partials` +is deprecated. The legacy templates will be removed in v14. + +Impact +====== + +No deprecation is logged since it would flood the logs. + +Affected Installations +====================== + +Installations using custom templates for form elements. + +Migration +========= + +Set your form rendering option "templateVariant" within the form setup from +"version1" to "version2" to use the future default templates. + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Form: + renderingOptions: + templateVariant: version2 + +Migrate your templates / partials to make them compatible with the ones stored in +:file:`EXT:form/Resources/Private/FrontendVersion2`. + +.. index:: Frontend, NotScanned, ext:form diff --git a/Documentation/Changelog/12.0/Deprecation-96136-DeprecateInlineJavaScriptInBackendUpdateSignals.rst b/Documentation/Changelog/12.0/Deprecation-96136-DeprecateInlineJavaScriptInBackendUpdateSignals.rst new file mode 100644 index 0000000..0574990 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96136-DeprecateInlineJavaScriptInBackendUpdateSignals.rst @@ -0,0 +1,120 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96136: + +=========================================================================== +Deprecation: #96136 - Deprecate inline JavaScript in backend update signals +=========================================================================== + +See :issue:`96136` + +Description +=========== + +When changing data via the backend user interface a so called *update signal* +is triggered to update other components like page tree or toolbar items in the +document header bar. + +Using inline JavaScript for handing custom signals is deprecated and will be +ignored in TYPO3 v13.0. + +Impact +====== + +Retrieving signals via :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getUpdateSignalCode` +or having custom signal callbacks defined in :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['updateSignalHook']` +which provide `JScode` are deprecated and will trigger a corresponding PHP +error message. + +Affected Installations +====================== + +see impact + +Migration +========= + +`BackendUtility::getUpdateSignalCode` +------------------------------------- + +In case :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getUpdateSignalCode` +is called directly, new :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getUpdateSignalDetails` +shall be used, which is supposed to return safe HTML markup instead of inline +JavaScript code. + +Custom signal callbacks +----------------------- + +Usually those custom signal hooks are declared like this: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php'] + ['updateSignalHook']['OpendocsController::updateNumber'] = + OpendocsToolbarItem::class . '->updateNumberOfOpenDocsHook'; + +Existing implementation using `JScode` +...................................... + +.. code-block:: php + + class OpendocsToolbarItem + { + public function updateNumberOfOpenDocsHook(&$params) + { + $params['JScode'] = ' + if (top && top.TYPO3.OpendocsMenu) { + top.TYPO3.OpendocsMenu.updateMenu(); + } + '; + } + } + +Using :php:`JScode` (containing inline JavaScript) is deprecated and +is subject to be migrated. + +Potential migration using HTML markup +..................................... + +TYPO3 v11 introduced some special markup helpers and components that +allow to dispatch actions, without actually using inline JavaScript. + +* :php:`\TYPO3\CMS\Backend\Domain\Model\Element\ImmediateActionElement`, + which creates a HTML web-component containing the actual relevant payload like + :html:`<typo3-immediate-action action="..." args="..."></typo3-immediate-action>` +* :php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` rendered using + :php:`\TYPO3\CMS\Core\Page\JavaScriptRenderer::render`, which uses a script helper + that loads JavaScript modules and invokes a method or assigns variables globally, e.g. + :html:`<script src="/typo3/sysext/core/Resources/Public/JavaScript/JavaScriptItemHandler.js" ...>` + +**Side-note**: Just using markup like :html:`<script>alert(1)</script>` +is **not** considered a good solution as it still contains inline JavaScript. + +.. code-block:: php + + class OpendocsToolbarItem + { + public function updateNumberOfOpenDocsHook(&$params) + { + $params['html'] = ImmediateActionElement::dispatchCustomEvent( + 'typo3:opendocs:updateRequested', + null, + true + ); + } + } + +:php:`ImmediateActionElement` is utilized to trigger a custom event in JavaScript, +which is handled by a custom JavaScript module in that particular case. + +.. code-block:: typescript + + class OpendocsMenu { + constructor() { + document.addEventListener( + 'typo3:opendocs:updateRequested', + (evt: CustomEvent) => this.updateMenu(), + ); + } + +.. index:: Backend, JavaScript, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-96444-AuthModeSelectItemsKeywordsMovedToIndex5.rst b/Documentation/Changelog/12.0/Deprecation-96444-AuthModeSelectItemsKeywordsMovedToIndex5.rst new file mode 100644 index 0000000..2220f31 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96444-AuthModeSelectItemsKeywordsMovedToIndex5.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96444: + +===================================================================== +Deprecation: #96444 - authMode select items keywords moved to index 5 +===================================================================== + +See :issue:`96444` + +Description +=========== + +With the introduction of itemGroups, the array index 3 of the select items array +has been shifted one position up. Before that, the index 3 was used for +descriptions and index 4 for an optional keyword `EXPL_ALLOW` or `EXPL_DENY`. +These are used together with :php:`'authMode' => 'individual'` to explicitly +allow or deny single items. + +Since descriptions now occupy the array index 4, the former usage of this index +is now shifted as well one position up to index 5. + +Impact +====== + +For backwards compatibility reasons, a TCA migration is in place, which will +check for these special keywords and move them one index up. This will log a +"TCA migration done" message in the admin tools upgrade module. + +Affected Installations +====================== + +All installations, which use TCA type `select` with `authMode=individual`, while +defining the keywords `EXPL_ALLOW` or `EXPL_DENY` in the items array at index 4. + +Migration +========= + +Before: + +.. code-block:: php + :emphasize-lines: 12 + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'select', + 'authMode' => 'individual', + 'items' => [ + [ + 0 => 'Label 1', + 1 => 'Value 1', + 2 => null, + 3 => null, + 4 => 'EXPL_ALLOW', + ], + ], + ], + ], + ], + +After: + +.. code-block:: php + :emphasize-lines: 12,13 + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'select', + 'authMode' => 'individual', + 'items' => [ + [ + 0 => 'Label 1', + 1 => 'Value 1', + 2 => null, + 3 => null, + 4 => '', // This can be left empty + 5 => 'EXPL_ALLOW', + ], + ], + ], + ], + ], + +.. index:: Backend, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-96500-ContentObjectRenderer-getMailTo.rst b/Documentation/Changelog/12.0/Deprecation-96500-ContentObjectRenderer-getMailTo.rst new file mode 100644 index 0000000..2514880 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96500-ContentObjectRenderer-getMailTo.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96500: + +====================================================== +Deprecation: #96500 - ContentObjectRenderer->getMailTo +====================================================== + +See :issue:`96500` + +Description +=========== + +Since :issue:`96483`, the :html:`<f:link.email/>` ViewHelper is directly +using `TypoLink` for the email link generation. As a result, the +:php:`ContentObjectRenderer->getMailTo()` method was only used in +:php:`EmailLinkBuilder`, the central place for building email links +with `TypoLink`. + +To stick to the separation of concerns principle, the corresponding +functionality has been moved to :php:`EmailLinkBuilder->processEmailLink()` +and the :php:`ContentObjectRenderer->getMailTo()` method has been +marked as deprecated. + +Impact +====== + +Calling :php:`ContentObjectRenderer->getMailTo()` will trigger a +PHP :php:`E_USER_DEPRECATED` error. The extension scanner will +find usages as weak match. + +Affected Installations +====================== + +All installations directly calling :php:`ContentObjectRenderer->getMailTo()` +in custom extension code. + +Migration +========= + +All occurrences in extension code have to be replaced by +:php:`EmailLinkBuilder->processEmailLink()`. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-96524-DeprecateInlineJavaScriptInDashboard.rst b/Documentation/Changelog/12.0/Deprecation-96524-DeprecateInlineJavaScriptInDashboard.rst new file mode 100644 index 0000000..98b8927 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96524-DeprecateInlineJavaScriptInDashboard.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96524: + +============================================================== +Deprecation: #96524 - Deprecate inline JavaScript in Dashboard +============================================================== + +See :issue:`96524` + +Description +=========== + +Using inline JavaScript when initializing RequireJS modules in individual +dashboard widgets has been deprecated. Widget implementations have to be +adjusted accordingly. + +Impact +====== + +Usages will trigger PHP :php:`E_USER_DEPRECATED` errors. + +Affected Installations +====================== + +Installations having individual widget implementations which are + +* implementing :php:`\TYPO3\CMS\Dashboard\Widgets\RequireJsModuleInterface` +* invoking :php:`\TYPO3\CMS\Dashboard\DashboardInitializationService->getRequireJsModules` + +Migration +========= + +Affected widget have to implement :php:`\TYPO3\CMS\Dashboard\Widgets\JavaScriptInterface` +instead of deprecated :php:`\TYPO3\CMS\Dashboard\Widgets\RequireJsModuleInterface`. +Instead of using inline JavaScript for initializing RequireJS modules, +:php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` have to be declared. + +.. code-block:: php + + class ExampleChartWidget implements RequireJsModuleInterface + { + // ... + public function getJavaScriptModuleInstructions(): array + { + return [ + 'TYPO3/CMS/Dashboard/ChartInitializer' => + 'function(ChartInitializer) { ChartInitializer.initialize(); }', + ]; + } + } + +Deprecated example widget above would look like the following when using +`JavaScriptInterface` and `JavaScriptModuleInstruction`: + +.. code-block:: php + + class ExampleChartWidget implements JavaScriptInterface + { + // ... + public function getJavaScriptModuleInstructions(): array + { + return [ + JavaScriptModuleInstruction::forRequireJS( + 'TYPO3/CMS/Dashboard/ChartInitializer' + )->invoke('initialize'), + ]; + } + } + +.. index:: Frontend, TypoScript, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-96568-RequireJSModulesInFormFramework.rst b/Documentation/Changelog/12.0/Deprecation-96568-RequireJSModulesInFormFramework.rst new file mode 100644 index 0000000..2a38cf9 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96568-RequireJSModulesInFormFramework.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96568: + +========================================================= +Deprecation: #96568 - RequireJS modules in Form Framework +========================================================= + +See :issue:`96568` + +Description +=========== + +Extending the Form Framework manager and editor via RequireJS modules has been +deprecated in favor of native ES6 JavaScript modules. + +The :yaml:`dynamicRequireJsModules` option is deprecated. + +Impact +====== + +RequireJS is no longer loaded and native module loading is approached, +if :yaml:`dynamicRequireJsModules` is not defined. Extensions that +use :yaml:`dynamicRequireJsModules` will work as before but trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +Installations that register custom form types or extend the backend JavaScript +of the form framework. + +Migration +========= + +Use :yaml:`dynamicJavaScriptModules` option instead of +:yaml:`dynamicRequireJsModules` to load ES6 instead of RequireJS modules: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formEditor: + dynamicJavaScriptModules: + additionalViewModelModules: + 10: '@my-vendor/my-site-package/backend/form-editor/view-model.js' + +And configure a corresponding importmap in +:file:`Configuration/JavaScriptModules.php`: + +.. code-block:: php + + # Configuration/JavaScriptModules.php + <?php + + return [ + 'dependencies' => ['form'], + 'imports' => [ + '@myvendor/my-site-package/' => 'EXT:my_site_package/Resources/Public/JavaScript/', + ], + ]; + +.. index:: Backend, JavaScript, NotScanned, ext:form diff --git a/Documentation/Changelog/12.0/Deprecation-96641-LinkRelatedFunctionalityInContentObjectRenderer.rst b/Documentation/Changelog/12.0/Deprecation-96641-LinkRelatedFunctionalityInContentObjectRenderer.rst new file mode 100644 index 0000000..fef9af1 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96641-LinkRelatedFunctionalityInContentObjectRenderer.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96641-2: + +========================================================================= +Deprecation: #96641 - Link-related functionality in ContentObjectRenderer +========================================================================= + +See :issue:`96641` + +Description +=========== + +Various methods related to shorthand syntax of generating +links and URLs have been marked as deprecated: + +* :php:`ContentObjectRenderer->getATagParams()` +* :php:`ContentObjectRenderer->getTypoLink()` +* :php:`ContentObjectRenderer->getUrlToCurrentLocation()` +* :php:`ContentObjectRenderer->getTypoLink_URL()` + +They are related to functionality for generating URLs, +and have been marked as deprecated in favor of the new LinkFactory +API, and the existing :php:`$cObj->typoLink()` and :php:`$cObj->typoLink_URL()` +methods. + +Impact +====== + +Calling these methods in your own PHP code will trigger PHP :php:`E_USER_DEPRECATED` errors. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using these methods for +generating links. The extension scanner in the Upgrade module / Install tool +will show affected occurrences. + +Migration +========= + +It is recommended to use either existing API calls: + +* :php:`ContentObjectRenderer->typoLink()` +* :php:`ContentObjectRenderer->typoLink_URL()` + +or the new methods for code that should be compatible with TYPO3 v12+ - only: + +* :php:`ContentObjectRenderer->createUrl()` +* :php:`ContentObjectRenderer->createLink()` + +or calling the LinkFactory API directly. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-96641-UnusedHookRelatedUrlProcessorInterface.rst b/Documentation/Changelog/12.0/Deprecation-96641-UnusedHookRelatedUrlProcessorInterface.rst new file mode 100644 index 0000000..d7c12e7 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96641-UnusedHookRelatedUrlProcessorInterface.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96641-1: + +=============================================================== +Deprecation: #96641 - Unused Hook related UrlProcessorInterface +=============================================================== + +See :issue:`96641` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['urlProcessing']['urlProcessors']` +required hook implementations to implement :php:`UrlProcessorInterface`. + +Since the mentioned hook is :doc:`removed <../12.0/Breaking-96641-TypoLinkRelatedHooksRemoved>`, +the interface is not in use anymore and has been marked as deprecated. + +Impact +====== + +The extension scanner will now notify any extension, which might still use +the PHP interface. + +Affected Installations +====================== + +TYPO3 installations using the PHP interface in custom extension code. + +Migration +========= + +The PHP interface is still available for TYPO3 v12.x, so extensions can +provide a version which is compatible with TYPO3 v11 (using the hook) +and TYPO3 v12.x (using the new +:doc:`PSR-14 event <../12.0/Feature-96641-NewPSR-14EventForModifyingLinks>`), +at the same time. + +Remove any usage of the PHP interface and use the new PSR-14 +event to avoid any further problems in TYPO3 v13+. + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-96733-DeprecatedTBE_MODULESRelatedFunctionality.rst b/Documentation/Changelog/12.0/Deprecation-96733-DeprecatedTBE_MODULESRelatedFunctionality.rst new file mode 100644 index 0000000..52ec610 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96733-DeprecatedTBE_MODULESRelatedFunctionality.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96733: + +================================================================== +Deprecation: #96733 - Deprecated TBE_MODULES related functionality +================================================================== + +See :issue:`96733` + +Description +=========== + +Due to the removal of global array :php:`$TBE_MODULES` +(see :doc:`breaking changelog <../12.0/Breaking-96733-RemovedSupportForModuleHandlingBasedOnTBE_MODULES>`), +the following related methods have been deprecated: + +- :php:`TYPO3\CMS\Core\Authentication\BackendUserAuthentication->modAccess()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::isModuleSetInTBE_MODULES()` + +Impact +====== + +Calling mentioned methods will trigger a PHP :php:`E_USER_DEPRECATED` error. +The extension scanner will report usages. + +Affected Installations +====================== + +All installations calling mentioned methods in custom extension code. + +Migration +========= + +Use the new :php:`ModuleProvider` API (see :doc:`feature changelog <../12.0/Feature-96733-NewBackendModuleRegistrationAPI>`) instead. + +Replace :php:`BackendUserAuthentication->modAccess()` with :php:`ModuleProvider->accessGranted()`. + +Replace :php:`BackendUtility::isModuleSetInTBE_MODULES()` with :php:`ModuleProvider->isModuleRegistered()`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-96903-DeprecateOldModuleTemplateAPI.rst b/Documentation/Changelog/12.0/Deprecation-96903-DeprecateOldModuleTemplateAPI.rst new file mode 100644 index 0000000..3905f22 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96903-DeprecateOldModuleTemplateAPI.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96903: + +====================================================== +Deprecation: #96903 - Deprecate old ModuleTemplate API +====================================================== + +See :issue:`96903` + +Description +=========== + +With the introduction of the :doc:`simplified ModuleTemplate API <Feature-96730-SimplifiedExtbackendModuleTemplateAPI>` +a series of PHP methods in backend related :php:`ModuleTemplate` class became obsolete. + +These methods are now marked as deprecated in TYPO3 v12 and will be removed in TYPO3 v13 to +encourage backend modules switching to the new API which is easier to use and +allows :doc:`overriding backend templates <Feature-96812-OverrideBackendTemplatesWithTSconfig>`. + +The following methods should not be used anymore: + +- :php:`ModuleTemplate->setContent()` and :php:`ModuleTemplate->renderContent()`: These methods + were the heart of the old API, using a :php:`StandaloneView` for module *template* rendering. They + are obsolete using :php:`ModuleTemplate->render()` or :php:`ModuleTemplate->renderResponse()` where + the outer ModuleTemplate HTML is referenced in "main body" templates as *layout*. +- :php:`ModuleTemplate->getView()`: This was an additional helper for :php:`ModuleTemplate->setContent()` + and :php:`ModuleTemplate->renderContent()` and is deprecated together with these. +- :php:`ModuleTemplate->getBodyTag()` and :php:`ModuleTemplate->isUiBlock()`: ModuleTemplate should be a + data sink. It should not be abused to carry data around that is later retrieved again. Controllers that + need these methods should be refactored slightly to carry the information around themselves. +- :php:`ModuleTemplate->registerModuleMenu()`: This was a helper method for "third level" menu + registration of (especially) the info module. It is unused in Core since at least TYPO3 v7. Extensions + most likely don't use it. +- :php:`ModuleTemplate->getDynamicTabMenu()`: This is a helper to render a "tabbed" view of + item titles and item content. Using the methods leads to strange controller code that relies + on multiple Fluid view instances at the same time. Consuming controllers should instead + add the needed HTML to their templates directly. Example HTML can be found in the EXT:styleguide + backend module, section "Tabs". +- :php:`ModuleTemplate->header()`: This is a tiny helper method to render a :html:`<h1>`. It was used + to make the page title editable in a couple of controllers in the past. Extensions should put this + HTML directly into their templates. + +Impact +====== + +Methods :php:`setContent()`, :php:`header()` and :php:`getView()` are rather common names, the extension +scanner is not configured to scan for them. All other methods names are scanned, the extension scanner +will report possible usages as weak match. + +All methods will trigger a PHP :php:`E_USER_DEPRECATED` error when called. One exception is :php:`setContent()`, +which is always used in combination with :php:`renderContent()` to be useful, so only one deprecation +log entry is created when using both methods. + +Affected Installations +====================== + +In general, instances with extensions that add custom backend modules may be affected. + +Migration +========= + +See the description section and :doc:`simplified ModuleTemplate API <Feature-96730-SimplifiedExtbackendModuleTemplateAPI>` +for migration information. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-96972-DeprecateQueryBuilderexecute.rst b/Documentation/Changelog/12.0/Deprecation-96972-DeprecateQueryBuilderexecute.rst new file mode 100644 index 0000000..8bc324d --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96972-DeprecateQueryBuilderexecute.rst @@ -0,0 +1,86 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96972: + +======================================================= +Deprecation: #96972 - Deprecate QueryBuilder::execute() +======================================================= + +See :issue:`96972` + +Description +=========== + +`doctrine/dbal` deprecated the union return-type method :php:`QueryBuilder->execute()` in favour +of single return-typed :php:`QueryBuilder->executeQuery()` and :php:`QueryBuilder->executeStatement()` +in `doctrine/dbal v3.1.x`. This makes it more obvious which return type is expected and further helps +static code analyzer tools like `phpstan` to recognize return types properly. TYPO3 already provides a +facade class around the `doctrine/dbal` :php:`QueryBuilder`, which has been changed to provide the new +methods in the Core facade class with a corresponding backport. + +Thus :php:`QueryBuilder->execute()` is marked as deprecated in TYPO3 v12 and will be removed in v13 to +encourage extension developers to use the cleaner methods and decrease issues with static code analysers. + +Impact +====== + +The method :php:`execute()` is also used for Extbase query execution and as Upgrade Wizard method, thus +the extension scanner is not configured to scan for this method to avoid a lot of noisy weak matches. + +:php:`QueryBuilder->execute()` will trigger a PHP :php:`E_USER_DEPRECATED` error when called. + +Affected Installations +====================== + +In general, instances with extensions that uses the deprecated :php:`QueryBuilder->execute()` method. + +Migration +========= + +Extensions should use the proper methods :php:`QueryBuilder->executeQuery()` and :php:`QueryBuilder->executeStatement()` +instead of the generic :php:`QueryBuilder->execute()`. Through the backport to TYPO3 v11 extensions can change to deprecation +less code but keep supporting two major Core version at the same time. + +- :php:`QueryBuilder::executeStatement()`: use this for INSERT, DELETE or UPDATE queries (expecting `int` as return value). +- :php:`QueryBuilder::executeQuery()`: use this for SELECT and COUNT queries (expecting ResultSet as return value). + +As a thumb rule you can say that queries which expects a result set should use :php:`QueryBuilder::executeQuery()`. +Queries which return the number of affected rows should use :php:`QueryBuilder::executeStatement()`. + +For example, following select query: + +.. code-block:: php + + $rows = $queryBuilder + ->select(...) + ->from(...) + ->execute() + ->fetchAllAssociative(); + +should be replaced with: + +.. code-block:: php + + $rows = $queryBuilder + ->select(...) + ->from(...) + ->executeQuery() + ->fetchAllAssociative(); + +As another example, given delete query: + +.. code-block:: php + + $deletedRows = $queryBuilder + ->delete(...) + ->execute(); + +should be replaced with: + +.. code-block:: php + + $deletedRows = $queryBuilder + ->delete(...) + ->executeStatement(); + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-96983-TCAInternal_type.rst b/Documentation/Changelog/12.0/Deprecation-96983-TCAInternal_type.rst new file mode 100644 index 0000000..88d85b8 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96983-TCAInternal_type.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96983: + +======================================= +Deprecation: #96983 - TCA internal_type +======================================= + +See :issue:`96983` + +Description +=========== + +The TCA option `internal_type` for type `group` has been removed altogether. The +only left usage for :php:`internal_type => 'folder'` is extracted into an own +TCA type `folder`. + +Impact +====== + +The TCA option `internal_type` is not evaluated anymore. Using `internal_type` +with the option `folder` will be automatically migrated to +:php:`type => 'folder'`. A message will appear in the TCA migration module, +which lists all migrations done. + +Affected Installations +====================== + +All installations which make use of the TCA option `internal_type`. + +Migration +========= + +The migration is fairly simple. Just change all occurrences of +:php:`internal_type => 'folder'` to :php:`type => 'folder'` and remove every +other occurrence of `internal_type`. + +Before: + +.. code-block:: php + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'group', + 'internal_type' => 'folder', + ], + ], + 'bColumn' => [ + 'config' => [ + 'type' => 'group', + 'internal_type' => 'db', + ], + ], + ], + +After: + +.. code-block:: php + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'folder', + ], + ], + 'bColumn' => [ + 'config' => [ + 'type' => 'group', + ], + ], + ], + +.. index:: Backend, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-96996-DeprecateTypoScriptFrontendController-checkEnableFields.rst b/Documentation/Changelog/12.0/Deprecation-96996-DeprecateTypoScriptFrontendController-checkEnableFields.rst new file mode 100644 index 0000000..b6933b4 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-96996-DeprecateTypoScriptFrontendController-checkEnableFields.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-96996: + +=============================================================================== +Deprecation: #96996 - Deprecate TypoScriptFrontendController->checkEnableFields +=============================================================================== + +See :issue:`96996` + +Description +=========== + +The :php:`TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkEnableFields()` +method has been deprecated in favour of the new :php:`TYPO3\CMS\Core\Domain\Access\RecordAccessVoter` +component. + +Impact +====== + +:php:`TypoScriptFrontendController->checkEnableFields()` will trigger a PHP :php:`E_USER_DEPRECATED` error +when called. The extension scanner will report usages as weak match. + +Affected Installations +====================== + +All installations calling :php:`TypoScriptFrontendController->checkEnableFields()` +in custom extension code. + +Migration +========= + +Replace all usages of the deprecated method. Use the :php:`RecordAccessVoter` +component instead, e.g. :php:`RecordAccessVoter->accessGranted()`. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-97016-DeprecateUsageOfRegularExpressionValidatorInFormEditor.rst b/Documentation/Changelog/12.0/Deprecation-97016-DeprecateUsageOfRegularExpressionValidatorInFormEditor.rst new file mode 100644 index 0000000..1b1f40e --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97016-DeprecateUsageOfRegularExpressionValidatorInFormEditor.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97016: + +================================================================================== +Deprecation: #97016 - Deprecate usage of RegularExpressionValidator in form editor +================================================================================== + +See :issue:`97016` + +Description +=========== + +Enabling the RegularExpressionValidator within the form editor has been marked +as deprecated. This configurable validator will be removed from the UI in TYPO3 v13. +The goal is to unclutter and strip the form editor from too technical and complex +concepts. Regular expressions are difficult to understand especially for our +main target group, which are editors. + +An according comment has been added to the configuration files of the form +framework. + +Impact +====== + +No deprecation will be logged. The extension scanner will not report anything. +The validator will be removed from the UI in TYPO3 v13 with an according breaking +change. The impact itself is quite low, since the validator can +easily be re-added. + +Affected Installations +====================== + +All TYPO3 installations with default form configurations (which is mostly the +case). + +Migration +========= + +To keep the RegularExpressionValidator within the form editor even after it has +been removed in TYPO3 v13 you can re-add it if needed. This can be done by extending +form configurations. + +The following example adds the validator to the form element `Text`. The path +:yaml:`TYPO3.CMS.Form.prototypes.standard.formElementsDefinition.Text.formEditor.editors.900` +contains the definition for validators. We are adding the validator with the key +`200` to not interfere with keys already taken by the Core. + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Text: + formEditor: + editors: + 900: + selectOptions: + 200: + value: RegularExpression + label: formEditor.elements.TextMixin.editor.validators.RegularExpression + +.. index:: Backend, NotScanned, ext:form diff --git a/Documentation/Changelog/12.0/Deprecation-97019-DeprecateOrderOfValidationMessage.rst b/Documentation/Changelog/12.0/Deprecation-97019-DeprecateOrderOfValidationMessage.rst new file mode 100644 index 0000000..5a7438e --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97019-DeprecateOrderOfValidationMessage.rst @@ -0,0 +1,107 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97019: + +=========================================================== +Deprecation: #97019 - Deprecate order of validation message +=========================================================== + +See :issue:`97019` + +Description +=========== + +The form framework ships a "date range validator". The validator can for example +be added via the form editor to the "date" form element. Especially when it comes +to this element, the order of the available fields is not in line with all of the +other form elements. The field "Custom error message" (validationErrorMessage) +is usually the last field. To further streamline the UI, the order has been +adapted for the "date" validator. + +Since the order can only be adjusted by changing the key within the form +configuration, this patch adds according comments to the form configuration. +The breaking change will be done with TYPO3 v13.0. + +Impact +====== + +Since the YAML keys within the form configuration of the "date" form element +will change in TYPO3 v13, custom configurations/implementations can fail. An +according comment has been added to the configuration file of the form +framework. Furthermore, the new key has been reserved. + +Current configuration in TYPO3 v12 (simplified): + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Date: + formEditor: + propertyCollections: + validators: + 10: + identifier: DateRange + editors: + # Deprecated since v12, will be removed in v13 + # Instead of using the key 200, the validationErrorMessage will be moved to the key 400 + 200: + identifier: validationErrorMessage + # ... + 250: + identifier: minimum + # ... + 300: + identifier: maximum + # ... + +New configuration in TYPO3 v13 (simplified): + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + Date: + formEditor: + propertyCollections: + validators: + 10: + identifier: DateRange + editors: + 250: + identifier: minimum + # ... + 300: + identifier: maximum + # ... + 400: + identifier: validationErrorMessage + # ... + +As you can see the key :yaml:`200` is not in use anymore. Instead, a new key +:yaml:`400` has been introduced. The new configuration is commented in TYPO3 v12 +and will be enabled in TYPO3 v13. + +Affected Installations +====================== + +All TYPO3 installations are affected as soon as the form configuration of the +"DateRange" validator of the "date" form element has been adapted. In detail, +installations where the above mentioned keys have been set or unset need to +be migrated to the new configuration. + +Migration +========= + +Check your form configuration accordingly and adapt your custom configuration. +That is, check if you set/unset the above mentioned keys. + +.. index:: Backend, NotScanned, ext:form diff --git a/Documentation/Changelog/12.0/Deprecation-97027-ContentObjectRenderer-getTreeList.rst b/Documentation/Changelog/12.0/Deprecation-97027-ContentObjectRenderer-getTreeList.rst new file mode 100644 index 0000000..e052b36 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97027-ContentObjectRenderer-getTreeList.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97027: + +========================================================== +Deprecation: #97027 - ContentObjectRenderer->getTreeList() +========================================================== + +See :issue:`97027` + +Description +=========== + +The method :php:`ContentObjectRenderer->getTreeList()` has been marked as +deprecated. + +The method signature has had various side-effects and too many options and +was used in different places across TYPO3 Core, where +:php:`ContentObjectRenderer` was not in use primarily. + +Impact +====== + +Calling the method directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +TYPO3 installations with third-party extensions accessing this method. This can +be checked via the Extension Scanner in the Install Tool. + +Migration +========= + +Several replacements for various use-cases have been introduced, which can be +found in :php:`PageRepository`. Instead of returning a comma-separated list of +integers as string, the methods now return an array of integer Page IDs, always +in the default language. + +The method :php:`PageRepository->getPageIdsRecursive()` is used to retrieve all +subpages (recursively) of a list of pages, commonly used for fetching recursive +Storage PIDs in Plugins. Extbase is using this method under the hood. + +The method :php:`PageRepository->getDescendantPageIdsRecursive()` is used to +return all subpages without the actual pages handed in as argument. + +This might be useful for finding all subpages, to check for values or records +within such pages (e.g. Sitemap functionality). + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-97035-RequiredOptionInEvalKeyword.rst b/Documentation/Changelog/12.0/Deprecation-97035-RequiredOptionInEvalKeyword.rst new file mode 100644 index 0000000..3a5f8f4 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97035-RequiredOptionInEvalKeyword.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97035: + +========================================================= +Deprecation: #97035 - "required" option in "eval" keyword +========================================================= + +See :issue:`97035` + +Description +=========== + +Since :issue:`67354`, the FormEngine may use :php:`required` with a bool value +in a TCA field configuration, enabling the same functionality as the `required` +option within `eval`. + +To clean up TCA and allow further refactoring, :php:`'eval' => 'required` +has been marked as deprecated. + +Impact +====== + +Using `required` within `eval` in TCA and FlexForm will trigger an automatic +migration and therefore trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All 3rd party extension either using :php:`'eval' => 'required'` or +`<eval>required</eval>` are affected. + +Migration +========= + +Migrate to :php:`'required' => true` and `<required>1</required>` to avoid +automatic migration and hence a deprecation log entry. + +Example before migration: + +.. code-block:: php + + 'columns' => [ + 'some_column' => [ + 'title' => 'foo', + 'config' => [ + 'eval' => 'trim,required', + ], + ], + ], + +Example after migration: + +.. code-block:: php + + 'columns' => [ + 'some_column' => [ + 'title' => 'foo', + 'config' => [ + 'required' => true, + 'eval' => 'trim', + ], + ], + ], + +.. index:: TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97057-DeprecateRequireJSSupport.rst b/Documentation/Changelog/12.0/Deprecation-97057-DeprecateRequireJSSupport.rst new file mode 100644 index 0000000..5d793c5 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97057-DeprecateRequireJSSupport.rst @@ -0,0 +1,76 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97057-1664653704: + +================================================= +Deprecation: #97057 - Deprecate RequireJS support +================================================= + +See :issue:`97057` + +Description +=========== + +The RequireJS project has been discontinued_ and was therefore +replaced by native ECMAScript v6/v11 modules in TYPO3 in :issue:`96510`. + +The infrastructure for configuration and loading of RequireJS +modules is now deprecated and will be removed in TYPO3 v13. + + +Impact +====== + +Registering modules via :php:`'requireJsModules'` will still work. +These modules will be loaded after modules registered via :php:`'javaScriptModules'`. Extensions that +use :php:`'requireJsModules` will work as before but trigger a PHP :php:`E_USER_DEPRECATED` error. + + +Affected installations +====================== + +Installations that register custom JavaScript modules for the TYPO3 backend. + + +Migration +========= + +Migrate your JavaScript from the AMD module format to native ES6 modules and register your configuration in :php:`Configuration/JavaScriptModules.php`, also see :issue:`96510` for more information: + +.. code-block:: php + + # Configuration/JavaScriptModules.php + <?php + + return [ + 'dependencies' => ['core', 'backend'], + 'imports' => [ + '@vendor/my-extension/' => 'EXT:my_extension/Resources/Public/JavaScript/', + ], + ]; + +Then use :php:`TYPO3\CMS\Core\Page\PageRenderer->loadJavaScriptModule()` instead of +:php:`TYPO3\CMS\Core\Page\PageRenderer->loadRequireJsModule()` to load the ES6 module: + +.. code-block:: php + + // via PageRenderer + $this->packageRenderer->loadJavaScriptModule('@vendor/my-extension/example.js'); + + +In Fluid templates `includeJavaScriptModules` is to be used instead of `includeRequireJsModules`: + +In Fluid template the `includeJavaScriptModules` property of the +:html:`<f:be.pageRenderer>` ViewHelper may be used: + +.. code-block:: xml + + <f:be.pageRenderer + includeJavaScriptModules="{ + 0: '@vendor/my-extension/example.js' + }" + /> + +.. _discontinued: https://github.com/requirejs/requirejs/issues/1816 + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97109-TCATypeNoneColsOption.rst b/Documentation/Changelog/12.0/Deprecation-97109-TCATypeNoneColsOption.rst new file mode 100644 index 0000000..9b15140 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97109-TCATypeNoneColsOption.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97109: + +================================================= +Deprecation: #97109 - TCA type none "cols" option +================================================= + +See :issue:`97109` + +Description +=========== + +The TCA type `none` had two option keys for the same functionality: `cols` and +`size`. In order to simplify the available configuration, `cols` has been +dropped in favour of `size`. + +Impact +====== + +Defining the option `cols` for the TCA type `none` will trigger a PHP :php:`E_USER_DEPRECATED` error. +An automatic migration is in place, which will be displayed in the TCA +Migrations view of the Upgrade module. + +Affected Installations +====================== + +All installations using the `cols` option for the TCA type `none`. + +Migration +========= + +Rename the option `cols` to `size`. + +Before: + +.. code-block:: php + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'none', + 'cols' => 20, + ], + ], + ], + +After: + +.. code-block:: php + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'none', + 'size' => 20, + ], + ], + ], + +.. index:: TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97126-TCEformsRemovedInFlexForm.rst b/Documentation/Changelog/12.0/Deprecation-97126-TCEformsRemovedInFlexForm.rst new file mode 100644 index 0000000..1888ba0 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97126-TCEformsRemovedInFlexForm.rst @@ -0,0 +1,140 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97126: + +================================================== +Deprecation: #97126 - TCEforms removed in FlexForm +================================================== + +See :issue:`97126` + +Description +=========== + +The `<TCEforms>` tag is no longer necessary (and allowed) in FlexForm +definitions. It was used to wrap TCA configuration and sheet +titles / descriptions. This wrapping must be omitted now. + +Impact +====== + +Not omitting `TCEforms` will trigger a deprecation warning and log a message in +the deprecation log. An automatic migration is in place, which will eventually +be removed in upcoming TYPO3 versions. + +Affected Installations +====================== + +* All installations making use of FlexForm in their TCA. FlexForm definitions can + be defined directly in TCA or can be external XML files. + +* Extensions, which extend the YAML configuration for forms with new fields. + +Migration +========= + +Omit the `<TCEforms>` tag in your FlexForm definition. The underlying +configuration moves one level up. + +Before: + +.. code-block:: xml + + <T3DataStructure> + <ROOT> + <TCEforms> + <sheetTitle>sheet description 1</sheetTitle> + <sheetDescription> + sheetDescription: Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </sheetDescription> + <sheetShortDescr> + sheetShortDescr: Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </sheetShortDescr> + </TCEforms> + <type>array</type> + <el> + <input_1> + <TCEforms> + <label>input_1</label> + <config> + <type>input</type> + </config> + </TCEforms> + </input_1> + </el> + </ROOT> + </T3DataStructure> + +After: + +.. code-block:: xml + + <T3DataStructure> + <ROOT> + <sheetTitle>sheet description 1</sheetTitle> + <sheetDescription> + sheetDescription: Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </sheetDescription> + <sheetShortDescr> + sheetShortDescr: Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </sheetShortDescr> + <type>array</type> + <el> + <input_1> + <label>input_1</label> + <config> + <type>input</type> + </config> + </input_1> + </el> + </ROOT> + </T3DataStructure> + +Migration for form YAML configuration: + +Before: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + finishersDefinition: + EmailToReceiver: + FormEngine: + elements: + recipients: + el: + _arrayContainer: + el: + email: + TCEforms: + label: tt_content.finishersDefinition.EmailToSender.recipients.email.label + config: + type: input + +After: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + finishersDefinition: + EmailToReceiver: + FormEngine: + elements: + recipients: + el: + _arrayContainer: + el: + email: + label: tt_content.finishersDefinition.EmailToSender.recipients.email.label + config: + type: input + +.. index:: FlexForm, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97201-UnusedInterfaceForNewContentElementWizardHook.rst b/Documentation/Changelog/12.0/Deprecation-97201-UnusedInterfaceForNewContentElementWizardHook.rst new file mode 100644 index 0000000..b557007 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97201-UnusedInterfaceForNewContentElementWizardHook.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97201: + +========================================================================== +Deprecation: #97201 - Unused Interface for new content element wizard hook +========================================================================== + +See :issue:`97201` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms']['db_new_content_el']['wizardItemsHook']` +required hook implementations to implement :php:`\TYPO3\CMS\Backend\Wizard\NewContentElementWizardHookInterface`. + +Since the mentioned hook has been :doc:`removed <../12.0/Breaking-97201-RemovedHookForNewContentElementWizard>`, +the interface is not in use anymore and has been marked as deprecated. + +Impact +====== + +Using the interface has no effect anymore and the extension scanner will +report any usage. + +Affected Installations +====================== + +TYPO3 installations using the PHP interface in custom extension code. + +Migration +========= + +The PHP interface is still available for TYPO3 v12.x, so extensions can +provide a version which is compatible with TYPO3 v11 (using the hook) +and TYPO3 v12.x (using the new :doc:`PSR-14 event <../12.0/Feature-97201-PSR-14EventForModifyingNewContentElementWizardItems>`), +at the same time. + +Remove any usage of the PHP interface and use the new PSR-14 +event to avoid any further problems in TYPO3 v13+. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97217-MovedTypoLinkCodecServiceToEXTcore.rst b/Documentation/Changelog/12.0/Deprecation-97217-MovedTypoLinkCodecServiceToEXTcore.rst new file mode 100644 index 0000000..645f52b --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97217-MovedTypoLinkCodecServiceToEXTcore.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97217: + +============================================================ +Deprecation: #97217 - Moved TypoLinkCodecService to EXT:core +============================================================ + +See :issue:`97217` + +Description +=========== + +The :php:`TypoLinkCodecService` class is used to encode and decode link +parameters, which are usually next to the actual link the `target` or +`class` information. This functionality is not directly bound to frontend +specific logic. To resolve cross dependencies the class has been moved to +the `LinkHandling` namespace in EXT:core. The old namespace has therefore +been deprecated. + +Impact +====== + +The namespace has changed from :php:`\TYPO3\CMS\Frontend\Service\TypoLinkCodecService` +to :php:`\TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService` and the old namespace +has been marked as deprecated. + +Affected Installations +====================== + +All installations using the deprecated namespace +:php:`\TYPO3\CMS\Frontend\Service\TypoLinkCodecService`. The extension +scanner will report usages. + +Migration +========= + +Replace usages with the new namespace +:php:`\TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService` +in custom extension code. + +.. index:: PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-97231-UnusedInterfaceForInlineElementHook.rst b/Documentation/Changelog/12.0/Deprecation-97231-UnusedInterfaceForInlineElementHook.rst new file mode 100644 index 0000000..b3d3a10 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97231-UnusedInterfaceForInlineElementHook.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97231: + +============================================================== +Deprecation: #97231 - Unused Interface for inline element hook +============================================================== + +See :issue:`97231` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tceforms_inline.php']['tceformsInlineHook']` +required hook implementations to implement :php:`\TYPO3\CMS\Backend\Form\Element\InlineElementHookInterface`. +Since the mentioned hook has been :doc:`removed <../12.0/Breaking-97231-RemovedHookForManipulatingInlineElementControls>`, +the interface is not in use anymore and has been marked as deprecated. + +Impact +====== + +Using the interface has no effect anymore and the extension scanner will +report any usage. + +Affected Installations +====================== + +TYPO3 installations using the PHP interface in custom extension code. + +Migration +========= + +The PHP interface is still available for TYPO3 v12.x, so extensions can +provide a version which is compatible with TYPO3 v11 (using the hook) +and TYPO3 v12.x (using the new :doc:`PSR-14 events <../12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls>`), +at the same time. +Remove any usage of the PHP interface and use the new PSR-14 +events to avoid any further problems in TYPO3 v13+. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97244-CompositeExpressionMethodsAddAndAddMultiple.rst b/Documentation/Changelog/12.0/Deprecation-97244-CompositeExpressionMethodsAddAndAddMultiple.rst new file mode 100644 index 0000000..4722596 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97244-CompositeExpressionMethodsAddAndAddMultiple.rst @@ -0,0 +1,192 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97244-2: + +============================================================================= +Deprecation: #97244 - CompositeExpression methods 'add()' and 'addMultiple()' +============================================================================= + +See :issue:`97244` + +Description +=========== + +`doctrine/dbal` `deprecated`_ multiple :php:`CompositeExpression` methods +:php:`CompositeExpression->add()` and :php:`CompositeExpression->addMultiple()`. +Therefore, those methods have also been deprecated in the Core facade class +(:php:`\TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression`), +to avoid shifting too far away. + +.. _`deprecated`: https://github.com/doctrine/dbal/commit/7bcd6ebcc2d30ba96cf00d3dca2345d6ae779cf9 + +Impact +====== + +Using :php:`CompositeExpression->add()` and :php:`CompositeExpression->addMultiple()` +will trigger a PHP :php:`E_USER_DEPRECATED` error when called. + +Affected Installations +====================== + +In general, instances with extensions that use the deprecated methods +:php:`CompositeExpression->add()` and :php:`CompositeExpression->addMultiple()`. +The extension scanner finds usages of :php:`CompositeExpression->addMultiple()` +with a weak match. The other method is not scanned, as its name is too common. + +Migration +========= + +The deprecated methods :php:`CompositeExpression->add()` and :php:`CompositeExpression->addMultiple()` +can be replaced by using the new method :php:`CompositeExpression->with()`. + +.. note:: + + The replacement method :php:`CompositeExpression->with()` has already been + added in a forward-compatible way in TYPO3 v11. Thus giving extension developers + the ability to adopt new methods and still being able to support multiple Core + versions without workarounds. + +For example, the following code: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpression = CompositeExpression::or(); + + $compositeExpression->add( + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($singleValue) + ) + ); + $compositeExpression->addMultiple( + [ + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value1) + ), + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value2) + ), + //... + ] + ); + +should be replaced with: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpression = CompositeExpression::or(); + + // note, you have to assign the return of with() to the + // variable, otherwise added elements are lost. + $compositeExpression = $compositeExpression->with( + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($singleValue) + ) + ); + + // Note the spread operator for the array + $compositeExpression = $compositeExpression->with( + ...[ + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value1) + ), + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value2) + ), + //... + ] + ); + +The multi expression example can now also be replaced like this: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpression = CompositeExpression::or(); + + // note, you have to assign the return of with() to the + // variable, otherwise added elements are lost. + $compositeExpression = $compositeExpression->with( + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value1) + ), + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($value2) + ), + //... + ); + +Extension developers may have used to loop over some data to build +multiple expressions which should be connected with :php:`and` or :php:`or`, +either adding each element with the deprecated :php:`add(...)` method or +collecting it in an array and using deprecated :php:`addMultiple(...)` method. +Both use cases can be replaced with :php:`with(...)`. + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpression = CompositeExpression::or(); + + foreach($array as $element) { + // note, you have to assign the return of with() to the + // variable, otherwise added elements are lost. + $compositeExpression = $compositeExpression->with( + $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($element) + ) + ); + } + +or + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpression = CompositeExpression::or(); + + $expressions = []; + foreach($array as $element) { + $expressions[] = $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($element) + ); + } + + // note, you have to assign the return of with() to the + // variable, otherwise added elements are lost. + $compositeExpression = $compositeExpression->with(...$expressions); + +Instead of using :php:`with()` when collecting expressions in an array, +it can be used when instantiating the composite expression after the +expression collecting: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $expressions = []; + foreach($array as $element) { + $expressions[] = $queryBuilder->expr()->eq( + 'field', + $queryBuilder->createNamedParameter($element) + ); + } + + $compositeExpression = CompositeExpression::or(...$expressions); + +.. index:: Database, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97244-DirectInstantiationOfCompositeExpression.rst b/Documentation/Changelog/12.0/Deprecation-97244-DirectInstantiationOfCompositeExpression.rst new file mode 100644 index 0000000..d9a3919 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97244-DirectInstantiationOfCompositeExpression.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97244-1: + +================================================================= +Deprecation: #97244 - Direct instantiation of CompositeExpression +================================================================= + +See :issue:`97244` + +Description +=========== + +`doctrine/dbal` `deprecated`_ direct instantiation of :php:`CompositeExpression` +in favour of moving forward to an immutable class implementation. Therefore, this +has also been deprecated in the Core facade class (:php:`\TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression`), +to avoid shifting too far away. + +.. _`deprecated`: https://github.com/doctrine/dbal/commit/7bcd6ebcc2d30ba96cf00d3dca2345d6ae779cf9 + +Impact +====== + +Instantiating directly with :php:`new CompositeExpression(...)` will trigger a PHP :php:`E_USER_DEPRECATED` error. + +The extension scanner cannot detect direct instantiation of this class. + +Affected Installations +====================== + +In general, instances with extensions that directly instantiate a composite +expression with :php:`new CompositeExpression(...)`. + +The extension scanner will not find and report direct instantiating. + +Migration +========= + +Instead of directly instantiating a composite expression with the type as +the first argument and an array of expressions as the second argument, the new +static methods :php:`and(...)` and :php:`or(...)` have to be used. + +.. note:: + + The static replacement methods :php:`CompositeExpression::and()` + and :php:`CompositeExpression::or()` have already been added in + a forward-compatible way in TYPO3 v11. Thus giving extension developers + the ability to adopt new methods and still being able to support + multiple Core versions without workarounds. + +For example, following code: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + $compositeExpressionAND = new CompositeExpression( + CompositeExpression::TYPE_AND, + [ + // expressions ... + ] + ); + + $compositeExpressionOR = new CompositeExpression( + CompositeExpression::TYPE_OR, + [ + // expressions ... + ] + ); + +should be replaced with: + +.. code-block:: php + + use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; + + // Note the spread operator + $compositeExpressionAND = CompositeExpression::and( + ...[ + // expressions ... + ] + ); + + $compositeExpressionOR = CompositeExpression::or( + ...[ + // expressions ... + ] + ); + +.. index:: Database, NotScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97271-GlobalColorPickerInitialization.rst b/Documentation/Changelog/12.0/Deprecation-97271-GlobalColorPickerInitialization.rst new file mode 100644 index 0000000..c604b1f --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97271-GlobalColorPickerInitialization.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97271: + +======================================================== +Deprecation: #97271 - Global Color Picker initialization +======================================================== + +See :issue:`97271` + +Description +=========== + +Initializing all color pickers (via the :html:`t3js-color-picker` class) at +once by invoking :js:`ColorPicker.initialize()` without passing an element +has been marked as deprecated. + +Impact +====== + +Initializing all datetime pickers at once will trigger a deprecation +warning in the browser's console. + +Affected Installations +====================== + +All 3rd party extensions calling :js:`ColorPicker.initialize()` without any +arguments are affected. + +Migration +========= + +Initialize the color picker by passing an :js:`HTMLElement` to the +:js:`ColorPicker.initialize()` method. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97312-DeprecateCSH-relatedMethods.rst b/Documentation/Changelog/12.0/Deprecation-97312-DeprecateCSH-relatedMethods.rst new file mode 100644 index 0000000..f90cc68 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97312-DeprecateCSH-relatedMethods.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97312: + +=================================================== +Deprecation: #97312 - Deprecate CSH-related methods +=================================================== + +See :issue:`97312` + +Description +=========== + +In order to be less breaking for extension authors, classes related to Context +Sensitive Help (CSH) have been marked as deprecated: + +* :php:`TYPO3\CMS\Backend\Template\Components\Buttons\Action\HelpButton` + +In order to be less breaking for extension authors, methods related to Context +Sensitive Help (CSH) have been marked as deprecated: + +* :php:`TYPO3\CMS\Backend\Utility\BackendUtility::cshItem()` +* :php:`TYPO3\CMS\Backend\Template\Components\ButtonBar::makeHelpButton()` +* :php:`TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addLLrefForTCAdescr()` + +Also, the following Fluid view helpers are marked as deprecated: + +* `f:be.buttons.csh` (:php:`TYPO3\CMS\Fluid\ViewHelpers\Be\Buttons\CshViewHelper`) +* `f:be.labels.csh` (:php:`TYPO3\CMS\Fluid\ViewHelpers\Be\Labels\CshViewHelper`) + +Impact +====== + +Using any of the deprecated classes and methods will trigger a PHP :php:`E_USER_DEPRECATED` error, +with an exception of :php:`ExtensionManagementUtility::addLLrefForTCAdescr()` +for being a low-level method. The extension scanner will report any usage. + +Affected Installations +====================== + +All extensions using any of the deprecated classes and methods are affected. + +Migration +========= + +Context Sensitive Help is aimed to get removed in TYPO3 v13, no migration is available. + +.. index:: Backend, Fluid, PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97354-ExpressionBuilderMethodsAndXAndOrX.rst b/Documentation/Changelog/12.0/Deprecation-97354-ExpressionBuilderMethodsAndXAndOrX.rst new file mode 100644 index 0000000..02bc308 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97354-ExpressionBuilderMethodsAndXAndOrX.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97354: + +================================================================ +Deprecation: #97354 - ExpressionBuilder methods andX() and orX() +================================================================ + +See :issue:`97354` + +Description +=========== + +`doctrine/dbal` `deprecated`_ the :php:`ExpressionBuilder` methods +:php:`andX()` and :php:`orX`. Therefore, those methods have also been +deprecated in the Core facade class (:php:`\TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder`), +to avoid shifting too far away. + +.. _`deprecated`: https://github.com/doctrine/dbal/commit/84328cd947706210caebcaea3ca0394b3ebc4673 + +Impact +====== + +Using :php:`ExpressionBuilder->andX()` and :php:`ExpressionBuilder->orX()` +will trigger a PHP :php:`E_USER_DEPRECATED` error when called. + +Affected Installations +====================== + +All installations, using the deprecated methods :php:`ExpressionBuilder->andX()` +and :php:`ExpressionBuilder->orX()` in custom extension code. The extension +scanner will detect any usage as weak match. + +Migration +========= + +Extensions should use the corresponding replacement: + +- :php:`ExpressionBuilder->andX()` -> :php:`ExpressionBuilder->and()` +- :php:`ExpressionBuilder->orX()` -> :php:`ExpressionBuilder->or()` + +.. note:: + + The replacement methods have already been added in a forward-compatible way + in TYPO3 v11. Thus giving extension developers the ability to adopt new + methods and still being able to support multiple Core versions without + workarounds. + +For example, the following select query: + +.. code-block:: php + + $rows = $queryBuilder + ->select(...) + ->from(...) + ->where( + $queryBuilder->expr()->andX(...), // replace with and(...) + $queryBuilder->expr()->orX(...) // replace with or(...) + ) + ->executeQuery() + ->fetchAllAssociative(); + +should be replaced with: + +.. code-block:: php + + $rows = $queryBuilder + ->select(...) + ->from(...) + ->where( + $queryBuilder->expr()->and(...), // replacement for andX(...) + $queryBuilder->expr()->or(...) // replacement for orX(...) + ) + ->executeQuery() + ->fetchAllAssociative(); + +.. index:: Database, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97384-TCAOptionNullable.rst b/Documentation/Changelog/12.0/Deprecation-97384-TCAOptionNullable.rst new file mode 100644 index 0000000..c141c4a --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97384-TCAOptionNullable.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97384: + +===================================================================== +Deprecation: #97384 - TCA option "eval=null" replaced with "nullable" +===================================================================== + +See :issue:`97384` + +Description +=========== + +The TCA option :php:`eval=null` has been replaced with the boolean option +:php:`nullable`. + +Impact +====== + +The TCA option :php:`eval=null` will be automatically migrated to +:php:`'nullable' => true`. The migration will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +All installations defining the :php:`null` value in their :php:`eval` list. + +Migration +========= + +To migrate your TCA add the TCA option :php:`'nullable' => true` and remove the +:php:`null` value from the field's :php:`eval` list. + +.. code-block:: php + + // Before + + 'columns' => [ + 'nullable_column' => [ + 'title' => 'A nullable field', + 'config' => [ + 'eval' => 'null', + ], + ], + ], + + // After + + 'columns' => [ + 'nullable_column' => [ + 'title' => 'A nullable field', + 'config' => [ + 'nullable' => true, + ], + ], + ], + +.. index:: TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97435-UsageOfSiteLanguageAwareTraitToDenoteSiteLanguageAwareness.rst b/Documentation/Changelog/12.0/Deprecation-97435-UsageOfSiteLanguageAwareTraitToDenoteSiteLanguageAwareness.rst new file mode 100644 index 0000000..abd04ca --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97435-UsageOfSiteLanguageAwareTraitToDenoteSiteLanguageAwareness.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97435: + +======================================================================================= +Deprecation: #97435 - Usage of SiteLanguageAwareTrait to denote site language awareness +======================================================================================= + +See :issue:`97435` + +Description +=========== + +The :php:`TYPO3\CMS\Core\Site\SiteLanguageAwareTrait` should not be used as +means to denote a class as aware of the site language anymore. Instead, the +:php:`TYPO3\CMS\Core\Site\SiteLanguageAwareInterface` should be implemented for +this purpose. The trait is an internal implementation and should not be used +in user land code. + +Impact +====== + +If you are currently using the :php:`SiteLanguageAwareTrait` to denote a class +as aware of the site language, you should implement the +:php:`SiteLanguageAwareInterface` instead. + +Affected Installations +====================== + +All installations where the :php:`SiteLanguageAwareTrait` is used to denote +a class as aware of the site language. + +Migration +========= + +Change classes that use the :php:`SiteLanguageAwareTrait` but not the +corresponding interface to implement the interface. Replace the usage of the +trait with an own trait, or implement the interface methods directly in the +class. + +Example before the migration: + +.. code-block:: php + + use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait; + + class MyClass + { + use SiteLanguageAwareTrait; + } + +Example after the migration: + +.. code-block:: php + + use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface; + use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + + class MyClass implements SiteLanguageAwareInterface + { + + protected SiteLanguage $siteLanguage; + + public function setSiteLanguage(SiteLanguage $siteLanguage) + { + $this->siteLanguage = $siteLanguage; + } + + public function getSiteLanguage(): SiteLanguage + { + return $this->siteLanguage; + } + } + +.. index:: PHP-API, ext:core, NotScanned diff --git a/Documentation/Changelog/12.0/Deprecation-97531-ContextRelatedMethodsWithinTSFE.rst b/Documentation/Changelog/12.0/Deprecation-97531-ContextRelatedMethodsWithinTSFE.rst new file mode 100644 index 0000000..dc3971a --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97531-ContextRelatedMethodsWithinTSFE.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97531: + +================================================================================= +Deprecation: #97531 - Context-related methods within TypoScriptFrontendController +================================================================================= + +See :issue:`97531` + +Description +=========== + +One of the main classes within TYPO3 Frontend — +:php:`TypoScriptFrontendController` a.k.a. :php:`$GLOBALS['TSFE']` — +had various short-hand functionality to access the Context API. + +This is mainly for historical reasons, before the Context API +was introduced in TYPO3 v9. + +For this reason, the related methods have been marked as deprecated: + +* :php:`initUserGroups()` +* :php:`isUserOrGroupSet()` +* :php:`isBackendUserLoggedIn()` +* :php:`doWorkspacePreview()` +* :php:`whichWorkspace()` + +Impact +====== + +Calling the methods directly will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using one of the methods. +The extension scanner will report any usage as weak match. + +Migration +========= + +Migrate towards the Context API instead: + +.. code-block:: php + + // Is this request within a Workspace currently + $context->getPropertyFromAspect('workspace', 'isOffline', false); + // Is a frontend user logged in + $context->getPropertyFromAspect('frontend.user', 'isLoggedIn', false); + +.. index:: Frontend, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-97544-PreviewURIGenerationRelatedFunctionalityInBackendUtility.rst b/Documentation/Changelog/12.0/Deprecation-97544-PreviewURIGenerationRelatedFunctionalityInBackendUtility.rst new file mode 100644 index 0000000..93ce3ba --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97544-PreviewURIGenerationRelatedFunctionalityInBackendUtility.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97544: + +==================================================================================== +Deprecation: #97544 - Preview URI Generation related functionality in BackendUtility +==================================================================================== + +See :issue:`97544` + +Description +=========== + +With :issue:`91123` the :php:`PreviewUriBuilder` has been introduced. +To further streamline any preview URI generation code, the related +functionality has now been fully integrated into :php:`PreviewUriBuilder` +along with two new PSR-14 events. Therefore, the previously used +:php:`BackendUtility::getPreviewUrl()` method, as well as the related hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['viewOnClickClass']`, +has been deprecated. + +Impact +====== + +Using the utility method or registering hooks will trigger a PHP :php:`E_USER_DEPRECATED` error. +The extension scanner will detect usages. + +Affected installations +====================== + +All installations using the utility method or the hook in custom extensions. + +Migration +========= + +Migrate any usage of :php:`BackendUtility::getPreviewUrl()` to +:php:`PreviewUriBuilder->buildUri()`. + +Replace any :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['viewOnClickClass']` +hook by using the new :doc:`PSR-14 events <../12.0/Feature-97544-PSR-14EventsForModifyingPreviewURIs>`. +The :php:`BeforePagePreviewUriGeneratedEvent` can be used as replacement for +the hooks' :php:`preProcess()` method, while the :php:`AfterPagePreviewUriGeneratedEvent` +can be used as replacement for the hooks' :php:`postProcess()` method. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-97549-ContentObjectRenderer-lastTypoLinkProperties.rst b/Documentation/Changelog/12.0/Deprecation-97549-ContentObjectRenderer-lastTypoLinkProperties.rst new file mode 100644 index 0000000..f329dd0 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97549-ContentObjectRenderer-lastTypoLinkProperties.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97549-1651696509: + +===================================================================== +Deprecation: #97549 - ContentObjectRenderer->lastTypoLink* properties +===================================================================== + +See :issue:`97549` + +Description +=========== + +When generating links via :php:`ContentObjectRenderer->typoLink()`, +it had been possible to retrieve information about the generated link +with the following public properties: + +* :php:`ContentObjectRenderer->lastTypoLinkUrl` +* :php:`ContentObjectRenderer->lastTypoLinkTarget` +* :php:`ContentObjectRenderer->lastTypoLinkLD` + +Since those information are also available in the :php:`LinkResultInterface`, +which is returned by :php:`ContentObjectRenderer->createLink()` or +can be accessed via :php:`ContentObjectRenderer->lastTypoLinkResult`, +these properties have now been deprecated. + +Impact +====== + +Accessing these properties is still possible, but will stop working in +TYPO3 v13.0. The extension scanner will detect any usage as weak match. + +Affected installations +====================== + +TYPO3 installations using these properties in their extensions in either +PHP or TypoScript code. + +Migration +========= + +It is recommended to retrieve this information via the :php:`LinkResultInterface` +object returned by calling :php:`ContentObjectRenderer->createLink()` directly, +or if this is not possible via :php:`ContentObjectRenderer->lastTypoLinkResult`. + +.. index:: Frontend, PHP-API, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-97576-TYPO3CMSCoreUtilityResourceUtility.rst b/Documentation/Changelog/12.0/Deprecation-97576-TYPO3CMSCoreUtilityResourceUtility.rst new file mode 100644 index 0000000..0358753 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97576-TYPO3CMSCoreUtilityResourceUtility.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97576-1651949640: + +================================================================ +Deprecation: #97576 - TYPO3\\CMS\\Core\\Utility\\ResourceUtility +================================================================ + +See :issue:`97576` + +Description +=========== + +The class :php:`TYPO3\CMS\Core\Utility\ResourceUtility` has no usage in the Core +and is therefore marked as deprecated. + +Impact +====== + +Calling any method of the class :php:`TYPO3\CMS\Core\Utility\ResourceUtility` +will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected installations +====================== + +All installations using any method of :php:`TYPO3\CMS\Core\Utility\ResourceUtility` +in their own code. + +Migration +========= + +There is no direct replacement of this class. Extensions that depend on any of the class' +methods should implement them in their codebase. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97787-SeveritiesOfFlashMessagesAndReportsDeprecated.rst b/Documentation/Changelog/12.0/Deprecation-97787-SeveritiesOfFlashMessagesAndReportsDeprecated.rst new file mode 100644 index 0000000..6741f9b --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97787-SeveritiesOfFlashMessagesAndReportsDeprecated.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97787-1655495192: + +========================================================================= +Deprecation: #97787 - Severities of flash messages and reports deprecated +========================================================================= + +See :issue:`97787` + +Description +=========== + +With the introduction of :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity`, +the existing severity constants of :php:`\TYPO3\CMS\Core\Messaging\FlashMessage` +and :php:`\TYPO3\CMS\Reports\Status` have been marked as deprecated. + +Impact +====== + +Passing the constants as listed below to the constructor of +:php:`\TYPO3\CMS\Core\Messaging\FlashMessage` will trigger a PHP :php:`E_USER_DEPRECATED` error: + +* :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::NOTICE` +* :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::INFO` +* :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::OK` +* :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::WARNING` +* :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::ERROR` + +Passing the constants as listed below to the constructor of +:php:`\TYPO3\CMS\Reports\Status` will trigger a PHP :php:`E_USER_DEPRECATED` error: + +* :php:`\TYPO3\CMS\Reports\Status::NOTICE` +* :php:`\TYPO3\CMS\Reports\Status::INFO` +* :php:`\TYPO3\CMS\Reports\Status::OK` +* :php:`\TYPO3\CMS\Reports\Status::WARNING` +* :php:`\TYPO3\CMS\Reports\Status::ERROR` + +Affected installations +====================== + +All installations with 3rd party plugins using the aforementioned constants are +affected. + +Migration +========= + +Use the cases of the :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity` enum. +The following cases are available: + +* :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::NOTICE` +* :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::INFO` +* :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::OK` +* :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::WARNING` +* :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::ERROR` + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-97866-VariousPublicTSFEProperties.rst b/Documentation/Changelog/12.0/Deprecation-97866-VariousPublicTSFEProperties.rst new file mode 100644 index 0000000..73411d0 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-97866-VariousPublicTSFEProperties.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97866-1657185866: + +==================================================== +Deprecation: #97866 - Various public TSFE properties +==================================================== + +See :issue:`97866` + +Description +=========== + +The following properties within TypoScriptFrontendController have been deprecated: + +* :php:`spamProtectEmailAddresses` +* :php:`intTarget` +* :php:`extTarget` +* :php:`fileTarget` +* :php:`baseUrl` + +All of these properties can be accessed through TypoScript's config array. + +Impact +====== + +Accessing these properties via TypoScript `getData` or via PHP will trigger a PHP :php:`E_USER_DEPRECATED` error. + +Affected installations +====================== + +TYPO3 installations with TypoScript options such as :typoscript:`.data = TSFE:fileTarget` or +TYPO3 installations with third-party extensions accessing the properties via PHP. + +Migration +========= + +Migrate the access to these properties to use the config property: + +In TypoScript you can access the TypoScript properties directly via +:typoscript:`.data = TSFE:config|config|fileTarget` and in PHP code via +:php:`$GLOBALS['TSFE']->config['config']['fileTarget']`. + +.. index:: Frontend, TypoScript, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Deprecation-98168-BindingContextMenuItemToThis.rst b/Documentation/Changelog/12.0/Deprecation-98168-BindingContextMenuItemToThis.rst new file mode 100644 index 0000000..93785df --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98168-BindingContextMenuItemToThis.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98168-1660890228: + +========================================================= +Deprecation: #98168 - Binding context menu item to `this` +========================================================= + +See :issue:`98168` + +Description +=========== + +Due to historical reasons, a context menu item is bound to :js:`this` in its callback action +which was used to access the context menu item's :js:`dataset`. The invocation of assigned callback actions +is adapted to pass the :js:`dataset` as the 3rd argument. + +Binding the context menu item to :js:`this` in the callback is now marked as deprecated. + +Impact +====== + +Using :js:`this` in a context menu item callback will trigger a deprecated log entry in the browser's console. + +Affected installations +====================== + +All extensions providing custom context menu actions are affected. + +Migration +========= + +To access data attributes, use the :js:`dataset` argument passed as the 3rd argument in the context menu callback action. + +.. code-block:: js + + // Before + ContextMenuActions.renameFile(table, uid): void { + const actionUrl = $(this).data('action-url'); + top.TYPO3.Backend.ContentContainer.setUrl( + actionUrl + '&target=' + encodeURIComponent(uid) + '&returnUrl=' + ContextMenuActions.getReturnUrl() + ); + } + + // After + ContextMenuActions.renameFile(table, uid, dataset): void { + const actionUrl = dataset.actionUrl; + top.TYPO3.Backend.ContentContainer.setUrl( + actionUrl + '&target=' + encodeURIComponent(uid) + '&returnUrl=' + ContextMenuActions.getReturnUrl() + ); + } + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-98283-PHPConstantTYPO3_mainDir.rst b/Documentation/Changelog/12.0/Deprecation-98283-PHPConstantTYPO3_mainDir.rst new file mode 100644 index 0000000..e609980 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98283-PHPConstantTYPO3_mainDir.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98283-1662557018: + +================================================ +Deprecation: #98283 - PHP Constant TYPO3_mainDir +================================================ + +See :issue:`98283` + +Description +=========== + +The PHP Constant :php:`TYPO3_mainDir` which is defined as :file:`typo3/` has been marked as deprecated. + +Impact +====== + +Accessing the constant will stop working in TYPO3 v13. No deprecation warning is thrown, +but the Extension Scanner will detect any usage in your installation. + +Affected installations +====================== + +TYPO3 installations with custom third-party extensions using the constant directly, which is +highly unlikely. + +Migration +========= + +It is recommended to use the :php:`BackendEntryPointResolver` class when needing +to direct to the TYPO3 Backend. All other common APIs such as the Backend Router already +calculate the path automatically anyway. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-98303-InterfacesForPageRepositoryLanguageOverlayHooks.rst b/Documentation/Changelog/12.0/Deprecation-98303-InterfacesForPageRepositoryLanguageOverlayHooks.rst new file mode 100644 index 0000000..30f7eca --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98303-InterfacesForPageRepositoryLanguageOverlayHooks.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98303-1662659648: + +========================================================================== +Deprecation: #98303 - Interfaces for PageRepository language overlay hooks +========================================================================== + +See :issue:`98303` + +Description +=========== + +The interfaces + +* :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepositoryGetRecordOverlayHookInterface` +* :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepositoryGetPageOverlayHookInterface` + +for the corresponding hooks + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getRecordOverlay']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getPageOverlay']` + +have been marked as deprecated. + +Impact +====== + +The corresponding hooks have been removed, so the interfaces are not needed +anymore, however they are kept for extensions which aim to be compatible +with TYPO3 v11 and TYPO3 v12+ at the same time. No deprecation notice +is triggered while using in TYPO3 v12+. + +Affected installations +====================== + +TYPO3 installations with custom extensions using these hooks and their interface. + +Migration +========= + +Migrate to the new :ref:`PSR-14 events <feature-98303-1662659478>`: + +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforeRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\AfterRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforePageLanguageOverlayEvent` + +.. index:: Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-98371-DeprecatedFluidGetters.rst b/Documentation/Changelog/12.0/Deprecation-98371-DeprecatedFluidGetters.rst new file mode 100644 index 0000000..9473c41 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98371-DeprecatedFluidGetters.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98371-1663524265: + +============================================== +Deprecation: #98371 - Deprecated Fluid getters +============================================== + +See :issue:`98371` + +Description +=========== + +Views in a Model-View-Controller (MVC) construct should be data sinks. +Class :php:`\TYPO3\CMS\Fluid\View\StandaloneView` violates this concept by +providing some :php:`getXY()` methods that allow fetching previously set state. + +The Fluid class :php:`\TYPO3\CMS\Fluid\Core\Rendering\RenderingContext` +is the main object carried around within the rendering chain to keep track +of state. It is the main data object used in view helpers. + +As such, :php:`StandaloneView` should not allow fetching state since it allows +to be misused to park state, which is primarily a controller concern instead. + +To enforce this pattern, the following methods have been marked as deprecated in +TYPO3 Core v12 and will be removed in TYPO3 v13: + +* :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getRequest()` +* :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getFormat()` +* :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getTemplatePathAndFilename()` + +Impact +====== + +Calling one of the above methods triggers a PHP :php:`E_USER_DEPRECATED` level +error. + +Affected installations +====================== + +Instances with extensions using one of the above methods. + +Migration +========= + +Do not misuse :php:`StandaloneView` as data source. Typically, controllers +should handle and keep track of state like a PSR-7 Request and set or update +view state. + +.. index:: Fluid, NotScanned, ext:fluid diff --git a/Documentation/Changelog/12.0/Deprecation-98431-ReplaceRequireJsModulesInFormEngineResultArray.rst b/Documentation/Changelog/12.0/Deprecation-98431-ReplaceRequireJsModulesInFormEngineResultArray.rst new file mode 100644 index 0000000..501a2fe --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98431-ReplaceRequireJsModulesInFormEngineResultArray.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98431-1664652773: + +======================================================================== +Deprecation: #98431 - Replace requireJsModules in FormEngine resultArray +======================================================================== + +See :issue:`98431` + +Description +=========== + +Loading JavaScript modules via :php:`$resultArray['requireJsModules']` has been +deprecated in favor of a new generic key named :php:`'javaScriptModules'`. + +The ability for custom :php:`FormEngine` components to load JavaScript modules +via instances of :php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` is now +streamlined to use a new, generic :php:`$resultArray` key named +:php:`'javaScriptModules'`. The behaviour is otherwise identical to the +functionality that has been available via :php:`'requireJsModules'`, +but the new name reflects that not just RequireJS modules may be loaded, +but also newer, native ECMAScript v6 JavaScript modules. + +Using :php:`'javaScriptModules'` is now the suggested to be used over +:php:`'requireJsModules'`, as this latter is deprecated from now on +and will be removed in TYPO3 v13. + +The ability for custom :php:`FormEngine` components to load JavaScript modules +via instances of :php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` is now +streamlined to use a new, generic :php:`$resultArray` key named +:php:`'javaScriptModules'`. The behaviour is otherwise identical to the +functionality that has been available via :php:`'requireJsModules'`, +but the new name reflects that not just RequireJS modules may be loaded, +but also newer, native ECMAScript v6 JavaScript modules. + +The :php:`'requireJsModules'` key is deprecated. + +Impact +====== + +Registering modules via :`'requireJsModules'` will still work. +These modules will be loaded after modules registered via `'javaScriptModules'`. +Extensions that use :php:`'requireJsModules` will work as before but trigger a +PHP :php:`E_USER_DEPRECATED` error. + +Affected installations +====================== + +Installations that register custom FormEngine components with JavaScript modules. + +Migration +========= + +Use the key :php:`'javaScriptModules'` and assign an instance of +:php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction`: + +.. code-block:: php + + // use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create( + '@my/extension/my-element.js' + ); + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-98479-DeprecatedFileReferenceRelatedFunctionality.rst b/Documentation/Changelog/12.0/Deprecation-98479-DeprecatedFileReferenceRelatedFunctionality.rst new file mode 100644 index 0000000..3291b84 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98479-DeprecatedFileReferenceRelatedFunctionality.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98479-1664622350: + +===================================================================== +Deprecation: #98479 - Deprecated file reference related functionality +===================================================================== + +See :issue:`98479` + +Description +=========== + +With the introduction of the new TCA type :php:`file`, a couple of cross +dependencies have been deprecated, mainly related to FormEngine. + +The :php:`UserFileInlineLabelService` class has been deprecated, since it was +only used for generating the inline label for file references in TCA type +:php:`inline`. This is now handled by the new TCA type :php:`file` directly. + +The :php:`FileExtensionFilter->filterInlineChildren()` method, which was +previously used as :php:`[filter][userFunc]` to filter the available +file extensions in FormEngine as well as :php:`DataHandler` has been +deprecated. This is now done internally. + +The :php:`ExtensionManagementUtility::getFileFieldTCAConfig()` method, which +was usually used to simplify configuration of FAL fields in TCA has been +deprecated as well, since the applied configuration is now handled internally. + +Impact +====== + +Instantiating the :php:`UserFileInlineLabelService` class, as well as +calling the :php:`FileExtensionFilter->filterInlineChildren()` and +:php:`ExtensionManagementUtility::getFileFieldTCAConfig()` methods will +trigger a PHP :php:`E_USER_DEPRECATED` level error. The extension scanner +also reports any usage. + +Affected installations +====================== + +All installations with extensions using the :php:`UserFileInlineLabelService` +class or one of the mentioned methods. + +Migration +========= + +Remove any usage of the :php:`UserFileInlineLabelService` class. There is no +migration available, since this FAL specific functionality is now handled +internally. + +Replace any usage of :php:`FileExtensionFilter->filterInlineChildren()` with +:php:`FileExtensionFilter->filter()`. However, usage of this method in custom +extension code should usually not be necessary. + +Replace any usage of :php:`ExtensionManagementUtility::getFileFieldTCAConfig()` +by directly using the new TCA type :ref:`file <feature-98479-1664537749>`. + +.. index:: Backend, Database, FAL, PHP-API, TCA, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/12.0/Deprecation-98487-ExtensionManagementUtilityallowTableOnStandardPages.rst b/Documentation/Changelog/12.0/Deprecation-98487-ExtensionManagementUtilityallowTableOnStandardPages.rst new file mode 100644 index 0000000..0d0a765 --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98487-ExtensionManagementUtilityallowTableOnStandardPages.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98487-1664575576: + +=========================================================================== +Deprecation: #98487 - ExtensionManagementUtility::allowTableOnStandardPages +=========================================================================== + +See :issue:`98487` + +Description +=========== + +The API method :php:`ExtensionManagementUtility::allowTableOnStandardPages` which +was used in `ext_tables.php` files of extensions registering custom records available +on any page type has been marked as deprecated. + +Impact +====== + +Calling the method will still work, however it is recommended to add a specific flag +to the tables TCA to be compatible with multiple TYPO3 versions. No deprecation notice +will be triggered. + +Affected installations +====================== + +TYPO3 installations with custom extensions creating custom TCA records to be added +on any page type calling the affected method. + +Migration +========= + +Set new TCA option :php:`$GLOBALS['TCA'][$table]['ctrl']['security']['ignorePageTypeRestriction']` +of a custom TCA table to keep the same behaviour as in previous TYPO3 versions. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.0/Deprecation-98488-ContentObjectRenderer-getQueryArguments.rst b/Documentation/Changelog/12.0/Deprecation-98488-ContentObjectRenderer-getQueryArguments.rst new file mode 100644 index 0000000..53dbe9b --- /dev/null +++ b/Documentation/Changelog/12.0/Deprecation-98488-ContentObjectRenderer-getQueryArguments.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98488-1664576976: + +============================================================== +Deprecation: #98488 - ContentObjectRenderer->getQueryArguments +============================================================== + +See :issue:`98488` + +Description +=========== + +The public method +:php:`TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getQueryArguments()` +has been marked as deprecated. + +Impact +====== + +Calling the method directly via PHP will trigger a PHP deprecation warning. + +Affected installations +====================== + +TYPO3 installations with custom third-party extensions calling this method directly, +which is highly unlikely. + +Migration +========= + +Use LinkFactory directly to create links with the typolink configuration option +:typoscript:`typolink.addQueryString = untrusted` to create links with the same behaviour. + +.. index:: Frontend, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-82809-MakeExtensionUtilityregisterPluginMethodReturnPluginSignature.rst b/Documentation/Changelog/12.0/Feature-82809-MakeExtensionUtilityregisterPluginMethodReturnPluginSignature.rst new file mode 100644 index 0000000..0879eb5 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-82809-MakeExtensionUtilityregisterPluginMethodReturnPluginSignature.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-82809: + +======================================================================================= +Feature: #82809 - Make ExtensionUtility::registerPlugin method return plugin signature. +======================================================================================= + +See :issue:`82809` + +Description +=========== + +The API method :php:`TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin()` +is used to register an Extbase plugin. The method is called in the +:file:`Configuration/TCA/Overrides/tt_content.php` file of an extension and +often followed by the definition of a FlexForm. + +Such methods require the plugin signature to be provided. To support +extension authors and to reduce coding errors, the :php:`registerPlugin()` +method therefore now returns the generated plugin signature as :php:`string`. + +Example +^^^^^^^ + +.. code-block:: php + + $pluginSignature = ExtensionUtility::registerPlugin( + 'indexed_search', + 'Pi2', + 'Testing' + ); + +The above call returns the plugin signature: `indexedsearch_pi2`. This could +then be used for, e.g., adding a FlexForm: + +.. code-block:: php + + ExtensionManagementUtility::addPiFlexFormValue( + $pluginSignature, + 'FILE:EXT:indexed_search/Configuration/FlexForms/Form.xml' + ); + +Impact +====== + +The API method :php:`TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin()` +now returns the plugin signature, which might be used to adjust TCA to +further needs, e.g. enabling FlexForms. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-83912-SpecifySectionRedirectFinisher.rst b/Documentation/Changelog/12.0/Feature-83912-SpecifySectionRedirectFinisher.rst new file mode 100644 index 0000000..7f2a38a --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-83912-SpecifySectionRedirectFinisher.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-83912: + +======================================================= +Feature: #83912 - Specify Section in Redirect Finisher +======================================================= + +See :issue:`83912` + +Description +=========== + +It is now possible to specify a fragment in the Redirect finisher. This +allows a user to be redirected to a specific content element or relevant +section after completing a form. + +Impact +====== + +A section can be defined in a form definition with the `fragment` option. +In the example below, :yaml:`fragment: '9'` refers to the content element +with `uid` 9. There is no need to add the :html:`#` character. It is also +possible to configure a custom section, e.g. :yaml:`fragment: 'foo'`. + +.. code-block:: yaml + + finishers: + - + options: + pageUid: '7' + additionalParameters: '' + fragment: '9' + identifier: Redirect + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/12.0/Feature-87616-PSR-14EventForModifyingPageLinkGeneration.rst b/Documentation/Changelog/12.0/Feature-87616-PSR-14EventForModifyingPageLinkGeneration.rst new file mode 100644 index 0000000..bba4a56 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-87616-PSR-14EventForModifyingPageLinkGeneration.rst @@ -0,0 +1,57 @@ +.. include:: /Includes.rst.txt + +.. _feature-87616: + +================================================================= +Feature: #87616 - PSR-14 event for modifying Page Link Generation +================================================================= + +See :issue:`87616` + +Description +=========== + +A new PSR-14 event :php:`TYPO3\CMS\Frontend\Event\ModifyPageLinkConfigurationEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typolinkProcessing']['typolinkModifyParameterForPageLinks']`. + +The event is called after a page has already been resolved, and includes much +more arguments such as the generated fragment or the to-be-used query parameters. + +The page to be linked to can also be modified, for example to link to a different page. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Frontend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/frontend/modify-page-link-configuration' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Frontend\Event\ModifyPageLinkConfigurationEvent; + + class MyEventListener { + + public function __invoke(ModifyPageLinkConfigurationEvent $event): void + { + // Do your magic here + } + } + +Impact +====== + +The main advantage of the PSR-14 event is that it is fired after TYPO3 has +already prepared some functionality within the :php:`PageLinkBuilder`, allowing +to modify more properties, if needed. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-89917-InheritPageAccess.rst b/Documentation/Changelog/12.0/Feature-89917-InheritPageAccess.rst new file mode 100644 index 0000000..11a45bc --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-89917-InheritPageAccess.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _feature-89917: + +======================================================= +Feature: #89917 - Copy page access settings from parent +======================================================= + +See :issue:`89917` + +Description +=========== + +It is now possible to copy page access permissions from the parent page, +while creating new pages. This is enabled using :typoscript:`copyFromParent` +as value for one of the page TSconfig :typoscript:`TCEMAIN.permissions.*` +sub keys. + +There are casual scenarios where this can be useful to avoid additional backend +administrator work: + +On a top-level page, backend admins use the access module to set a page +group owner and add TSconfig on this page to specify access details. A typical +TSconfig is :typoscript:`TCEMAIN.permissions.groupid` to a group ID, plus +:typoscript:`TCEMAIN.permissions.group=31` to allow all members of this group to do +"everything". To be sure, :typoscript:`TCEMAIN.permissions.everybody=0` is set +to deny access for non-members. Most often, a "page owner" base group is added, +where all backend users are members of (directly or via subgroups). Access to single page +trees are then done via page mount points in backend group "mount group records" +that have this "page owner" group as subgroup. When a backend user being member +of that "mount group" and thus having access to that page tree then creates new pages +below this top-level page, the page owner is set to the creating user, and group +ownership plus access details like "Can I modify?" are forced to the TSconfig +settings of the top-level page. + +With a base setup like that, administrators typically have to configure and set up +page group ownership once per root site and are then sure that everything below +"inherits" properly. + +In more complex scenarios, different group owners are set for parts of a subpage +tree. Administrators then want to make sure that new pages created below this differently +restricted set of pages also inherit those changed group ownership settings when users +create new pages. Until now, they had to change :typoscript:`TCEMAIN.permissions.groupid` +and potentially `TCEMAIN.permissions.group` and :typoscript:`TCEMAIN.permissions.everybody` +to achieve that and had to maintain TSconfig accordingly. + +The new :typoscript:`copyFromParent` value can be leveraged to reduce administrator +overhead to near-zero for access settings, especially when combined with :php:`defaultPageTSconfig`. +Let's say we have a TYPO3 instance with multiple sites. There is still a "page owner" group +plus various other groups for backend user mount points to single sites. A basic +site extension now sets this in an :file:`ext_tables.php` file: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPageTSconfig'] .= ' + TCEMAIN.permissions.groupid = copyFromParent + TCEMAIN.permissions.group = 31 + TCEMAIN.permissions.everybody = 0 + '; + +This configures a default 'New pages are set to the owner of the parent page and members +of this group can do "everything"'. When an administrator now creates a new site, it would +set the "group" of that page using the access module to the "page owner" group once, and +this will inherit to subpages automatically whenever a backend user creates a page. And +all that without additional TSconfig settings. If an administrator later sets a different +group owner for a subpage, new pages in there will inherit that owner instead, too. + +From a Unix administrator's point of view, this setting is similar to the "group sticky bit" +for directories - new directories get that group owner set by looking at the parent +directory and inherit it to new subdirectories. + +Example +======= + +.. code-block:: typoscript + + TCEMAIN.permissions.userid = copyFromParent + TCEMAIN.permissions.groupid = copyFromParent + TCEMAIN.permissions.user = copyFromParent + TCEMAIN.permissions.group = copyFromParent + TCEMAIN.permissions.everybody = copyFromParent + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.0/Feature-90919-SkipTranslationOfOverriddenFormFinisherOptions.rst b/Documentation/Changelog/12.0/Feature-90919-SkipTranslationOfOverriddenFormFinisherOptions.rst new file mode 100644 index 0000000..37faef0 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-90919-SkipTranslationOfOverriddenFormFinisherOptions.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _feature-90919: + +====================================================================== +Feature: #90919 - Skip translation of overridden form finisher options +====================================================================== + +See :issue:`90919` + +Description +=========== + +If form finisher options are overridden via FlexForm, they must not be translated +by the :php:`TranslationService`. Otherwise, they would probably be overridden +again by a localization from a translation file. + +To address this issue, a new translation option :yaml:`propertiesExcludedFromTranslation` +has been introduced. The option allows skipping all those finisher options whose +option value has been changed within a FlexForm. The translation option is only +respected in :php:`TranslationService::translateFinisherOption()`. + +The following example excludes three properties (subject, recipients and format). +That way, the options can only be overridden within a FlexForm but not by +:php:`TranslationService`. The option is automatically generated as soon as +FlexForm overrides are in place. The following syntax is only documented for +completeness. Nonetheless, it can also be written manually into a form definition. + +.. code-block:: yaml + + finishers: + - + options: + identifier: EmailToSender + subject: 'Email to sender' + recipients: + recipient@sender.de: 'recipient@sender name' + translation: + propertiesExcludedFromTranslation: + - subject + - recipients + - format + +Impact +====== + +The translation order is as follows: + +1. Default value from form definition +2. Overridden value within a FlexForm (if any) +3. Localized value provided by translation files (if any) + +With the new translation option, the last (third) step can be skipped. That way, +the FlexForm value will be preferred. + +.. index:: Frontend, FlexForm, ext:form diff --git a/Documentation/Changelog/12.0/Feature-90994-MarkCurrentPageInFluid_styled_contentMenuContentElements.rst b/Documentation/Changelog/12.0/Feature-90994-MarkCurrentPageInFluid_styled_contentMenuContentElements.rst new file mode 100644 index 0000000..14de4ed --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-90994-MarkCurrentPageInFluid_styled_contentMenuContentElements.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _feature-90994: + +================================================================================= +Feature: #90994 - Mark current page in fluid_styled_content menu content elements +================================================================================= + +See :issue:`90994` + +Description +=========== + +All menu content elements related to page navigation reflect the "current" +state of a page now. + +The resulting HTML of these page link lists is then: + +.. code-block:: html + + <li> + <a aria-current="page" > ... + </li> + +Impact +====== + +The aria attribute :html:`aria-current="page"` is added to the :html:`a` tag of +the menu item of the current page. + +For styling with CSS the attribute of the link can be used: + +.. code-block:: css + + [aria-current="page"] { + /* Special style for the current page link */ + } + [aria-current="page"]:hover { + /* Special style for the current page link when hovered */ + } + [aria-current="page"]::before { + /* Special virtual element for additions like chevrons, etc. */ + } + +.. index:: Frontend, ext:fluid_styled_content diff --git a/Documentation/Changelog/12.0/Feature-91077-ElementBrowserEntryPointsForTCATypeGroup.rst b/Documentation/Changelog/12.0/Feature-91077-ElementBrowserEntryPointsForTCATypeGroup.rst new file mode 100644 index 0000000..3f4094e --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-91077-ElementBrowserEntryPointsForTCATypeGroup.rst @@ -0,0 +1,132 @@ +.. include:: /Includes.rst.txt + +.. _feature-91077: + +================================================================================= +Feature: #91077 - Element browser entry points for TCA types "group" and "folder" +================================================================================= + +See :issue:`91077` + +Description +=========== + +The TCA types "group" and "folder" allow an editor to create references +to folders or records from multiple tables in the system. By default, +an editor can select those records by either using the suggest wizard +or with the element browser. The latter displays the page or folder +tree (depending on the fields' configuration). The editor can then +select a page or folder to select records / folders from. + +By default, the last selected page / folder is used when opening +the element browser. However, there are usually "storage pages" +in each system, which contain records of one specific type, e.g. a +storage for news records. Therefore, the editor always has to search +for this particular page, which might take some time, especially in +systems with large page trees. + +This situation has now been improved by introducing a new TCA field +configuration `elementBrowserEntryPoints` for the TCA types "group" and "folder". +It's a PHP :php:`array`, containing `table => id` pairs. When +opening the element browser for a specific table (buttons below the +group field), the defined page or folder is then always selected by +default. There is also the special `_default` key, used for the +general element browser button (on the right side of the group field), +which is not dedicated to a specific table. + +Making this even more useful, the new configuration also supports the known +markers `###SITEROOT###`, `###CURRENT_PID###` and `###PAGE_TSCONFIG_<key>###`. +Additionally, the configuration is also added to FormEngine's "allowOverrideMatrix". +This means, each `table => id` pair can be overridden via page TSconfig. + +Let's see a simple example for a group field with one allowed table: + +.. code-block:: php + + 'simple_group' => [ + 'label' => 'Simple group field', + 'config' => [ + 'type' => 'group', + 'allowed' => 'tt_content', + 'elementBrowserEntryPoints' => [ + 'tt_content' => 123, + ] + ] + ], + +This could then be overridden via page TSconfig: + +.. code-block:: typoscript + + TCEFORM.my_table.simple_group.config.elementBrowserEntryPoints.tt_content = 321 + +Since only one table is allowed, the defined entry point is also automatically +used for the general element browser button. In case the group field allows +more than one table the `_default` key has to be set: + +.. code-block:: php + + 'extended_group' => [ + 'label' => 'Extended group field', + 'config' => [ + 'type' => 'group', + 'allowed' => 'tt_content,tx_news_domain_model_news', + 'elementBrowserEntryPoints' => [ + '_default' => '###CURRENT_PID###' // E.g. use a special marker + 'tt_content' => 123, + 'tx_news_domain_model_news' => 124, + ] + ] + ], + +Of course, the `_default` key can also be overridden via page TSconfig: + +.. code-block:: typoscript + + TCEFORM.my_table.extended_group.config.elementBrowserEntryPoints._default = 122 + +For TCA type "folder" one can also define an entry point with the `_default` key: + +.. code-block:: php + + 'folder_group' => [ + 'label' => 'Folder group field', + 'config' => [ + 'type' => 'folder', + 'elementBrowserEntryPoints' => [ + '_default' => '1:/styleguide/' + ] + ] + ], + +It's also possible to use a special TSconfig key: + +.. code-block:: php + + 'folder_group' => [ + 'label' => 'Folder group field', + 'config' => [ + 'type' => 'folder', + 'elementBrowserEntryPoints' => [ + '_default' => '###PAGE_TSCONFIG_ID###' + ] + ] + ], + +This key has then to be defined on field level: + +.. code-block:: typoscript + + TCEFORM.my_table.folder_group.PAGE_TSCONFIG_ID = 1:/styleguide/subfolder + +In case an allowed table has no entry point defined, the `_default` is used. +In case `_default` is also not set or `elementBrowserEntryPoints` is not +used at all, the previous behaviour applies. + +Impact +====== + +Editors workflow for selecting records or folders in TCA types "group" and "folder" fields +can now be improved by defining default entry points for tables and folders. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-91082-AddNewOptionShowScheduledRecordsToAdminPanel.rst b/Documentation/Changelog/12.0/Feature-91082-AddNewOptionShowScheduledRecordsToAdminPanel.rst new file mode 100644 index 0000000..84c776e --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-91082-AddNewOptionShowScheduledRecordsToAdminPanel.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-91082: + +======================================================================== +Feature: #91082 - Add new option "show scheduled records" to admin panel +======================================================================== + +See :issue:`91082` + +Description +=========== + +The admin panel has a new checkbox to show all records regardless of start and end time restrictions. + +This is especially helpful if content on a page has different start and end times and an editor +wants to have an overview of all elements on that page. Without this option an editor has to simulate +multiple dates to be able to see all records, because records which are visible on one date, can be invisible on another. + +Impact +====== + +With this new option a TYPO3 user is able to view all records on +a page regardless of the start and end time. + +.. index:: Frontend, ext:adminpanel diff --git a/Documentation/Changelog/12.0/Feature-91715-AddMultipleHasidentifierMethodsToTYPO3CMSCorePageAssetCollector.rst b/Documentation/Changelog/12.0/Feature-91715-AddMultipleHasidentifierMethodsToTYPO3CMSCorePageAssetCollector.rst new file mode 100644 index 0000000..c06931b --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-91715-AddMultipleHasidentifierMethodsToTYPO3CMSCorePageAssetCollector.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-91715: + +=================================================================================================== +Feature: #91715 - Add multiple has($identifier) methods to \\TYPO3\\CMS\\Core\\Page\\AssetCollector +=================================================================================================== + +See :issue:`91715` + +Description +=========== + +The new feature can check if the assets such as JavaScript, inline stylesheets, +stylesheets, and media already exist before generating it again. + +To accomplish this, new methods has been added to :php:`\TYPO3\CMS\Core\Page\AssetCollector`: + +- :php:`hasJavaScript(string $identifier): bool` +- :php:`hasInlineJavaScript(string $identifier): bool` +- :php:`hasStyleSheet(string $identifier): bool` +- :php:`hasInlineStyleSheet(string $identifier): bool` +- :php:`hasMedia(string $identifier): bool` + +.. code-block:: php + + //use TYPO3\CMS\Core\Page\AssetCollector; + //use TYPO3\CMS\Core\Utility\GeneralUtility; + + $assetsCollector = GeneralUtility::makeInstance(AssetCollector::class); + if ($assetsCollector->hasJavaScript($identifier)) { + // result: true - javascript with identifier $identifier exists + } else { + // result: false - javascript with identifier $identifier do not exists + } + + // $result<X> is true if $identifier exists, otherwise false. + $result1 = $assetsCollector->hasJavaScript($identifier); + $result2 = $assetsCollector->hasInlineJavaScript($identifier); + $result3 = $assetsCollector->hasStyleSheet($identifier); + $result4 = $assetsCollector->hasInlineStyleSheet($identifier); + $result5 = $assetsCollector->hasMedia($identifier); + +Impact +====== + +Users have the ability to check if the asset already exists before regenerating +it, thus avoiding redundancy. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-92508-PSR-14EventForModifyingMenuItems.rst b/Documentation/Changelog/12.0/Feature-92508-PSR-14EventForModifyingMenuItems.rst new file mode 100644 index 0000000..002db8f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-92508-PSR-14EventForModifyingMenuItems.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-92508: + +======================================================= +Feature: #92508 - PSR-14 event for modifying menu items +======================================================= + +See :issue:`92508` + +Description +=========== + +A new PSR-14 event :php:`TYPO3\CMS\Frontend\Event\FilterMenuItemsEvent` has been +introduced which serves as a more powerful and flexible alternative +for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/tslib/class.tslib_menu.php']['filterMenuPages']`. + +The new PSR-14 event has a variety of properties and getters, along with +:php:`->getFilteredMenuItems()` and :php:`->setFilteredMenuItems()`. Those +methods can be used to change the items of a menu, which has been generated +with :typoscript:`HMENU`. + +Impact +====== + +The main advantage of the PSR-14 event is that it is fired after TYPO3 has +filtered all menu items. The menu can then be adjusted by adding, removing +or modifying the menu items. Also changing the order is possible. + +Additionally, more information about the currently rendered menu, such as the +menu items which were filtered out, is available in the PSR-14 event. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-92749-ImproveContentObjectInitializationInHtmlViewHelper.rst b/Documentation/Changelog/12.0/Feature-92749-ImproveContentObjectInitializationInHtmlViewHelper.rst new file mode 100644 index 0000000..30ab868 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-92749-ImproveContentObjectInitializationInHtmlViewHelper.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _feature-92749: + +========================================================================= +Feature: #92749 - Improve content object initialization in HtmlViewHelper +========================================================================= + +See :issue:`92749` + +Description +=========== + +New options are available for the :html:`f:format.html` ViewHelper, +related to the initialization of the underlying content object. The options +are similar to the ones, available for the :html:`f:cObject` ViewHelper. + +With the `data` argument an integrator can pass an array or an object (e.g. a +domain model), which will be used as data record on initialization. + +With the `currentValueKey` argument, one can specify the array key of the +provided data record, which should be used as the current value. + +Alternatively, one can use the new `current` argument to set a static value +as current value for the content object. + +Additionally, with the `table` argument, the :php:`ContentObjectRenderer` +receives the table name, the given data record is from. + +Example +======= + +Access a news record title with `CURRENT:1` and resolve a marker: + +.. code-block:: html + + <f:format.html + parseFuncTSPath="lib.news" + data="{uid: 1, title: \'Great news\'}" + currentValueKey="title"> + ###PROJECT### news: + </f:format.html> + +.. code-block:: typoscript + + constants.PROJECT = TYPO3 + lib.news { + htmlSanitize = 1 + constants = 1 + plainTextStdWrap.noTrimWrap = || | + plainTextStdWrap.dataWrap = |{CURRENT:1} + } + +This will result in: + +.. code-block:: html + + TYPO3 news: Great news + +Impact +====== + +The :html:`f:format.html` ViewHelper can now be utilized in more customized use cases. + +.. index:: Frontend, TypoScript, ext:fluid diff --git a/Documentation/Changelog/12.0/Feature-92861-IntroduceTCAOptionMin.rst b/Documentation/Changelog/12.0/Feature-92861-IntroduceTCAOptionMin.rst new file mode 100644 index 0000000..4b70075 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-92861-IntroduceTCAOptionMin.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-92861: + +============================================ +Feature: #92861 - Introduce TCA option "min" +============================================ + +See :issue:`92861` + +Description +=========== + +The new TCA option :php:`min` allows to define a minimum number of characters +for fields of type :php:`input` and :php:`text`. This option simply adds a +:html:`minlength` attribute to the input field. If at least one character is +typed in and the number of characters is less than :php:`min`, the FormEngine +marks the field as invalid, preventing the user to save the element. + +When using :php:`min` in combination with :php:`max`, one has to make sure, the +:php:`min` value is less than or equal :php:`max`. Otherwise the option is +ignored. + +Empty fields are not validated. If one needs to have non-empty values, it is +recommended to use :php:`required => true` in combination with :php:`min`. + +.. note:: + + This option does not work for text fields, if RTE is enabled. + +Impact +====== + +Integrators and developers are now able to define a minimum number of characters +a simple text or textarea field should have. Editors are forced to provide the +specified minimum amount of characters. An alert badge, similar to the one of +the max value, will show, how many characters are missing. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-93494-NewPSR-14ModifyQueryForLiveSearchEvent.rst b/Documentation/Changelog/12.0/Feature-93494-NewPSR-14ModifyQueryForLiveSearchEvent.rst new file mode 100644 index 0000000..cfbec55 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-93494-NewPSR-14ModifyQueryForLiveSearchEvent.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _feature-93494: + +========================================================== +Feature: #93494 - New PSR-14 ModifyQueryForLiveSearchEvent +========================================================== + +See :issue:`93494` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Search\Event\ModifyQueryForLiveSearchEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Backend\Search\LiveSearch\LiveSearch` class +and allows extensions to modify the :php:`QueryBuilder` instance +before execution. + +The event features the following methods: + +- :php:`getQueryBuilder()`: Returns the current :php:`QueryBuilder` instance +- :php:`getTableName()`: Returns the table, for which the query will be executed + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\ModifyQueryForLiveSearchEventListener: + tags: + - name: event.listener + identifier: 'my-package/modify-query-for-live-search-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Search\Event\ModifyQueryForLiveSearchEvent; + + final class ModifyQueryForLiveSearchEventListener + { + public function __invoke(ModifyQueryForLiveSearchEvent $event): void + { + // Get the current instance + $queryBuilder = $event->getQueryBuilder(); + + // Change limit depending on the table + if ($event->getTableName() === 'pages') { + $queryBuilder->setMaxResults(2); + } + + // Reset the orderBy part + $queryBuilder->resetQueryPart('orderBy'); + } + } + +Impact +====== + +It is now possible to use a new PSR-14 event for modifying the live +search query. This can be used, for example, to adjust the limit for a specific +table or to change the result order. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-93689-PSR-14EventsOnSendingMessagesWithMailer.rst b/Documentation/Changelog/12.0/Feature-93689-PSR-14EventsOnSendingMessagesWithMailer.rst new file mode 100644 index 0000000..2f31f60 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-93689-PSR-14EventsOnSendingMessagesWithMailer.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _feature-93689-1654629861: + +=============================================================== +Feature: #93689 - PSR-14 events on sending messages with Mailer +=============================================================== + +See :issue:`93689` + +Description +=========== + +TYPO3's :php:`MailerInterface` implementation :php:`Mailer` is used for sending +messages, e.g. in the EXT:form email finishers. To allow further handling and +manipulation of the message sending process, two new PSR-14 events have been +introduced. + +The :php:`BeforeMailerSentMessageEvent` is dispatched before the message +is sent by the mailer and can be used to manipulate the :php:`RawMessage` +and the :php:`Envelope`. Usually an :php:`Email` or `FluidEmail` instance +is given as :php:`RawMessage`. Additionally, the :php:`MailerInstance` is +given, which depending on the implementation - usually +:php:`TYPO3\CMS\Core\Mail\Mailer` - contains the :php:`Transport` object, +which can be retrieved using the :php:`getTransport()` method. + +The :php:`AfterMailerSentMessageEvent` is dispatched as soon as the +message has been sent via the corresponding :php:`TransportInterface`. +The event receives the current :php:`MailerInstance`, which, depending +on the implementation - usually :php:`TYPO3\CMS\Core\Mail\Mailer` - +contains the :php:`SentMessage` object that can be retrieved using +the :php:`getSentMessage()` method. + +Registration of the event listeners in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\MailerSentMessageEventListener: + tags: + - name: event.listener + identifier: 'my-package/modify-message' + method: 'modifyMessage' + - name: event.listener + identifier: 'my-package/process-sent-message' + method: 'processSentMessage' + +The corresponding event listener class: + +.. code-block:: php + + use Psr\Log\LoggerInterface; + use Symfony\Component\Mime\Address; + use Symfony\Component\Mime\Email; + use TYPO3\CMS\Core\Mail\Event\AfterMailerSentMessageEvent; + use TYPO3\CMS\Core\Mail\Event\BeforeMailerSentMessageEvent; + use TYPO3\CMS\Core\Mail\Mailer; + + final class MailerSentMessageEventListener + { + public function __construct( + private readonly LoggerInterface $logger + ) { + } + + public function modifyMessage(BeforeMailerSentMessageEvent $event): void + { + $message = $event->getMessage(); + + // If $message is an Email implementation, add an additional recipient + if ($message instanceof Email) { + $message->addCc(new Address('kasperYYYY@typo3.org')); + } + } + + public function processSentMessage(AfterMailerSentMessageEvent $event): void + { + $mailer = $event->getMailer(); + if ($mailer instanceof Mailer) { + $sentMessage = $mailer->getSentMessage(); + if ($sentMessage !== null) { + $this->logger->debug($sentMessage->getDebug()); + } + } + } + } + +Impact +====== + +With the new PSR-14 events, it's now possible to manipulate messages before +they are sent by the mailer. Additionally, after the mailer has sent messages, +further processing can be performed. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-94117-ImproveExtbaseTypeConverterRegistration.rst b/Documentation/Changelog/12.0/Feature-94117-ImproveExtbaseTypeConverterRegistration.rst new file mode 100644 index 0000000..3ff60d6 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-94117-ImproveExtbaseTypeConverterRegistration.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-94117: + +============================================================= +Feature: #94117 - Improve Extbase type converter registration +============================================================= + +See :issue:`94117` + +Description +=========== + +Extbase type converters are an important part of the Extbase data and property +mapping mechanism. Those converters usually convert data from simple types +to objects or other simple types. + +Extension authors can add their own type converters. This was previously done +by registering the type converter class in the :file:`ext_localconf.php` +file and adding the configuration, such as the `sourceType`, the `targetType` +or the `priority` as class properties, accessible via public methods. + +This has now been improved. Type converters are now registered as container +services in the extension's :file:`Services.yaml` file by tagging the service +with :yaml:`extbase.type_converter` and adding the configuration as tag +attributes. + +This means, the registration via php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()` +can be removed together with the configuration related class properties and +methods. See :doc:`changelog <../12.0/Breaking-94117-RegisterExtbaseTypeConvertersAsServices>` +for more information. + +Impact +====== + +Registration is now done in your :file:`Services.yaml` like the following: + +.. code-block:: yaml + + services: + Vendor\Extension\Property\TypeConverter\MyBooleanConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: boolean + sources: boolean,string + +.. tip:: + + Tag arguments (priority, target, sources, etc.) have to be simple types. + Don't register the sources as array but as comma separated list as shown + in the example. + +.. note:: + + Since the configuration (priority, target and sources) are now done at + this place, respective type converter properties are now superfluous and + will also no longer be evaluated. See the :doc:`deprecation changelog <../12.0/Deprecation-94117-RegisterExtbaseTypeConvertersAsServices>` + for more information. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-94544-AddNewSMTPConfigurationSettings.rst b/Documentation/Changelog/12.0/Feature-94544-AddNewSMTPConfigurationSettings.rst new file mode 100644 index 0000000..a9c9751 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-94544-AddNewSMTPConfigurationSettings.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-94544: + +===================================================== +Feature: #94544 - Add new SMTP configuration settings +===================================================== + +See :issue:`94544` + +Description +=========== + +A few more SMTP options are now supported by TYPO3 and can be set in the Install Tool: + +`transport_smtp_restart_threshold` + Sets the maximum number of messages to send before re-starting the transport. + +`transport_smtp_restart_threshold_sleep` + The number of seconds to sleep between stopping and re-starting the transport + +`transport_smtp_ping_threshold` + Sets the minimum number of seconds required between two messages, before the server is pinged. If + the transport wants to send a message and the time since the last message exceeds the specified + threshold, the transport will ping the server first (NOOP command) to check if the connection is + still alive. Otherwise the message will be sent without pinging the server first. + +Do not set the threshold too low, as the SMTP server may drop the connection if there are too many +non-mail commands (like pinging the server with NOOP). + +It is now also possible to define an array with SMTP stream options in the +:file:`AdditionalConfiguration.php` file. + +Configuration Example: + +.. code-block:: php + + return [ + //.... + 'MAIL' => [ + 'transport' => 'smtp', + 'transport_smtp_server' => 'localhost:1025', + 'transport_smtp_stream_options' => [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ] + ], + ], + //.... + ]; + +Impact +====== + +Now it is possible to set more options for some SMTP cases. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/12.0/Feature-94625-IntroduceSlidingWindowPagination.rst b/Documentation/Changelog/12.0/Feature-94625-IntroduceSlidingWindowPagination.rst new file mode 100644 index 0000000..9b355bc --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-94625-IntroduceSlidingWindowPagination.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _feature-94625: + +===================================================== +Feature: #94625 - Introduce sliding window pagination +===================================================== + +See :issue:`94625` + +Description +=========== + +Since TYPO3 v10 a new `Pagination API <https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/Pagination/Index.html>`__ +is shipped, which supersedes the pagination widget controller, which had +been removed in TYPO3 v11. + +This patch provides an improved pagination which can be used to paginate array +items or query results from Extbase. The main advantage is that it reduces the +amount of pages shown. + +**Example**: Imagine 1000 records and 20 items per page which would lead to +50 links. Using the `SlidingWindowPagination`, you will get something like +`< 1 2 ... 21 22 23 24 ... 100 >`. + +Usage +===== + +Just replace the usage of :php:`SimplePagination` with +:php:`\TYPO3\CMS\Core\Pagination\SlidingWindowPagination` and you are done. +Set the 2nd argument to the maximum number of links which should be rendered. + +.. code-block:: php + + use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator; + use TYPO3\CMS\Core\Pagination\SlidingWindowPagination + + $currentPage = $this->request->hasArgument('currentPage') + ? (int)$this->request->getArgument('currentPage') + : 1; + $itemsPerPage = 10; + $maximumLinks = 15; + + $paginator = new QueryResultPaginator( + $allItems, + $currentPage, + $itemsPerPage + ); + $pagination = new SlidingWindowPagination( + $paginator, + $maximumLinks + ); + + $this->view->assign( + 'pagination', + [ + 'pagination' => $pagination, + 'paginator' => $paginator + ] + ); + +Credits +======= + +This patch is loosely based on the "`numbered_pagination <https://github.com/georgringer/numbered_pagination>`__" +extension by Georg Ringer. + +Thanks to him. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-95486-AddAcceptArgument.rst b/Documentation/Changelog/12.0/Feature-95486-AddAcceptArgument.rst new file mode 100644 index 0000000..8386b3d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-95486-AddAcceptArgument.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-95486: + +========================================================== +Feature: #95486 - Add accept argument for UploadViewHelper +========================================================== + +See :issue:`95486` + +Description +=========== + +It is now possible to pass file types via "accept" as argument directly to the +UploadViewHelper. +Previously this had to be done by using "additionalAttributes". +This way it can be defined which file types are allowed for uploads, +to prevent unwanted file formats. + +Example +======= + +.. code-block:: html + + <f:form.upload accept=".jpg,.png" /> + +.. index:: Fluid, ext:fluid diff --git a/Documentation/Changelog/12.0/Feature-96041-ImproveBackendToolbarRegistration.rst b/Documentation/Changelog/12.0/Feature-96041-ImproveBackendToolbarRegistration.rst new file mode 100644 index 0000000..b955777 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96041-ImproveBackendToolbarRegistration.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-96041: + +====================================================== +Feature: #96041 - Improve Backend toolbar registration +====================================================== + +See :issue:`96041` + +Description +=========== + +The toolbar in the TYPO3 backend is well known by users as it provides +e.g. the personal bookmarks or a common used feature for administrators: +the "flush caches" action. + +It's also possible for extension authors to add their own +toolbar items. The registration therefore had to be done in the +:file:`LocalConfiguration.php` file. + +Since the introduction of the Symfony service container in TYPO3 v10, +it's possible to autoconfigure services. This feature is now also used +for the toolbar items. Therefore, the previous registration step is +now superfluous. All toolbar items are now automatically tagged and +registered based on the implemented :php:`TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface`. + +Impact +====== + +Custom toolbar items are now automatically registered, based on +the implemented interface, through the service configuration. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96147-NewPSR-14RedirectWasHitEvent.rst b/Documentation/Changelog/12.0/Feature-96147-NewPSR-14RedirectWasHitEvent.rst new file mode 100644 index 0000000..02f01c5 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96147-NewPSR-14RedirectWasHitEvent.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _feature-96147: + +================================================ +Feature: #96147 - New PSR-14 RedirectWasHitEvent +================================================ + +See :issue:`96147` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Redirects\Event\RedirectWasHitEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Redirects\Http\Middleware\RedirectHandler` +middleware and allows extensions to further process the matched +redirect and to adjust the PSR-7 response. + +The event features the following methods: + +- :php:`getRequest()`: Returns the current PSR-7 Request +- :php:`getResponse()`: Returns the current PSR-7 Response +- :php:`setResponse()`: Can be used to set / update the PSR-7 Response +- :php:`getMatchedRedirect()`: Returns the matched redirect record +- :php:`setMatchedRedirect()`: Can be used to set / update the matched redirect record +- :php:`getTargetUrl()`: Returns the resolved redirect target + +TYPO3 already implements the :php:`IncrementHitCount` listener. It is +used to increment the hit count of the matched redirect record, if the +feature is enabled. In case you want to prevent the increment in some +cases, e.g. when the request was initiated by a monitoring tool, you +can either implement your own listener with the same identifier +(:yaml:`redirects-increment-hit-count`) or add your custom listener +before and dynamically set the records :php:`disable_hitcount` flag. + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/redirects/validate-hit-count' + before: 'redirects-increment-hit-count' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Redirects\Event\RedirectWasHitEvent; + + class MyEventListener { + + public function __invoke(RedirectWasHitEvent $event): void + { + $matchedRedirect = $event->getMatchedRedirect(); + + // This will disable the hit count increment in case the target + // is the page 123 and the request is from the monitoring tool. + if (str_contains($matchedRedirect['target'], 'uid=123') + && $event->getRequest()->getAttribute('normalizedParams')->getHttpUserAgent() === 'my monitoring tool' + ) { + $matchedRedirect['disable_hitcount'] = true; + $event->setMatchedRedirect( + $matchedRedirect + ); + + // Also add a custom response header + $event->setResponse( + $event->getResponse()->withAddedHeader('X-Custom-Header', 'Hit count increment skipped') + ); + } + } + } + +Impact +====== + +This event can be used to further process the matched redirect +and to adjust the PSR-7 Response. It furthermore allows to +influence Core functionality, e.g. the hit count increment. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.0/Feature-96152-BackendToolbarItemsOverviewInConfigurationModule.rst b/Documentation/Changelog/12.0/Feature-96152-BackendToolbarItemsOverviewInConfigurationModule.rst new file mode 100644 index 0000000..50650f7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96152-BackendToolbarItemsOverviewInConfigurationModule.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-96152: + +======================================================================== +Feature: #96152 - Backend Toolbar items overview in configuration module +======================================================================== + +See :issue:`96152` + +Description +=========== + +With :issue:`96041`, the registration of backend toolbar items had been +improved. Instead of being registered via :php:`$GLOBALS`, all implementations +of :php:`TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface` are now automatically +registered, while taking the defined :php:`index` into account. + +To still allow administrators an overview of the registered toolbar items, +especially the final ordering, a corresponding list has been added to +the configuration module. + +Impact +====== + +It's now possible for administrators to get an overview of all registered +toolbar items and the final ordering in the :guilabel:`Configuration` module. + +.. index:: Backend, ext:lowlevel diff --git a/Documentation/Changelog/12.0/Feature-96333-ImproveContextMenuItemProviderRegistration.rst b/Documentation/Changelog/12.0/Feature-96333-ImproveContextMenuItemProviderRegistration.rst new file mode 100644 index 0000000..d370bdd --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96333-ImproveContextMenuItemProviderRegistration.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-96333: + +================================================================ +Feature: #96333 - Improve ContextMenu item provider registration +================================================================ + +See :issue:`96333` + +Description +=========== + +The context menu in the TYPO3 backend is used to easily access all +relevant actions for the corresponding record, such as "edit", "hide" +or "delete". + +It's furthermore also possible for extensions to extend the context menu +with additional actions using so called "item providers". Those were +previously registered in the global TYPO3 configuration, via the +:php:`ext_localconf.php` file. + +Since the introduction of the Symfony service container in TYPO3 v10, +it's possible to autoconfigure services. This feature is now also used +for the context menu item providers. Therefore, the previous registration +step is now superfluous. All item providers are now automatically tagged +and registered based on the implemented +:php:`TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface`. + +Impact +====== + +Custom context menu item providers are now automatically registered, based +on the implemented interface, through the service configuration. + +Besides the simplified registration, it's now also possible to use DI +in item provider classes. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96465-NewLinkvalidatorModule.rst b/Documentation/Changelog/12.0/Feature-96465-NewLinkvalidatorModule.rst new file mode 100644 index 0000000..90c001a --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96465-NewLinkvalidatorModule.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-96465: + +========================================== +Feature: #96465 - New Linkvalidator module +========================================== + +See :issue:`96465` + +Description +=========== + +Checking a TYPO3 installation for broken links is a common and necessary +task for editors. Therefore, TYPO3 provides the "linkvalidator" system +extension, which allows to check and report broken links via the TYPO3 +backend or via email. Previously, the backend part was built into the +:guilabel:`Web > Info` module. + +To make "linkvalidator" more prominent, its functionality is now available +in an independent backend module :guilabel:`Web > Check links` . This also +allows administrators to define access permissions via the module access logic. + +The new module still contains the two known functions "report" and +"check links". However, those are no longer divided by tabs, but as +all other modules, by different actions, which can be selected using +the corresponding dropdown in the docheader. + +Impact +====== + +The linkvalidator reports are now available in an independent backend module. + +.. index:: Backend, ext:linkvalidator diff --git a/Documentation/Changelog/12.0/Feature-96510-InfrastructureForJavaScriptModulesAndImportmaps.rst b/Documentation/Changelog/12.0/Feature-96510-InfrastructureForJavaScriptModulesAndImportmaps.rst new file mode 100644 index 0000000..8ad788f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96510-InfrastructureForJavaScriptModulesAndImportmaps.rst @@ -0,0 +1,133 @@ +.. include:: /Includes.rst.txt + +.. _feature-96510: + +====================================================================== +Feature: #96510 - Infrastructure for JavaScript modules and importmaps +====================================================================== + +See :issue:`96510` + +Description +=========== + +JavaScript ES6 modules may now be used instead of AMD modules, both in backend +and frontend context. JavaScript node-js style path resolutions are managed by +`importmaps`_, which allow web pages to control the behavior of JavaScript imports. + +By the time of writing importmaps are supported natively by Google Chrome, +a polyfill is available for Firefox and Safari and included by TYPO3 Core +and applied whenever an importmap is emitted. + +RequireJS is shimmed to prefer ES6 modules if available, allowing any extension +to ship ES6 modules by providing an importmap configuration in +:file:`Configuration/JavaScriptModules.php` while providing full backwards +compatibility support for extensions that load modules via RequireJS. + +For security reasons importmap configuration is only emitted when the modules +are actually used, that means when a module has been added to the current +page response via :php:`PageRenderer->loadJavaScriptModule()` or +:php:`JavaScriptRenderer->addJavaScriptModuleInstruction()`. +Exposing all module configurations is possible via +:php:`JavaScriptRenderer->includeAllImports()`, but that should only be +done in backend context for logged in users, to avoid disclosing installed +extensions to anonymous visitors. + +Existing RequireJS modules can load new ES6 modules via a bridge that +prefers ES6 modules over traditional RequireJS AMD modules. This allows +extensions authors to migrate to ES6 without breaking dependencies that +used to load a module of that extension via RequireJS. + +Configuration +------------- + +A simple configuration example for an extension that maps +the `Public/JavaScript` folder to an import prefix `@vendor/my-extensions`: + +.. code-block:: php + :caption: EXT:my_extension/Configuration/JavaScriptModules.php + + <?php + + return [ + // required import configurations of other extensions, + // in case a module imports from another package + 'dependencies' => ['backend'], + 'imports' => [ + // recursive definition, all *.js files in this folder are import-mapped + // trailing slash is required per importmap-specification + '@vendor/my-extension/' => 'EXT:my_extension/Resources/Public/JavaScript/', + ], + ]; + +Complex configuration example containing recursive-lookup exclusions, +third-party library definitions and overwrites: + +.. code-block:: php + :caption: EXT:my_extension/Configuration/JavaScriptModules.php + + <?php + + return [ + 'dependencies' => ['core', 'backend'], + 'imports' => [ + '@vendor/my-extension/' => [ + 'path' => 'EXT:my_extension/Resources/Public/JavaScript/', + # Exclude files of the following folders from being import-mapped + 'exclude' => [ + 'EXT:my_extension/Resources/Public/JavaScript/Contrib/', + 'EXT:my_extension/Resources/Public/JavaScript/Overrides/', + ], + ], + # Adding a third-party package + 'thirdpartypkg' => 'EXT:my_extension/Resources/Public/JavaScript/Contrib/thidpartypkg/index.js', + 'thidpartypkg/' => 'EXT:my_extension/Resources/Public/JavaScript/Contrib/thirdpartypkg/', + # Overriding a file from another package + 'TYPO3/CMS/Backend/Modal.js' => 'EXT:my_extension/Resources/Public/JavaScript/Overrides/BackendModal.js', + ], + ]; + +Usage +----- + +A module can be added to the current page response either via +:php:`PageRenderer` or as :php:`JavaScriptModuleInstruction` via +:php:`JavaScriptRenderer`: + +.. code-block:: php + + // via PageRenderer + $this->pageRenderer->loadJavaScriptModule('@vendor/my-extension/example.js'); + + // via JavaScriptRenderer + $this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction( + JavaScriptModuleInstruction::create('@vendor/my-extension/example.js') + ); + +In Fluid template the `includeJavaScriptModules` property of the +:html:`<f:be.pageRenderer>` ViewHelper may be used: + +.. code-block:: xml + + <f:be.pageRenderer + includeJavaScriptModules="{ + 0: '@vendor/my-extension/example.js' + }" + /> + +.. _`importmaps`: https://wicg.github.io/import-maps/ + +Impact +====== + +The custom module loader RequireJS will become superfluous and can be removed +in favor of native browser modules. This will speed up module loading. +Also the RequireJS system is discontinued. + +.. attention:: + + This API is considered experimental and may change until v12.0. + For example there are plans to take :file:`package.json` files into + account. + +.. index:: JavaScript, ext:core diff --git a/Documentation/Changelog/12.0/Feature-96515-AliasesForBackendRoutesAndBackendModules.rst b/Documentation/Changelog/12.0/Feature-96515-AliasesForBackendRoutesAndBackendModules.rst new file mode 100644 index 0000000..73afb9f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96515-AliasesForBackendRoutesAndBackendModules.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-96515-1657733886: + +================================================================ +Feature: #96515 - Aliases for Backend Routes and Backend Modules +================================================================ + +See :issue:`96515` + +Description +=========== + +TYPO3 Backend Module and Routing functionality now allows to define a different +route identifier (e.g. "record_edit") or module identifier (e.g. "web_layout" +as the identifier for the Page Module) while also defining aliases for any +previous identifier. + +This is especially important when a module identifier should be changed to use +a proper naming, reflecting the actual module, while keeping any links from +within TYPO3's Backend extensions - e.g. third-party - to continue to work. + +An upgrade wizard allows to continuously verify backend user and backend group +permissions when a module identifier has been changed, as long as the previous +identifier is added as an alias to the :doc:`module configuration <../12.0/Feature-96733-NewBackendModuleRegistrationAPI>`. + +Impact +====== + +The new array key :php:`aliases` in module and route configurations can be used +to provide support for different names, which ultimately allows to rename +route and module identifiers, since the old identifier can still be used to +reference them. + +Example for a new module identifier within +:file:`Configuration/Backend/Modules.php`: + +.. code-block:: php + + return [ + 'workspaces_admin' => [ + 'parent' => 'web', + ... + // choose the previous name or an alternative name + 'aliases' => ['web_WorkspacesWorkspaces'], + ], + ]; + +Example for a route alias identifier within +:file:`Configuration/Backend/Routes.php`: + +.. code-block:: php + + return [ + 'file_editcontent' => [ + 'path' => '/file/editcontent', + 'aliases' => ['file_edit'], + ], + ]; + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96526-PSR-14EventForModifyingPageModuleContent.rst b/Documentation/Changelog/12.0/Feature-96526-PSR-14EventForModifyingPageModuleContent.rst new file mode 100644 index 0000000..0fbbfe8 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96526-PSR-14EventForModifyingPageModuleContent.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _feature-96526: + +================================================================ +Feature: #96526 - PSR-14 event for modifying page module content +================================================================ + +See :issue:`96526` + +Description +=========== + +A new PSR-14 event :php:`TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed hooks :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawHeaderHook']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/db_layout.php']['drawFooterHook']`. + +Next to the :php:`getRequest()` and :php:`getModuleTemplate()` methods does +the event feature the usual getter and setter for the header and footer +content. It is therefore now possible to not just add additional content to +the module, but to also overwrite existing content or to reorder the content. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-page-module-content' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent; + + class MyEventListener { + + public function __invoke(ModifyPageLayoutContentEvent $event): void + { + $event->addHeaderContent('Additional header content'); + + $event->setFooterContent('Overwrite footer content'); + } + } + +In contrast to the removed hooks, the new event does not provide the +:php:`PageLayoutController` as :php:`$parentObject`, since :php:`getModuleTemplate()` +has been the only public method, which is now directly included in the event. + +Additionally, there were three public properties :php:`$id`, :php:`$pageInfo` +and :php:`$MOD_SETTINGS`, which however had already been marked as :php:`@internal` +in TYPO3 v9. If needed, the information can be retrieved from the request directly. + +An example to get the current :php:`$id`: + +.. code-block:: php + + public function __invoke(ModifyPageLayoutContentEvent $event): void + { + $id = (int)($event->getRequest()->getQueryParams()['id'] ?? 0); + } + +Impact +====== + +The new PSR-14 event allows to modify the content of the page module +header and footer sections in an efficient and flexible way. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions.rst b/Documentation/Changelog/12.0/Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions.rst new file mode 100644 index 0000000..f4b5856 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-96614: + +==================================================================== +Feature: #96614 - Automatic inclusion of page TSconfig of extensions +==================================================================== + +See :issue:`96614` + +Description +=========== + +Extension authors can now put a file named +:file:`Configuration/page.tsconfig` in their extension folder. + +This file is then recognized to load the contents as global page TSconfig +for the whole TYPO3 installation during build-time. This is much +more performant than the existing solution to use +:php:`ExtensionManagementUtility::addPageTSConfig()` in +:file:`ext_localconf.php`, which is added to +:php:`$TYPO3_CONF_VARS[SYS][defaultPageTSconfig]` during runtime. + +Impact +====== + +When a file is created, the page TSconfig is loaded automatically without a +custom registration anymore, and cached within the Core caches, and more +performant than the existing registration format. + +.. index:: TSConfig, ext:core diff --git a/Documentation/Changelog/12.0/Feature-96641-NewPSR-14EventForModifyingLinks.rst b/Documentation/Changelog/12.0/Feature-96641-NewPSR-14EventForModifyingLinks.rst new file mode 100644 index 0000000..57c0c2a --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96641-NewPSR-14EventForModifyingLinks.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _feature-96641: + +====================================================== +Feature: #96641 - New PSR-14 event for modifying links +====================================================== + +See :issue:`96641` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent` +is added which allows PHP developers to modify any kind of link generated +by TYPO3's mighty "typolink()" functionality. + +This PSR-14 event also supersedes the :php:`UrlProcessorInterface` logic +which allowed to modify mail URNs or external URLs, but not the +full anchor tag. + +In addition, this PSR-14 event also replaces the +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['typoLink_PostProc']` +hook which was not executed at all times, and had a cumbersome API +to modify values. + +It is also recommended to use the PSR-14 event instead of the global +getATagParams hook (:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['getATagParamsPostProc']`) +to add additional attributes (see example below) to links. + +All mentioned hooks have been :doc:`removed <../12.0/Breaking-96641-TypoLinkRelatedHooksRemoved>`. + +Impact +====== + +By using the PSR-14 event, it is possible to add attributes to links to +internal pages, or links to files, as the event contains the actual information +of the link type with it. + +As the PSR-14 event works with the :php:`LinkResultInterface` object it is possible +to modify or replace the LinkResult information instead of working with string +replacement functionality for adding, changing or removing attributes. + +To register an event listener to the new event, use the following code in your +:file:`Services.yaml`: + +.. code-block:: yaml + + services: + MyCompany\MyPackage\TypoLink\LinkModifier: + tags: + - name: event.listener + identifier: 'myLoadedListener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent; + + final class LinkModifier + { + public function __invoke(AfterLinkIsGeneratedEvent $event): void + { + $linkResult = $event->getLinkResult()->withAttribute('data-enable-lightbox', 'true'); + $event->setLinkResult($linkResult); + } + } + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-96659-ContentObjectRegistrationViaServiceConfiguration.rst b/Documentation/Changelog/12.0/Feature-96659-ContentObjectRegistrationViaServiceConfiguration.rst new file mode 100644 index 0000000..3f8c653 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96659-ContentObjectRegistrationViaServiceConfiguration.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-96659: + +====================================================================== +Feature: #96659 - ContentObject Registration via service configuration +====================================================================== + +See :issue:`96659` + +Description +=========== + +ContentObjects such as `TEXT` or `COA` for rendering content blocks in TYPO3 +Frontend via TypoScript are now registered via the service configuration. + +This way registration is done during build-time and not on every TYPO3 request +and dependency injection can be used in ContentObjects. + +The registration was previously done in an extension's :file:`ext_localconf.php` +via :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['ContentObjects']`. + +Impact +====== + +Registering a custom ContentObject is now done in an extension's +:file:`Configuration/Services.yaml`: + +.. code-block:: yaml + + MyCompany\MyPackage\ContentObject\CustomContentObject: + tags: + - name: frontend.contentobject + identifier: 'MY_OBJ' + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-96688-AttributesForExtbaseAnnotations.rst b/Documentation/Changelog/12.0/Feature-96688-AttributesForExtbaseAnnotations.rst new file mode 100644 index 0000000..7adcc41 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96688-AttributesForExtbaseAnnotations.rst @@ -0,0 +1,108 @@ +.. include:: /Includes.rst.txt + +.. _feature-96688: + +==================================================== +Feature: #96688 - Attributes for Extbase Annotations +==================================================== + +See :issue:`96688` + +Description +=========== + +Since PHP 8, native attributes are supported. In comparison to doc comments, attributes have auto-completion, +are better readable and were "invented" for storing meta-information about properties. +For more info on attributes see https://stitcher.io/blog/attributes-in-php-8 and https://www.php.net/manual/en/language.attributes.overview.php + +Extbase annotations are already nearly 1:1 translatable to attributes. + +Impact +====== + +In addition to their usage as annotations, the following Extbase annotations have been enriched for usage as attributes: + +.. code-block:: php + + @Extbase\ORM\Transient + @Extbase\ORM\Cascade + @Extbase\ORM\Lazy + @Extbase\IgnoreValidation + @Extbase\Validate + +Examples +-------- + +Transient & Lazy +++++++++++++++++ + +Annotations:: + + use TYPO3\CMS\Extbase\Annotation as Extbase; + + /** + * @Extbase\ORM\Lazy() + * @Extbase\ORM\Transient() + */ + +Attributes:: + + use TYPO3\CMS\Extbase\Annotation as Extbase; + + #[Extbase\ORM\Lazy()] + #[Extbase\ORM\Transient()] + +Cascade ++++++++ + +Annotation:: + + /** + * @Extbase\ORM\Cascade("remove") + */ + +Attribute:: + + #[Extbase\ORM\Cascade(['value' => 'remove'])] + +Validate +++++++++ + +Annotations:: + + /** + * @Extbase\Validate("StringLength", options={"minimum": 1, "maximum": 10}) + * @Extbase\Validate("NotEmpty") + * @Extbase\Validate("TYPO3.CMS.Extbase:NotEmpty") + * @Extbase\Validate("TYPO3.CMS.Extbase.Tests.Unit.Reflection.Fixture:DummyValidator") + * @Extbase\Validate("\TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator") + * @Extbase\Validate("TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator") + */ + protected $propertyWithValidateAnnotations; + +Attributes:: + + #[Extbase\Validate(['validator' => 'StringLength', 'options' => ['minimum' => 1, 'maximum' => 10]])] + #[Extbase\Validate(['validator' => 'NotEmpty'])] + #[Extbase\Validate(['validator' => 'TYPO3.CMS.Extbase:NotEmpty'])] + #[Extbase\Validate(['validator' => 'TYPO3.CMS.Extbase.Tests.Unit.Reflection.Fixture:DummyValidator'])] + #[Extbase\Validate(['validator' => '\TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator'])] + #[Extbase\Validate(['validator' => NotEmptyValidator::class])] + protected $propertyWithValidateAttributes; + +With promoted properties in constructor:: + + public function __construct( + #[Extbase\Validate(['validator' => 'StringLength', 'options' => ['minimum' => 1, 'maximum' => 10]])] + #[Extbase\Validate(['validator' => 'NotEmpty'])] + #[Extbase\Validate(['validator' => 'TYPO3.CMS.Extbase:NotEmpty'])] + #[Extbase\Validate(['validator' => 'TYPO3.CMS.Extbase.Tests.Unit.Reflection.Fixture:DummyValidator'])] + #[Extbase\Validate(['validator' => '\TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator'])] + #[Extbase\Validate(['validator' => NotEmptyValidator::class])] + public readonly string $dummyPromotedProperty + ) + { + // your code here + } + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-96730-SimplifiedExtbackendModuleTemplateAPI.rst b/Documentation/Changelog/12.0/Feature-96730-SimplifiedExtbackendModuleTemplateAPI.rst new file mode 100644 index 0000000..b8f3b06 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96730-SimplifiedExtbackendModuleTemplateAPI.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _feature-96730: + +=========================================================== +Feature: #96730 - Simplified ext:backend ModuleTemplate API +=========================================================== + +See :issue:`96730` + +Description +=========== + +Extensions that deliver own backend modules can now use the +:php:`TYPO3\CMS\Backend\Template\ModuleTemplate` class as +view for the 'body' part of the view and do not need to +instantiate an own view anymore. + +The default document header of :php:`ModuleTemplate` can be rendered +using a new Fluid layout named `Module`. + +Impact +====== + +The standard code within backend module related controllers +looked like this until now: + +.. code-block:: php + + $moduleTemplate = $this->moduleTemplateFactory->create($request); + $view = GeneralUtility::makeInstance(StandaloneView::class); + $view->setTemplateRootPaths(['EXT:my_extension/Resources/Private/Templates']); + $view->assign('aVariable', 'aValue'); + $moduleTemplate->setContent($view->render('MyTemplate')); + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($this->streamFactory->createStream($moduleTemplate->renderContent($templateFileName))); + +This can be streamlined as shown below. Template paths (Templates, Layouts, Partials) +are configured automatically, calling :php:`renderResponse('SomeController/SomeAction')` will look for file +:file:`Resources/Private/Templates/SomeController/SomeAction.html`. Templates can be +overridden by other extensions using page TSconfig, see :doc:`this changelog entry <Feature-96812-OverrideBackendTemplatesWithTSconfig>` +for details on this. + +.. code-block:: php + + $moduleTemplate = $this->moduleTemplateFactory->create($request); + $moduleTemplate->assign('aVariable', 'aValue'); + return $moduleTemplate->renderResponse('MyTemplate'); + +The HTML template should then reference the ModuleTemplate layout: + +.. code-block:: html + + <html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true"> + <f:layout name="Module" /> + <f:section name="Content"> + My body content + </f:section> + </html> + +.. index:: Backend, Fluid, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96733-NewBackendModuleRegistrationAPI.rst b/Documentation/Changelog/12.0/Feature-96733-NewBackendModuleRegistrationAPI.rst new file mode 100644 index 0000000..156fd93 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96733-NewBackendModuleRegistrationAPI.rst @@ -0,0 +1,448 @@ +.. include:: /Includes.rst.txt + +.. _feature-96733: + +===================================================== +Feature: #96733 - New backend module registration API +===================================================== + +See :issue:`96733` + +Description +=========== + +The registration and usage of backend modules was previously based +on the global array :php:`$TBE_MODULES`. This however had a couple +of drawbacks, e.g. module registration could be changed at runtime, +which had been resolved by introducing a new registration API. + +Therefore, instead of using the :php:`ExtensionManagementUtility::addModule()` +and :php:`ExtensionUtility::registerModule()` (Extbase) API methods in +:file:`ext_tables.php` files, the configuration is now placed in the +dedicated :file:`Configuration/Backend/Modules.php` configuration file. + +Those files are then read and processed when building the container. This +means the state is fixed and can't be changed at runtime. This approach +follows the general Core strategy (see e.g. :doc:`Icons.php <../11.4/Feature-94692-RegisteringIconsViaServiceContainer>`), +since it highly improves the loading speed of every request as the +registration can be handled at once and cached during warmup of the +Core caches. Besides caching, this will also allow additional features +in the future, which were blocked due to the loose state. + +Previous configuration in :file:`ext_tables.php`: + +.. code-block:: php + + ExtensionManagementUtility::addModule( + 'web', + 'example', + 'top', + '', + [ + 'routeTarget' => MyExampleModuleController::class . '::handleRequest', + 'name' => 'web_example', + 'access' => 'admin', + 'workspaces' => 'online', + 'iconIdentifier' => 'module-example', + 'labels' => 'LLL:EXT:example/Resources/Private/Language/locallang_mod.xlf', + 'navigationComponentId' => 'TYPO3/CMS/Backend/PageTree/PageTreeElement', + ] + ); + + ExtensionUtility::registerModule( + 'Extkey', + 'web', + 'example', + 'after:info', + [ + MyExtbaseExampleModuleController::class => 'list, detail', + ], + [ + 'access' => 'admin', + 'workspaces' => 'online', + 'iconIdentifier' => 'module-example', + 'labels' => 'LLL:EXT:extkey/Resources/Private/Language/locallang_mod.xlf', + 'navigationComponentId' => 'TYPO3/CMS/Backend/PageTree/PageTreeElement', + ] + ); + +Will now be registered in :file:`Configuration/Backend/Modules.php`: + +.. code-block:: php + + return [ + 'web_module' => [ + 'parent' => 'web', + 'position' => ['before' => '*'], + 'access' => 'admin', + 'workspaces' => 'live', + 'path' => '/module/web/example', + 'iconIdentifier' => 'module-example', + 'navigationComponent' => 'TYPO3/CMS/Backend/PageTree/PageTreeElement', + 'labels' => 'LLL:EXT:example/Resources/Private/Language/locallang_mod.xlf', + 'routes' => [ + '_default' => [ + 'target' => MyExampleModuleController::class . '::handleRequest', + ], + ], + ], + 'web_ExtkeyExample' => [ + 'parent' => 'web', + 'position' => ['after' => 'web_info'], + 'access' => 'admin', + 'workspaces' => 'live', + 'iconIdentifier' => 'module-example', + 'path' => '/module/web/ExtkeyExample', + 'labels' => 'LLL:EXT:beuser/Resources/Private/Language/locallang_mod.xlf', + 'extensionName' => 'Extkey', + 'controllerActions' => [ + MyExtbaseExampleModuleController::class => [ + 'list', + 'detail' + ], + ], + ], + ]; + +.. note:: + + Each modules array key is used as the module identifier, which + will also be the route identifier. It's no longer necessary to + use the `mainModule_subModule` pattern, since a possible parent + will be defined with the `parent` option. + +Module configuration options +============================ + ++----------------------------------------------------------+------------------------------------------------------------------+ +| Option | Description | ++==========================================================+==================================================================+ +| parent (:php:`string`) | If the module should be a submodule, the parent identifier, e.g. | +| | `web` has to be set here. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| path (:php:`string`) | Define the path to the default endpoint. The path can be | +| | anything, but will fallback to the known | +| | `/module/<mainModule>/<subModule>` pattern, if not set. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| standalone (:php:`bool`) | Whether the module is a standalone module (parent without | +| | submodules). | ++----------------------------------------------------------+------------------------------------------------------------------+ +| access (:php:`string`) | Can be `user` (editor permissions), `admin`, or | +| | `systemMaintainer`. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| workspaces (:php:`string`) | Can be `*` (= always), `live` or `offline`. If not set, the | +| | value of the parent module - if any - is used. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| position (:php:`array`) | The module position. Allowed values are `before => <identifier>` | +| | and `after => <identifier>`. To define modules on top or at the | +| | bottom, `before => *` and `after => *` can be used. Using the | +| | `top` and `bottom` values (without key) is deprecated and will | +| | be removed in upcoming versions. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| appearance (:php:`array`) | Allows to define additional appearance options: | +| | | +| | - `renderInModuleMenu` (:php:`bool`) | ++----------------------------------------------------------+------------------------------------------------------------------+ +| iconIdentifier (:php:`string`) | The module icon identifier | ++----------------------------------------------------------+------------------------------------------------------------------+ +| icon (:php:`string`) | Path to a module icon (Deprecated: Use `iconIdentifier` instead) | ++----------------------------------------------------------+------------------------------------------------------------------+ +| labels (:php:`array` or :php:`string`) | An :php:`array` with the following keys: | +| | | +| | - `title` | +| | - `description` | +| | - `shortDescription` | +| | | +| | The value can either be a static string or a locallang label | +| | reference. | +| | | +| | It's also possible to define the path to a locallang file. | +| | The referenced file should contain the following label keys: | +| | | +| | - `mlang_tabs_tab` (Used as module title) | +| | - `mlang_labels_tabdescr` (Used as module description) | +| | - `mlang_labels_tablabel` (Used as module short description) | ++----------------------------------------------------------+------------------------------------------------------------------+ +| component (:php:`string`) | The view component, responsible for rendering the module. | +| | Defaults to `TYPO3/CMS/Backend/Module/Iframe`. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| navigationComponent (:php:`string`) | The module navigation component, e.g. | +| | `TYPO3/CMS/Backend/PageTree/PageTreeElement`. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| navigationComponentId (:php:`string`) | The module navigation component (Deprecated: Use | +| | `navigationComponent` instead). | ++----------------------------------------------------------+------------------------------------------------------------------+ +| inheritNavigationComponentFromMainModule (:php:`bool`) | Whether the module should use the parents navigation component. | +| | This option defaults to :php:`true` and can therefore be used to | +| | stop the inheritance for submodules. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| moduleData (:php:`array`) | The allowed module data properties and their default value. | +| | Module data are the module specific settings of a backend user. | +| | The properties, defined in the registration, can be overwritten | +| | in a request via :php:`GET` or :php:`POST`. For more information | +| | about the usage of this option, see the corresponding | +| | :doc:`changelog <Feature-96895-IntroduceModuleDataObject>`. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| aliases (:php:`array`) | List of identifiers that are aliases to this module. Those are | +| | added as route aliases, which allows to use them for building | +| | links, e.g. with the :php:`UriBuilder`. Additionally, the | +| | aliases can also be used for references in other modules, e.g. | +| | to specify a modules' :php:`parent`. | ++----------------------------------------------------------+------------------------------------------------------------------+ +| routeOptions (:php:`array`) | Generic side information that will be merged with each generated | +| | `\TYPO3\CMS\Backend\Routing\Route::$options` array. This can be | +| | used for information, that is not relevant for a module aspect, | +| | but more relevant for the routing aspect (e.g. sudo-mode). | ++----------------------------------------------------------+------------------------------------------------------------------+ + +Module-dependent configuration options +-------------------------------------- + +Default: + ++----------------------------+---------------------------------------------------------------------+ +| Option | Description | ++============================+=====================================================================+ +| routes (:php:`array`) | Define the routes to this module. Each route requires at least the | +| | `target`. The `_default` route is mandatory, except for modules, | +| | which can fall back to a sub module. The `_default` routes `path` | +| | is taken from the top-level configuration. For all other routes | +| | is the route identifier taken as `path`, if not explicitly defined. | +| | Each route can define any controller / action pair and can restrict | +| | the allowed HTTP methods:: | +| | | +| | 'routes' => [ | +| | '_default' => [ | +| | 'target' => ControllerA::class . '::handleRequest', | +| | ], | +| | 'edit' => [ | +| | 'path' => '/edit-me', | +| | 'target' => ControllerA::class . '::edit', | +| | ], | +| | 'manage' => [ | +| | 'target' => ControllerB::class . '::manage', | +| | 'methods' => ['POST'], | +| | ], | +| | ], | +| | | ++----------------------------+---------------------------------------------------------------------+ + +Extbase: + ++----------------------------------+---------------------------------------------------------------+ +| Option | Description | ++==================================+===============================================================+ +| extensionName (:php:`string`) | The extension name, the module is registered for. | ++----------------------------------+---------------------------------------------------------------+ +| controllerActions (:php:`array`) | Define the controller action pair. The array keys are the | +| | controller class names and the values are the actions, which | +| | can either be defined as array or comma-separated list:: | +| | | +| | 'controllerActions' => [ | +| | Controller::class => [ | +| | 'aAction', 'anotherAction', | +| | ], | +| | ], | ++----------------------------------+---------------------------------------------------------------+ + +The BeforeModuleCreationEvent +============================= + +The new PSR-14 :php:`BeforeModuleCreationEvent` allows extension authors +to manipulate the module configuration, before it is used to create and +register the module. + +Registration of an event listener in the :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\ModifyModuleIcon: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-module-icon' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Module\BeforeModuleCreationEvent; + + class ModifyModuleIcon { + + public function __invoke(BeforeModuleCreationEvent $event): void + { + // Change module icon of page module + if ($event->getIdentifier() === 'web_layout') { + $event->setConfigurationValue('iconIdentifier', 'my-custom-icon-identifier'); + } + } + } + +BeforeModuleCreationEvent methods +--------------------------------- + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getIdentifier() | | Returns the identifier of the module in question. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getConfiguration() | | Get the module configuration, as defined in the | +| | | :file:`Configuration/Backend/Modules.php` file. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setConfiguration() | :php:`$configuration` | Overrides the module configuration. | ++-------------------------+-----------------------+----------------------------------------------------+ +| hasConfigurationValue() | :php:`$key` | Checks whether the given key is set. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getConfigurationValue() | :php:`$key` | Returns the value for the given :php:`$key`, or | +| | :php:`$default` | the :php:`$default`, if not set. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setConfigurationValue() | :php:`$key` | Updates the configuration :php:`$key` with the | +| | :php:`$value` | given :php:`value`. | ++-------------------------+-----------------------+----------------------------------------------------+ + +New ModuleProvider API +======================= + +The other piece is the new :php:`ModuleProvider` API, which allows extension +authors to work with the registered modules in a straightforward way. + +Previously, there had been a couple of different classes and methods, which +did mostly the same but in other ways. Also handling of those classes had +been tough, especially the :php:`ModuleLoader` component. + +See :doc:`changelog <../12.0/Breaking-96733-RemovedSupportForModuleHandlingBasedOnTBE_MODULES>` +for all removed classes and methods. + +The new API is now the central point to retrieve modules, since it will +automatically perform necessary access checks and prepare specific structures, +e.g. for the use in menus. + +ModuleProvider API methods +-------------------------- + ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| Method | Parameters | Description | ++===========================+======================================+==========================================================+ +| isModuleRegistered() | :php:`$identifier` | Checks whether a module is registered for the given | +| | | identifier. Does NOT perform any access check! | ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| getModule() | :php:`$identifier` | Returns a module for the given identifier. In case a | +| | :php:`$user` | user is given, also access checks are performed. | +| | :php:`$respectWorkspaceRestrictions` | Additionally, one can define whether workspace | +| | | restrictions should be respected. | ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| getModules() | :php:`$user` | Returns all modules either grouped by main modules | +| | :php:`$respectWorkspaceRestrictions` | or flat. In case a user is given, also access checks | +| | :php:`$grouped` | are performed. Additionally, one can define whether | +| | | workspace restrictions should be respected. | ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| getModuleForMenu() | :php:`$identifier` | Returns the requested main module prepared for | +| | :php:`$user` | menu generation or similar structured output (nested), | +| | :php:`$respectWorkspaceRestrictions` | if it exists and the user has necessary permissions. | +| | | Additionally, one can define whether workspace | +| | | restrictions should be respected. | ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| getModulesForModuleMenu() | :php:`$user` | Returns all allowed modules for the current user, | +| | :php:`$respectWorkspaceRestrictions` | prepared for module menu generation or similar | +| | | structured output (nested). Additionally, one can define | +| | | whether workspace restrictions should be respected. | ++---------------------------+--------------------------------------+----------------------------------------------------------+ +| accessGranted() | :php:`$identifier` | Check access of a module for a given user. Additionally, | +| | | one can define whether workspace restrictions should | +| | | be respected. | ++---------------------------+--------------------------------------+----------------------------------------------------------+ + +ModuleInterface +=============== + +Instead of a global array structure, the registered modules are stored as +objects in a registry. The module objects implement all the :php:`ModuleInterface`. +This allows a well-defined OOP-based approach to work with registered models. + +The :php:`ModuleInterface` basically provides getters for the options, +defined in the module registration and additionally provides methods for +relation handling (main modules and submodules). + ++---------------------------+--------------------------+-----------------------------------------------+ +| Method | Return type | Description | ++===========================+==========================+===============================================+ +| getIdentifier() | :php:`string` | Returns the internal name of the module, | +| | | used for referencing in permissions etc. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getPath() | :php:`string` | Returns the module main path | ++---------------------------+--------------------------+-----------------------------------------------+ +| getIconIdentifier() | :php:`$string` | Returns the module icon identifier | ++---------------------------+--------------------------+-----------------------------------------------+ +| getTitle() | :php:`string` | Returns the module title (see: | +| | | `mlang_tabs_tab`). | ++---------------------------+--------------------------+-----------------------------------------------+ +| getDescription() | :php:`string` | Returns the module description (see: | +| | | `mlang_labels_tabdescr`). | ++---------------------------+--------------------------+-----------------------------------------------+ +| getShortDescription() | :php:`string` | Returns the module short description (see: | +| | | `mlang_labels_tablabel`). | ++---------------------------+--------------------------+-----------------------------------------------+ +| isStandalone() | :php:`bool` | Returns, whether the module is standalone | +| | | (main module without submodules). | ++---------------------------+--------------------------+-----------------------------------------------+ +| getComponent() | :php:`string` | Returns the view component responsible for | +| | | rendering the module (iFrame or name of the | +| | | web component). | ++---------------------------+--------------------------+-----------------------------------------------+ +| getNavigationComponent() | :php:`string` | Returns the web component to be rendering the | +| | | navigation area. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getPosition() | :php:`array` | Returns the position of the module, such as | +| | | `top` or `bottom` or `after => anotherModule` | +| | | or `before => anotherModule`. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getAppearance() | :php:`array` | Returns a modules' appearance options, e.g. | +| | | used for module menu. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getAccess() | :php:`string` | Returns defined access level, can be `user`, | +| | | `admin` or `systemMaintainer`. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getWorkspaceAccess() | :php:`string` | Returns defined workspace access, can be `*` | +| | | (all), `live` or `offline`. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getParentIdentifier() | :php:`string` | In case this is a submodule, returns the | +| | | parent module identifier. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getParentModule() | :php:`?ModuleInterface` | In case this is a submodule, returns the | +| | | parent module. | ++---------------------------+--------------------------+-----------------------------------------------+ +| hasParentModule() | :php:`bool` | Returns whether the module has a parent | +| | | module defined (is a submodule). | ++---------------------------+--------------------------+-----------------------------------------------+ +| hasSubModule($identifier) | :php:`bool` | Returns whether the module has a specific | +| | | submodule assigned. | ++---------------------------+--------------------------+-----------------------------------------------+ +| hasSubModules() | :php:`bool` | Returns whether the module has submodules | +| | | assigned. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getSubModule($identifier) | :php:`?ModuleInterface` | If set, returns the requested submodule. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getSubModules() | :php:`ModuleInterface[]` | Returns all assigned submodules. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getDefaultRouteOptions() | :php:`array` | Returns options to be added to the main | +| | | module route. Usually `module`, `moduleName` | +| | | and `access`. | ++---------------------------+--------------------------+-----------------------------------------------+ +| getAliases() | :php:`array` | List of identifiers (e.g. to an old name of | +| | | the module), which is also used to link and | +| | | reference in access checks. | ++---------------------------+--------------------------+-----------------------------------------------+ + +Impact +====== + +Registration of backend modules is now done in extension's +:file:`Configuration/Backend/Modules.php` file. This allows +to have all modules registered at build-time. + +The new :php:`ModuleProvider` API takes care of permission handling +and returns objects based on the :php:`ModuleInterface`. The rendering +is now based on a well-defined OOP-based approach, which is used throughout +all places in TYPO3 Backend now in a unified way. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96800-AddSiteLanguageProcessor.rst b/Documentation/Changelog/12.0/Feature-96800-AddSiteLanguageProcessor.rst new file mode 100644 index 0000000..52fa479 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96800-AddSiteLanguageProcessor.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-96800: + +=========================================== +Feature: #96800 - Add SiteLanguageProcessor +=========================================== + +See :issue:`96800` + +Description +=========== + +A new Data Processor :php:`SiteLanguageProcessor` has been introduced, which +can be used to fetch the properties of the current SiteLanguage within Fluid +Templates in TYPO3 Frontend rendering: + +.. code-block:: typoscript + + tt_content.mycontent.20 = FLUIDTEMPLATE + tt_content.mycontent.20 { + file = EXT:myextension/Resources/Private/Templates/ContentObjects/MyContent.html + + dataProcessing.10 = TYPO3\CMS\Frontend\DataProcessing\SiteLanguageProcessor + dataProcessing.10 { + as = language + } + } + +In the Fluid template the properties of the SiteLanguage entity can be accessed: + +.. code-block:: html + + <p>{language.languageId}</p> + <p>{language.customValue}</p> + +.. index:: Fluid, Frontend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-96806-PSR-14EventForModifyingButtonBar.rst b/Documentation/Changelog/12.0/Feature-96806-PSR-14EventForModifyingButtonBar.rst new file mode 100644 index 0000000..8c3f15d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96806-PSR-14EventForModifyingButtonBar.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-96806: + +======================================================= +Feature: #96806 - PSR-14 event for modifying button bar +======================================================= + +See :issue:`96806` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent` +has been introduced. It serves as a direct replacement for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend\Template\Components\ButtonBar']['getButtonsHook']`. + +It can be used to modify the button bar in the TYPO3 backend module docheader. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Frontend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/frontend/modify-button-bar' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent; + + class MyEventListener { + + public function __invoke(ModifyButtonBarEvent $event): void + { + // Do your magic here + } + } + +Impact +====== + +It's now possible to modify the TYPO3 backend button bar, using the +new PSR-14 event. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96812-OverrideBackendTemplatesWithTSconfig.rst b/Documentation/Changelog/12.0/Feature-96812-OverrideBackendTemplatesWithTSconfig.rst new file mode 100644 index 0000000..a98cde8 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96812-OverrideBackendTemplatesWithTSconfig.rst @@ -0,0 +1,127 @@ +.. include:: /Includes.rst.txt + +.. _feature-96812: + +========================================================== +Feature: #96812 - Override backend templates with TSconfig +========================================================== + +See :issue:`96812` + +Description +=========== + +Introduction +------------ + +All Fluid templates rendered by backend controllers can be overridden with +own templates on a per-file basis. + +This can be configured using TSconfig: Both page TSconfig and user TSconfig are +observed. The feature is available for basically all Core backend modules, as +well as the backend main frame templates. Exceptions are email templates and +templates of the install tool. + +This feature was previously available in a similar way for some specifically +crafted backend controllers, namely the dashboard extension and the page module. +It was based on frontend TypoScript in combination with Extbase magic. This has +been superseded by the new TSconfig based approach. Instances using the old +solution need an adaption. Please find details in the +:doc:`changelog entry<Breaking-96812-NoFrontendTypoScriptBasedTemplateOverridesInTheBackend>`. + +.. note:: + + While this feature is powerful and allows overriding nearly any backend + template, it *should be used with care*: Fluid templates of the Core + extensions are *not* considered API. The Core development needs the + freedom to add, change and delete Fluid templates any time, even for + bugfix releases. Template overrides are similar to an :php:`XCLASS` in + PHP - the Core can not guarantee integrity on this level across versions. + +Basic syntax +------------ + +The various combinations are best explained by example: The linkvalidator +extension (its Composer name is "typo3/cms-linkvalidator") comes with a backend +module in the :guilabel:`Web` main section. The page tree is displayed for this module and +linkvalidator has two main views and templates: :file:`Resources/Private/Templates/Backend/Report.html` +for the "Report" view and another one for the "Check link" view. To override +the :file:`Backend/Report.html` with an own template, this definition can +be added to an extension's :file:`Configuration/page.tsconfig` file +(see :doc:`changelog <Feature-96614-AutomaticInclusionOfPageTsConfigOfExtensions>`): + +.. code-block:: typoscript + + # Pattern: templates."composer-name"."something-unique" = "overriding-extension-composer-name":"entry-path" + templates.typo3/cms-linkvalidator.1643293191 = my-vendor/my-extension:Resources/Private/TemplateOverrides + +When the target extension identified by its Composer name "my-vendor/my-extension" provides the file +:file:`Resources/Private/TemplateOverrides/Templates/Backend/Report.html`, **this** file will +be used instead of the default template file from the linkvalidator extension. + +All Core extensions stick to the general templates, layouts and partial file and directory position structure. +When an extension needs to override a partial that is located in :file:`Resources/Private/Partials/SomeName/SomePartial.html`, +and an override has been specified like above to :typoscript:`my-vendor/my-extension:Resources/Private/TemplateOverrides`, the +system will look for file :file:`Resources/Private/TemplateOverrides/Partials/SomeName/SomePartial.html`. Similar for layouts. + +The path part of the override definition can be set to whatever an integrator prefers, +:file:`Resources/Private/TemplateOverrides` is just an idea here and hopefully not a bad one, +further details rely on additional needs. For instance, it is probably a good idea to include +the Composer or extension name of the source extension in the path (linkvalidator in our example), +or when using overrides based on page IDs or group IDs, to include those in the path. The source +extension sub-path is automatically added by the system when looking for override files, when +a layout file is located at :file:`Resources/Private/Layouts/ExtraLarge/Main.html`, and an +override definition uses path :file:`Resources/Private/TemplateOverrides`, the system +will look up :file:`Resources/Private/TemplateOverrides/Layouts/ExtraLarge/Main.html`. + +Templates overrides are based on file existence: Two files are never merged. An override definition +either kicks in because it actually supplies a file at the correct position with the correct file name, +or it doesn't and the default is used. This can become unhandy for big template files. In such cases +it might be an option to request a split of a big template file into smaller partial files, so an +extension can override a dedicated partial only. + +When multiple override paths are defined and more than one of them have overrides for a specific +template, the override definition with the highest numerical value wins: + +.. code-block:: typoscript + + templates.typo3/cms-linkvalidator.23 = other-vendor/other-extension:Resources/Private/TemplateOverrides/Linkvalidator + templates.typo3/cms-linkvalidator.2300 = my-vendor/my-extension:Resources/Private/MyOverrideIsBigger + +Due to the nature of TSconfig, and its two shapes page TSconfig and user TSconfig, +various combinations are possible: + +* Define "global" overrides with page TSconfig in :file:`page.tsconfig` of an extension. + This works for all modules, no matter if the module renders a page tree or not. +* Define overrides on page level using the :sql:`TSconfig` field of page records. As + always with page TSconfig, sub pages and sub trees inherit these settings from + parent pages. +* Define overrides on user or (better) group level. As always, User TSconfig can override + page TSconfig by prefixing any setting available as page TSconfig with :typoscript:`page.` + in user TSconfig. So a user TSconfig template override starts with :typoscript:`page.templates.` + instead of :typoscript:`templates.`. + +Usage in own modules +-------------------- + +Extensions with backend modules that use the :doc:`Simplified backend module +template API <Feature-96730-SimplifiedExtbackendModuleTemplateAPI>` automatically +enable the general backend template override feature. Extension authors do not +need to further prepare their extensions to allow template overrides by other extensions. + +Impact +====== + +Third-party or custom extensions like a site extension can now change backend +templates if needed. This can be handy, for instance, to give editors custom +hints in certain areas without custom PHP code, or to do some other quick solutions. + +Some Core extensions like the dashboard also use this feature when third-party extensions +supply additional widgets with templates to register those templates into the dashboard +namespace. See the dashboard extension documentation and :doc:`this changelog <Breaking-96812-NoFrontendTypoScriptBasedTemplateOverridesInTheBackend>` +for more details. + +This feature needs to be used with care since the Core does not +consider templates as API and a template override may thus break anytime. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96874-CKEditor5.rst b/Documentation/Changelog/12.0/Feature-96874-CKEditor5.rst new file mode 100644 index 0000000..88cf97c --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96874-CKEditor5.rst @@ -0,0 +1,187 @@ +.. include:: /Includes.rst.txt + +.. _feature-96874-1664488673: + +============================ +Feature: #96874 - CKEditor 5 +============================ + +See :issue:`96874` + +Description +=========== + +TYPO3 v12 ships with CKEditor 5, a Rich-Text Editor to edit fields where +custom formatting for text with styling or links, or table formatting can be +achieved. + +CKEditor 5 is a completely rewritten and new editor compared to CKEditor 4, +which was shipped since TYPO3 v8. + +In general, most of the feature-set can be used in TYPO3 as before, with some +details kept in mind when upgrading. + +Please read the documentation on the conceptual changes between CKEditor 4 and +CKEditor 5: + +https://ckeditor.com/docs/ckeditor5/latest/installation/getting-started/migration-from-ckeditor-4.html + +Impact +====== + +Next to plugins, which are not compatible anymore due to a completely different +model architecture, some configuration options have been modified or do not +apply anymore. + +Most of the RTE configuration, which is done in TYPO3 in YAML preset files, +is migrated, however it is recommended to rewrite any custom configuration files +to become familiar with the CKEditor 5 API. + +CSS Styling +----------- + +CKEditor 5 does not load its editor in a specific iframe anymore. Especially +for adding custom styling and fonts, all CSS declarations now need to be prefixed +with ".ck-content". This scoping is applied by TYPO3 automatically to all custom +CSS styles. + +Please be aware that referenced CSS stylesheets need to be downloadable via +:js:`fetch()` in order for the JavaScript based prefixing to work. + +Configuration Options +--------------------- + +Some options have been adapted, which are rarely used, but now documented here: + +* editor.config.defaultContentLanguage is migrated to editor.config.language.content +* editor.config.defaultLanguage is migrated to editor.config.language.ui + +The following options are not needed anymore in CKEditor 5: + +* editor.config.uiColor +* editor.config.removeDialogTabs +* editor.config.entities_latin +* editor.config.entities +* editor.config.extraAllowedContent (migrated to editor.config.htmlSupport, covered via GeneralHTMLSupport plugin) +* :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['rte_ckeditor']['plugins']['TYPO3Link']['additionalAttributes']` + +More migration options can be found here: +https://ckeditor.com/docs/ckeditor5/latest/installation/getting-started/migration-from-ckeditor-4.html + +Custom configuration to the WordCount plugin is automatically migrated +from `editor.config.wordcount` to `editor.config.wordCount`: + +* `editor.config.justifyClasses` was used to add classes to the alignment types, + which is migrated to `editor.config.alignment`. Example: + + .. code-block:: yaml + + alignment: + options: + - { name: 'left', className: 'text-start' } + - { name: 'center', className: 'text-center' } + - { name: 'right', className: 'text-end' } + - { name: 'justify', className: 'text-justify' } + + In addition, the extraPlugins `justify` is not needed anymore. The new + plugin called `Alignment` is always active. + +* `editor.config.format_tags` was used to populate various block-level elements + with a syntax like `p;h1;h2;h3;h4;h5;pre`. This is now moved to `editor.config.heading`: + + .. code-block:: yaml + + heading: + options: + - { model: 'paragraph', title: 'Paragraph' } + - { model: 'heading2', view: 'h2', title: 'Heading 2' } + - { model: 'heading3', view: 'h3', title: 'Heading 3' } + - { model: 'formatted', view: 'pre', title: 'My Pre-Formatted Text' } + +* `editor.config.removeButtons` items have a different naming now, and + are moved to `editor.config.toolbar.removeItems`. This is however not needed + anymore since toolbarGroups are removed and each button can now be declared + properly. + +* `editor.config.stylesSet` which is used for the dropdown of custom + style elements, is moved to `editor.config.style.definitions` + with a similar syntax. + + .. code-block:: yaml + + style: + definitions: + # block level styles + - { name: "Lead", element: "p", classes: ['lead'] } + - { name: "Multiple", element: "p", classes: ['first', 'second'] } + - { name: "Small", element: "small" } + # Inline styles + - { name: "Muted", element: "span", classes: ['text-muted'] } + + Please note that as of today, the "classes" attribute must be used, + and custom "style" attribute is no longer supported. Also note that an empty + class list is migrated to `classes: ['']` and will render `class=""`, as + CKEditor 5 internals require this attribute to be set. + +* `editor.config.toolbarGroups` was previously used to create the buttons in the + toolbar. This was used in conjunction with `editor.config.removeButtons`. + Grouping is no longer available, but instead all buttons are listed + separately with minor naming changes. + The new option is now named `editor.config.toolbar` with `items` and + `removeItems` as possible lists of buttons to show or hide. + + Functionality like "Cut/Copy/Paste" is now implicitly built-in without the + need of cluttering the toolbar. + + Example from TYPO3's "Full" RTE configuration Yaml file: + + .. code-block:: yaml + + toolbar: + items: + - clipboard + - undo + - redo + # grouping separator + - '|' + - find + - selectAll + - '|' + - Link + - SoftHyphen + - insertTable + - tableColumn + - tableRow + - mergeTableCells + - '|' + - sourceEditing + - horizontalLine + # line break + - '-' + - bold + - italic + - underline + - strikethrough + - subscript + - superscript + - alignment + - removeFormat + - '|' + - bulletedList + - numberedList + - blockQuote + - indent + - outdent + - '|' + - specialCharacters + - '-' + - style + - heading + + Removal of single buttons via `editor.config.removeButtons` is now of limited + need, however a list of `editor.config.toolbar.removeItems` can be given. + +CKEditor 5 integration is still experimental and subject to change to adapt +to further needs until TYPO3 v12 LTS. + +.. index:: RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.0/Feature-96879-NewPSR-14EventModifyCacheLifetimeForPageEvent.rst b/Documentation/Changelog/12.0/Feature-96879-NewPSR-14EventModifyCacheLifetimeForPageEvent.rst new file mode 100644 index 0000000..e554d38 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96879-NewPSR-14EventModifyCacheLifetimeForPageEvent.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-96879-1663513042: + +================================================================== +Feature: #96879 - New PSR-14 event ModifyCacheLifetimeForPageEvent +================================================================== + +See :issue:`96879` + +Description +=========== + +A new PSR-14 event :php:`ModifyCacheLifetimeForPageEvent` has been introduced. +This event serves as a successor for the +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['get_cache_timeout']` +hook. + +Impact +====== + +The new event allows to modify the lifetime how long a rendered page of a +frontend call should be stored in the "pages" cache. + +Common registration and usage for a listener: + +.. code-block:: yaml + + services: + MyCompany\MyPackage\EventListener\ChangeCacheTimeout: + tags: + - name: event.listener + identifier: 'mycompany/mypackage/cache-timeout' + +.. code-block:: php + + <?php + + namespace MyCompany\MyPackage\EventListener; + + class ChangeCacheTimeout + { + public function __invoke(ModifyCacheLifetimeForPageEvent $event): void + { + // Only cache all pages for 30 seconds when in development context + if (Environment::getContext()->isDevelopment()) { + $event->setCacheLifetime(30); + } + } + } + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-96895-IntroduceModuleDataObject.rst b/Documentation/Changelog/12.0/Feature-96895-IntroduceModuleDataObject.rst new file mode 100644 index 0000000..77a5405 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96895-IntroduceModuleDataObject.rst @@ -0,0 +1,125 @@ +.. include:: /Includes.rst.txt + +.. _feature-96895: + +============================================== +Feature: #96895 - Introduce Module data object +============================================== + +See :issue:`96895` + +Description +=========== + +To further improve the handling of TYPO3 backend modules, the module +registration API, introduced in :issue:`96733`, is extended by the new +:php:`TYPO3\CMS\Backend\Module\ModuleData` object. + +The :php:`ModuleData` object contains the user specific module settings, +e.g. whether the clipboard is shown, for the requested module. Those settings +are fetched from the users' session. A PSR-15 middleware does automatically +create the object from the stored user data and attach it to the PSR-7 Request. + +Through the module registration one can define, which properties can +be overwritten via :php:`GET` / :php:`POST` and their default value. + +The whole determination is done before the requested route target - usually +a backend controller - is called. This means, the route target can just +read the final module data and does no longer have to fiddle around with +overwriting and persisting the data manually. + +Previously, reading, overwriting and persisting of module data (settings) +was done in the controller: + +.. code-block:: php + + // Classes/Controller/MyController.php + + $MOD_MENU = [ + 'allowedProperty' => '', + 'anotherAllowedProperty' => true + ]; + + $MOD_SETTINGS = BackendUtility::getModuleData( + $MOD_MENU, + $request->getParsedBody()['SET'] ?? $request->getQueryParams()['SET'] ?? [], + 'my_module' + ); + +This is now automatically done by a new PSR-15 middleware. The "allowed" +properties are defined with their default value in the module registration: + +.. code-block:: php + + // Configuration/Backend/Modules.php + + 'moduleData' => [ + 'allowedProperty' => '', + 'anotherAllowedProperty' => true, + ], + + // Classes/Controller/MyController.php + + $MOD_SETTINGS = $request->getAttribute('moduleData'); + +The :php:`ModuleData` object provides the following methods: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| createFromModule() | :php:`$module` | Create a new object for the given module, while | +| | :php:`$data` | overwriting the default values with :php:`$data`. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getModuleIdentifier() | | Returns the related module identifier | ++-------------------------+-----------------------+----------------------------------------------------+ +| get() | :php:`$propertyName` | Returns the value for :php:`$propertyName`, or the | +| | :php:`$default` | :php:`$default`, if not set. | ++-------------------------+-----------------------+----------------------------------------------------+ +| set() | :php:`$propertyName` | Updates :php:`$propertyName` with the given | +| | :php:`$value` | :php:`$value`. | ++-------------------------+-----------------------+----------------------------------------------------+ +| has() | :php:`$propertyName` | Whether :php:`$propertyName` exists. | ++-------------------------+-----------------------+----------------------------------------------------+ +| clean() | :php:`$propertyName` | Cleans a single property by the given allowed | +| | :php:`$allowedValues` | list and falls back to either the default value | +| | | or the first allowed value. | ++-------------------------+-----------------------+----------------------------------------------------+ +| cleanUp() | :php:`$allowedData` | Cleans up all module data defined in the given | +| | :php:`$useKeys` | list of allowed data. Usually called with | +| | | :php:`$MOD_MENU` in a controller with module menu. | ++-------------------------+-----------------------+----------------------------------------------------+ +| toArray() | | Returns the module data as :php:`array`. | ++-------------------------+-----------------------+----------------------------------------------------+ + +In case a controller needs to store changed module data, this can still be done +using :php:`$backendUser->pushModuleData('my_module', $this->moduleData->toArray());`. + +.. note:: + + It's still possible to store and retrieve arbitrary module data. The + definition of :php:`moduleData` in the module registration only defines, + which properties can be overwritten in a request (with :php:`GET` / :php:`POST`). + +To restrict the values of module data properties, the given :php:`ModuleData` +object can be cleaned e.g. in a controller: + +.. code-block:: php + + $allowedValues = ['foo', 'bar']; + + $this->moduleData->clean('property', $allowedValues); + +If :php:`ModuleData` contains :php:`property`, the value is checked +against the :php:`$allowedValues` list. If the current value is valid, +nothing happens. Otherwise the value is either changed to the default +or if this value is also not allowed, to the first allowed value. + +Impact +====== + +The new :php:`ModuleData` object is available as new attribute of the +PSR-7 Request - in case a TYPO3 backend module is requested - and contains +the stored module data, which might have been overwritten through the current +request (with :php:`GET` / :php:`POST`). + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96899-NewPSR-14EventModifyGenericBackendMessagesEvent.rst b/Documentation/Changelog/12.0/Feature-96899-NewPSR-14EventModifyGenericBackendMessagesEvent.rst new file mode 100644 index 0000000..814258e --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96899-NewPSR-14EventModifyGenericBackendMessagesEvent.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-96899: + +===================================================================== +Feature: #96899 - New PSR-14 event: ModifyGenericBackendMessagesEvent +===================================================================== + +See :issue:`96899` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\ModifyGenericBackendMessagesEvent` +has been introduced. It serves as direct replacement for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['displayWarningMessages']`. + +Example +======= + +Registration of an event listener in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/add-message' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\ModifyGenericBackendMessagesEvent; + use TYPO3\CMS\Core\Messaging\FlashMessage; + + class MyEventListener { + + public function __invoke(ModifyGenericBackendMessagesEvent $event): void + { + // Add a custom message + $event->addMessage(new FlashMessage('My custom message')); + } + } + +Impact +====== + +The PSR-14 event allows to add or alter messages that are displayed +in the "About" module (default start module of the TYPO3 Backend). + +Extensions such as "Reports" already use this event to display custom +messages based on the status of the system. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96904-BackendToolbarItemsAreRequestAware.rst b/Documentation/Changelog/12.0/Feature-96904-BackendToolbarItemsAreRequestAware.rst new file mode 100644 index 0000000..bf591bc --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96904-BackendToolbarItemsAreRequestAware.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-96904: + +========================================================= +Feature: #96904 - Backend toolbar items are request aware +========================================================= + +See :issue:`96904` + +Description +=========== + +When registering own toolbar items to the TYPO3 backend top bar, classes can +now retrieve the current PSR-7 request by implementing +:php:`TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface`. This +is especially useful when rendering views using +:php:`TYPO3\CMS\Backend\View\BackendViewFactory` which depends on +current request. + +Impact +====== + +The TYPO3 Core encourages to use :php:`BackendViewFactory` instead of +:php:`StandaloneView` when toolbar items of extensions use Fluid templates. +:php:`BackendViewFactory` has a dependency to current request, so +:php:`RequestAwareToolbarItemInterface` should be implemented +to receive the current request from TYPO3 EXT:backend. + +Doing so enables the :doc:`template overrides by TSconfig +feature <../12.0/Feature-96812-OverrideBackendTemplatesWithTSconfig>`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96935-NewRegistrationForLinkvalidatorLinktype.rst b/Documentation/Changelog/12.0/Feature-96935-NewRegistrationForLinkvalidatorLinktype.rst new file mode 100644 index 0000000..1e93c80 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96935-NewRegistrationForLinkvalidatorLinktype.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-96935: + +============================================================= +Feature: #96935 - New registration for linkvalidator linktype +============================================================= + +See :issue:`96935` + +Description +=========== + +The system extension `linkvalidator` uses so called `linktypes` for +checking different types of links, e.g. internal or external links. +All `linktypes` have to implement the :php:`LinktypeInterface`. + +This fact is now used to automatically register the `linktypes`, based +on the interface, if :yaml:`autoconfigure` is enabled in :file:`Services.yaml`. +Alternatively, one can manually tag a custom `linktype` with the +:yaml:`linkvalidator.linktype` tag (see section "Migration" in the +:doc:`breaking changelog <Breaking-96935-RegisterLinkvalidatorLinktypesViaServiceConfiguration>`). + +Due to the autoconfiguration, the identifier has to be provided by the +class directly, using the now required :php:`getIdentifier()` method. +When extending :php:`\TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype` +it's sufficient to set the `$identifier` class property. + +Impact +====== + +`linktypes` are now automatically registered through the service configuration, +based on the implemented interface. + +.. index:: Backend, LocalConfiguration, PHP-API, ext:linkvalidator diff --git a/Documentation/Changelog/12.0/Feature-96961-BackendRoutesContainComposerPackageName.rst b/Documentation/Changelog/12.0/Feature-96961-BackendRoutesContainComposerPackageName.rst new file mode 100644 index 0000000..5bbe23f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96961-BackendRoutesContainComposerPackageName.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-96961: + +============================================================== +Feature: #96961 - Backend routes contain Composer package name +============================================================== + +See :issue:`96961` + +Description +=========== + +Request objects in the backend already contain the resolved route +object as attribute. These route objects now contain the Composer +package name of the package ("extension") that defined the route as option: + +.. code-block:: php + + /** @var \TYPO3\CMS\Backend\Routing\Route $route */ + $route = $request->getAttribute('route'); + // Example return: "typo3/cms-backend" when EXT:backend defined that route. + $packageName = $route->getOption('packageName'); + +Impact +====== + +The package name can be useful for filesystem lookups +or to bind configuration based on package name to it. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96968-PSR-14EventForAvoidLoadingFrontendPagesFromCache.rst b/Documentation/Changelog/12.0/Feature-96968-PSR-14EventForAvoidLoadingFrontendPagesFromCache.rst new file mode 100644 index 0000000..d525723 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96968-PSR-14EventForAvoidLoadingFrontendPagesFromCache.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-96968-1663513232: + +========================================================================== +Feature: #96968 - PSR-14 event for avoid loading Frontend pages from cache +========================================================================== + +See :issue:`96968` + +Description +=========== + +A new PSR-14 event :php:`ShouldUseCachedPageDataIfAvailableEvent` is added which +allows TYPO3 Extensions to register event listeners to modify if a page should +be read from cache (if it has been created in store already), or if it should +be re-built completely ignoring the cache entry for the request. + +Impact +====== + +The new PSR-14 event can be used for avoiding loading from cache when indexing +via CLI happens from an external source, or if the cache should be ignored when +logged in from a certain IP address. + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/avoid-cache-loading' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Frontend\Event\ShouldUseCachedPageDataIfAvailableEvent; + + class MyEventListener { + + public function __invoke(ShouldUseCachedPageDataIfAvailableEvent $event): void + { + if (!($event->getRequest()->getServerParams()['X-SolR-API'] ?? null)) { + return; + } + $event->setShouldUseCachedPageData(false); + } + } + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-96975-NewPSR-14EventsForSiteConfiguration.rst b/Documentation/Changelog/12.0/Feature-96975-NewPSR-14EventsForSiteConfiguration.rst new file mode 100644 index 0000000..695b136 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96975-NewPSR-14EventsForSiteConfiguration.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-96975: + +================================================================== +Feature: #96975 - New PSR-14 events for SiteConfiguration Handling +================================================================== + +See :issue:`96975` + +Description +=========== + +Two new events have been added to TYPO3, to allow manipulation of the loaded +SiteConfiguration before it is cached and before the configuration is written to disk. + +- :php:`\TYPO3\CMS\Core\Configuration\Event\SiteConfigurationBeforeWriteEvent` +- :php:`\TYPO3\CMS\Core\Configuration\Event\SiteConfigurationLoadedEvent` + +Impact +====== + +The events SiteConfigurationLoadedEvent and SiteConfigurationBeforeWriteEvent +have been introduced. + +Both contain the following methods: + +- :php:`getSiteIdentifier()`: returns the sites' identifier +- :php:`getConfiguration()`: returns the configuration (loaded or to be written) +- :php:`setConfiguration(array $configuration)`: allows overwriting of the configuration + +They allow modification of the site configuration array both before loading and +before writing the configuration to disk. + +To register an event listener for the new events, use the following code in your +:file:`Services.yaml`: + +.. code-block:: yaml + + services: + MyCompany\MyPackage\EventListener\SiteConfigurationLoadedListener: + tags: + - name: event.listener + identifier: 'myLoadedListener' + MyCompany\MyPackage\EventListener\SiteConfigurationBeforeWriteListener: + tags: + - name: event.listener + identifier: 'myWriteListener' + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-96983-TCATypeFolder.rst b/Documentation/Changelog/12.0/Feature-96983-TCATypeFolder.rst new file mode 100644 index 0000000..8c993be --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96983-TCATypeFolder.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-96983: + +=================================== +Feature: #96983 - TCA type "folder" +=================================== + +See :issue:`96983` + +Description +=========== + +A new TCA type :php:`folder` has been introduced, which replaces the old +combination of :php:`type => 'group'` together with +:php:`internal_type => 'folder'`. Other than that, there is nothing new about +this type. + +Example usage: + +.. code-block:: php + + 'columns' => [ + 'aColumn' => [ + 'config' => [ + 'type' => 'folder', + ], + ], + ], + +Impact +====== + +You may now use the new TCA type :php:`folder` as a quicker way to define a +field, which can select multiple folders in an element browser window. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-96996-PSR-14EventForModifyingRecordAccessEvaluation.rst b/Documentation/Changelog/12.0/Feature-96996-PSR-14EventForModifyingRecordAccessEvaluation.rst new file mode 100644 index 0000000..647be03 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-96996-PSR-14EventForModifyingRecordAccessEvaluation.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _feature-96996-1663513388: + +===================================================================== +Feature: #96996 - PSR-14 event for modifying record access evaluation +===================================================================== + +See :issue:`96996` + +Description +=========== + +A new PSR-14 event :php:`RecordAccessGrantedEvent` has been added. It serves +as a replacement for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['hook_checkEnableFields']`. + +The new PSR-14 event can be used to either define whether record access is granted +for a user, or to even modify the record in question. In case the `$accessGranted` +property is set (either :php:`true` or :php:`false`), the defined settings are +directly used, skipping any further event listener as well as any further +evaluation. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/set-access-granted' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Core\Domain\Access\RecordAccessGrantedEvent; + + class MyEventListener + { + public function __invoke(RecordAccessGrantedEvent $event): void + { + // Manually set access granted + if ($event->getTable() === 'my_table' + && ($event->getRecord()['custom_access_field'] ?? false)) { + $event->setAccessGranted(true); + } + + // Update the record to be checked + $record = $event->getRecord(); + $record['some_field'] = true; + $event->updateRecord($record); + } + } + +Impact +====== + +With the new PSR-14 event :php:`RecordAccessGrantedEvent`, it's +now possible to manipulate the record access evaluation by +either directly granting access or by modifying the record +to be evaluated. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-97013-NewTCATypeEmail.rst b/Documentation/Changelog/12.0/Feature-97013-NewTCATypeEmail.rst new file mode 100644 index 0000000..9250ca4 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97013-NewTCATypeEmail.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _feature-97013: + +====================================== +Feature: #97013 - New TCA type "email" +====================================== + +See :issue:`97013` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use cases, the +TCA type :php:`email` has been introduced. It replaces the :php:`eval=email` +option of TCA type :php:`input`. + +The TCA type :php:`email` features the following column configuration: + +- :php:`autocomplete` +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`default` +- :php:`eval`: :php:`unique` and :php:`uniqueInPid` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`search` +- :php:`size` + +.. note:: + + The soft reference definition :php:`softref=>email[subst]` is automatically applied + to all :php:`email` fields. + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`readOnly` +- :typoscript:`size` + +The migration from :php:`eval='email'` to :php:`type=email` is done like following: + +.. code-block:: php + + // Before + + 'email_field' => [ + 'label' => 'Email', + 'config' => [ + 'type' => 'input', + 'eval' => 'trim,email', + 'max' => 255, + ] + ] + + // After + + 'email_field' => [ + 'label' => 'Email', + 'config' => [ + 'type' => 'email', + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +.. note:: + + The value of TCA type :php:`email` columns is automatically trimmed before + being stored in the database. Therefore, the :php:`eval=trim` option is no + longer needed and should be removed from the TCA configuration. + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`email`. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97035-UtilizeRequiredDirectlyInTCAFieldConfiguration.rst b/Documentation/Changelog/12.0/Feature-97035-UtilizeRequiredDirectlyInTCAFieldConfiguration.rst new file mode 100644 index 0000000..28b5699 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97035-UtilizeRequiredDirectlyInTCAFieldConfiguration.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-97035: + +======================================================================== +Feature: #97035 - Utilize "required" directly in TCA field configuration +======================================================================== + +See :issue:`97035` + +Description +=========== + +Since :issue:`67354`, the FormEngine may use :php:`required` with a bool value +in a TCA field configuration, enabling the same functionality as the `required` +option within `eval`. + +All TCA in TYPO3 Core is migrated to use the `required` configuration over the +corresponding `eval` option. + +Impact +====== + +If not done already, TCA is automatically migrated to use :php:`'required' => true` +instead of the co-existing `eval` option. The automated migration will trigger +a deprecation entry though. + +Example +======= + +.. code-block:: php + + 'columns' => [ + 'some_column' => [ + 'title' => 'foo', + 'config' => [ + 'required' => true, + 'eval' => 'trim', + ], + ], + ], + +.. index:: Backend, TCA, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97051-FilterLogsByPageInLogModule.rst b/Documentation/Changelog/12.0/Feature-97051-FilterLogsByPageInLogModule.rst new file mode 100644 index 0000000..715bf3b --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97051-FilterLogsByPageInLogModule.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-97051: + +=================================================== +Feature: #97051 - Filter logs by page in Log module +=================================================== + +See :issue:`97051` + +Description +=========== + +The :guilabel:`System > Log` module has been extended by a new filter option +`Page`. When used, the result is limited to logs related to the selected page. +Those are usually content related logs, such as "User X created record Y". + +With the `Depth` option - which is only available in case a page is selected - +the result can be extended to further subpages. + +Additionally, administrators are now able to define access permissions via +the module access logic for the :guilabel:`System > Log` module. + +Impact +====== + +It's now possible to filter system logs in the :guilabel:`System > Log` +module by pages. + +.. index:: Backend, ext:belog diff --git a/Documentation/Changelog/12.0/Feature-97096-Non-namespacedArgumentsInExtbaseBackendModules.rst b/Documentation/Changelog/12.0/Feature-97096-Non-namespacedArgumentsInExtbaseBackendModules.rst new file mode 100644 index 0000000..18f9ddc --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97096-Non-namespacedArgumentsInExtbaseBackendModules.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-97096: + +===================================================================== +Feature: #97096 - Non-namespaced arguments in Extbase backend modules +===================================================================== + +See :issue:`97096` + +Description +=========== + +Extbase plugins and backend modules traditionally use the plugin / module +namespace to prefix their GET parameters and form data. In the frontend context, +this makes sense, as multiple plugins may reside on a page. In the backend, +however, an Extbase module is responsible for rendering a complete view. +Therefore, the namespacing of arguments has been disabled, making URLs easier +to read, more in line with non-Extbase modules and allowing Extbase modules +to directly access outside information like the `id` parameter handed over +by the page tree for example. + +To allow Extbase modules to configure this behaviour, the Extbase feature +flag :typoscript:`enableNamespacedArgumentsForBackend` can be set in the module +configuration, turning the namespacing off or on. + +Impact +====== + +Extbase will by default build and react to backend module links without paying +attention to the namespace of the parameters. + +A link may look like this: + +:samp:`https://example.org/typo3/module/web/BeuserTxBeuser?action=groups&controller=BackendUser` + +If a module explicitly wants to keep using the namespaced version of the arguments, +the feature flag can be set: + +.. code-block:: typoscript + :caption: EXT:my_extension/ext_typoscript_setup.typoscript + + module.tx_myextension_somemodule { + features { + enableNamespacedArgumentsForBackend = 1 + } + } + +.. index:: Backend, PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-97104-NewTCATypePassword.rst b/Documentation/Changelog/12.0/Feature-97104-NewTCATypePassword.rst new file mode 100644 index 0000000..f83eca7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97104-NewTCATypePassword.rst @@ -0,0 +1,124 @@ +.. include:: /Includes.rst.txt + +.. _feature-97104: + +========================================= +Feature: #97104 - New TCA type "password" +========================================= + +See :issue:`97104` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use cases, the +TCA type :php:`password` has been added. It replaces the :php:`eval=password` +and :php:`eval=saltedPassword` option of TCA type :php:`input`. + +TCA password fields will be rendered as input :php:`type=password` fields. +By default, the :php:`autocomplete=new-password` attribute will be added to the +resulting input field. If :php:`autocomplete=true` is configured in TCA, a +:php:`autocomplete=current-password` attribute will be added to the element. + +The TCA type :php:`password` features the following column configuration: + +- :php:`autocomplete` +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`default` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`size` +- :php:`hashed` + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`readOnly` +- :typoscript:`size` + +By default, TCA type :php:`password` will always save the field value +hashed to the database. The value will be hashed using the password hash +configuration for BE for all tables except :sql:`fe_users`, where the password hash +configuration for FE is used. + +The TCA type :php:`password` introduces the new configuration :php:`hashed`, +which can be set to :php:`false`, if the field value should be saved as +plaintext to the database. + +.. note:: + + The configuration :php:`'hashed' => false` has no effect for all fields in + the tables :sql:`be_users` and :sql:`fe_users`. In general it is not + recommended to save passwords as plain text to the database. + +The migration from :php:`eval='password'` and :php:`eval='saltedPassword'` to +:php:`type=password` is done like following: + +.. code-block:: php + + // Before + + 'password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'input', + 'eval' => 'trim,password,saltedPassword', + ] + ] + + // After + + 'password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'password', + ] + ] + + // Before + + 'another_password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'input', + 'eval' => 'trim,password', + ] + ] + + // After + + 'another_password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'password', + 'hashed' => false, + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +.. note:: + + The value of TCA type :php:`password` column is automatically trimmed before + being stored (and optionally hashed) in the database. Therefore, the :php:`eval=trim` + option is no longer needed and should be removed from the TCA configuration. + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`password`. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97135-NewRegistrationForModuleFunctions.rst b/Documentation/Changelog/12.0/Feature-97135-NewRegistrationForModuleFunctions.rst new file mode 100644 index 0000000..053942d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97135-NewRegistrationForModuleFunctions.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _feature-97135: + +======================================================= +Feature: #97135 - New Registration for module functions +======================================================= + +See :issue:`97135` + +Description +=========== + +Previously, module functions could be added to modules such as +:guilabel:`Web > Info` or :guilabel:`Web > Template` via the +now removed global :php:`TBE_MODULES_EXT` array. + +Since those functions are actually additional - "third-level" - modules, +they are now registered as such via the +:doc:`new Module Registration API <Feature-96733-NewBackendModuleRegistrationAPI>`, +in an extension's :file:`Configuration/Backend/Modules.php` file. + +Next to the additional configuration options, e.g. for defining the position, +this also allows administrators to define access permissions via the module +access logic for those modules individually. + +Additionally, the corresponding backend controller classes are now +able to make use of the :doc:`new ModuleData API <Feature-96895-IntroduceModuleDataObject>`. + +Example +======= + +Registration of an additional - "third-level" - module for +:guilabel:`Web > Template` in the :file:`Configuration/Backend/Modules.php` +file of an extension: + +.. code-block:: php + + 'web_ts_customts' => [ + 'parent' => 'web_ts', + 'access' => 'user', + 'path' => '/module/web/typoscript/custom-ts', + 'iconIdentifier' => 'module-custom-ts', + 'labels' => [ + 'title' => 'LLL:EXT:extkey/Resources/Private/Language/locallang.xlf:mod_title', + ], + 'routes' => [ + '_default' => [ + 'target' => CustomTsController::class . '::handleRequest', + ], + ], + 'moduleData' => [ + 'someOption' => false, + ], + ], + +Impact +====== + +Additional - "third-level" - modules are now registered in the +extension's :file:`Configuration/Backend/Modules.php` file, the +same way as main and submodules. This therefore allows those +modules to benefit from the same functionality. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97159-NewTCATypeLink.rst b/Documentation/Changelog/12.0/Feature-97159-NewTCATypeLink.rst new file mode 100644 index 0000000..fba93e1 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97159-NewTCATypeLink.rst @@ -0,0 +1,180 @@ +.. include:: /Includes.rst.txt + +.. _feature-97159: + +===================================== +Feature: #97159 - New TCA type "link" +===================================== + +See :issue:`97159` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use +cases, the TCA type :php:`link` has been introduced. It replaces the +:php:`renderType=inputLink` of TCA type :php:`input`. + +The TCA type :php:`link` features the following column configuration: + +- :php:`allowedTypes` +- :php:`appearance`: :php:`enableBrowser`, :php:`browserTitle`, + :php:`allowedOptions`, :php:`allowedFileExtensions` +- :php:`autocomplete` +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`default` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`search` +- :php:`size` +- :php:`valuePicker` + +.. note:: + The soft reference definition :php:`softref=typolink` is automatically applied + to all TCA type :php:`link` columns. + +.. note:: + The value of TCA type :php:`link` columns is automatically trimmed before + being stored in the database. Therefore, the :php:`eval=trim` option is no + longer needed and should be removed from the TCA configuration. + +The following column configurations can be overwritten by page TSconfig: + +* :typoscript:`readOnly` +* :typoscript:`size` + +The previously configured :php:`linkPopup` field control is now integrated +into the new TCA type directly. Additionally, instead of exclude lists +(:php:`[blindLink[Fields|Options]`) the new type now use include lists. +Those lists are furthermore no longer comma-separated, but PHP arrays, +with each option as a separate value. + +The replacement for the previously used :php:`blindLinkOptions` option is the +:php:`allowedTypes` configuration. The :php:`blindLinkFields` option is +now configured via :php:`appearance[allowedOptions]`. While latter only +affects the display in the Link Browser, the :php:`allowedTypes` configuration +is also evaluated in the :php:`DataHandler`, preventing the user from adding +links of non-allowed types. + +To allow all link types, skip the :php:`allowedTypes` configuration or set +it to :php:`['*']`. It's not possible to deny all types. + +To allow all options in the Link Browser, skip the +:php:`appearance[allowedOptions]` configuration or set it to :php:`['*']`. To +deny all options in the Link Browser, set the :php:`appearance[allowedOptions]` +configuration to :php:`[]` (empty :php:`array`). + +The :php:`allowedExtensions` option is renamed to :php:`allowedFileExtensions` +and also moved to :php:`appearance`. Now it requires to be an :php:`array`. +To allow all extensions, skip the :php:`appearance[allowedFileExtensions]` +configuration or set it to :php:`['*']`. It's not possible to deny all +extensions. + +With :php:`appearance[browserTitle]`, a custom title for the Link Browser +can be defined. To disable the Link Browser, :php:`appearance[enableBrowser]` +has to be set to :php:`false`. + +A complete migration from :php:`renderType=inputLink` to :php:`type=link` +looks like the following: + +.. code-block:: php + + // Before + + 'a_link_field' => [ + 'label' => 'Link', + 'config' => [ + 'type' => 'input', + 'renderType' => 'inputLink', + 'required' => true, + 'nullable' => true, + 'size' => 20, + 'max' => 1024, + 'eval' => 'trim', + 'fieldControl' => [ + 'linkPopup' => [ + 'disabled' => true, + 'options' => [ + 'title' => 'Browser title', + 'allowedExtensions' => 'jpg,png', + 'blindLinkFields' => 'class,target,title', + 'blindLinkOptions' => 'mail,folder,file,telephone', + ], + ], + ], + 'softref' => 'typolink', + ], + ], + + // After + + 'a_link_field' => [ + 'label' => 'Link', + 'config' => [ + 'type' => 'link', + 'required' => true, + 'nullable' => true, + 'size' => 20, + 'allowedTypes' => ['page', 'url', 'record'], + 'appearance' => [ + 'enableBrowser' => false, + 'browserTitle' => 'Browser title', + 'allowedFileExtensions' => ['jpg', 'png'], + 'allowedOptions' => ['params', 'rel'], + ], + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +.. note:: + + The corresponding FormEngine class has been renamed from :php:`InputLinkElement` + to :php:`LinkElement`. An entry in the "ClassAliasMap" has been added for + extensions calling this class directly, which is rather unlikely. The + extension scanner will report any usage, which should then be migrated. + +Allowed type "record" +===================== + +One of the primary tasks of the corresponding TCA migration is to migrate +the exclude lists to include lists. To achieve this, the migration would need +to know all possible values. Since the LinkHandler API provides the possibility +to use the :php:`RecordLinkHandler` as basis for various custom record types, +whose availability however depends on the page context, it's not possible for +the migration to add the custom record identifiers correctly. Therefore, the +:php:`record` type is added to the :php:`allowedTypes`, enabling all custom +record identifiers. The actually available identifiers are then resolved +automatically in the :php:`link` element, depending on the context. + +To limit this in TCA already, replace the :php:`record` value with the +desired record identifiers. + +.. code-block:: php + + // Before + 'allowedTypes' => ['page', 'url', 'record'], + + // After + 'allowedTypes' => ['page', 'url', 'tx_news', 'tt_address'], + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`link`. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97173-AutoCreationOfDatabaseFieldsForTCASlug.rst b/Documentation/Changelog/12.0/Feature-97173-AutoCreationOfDatabaseFieldsForTCASlug.rst new file mode 100644 index 0000000..29b3c71 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97173-AutoCreationOfDatabaseFieldsForTCASlug.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-97173-1663950087: + +================================================================= +Feature: #97173 - Auto creation of database fields for TCA "slug" +================================================================= + +See :issue:`97173` + +Description +=========== + +TYPO3 automatically creates database fields for TCA type :php:`slug` columns, +if they have not already been defined in an extension's :file:`ext_tables.sql` +file. + +Impact +====== + +The corresponding database field definition of a slug field can be omitted from +:file:`ext_tables.sql`. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97174-PSR-14EventForModifyingInfoModuleContent.rst b/Documentation/Changelog/12.0/Feature-97174-PSR-14EventForModifyingInfoModuleContent.rst new file mode 100644 index 0000000..b68ad0c --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97174-PSR-14EventForModifyingInfoModuleContent.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-97174: + +================================================================ +Feature: #97174 - PSR-14 event for modifying info module content +================================================================ + +See :issue:`97174` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Info\Controller\Event\ModifyInfoModuleContentEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/web_info/class.tx_cms_webinfo.php']['drawFooterHook']` +hook. + +While the removed hook effectively only allowed to add content +to the footer of the :guilabel:`Pagetree Overview` submodule in :guilabel:`Web > Info`, +the new PSR-14 event now allows to modify the content above and below the +actual info module content. This means that the content, added in the event, will +be displayed in each submodule of :guilabel:`Web > Info`. + +The PSR-14 event also provides the :php:`getCurrentModule()` method, which +returns the currently requested (sub)module. It's therefore possible to +limit the added content to a subset of the available :guilabel:`Web > Info` +submodules. + +In addition to the :php:`getRequest()` and the :php:`getModuleTemplate()` methods, +the event also has the usual getters and setters for the header and footer +content. + +Access control +============== + +By default, the added content is always displayed. The PSR-14 event however +provides the :php:`hasAccess()` method, returning whether the access checks +in the module were passed by the user. + +This way, event listeners can decide on their own whether their content +should always be shown, or only if a user also has access to the main module +content. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/content-to-info-module' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Info\Controller\Event\ModifyInfoModuleContentEvent; + + class MyEventListener { + + public function __invoke(ModifyInfoModuleContentEvent $event): void + { + // Add header content for the "page TSconfig" submodule if user has access to module content + if ($event->hasAccess() && $event->getCurrentModule()->getIdentifier() === 'web_info_pagets') { + $event->addHeaderContent('<h3>Additional header content</h3>'); + } + } + } + +Impact +====== + +It's now possible to modify the header and footer content of the +:guilabel:`Web > Info` module, using the new PSR-14 event. + +.. index:: Backend, PHP-API, ext:info diff --git a/Documentation/Changelog/12.0/Feature-97187-PSR-14EventForModifyingLinkExplanation.rst b/Documentation/Changelog/12.0/Feature-97187-PSR-14EventForModifyingLinkExplanation.rst new file mode 100644 index 0000000..c85f0a4 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97187-PSR-14EventForModifyingLinkExplanation.rst @@ -0,0 +1,92 @@ +.. include:: /Includes.rst.txt + +.. _feature-97187: + +============================================================= +Feature: #97187 - PSR-14 event for modifying link explanation +============================================================= + +See :issue:`97187` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Form\Event\ModifyLinkExplanationEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['linkHandler']` +hook. + +While the removed hook effectively only allowed to modify the link explanation +of TCA `link` fields in case the resolved link type did not already match +one of those, implemented by TYPO3 itself, the new event now allows to +always modify the link explanation of any type. Additionally, this allows +to modify the `additionalAttributes`, displayed below the actual link +explanation field. This is especially useful for extended link handler setups. + +To modify the link explanation, the following methods are available: + +- :php:`getLinkExplanation()`: Returns the current link explanation data +- :php:`setLinkExplanation()`: Set the link explanation data +- :php:`getLinkExplanationValue()`: Returns a specific link explanation value +- :php:`setLinkExplanationValue()`: Sets a specific link explanation value + +The link explanation array usually contains the following values: + +- :php:`text` : The text to show in the link explanation field +- :php:`icon`: The markup for the icon, displayed in front of the link explanation field +- :php:`additionalAttributes`: The markup for additional attributes, displayed below the link explanation field + +The current context can be evaluated using the following methods: + +- :php:`getLinkData()`: Returns the resolved link data, such as the page uid +- :php:`getLinkParts()`: Returns the resolved link parts, such as `url`, `target` and `additionalParams` +- :php:`getElementData()`: Returns the full FormEngine `$data` array for the current element + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\ModifyLinkExplanationEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-link-explanation' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Form\Event\ModifyLinkExplanationEvent; + use TYPO3\CMS\Core\Imaging\Icon; + use TYPO3\CMS\Core\Imaging\IconFactory; + + final class ModifyLinkExplanationEventListener + { + public function __construct( + protected readonly IconFactory $iconFactory + ) { + } + + public function __invoke( + ModifyLinkExplanationEvent $event + ): void { + // Use a custom icon for a custom link type + if ($event->getLinkData()['type'] === 'myCustomLinkType') { + $icon = $this->iconFactory->getIcon( + 'my-custom-link-icon', + Icon::SIZE_SMALL + )->render() + $event->setLinkExplanationValue('icon', $icon); + } + } + } + +Impact +====== + +It's now possible to fully modify the link explanation of TCA `link` +elements, using the new PSR-14 event :php:`ModifyLinkExplanationEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97188-NewRegistrationForElementBrowsers.rst b/Documentation/Changelog/12.0/Feature-97188-NewRegistrationForElementBrowsers.rst new file mode 100644 index 0000000..e7b94ba --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97188-NewRegistrationForElementBrowsers.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-97188: + +======================================================= +Feature: #97188 - New registration for element browsers +======================================================= + +See :issue:`97188` + +Description +=========== + +The system extension `backend` provides different `element browsers`, +such as the "File browser" or the "Database browser" to select files +and records in e.g. FormEngine fields. Extension authors are able to +register their own browsers. This was previously done using global +configuration. + +However, since all `element browsers` have to implement the +:php:`ElementBrowserInterface`, this fact is now used to automatically +register the `element browsers`, based on the interface, if +:yaml:`autoconfigure` is enabled in :file:`Services.yaml`. Alternatively, +one can manually tag a custom `element browser` with the +:yaml:`recordlist.elementbrowser` tag (see section "Migration" in the +:doc:`breaking changelog <Breaking-97188-RegisterElementBrowsersViaServiceConfiguration>`). + +Due to the autoconfiguration, the identifier has to be provided by the +class directly, using the now required :php:`getIdentifier()` method. +When extending :php:`\TYPO3\CMS\Backend\Browser\AbstractElementBrowser` +it's sufficient to set the `$identifier` class property. + +Impact +====== + +`element browsers` are now automatically registered through the service +configuration, based on the implemented interface. + +.. index:: Backend, LocalConfiguration, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97193-NewTCATypeNumber.rst b/Documentation/Changelog/12.0/Feature-97193-NewTCATypeNumber.rst new file mode 100644 index 0000000..a54aac8 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97193-NewTCATypeNumber.rst @@ -0,0 +1,135 @@ +.. include:: /Includes.rst.txt + +.. _feature-97193: + +======================================= +Feature: #97193 - New TCA type "number" +======================================= + +See :issue:`97193` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use +cases, the TCA type :php:`number` has been introduced. It replaces the +:php:`eval=int` and :php:`eval=double2` options of TCA type :php:`input`. + +The TCA :php:`number` fields will be rendered with the html :html:`type` +attribute set to :html:`number`. + +The TCA type :php:`number` features the following column configuration: + +- :php:`autocomplete` +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`default` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`format`: :php:`integer`, :php:`decimal` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`range`: :php:`lower`, :php:`upper` +- :php:`readOnly` +- :php:`required` +- :php:`search` +- :php:`size` +- :php:`slider`: :php:`step`, :php:`width` +- :php:`valuePicker`: :php:`items`, :php:`mode` + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`readOnly` +- :typoscript:`size` + +The TCA type :php:`number` introduces the new configuration :php:`format`, +which can be set to :php:`decimal` or :php:`integer`, which is the default. + +.. note:: + + The :php:`slider` option allows to define a visual slider element + next to the input field. The steps can be defined with the :php:`step` + option. The minimum and maximum value can be configured with the + :php:`range[lower]` and :php:`range[upper]` options. + +.. note:: + + The :php:`valuePicker` option allows to define default values via + :php:`items`. With :php:`mode`, one can define how the selected + value should be added (replace, prepend or append). + +.. note:: + + The options :php:`range`, :php:`slider` as well as :php:`eval=double2` + are no longer evaluated for TCA type :php:`input`. + +Migration +--------- + +The migration from :php:`eval='int'` to :php:`type=number` +is done like following: + +.. code-block:: php + + // Before + + 'int_field' => [ + 'label' => 'Int field', + 'config' => [ + 'type' => 'input', + 'eval' => 'int', + ] + ] + + // After + + 'int_field' => [ + 'label' => 'Int field', + 'config' => [ + 'type' => 'number', + ] + ] + +The migration from :php:`eval=double2` to :php:`type=number` +is done like following: + +.. code-block:: php + + // Before + + 'double2_field' => [ + 'label' => 'double2 field', + 'config' => [ + 'type' => 'input', + 'eval' => 'double2', + ] + ] + + // After + + 'double2_field' => [ + 'label' => 'double2 field', + 'config' => [ + 'type' => 'number', + 'format' => 'decimal' + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`number`. Setting the new :php:`format` option +to :php:`decimal` behaves like the former :php:`eval=double2`. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97201-PSR-14EventForModifyingNewContentElementWizardItems.rst b/Documentation/Changelog/12.0/Feature-97201-PSR-14EventForModifyingNewContentElementWizardItems.rst new file mode 100644 index 0000000..42a9dd0 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97201-PSR-14EventForModifyingNewContentElementWizardItems.rst @@ -0,0 +1,111 @@ +.. include:: /Includes.rst.txt + +.. _feature-97201: + +============================================================================= +Feature: #97201 - PSR-14 event for modifying new content element wizard items +============================================================================= + +See :issue:`97201` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms']['db_new_content_el']['wizardItemsHook']`. + +The event is called after TYPO3 has already prepared the wizard items, +defined in TSconfig (:typoscript:`mod.wizards.newContentElement.wizardItems`). + +The event allows listeners to modify any available wizard item as well +as adding new ones. It's therefore possible for the listeners to e.g. change +the configuration, the position or to remove existing items altogether. + +Following methods are available: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getWizardItems() | | Returns all available wizard items. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setWizardItems() | :php:`$wizardItems` | Updates / overwrites the available wizard items. | ++-------------------------+-----------------------+----------------------------------------------------+ +| hasWizardItem() | :php:`$identifier` | Whether a wizard item with the :php:`$identifier` | +| | | exists. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getWizardItem() | :php:`$identifier` | Returns the wizard item with the | +| | | :php:`$identifier` or :php:`null` if it does not | +| | | exist. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setWizardItem() | :php:`$identifier` | Add a new wizard item with the :php:`identifier` | +| | :php:`$configuration` | and the :php:`$configuration` at the defined | +| | :php:`$position` | :php:`$position`. :php:`$position` is an `array`. | +| | | Allowed values are `before => <identifier>` and | +| | | `after => <identifier>`. Can also be used to | +| | | modify or relocate existing items. | ++-------------------------+-----------------------+----------------------------------------------------+ +| removeWizardItem() | :php:`$identifier` | Removes a wizard item with the :php:`$identifier` | ++-------------------------+-----------------------+----------------------------------------------------+ +| getPageInfo() | | Provides information about the current page making | +| | | use of the wizard. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getColPos() | | Provides information about the column position | +| | | of the button that triggered the wizard. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getSysLanguage() | | Provides information about the language used | +| | | while triggering the wizard. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getUidPid() | | Provides information about the element to position | +| | | the new element after (uid) or into (pid). | ++-------------------------+-----------------------+----------------------------------------------------+ + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Frontend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-wizard-items' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent; + + class MyEventListener { + + public function __invoke( + ModifyNewContentElementWizardItemsEvent $event + ): void + { + // Add a new wizard item after "textpic" + $event->setWizardItem( + 'my_element', + [ + 'iconIdentifier' => 'icon-my-element', + 'title' => 'My element', + 'description' => 'My element description', + 'tt_content_defValues' => [ + 'CType' => 'my_element' + ], + ], + ['after' => 'common_textpic'] + ); + } + } + +Impact +====== + +The main advantages of the new PSR-14 event are the object-oriented +approach as well as the built-in convenience features, like relocating +of the wizard items. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-97230-PSR-14EventForModifyingImageManipulationPreviewUrl.rst b/Documentation/Changelog/12.0/Feature-97230-PSR-14EventForModifyingImageManipulationPreviewUrl.rst new file mode 100644 index 0000000..a649590 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97230-PSR-14EventForModifyingImageManipulationPreviewUrl.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _feature-97230: + +=========================================================================== +Feature: #97230 - PSR-14 event for modifying image manipulation preview URL +=========================================================================== + +See :issue:`97230` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Form\Event\ModifyImageManipulationPreviewUrlEvent` +has been introduced which serves as a direct replacement for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend/Form/Element/ImageManipulationElement']['previewUrl']` +hook. + +It can be used to modify the preview URL within the image manipulation element, +used e.g. for the :php:`crop` field of the :sql:`sys_file_reference` table. + +As soon as a preview URL is set, the image manipulation element will display +a corresponding button in the footer of the modal window, next to the +:guilabel:`Cancel` and :guilabel:`Accept` buttons. On click, the preview +URL will be opened in a new window. + +.. note:: + + The element's crop variants will always be appended to the preview URL + as JSON-encoded string, using the `cropVariants` parameter. + +Next to the :php:`getPreviewUrl()` and :php:`setPreviewUrl()` the new +PSR-14 event feature the following methods: + +- :php:`getDatabaseRow()`: Returns the whole database row for the corresponding record +- :php:`getFieldConfiguration()`: Returns the processed field configuration +- :php:`getFile()`: Returns the resolved file object + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-imagemanipulation-previewurl' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Form\Event\ModifyImageManipulationPreviewUrlEvent; + + final class MyEventListener + { + public function __invoke(ModifyImageManipulationPreviewUrlEvent $event): void + { + $event->setPreviewUrl('https://example.com/some/preview/url'); + } + } + +Impact +====== + +It's now possible to modify the preview URL for the image manipulation +element, using the new PSR-14 event :php:`ModifyImageManipulationPreviewUrlEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls.rst b/Documentation/Changelog/12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls.rst new file mode 100644 index 0000000..5657953 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls.rst @@ -0,0 +1,174 @@ +.. include:: /Includes.rst.txt + +.. _feature-97231: + +===================================================================== +Feature: #97231 - PSR-14 events for modifying inline element controls +===================================================================== + +See :issue:`97231` + +Description +=========== + +The new PSR-14 events :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementEnabledControlsEvent` +and :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementControlsEvent` +have been introduced, which serve as a more powerful and flexible replacement +for the now removed hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tceforms_inline.php']['tceformsInlineHook']`. + +The :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementEnabledControlsEvent` +is called before any control markup is generated. It can be used to +enable or disable each control. With this event it's therefore possible +to e.g. enable a control, which is disabled in TCA, only for some use case. + +The :php:`\TYPO3\CMS\Backend\Form\Event\ModifyInlineElementControlsEvent` +is called after the markup for all enabled controls has been generated. It +can be used to either change the markup of a control, to add a new control +or to completely remove a control. + +.. note:: + + Previously the deprecated hook interface :php:`InlineElementHookInterface` + required hook implementations to implement both methods + :php:`renderForeignRecordHeaderControl_preProcess()` and + :php:`renderForeignRecordHeaderControl_postProcess()`, even if only + one was used. This is now resolved since listeners can be registered + only for the needed PSR-14 event. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Frontend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-enabled-controls' + method: 'modifyEnabledControls' + - name: event.listener + identifier: 'my-package/backend/modify-controls' + method: 'modifyControls' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Form\Event\ModifyInlineElementEnabledControlsEvent; + use TYPO3\CMS\Backend\Form\Event\ModifyInlineElementControlsEvent; + use TYPO3\CMS\Core\Imaging\Icon; + use TYPO3\CMS\Core\Imaging\IconFactory; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + class MyEventListener + { + + public function modifyEnabledControls( + ModifyInlineElementEnabledControlsEvent $event + ): void { + // Enable a control depending on the foreign table + if ($event->getForeignTable() === 'sys_file_reference' + && $event->isControlEnabled('sort')) { + $event->enableControl('sort'); + } + } + + public function modifyControls( + ModifyInlineElementControlsEvent $event + ): void { + // Add a custom control depending on the parent table + if ($event->getElementData()['inlineParentTableName'] === 'tt_content') { + $iconFactory = GeneralUtility::makeInstance(IconFactory::class); + $iconCode = $iconFactory->getIcon( + 'my-icon-identifier', + Icon::SIZE_SMALL + )->render(); + $event->setControl( + 'tx_my_control', + '<a href="/some/url" class="btn btn-default t3js-modal-trigger">' + . $iconCode . '</a>' + ); + } + } + } + +Available Methods +================= + +The list below describes all specific methods for the :php:`ModifyInlineElementEnabledControlsEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| enableControl() | :php:`$identifier` | Enable a control, if it exists. Returns whether | +| | | the control could be enabled. | ++-------------------------+-----------------------+----------------------------------------------------+ +| disableControl() | :php:`$identifier` | Disable a control, if it exists. Returns whether | +| | | the control could be disabled. | ++-------------------------+-----------------------+----------------------------------------------------+ +| hasControl | :php:`$identifier` | Whether a control exists for the given identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| isControlEnabled() | :php:`$identifier` | Returns whether the control is enabled. Will also | +| | | return :php:`false` in case no control exists for | +| | | the requested identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getControlsState() | :php:`$identifier` | Returns all controls with their state (enabled | +| | | or disabled). | ++-------------------------+-----------------------+----------------------------------------------------+ +| getEnabledControls() | | Returns only the enabled controls. | ++-------------------------+-----------------------+----------------------------------------------------+ + +The list below describes all specific methods for the :php:`ModifyInlineElementControlsEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getControls() | | Returns all controls with their markup. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setControls() | :php:`$controls` | Overwrite the controls. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getControl() | :php:`$identifier` | Returns the markup for the requested control. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setControl() | :php:`$identifier` | Set a control with the given identifier and | +| | :php:`$markup` | markup. Overwrites an existing control with the | +| | | same identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| hasControl() | :php:`$identifier` | Returns whether a control exists for the given | +| | | identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| removeControl() | :php:`$identifier` | Removes a control from the inline element. Returns | +| | | whether the control could be disabled. | ++-------------------------+-----------------------+----------------------------------------------------+ + +The list below describes all common methods of both events: + ++-------------------------+----------------------------------------------------------------------------+ +| Method | Description | ++=========================+============================================================================+ +| getElementData() | Returns the whole element data. | ++-------------------------+----------------------------------------------------------------------------+ +| getRecord() | Returns the current record of the controls are created for. | ++-------------------------+----------------------------------------------------------------------------+ +| getParentUid() | Returns the uid of the parent (embedding) record (uid or NEW...). | ++-------------------------+----------------------------------------------------------------------------+ +| getForeignTable() | Returns the table (foreign_table) the controls are created for. | ++-------------------------+----------------------------------------------------------------------------+ +| getFieldConfiguration() | Returns the TCA configuration of the inline record field. | ++-------------------------+----------------------------------------------------------------------------+ +| isVirtual() | Returns whether the current records is only virtually shown and not | +| | physically part of the parent record. | ++-------------------------+----------------------------------------------------------------------------+ + +Impact +====== + +The main advantages of the new PSR-14 events are an increased amount of +available information, the object-oriented approach as well as the new +built-in convenience features. + +Additionally, it's no longer necessary to implement empty methods, required +by the interface. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97232-NewTCATypeDatetime.rst b/Documentation/Changelog/12.0/Feature-97232-NewTCATypeDatetime.rst new file mode 100644 index 0000000..a7f6069 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97232-NewTCATypeDatetime.rst @@ -0,0 +1,121 @@ +.. include:: /Includes.rst.txt + +.. _feature-97232: + +========================================= +Feature: #97232 - New TCA type "datetime" +========================================= + +See :issue:`97232` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use +cases, the TCA type :php:`datetime` has been introduced. It replaces the +:php:`renderType=inputDateTime` of TCA type :php:`input`. + +The TCA type :php:`datetime` features the following column configuration: + +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`dbType`: :php:`date`, :php:`time`, :php:`datetime` +- :php:`default` +- :php:`disableAgeDisplay` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`format`: :php:`datetime` (default), :php:`date`, :php:`time`, :php:`timesec` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`range`: :php:`lower`, :php:`upper` +- :php:`readOnly` +- :php:`required` +- :php:`search` +- :php:`size` + +.. note:: + + The :php:`eval=integer` option is now automatically set for the element + in case no specific :php:`dbType` has been defined. It should therefore + be removed from the TCA configuration. + +.. note:: + + The :php:`format` option defines how the display of the field value + will be in e.g. FormEngine. The storage format is defined via :php:`dbType` + and falls back to :php:`eval=integer`. + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`readOnly` +- :typoscript:`size` + +A complete migration from :php:`renderType=inputDateTime` to :php:`type=datetime` +looks like the following: + +.. code-block:: php + + // Before + + 'a_datetime_field' => [ + 'label' => 'Datetime field', + 'config' => [ + 'type' => 'input', + 'renderType' => 'inputDateTime', + 'required' => true, + 'size' => 20, + 'max' => 1024, + 'eval' => 'date,int', + 'default' => 0, + ], + ], + + // After + + 'a_datetime_field' => [ + 'label' => 'Datetime field', + 'config' => [ + 'type' => 'datetime', + 'format' => 'date', + 'required' => true, + 'size' => 20, + 'default' => 0, + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +.. note:: + + The corresponding FormEngine class has been renamed from :php:`InputDateTimeElement` + to :php:`DatetimeElement`. An entry in the "ClassAliasMap" has been added + for extensions calling this class directly, which is rather unlikely. The + extension scanner will report any usage, which should then be migrated. + +Automatic database fields +------------------------- + +TYPO3 automatically creates database fields for TCA type :php:`datetime` +columns, if they have not already been defined in an extension's +:file:`ext_tables.sql` file. This also supports columns, having a +native database type (:php:`dbType`) defined. Fields without a native +type always define :sql:`default 0` and are always signed (to allow +negative timestamps). As long as a column does not use :php:`nullable=true`, +the fields are also always defined as :sql:`NOT NULL`. + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`datetime`. Next to reduced TCA configuration, +the new type allows to omit the corresponding database field definition. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97254-AddLuxembourgishAsSupportedLanguage.rst b/Documentation/Changelog/12.0/Feature-97254-AddLuxembourgishAsSupportedLanguage.rst new file mode 100644 index 0000000..19661c0 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97254-AddLuxembourgishAsSupportedLanguage.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-97254: + +========================================================= +Feature: #97254 - Add Luxembourgish as supported language +========================================================= + +See :issue:`97254` + +Description +=========== + +TYPO3 now supports Luxembourgish out of the box. + +Luxembourgish is one of the three administrative languages of Luxembourg +(https://en.wikipedia.org/wiki/Luxembourgish). + +The ISO 639-1 code for Luxembourgish is "lb", which is how TYPO3 +is accessing the language internally. + +Impact +====== + +It is now possible to + +* Fetch translated labels from translations.typo3.org / Crowdin automatically + within the TYPO3 Backend +* Switch the Backend Interface to Luxembourgish language +* Create a new language in a site configuration using Luxembourgish +* Create translation files with the "lb" prefix (such as `lb.locallang.xlf`) + to create your own labels + +and TYPO3 picks Luxembourgish as a language just like any other +supported language. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97271-NewTCATypeColor.rst b/Documentation/Changelog/12.0/Feature-97271-NewTCATypeColor.rst new file mode 100644 index 0000000..6178bd7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97271-NewTCATypeColor.rst @@ -0,0 +1,110 @@ +.. include:: /Includes.rst.txt + +.. _feature-97271: + +====================================== +Feature: #97271 - New TCA type "color" +====================================== + +See :issue:`97271` + +Description +=========== + +Especially TCA type :php:`input` has a wide range of use cases, depending +on the configured :php:`renderType` and the :php:`eval` options. Determination +of the semantic meaning is therefore usually quite hard and often leads to +duplicated checks and evaluations in custom extension code. + +In our effort of introducing dedicated TCA types for all those use +cases, the TCA type :php:`color` has been introduced. It replaces the +:php:`renderType=colorpicker` of TCA type :php:`input`. + +The TCA type :php:`color` features the following column configuration: + +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`default` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`mode` +- :php:`nullable` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`search` +- :php:`size` +- :php:`valuePicker`: :php:`items` + +.. note:: + The value of TCA type :php:`color` columns is automatically trimmed before + being stored in the database. Therefore, the :php:`eval=trim` option is no + longer needed and should be removed from the TCA configuration. + +.. note:: + The :php:`valuePicker` allows to define default color codes via :php:`items` + for a TCA type :php:`color` field. + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`readOnly` +- :typoscript:`size` + +A complete migration from :php:`renderType=colorpicker` to :php:`type=color` +looks like the following: + +.. code-block:: php + + // Before + + 'a_color_field' => [ + 'label' => 'Color field', + 'config' => [ + 'type' => 'input', + 'renderType' => 'colorpicker', + 'required' => true, + 'size' => 20, + 'max' => 1024, + 'eval' => 'trim', + 'valuePicker' => [ + 'items' => [ + ['typo3 orange', '#FF8700'], + ], + ], + ], + ], + + // After + + 'a_color_field' => [ + 'label' => 'Color field', + 'config' => [ + 'type' => 'color', + 'required' => true, + 'size' => 20, + 'valuePicker' => [ + 'items' => [ + ['typo3 orange', '#FF8700'], + ], + ], + ] + ] + +An automatic TCA migration is performed on the fly, migrating all occurrences +to the new TCA type and triggering a PHP :php:`E_USER_DEPRECATED` error +where code adoption has to take place. + +.. note:: + The corresponding FormEngine class has been renamed from + :php:`InputColorPickerElement` to :php:`ColorElement`. An entry in + the "ClassAliasMap" has been added for extensions calling this class + directly, which is rather unlikely. The extension scanner will report + any usage, which should then be migrated. + +Impact +====== + +It's now possible to simplify the TCA configuration by using the new +dedicated TCA type :php:`color`. + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst b/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst new file mode 100644 index 0000000..e8ce086 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97305-IntroduceCSRF-likeRequest-tokenHandling.rst @@ -0,0 +1,189 @@ +.. include:: /Includes.rst.txt + +.. _feature-97305-1664099950: + +============================================================ +Feature: #97305 - Introduce CSRF-like request-token handling +============================================================ + +See :issue:`97305` + +Description +=========== + +A CSRF-like request-token handling has been introduced to mitigate +potential cross-site requests on actions with side-effects. This approach +does not require an existing server-side user session, but uses a nonce +(number used once) as a "pre-session". The main scope is to ensure a user +actually has visited a page, before submitting data to the web server. + +This token can only be used for HTTP methods `POST`, `PUT` or `PATCH`, but +for instance not for `GET` request. + +New :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` resolves +request-tokens and nonce values from a request and enhances responses with +a nonce value in case the underlying application issues one. Both items are +serialized as JSON Web Token (JWT) hash signed with `HS256`. Request-tokens +use the provided nonce value during signing. + +Session cookie names involved for providing the nonce value: + +* `typo3nonce_[hash]` in case request served with plain HTTP +* `__Secure-typo3nonce_[hash]` in case request served with secured HTTPS + +Submitting request-token value to application: + +* HTTP body, e.g. in `<form>` via parameter `__RequestToken` +* HTTP header, e.g. in XHR via header `X-TYPO3-RequestToken` + +The sequence looks like the following: + +1. Retrieve nonce and request-token values +------------------------------------------ + +This happens on the previous legitimate visit on a page that offers +a corresponding form that shall be protected. The `RequestToken` and `Nonce` +objects (later created implicitly in this example) are organized in the new +:php:`\TYPO3\CMS\Core\Context\SecurityAspect`. + +.. code-block:: php + + use \TYPO3\CMS\Core\Context\Context; + use \TYPO3\CMS\Core\Security\RequestToken; + use \TYPO3\CMS\Fluid\View\StandaloneView; + + class MyController + { + protected StandaloneView $view; + protected Context $context; + + public function showFormAction() + { + // creating new request-token with scope + // 'my/process' and hand over to view + $requestToken = RequestToken::create('my/process'); + $this->view->assign('requestToken', $requestToken) + // ... + } + + public function processAction() + { + } + } + +.. code-block:: html + + <!-- in ShowForm.html template: assign request-token object for view-helper --> + <f:form action="process" requestToken="{requestToken}>...</f:form> + +The HTTP response on calling the shown controller-action above will be like this: + +.. code-block:: text + + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + Set-Cookie: typo3nonce_[hash]=[nonce-as-jwt]; path=/; httponly; samesite=strict + + ... + <form action="/my/process" method="post"> + ... + <input type="hidden" name="__request_token" value="[request-token-as-jwt]"> + ... + </form> + +2. Invoke action request and provide nonce and request-token values +------------------------------------------------------------------- + +When submitting the form and invoking the corresponding action, same-site +cookies `typo3nonce_[hash]` and request-token value `__RequestToken` are sent +back to the server. Without using a separate nonce in a scope that is protected +by the client, corresponding request-token could be easily extracted from markup +and used without having the possibility to verify the procedural integrity. + +Middleware :php:`\TYPO3\CMS\Core\Middleware\RequestTokenMiddleware` takes care +of providing received nonce and received request-token values in +:php:`\TYPO3\CMS\Core\Context\SecurityAspect`. The handling controller-action +needs to verify that the request-token has the expected `'my/process'` scope. + +.. code-block:: php + + class MyController + { + protected \TYPO3\CMS\Fluid\View\StandaloneView $view; + protected \TYPO3\CMS\Core\Context\Context $context; + + public function showFormAction() {} + + public function processAction() + { + $securityAspect = \TYPO3\CMS\Core\Context\SecurityAspect::provideIn($this->context); + $requestToken = $securityAspect->getReceivedRequestToken(); + + if ($requestToken === null) { + // no request-token was provided in request + // e.g. (overridden) templates need to be adjusted + } elseif ($requestToken === false) { + // there was a request-token, which could not be verified with the nonce + // e.g. when nonce cookie has been overridden by another HTTP request + } elseif ($requestToken->scope !== 'my/process') { + // there was a request-token, but for a different scope + // e.g. when a form with different scope was submitted + } else { + // request-token was valid and for the expected scope + $this->doTheMagic(); + // middleware takes care to remove the cookie in case no other + // nonce value shall be emitted during the current HTTP request + if ($requestToken->getSigningSecretIdentifier() !== null) { + $securityAspect->getSigningSecretResolver()->revokeIdentifier( + $requestToken->getSigningSecretIdentifier() + ); + } + } + } + } + +Intercept & Adjust Request Token +-------------------------------- + +Scenarios that are not using a login callback without having the possibility to +submit a request-token, :php:`\TYPO3\CMS\Core\Authentication\Event\BeforeRequestTokenProcessedEvent` +can be used to generate the token individually. + +.. code-block:: php + + use TYPO3\CMS\Core\Authentication\Event\BeforeRequestTokenProcessedEvent; + use TYPO3\CMS\Core\Security\RequestToken; + + final class ProcessRequestTokenListener + { + public function __invoke(BeforeRequestTokenProcessedEvent $event): void + { + $user = $event->getUser(); + $requestToken = $event->getRequestToken(); + // fine, there is a valid request-token + if ($requestToken instanceof RequestToken) { + return; + } + // validate individual requirements/checks + // ... + $event->setRequestToken( + RequestToken::create('core/user-auth/' . strtolower($user->loginType)) + ); + } + } + +Impact +====== + +In case a form is protected with the new request-token, actors have to visit the +page containing the form before being able to actually submit data to the +underlying server-side processing. + +When working with multiple browser tabs, an existing nonce value (stored as +session cookie in users' browser) might be overridden. + +The current concept uses a :php:`\TYPO3\CMS\Core\Security\NoncePool` which +supports five different nonces in the same request. The pool purges nonces +15 minutes (900 seconds) after they have been issued. + +.. index:: Backend, Fluid, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97306-RefreshTheLookOfPagemodule.rst b/Documentation/Changelog/12.0/Feature-97306-RefreshTheLookOfPagemodule.rst new file mode 100644 index 0000000..8b2df28 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97306-RefreshTheLookOfPagemodule.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-97306: + +================================================= +Feature: #97306 - Refresh the look of page module +================================================= + +See :issue:`97306` + +Description +=========== + +The readability of the page module interface has been simplified and optimized. +It is based on the UX Team's concept of improving user experience for editors. + +Further links to the page module concept and official communication: + +* First step page module + `simplification <https://typo3.org/article/structured-content-initiative-what-happened-between-july-and-november>`__ + with screenshots +* Promotion of page module + `pilot <https://typo3.org/article/structured-content-initiative-feedback-wanted>`__ + +Impact +====== + +* Underlying CSS is refactored and optimized for future adaptations. +* Content element boxes and their header buttons are visually simplified. +* Hidden content elements are now differentiated with a better opacity + and a dotted border. +* :guilabel:`New Content` buttons were placed in the centre in preparation + for implementation of later concepts. +* A new button for the content element context menu is added in the content + element header right button bar. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97320-NewRegistrationForReportsAndStatus.rst b/Documentation/Changelog/12.0/Feature-97320-NewRegistrationForReportsAndStatus.rst new file mode 100644 index 0000000..47829d7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97320-NewRegistrationForReportsAndStatus.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-97320: + +========================================================= +Feature: #97320 - New registration for reports and status +========================================================= + +See :issue:`97320` + +Description +=========== + +The system extension `reports` provides the possibility to render various reports. +The most prominent and (only one) provided by the TYPO3 Core is the one called `Status`. +The Status Report itself is extendable and shows status like a system environment check +and status of the used extensions. + +Reports +------- + +As all `reports` have to implement the :php:`ReportInterface` this fact is now +used to automatically register the `report`, based on the interface, +if :yaml:`autoconfigure` is enabled in :file:`Services.yaml`. Alternatively, +one can manually tag a custom `report` with the +:yaml:`reports.report` tag (see section "Migration" in the +:doc:`breaking changelog <Breaking-97320-RegisterReportAndStatusViaServiceConfiguration>`). + +Due to the autoconfiguration, the following methods have to be implemented: + +- :php:`getIdentifier` +- :php:`getIconIdentifier` +- :php:`getTitle` +- :php:`getDescription` + +Status +------ + +As all `status` have to implement the :php:`StatusProviderInterface` this fact is now +used to automatically register the `status`, based on the interface, +if :yaml:`autoconfigure` is enabled in :file:`Services.yaml`. Alternatively, +one can manually tag a custom `report` with the +:yaml:`reports.status` tag (eee section "Migration" in the +:doc:`breaking changelog <./Breaking-97320-RegisterReportAndStatusViaServiceConfiguration>`). + +Due to the autoconfiguration, the label has to be provided by the +class directly, using the now required :php:`getLabel()` method. + +Impact +====== + +`reports` and `status` are now automatically registered through the service +configuration, based on the implemented interface. + +.. index:: Backend, PHP-API, ext:reports diff --git a/Documentation/Changelog/12.0/Feature-97326-OpenBackendPageFromAdminPanel.rst b/Documentation/Changelog/12.0/Feature-97326-OpenBackendPageFromAdminPanel.rst new file mode 100644 index 0000000..a0bfbf9 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97326-OpenBackendPageFromAdminPanel.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-97326: + +==================================================== +Feature: #97326 - Open backend page from admin panel +==================================================== + +See :issue:`97326` + +Description +=========== + +In previous versions with EXT:feedit the admin panel had a button to directly +open the currently viewed page in the backend. Since we have backend routing +available, we can implement a similar feature directly in the admin panel. +This will not replace the complete functionality of `feedit`, but it gives editors +a possibility to navigate via the frontend and directly open the backend with a +single click. + +Impact +====== + +The admin panel has a direct link to the corresponding backend page. + +.. index:: ext:adminpanel diff --git a/Documentation/Changelog/12.0/Feature-97347-LiveSearchKeyboardNavigation.rst b/Documentation/Changelog/12.0/Feature-97347-LiveSearchKeyboardNavigation.rst new file mode 100644 index 0000000..ca6bb44 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97347-LiveSearchKeyboardNavigation.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _feature-97347: + +========================================================== +Feature: #97347 - Allow keyboard navigation in live search +========================================================== + +See :issue:`97347` + +Description +=========== + +It is now possible to select results in the live search +using the arrow keys up/down and enter/tab to open the +selected result. On top, the input field stays focused after +a record is selected, allowing a user to quickly search and open any +record from the result list without using the mouse. + +Previously, it was not possible to use the keyboard to navigate +through the search results of the live search. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97384-TCAOptionNullable.rst b/Documentation/Changelog/12.0/Feature-97384-TCAOptionNullable.rst new file mode 100644 index 0000000..65838c7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97384-TCAOptionNullable.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-97384: + +======================================= +Feature: #97384 - TCA option "nullable" +======================================= + +See :issue:`97384` + +Description +=========== + +In order to further thin out the TCA :php:`eval` option, the `null` value has +been extracted into its own option: :php:`nullable`, which is a `boolean` value. + +Example: + +.. code-block:: php + + 'columns' => [ + 'nullable_column' => [ + 'title' => 'A nullable field', + 'config' => [ + 'nullable' => true, + 'eval' => 'trim', + ], + ], + ], + +Impact +====== + +It is now possible to define TCA fields as nullable by setting the +:php:`nullable` option to :php:`true`. The database field should have the +according :sql:`NULL` option set. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97388-ConfigurablePasswordPolicies.rst b/Documentation/Changelog/12.0/Feature-97388-ConfigurablePasswordPolicies.rst new file mode 100644 index 0000000..f8e3454 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97388-ConfigurablePasswordPolicies.rst @@ -0,0 +1,136 @@ +.. include:: /Includes.rst.txt + +.. _feature-97388: + +========================================================== +Feature: #97388 - Introduce configurable password policies +========================================================== + +See :issue:`97388` + +Description +=========== + +TYPO3 now includes a PasswordPolicyValidator component which can be used to +validate passwords against configurable password policies. TYPO3 now also +includes a default password policy which ensures that passwords meet +the following requirements: + +* At least 8 chars +* At least one number +* At least one upper case char +* At least one special char +* Must be different than current password (if available) + +Password policies can be configured individually for both frontend and +backend context. It is also possible to extend a password policy with own +validation requirements. + +As a first step, the included default password policy is applied to ext:setup +to ensure, that new passwords of backend users entered in "User Settings" +will match the default password requirements. + +Impact +====== + +The new password of an existing TYPO3 backend user has to meet the default +password policy when set using ext:setup. + +Configuring password policies +----------------------------- + +A password policy is defined in the TYPO3 global configuration. Each policy +must have a unique identifier (the identifier `default` is reserved by TYPO3) +and must at least contain one validator. + +The example below shows, how the password policy with the identifier `simple` +is configured: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['passwordPolicies'] = [ + 'simple' => [ + 'validators' => [ + \TYPO3\CMS\Core\PasswordPolicy\Validator\CorePasswordValidator::class => [ + 'options' => [ + 'minimumLength' => 6, + ], + ], + ], + ], + ]; + +The password policy in the example uses the `CorePasswordValidator` with the +option to require a password with a minimum length of 6 chars. + +The password policy identifier is used to assign the defined password policy +to the either backend and/or frontend context. By default, TYPO3 uses the +password policy `default` as shown below: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] = 'default'; + $GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy'] = 'default'; + +Password policy validators +-------------------------- + +TYPO3 ships with two password policy validators, which are both used in the +default password policy. + +\\TYPO3\\CMS\\Core\\PasswordPolicy\\Validator\\CorePasswordValidator +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This validator has the ability to ensure a complex password with a defined +minimum length and 4 individual requirements. + +The following options are available: + ++------------------------------+-----------------------+---------+---------+ +| Option | Description | Type | Default | ++------------------------------+-----------------------+---------+---------+ +| `minimumLength` | Minimum length | Integer | 8 | ++------------------------------+-----------------------+---------+---------+ +| `upperCaseCharacterRequired` | Upper case char check | Boolean | false | ++------------------------------+-----------------------+---------+---------+ +| `lowerCaseCharacterRequired` | Lower case char check | Boolean | false | ++------------------------------+-----------------------+---------+---------+ +| `digitCharacterRequired` | Digit check | Boolean | false | ++------------------------------+-----------------------+---------+---------+ +| `specialCharacterRequired` | Special char check | Boolean | false | ++------------------------------+-----------------------+---------+---------+ + +\\TYPO3\\CMS\\Core\\PasswordPolicy\\Validator\\NotCurrentPasswordValidator +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This validator can be used to ensure, that the new user password is not +equal to the old password. The validator must always be configured with +the exclude action :php:`\TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction::NEW_USER_PASSWORD`, +because it should be excluded, when a new user account is created. + +Disable password policies globally +---------------------------------- + +To disable the password policy globally (e.g. for local development) an +empty string has to be supplied as password policy for both frontend and +backend context as shown below: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] = ''; + $GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy'] = ''; + +Custom password validator +------------------------- + +To create a custom password validator, a new class has to be created which +extends :php:`\TYPO3\CMS\Core\PasswordPolicy\Validator\AbstractPasswordValidator`. +It is required to overwrite the following functions: + +* :php:`public function initializeRequirements(): void` +* :php:`public function validate(string $password, ?ContextData $contextData = null): bool` + +Please refer to :php:`\TYPO3\CMS\Core\PasswordPolicy\Validator\CorePasswordValidator` +for a detailed implementation example. + +.. index:: Backend diff --git a/Documentation/Changelog/12.0/Feature-97449-PSR-14EventsForModifyingFlexFormParsing.rst b/Documentation/Changelog/12.0/Feature-97449-PSR-14EventsForModifyingFlexFormParsing.rst new file mode 100644 index 0000000..3d2ed73 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97449-PSR-14EventsForModifyingFlexFormParsing.rst @@ -0,0 +1,209 @@ +.. include:: /Includes.rst.txt + +.. _feature-97449: + +============================================================== +Feature: #97449 - PSR-14 events for modifying FlexForm parsing +============================================================== + +See :issue:`97449` + +Description +=========== + +Four new PSR-14 events have been introduced which serve as a more powerful +and flexible alternative for the now removed :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][FlexFormTools::class]['flexParsing']` +hooks. Corresponding docblocks describe how and when the new PSR-14 events + +- :php:`TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureParsedEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureParsedEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureIdentifierInitializedEvent` +- :php:`TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureIdentifierInitializedEvent` + +should be used. + +Example +======= + +Registration of the events in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\FlexFormParsingModifyEventListener: + tags: + - name: event.listener + identifier: 'form-framework/set-data-structure' + method: 'setDataStructure' + - name: event.listener + identifier: 'form-framework/modify-data-structure' + method: 'modifyDataStructure' + - name: event.listener + identifier: 'form-framework/set-data-structure-identifier' + method: 'setDataStructureIdentifier' + - name: event.listener + identifier: 'form-framework/modify-data-structure-identifier' + method: 'modifyDataStructureIdentifier' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureIdentifierInitializedEvent; + use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureParsedEvent; + use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureIdentifierInitializedEvent; + use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureParsedEvent; + + final class FlexFormParsingModifyEventListener + { + public function setDataStructure(BeforeFlexFormDataStructureParsedEvent $event): void + { + $identifier = $event->getIdentifier(); + if (($identifier['type'] ?? '') === 'my_custom_type') { + $event->setDataStructure('FILE:EXT:myext/Configuration/FlexForms/MyFlexform.xml'); + } + } + + public function modifyDataStructure(AfterFlexFormDataStructureParsedEvent $event): void + { + $identifier = $event->getIdentifier(); + if (($identifier['type'] ?? '') === 'my_custom_type') { + $parsedDataStructure = $event->getDataStructure(); + $parsedDataStructure['sheets']['sDEF']['ROOT']['TCEforms']['sheetTitle'] = 'Some dynamic custom sheet title'; + $event->setDataStructure($parsedDataStructure); + } + } + + public function setDataStructureIdentifier(BeforeFlexFormDataStructureIdentifierInitializedEvent $event): void + { + if ($event->getTableName() === 'tx_myext_sometable') { + $event->setIdentifier([ + 'type' => 'my_custom_type', + ]); + } + } + + public function modifyDataStructureIdentifier(AfterFlexFormDataStructureIdentifierInitializedEvent $event): void + { + $identifier = $event->getIdentifier(); + if (($identifier['type'] ?? '') !== 'my_custom_type') { + $identifier['type'] = 'my_custom_type'; + } + $event->setIdentifier($identifier); + } + } + +Available Methods +================= + +The list below describes all available methods for :php:`BeforeFlexFormDataStructureParsedEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getIdentifier() | | Returns the resolved data structure identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setDataStructure() | :php:`$dataStructure` | Allows to either set an already parsed data | +| | | structure as :php:`array`, a file reference or the | +| | | XML structure as :php:`string`. Setting a data | +| | | structure will immediately stop propagation. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getDataStructure() | | Returns the current data structure, which will | +| | | always be :php:`null` for listeners, since the | +| | | event propagation is stopped as soon as a listener | +| | | sets a data structure. | ++-------------------------+-----------------------+----------------------------------------------------+ +| isPropagationStopped() | | Returns whether propagation has been stopped. | ++-------------------------+-----------------------+----------------------------------------------------+ + +.. note:: + + Using the now-removed hook method :php:`parseDataStructureByIdentifierPreProcess()` previously required + implementations to always return an :php:`array` or :php:`string`. This means, implementations returned + an empty :php:`array` or empty :php:`string` in case they did not want to set a data structure, allowing + further implementations to be called. This has now changed. As soon as a listener sets a data structure + using the :php:`setDataStructure()` method, the event propagation is stopped immediately and no further + listeners are being called. Therefore, listeners should avoid setting an empty :php:`array` or an empty + :php:`string`, but should just "return" without any change to the :php:`$event` object in such a case. + +The list below describes all available methods for :php:`AfterFlexFormDataStructureParsedEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getIdentifier() | | Returns the resolved data structure identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setDataStructure() | :php:`$dataStructure` | Allows to modify or completely replace the parsed | +| | | data structure. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getDataStructure() | | Returns the current data structure, which has been | +| | | processed and parsed by the :php:`FlexFormTools` | +| | | component. Might contain additional data from | +| | | previously called listeners. | ++-------------------------+-----------------------+----------------------------------------------------+ + +The list below describes all available methods for :php:`BeforeFlexFormDataStructureIdentifierInitializedEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getFieldTca() | | Returns the full TCA of the currently handled | +| | | field, having `type=flex` set. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getTableName() | | Returns the table name of the TCA field. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getFieldName() | | Returns the TCA field name. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getRow() | | Returns the whole database row of the record. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setIdentifier() | :php:`$identifier` | Allows to define the data structure identifier for | +| | | the TCA field. Setting an identifier will | +| | | immediately stop propagation. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getIdentifier() | | Returns the current data structure identifier, | +| | | which will always be :php:`null` for listeners, | +| | | since the event propagation is stopped as soon | +| | | as a listener defines an identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| isPropagationStopped() | | Returns whether propagation has been stopped. | ++-------------------------+-----------------------+----------------------------------------------------+ + +.. note:: + + Using the now removed hook method :php:`getDataStructureIdentifierPreProcess()` previously required + implementations to always return an :php:`array`. This means, implementations returned an empty + :php:`array` in case they did not want to set an identifier, allowing further implementations to be + called. This has now changed. As soon as a listener sets the identifier using the :php:`setIdentifier()` + method, the event propagation is stopped immediately and no further listeners are being called. + Therefore, listeners should avoid setting an empty :php:`array`, but should just "return" without + any change to the :php:`$event` object in such a case. + +The list below describes all available methods for :php:`AfterFlexFormDataStructureIdentifierInitializedEvent`: + ++-------------------------+-----------------------+----------------------------------------------------+ +| Method | Parameters | Description | ++=========================+=======================+====================================================+ +| getFieldTca() | | Returns the full TCA of the currently handled | +| | | field, having `type=flex` set. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getTableName() | | Returns the table name of the TCA field. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getFieldName() | | Returns the TCA field name. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getRow() | | Returns the whole database row of the record. | ++-------------------------+-----------------------+----------------------------------------------------+ +| setIdentifier() | :php:`$identifier` | Allows to modify or completely replace the | +| | | initialized data structure identifier. | ++-------------------------+-----------------------+----------------------------------------------------+ +| getIdentifier() | | Returns the initialized data structure identifier, | +| | | which has either been defined by an event listener | +| | | or set to the default by the :php:`FlexFormTools` | +| | | component. | ++-------------------------+-----------------------+----------------------------------------------------+ + +Impact +====== + +It's now possible to fully control the FlexForm parsing using an +object oriented approach with four new PSR-14 events. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97450-PSR-14EventForModifyingVersionDifferences.rst b/Documentation/Changelog/12.0/Feature-97450-PSR-14EventForModifyingVersionDifferences.rst new file mode 100644 index 0000000..8ed683f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97450-PSR-14EventForModifyingVersionDifferences.rst @@ -0,0 +1,93 @@ +.. include:: /Includes.rst.txt + +.. _feature-97450: + +================================================================ +Feature: #97450 - PSR-14 event for modifying version differences +================================================================ + +See :issue:`97450` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Workspaces\Event\ModifyVersionDifferencesEvent` +has been introduced which serves as a direct replacement for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['workspaces']['modifyDifferenceArray']` +:doc:`hook <../12.0/Breaking-97450-RemovedHookForModifyingVersionDifferences>`. + +It can be used to modify the version differences data, used for the display +in the :guilabel:`Workspaces` backend module. Those data can be accessed with +the :php:`getVersionDifferences()` method, and updated using +the :php:`setVersionDifferences(array $versionDifferences)` method. + +The version differences :php:`array` contains the differences of each field, +with the following keys: + +- :php:`field`: The corresponding field name, +- :php:`label`: The corresponding fields' label, +- :php:`content`: The field values difference + +Furthermore does the event provide the following methods + +- :php:`getLiveRecordData()`: Returns the records live data (used to create the version difference) +- :php:`getParameters()`: Returns meta information like current stage and current workspace + +.. note:: + The removed hook allowed to update the live record data. This however had + no effect since those data are not further used by TYPO3. Therefore, the + new event does no longer provide a setter for the live record data. + +.. note:: + The removed hook contained an instance of :php:`DiffUtility`, which can + be used to generate the differences :php:`string`. Since PSR-14 events + are usually pure data objects, without dependencies to any service, the + new PSR-14 event does no longer provide an instance of :php:`DiffUtility`. + Listeners have to inject the service on their own - if needed. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Workspaces\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/workspaces/modify-version-differences' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\DiffUtility; + use TYPO3\CMS\Workspaces\Event\ModifyVersionDifferencesEvent; + + final class MyEventListener + { + public function __construct(protected readonly DiffUtility $diffUtility) + { + $this->diffUtility->stripTags = false; + } + + public function __invoke(ModifyVersionDifferencesEvent $event): void + { + $differences = $event->getVersionDifferences(); + foreach($differences as $key => $difference) { + if ($difference['field'] === 'my_test_field') { + $differences[$key]['content'] = $this->diffUtility->makeDiffDisplay('a', 'b'); + } + } + + $event->setVersionDifferences($differences); + } + } + +Impact +====== + +It's now possible to modify the version differences of a versioned record, +using the new PSR-14 event :php:`ModifyVersionDifferencesEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97451-PSR-14EventsForBackendPageController.rst b/Documentation/Changelog/12.0/Feature-97451-PSR-14EventsForBackendPageController.rst new file mode 100644 index 0000000..9041115 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97451-PSR-14EventsForBackendPageController.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _feature-97451: + +================================================================== +Feature: #97451 - PSR-14 events for modifying backend page content +================================================================== + +See :issue:`97451` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent` has +been introduced which serves as a direct replacement for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['constructPostProcess']`, +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['renderPreProcess']`, and +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/backend.php']['renderPostProcess']` +:doc:`hooks <../12.0/Breaking-97451-RemoveBackendControllerPageHooks>`. + +The new event triggers after the page is rendered and includes +the rendered page body. Listeners may overwrite the page string if desired. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/after-backend-controller-render' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent; + + final class MyEventListener + { + public function __invoke(AfterBackendPageRenderEvent $event): void + { + $content = $event->getContent() . ' I was here'; + $event->setContent($content); + } + } + +Impact +====== + +It's now possible to modify the backend page using the new PSR-14 event :php:`AfterBackendPageRenderEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97454-PSR14EventsForLinkBrowserLifecycle.rst b/Documentation/Changelog/12.0/Feature-97454-PSR14EventsForLinkBrowserLifecycle.rst new file mode 100644 index 0000000..42b87cf --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97454-PSR14EventsForLinkBrowserLifecycle.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _feature-97454-1657327622: + +=================================================================== +Feature: #97454 - PSR-14 events for modifying link browser behavior +=================================================================== + +See :issue:`97454` + +Description +=========== + +Two new PSR-14 events :php:`\TYPO3\CMS\Backend\Controller\Event\ModifyLinkHandlersEvent` and +:php:`\TYPO3\CMS\Backend\Controller\Event\ModifyAllowedItemsEvent` have been introduced which +serve as a direct replacement for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['LinkBrowser']['hooks']` +:doc:`hooks <../12.0/Breaking-97454-RemoveLinkBrowserHooks>`. + +The :php:`ModifyLinkHandlersEvent` is triggered before link handlers are +executed, allowing listeners to modify the set of handlers that will be used. +It is the direct replacement for the method :php:`modifyLinkHandlers()` in the +LinkBrowser hook. + +The :php:`ModifyAllowedItemsEvent` can be used to dynamically modify the +allowed link types. It is the direct replacement for the method :php:`modifyAllowedItems()` +in the LinkBrowser hook. + +.. seealso:: + * :ref:`breaking-97454-1657327622` + * :ref:`t3coreapi:modifyLinkHandlers` + * :ref:`t3coreapi:ModifyLinkHandlersEvent` + * :ref:`t3coreapi:ModifyAllowedItemsEvent` + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/recordlist/link-handlers' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\ModifyLinkHandlersEvent; + + final class MyEventListener + { + public function __invoke(ModifyLinkHandlersEvent $event): void + { + $handler = $event->getLinkHandler('url.'); + $handler['label'] = 'My custom label'; + $event->setLinkHandler('url.', $handler); + } + } + +Impact +====== + +It's now possible to modify link handlers behavior using the new PSR-14 +:php:`ModifyLinkHandlersEvent` and :php:`ModifyAllowedItemsEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97480-SymfonyExpressionLanguageProvidersAvailableInConfigurationModule.rst b/Documentation/Changelog/12.0/Feature-97480-SymfonyExpressionLanguageProvidersAvailableInConfigurationModule.rst new file mode 100644 index 0000000..1a66af7 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97480-SymfonyExpressionLanguageProvidersAvailableInConfigurationModule.rst @@ -0,0 +1,19 @@ +.. include:: /Includes.rst.txt + +.. _feature-97480: + +========================================================================================= +Feature: #97480 - Symfony Expression Language providers available in configuration module +========================================================================================= + +See :issue:`97480` + +Description +=========== + +A new entry `Symfony Expression Language Providers` is available in the menu +of the :guilabel:`System > Configuration` module of the `lowlevel` system extension. +When selected, all Symfony expression language providers including their +`variables` and `functions` are shown. + +.. index:: Backend, ext:lowlevel diff --git a/Documentation/Changelog/12.0/Feature-97544-PSR-14EventsForModifyingPreviewURIs.rst b/Documentation/Changelog/12.0/Feature-97544-PSR-14EventsForModifyingPreviewURIs.rst new file mode 100644 index 0000000..9aee299 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97544-PSR-14EventsForModifyingPreviewURIs.rst @@ -0,0 +1,123 @@ +.. include:: /Includes.rst.txt + +.. _feature-97544: + +========================================================== +Feature: #97544 - PSR-14 events for modifying preview URIs +========================================================== + +See :issue:`97544` + +Description +=========== + +Two new PSR-14 events :php:`\TYPO3\CMS\Backend\Routing\Event\BeforePagePreviewUriGeneratedEvent` +and :php:`\TYPO3\CMS\Backend\Routing\Event\AfterPagePreviewUriGeneratedEvent` +have been introduced. Those serve as a direct replacement for the now deprecated +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['viewOnClickClass']` +:doc:`hook <../12.0/Deprecation-97544-PreviewURIGenerationRelatedFunctionalityInBackendUtility>`. + +The :php:`BeforePagePreviewUriGeneratedEvent` is executed in the +:php:`PreviewUriBuilder->buildUri()`, before the preview URI is actually built. +It allows to either adjust the parameters, such as the page ID or the language ID, +or to set a custom preview URI, which will then stop the event propagation and +also prevents :php:`PreviewUriBuilder` from building the URI based on the +parameters. + +Methods of :php:`BeforePagePreviewUriGeneratedEvent`: + +- :php:`setPreviewUri(UriInterface $uri)` +- :php:`getPageId()` +- :php:`setPageId(int $pageId)` +- :php:`getLanguageId()` +- :php:`setLanguageId(int $languageId)` +- :php:`getRootline()` +- :php:`setRootline(array $rootline)` +- :php:`getSection()` +- :php:`setSection(string $section)` +- :php:`getAdditionalQueryParameters()` +- :php:`setAdditionalQueryParameters(array $additionalQueryParameters)` +- :php:`getContext()` +- :php:`getOptions()` + +.. note:: + + The overwritten parameters are used for building the URI and are also + passed to the :php:`AfterPagePreviewUriGeneratedEvent`. They however + do not overwrite the related class properties in :php:`PreviewUriBuilder`. + +The :php:`AfterPagePreviewUriGeneratedEvent` is executed in the +:php:`PreviewUriBuilder->buildUri()`, after the preview URI has been built - +or set by an event listener to :php:`BeforePagePreviewUriGeneratedEvent`. It +allows to overwrite the built preview URI. This event however does not feature +the possibility to modify the parameters, since this won't have any effect as +the preview URI is directly returned after event dispatching and no +further action is done by the :php:`PreviewUriBuilder`. + +Methods of :php:`AfterPagePreviewUriGeneratedEvent`: + +- :php:`setPreviewUri(UriInterface $uri)` +- :php:`getPreviewUri()` +- :php:`getPageId()` +- :php:`getLanguageId()` +- :php:`getRootline()` +- :php:`getSection()` +- :php:`getAdditionalQueryParameters()` +- :php:`getContext()` +- :php:`getOptions()` + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-parameters' + method: 'modifyParameters' + - name: event.listener + identifier: 'my-package/backend/modify-preview-uri' + method: 'modifyPreviewUri' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Routing\Event\AfterPagePreviewUriGeneratedEvent; + use TYPO3\CMS\Backend\Routing\Event\BeforePagePreviewUriGeneratedEvent; + + final class MyEventListener + { + public function modifyParameters(BeforePagePreviewUriGeneratedEvent $event): void + { + // Add custom query parameter before URI generation + $event->setAdditionalQueryParameters( + array_replace_recursive( + $event->getAdditionalQueryParameters(), + ['myParam' => 'paramValue'] + ) + ); + } + + public function modifyPreviewUri(AfterPagePreviewUriGeneratedEvent $event): void + { + // Add custom fragment to built preview URI + $uri = $event->getPreviewUri(); + $uri = $uri->withFragment('#customFragment'); + $event->setPreviewUri($uri); + } + } + +Impact +====== + +It's now possible to modify the parameters used to build a preview URI and +also to directly set a custom preview URI, using the new PSR-14 event +:php:`BeforePagePreviewUriGeneratedEvent`. It's also now possible to +modify or completely replace a built preview URI using the new PSR-14 event +:php:`AfterPagePreviewUriGeneratedEvent`. + +.. index:: Backend, Frontend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97595-ProvideDefaultQueueForNotifications.rst b/Documentation/Changelog/12.0/Feature-97595-ProvideDefaultQueueForNotifications.rst new file mode 100644 index 0000000..2739b3d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97595-ProvideDefaultQueueForNotifications.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-97595-1652121042: + +========================================================= +Feature: #97595 - Provide default queue for notifications +========================================================= + +See :issue:`97595` + +Description +=========== + +To allow dispatching notifications to the user the easy way, a new global flash +message queue, identified by +:php:`TYPO3\CMS\Core\Messaging\FlashMessageQueue::NOTIFICATION_QUEUE`, is +introduced that takes the flash message and renders it as a notification on the +top-right edge of the backend. + +Backend modules based on :php:`TYPO3\CMS\Backend\Template\ModuleTemplate` +automatically gain advantage of this feature. + +Example +======= + +.. code-block:: php + + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $notificationQueue = $flashMessageService->getMessageQueueByIdentifier( + FlashMessageQueue::NOTIFICATION_QUEUE + ); + $flashMessage = GeneralUtility::makeInstance( + FlashMessage::class, + 'I\'m a message rendered as notification', + 'Hooray!', + FlashMessage::OK + ); + $notificationQueue->enqueue($flashMessage); + +Impact +====== + +All flash messages dispatched to the flash message queue +:php:`FlashMessageQueue::NOTIFICATION_QUEUE` will be rendered as notifications +in the browser. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-97653-TypoScriptOptionShowWebsiteTitle.rst b/Documentation/Changelog/12.0/Feature-97653-TypoScriptOptionShowWebsiteTitle.rst new file mode 100644 index 0000000..95280a6 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97653-TypoScriptOptionShowWebsiteTitle.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-97653-1652873318: + +====================================================== +Feature: #97653 - TypoScript Option "showWebsiteTitle" +====================================================== + +See :issue:`97653` + +Description +=========== + +A new TypoScript option :typoscript:`config.showWebsiteTitle` has been added. + +The option allows to define whether the website title, which is defined +in the site configuration, should be added to the page title, which is +e.g. used for the :html:`<title>` tag. + +By default, the website title is added. To omit the website title, the +option has to be set to `0`. + +Impact +====== + +It is now possible to influence the rendering of the website title in the +website's title tag by setting the :typoscript:`config.showWebsiteTitle` +option in TypoScript, which is enabled by default. + +.. index:: Frontend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-97729-SupportAttributeApprovedInXlfFiles.rst b/Documentation/Changelog/12.0/Feature-97729-SupportAttributeApprovedInXlfFiles.rst new file mode 100644 index 0000000..787a495 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97729-SupportAttributeApprovedInXlfFiles.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-97729-1654626734: + +========================================================= +Feature: #97729 - Respect attribute approved in XLF files +========================================================= + +See :issue:`97729` + +Description +=========== + +The attribute `approved` of the XLIFF standard is now supported by TYPO3 when +parsing XLF files. This attribute can either have the value `yes` or `no` and +indicates whether the translation is final or not. + +.. code-block:: xml + + <trans-unit id="label2" approved="yes"> + <source>This is label #2</source> + <target>Ceci est le libellé no. 2</target> + </trans-unit> + +The setting :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['lang']['requireApprovedLocalizations']` +can be used to control the behaviour. + +- If it is set to `true` (which is the default setting), only translations with no attribute `approved` + or with the attribute `approved` set to `yes` will be used. +- If it is set to `false`, all translations are used. + +This attribute is particularly useful when working with third-party software and translation agencies. +Allowing unapproved translations may increase the number of translations, possibly at the expense of their quality. + +Impact +====== + +Crowdin supports this attribute. Currently only approved translations are exported. +Therefore no change is expected for official translations. + +.. index:: Backend, Fluid, Frontend, TCA, TypoScript, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97737-PSR-14EventsWhenPageRootlineInFrontendIsResolved.rst b/Documentation/Changelog/12.0/Feature-97737-PSR-14EventsWhenPageRootlineInFrontendIsResolved.rst new file mode 100644 index 0000000..7ba1481 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97737-PSR-14EventsWhenPageRootlineInFrontendIsResolved.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-97737-1654595148: + +================================================================================ +Feature: #97737 - New PSR-14 events when Page + Rootline in Frontend is resolved +================================================================================ + +See :issue:`97737` + +Description +=========== + +Three new PSR-14 events have been added in the process when the main class +:php:`TypoScriptFrontendController` is resolving a page and its rootline, +based on the incoming request. + +* :php:`BeforePageIsResolvedEvent` +* :php:`AfterPageWithRootLineIsResolvedEvent` +* :php:`AfterPageAndLanguageIsResolvedEvent` + +All events receive the incoming PSR-7 Request object, and the +:php:`TypoScriptFrontendController` object. + +In addition, the latter two events allow event listeners to define a custom +PSR-7 Response for custom permission layers, and interrupting further processing +of a page. + +These events serve as a replacement for the previously available hooks: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PreProcessing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['fetchPageId-PostProcessing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_preProcess']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['settingLanguage_postProcess']` + +Impact +====== + +Please note that TypoScript hasn't been resolved at the time of firing the +events, as this is done in the next step of the Frontend request. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-97778-SupportOfLanguageDirectionInCkeditor.rst b/Documentation/Changelog/12.0/Feature-97778-SupportOfLanguageDirectionInCkeditor.rst new file mode 100644 index 0000000..7ef9261 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97778-SupportOfLanguageDirectionInCkeditor.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-97778-1655732248: + +=========================================================== +Feature: #97778 - Support of language direction in ckeditor +=========================================================== + +See :issue:`97778` + +Description +=========== + +The configuration `contentsLangDirection` of the ckeditor is used to define the +direction of the content. It is now filled by the direction defined in the site +language of the current element. + +As fallback the page TSconfig configuration :typoscript:`RTE.config.contentsLanguageDirection = rtl` +can be used. + +Impact +====== + +The direction of the content inside the RichText element is defined by the +language of record. + +.. index:: Backend, RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.0/Feature-97787-EnumForSeveritiesIntroduced.rst b/Documentation/Changelog/12.0/Feature-97787-EnumForSeveritiesIntroduced.rst new file mode 100644 index 0000000..059097f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97787-EnumForSeveritiesIntroduced.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-97787-1655495723: + +================================================ +Feature: #97787 - Enum for severities introduced +================================================ + +See :issue:`97787` + +Description +=========== + +The PHP enum :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity` has been +introduced, allowing streamlined usage of severities across the codebase. At the +time of writing, this affects flash messages and status reports used in +EXT:reports. + +Impact +====== + +The enum cases in :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity` are +meant to be a drop-in replacement for the severity constants of +:php:`\TYPO3\CMS\Core\Messaging\FlashMessage` and :php:`\TYPO3\CMS\Reports\Status`. + +Example +======= + +Example of using the enum in a flash message: + +.. code-block:: php + + $flashMessage = GeneralUtility::makeInstance( + \TYPO3\CMS\Core\Messaging\FlashMessage::class, + 'Flash message text', + 'This is fine', + \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::OK + ); + +Example of using the enum in a status report: + +.. code-block:: php + + $statusReport = GeneralUtility::makeInstance( + \TYPO3\CMS\Reports\Status::class, + 'Lemming-o-meter', + 'Oops', + 'Not all lemmings were saved!', + \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::WARNING + ); + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97816-NewAfterTemplatesHaveBeenDeterminedEvent.rst b/Documentation/Changelog/12.0/Feature-97816-NewAfterTemplatesHaveBeenDeterminedEvent.rst new file mode 100644 index 0000000..c8f62e5 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97816-NewAfterTemplatesHaveBeenDeterminedEvent.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-97816-1664801053: + +=========================================================== +Feature: #97816 - New AfterTemplatesHaveBeenDeterminedEvent +=========================================================== + +See :issue:`97816` + +Description +=========== + +With switching to the new TypoScript parser, hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Core/TypoScript/TemplateService']['runThroughTemplatesPostProcessing']` +has been removed. + +The new event :php:`AfterTemplatesHaveBeenDeterminedEvent` can be used +to manipulate sys_template rows. The event receives the list of resolved +sys_template rows and the :php:`ServerRequestInterface` and allows manipulating the +sys_template rows array. + + +Impact +====== + +The event is called in Backend EXT:tstemplate code, for example in the Template Analyzer, +and - more importantly - in the Frontend. + +Extensions using the old hook that want to stay compatible with both core v11 and v12 +can implement both. + +.. index:: PHP-API, TypoScript, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97816-TypoScriptSyntaxImprovements.rst b/Documentation/Changelog/12.0/Feature-97816-TypoScriptSyntaxImprovements.rst new file mode 100644 index 0000000..9712f7c --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97816-TypoScriptSyntaxImprovements.rst @@ -0,0 +1,166 @@ +.. include:: /Includes.rst.txt + +.. _feature-97816-1656350667: + +================================================ +Feature: #97816 - TypoScript syntax improvements +================================================ + +See :issue:`97816` + +Description +=========== + +TYPO3 v12 comes with a new TypoScript syntax parser that is more performant, +more robust and allows better tooling in the Backend. + +The new parser is more forgiving in many places, this documentation lists +the new capabilities. + +Also see :ref:`breaking-97816-1656350406` +for an overview of breaking syntax changes. + +Impact +====== + +Improved comment parsing +------------------------ + +TypoScript comment detection had various nasty quirks with the old parser. The confusing +behavior did lead to many headaches in the past and not sticking to the weird parser +restrictions in comment parsing could easily lead to unexpected results, often ignoring +bigger sections of the subsequent TypoScript lines. + +This has been relaxed heavily: Comment detection should almost always act as developers +and integrators would expect from a language. Especially the former obligation to place +a closing multiline comment (:typoscript:`*/`) on a single line to close the comment +section has been removed. + +A couple of examples to clarify: + +.. code-block:: typoscript + + foo # This is a comment to an invalid line + + foo < bar // This is a comment + foo < bar /* This is a valid comment, too */ + + foo > # Another valid comment + + foo := addToList(1) # Yes, a comment + + [foo = bar] # Much comment. Much wow. + + <INCLUDE_TYPOSCRIPT: source="..."> /* A comment */ + + foo ( + # This is NOT a comment but part of the value assignment! + bar = barValue + ) # This is a comment + + foo = bar // This is NOT a comment but part of the value assignment! + +@import in conditions +--------------------- + +Placing an :typoscript:`@import` keyword within a condition is now supported, +the example below works. Note this obsoletes the clumsy :typoscript:`<INCLUDE_TYPOSCRIPT:` +syntax, and integrators are encouraged to fully switch to :typoscript:`@import`. + +.. code-block:: typoscript + + [frontend.user.isLoggedIn] + @import 'EXT:my_extension/Configuration/TypoScript/LoggedInUser.typoscript' + [ELSE] + @import 'EXT:my_extension/Configuration/TypoScript/NotLoggedInUser.typoscript' + [END] + +Scope restriction to file / snipped level +----------------------------------------- + +The old TypoScript parser merged the entire TypoScript for a page into one big +chunk of text. The new parser does not do that anymore, but parses each included +snippet one-by-one. This automatically means state no longer leaks to subsequent +snippets. Missing closing brackets :typoscript:`}` in one file do not destroy block +integrity of a following include anymore. Same for conditions: A missing closing +condition block (:typoscript:`[END]` or :typoscript:`[GLOBALS]`) no longer leaks +to another file - a conditions ends at the end of a file or snippet. + +Nesting conditions is partially supported +----------------------------------------- + +Nesting conditions is partially possible with the new TypoScript parser, **if** the +conditions are in different files. As example, let's first sort what happens when +two conditions follow directly in one snippet: + +.. code-block:: typoscript + + [frontend.user.isLoggedIn] + @import 'EXT:my_extension/Configuration/TypoScript/LoggedInUser.typoscript' + [applicationContext == "Development"] + @import 'EXT:my_extension/Configuration/TypoScript/Development.typoscript' + [END] + +This always worked and did not change with the new parser: Opening a new condition +automatically closes the preceding one. In the example above, both conditions are +standalone: :file:`Development.typoscript` is included no matter if a user is +logged in or not. + +But, and this in new, nesting conditions within different files is possible now. +In the example below, file :file:`LoggedInUserDevelopment.typoscript` is only +included if a user is logged in *and* the application is in development context. + +.. code-block:: typoscript + + [frontend.user.isLoggedIn] + @import 'EXT:my_extension/Configuration/TypoScript/LoggedInUser.typoscript' + [END] + + # File LoggedInUser.typoscript: + [applicationContext == "Development"] + @import 'EXT:my_extension/Configuration/TypoScript/LoggedInUserDevelopment.typoscript' + [END] + +Irrelevant order of <INCLUDE_TYPOSCRIPT: tokens +----------------------------------------------- + +The :typoscript:`<INCLUDE_TYPOSCRIPT:` keywords has the three properties +:typoscript:`source`, :typoscript:`condition` and :typoscript:`extensions`. They had to +be in a specific order with the old parser, but can be placed in arbitrary order now. + +Further clarifications +---------------------- + +* The "reference" :typoscript:`=<` operator is not a direct language construct. The parser + understands the syntax, but does not resolve it. Allowed :typoscript:`=<` operator are very + limited: In general, it can *only* be used for Frontend Content Objects, typically like this: + :typoscript:`tt_content.bullets =< lib.contentElement`. Another usage is referencing :typoscript:`lib.parseFunc`. + Using the :typoscript:`=<` operator in these cases can have a performance advantage since it + avoids an expensive copy operation that is done lazily if really needed. There are two methods + that resolve this operator, namely :php:`ContentObjectRenderer->cObjGetSingle()` and + :php:`ContentObjectRenderer->mergeTSRef()`. + This also means: The :typoscript:`=<` operator is *not supported* in TypoScript constants, + is only supported for specific elements in TypoScript setup, and is *not supported* in TSconfig. + Also note the reference operator does not support "relative" copies like the "copy" operator supports + with :typoscript:`20 < .10` and similar. + +* The new parser has a minor change in behavior with the "copy" :typoscript:`<` operator on top-level. + This shouldn't have huge impact in real life usage and is documented for completeness. Consider this + example: + + .. code-block:: typoscript + + lib.viewConfig { + baz = bazValue + } + + first = FLUIDTEMPLATE + first < lib.viewConfig + + The situation is there that :typoscript:`lib.viewConfig` has no assigned value (just children). The + target :typoscript:`first` however has value :typoscript:`FLUIDTEMPLATE`. The old TypoScript parser + usually keeps the "target" value in such cases, but only if the TypoScript object is not on top level + (:typoscript:`first` in contrast to :typoscript:`first.10` or similar). In the example above, value + :typoscript:`FLUIDTEMPLATE` would vanish with the old parser, but is now kept with the new parser. + +.. index:: Backend, Frontend, TSConfig, TypoScript, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97821-OptionToConfigurePrimaryActionsInFileList.rst b/Documentation/Changelog/12.0/Feature-97821-OptionToConfigurePrimaryActionsInFileList.rst new file mode 100644 index 0000000..486d2e9 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97821-OptionToConfigurePrimaryActionsInFileList.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-97821-1662456761: + +================================================================== +Feature: #97821 - Option to configure primary actions in File List +================================================================== + +See :issue:`97821` + +Description +=========== + +This change provides the option to add more primary actions to the list view. +The list of actions to be displayed can be given in the TSconfig of the backend +user. The actions that can be added are `view`, `metadata`, `copy` and `cut`. + +Example: + +.. code-block:: typoscript + + options.file_list.primaryActions = view,metadata,copy,cut,delete + +Impact +====== + +The actions available for the user by default become more clear. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/12.0/Feature-97862-NewPSR-14EventsForManipulatingFrontendPageGenerationAndCacheBehaviour.rst b/Documentation/Changelog/12.0/Feature-97862-NewPSR-14EventsForManipulatingFrontendPageGenerationAndCacheBehaviour.rst new file mode 100644 index 0000000..ea6e650 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97862-NewPSR-14EventsForManipulatingFrontendPageGenerationAndCacheBehaviour.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _feature-97862-1657195761: + +================================================================================================= +Feature: #97862 - New PSR-14 events for manipulating frontend page generation and cache behaviour +================================================================================================= + +See :issue:`97862` + +Description +=========== + +Two new PSR-14 events have been added: + +* :php:`TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent` +* :php:`TYPO3\CMS\Frontend\Event\AfterCachedPageIsPersistedEvent` + +They are added in favor of the :doc:`removed hooks <../12.0/Breaking-97862-HooksRelatedToGeneratingPageContentRemoved>`: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-cached']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['contentPostProc-all']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['usePageCache']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['insertPageIncache']` + +Both events are called when the content of a page has been +generated in the TYPO3 Frontend. + +Example +======= + +Registration of the `AfterCacheableContentIsGeneratedEvent` in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/content-modifier' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; + + class MyEventListener { + + public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void + { + // Only do this when caching is enabled + if (!$event->isCachingEnabled()) { + return; + } + $event->getController()->content = str_replace('foo', 'bar', $event->getController()->content); + } + } + +Impact +====== + +The event :php:`AfterCacheableContentIsGeneratedEvent` can be used +to decide if a page should be stored in cache and is executed right after +all cacheable content is generated. It can also be used to manipulate +the content before it is stored in TYPO3's page cache. The event is used +in indexed search to index cacheable content. + +The :php:`AfterCacheableContentIsGeneratedEvent` contains the +information if a generated page is able to store in cache via the +:php:`$event->isCachingEnabled()` method. This can be used to +differentiate between the previous hooks `contentPostProc-cached` and +`contentPostProc-all` (do something regardless if caching is enabled or not). + +The :php:`AfterCachedPageIsPersistedEvent` is commonly used to +generate a static file cache. This event is only called if the +page was actually stored in TYPO3's page cache. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-97922-ImprovePerformanceAndUsabilityWhileEditingSys_filemounts.rst b/Documentation/Changelog/12.0/Feature-97922-ImprovePerformanceAndUsabilityWhileEditingSys_filemounts.rst new file mode 100644 index 0000000..fa143b9 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97922-ImprovePerformanceAndUsabilityWhileEditingSys_filemounts.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-97922-1657706124: + +================================================================================ +Feature: #97922 - Improve performance and usability while editing sys_filemounts +================================================================================ + +See :issue:`97922` + +Description +=========== + +The two fields :sql:`base` and :sql:`path` of the :sql:`sys_filemounts` table +are now combined in one new field :sql:`identifier`. The field contains the so +called combined identifier in the format `base:path`, where "base" is the +storage ID and "path" the path to the folder, e.g. `1:/user_upload`. + +An upgrade wizard is in place, migrating the two fields of existing records +into the new field. + +The TCA type `folder` is used in the backend form to select the entry point. + +Impact +====== + +Editing :sql:`sys_filemounts` records in the backend is improved. Instead of +selecting the storage first, reloading the form and selecting the entry point +in a possibly large list afterwards, are users now able to select the entry +point using the folder browser in a single step. This additionally improves +the performance of the backend form, especially for storages with a huge +amount of folders. + +.. index:: Backend, TCA, ext:core diff --git a/Documentation/Changelog/12.0/Feature-97926-ConfigureExtbasePersistenceLanguageViaLanguageAspect.rst b/Documentation/Changelog/12.0/Feature-97926-ConfigureExtbasePersistenceLanguageViaLanguageAspect.rst new file mode 100644 index 0000000..8ac5f89 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97926-ConfigureExtbasePersistenceLanguageViaLanguageAspect.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-97926-1657726554: + +=========================================================================== +Feature: #97926 - Configure Extbase Persistence Language via LanguageAspect +=========================================================================== + +See :issue:`97926` + +Description +=========== + +Extbase's persistence functionality allows to configure the ORM queries via QuerySettings. + +QuerySettings now accept to use a custom LanguageAspect (known from the Context API) +to define the language ID and the overlay behaviour. + +This is more consistent to other places within TYPO3 Core to define translation behaviour when +querying records. + +Impact +====== + +You can now specify a custom Language Aspect per query as defined in the query settings +in any Repository class: + +Example to use the fallback to the default language when working with overlays: + +.. code-block:: php + + $query = $this->createQuery(); + $query->getQuerySettings()->setLanguageAspect( + new LanguageAspect(2, 2, LanguageAspect::OVERLAYS_MIXED) + ); + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-97941-ImprovedTypoScriptTemplateAnalyzer.rst b/Documentation/Changelog/12.0/Feature-97941-ImprovedTypoScriptTemplateAnalyzer.rst new file mode 100644 index 0000000..4054a1d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97941-ImprovedTypoScriptTemplateAnalyzer.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _feature-97941-1657809445: + +======================================================= +Feature: #97941 - Improved TypoScript Template Analyzer +======================================================= + +See :issue:`97941` + +Description +=========== + +The backend "Template" module "Template Analyzer" got a major overhaul and +displays much more information than before: + +* The rendering separates "constant" and "setup" includes and renders + both in own panels. +* :typoscript:`@import` and :typoscript:`<INCLUDE_TYPOSCRIPT:` are now resolved + and shown as nodes within the include tree. +* TypoScript conditions are reflected in the include tree and can be toggled + to simulate frontend condition verdicts. +* Clicking an include node displays this section of the include tree as source + tree with appropriate comments for import statements. + +Impact +====== + +The "Template Analyzer" is now based on the +:ref:`new TypoScript Parser <breaking-97816-1656350406>` +and gives more information to integrators and administrators. + +.. index:: Backend, TypoScript, ext:tstemplate diff --git a/Documentation/Changelog/12.0/Feature-97945-PSR14AfterPageTreeItemsPreparedEvent.rst b/Documentation/Changelog/12.0/Feature-97945-PSR14AfterPageTreeItemsPreparedEvent.rst new file mode 100644 index 0000000..c38b0bb --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-97945-PSR14AfterPageTreeItemsPreparedEvent.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _feature-97945: + +======================================================== +Feature: #97945 - PSR-14 AfterPageTreeItemsPreparedEvent +======================================================== + +See :issue:`97945` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent` +has been introduced which allows to modify prepared page tree items. It can also +be used as a replacement for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Workspaces\Service\WorkspaceService']['hasPageRecordVersions']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Workspaces\Service\WorkspaceService']['fetchPagesWithVersionsInTable']` +:doc:`hooks <../12.0/Breaking-97945-RemovedWorkspaceServiceHooks>`. + +The event is dispatched in the :php:`TreeController` after the page tree items +have been resolved and prepared. The event provides the current PSR-7 Request +as well as the page tree items. All items contain the corresponding page +record in the special :php:`_page` key. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml` file: + +.. code-block:: yaml + + MyVendor\MyPackage\Workspaces\MyEventListener: + tags: + - name: event.listener + identifier: 'my-package/workspaces/modify-page-tree-items' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent; + + final class MyEventListener + { + public function __invoke(AfterPageTreeItemsPreparedEvent $event): void + { + $items = $event->getItems(); + foreach ($items as &$item) { + // Setting special item for page with id 123 + if ($item['_page']['uid'] === 123) { + $item['icon'] = 'my-special-icon'; + } + } + $event->setItems($items); + } + } + +Impact +====== + +It is now possible to modify the prepared page tree items before they are +returned by the :php:`TreeController`, using the new PSR-14 event +:php:`AfterPageTreeItemsPreparedEvent`. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98016-PSR-14EvaluateModifierFunctionEvent.rst b/Documentation/Changelog/12.0/Feature-98016-PSR-14EvaluateModifierFunctionEvent.rst new file mode 100644 index 0000000..57b5052 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98016-PSR-14EvaluateModifierFunctionEvent.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _feature-98016-1658732423: + +====================================================== +Feature: #98016 - PSR-14 EvaluateModifierFunctionEvent +====================================================== + +See :issue:`98016` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent` +has been introduced which allows own TypoScript functions using the :typoscript:`:=` operator. + +This is a substitution of the old +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsparser.php']['preParseFunc']` +hook as described in :ref:`this Changelog <breaking-98016-1658731955>`. + +Impact +====== + +The TYPO3 Core tests come with test extension +:file:`EXT:core/Tests/Functional/Fixtures/Extensions/test_typoscript_ast_function_event` to functional +test the new event. The extension implements an example listener that can be used as boilerplate. + +A simple TypoScript example looks like this: + +.. code-block:: typoscript + + someIdentifier = originalValue + someIdentifier := myModifierFunction(myFunctionArgument) + +To implement :typoscript:`myModifierFunction`, an extension needs to register an event listener +in file :file:`Configuration/Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\MyTypoScriptModifierFunction: + tags: + - name: event.listener + identifier: 'my-package/typoscript/evaluate-modifier-function' + +The corresponding event listener class could look like this: + +.. code-block:: php + + use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent; + + final class MyTypoScriptModifierFunction + { + public function __invoke(EvaluateModifierFunctionEvent $event): void + { + if ($event->getFunctionName() === 'myModifierFunction') { + $originalValue = $event->getOriginalValue(); + $functionArgument = $event->getFunctionArgument(); + // Manipulate values and set new value + $event->setValue($originalValue . ' example ' . $functionArgument); + } + } + } + +.. index:: PHP-API, TSConfig, TypoScript, ext:core diff --git a/Documentation/Changelog/12.0/Feature-98130-AllowDeprecationOfIconsInExtensions.rst b/Documentation/Changelog/12.0/Feature-98130-AllowDeprecationOfIconsInExtensions.rst new file mode 100644 index 0000000..08f6400 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98130-AllowDeprecationOfIconsInExtensions.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _feature-98130-1660295017: + +========================================================== +Feature: #98130 - Allow deprecation of icons in extensions +========================================================== + +See :issue:`98130` + +Description +=========== + +Extension authors are now able to deprecate icons if they are meant to be public +API. A new option :php:`deprecated` is introduced that may contain the following +data: + +* :php:`since` - since when is the icon deprecated +* :php:`until` - when will the icon be removed +* :php:`replacement` - if given, an alternative icon is offered + +Impact +====== + +An extension that provides icons for broader use is now able to mark such icons +as deprecated properly with logging to the TYPO3 deprecation log. + +Example: + +.. code-block:: php + + // Configuration/Icons.php + return [ + 'deprecated-icon' => [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider::class, + 'source' => 'EXT:my_extension/Resources/Public/Icons/deprecated-icon.png', + 'deprecated' => [ + 'since' => 'my extension v2', + 'until' => 'my extension v3', + 'replacement' => 'alternative-icon', + ], + ], + ]; + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.0/Feature-98158-Symfony6Components.rst b/Documentation/Changelog/12.0/Feature-98158-Symfony6Components.rst new file mode 100644 index 0000000..be3a211 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98158-Symfony6Components.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-98158-1660740363: + +====================================== +Feature: #98158 - Symfony 6 Components +====================================== + +See :issue:`98158` + +Description +=========== + +TYPO3 Core now ships with Symfony 6.1. Previously TYPO3 v11 used Symfony Components +in version 5.4. New features are available with the latest versions of Symfony Components: + +https://symfony.com/blog/category/living-on-the-edge/6.1 + +Impact +====== + +New functionality including PHP 8.1 functionality for used Symfony Components are +included automatically for extension developers. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-98171-AddExtbaseTypeConverterForEnums.rst b/Documentation/Changelog/12.0/Feature-98171-AddExtbaseTypeConverterForEnums.rst new file mode 100644 index 0000000..2437625 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98171-AddExtbaseTypeConverterForEnums.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _feature-98171-1660910151: + +===================================================== +Feature: #98171 - Add Extbase TypeConverter for enums +===================================================== + +See :issue:`98171` + +Description +=========== + +Since PHP 8.1 provides enums, we can also use them in our Extbase actions. +A new TypeConverter +:php:`\TYPO3\CMS\Extbase\Property\TypeConverter\EnumConverter` +was added with this feature. + +Example +======= + +Given an enum like this one: + +.. code-block:: php + + enum ClosedStates + { + case Hide; + case Show; + case All; + } + +We can now use it like this in any Extbase action: + +.. code-block:: php + + public function overviewAction(ClosedStates $closed = ClosedStates::Hide): ResponseInterface + { + // ... + } + +The URL argument can be send as `[closed]=Show` and is automatically converted +to an instance of :php:`ClosedStates::Show` + +Impact +====== + +Enums can now be used as Extbase action arguments. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Feature-98303-PSR-14EventsForModifyingLanguageOverlays.rst b/Documentation/Changelog/12.0/Feature-98303-PSR-14EventsForModifyingLanguageOverlays.rst new file mode 100644 index 0000000..aa62189 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98303-PSR-14EventsForModifyingLanguageOverlays.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-98303-1662659478: + +=============================================================== +Feature: #98303 - PSR-14 events for modifying language overlays +=============================================================== + +See :issue:`98303` + +Description +=========== + +Three new PSR-14 events have been introduced which serve as a more powerful +and flexible alternative for the now removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getRecordOverlay']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getPageOverlay']` +hooks. + +The new PSR-14 events allow listeners to modify corresponding information, +before and after TYPO3 tries to overlay a language version of any kind of +record. "Language Overlaying" is a Core concept of TYPO3 to find a suitable +translation for a record and merged together with the base record. + +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforeRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\AfterRecordLanguageOverlayEvent` +* :php:`\TYPO3\CMS\Core\Domain\Event\BeforePageLanguageOverlayEvent` + +Impact +====== + +The event :php:`\TYPO3\CMS\Core\Domain\Event\BeforeRecordLanguageOverlayEvent` +can be used to modify information (such as the :php:`LanguageAspect` +or the actual incoming record from the database) before the database +is queried. + +The event :php:`\TYPO3\CMS\Core\Domain\Event\AfterRecordLanguageOverlayEvent` +can be used to modify the actual translated record (if found) to add additional +information or do custom processing of the record. + +:php:`\TYPO3\CMS\Core\Domain\Event\BeforePageLanguageOverlayEvent` is a special +event which is fired when TYPO3 is about to do the language overlay of one or +multiple pages, which could be one full record, or multiple page IDs. This +event is fired only for pages and in-between the events above. + +.. index:: Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Feature-98304-PSR-14EventForModifyingEditFormUserAccess.rst b/Documentation/Changelog/12.0/Feature-98304-PSR-14EventForModifyingEditFormUserAccess.rst new file mode 100644 index 0000000..8d4664f --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98304-PSR-14EventForModifyingEditFormUserAccess.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-98304: + +================================================================== +Feature: #98304 - PSR-14 event for modifying edit form user access +================================================================== + +See :issue:`98304` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent` +has been introduced which serves as a more powerful and flexible alternative +for the now removed :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/alt_doc.php']['makeEditForm_accessCheck']` +hook. + +In contrast to the removed hook, the new event provides the full +database row of the record in question next to the exception, which +might have been set by the Core. Additionally, the event allows to +modify the user access decision in an object-oriented way, using +convenience methods. + +To modify the user access, the following methods are available: + +* :php:`allowUserAccess()`: Allows user access to the editing form +* :php:`denyUserAccess()`: Denies user access to the editing form +* :php:`doesUserHaveAccess()`: Returns the current user access state +* :php:`getAccessDeniedException()`: If Core's DataProvider previously denied + access, this returns the corresponding exception, :php:`null` otherwise + +The following additional methods can be used for further context: + +* :php:`getTableName()`: Returns the table name of the record in question +* :php:`getCommand()`: Returns the requested command, either `new` or `edit` +* :php:`getDatabaseRow()`: Returns the record's database row + +In case any listener to the new event denies user access, while it was initially +allowed by Core, the :php:`TYPO3\CMS\Backend\Form\Exception\AccessDeniedListenerException` +will be thrown. + +Example +======= + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\Backend\Form\ModifyEditFormUserAccessEventListener: + tags: + - name: event.listener + identifier: 'my-package/backend/modify-edit-form-user-access' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent; + + final class ModifyEditFormUserAccessEventListener + { + public function __invoke(ModifyEditFormUserAccessEvent $event): void + { + // Deny access for creating records of a custom table + if ($event->getTableName() === 'my_custom_table' && $event->getCommand() === 'new') { + $event->denyUserAccess(); + } + } + } + +Impact +====== + +It's now possible to modify the user access for the editing form, +using the new PSR-14 event :php:`ModifyLinkExplanationEvent`. The main +advantages of the new PSR-14 event are the object-oriented approach +as well as the built-in convenience features and an increased amount +of context information. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98348-LiveSearchMovedIntoModalWindow.rst b/Documentation/Changelog/12.0/Feature-98348-LiveSearchMovedIntoModalWindow.rst new file mode 100644 index 0000000..e2cdf13 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98348-LiveSearchMovedIntoModalWindow.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-98348-1663235550: + +===================================================== +Feature: #98348 - Live Search moved into modal window +===================================================== + +See :issue:`98348` + +Description +=========== + +The live search located at the top right side of the TYPO3 backend now uses a +modal window to render the search controls and the results. +As more space is now available, the amount of search results is increased to 50 +records per search. + +The modal may be opened via a keyboard shortcut by pressing the :kbd:`Cmd` + :kbd:`K` +keystroke on macOS or the :kbd:`Ctrl` + :kbd:`K` keystroke on Windows and Linux +systems. + +Impact +====== + +Moving the search into a modal provides more possibilities for future +enhancements, e.g. dynamic loading of more results or filters. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98375-PSR-14EventsInPageModule.rst b/Documentation/Changelog/12.0/Feature-98375-PSR-14EventsInPageModule.rst new file mode 100644 index 0000000..7bed3d3 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98375-PSR-14EventsInPageModule.rst @@ -0,0 +1,191 @@ +.. include:: /Includes.rst.txt + +.. _feature-98375-1663598746: + +============================================== +Feature: #98375 - PSR-14 events in Page Module +============================================== + +See :issue:`98375` + +Description +=========== + +Three new PSR-14 events have been added to TYPO3's page module to modify +the preparation and rendering of content elements: + +* :php:`TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent` +* :php:`TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent` +* :php:`TYPO3\CMS\Backend\View\Event\PageContentPreviewRenderingEvent` + +They are drop-in replacement to the removed hooks: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['record_is_used']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][PageLayoutView::class]['modifyQuery']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem']` + +Example for :php:`IsContentUsedOnPageLayoutEvent` +------------------------------------------------- + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Listener\ContentUsedOnPage: + tags: + - name: event.listener + identifier: 'my-extension/view/content-used-on-page' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Listener/ContentUsedOnPage.php + + <?php + + declare(strict_types=1); + + namespace MyVendor\MyExtension\Listener; + + use TYPO3\CMS\Backend\View\Event\IsContentUsedOnPageLayoutEvent; + + final class ContentUsedOnPage + { + public function __invoke(IsContentUsedOnPageLayoutEvent $event): void + { + // Get the current record from the event. + $record = $event->getRecord(); + + // This code will be your domain logic to indicate if content + // should be hidden in the page module. + if ((int)($record['colPos'] ?? 0) === 999 + && !empty($record['tx_myext_content_parent']) + ) { + // Flag the current element as not used. Set it to true, if you + // want to flag it as used and hide it from the page module. + $event->setUsed(false); + } + } + } + +Example for :php:`ModifyDatabaseQueryForContentEvent` +----------------------------------------------------- + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Listener\ModifyDatabaseQueryForContent: + tags: + - name: event.listener + identifier: 'my-extension/view/modify-database-query-for-content' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Listener/ModifyDatabaseQueryForContent.php + + <?php + + declare(strict_types=1); + + namespace MyVendor\MyExtension\Listener; + + use TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForContentEvent; + use TYPO3\CMS\Core\Database\Connection; + + final class ModifyDatabaseQueryForContent + { + public function __invoke(ModifyDatabaseQueryForContentEvent $event): void + { + // early return if we do not need to react + if ($event->getTable() !== 'tt_content') { + return; + } + + // Retrieve QueryBuilder instance from event + $queryBuilder = $event->getQueryBuilder(); + + // Add an additional condition to the QueryBuilder for the table + // Note: This is only an example, modify the QueryBuilder instance + // here to your needs. + $queryBuilder = $queryBuilder->andWhere( + $queryBuilder->expr()->neq( + 'some_field', + $queryBuilder->createNamedParameter(1, Connection::PARAM_INT) + ) + ); + + // set updated QueryBuilder to event + $event->setQueryBuilder($queryBuilder); + } + } + +Example :php:`PageContentPreviewRenderingEvent` +----------------------------------------------- + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Listener\PageContentPreviewRendering: + tags: + - name: event.listener + identifier: 'my-extension/view/page-content-preview-rendering' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Listener/PageContentPreviewRendering.php + + <?php + + declare(strict_types=1); + + namespace MyVendor\MyExtension\Listener; + + use TYPO3\CMS\Backend\View\Event\PageContentPreviewRenderingEvent; + + final class PageContentPreviewRendering + { + public function __invoke(PageContentPreviewRenderingEvent $event): void + { + $tableName = $event->getTable(); + $record = $event->getRecord(); + + // early return if we do not need to react + if ( + $tableName !== 'tt_content' + || (string)($record['CType'] ?? '') !== 'my-content-element' + ) { + return; + } + + // Create custom preview content + $previewContent = sprintf( + '<div class="alert alert-notice">No preview available for %s:%s</div>', + $event->getTable(), + ($event->getRecord()['uid'] ?? 0) + ); + + // Set (override) preview content with custom content. + $event->setPreviewContent($previewContent); + } + } + +Impact +====== + +Use :php:`IsContentUsedOnPageLayoutEvent` to identify if a content has been used +in a column that isn't on a Backend Layout. + +Use :php:`ModifyDatabaseQueryForContentEvent` to filter out certain content elements +from being shown in the Page Module. + +Use :php:`PageContentPreviewRenderingEvent` to ship an alternative rendering for +a specific content type or to manipulate the content elements' record data. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98426-PSR-14AfterRecordSummaryForLocalizationEvent.rst b/Documentation/Changelog/12.0/Feature-98426-PSR-14AfterRecordSummaryForLocalizationEvent.rst new file mode 100644 index 0000000..c1e0856 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98426-PSR-14AfterRecordSummaryForLocalizationEvent.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _feature-98426-1664381958: + +========================================================================= +Feature: #98426 - New PSR-14 event AfterRecordSummaryForLocalizationEvent +========================================================================= + +See :issue:`98426` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Controller\Event\AfterRecordSummaryForLocalizationEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Backend\Controller\Page\RecordSummaryForLocalization` class +and allows extensions to modify the payload of the :php:`JsonResponse` +in the :php:`getRecordLocalizeSummary` method. + +The event features the following methods: + +- :php:`getColumns()`: Returns the current :php:`$columns` array +- :php:`getRecords()`: Returns the current :php:`$records` array +- :php:`setColumns()`: Sets the current :php:`$columns` array +- :php:`setRecords()`: Sets the current :php:`$records` array + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\AfterRecordSummaryForLocalizationEventListener: + tags: + - name: event.listener + identifier: 'my-package/after-record-summary-for-localization-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Controller\Event\AfterRecordSummaryForLocalizationEvent; + + final class AfterRecordSummaryForLocalizationEventListener + { + public function __invoke(AfterRecordSummaryForLocalizationEvent $event): void + { + // Get current records + $records = $event->getRecords(); + + // Remove or add $records available for translation + + // Set new records + $event->setRecords($records); + + // Get current columns + $columns = $event->getColumns(); + + // Remove or add $columns available for translation + + // Set new columns + $event->setColumns($columns); + } + } + +Impact +====== + +The :php:`getRecordLocalizeSummary` method is called in the translation process, +when displaying records and columns to translate. +It is now possible to use a new PSR-14 event that can modify the +:php:`$columns` and :php:`$records` which are available for translation. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98431-SupportJavaScriptModulesInFormEngineResultArray.rst b/Documentation/Changelog/12.0/Feature-98431-SupportJavaScriptModulesInFormEngineResultArray.rst new file mode 100644 index 0000000..0d4af75 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98431-SupportJavaScriptModulesInFormEngineResultArray.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-98431-1664652179: + +===================================================================== +Feature: #98431 - Support javaScriptModules in FormEngine resultArray +===================================================================== + +See :issue:`98431` + +Description +=========== + +The ability for custom :php:`FormEngine` components to load JavaScript modules +via instances of :php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` is now +streamlined to use a new, generic :php:`$resultArray` key named +:php:`'javaScriptModules'`. The behaviour is otherwise identical to the +functionality that has been available via :php:`'requireJsModules'`, +but the new name reflects that not just RequireJS modules may be loaded, +but also newer, native ECMAScript v6 JavaScript modules. + +Using :php:`'javaScriptModules'` is now the suggested to be used over +:php:`'requireJsModules'`, as this latter is deprecated from now on +and will be removed in TYPO3 v13. + +Impact +====== + +FormEngine components now use the key :php:`'javaScriptModules'` which +expects an instance of :php:`TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` +to be passed as value. + +Example JavaScript module registration: + +.. code-block:: php + + // use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create( + '@my/extension/my-element.js' + ); + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98479-NewTCATypeFile.rst b/Documentation/Changelog/12.0/Feature-98479-NewTCATypeFile.rst new file mode 100644 index 0000000..4bfe0f2 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98479-NewTCATypeFile.rst @@ -0,0 +1,209 @@ +.. include:: /Includes.rst.txt + +.. _feature-98479-1664537749: + +===================================== +Feature: #98479 - New TCA type "file" +===================================== + +See :issue:`98479` + +Description +=========== + +A new TCA field type called :php:`file` has been added to TYPO3 Core. Its +main purpose is to simplify the TCA configuration for adding file reference +fields to records. It therefore supersedes the usage of TCA type :php:`inline` +with :php:`foreign_table` set to :php:`sys_file_reference`, which had previously +usually been configured using the now deprecated API method +:php:`ExtensionManagementUtility->getFileFieldTCAConfig()` for this use case. + +This helps on determination of the semantic meaning and also allows to +reduce internal cross dependencies between TCA type `inline` and FAL. + +The new TCA type :php:`file` features the following column configuration: + +* :php:`allowed` +* :php:`appearance`: :php:`collapseAll`, :php:`expandSingle`, + :php:`createNewRelationLinkTitle`, :php:`useSortable`, :php:`enabledControls`, + :php:`headerThumbnail`, :php:`fileUploadAllowed`, :php:`fileByUrlAllowed`, + :php:`elementBrowserEnabled`, :php:`showPossibleLocalizationRecords`, + :php:`showAllLocalizationLink`, :php:`showSynchronizationLink`, + :php:`showFileSelectors` +* :php:`behaviour`: :php:`allowLanguageSynchronization`, + :php:`disableMovingChildrenWithParent`, :php:`enableCascadingDelete` +* :php:`disallowed` +* :php:`fieldInformation` +* :php:`fieldWizard` +* :php:`maxitems` +* :php:`minitems` +* :php:`overrideChildTca` +* :php:`readOnly` + +.. note:: + + The option :php:`showFileSelectors` can be used to define whether the + file selectors, such as "Select & upload files" are displayed. This is + similar to the :php:`showPossibleRecordsSelector` option, available + for TCA type :php:`inline`. + +The following column configuration can be overwritten by Page TSconfig: + +- :typoscript:`appearance` +- :typoscript:`behaviour` +- :typoscript:`maxitems` +- :typoscript:`minitems` +- :typoscript:`readOnly` + +A possible migration using the API method therefore looks like the following: + +.. code-block:: php + + // Before + 'columns' => [ + 'image' => [ + 'label' => 'My image', + 'config' => \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::getFileFieldTCAConfig( + 'image', + [ + 'maxitems' => 6, + ], + $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'] + ), + ], + ], + + // After + 'columns' => [ + 'image' => [ + 'label' => 'My image', + 'config' => [ + 'type' => 'file', + 'maxitems' => 6, + 'allowed' => 'common-image-types' + ], + ], + ], + +The example uses the :php:`common-image-types` placeholder for the +:php:`allowed` option. This placeholder is internally replaced and +helps to further reduce the usage of :php:`$GLOBALS`. Further placeholders +are :php:`common-text-types` and :php:`common-media-types`. It's possible +to use multiple placeholders. It's also possible to mix them with single +file extensions. Additionally, it's also possible to define the file +extensions as `array`. + +Another example without usage of the API method would therefore look like this: + +.. code-block:: php + + // Before + 'columns' => [ + 'image' => [ + 'label' => 'My image', + 'config' => [ + 'type' => 'inline', + 'foreign_table' => 'sys_file_reference', + 'foreign_field' => 'uid_foreign', + 'foreign_sortby' => 'sorting_foreign', + 'foreign_table_field' => 'tablenames', + 'foreign_match_fields' => [ + 'fieldname' => 'image', + ], + 'foreign_label' => 'uid_local', + 'foreign_selector' => 'uid_local', + 'overrideChildTca' => [ + 'columns' => [ + 'uid_local' => [ + 'config' => [ + 'appearance' => [ + 'elementBrowserType' => 'file', + 'elementBrowserAllowed' => 'jpg,png,gif', + ], + ], + ], + ], + ], + ] + ], + ], + + // After + 'columns' => [ + 'image' => [ + 'label' => 'My image', + 'config' => [ + 'type' => 'file', + 'allowed' => ['jpg','png','gif'], + ], + ], + ], + +Together with the new TCA type, three new PSR-14 events have been introduced: + +* :php:`TYPO3\CMS\Backend\Form\Event\CustomFileControlsEvent` +* :php:`TYPO3\CMS\Backend\Form\Event\ModifyFileReferenceControlsEvent` +* :php:`TYPO3\CMS\Backend\Form\Event\ModifyFileReferenceEnabledControlsEvent` + +CustomFileControlsEvent +======================= + +Listeners to this event will be able to add custom controls to a TCA type +:php:`file` field in FormEngine. This replaces the :php:`customControls` +hook option, which is only available for TCA type :php:`inline`. + +The new event provides the following methods: + +* :php:`getResultArray()`: Returns the whole result array +* :php:`setResultArray(array $resultArray)`: Allows to overwrite the result + array, e.g. to add additional JS modules +* :php:`getControls()`: Returns all configured custom controls +* :php:`setControls()`: Overwrites the custom controls +* :php:`addControl()`: Adds a custom control. It's recommended to set the + optional :php:`$identifier` argument. +* :php:`removeControl()`: Removes a custom control. This only works in case + the custom control was added with an identifier. +* :php:`getTableName()`: Returns the table name in question +* :php:`getFieldName()`: Returns the field name in question +* :php:`getDatabaseRow()`: Returns the database row of the record in question +* :php:`getFieldConfig()`: Returns the fields' TCA configuration +* :php:`getFormFieldIdentifier()`: Returns the form elements' identifier +* :php:`getFormFieldName()`: Returns the form elements' name + +.. note:: + + Custom controls are always displayed below the file references. In contrast + to the selectors, e.g. "Select & upload files" are custom controls + independent of the :php:`readonly` and :php:`showFileSelectors` options. + This means, you have full control in which scenario your custom controls + are being displayed. + +ModifyFileReferenceControlsEvent +================================ + +Listeners to this event will be able to modify the controls of a single +file reference of a TCA type `file` field. This event is similar to the +:php:`ModifyInlineElementControlsEvent`, which is only available for TCA +type `inline`. See corresponding PHP class or the other +:doc:`changelog <../12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls>` +for more information about available methods and their usage. + +ModifyFileReferenceEnabledControlsEvent +======================================= + +Listeners to this event will be able to modify the state (enabled or disabled) +for the controls of a single file reference of a TCA type `file` field. This +event is similar to the :php:`ModifyInlineElementEnabledControlsEvent`, which +is only available for TCA type `inline`. See corresponding PHP class or the +other :doc:`changelog <../12.0/Feature-97231-PSR-14EventsForModifyingInlineElementControls>` +for more information about available methods and their usage. + +Impact +====== + +It's now possible to simplify the TCA configuration for file reference +fields, using the new TCA type `file`. Three new PSR-14 events allow to +modify available controls of the TCA field as well as the related file +references. + +.. index:: Backend, FAL, PHP-API, TCA, ext:backend diff --git a/Documentation/Changelog/12.0/Feature-98487-TCAOptionCtrlsecurityignorePageTypeRestriction.rst b/Documentation/Changelog/12.0/Feature-98487-TCAOptionCtrlsecurityignorePageTypeRestriction.rst new file mode 100644 index 0000000..c736f4d --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98487-TCAOptionCtrlsecurityignorePageTypeRestriction.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-98487-1664575753: + +======================================================================== +Feature: #98487 - TCA option [ctrl][security][ignorePageTypeRestriction] +======================================================================== + +See :issue:`98487` + +Description +=========== + +A new TCA ctrl option :php:`$GLOBALS['TCA'][$table]['ctrl']['security']['ignorePageTypeRestriction']` +(boolean) is introduced to define the custom TCA table to be added to any +given page type (custom or defined), unless specified differently via the +:php:`PageDoktypeRegistry` API class for a specified doktype. + +Impact +====== + +This is a replacement for the previous PHP API call +:php:`ExtensionManagementUtility::allowTableOnStandardPages` which was found +in :file:`ext_tables.php` files. + +Setting the new TCA option allows to use a TCA table on any kind of page doktype +unless a doktype has a restriction set in the :php:`PageDoktypeRegistry` API +class. + +.. index:: PHP-API, TCA, ext:core diff --git a/Documentation/Changelog/12.0/Feature-98488-AdditionalSettingForTypolinkOptionAddQueryString.rst b/Documentation/Changelog/12.0/Feature-98488-AdditionalSettingForTypolinkOptionAddQueryString.rst new file mode 100644 index 0000000..2ecceee --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98488-AdditionalSettingForTypolinkOptionAddQueryString.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-98488-1664578785: + +========================================================================= +Feature: #98488 - Additional setting for Typolink option "addQueryString" +========================================================================= + +See :issue:`98488` + +Description +=========== + +The Typolink option :typoscript:`typolink.addQueryString` now also accepts +the value `untrusted` to be used to retrieve all GET parameters of the current request. + +This value can also used in the Fluid ViewHelpers +:html:`<f:link.typolink>`, :html:`<f:uri.typolink>`, :html:`<f:link.page>`, +:html:`<f:uri.page>`, :html:`<f:link.action>`, :html:`<f:link.action>` and +:html:`<f:form>`. + +Impact +====== + +Setting :typoscript:`typolink.addQueryString = untrusted` adds any given query parameters +just as it was done in TYPO3 v11 when using :typoscript:`typolink.addQueryString = 1`. + +.. index:: Fluid, TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.0/Feature-98490-PSR-14EventToAlterTheRecordsRenderedInRecordListings.rst b/Documentation/Changelog/12.0/Feature-98490-PSR-14EventToAlterTheRecordsRenderedInRecordListings.rst new file mode 100644 index 0000000..f695fa4 --- /dev/null +++ b/Documentation/Changelog/12.0/Feature-98490-PSR-14EventToAlterTheRecordsRenderedInRecordListings.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-98490-1664580564: + +=============================================================================== +Feature: #98490 - PSR-14 event to alter the records rendered in record listings +=============================================================================== + +See :issue:`98490` + +Description +=========== + +A new PSR-14 event :php:`TYPO3\CMS\Backend\View\Event\ModifyDatabaseQueryForRecordListingEvent` +has been added, which allows to alter the :php:`QueryBuilder` SQL statement +before a list of records is about to be rendered and executed in record lists +such as the list module or element browser. + +Impact +====== + +Registering an event listeners allows to further limit or expand the list of +records shown in certain record lists to alter the records shown. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.0/Important-59992-ExtbaseConsistentUidValuesFromPersistenceSession.rst b/Documentation/Changelog/12.0/Important-59992-ExtbaseConsistentUidValuesFromPersistenceSession.rst new file mode 100644 index 0000000..5852784 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-59992-ExtbaseConsistentUidValuesFromPersistenceSession.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _important-59992-1657551957: + +=========================================================================== +Important: #59992 - Extbase: Consistent uid values from Persistence Session +=========================================================================== + +See :issue:`59992` + +Description +=========== + +Extbase's Domain Models now always return the default language's "uid" property +when accessing :php:`$myModel->getUid()`. + +Previously the property was sometimes filled with the "language overlay" ID for +translated records, and sometimes filled with the ID of the "translation origin" +ID depending on the query settings' language configuration. + +Under the hood, Extbase now always checks if a record from the database, +which should be constituted as object, has a "translation parent" ("l10n_parent") +and uses this value for the "uid". The field `_localizedUid` then contains +the uid value of the translated record. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.0/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst b/Documentation/Changelog/12.0/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst new file mode 100644 index 0000000..735554d --- /dev/null +++ b/Documentation/Changelog/12.0/Important-92020-NewAPIEntryPointAvailableAtHttpsgettypo3orgapi.rst @@ -0,0 +1,21 @@ +.. include:: /Includes.rst.txt + +.. _important-92020: + +=============================================================================== +Important: #92020 - New API entry point available at https://get.typo3.org/api/ +=============================================================================== + +See :issue:`92020` + +Description +=========== + +The Core version service now uses the new entry point of the REST API +available via https://get.typo3.org/api. + +The old entry point is still available but should not be longer used. + +For more information see `https://get.typo3.org/api/doc <https://get.typo3.org/api/doc>`_. + +.. index:: ext:install diff --git a/Documentation/Changelog/12.0/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst b/Documentation/Changelog/12.0/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst new file mode 100644 index 0000000..5e61c69 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-94951-RestrictExportFunctionalityToAllowedUsers.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _important-94951-1655368666: + +=================================================================== +Important: #94951 - Restrict export functionality to allowed users +=================================================================== + +See :issue:`94951` + +.. important:: + This change was introduced as part of the + `TYPO3 11.5.11 and 10.4.29 security release <https://typo3.org/security/advisory/typo3-core-sa-2022-001>`__. + +Description +=========== + +The export functionality has the following security drawbacks: + +* Export for editors is not limited on field level +* The :guilabel:`Save to filename` functionality saves to a shared folder, + which other editors with different access rights may have access to. + +Both issues are not easy to resolve and also the target +audience for the Import/Export functionality are mainly +TYPO3 admins. + +Impact +====== + +The export functionality is restricted +to TYPO3 admin users and to users, who explicitly have +access through the new user TSconfig setting +:typoscript:`options.impexp.enableExportForNonAdminUser`. + +Affected installations +====================== + +Installations with EXT:impexp installed where non-admin users need to use the +export functionality. + +Migration +========= + +If non-admin users should be able to use the export tool, set the +following user TSconfig: + +.. code-block:: typoscript + :caption: EXT:my_sitepackage/Configuration/TSconfig/allusers.tsconfig + + options.impexp.enableExportForNonAdminUser = 1 + +.. index:: Backend, TSConfig, NotScanned, ext:impexp diff --git a/Documentation/Changelog/12.0/Important-97031-RemovedLogSubmoduleFromInfoModule.rst b/Documentation/Changelog/12.0/Important-97031-RemovedLogSubmoduleFromInfoModule.rst new file mode 100644 index 0000000..0530810 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97031-RemovedLogSubmoduleFromInfoModule.rst @@ -0,0 +1,18 @@ +.. include:: /Includes.rst.txt + +.. _important-97031: + +============================================================ +Important: #97031 - Removed "Log" submodule from info module +============================================================ + +See :issue:`97031` + +Description +=========== + +The info module in the TYPO3 backend does no longer provide the :guilabel:`Log` +submodule. It has been removed since related page and record logs can +be accessed via the history module since TYPO3 v9. + +.. index:: Backend, ext:belog diff --git a/Documentation/Changelog/12.0/Important-97111-DefaultURIScheme.rst b/Documentation/Changelog/12.0/Important-97111-DefaultURIScheme.rst new file mode 100644 index 0000000..dae199d --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97111-DefaultURIScheme.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-97111-1657214951: + +====================================== +Important: #97111 - Default URI scheme +====================================== + +See :issue:`97111` + +Description +=========== + +Several places in the TYPO3 Core fall back to using `http` as a protocol for +links in case none was given. In order to adjust this behavior the new +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme']` setting has been +introduced, which uses `http` as default. + +In order to adjust the default protocol, one has to add the following +assignment to their :file:`typo3conf/LocalConfiguration.php` settings: + +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme'] = 'https'` + +.. index:: LocalConfiguration, RTE, ext:core diff --git a/Documentation/Changelog/12.0/Important-97145-SerializedLog_dataInSys_logMigratedToJSON-encodedData.rst b/Documentation/Changelog/12.0/Important-97145-SerializedLog_dataInSys_logMigratedToJSON-encodedData.rst new file mode 100644 index 0000000..dbcab62 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97145-SerializedLog_dataInSys_logMigratedToJSON-encodedData.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _important-97145: + +================================================================================ +Important: #97145 - Serialized log_data in sys_log migrated to JSON-encoded data +================================================================================ + +See :issue:`97145` + +Description +=========== + +TYPO3's `sys_log` database table contains a field :sql:`log_data`, which +is used by TYPO3 Core internally to store additional information for log +details. This field has been previously filled with a serialized string +(PHP function :php:`serialize()`) and has now been migrated to a +JSON-encoded field (PHP function :php:`json_encode()`). + +An Upgrade Wizard migrates the field values of existing `sys_log` entries +to the new format automatically. + +Impact +====== + +Additional information are now stored as JSON-encoded data in the +:sql:`log_data` field of table `sys_log`. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.0/Important-97159-MailLinkHandlerKeyInTSconfigRenamed.rst b/Documentation/Changelog/12.0/Important-97159-MailLinkHandlerKeyInTSconfigRenamed.rst new file mode 100644 index 0000000..7be73b5 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97159-MailLinkHandlerKeyInTSconfigRenamed.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-97159: + +============================================================= +Important: #97159 - MailLinkHandler key in TSconfig renamed +============================================================= + +See :issue:`97159` + +Description +=========== + +The key for the :php:`MailLinkHandler` in the :typoscript:`TCEMAIN.linkHandler.` +TSconfig has been renamed from :typoscript:`mail` to :typoscript:`email`. + +This is done to be consistent with the identifier, used for the +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['linkHandler']` registration, +as well as the value of the :php:`LinkService::TYPE_EMAIL` constant. + +Update any usage of this key in your extension code. + +.. index:: TSConfig, ext:backend diff --git a/Documentation/Changelog/12.0/Important-97411-AlignSystemEnvironmentChecksToChangedRequirements.rst b/Documentation/Changelog/12.0/Important-97411-AlignSystemEnvironmentChecksToChangedRequirements.rst new file mode 100644 index 0000000..707acb0 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97411-AlignSystemEnvironmentChecksToChangedRequirements.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-97411: + +========================================================================== +Important: #97411 - Align SystemEnvironment checks to changed requirements +========================================================================== + +See :issue:`97411` + +Description +=========== + +With :issue:`96553` the minimum supported PHP version and supported database +products and versions have been changed. SystemEnvironment checks and reports +have been aligned to reflect these changed requirements. + +* check for MySQL version 8.0.0 or newer +* check for MariaDB version 10.3.0 or newer +* check for PostgreSQL version 10.0 or newer +* removed Microsoft SQL Server checks and reports +* adjusted PHP Version check + +.. index:: Database, ext:install diff --git a/Documentation/Changelog/12.0/Important-97462-RemovedMSSQLSupportiveCode.rst b/Documentation/Changelog/12.0/Important-97462-RemovedMSSQLSupportiveCode.rst new file mode 100644 index 0000000..0e2fa00 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97462-RemovedMSSQLSupportiveCode.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _important-97462: + +================================================= +Important: #97462 - Removed MSSQL supportive code +================================================= + +See :issue:`97462` + +Description +=========== + +Support for Microsoft SQL Server (MSSQL) has been dropped as supported database +system with :issue:`96553`. + +Therefore supportive code for MSSQL has been removed from Core, additionally to +:doc:`Align SystemEnvironment checks to changed requirements <../12.0/Important-97411-AlignSystemEnvironmentChecksToChangedRequirements>`. + +* special handling for MSSQL platform/driver has been remove on several places +* removed internal doctrine/dbal facade classes + +.. note:: + + MSSQL could not be selected during installation, but be configured manually + through the database configuration. This does not work anymore, and regarding + the removal of special handling code it would not be reliable. Migrate to + one of the supported database system and version before upgrading to TYPO3 v12. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.0/Important-97517-RemoveTheSuperfluousNamespaceWithinTheFormConfiguration.rst b/Documentation/Changelog/12.0/Important-97517-RemoveTheSuperfluousNamespaceWithinTheFormConfiguration.rst new file mode 100644 index 0000000..d174bff --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97517-RemoveTheSuperfluousNamespaceWithinTheFormConfiguration.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _important-97517: + +================================================================================== +Important: #97517 - Remove the superfluous namespace within the form configuration +================================================================================== + +See :issue:`97517` + +Description +=========== + +The superfluous vendor namespace (:yaml:`TYPO3.CMS.Form`) has been removed from the form configuration. +That way the configuration of the form framework is less deeply nested. + +The compatibility to the notation with the vendor namespace is +maintained, both notations are still possible. Nevertheless we recommend not to apply the vendor namespace. + +Migration +========= + +This is how the legacy configuration with vendor namespace looks like: + +.. code-block:: yaml + + TYPO3: + CMS: + Form: + prototypes: + standard: + formElementsDefinition: + # ... + # ... + +This is how the new (preferred) configuration without vendor namespace looks like: + +.. code-block:: yaml + + prototypes: + standard: + formElementsDefinition: + # ... + # ... + +.. index:: Backend, ext:form diff --git a/Documentation/Changelog/12.0/Important-97809-UpdateTypo3iconsToV3.rst b/Documentation/Changelog/12.0/Important-97809-UpdateTypo3iconsToV3.rst new file mode 100644 index 0000000..712e5f4 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-97809-UpdateTypo3iconsToV3.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _important-97809-1656679033: + +============================================= +Important: #97809 - Update @typo3.icons to v3 +============================================= + +See :issue:`97809` + +Description +=========== + +The TYPO3 Icon set in version 3.x introduced a change in icon scaling. +Instead of having the default size fixed to a size, it's now scaling +according to the font-size where the icon is used. + +The size previously represented by the identifier "default" is +now called "medium" to better reflect the scaling increase, resulting +in the fixed sizes now named "small", "medium" and "large". + +How sizes can now be translated: + +- "default" -> 1em, to scale with font size +- "small" -> fixed to 16px +- "medium" -> fixed to 32px +- "large" -> fixed to 64px + +The TYPO3 Icon API previously set to :php:`Icon::SIZE_DEFAULT` by default and was +adapted to now use :php:`Icon::SIZE_MEDIUM` instead. That means there is no +change in behaviour except you explicitly called the icon API with the +size "default". + +You should now see the icon scaling to the font size instead of set it to 32px. +To change it back to a fixed size, use the "medium" sized variant. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.0/Important-98090-UsePreconfiguredUTF8FilesystemOnFirstInstallation.rst b/Documentation/Changelog/12.0/Important-98090-UsePreconfiguredUTF8FilesystemOnFirstInstallation.rst new file mode 100644 index 0000000..ef5589d --- /dev/null +++ b/Documentation/Changelog/12.0/Important-98090-UsePreconfiguredUTF8FilesystemOnFirstInstallation.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _important-98090-1660490213: + +============================================================================ +Important: #98090 - Use preconfigured UTF-8 filesystem on first installation +============================================================================ + +See :issue:`98090` + +Description +=========== + +Back in the old and dark days, some filesystems did not know about "special" +chars. TYPO3 has the :php:`TYPO3_CONF_VARS` toggle :php:`['SYS']['UTF8filesystem']` +to declare if filesystems are UTF-8 aware. + +This toggle is :php:`false` by default since ever. It triggers functionality to +rename any file that contains characters like umlauts, or maybe entirely consist +of "special" chars only (japanese) to something "safe". + +This is a usability issue since information is lost and language-specific +characters are destroyed. + +Nowadays every serious filesystem supports UTF-8. + +There are no issues related to UTF8filesystem=true for years. + +The patch now sets UTF8filesystem=true for new installations to see if anything +still pops up. If that works out, we'll continue with further patches in v13 to +further phase out the option entirely. + +.. index:: LocalConfiguration, ext:core diff --git a/Documentation/Changelog/12.0/Important-98475-UnsignedPidTableColumns.rst b/Documentation/Changelog/12.0/Important-98475-UnsignedPidTableColumns.rst new file mode 100644 index 0000000..5b7d0d5 --- /dev/null +++ b/Documentation/Changelog/12.0/Important-98475-UnsignedPidTableColumns.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-98475-1664482965: + +================================================ +Important: #98475 - Unsigned "pid" table columns +================================================ + +See :issue:`98475` + +Description +=========== + +When upgrading to TYPO3 v12, the install tool database analyzer will change +the column field :sql:`pid` of various tables to :sql:`UNSIGNED`: They no longer +accept negative values. + +Negative pids were needed until TYPO3 v10 in combination with workspaces, the Core +nowadays only inserts rows with positive integers or 0 (zero) as pid value. An +upgrade wizard took care of updating affected rows when upgrading to v10, current +databases shouldn't contain such rows anymore. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.0/Important-98484-ExtensionsOutsideOfDocumentRootForComposer-basedTYPO3Installations.rst b/Documentation/Changelog/12.0/Important-98484-ExtensionsOutsideOfDocumentRootForComposer-basedTYPO3Installations.rst new file mode 100644 index 0000000..12740ac --- /dev/null +++ b/Documentation/Changelog/12.0/Important-98484-ExtensionsOutsideOfDocumentRootForComposer-basedTYPO3Installations.rst @@ -0,0 +1,98 @@ +.. include:: /Includes.rst.txt + +.. _important-98484-1664553704: + +========================================================================================================= +Important: #98484 - Extensions and assets outside of document root for Composer-based TYPO3 installations +========================================================================================================= + +See :issue:`98484` + +Description +=========== + +TYPO3 v12 requires the Composer plugin `typo3/cms-composer-installers` with v5, +which automatically installs extensions into Composer's :file:`vendor/` +directory, just like any other regular dependency. This increases the default +security, so that files from extensions can no longer be accessed directly via +HTTP. + +In order to allow serving assets (images/icons, CSS, JavaScript) from the +public web folder, every directory :file:`Resources/Public/` of any +installed extension is symlinked from their original location to +a directory called :file:`_assets/` within the public web folder (:file:`public/` by +default). + +The name of a symlinked directory is created as a MD5 hash to prevent possible +information disclosure. As of now, this hash depends on the extension name +and its Composer project path, so it will not change upon deployment. The specific +hashing is an implementation detail that may be subject to change with future TYPO3 +major versions. + +For example, a file that was previously accessible as +:file:`public/typo3conf/ext/my_extension/Resources/Public/Images/logo.svg` will +now be stored in :file:`vendor/my-vendor/my-extension/Resources/Public/Images/logo.svg` +and be symlinked to :file:`public/_assets/9e592a1e5eec5752a1be78133e5e1a60/Resources/Public/Images/logo.svg`. + +Impact +====== + +Please note that this only affects TYPO3 installations in Composer mode: + +* Any references from your Fluid templates, CSS/JavaScript files (or similar) + that pointed to `typo3conf/ext/...` must now be changed (search your extension + code for `typo3conf/ext/`). Ideally change code within Fluid or TypoScript, so + that you can use a `EXT:my_extension/Resources/Public/...` + reference. Those will automatically point to the right :file:`_assets` directory. + For example, the :fluid:`f:uri.resource` ViewHelper will help you with this, as + well as the TypoScript :ref:`stdWrap insertData and data path <t3tsref:data-type-gettext-path>` or + :ref:`typolink <t3tsref:typolink>` / :ref:`IMG_RESOURCE <t3tsref:cobj-img-resource>` + functionality. Also, in most YAML definitions you can use + the `EXT:my_extension/Resources/Public/...` notation. +* Adjust possible frontend build pipelines which previously wrote files into + :file:`typo3conf/ext/...` so that they are now put into your extension source + directory (for example, :file:`packages/my-extension/...`). +* Any other static links to these files (like PHP API endpoints) must be changed + to either utilize dynamic routes, middleware endpoints or static files/directories + from custom directories in your project's public web path. +* References within the same extension should use relative links, for example use + :css:`background-image: url('../Images/logo.jpg')` instead of + :css:`background-image: url('/typo3conf/ext/my_extension/Resources/Public/Images/logo.jpg')`. +* You can use TypoScript/PHP/Fluid as mentioned above to create variables with + resolved asset URI locations. These variables can utilize the + `EXT:my_extension/Resources/Public/...` notation, and can be passed along + to a JavaScript variable or a HTML DOM/data attribute, so it can be further evaluated. +* If one extension links to an asset from another extension, and you cannot use + the `EXT:my_extension/Resources/Public/...` syntax (for example, background images + in a CSS file) you should either: + + * Create a central, sitepackage-like extension that can take care of delivering + all assets. CSS classes could be defined that refer to assets, and then other + extensions could use the CSS class, instead of utilizing + their own :css:`background-image: url(...)` directives. Ideally, use a bundler + for your CSS/JavaScript (for example Vite, webpack, grunt/gulp, encore, ...) + so that you only have a single extension that is responsible for shared assets. + Bundlers can also help you to have a central asset storage, and distribute + copies of these assets to all dependencies/sub-packages that depend on these assets. + * Utilize a PSR middleware or dynamic routes to "listen" on a specific URL like + :file:`dynamicAssets/logo.jpg` and create a wrapper that returns specific files, + resolved via the TYPO3 method + :php:`PathUtility::getAbsoluteWebPath(GeneralUtility::getFileAbsFileName('EXT:my-extension/Resources/Public/logo.jpg')`. + * If all else fails: You can link to the full MD5 hashed URL, like + :css:`background-image: url('/_assets/9e592a1e5eec5752a1be78133e5e1a60/Images/logo.jpg')` + (or create a custom stable symlink, for example within your deployment, that points + to the hashed directory name). + The caveat of this: the hashing method may change in future TYPO3 major versions, + and since the hash is based on a Composer project directory, this is only a suitable + workaround for custom projects, and not publicly available extensions that need to + work in all installations. Changes to the location/name of the :file:`vendor/` directory + would then break frontend functionality. + +For more details and the background about the change, read more: + +* https://usetypo3.com/composer-changes-for-typo3-v11-and-v12.html +* https://b13.com/core-insights/typo3-and-composer-weve-come-a-long-way +* https://brotkrueml.dev/migration-typo3-composer-cms-installers-version-4/ +* :ref:`Documentation on public/_assets/ structure <t3coreapi:directory-public-assets>` + +.. index:: CLI, PHP-API, ext:core diff --git a/Documentation/Changelog/12.0/Index.rst b/Documentation/Changelog/12.0/Index.rst new file mode 100644 index 0000000..ea1fa01 --- /dev/null +++ b/Documentation/Changelog/12.0/Index.rst @@ -0,0 +1,53 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-0: + +============ +12.0 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Breaking-* + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.1/Deprecation-97536-LinkResultFactory.rst b/Documentation/Changelog/12.1/Deprecation-97536-LinkResultFactory.rst new file mode 100644 index 0000000..601495a --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-97536-LinkResultFactory.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97536-1651523804: + +======================================= +Deprecation: #97536 - LinkResultFactory +======================================= + +See :issue:`97536` + +Description +=========== + +The PHP class :php:`TYPO3\CMS\Frontend\Typolink\LinkResultFactory` has been +marked as deprecated, as its functionality has been migrated into +:php:`TYPO3\CMS\Frontend\Typolink\LinkFactory`. + +In addition, the method :php:`createFromUriString()` has been marked as +deprecated as the shortened variant `createUri()` should be used instead. + + +Impact +====== + +Instantiating an object of type :php:`LinkResultFactory` will instantiate +:php:`LinkFactory` instead via class alias in TYPO3 v12, as the class itself +has been removed. + +Calling :php:`createFromUriString()` will trigger a deprecation log entry. + +The extension scanner reports affected extensions. + +Affected installations +====================== + +TYPO3 installations with custom extensions instantiating :php:`LinkResultFactory` +as a PHP object or calling :php:`createFromUriString()` directly, which is very +rare. + + +Migration +========= + +TYPO3 extensions should migrate to using :php:`LinkFactory` and its main methods +directly. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.1/Deprecation-98613-CKEditorRemovePluginConfigurationAsString.rst b/Documentation/Changelog/12.1/Deprecation-98613-CKEditorRemovePluginConfigurationAsString.rst new file mode 100644 index 0000000..805e25a --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-98613-CKEditorRemovePluginConfigurationAsString.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98613: + +=================================================================== +Deprecation: #98613 - CKEditor removePlugin configuration as string +=================================================================== + +See :issue:`98613` + +Description +=========== + +The :yaml:`removePlugins` option needs to be assigned as an array in CKEditor 5. +While we recommended passing the option already as an array, CKEditor 4 needed a +comma-separated string. + +The conversion was only handled if the integrator passed an array, which means +if someone already provided a comma-separated string the option was simply +passed as-is to the editor configuration. + +To avoid JavaScript errors, we are going to migrate it to array for now. The +possibility to pass the option as a string is deprecated and will be removed +with TYPO3 v13. + + +Impact +====== + +Passing the CKEditor configuration :yaml:`removePlugins` as string will trigger +a PHP :php:`E_USER_DEPRECATED` error. + + +Affected installations +====================== + +All installations that pass the CKEditor configuration :yaml:`removePlugins` as +string. + + +Migration +========= + +Adjust your CKEditor configuration and pass :yaml:`removePlugins` as array. + + +Before +------ + +.. code-block:: yaml + + editor: + config: + removePlugins: image + +After +----- + +.. code-block:: yaml + + editor: + config: + removePlugins: + - image + +.. index:: RTE, NotScanned, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.1/Deprecation-98996-DoctrineDBALBackendWorkspaceRestrictionAndFrontendWorkspaceRestriction.rst b/Documentation/Changelog/12.1/Deprecation-98996-DoctrineDBALBackendWorkspaceRestrictionAndFrontendWorkspaceRestriction.rst new file mode 100644 index 0000000..4487609 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-98996-DoctrineDBALBackendWorkspaceRestrictionAndFrontendWorkspaceRestriction.rst @@ -0,0 +1,102 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98996-1667549770: + +================================================================================================= +Deprecation: #98996 - Doctrine DBAL: BackendWorkspaceRestriction and FrontendWorkspaceRestriction +================================================================================================= + +See :issue:`98996` + +Description +=========== + +TYPO3's Database Abstraction Layer works with restrictions to limit the selection +based on TYPO3's TCA information for certain database tables. + +With the introduction of Doctrine DBAL and the Database Restrictions in TYPO3 v8, +the two restrictions +:php:`\TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction` +and :php:`\TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction` +were introduced, which had some conceptual flaws. The usages to these restrictions +were removed subsequently within TYPO3 Core since TYPO3 v9, as various improvements +were made to the database layer when working with Workspaces. + +In TYPO3 v9.5.x a new restriction :php:`\TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction` +:ref:`was added <important-84985>`, which superseded both existing +Workspace-related restrictions, solving almost all cases needed when reading +rows from the database. + +The former restriction classes have now been marked as deprecated. + + +Impact +====== + +Instantiating any of the classes + +* :php:`\TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction` +* :php:`\TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction` + +will trigger a PHP deprecation notice. + + +Affected installations +====================== + +TYPO3 installations with custom extensions explicitly using one of the +restrictions. Affected extensions can be detected via the Extension Scanner +in the Install Tool / Maintenance Area. + + +Migration +========= + +Use the class :php:`\TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction` +instead. It allows to hand in the current workspace ID, which then fetches all +records from the database only for a certain workspace (unlike +:php:`FrontendWorkspaceRestriction` which did not limit the database query to +one workspace in certain cases). + +When querying the database, ensure to use the Overlay APIs in +:php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->versionOL` (Frontend) +or :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::workspaceOL()` would then +filter the invalid records. + +Example +------- + +This shows a regular example within the TYPO3 backend to query records +within + +.. code-block:: php + + $context = GeneralUtility::makeInstance(Context::class); + $workspaceId = $context->getPropertyFromAspect('workspace', 'id', 0); + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('tt_content'); + + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) + ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId)); + + $statement = $queryBuilder + ->select('*') + ->from('tt_content') + ->where( + $queryBuilder->expr()->eq('colPos', $queryBuilder->createNamedParameter(0)) + ) + ->execute(); + + $records = []; + while ($record = $statement->fetchAssociative()) { + BackendUtility::workspaceOL('tt_content', $record, $workspaceId); + if (is_array($record)) { + $records[] = $record; + } + } + return $records; + +.. index:: Database, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.1/Deprecation-99019-DeprecatedExt_emconfphpClearCacheOnLoad.rst b/Documentation/Changelog/12.1/Deprecation-99019-DeprecatedExt_emconfphpClearCacheOnLoad.rst new file mode 100644 index 0000000..cdd9ff6 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99019-DeprecatedExt_emconfphpClearCacheOnLoad.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99019-1667905697: + +================================================================ +Deprecation: #99019 - Deprecated ext_emconf.php clearCacheOnLoad +================================================================ + +See :issue:`99019` + +Description +=========== + +The array keys :php:`clearCacheOnLoad` and :php:`clearcacheonload` +in extension's :file:`ext_emconf.php` files have been deprecated and +can be removed. + +When loading or unloading extensions using the extension manager, +all caches are always cleared. + + +Impact +====== + +When loading or unloading extensions using the extension manager, all +caches are flushed, regardless of the boolean toggle in :file:`ext_emconf.php`. + + +Affected installations +====================== + +Instances with extensions :file:`ext_emconf.php` files setting :php:`clearCacheOnLoad` +or :php:`clearcacheonload`. + + +Migration +========= + +Simply drop this key from :file:`ext_emconf.php`. Extensions with this toggle set +to true that want to keep compatibility with both TYPO3 v11 and v12 should keep +the setting until v11 compatibility is dropped from the extensions. + + +.. index:: PHP-API, NotScanned, ext:extensionmanager diff --git a/Documentation/Changelog/12.1/Deprecation-99020-DeprecateTypoScriptTemplateService.rst b/Documentation/Changelog/12.1/Deprecation-99020-DeprecateTypoScriptTemplateService.rst new file mode 100644 index 0000000..2254978 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99020-DeprecateTypoScriptTemplateService.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99020-1667911024: + +========================================================== +Deprecation: #99020 - Deprecate TypoScript/TemplateService +========================================================== + +See :issue:`99020` + +Description +=========== + +The class :php:`TYPO3\CMS\Core\TypoScript\TemplateService` has been marked as deprecated +in TYPO3 v12 and will be removed in v13. This class is sometimes indirectly accessed +using :php:`TypoScriptFrontendController->tmpl` or :php:`$GLOBALS['TSFE']->tmpl`. + + +Impact +====== + +The class :php:`TemplateService` is part of the old TypoScript parser and has been +substituted with a :ref:`new parser approach <breaking-97816-1664800747>`. +Actively calling class methods will trigger a deprecation log level warning. + + +Affected installations +====================== + +Instances with extensions directly using :php:`TemplateService` or indirectly +using it by calling :php:`TypoScriptFrontendController->tmpl` or +:php:`$GLOBALS['TSFE']->tmpl` are affected. + + +Migration +========= + +The class :php:`TemplateService` is typically called in TYPO3 frontend scope. Extensions +should avoid using :php:`TypoScriptFrontendController->tmpl` and :php:`$GLOBALS['TSFE']->tmpl` +methods and properties. They can retrieve TypoScript from the PSR-7 request instead +using the attribute :ref:`frontend.typoscript <feature-98914-1666689687>`. +As example, the full frontend TypoScript can be retrieved like this: + +.. code-block:: php + + $fullTypoScript = $request->getAttribute('frontend.typoscript')->getSetupArray(); + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.1/Deprecation-99031-DeprecatedFformathtmlInBackendContext.rst b/Documentation/Changelog/12.1/Deprecation-99031-DeprecatedFformathtmlInBackendContext.rst new file mode 100644 index 0000000..49308f3 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99031-DeprecatedFformathtmlInBackendContext.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99031-1667998430: + +================================================================= +Deprecation: #99031 - Deprecated f:format.html in Backend context +================================================================= + +See :issue:`99031` + +Description +=========== + +The :html:`<f:format.html />` ViewHelper :php:`TYPO3\CMS\Fluid\ViewHelpers\Format\HtmlViewHelper` +should not be used in TYPO3 backend context anymore. + +Using this ViewHelper in backend context triggers frontend :typoscript:`parseFunc` logic, which +should be avoided in the backend. + +There are other ViewHelpers to output and parse HTML in backend context. See description of +the :ref:`f:sanitize.html <feature-94825-1667998632>` ViewHelper for more details. + + +Impact +====== + +Using :html:`<f:format.html />` logs a deprecation level warning. + + +Affected installations +====================== + +Instances with extensions that come with backend modules using Fluid rendering and +accessing :html:`<f:format.html />` are affected. + + +Migration +========= + +Switch to one of the other ViewHelpers instead, typically :html:`<f:sanitize.html />` +to secure a given HTML string, :html:`<f:transform.html />` to parse links in HTML, +or :html:`<f:format.raw />` to output the HTML as is when the input can be considered +"secure". + + +.. index:: Backend, Fluid, NotScanned, ext:fluid diff --git a/Documentation/Changelog/12.1/Deprecation-99040-DeprecatedTypoScriptSetupConstantsTop-level-object.rst b/Documentation/Changelog/12.1/Deprecation-99040-DeprecatedTypoScriptSetupConstantsTop-level-object.rst new file mode 100644 index 0000000..0e7afe3 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99040-DeprecatedTypoScriptSetupConstantsTop-level-object.rst @@ -0,0 +1,93 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99040-1668076207: + +============================================================================== +Deprecation: #99040 - Deprecated TypoScript setup "constants" top-level-object +============================================================================== + +See :issue:`99040` + +Description +=========== + +The frontend TypoScript setup (!) top-level-object :typoscript:`constants` can be +used to define constants for replacement inside a :typoscript:`parseFunc`. +If :typoscript:`parseFunc` somewhere is configured with :typoscript:`.constants = 1`, +then all occurrences of the constant in the text will be substituted with the +actual value. + +This construct has been marked as deprecated in TYPO3 v12 and will be removed with v13. + + +Impact +====== + +Using the :typoscript:`constants` top-level-object in combination with the +:typoscript:`constants = 1` in :typoscript:`parseFunc` to substitute strings +like :typoscript:`###MY_CONSTANT###` triggers a deprecation level log error +in TYPO3 v12 and will stop working in v13. + + +Affected installations +====================== + +This is a relatively rarely used feature, not well-known by many integrators. +TYPO3 integrators should watch out for :typoscript:`###` markers within +TypoScript, the :guilabel:`Template` backend module search functionality should +help here. + +The :guilabel:`Template Analyzer` will also show usages of the setup top-level-object +:typoscript:`constants`. + + +Migration +========= + +One possible solution is to switch to TypoScript constants / settings instead +for simple cases. + +A simple example usage before: + +.. code-block:: typoscript + + TypoScript setup: + + constants.EMAIL = mail@example.com + page = PAGE + page.10 = TEXT + page.10.value = Write an email to ###EMAIL### + page.10.parseFunc.constants = 1 + +Switching to a TypoScript constant / setting: + +.. code-block:: typoscript + + TypoScript constants / settings: + + myEmail = mail@example.com + + TypoScript setup: + + page = PAGE + page.10 = TEXT + page.10.value = Write an email to {$myEmail} + +The main usage of this feature has been a "magic" substitution within :typoscript:`lib.parseFunc_RTE`: +When :sql:`tt_content` rich text content elements contain such substitution strings, they are +replaced by :typoscript:`parseFunc` accordingly. For instance, a tt_content RTE element with the +content `Send an email to ###EMAIL###` would substitute to `Send an email to email@example.com` *if* +the top-level setup :typoscript:`constants` object has been set up. This substitution +relies on the fact that editors actively know about and use this construct: If only one content +element did not prepare for this - since an editor forgot or hasn't been trained about it, changing +such a constant on TypoScript level would still lead to faulty frontend output, rendering the +entire substitution approach useless. + +In case instances still rely on this magic substitution principle, and made sure all editors +always know and follow this approach, instances can use the :typoscript:`userFunc` +property of :typoscript:`parseFunc` to re-implement the functionality: basically by +copying the deprecated code to an own class and registering the :typoscript:`userFunc` +in :typoscript:`lib.parseFunc_RTE`. + + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.1/Deprecation-99050-TypoScript_CSS_PAGE_STYLEAndConfigremovePageCss.rst b/Documentation/Changelog/12.1/Deprecation-99050-TypoScript_CSS_PAGE_STYLEAndConfigremovePageCss.rst new file mode 100644 index 0000000..44c26bb --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99050-TypoScript_CSS_PAGE_STYLEAndConfigremovePageCss.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99050-1668086497: + +========================================================================= +Deprecation: #99050 - TypoScript _CSS_PAGE_STYLE and config.removePageCss +========================================================================= + +See :issue:`99050` + +Description +=========== + +Integrators can use the TypoScript setup plugin-level property :typoscript:`_CSS_PAGE_STYLE` +to define custom CSS that is loaded in the frontend. To suppress this output, the additional +property :typoscript:`config.removePageCss` (or :typoscript:`somePageObject.config.removePageCss`) +can be used. + +Handling of both plugin-level :typoscript:`_CSS_PAGE_STYLE` and config object level +property :typoscript:`removePageCss` have been marked as deprecated in TYPO3 v12 and +will be removed in v13. + + +Impact +====== + +Using a TypoScript property like :typoscript:`plugin.tx_myPlugin._CSS_PAGE_STYLE` or +the config property :typoscript:`removePageCss` in :typoscript:`config.removePageCss` +or in a page-specific usage like :typoscript:`myPage.config.removePageCss` triggers +a deprecation level log entry. + + +Affected installations +====================== + +TypoScript property :typoscript:`_CSS_PAGE_STYLE` is a relatively rarely used +property in TYPO3 instances. Instances can use the :guilabel:`Template` backend +module to scan for usages. + + +Migration +========= + +Integrators should avoid using :typoscript:`_CSS_PAGE_STYLE` on plugin level. They +should switch adding CSS on a :typoscript:`PAGE` level. A direct replacement looks +like this: + +Before: + +.. code-block:: typoscript + + plugin.tx_myPlugin._CSS_PAGE_STYLE ( + .myClass { text-align: center } + ) + +After: + +.. code-block:: typoscript + + page.cssInline { + 10 = TEXT + 10.value ( + .myClass { text-align: center } + ) + } + +As a general note, the :typoscript:`PAGE` property :typoscript:`includeCSS` is often +better suited to include CSS as files, especially when frontend CSS is generated by +some processor like SCSS. + + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.1/Deprecation-99075-Fe_usersAndFe_groupsTSconfig.rst b/Documentation/Changelog/12.1/Deprecation-99075-Fe_usersAndFe_groupsTSconfig.rst new file mode 100644 index 0000000..f46f30a --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99075-Fe_usersAndFe_groupsTSconfig.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99075-1668337874: + +===================================================== +Deprecation: #99075 - fe_users and fe_groups TSconfig +===================================================== + +See :issue:`99075` + +Description +=========== + +The two database fields :sql:`fe_users.TSconfig` and :sql:`fe_groups.TSconfig` +have been marked as deprecated in TYPO3 v12 and will be removed in v13 along with +its PHP API. + + +Impact +====== + +Backend users and groups provide these :guilabel:`TSconfig` fields as well, they are the base +of the well-known `UserTsConfig` configuration to specify rendering and behavior of +TYPO3 backend-related details. This is kept. + +Frontend users and groups had these fields as well, they are unused by TYPO3 core +and only a few extensions ever used them. + +The frontend user and group related database fields, the editing functionality of +these fields in the backend (TCA), and according PHP API will be removed with +TYPO3 v13. In detail: + +* Database field :sql:`fe_users.TSconfig` will be removed from the table definition. +* Database field :sql:`fe_groups.TSconfig` will be removed from the table definition. +* Rendering and editing setup of field :php:`fe_users.TSconfig` will be removed from TCA. +* Rendering and editing setup of field :php:`fe_groups.TSconfig` will be removed from TCA. +* Default configuration value :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['defaultUserTSconfig']` + will be removed. +* PHP method :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->getUserTSconf()` + will be removed. + + +Affected installations +====================== + +Instances are relatively unlikely to be affected: Only a few extensions ever used these +fields to store configuration for frontend users, most likely extensions related to +additional authentication mechanisms. + +The extension scanner will find extensions that access :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['defaultUserTSconfig']` +or call :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->getUserTSconf()` as "weak" matches. + + +Migration +========= + +Extensions should avoid using the fields to store and access configuration in a +TypoScript-like syntax. Affected extensions should add own fields prefixed with an +extension-specific key, or switch to a file-based configuration approach, if possible. + +To simulate the deprecated logic, extensions may extract the deprecated parsing logic from +:php:`FrontendUserAuthentication` class into an own service, probably by fetching group data +using :php:`\TYPO3\CMS\Core\Authentication\GroupResolver`, and then merge and parse group +data of the field with frontend user-specific data. + + +.. index:: Database, Frontend, LocalConfiguration, PHP-API, TCA, TSConfig, TypoScript, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.1/Deprecation-99084-MakeContextMenuTriggerConfigurable.rst b/Documentation/Changelog/12.1/Deprecation-99084-MakeContextMenuTriggerConfigurable.rst new file mode 100644 index 0000000..05d1d22 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99084-MakeContextMenuTriggerConfigurable.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99084-1667981931: + +=============================================================== +Deprecation: #99084 - Make trigger of context menu configurable +=============================================================== + +See :issue:`99084` + +Description +=========== + +The context menu JavaScript API was adapted to also support opening +the menu through the "contextmenu" event type (right click) only. +The configuration for the context menu was streamlined and now reflects +its purpose. The trigger can now be set to "click" or "contextmenu". + + +Impact +====== + +Using the deprecated JavaScript API will trigger a warning in the console. + + +Affected installations +====================== + +All extensions that use the context menu. + + +Migration +========= + +Replace the trigger :html:`class="t3js-contextmenutrigger"` with +:html:`data-contextmenu-trigger="click"`. Prefix all configuration with +:html:`data-contextmenu-`. + +Before +------ + +.. code-block:: html + + <a href="#" + class="t3js-contextmenutrigger" + data-table="pages" + data-uid="10" + data-context="tree" + >...</a> + +After +----- + +.. code-block:: html + + <a href="#" + data-contextmenu-trigger="click" + data-contextmenu-table="pages" + data-contextmenu-uid="10" + data-contextmenu-context="tree" + >...</a> + + +.. index:: Backend, JavaScript, NotScanned diff --git a/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst b/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst new file mode 100644 index 0000000..68ba58e --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99098-StaticUsageOfFormProtectionFactory.rst @@ -0,0 +1,140 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99098-1668546853: + +=========================================================== +Deprecation: #99098 - Static usage of FormProtectionFactory +=========================================================== + +See :issue:`99098` + +Description +=========== + +:php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory` has been +constructed in a static class manner in TYPO3 v6.2, using a static property +based instance cache to avoid recreating instances for a specific typed +FormProtection implementation. This design made it impossible to retrieve an +instance of this class via dependency injection. Another side-effect was that +ensuring a properly cleared state between tests has been hard and often +spread to other tests and thus influencing them. + +To mitigate these issues, :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory` +is now transformed to a non-static class usage with injected services +and the Core runtime cache, removing the static property cache. + +Based on these changes, the old static methods :php:`get()` and +:php:`purgeInstances()` are now deprecated. + +There are two general ways to get a specific FormProtection implementation: + +* auto-detected from request: :php:`$formProtectionFactory->createFromRequest()` +* create for a specific type: :php:`$formProtectionFactory->createForType()` + +Possible types for :php:`$formProtectionFactory->createForType()` are `frontend` +`backend`, `installtool` or `disabled`. + + +Impact +====== + +Using any of the following class methods + +* :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get()` +* :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::purgeInstances()` + +will trigger a PHP deprecation notice and will throw a fatal PHP error in +TYPO3 v13. + + +Affected installations +====================== + +The extension scanner will find extensions calling :php:`FormProtectionFactory::get()` +or :php:`FormProtectionFactory::purgeInstances()` as "strong" matches. + + +Migration +========= + +Provided implementation by TYPO3 core +------------------------------------- + +Before + +.. code-block:: php + + // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; + + // BackendFormProtection + $formProtection = FormProtectionFactory::get(BackendFormProtection::class); + $formProtection = FormProtectionFactory::get('backend'); + + // FrontendFormProtection + $formProtection = FormProtectionFactory::get(FrontedFormProtection::class); + $formProtection = FormProtectionFactory::get('frontend'); + + // Default / Disabled FormProtection + $formProtection = FormProtectionFactory::get(DisabledFormProtection::class); + $formProtection = FormProtectionFactory::get('default'); + +After + +It is recommended to use :php:`FormProtectionFactory->createForRequest()` to +auto-detect which type is needed and return the corresponding instance: + +.. code-block:: php + + // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; + + // Better: Get FormProtectionFactory injected by DI. + $formProtectionFactory = GeneralUtility::makeInstance(FormProtectionFactory::class); + // $request is assumed to be available, for instance in controller classes. + $formProtection = $formProtectionFactory->createFromRequest($request); + +To create a specific type directly, using following replacements: + +.. code-block:: php + + // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; + // Better: Get FormProtectionFactory injected by DI. + $formProtectionFactory = GeneralUtility::makeInstance(FormProtectionFactory::class); + + // BackendFormProtection + $formProtection = $formProtectionFactory->createFromType('backend'); + + // FrontendFormProtection + $formProtection = $formProtectionFactory->createFromType('frontend'); + + // Default / Disabled FormProtection + $formProtection = $formProtectionFactory->createFromType('disabled'); + +Custom FormProtection-based implementation +------------------------------------------ + +Before + +.. code-block:: php + + // use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; + + $formProtection = FormProtectionFactory::get( + Vendor\ExtensionKey\FormProtection\CustomFormProtection::class, + $customService, + 'someDirectValue', + ... + ); + +After + +.. code-block:: php + + // Create an instance of the class yourself, take care of an + // instance cache if needed. + GeneralUtility::makeInstance( + Vendor\ExtensionKey\FormProtection\CustomFormProtection::class, + $constructorArguments + ); + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.1/Deprecation-99150-UpdatedChartLibraryInEXTdashboard.rst b/Documentation/Changelog/12.1/Deprecation-99150-UpdatedChartLibraryInEXTdashboard.rst new file mode 100644 index 0000000..4aa3f46 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99150-UpdatedChartLibraryInEXTdashboard.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99150-1669026092: + +============================================================ +Deprecation: #99150 - Updated chart library in EXT:dashboard +============================================================ + +See :issue:`99150` + +Description +=========== + +The library `chart.js` used to render charts in a dashboard has been +updated to version 4.x, introducing some breaking changes. A migration layer is +in place to migrate known and used affected settings. + + +Impact +====== + +The CSS file :file:`EXT:dashboard/Resources/Public/Css/Contrib/chart.css` became +obsolete with the update of `chart.js` and was therefore removed without +replacement. + +If a migration is executed, an entry will be written into the deprecation log. + +Affected installations +====================== + +All plugins providing third-party chart widgets are affected. + + +Migration +========= + +Migrate the configuration as mentioned in the table below. + +================================ ============================ +Old setting New setting +================================ ============================ +graphConfig/options/scales/xAxes graphConfig/options/scales/x +graphConfig/options/scales/yAxes graphConfig/options/scales/y +================================ ============================ + +Also, please consult the migration guides available at + +* https://www.chartjs.org/docs/latest/migration/v3-migration.html +* https://www.chartjs.org/docs/latest/migration/v4-migration.html + +.. index:: Backend, JavaScript, NotScanned, ext:dashboard diff --git a/Documentation/Changelog/12.1/Deprecation-99170-ConfigbaseURLAndBaseTagFunctionality.rst b/Documentation/Changelog/12.1/Deprecation-99170-ConfigbaseURLAndBaseTagFunctionality.rst new file mode 100644 index 0000000..f6e4e85 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99170-ConfigbaseURLAndBaseTagFunctionality.rst @@ -0,0 +1,82 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99170-1669411707: + +================================================================= +Deprecation: #99170 - config.baseURL and <base> tag functionality +================================================================= + +See :issue:`99170` + +Description +=========== + +The TypoScript option :typoscript:`config.baseURL` has been deprecated. + +The option allowed to set a fixed URL which was then added as :html:`<base>` tag +to the HTML :html:`<head>` part of a website. This feature was particularly +useful back in previous TYPO3 versions in combination with RealURL for +providing absolute links. + +However, TYPO3 v9 introduced site handling, which produces absolute +URLs or absolute paths directly. In addition, with TYPO3 v12.1 the option +:ref:`config.forceAbsoluteUrls = 1 <feature-87919-1667984808>` allows to +generate absolute URLs completely for all links, images or assets, making the +baseURL option obsolete, as it isn't as powerful as the mentioned alternatives: +It only allows to define a static value rather than loading the information +based on the current request. With the TypoScript setting this is only possible +with having multiple variants of :typoscript:`config.baseURL` set via TypoScript +conditions. + +In addition to the TypoScript option, the related public PHP methods are now +obsolete and have also been deprecated: + +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->setBaseUrl()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->getBaseUrl()` +* :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->baseUrlWrap()` + + +Impact +====== + +Setting the TypoScript option :typoscript:`config.baseURL` will trigger a +deprecation message, but will continue to work in TYPO3 v12. + +Calling any of the PHP methods directly in PHP code will also trigger a +deprecation message. + + +Affected installations +====================== + +TYPO3 installations using the :typoscript:`config.baseURL` option, which is +common for projects which were started before TYPO3 v9. + + +Migration +========= + +Use the site configuration with fully-qualified domain names to achieve the same +result, as rendering a :html:`<base>` tag in HTML will not be supported +out-of-the-box anymore by TYPO3 v13. + +If you are already using the site configuration, but need to build +fully-qualified URLs, you can safely remove the TypoScript option +:typoscript:`config.baseURL` without any impact in 99% of the use cases. + +In special cases the option :typoscript:`config.forceAbsoluteUrls = 1` can +help you to achieve the same result. + +If you need to manually set a :html:`<base>` tag, this is still possible via +TypoScript: + +.. code-block:: typoscript + + page = PAGE + page.headTag.append = TEXT + page.headTag.append.value = <base href="https://static.example.com/"> + +In general, it is recommended not to use the :html:`<base>` tag, as +certain crawlers cannot interpret this HTML tag properly. + +.. index:: TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.1/Deprecation-99201-UserSessionManager-createFromGlobalCookieOrAnonymous.rst b/Documentation/Changelog/12.1/Deprecation-99201-UserSessionManager-createFromGlobalCookieOrAnonymous.rst new file mode 100644 index 0000000..6111650 --- /dev/null +++ b/Documentation/Changelog/12.1/Deprecation-99201-UserSessionManager-createFromGlobalCookieOrAnonymous.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99201-1669561044: + +=========================================================================== +Deprecation: #99201 - UserSessionManager->createFromGlobalCookieOrAnonymous +=========================================================================== + +See :issue:`99201` + +Description +=========== + +The PHP method :php:`\TYPO3\CMS\Core\Session\UserSessionManager->createFromGlobalCookieOrAnonymous` +has been deprecated. It served as a low-level API to create a user session based +on the superglobal :php:`$_COOKIE` variable. However, the usage of PHP +superglobals should be avoided in TYPO3 code. As TYPO3 Core is moving towards +accessing request information via PSR-7 request attribute, this method is also +deprecated, even though it was only introduced in TYPO3 v11.0. + + +Impact +====== + +Calling the method directly within PHP code of a third-party extension will +trigger a PHP deprecation message. + + +Affected installations +====================== + +The method was only introduced in TYPO3 v11.0, and only acted as a +backwards-compatibility layer for using the UserSessionManager API class in +legacy code, which is why it is very unlikely that this method is called directly +in any TYPO3 extension. However, the Extension Scanner will pick up any +usages of this method. + +TYPO3 extensions usually do not use the :php:`UserSessionManager` directly to create +a user session. + + +Migration +========= + +The :php:`UserSessionManager` API also provides the +:php:`createFromRequestOrAnonymous(ServerRequestInterface $request)` method when +the API itself was added. The method achieves the same logic based on a PSR-7 +request. Use this method instead and use PSR-7 as much as possible. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.1/Feature-100586-Null-safeOperatorInTypoScriptConditions.rst b/Documentation/Changelog/12.1/Feature-100586-Null-safeOperatorInTypoScriptConditions.rst new file mode 100644 index 0000000..72d4f32 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-100586-Null-safeOperatorInTypoScriptConditions.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-100586-1681464016: + +============================================================== +Feature: #100586 - Null-safe operator in TypoScript conditions +============================================================== + +See :issue:`100586` + +Description +=========== + +By raising TYPO3's Symfony dependencies to `6.2` in :issue:`99239`, a couple +of new features were made available for the `expression language`_, which is +used by TYPO3 for its :ref:`TypoScript conditions <t3tsref:conditions>`. + +One of those new features is the `null-safe operator`_. This operator is +especially useful when accessing properties on objects, which however might +not be available in some context, e.g. "TSFE" in the backend. + +TYPO3 designed its custom expression functions in a way that they support the +usage of the null-safe operator by default. This is done by returning +:php:`NULL` in case the requested object is not available. + +Therefore, instead of :typoscript:`[getTSFE() && getTSFE().id == 123]`, +integrators can simplify the condition to :typoscript:`[getTSFE()?.id == 123]`. + +Impact +====== + +It's now possible to simplify TypoScript conditions using the new expression +language features, especially the null-safe operator. + +.. _expression language: https://symfony.com/doc/current/reference/formats/expression_language.html +.. _null-safe operator: https://symfony.com/doc/current/reference/formats/expression_language.html#null-safe-operator + +.. index:: Backend, Frontend, TypoScript, ext:core diff --git a/Documentation/Changelog/12.1/Feature-87919-AllowGenerationOfAbsoluteURLsCompletely.rst b/Documentation/Changelog/12.1/Feature-87919-AllowGenerationOfAbsoluteURLsCompletely.rst new file mode 100644 index 0000000..3d92e8c --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-87919-AllowGenerationOfAbsoluteURLsCompletely.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-87919-1667984808: + +============================================================== +Feature: #87919 - Allow generation of absolute URLs completely +============================================================== + +See :issue:`87919` + +Description +=========== + +A new TypoScript option :typoscript:`config.forceAbsoluteUrls = 1` has been added. + + +Impact +====== + +If the option is set, all links, references to images or assets previously built with a relative +or absolute path (e.g. :file:`/fileadmin/my-pdf.pdf`) will be rendered as absolute URLs +with the site prefix / current domain. + +Examples for such use cases are the generation of a full static version of a TYPO3 site +for sending a page via email. + +.. index:: TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-91499-AdditionalAttributesForIncludeJSIncludeCSSAndAllOtherInclude.rst b/Documentation/Changelog/12.1/Feature-91499-AdditionalAttributesForIncludeJSIncludeCSSAndAllOtherInclude.rst new file mode 100644 index 0000000..fb71d58 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-91499-AdditionalAttributesForIncludeJSIncludeCSSAndAllOtherInclude.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _feature-91499: + +============================================================================================== +Feature: #91499 - Additional attributes for includeJS, includeCSS and all other page.include** +============================================================================================== + +See :issue:`91499` + +Description +=========== + +The :php:`PageRenderer` supports additional tag attributes for CSS and JavaScript files. +These data attributes can be configured using a key/value list via TypoScript. + +* :typoscript:`page.includeCSS` +* :typoscript:`page.includeCSSLibs` +* :typoscript:`page.includeJS` +* :typoscript:`page.includeJSFooter` +* :typoscript:`page.includeJSLibs` +* :typoscript:`page.includeJSFooterlibs` + + +Impact +====== + +It is now possible to extend :html:`<script>` and :html:`<link>` tags with any +kind of HTML tag attributes, which is very useful for integration with +external scripts such as a consent manager. + +Example +------- + +Configuration: + +.. code-block:: typoscript + + page = PAGE + page { + includeCSSLibs { + someIncludeFile = fileadmin/someIncludeFile1 + someIncludeFile.data-foo = includeCSSLibs + } + includeCSS { + someIncludeFile = fileadmin/someIncludeFile2 + someIncludeFile.data-foo = includeCSS + } + includeJSLibs { + someIncludeFile = fileadmin/someIncludeFile3 + someIncludeFile.data-consent-type = marketing + } + includeJS { + someIncludeFile = fileadmin/someIncludeFile4 + someIncludeFile.data-consent-type = essential + } + includeJSFooterlibs { + someIncludeFile = fileadmin/someIncludeFile5 + someIncludeFile.data-my-attribute = foo + } + includeJSFooter { + someIncludeFile = fileadmin/someIncludeFile6 + someIncludeFile.data-foo = includeJSFooter + } + } + +Reserved keywords which will not be mapped to attributes are: + +- :typoscript:`compress` +- :typoscript:`forceOnTop` +- :typoscript:`allWrap` +- :typoscript:`type` (set automatically, depending on :typoscript:`config.doctype`) +- :typoscript:`disableCompression` +- :typoscript:`excludeFromConcatenation` +- :typoscript:`external` +- :typoscript:`inline` + +Resulting HTML of the above example: + +.. code-block:: html + + <head> + <link rel="stylesheet" type="text/css" href="/typo3conf/ext/myext/Resources/Public/someIncludeFile1" media="all" data-foo="includeCSS"> + <link rel="stylesheet" type="text/css" href="/typo3conf/ext/myext/Resources/Public/someIncludeFile2" media="all" data-foo="includeCSSLibs"> + + <script src="/typo3conf/ext/myext/Resources/Public/someIncludeFile3" data-consent-type="marketing"></script> + <script src="/typo3conf/ext/myext/Resources/Public/someIncludeFile4" data-consent-type="essential"></script> + </head> + <body> + <script src="/typo3conf/ext/myext/Resources/Public/someIncludeFile5" data-my-attribute="foo"></script> + <script src="/typo3conf/ext/myext/Resources/Public/someIncludeFile6" data-foo="includeJSFooteribs"></script> + </body> + +.. index:: Frontend, TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-93112-AllowGlobPatternsInYamlImports.rst b/Documentation/Changelog/12.1/Feature-93112-AllowGlobPatternsInYamlImports.rst new file mode 100644 index 0000000..334504f --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-93112-AllowGlobPatternsInYamlImports.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-93112-1667904722: + +===================================================== +Feature: #93112 - Allow glob patterns in yaml imports +===================================================== + +See :issue:`93112` + +Description +=========== + +The TYPO3 :php:`YamlFileLoader` (used, for example, when loading site configurations) does +now support importing files with glob patterns. This will simplify the configuration +and allow for more compact configuration files. + +To enable globbing, set the option :yaml:`glob: true` on the import level. + + +Impact +====== + +You can now use `glob()` syntax when importing configuration files in YAML. + +Example: + +.. code-block:: yaml + + imports: + - { resource: "./**/*.yaml", glob: true } + - { resource: "EXT:core/Tests/**/Configuration/**/SiteConfigs/*.yaml", glob: true } + +.. index:: PHP-API, YAML, ext:core diff --git a/Documentation/Changelog/12.1/Feature-93423-ShowWarningAboutDuplicatedRootPagesInSitesModule.rst b/Documentation/Changelog/12.1/Feature-93423-ShowWarningAboutDuplicatedRootPagesInSitesModule.rst new file mode 100644 index 0000000..d0e466a --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-93423-ShowWarningAboutDuplicatedRootPagesInSitesModule.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-93423-1667988850: + +========================================================================== +Feature: #93423 - Show warning about duplicated root pages in sites module +========================================================================== + +See :issue:`93423` + +Description +=========== + +It might happen that the same root page ID is configured for multiple site +configurations, e.g. in case corresponding files were copied manually. This +might lead to misbehavior, since always the last site with this root page +ID defined is used by TYPO3. As such configuration errors might be hard to +spot, the :guilabel:`Sites` module now informs about such duplications in +the site configuration overview view. + +Impact +====== + +The site module now warns administrators in case the same root page ID is used +in multiple site configurations. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-96005-AllowTaggingandAliasingOfDataProcessors.rst b/Documentation/Changelog/12.1/Feature-96005-AllowTaggingandAliasingOfDataProcessors.rst new file mode 100644 index 0000000..4b7436f --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-96005-AllowTaggingandAliasingOfDataProcessors.rst @@ -0,0 +1,91 @@ +.. include:: /Includes.rst.txt + +.. _feature-96005-1660340104: + +=============================================================== +Feature: #96005 - Allow tagging and aliasing of data processors +=============================================================== + +See :issue:`96005` + +Description +=========== + +It is now possible to set an alias / identifier for data processors by +tagging them with the :yaml:`data.processor` tag in the +:file:`Configuration/Services.yaml` file and defining the :yaml:`identifier` +key. On the one hand, this improves readability in corresponding TypoScript +configurations, since those aliases / identifiers can be used instead of +the fully-qualified class name, while also providing dependency injection out +of the box. On the other hand, this allows improving and enhancing the +functionality of data processors in the future by automatically adding +tagged processors to a registry. + +Tagging a data processor in the :file:`Configuration/Services.yaml` file: + +.. code-block:: yaml + + Vendor\MyExt\DataProcessing\AwesomeProcessor: + tags: + - { name: 'data.processor', identifier: 'awesome' } + +Usage in TypoScript: + +.. code-block:: typoscript + + dataProcessing.10 = awesome + +All data processors shipped by TYPO3 are already tagged and can therefore +be used with their alias / identifier in your TypoScript configuration: + +.. code-block:: typoscript + + # Default with fully-qualified class name (still supported): + dataProcessing { + 10 = TYPO3\CMS\Frontend\DataProcessing\CommaSeparatedValueProcessor + 20 = TYPO3\CMS\Frontend\DataProcessing\DatabaseQueryProcessor + 30 = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor + 40 = TYPO3\CMS\Frontend\DataProcessing\FlexFormProcessor + 50 = TYPO3\CMS\Frontend\DataProcessing\GalleryProcessor + 60 = TYPO3\CMS\Frontend\DataProcessing\LanguageMenuProcessor + 70 = TYPO3\CMS\Frontend\DataProcessing\MenuProcessor + 80 = TYPO3\CMS\Frontend\DataProcessing\SiteProcessor + 90 = TYPO3\CMS\Frontend\DataProcessing\SiteLanguageProcessor + 100 = TYPO3\CMS\Frontend\DataProcessing\SplitProcessor + } + + # New alternative using the alias / identifier: + dataProcessing { + 10 = comma-separated-value + 20 = database-query + 30 = files + 40 = flex-form + 50 = gallery + 60 = language-menu + 70 = menu + 80 = site + 90 = site-language + 100 = split + } + +.. note:: + + The standard service aliasing mechanism is still supported. However, + it is recommended to tag the data processors instead, because this will + automatically add them to the internal :php:`DataProcessorRegistry`, + enabling dependency injection by default. Otherwise the service would need + to be set :yaml:`public`. + +.. note:: + + It might be that your data processor should not be shared. In such case + you need to set the :yaml:`shared: false` tag attribute for the service. + +Impact +====== + +Data processors can now be tagged with the :yaml:`data.processor` tag. This +allows to define an alias / identifier, which can then be used instead of +the fully-qualified class name, e.g. in TypoScript configurations. + +.. index:: TypoScript, Frontend, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-97309-DifferentiateRedirectsBasedOnCreationType.rst b/Documentation/Changelog/12.1/Feature-97309-DifferentiateRedirectsBasedOnCreationType.rst new file mode 100644 index 0000000..3104aae --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-97309-DifferentiateRedirectsBasedOnCreationType.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-97309: + +================================================================ +Feature: #97309 - Differentiate redirects based on creation type +================================================================ + +See :issue:`97309` + +Description +=========== + +A new field :sql:`creation_type` has been added to the :sql:`sys_redirect` table. +This allows to differentiate between redirects created automatically when the +slug of a page has changed and the ones which are created in the backend +module by editors. + +A new option in the :guilabel:`Redirects` module allows to filter by this type. + +Impact +====== + +The distinction by the creation type helps users to administrate the redirect +records and helps to identify why a record has been created initially. + +An update wizard updates all existing redirects and sets the type to "manual". + +If desired, the available items of the field :sql:`creation_type` can be extended +with additional types by adjusting TCA for this field. This simplifies the +registration of additional types, which are automatically available in the backend +filtering. Possible use cases are, for example, synchronized redirects or migrated +from other / old sources sources. + +.. code-block:: php + + $GLOBALS['TCA']['sys_redirect']['columns']['creation_type']['config']['items'][] = [ + 'My extension redirects', + 91, + ]; + +.. index:: Backend, ext:redirects diff --git a/Documentation/Changelog/12.1/Feature-97391-UsePasswordPolicyForPasswordResetInExtbackend.rst b/Documentation/Changelog/12.1/Feature-97391-UsePasswordPolicyForPasswordResetInExtbackend.rst new file mode 100644 index 0000000..eb2f7da --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-97391-UsePasswordPolicyForPasswordResetInExtbackend.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-97391: + +======================================================================= +Feature: #97391 - Use password policy for password reset in ext:backend +======================================================================= + +See :issue:`97391` + +Description +=========== + +The password reset feature for TYPO3 backend users now considers the +configurable password policy introduced in :ref:`#97388 <feature-97388>`. + + +Impact +====== + +The formerly hardcoded minimum length of 8 chars for the new password +has been removed. Instead, the globally configured password policy is now +taken into account when a TYPO3 backend user resets the password. The +TYPO3 default password policy contains the following password requirements: + +* At least 8 chars +* At least one number +* At least one upper case char +* At least one special char +* Must be different than current password (if available) + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-97536-UnifiedAPIForGeneratingTypolinks.rst b/Documentation/Changelog/12.1/Feature-97536-UnifiedAPIForGeneratingTypolinks.rst new file mode 100644 index 0000000..b2590d5 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-97536-UnifiedAPIForGeneratingTypolinks.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-97536-1651523601: + +====================================================== +Feature: #97536 - Unified API for generating typolinks +====================================================== + +See :issue:`97536` + +Description +=========== + +A new :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory` class is added to TYPO3 +Core, which allows to generate any kind of links in the TYPO3 frontend - links +to files, pages, URLs, email, telephone links, or links to specific records, +such as news entries. + +Previously, this functionality resided in :php:`ContentObjectRenderer->typoLink()` +and :php:`ContentObjectRenderer->typoLink_URL()` but was extracted into a +specific class, only dealing with the generation of links. + +This class works with two main methods: + +:php:`LinkFactory->create()` +:php:`LinkFactory->createUri()` + +Both methods return a :php:`LinkResultInterface` instance, which can be used +programmatically to render the results of the link generation for HTML output +via :php:`LinkResult->getHtml()` or as JSON with :php:`LinkResult->getJson()`. + + +Impact +====== + +For TypoScript or Fluid-based renderings, the base functionality for using +:php:`ContentObjectRenderer->typoLink()` is still recommended. However, when +an extension developer wants to work with the raw result, the +:php:`LinkResultInterface` and corresponding implementations for JSON and HTML +rendering allow for much more flexibility by accessing more information than +just the raw anchor tag. + +.. index:: Frontend, PHP-API, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-97747-IntroduceMailerInterface.rst b/Documentation/Changelog/12.1/Feature-97747-IntroduceMailerInterface.rst new file mode 100644 index 0000000..2dda440 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-97747-IntroduceMailerInterface.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-97747-1654691279: + +=========================================== +Feature: #97747 - Introduce MailerInterface +=========================================== + +See :issue:`97747` + +Description +=========== + +To be able to use your own custom mailer implementation in the TYPO3 Core, an +interface :php:`\TYPO3\CMS\Core\Mail\MailerInterface` is introduced, which extends +:php:`\Symfony\Component\Mailer\MailerInterface` + +By default, :php:`\TYPO3\CMS\Core\Mail\Mailer` is registered as implementation in +:file:`Configuration/Services.yaml`. + +Example +------- + +.. code-block:: php + + use TYPO3\CMS\Core\Mail\MailerInterface; + + class MyClass + { + public function __construct( + private readonly MailerInterface $mailer + ) { + } + } + +Or where constructor injection is not possible: + +.. code-block:: php + + $mailer = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\MailerInterface::class); + +Impact +====== + +This change makes it possible to create your own :php:`\My\Custom\Mailer` that implements +:php:`\TYPO3\CMS\Core\Mail\MailerInterface` which is used by TYPO3 core. Therefore, it is recommended +to use the interface :php:`\TYPO3\CMS\Core\Mail\MailerInterface`, to let dependency injection inject the +desired implementation for every :php:`\TYPO3\CMS\Core\Mail\MailerInterface`. + +Add the following line in :file:`Configuration/Services.yaml`, to ensure that your custom +implementation can be injected. + +.. code-block:: yaml + + TYPO3\CMS\Core\Mail\MailerInterface: + alias: My\Custom\Mailer + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst b/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst new file mode 100644 index 0000000..1008082 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _feature-98373-1663587471: + +========================================================= +Feature: #98373 - Reactions - Incoming webhooks for TYPO3 +========================================================= + +See :issue:`98373` + +Description +=========== + +This feature adds the possibility to receive webhooks in TYPO3. + +With the new :guilabel:`System > Reactions` backend module it is possible to +configure the reactions triggered by any webhook. + +A webhook is defined as an authorized POST request to the backend. + +The core provides a basic default reaction that can be used to create +records triggered and enriched by data from the caller. + +Additionally, the Core provides the :php:`\TYPO3\CMS\Reactions\Reaction\ReactionInterface` +to allow extension authors to add their own reaction types. + +Any reaction record is defined by a unique uid (UUID) and also requires a secret. +Both information are generated in the backend. The secret is only visible once and +stored in the database as an encrypted value like a backend user password. + +Next to static field values, the "create record" reaction features placeholders, +which can be used to dynamically set field values by resolving the incoming +data from the webhook's payload. The syntax for those values is :code:`${key}`. +The key can be a simple string or a path to a nested value like :code:`${key.nested}`. + +Definition of the placeholders in the record +-------------------------------------------- + +.. code-block:: text + + ${title} + ${description} + ${key.nested} + +Example payload for placeholders +-------------------------------- + +.. code-block:: json + + { + "title": "My title", + "description": "My description", + "key": { + "nested": "bar" + } + } + +By default, only a few tables can be selected for external creation in the +create record reaction. In case you want to allow your own tables to be +available in the reactions' table selection, add the table in a corresponding TCA +override file with: + +.. code-block:: php + + if (\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded('reactions')) { + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTcaSelectItem( + 'sys_reaction', + 'table_name', + [ + 'label' => 'LLL:EXT:myext/Resources/Private/Language/locallang.xlf:my_table', + 'value' => 'my_table', + 'icon' => 'myext-my_table-icon', + ] + ); + } + +In case your extension depends on EXT:reactions the :php:`isLoaded()` check +might be skipped. Please note that tables with :php:`adminOnly` set are not +allowed. + +Impact +====== + +This feature allows everybody to provide additional value for any TYPO3 instance. +By reacting to webhooks, TYPO3 can now be used to create records in the backend. +Furthermore, by implementing the :php:`ReactionInterface`, it is possible to +create any custom reaction. + +.. index:: Backend, ext:reactions diff --git a/Documentation/Changelog/12.1/Feature-98521-PSR-14EventToModifyFormDataForEditFileForm.rst b/Documentation/Changelog/12.1/Feature-98521-PSR-14EventToModifyFormDataForEditFileForm.rst new file mode 100644 index 0000000..34033bb --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98521-PSR-14EventToModifyFormDataForEditFileForm.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _feature-98521-1664890745: + +===================================================================== +Feature: #98521 - PSR-14 event to modify form data for edit file form +===================================================================== + +See :issue:`98521` + +Description +=========== + +A new PSR-14 event :php:`TYPO3\CMS\Filelist\Event\ModifyEditFileFormDataEvent` +has been added, which allows to modify the form data used to render the +file edit form in the :guilabel:`File > Filelist` module using +:ref:`FormEngine data compiling <t3coreapi:FormEngine-DataCompiling>`. + +The new event can be used as an improved alternative for the removed +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/file_edit.php']['preOutputProcessingHook']` +hook. + +The event features the following methods: + +- :php:`getFormData()`: Returns the current :php:`$formData` array +- :php:`setFormData()`: Sets the :php:`$formData` array +- :php:`getFile()`: Returns the corresponding :php:`\TYPO3\CMS\Core\Resource\FileInterface` +- :php:`getRequest()`: Returns the full PSR-7 :php:`\Psr\Http\Message\ServerRequestInterface` + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\ModifyEditFileFormDataEventListener: + tags: + - name: event.listener + identifier: 'my-package/modify-edit-file-form-data-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Filelist\Event\ModifyEditFileFormDataEvent; + + final class ModifyEditFileFormDataEventListener + { + public function __invoke(ModifyEditFileFormDataEvent $event): void + { + // Get current form data + $formData = $event->getFormData(); + + // Change TCA "renderType" based on the file extension + $fileExtension = $event->getFile()->getExtension(); + if ($fileExtension === 'ts') { + $formData['processedTca']['columns']['data']['config']['renderType'] = 'tsRenderer'; + } + + // Set updated form data + $event->setFormData($formData); + } + } + +Impact +====== + +It is now possible to modify the whole :php:`$formData` array used to generate +the edit file form in the :guilabel:`File > Filelist` module, while having the +resolved :php:`FileInterface` and the current PSR-7 :php:`ServerRequestInterface` +available. + +.. index:: Backend, PHP-API, ext:filelist diff --git a/Documentation/Changelog/12.1/Feature-98540-NewFieldControlPasswordGenerator.rst b/Documentation/Changelog/12.1/Feature-98540-NewFieldControlPasswordGenerator.rst new file mode 100644 index 0000000..aa0dcde --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98540-NewFieldControlPasswordGenerator.rst @@ -0,0 +1,115 @@ +.. include:: /Includes.rst.txt + +.. _feature-98540: + +=========================================================== +Feature: #98540 - New TCA field control "passwordGenerator" +=========================================================== + +See :issue:`98540` + +Description +=========== + +A new TCA field control :php:`passwordGenerator` has been introduced, +which can be used in combination with TCA type `password`. The control +renders a button next to the password field allowing the user to generate +a random password based on defined rules. + +Using the control adds the generated password to the corresponding field. +The password is visible to the backend user only once and stored encrypted +in the database. Integrators are also able to define whether the user +is allowed to edit the generated password before saving. + +Example configuration +--------------------- + +.. code-block:: php + + 'password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'password', + 'fieldControl' => [ + 'passwordGenerator' => [ + 'renderType' => 'passwordGenerator', + 'options' => [ + 'title' => 'Generate a password', + 'allowEdit' => false, + 'passwordRules' => [ + 'length' => 38, + 'digitCharacters' => false, + 'specialCharacters' => true, + ], + ], + ], + ], + ], + ], + +This example will add the control with a custom title. The generated password +will be 38 characters long, will contain lowercase, uppercase and special +characters and no digit characters. The user won't be able to edit the +generated password. + +Field control options +--------------------- + +- :php:`title`: Define a title for the control button +- :php:`allowEdit`: Whether the user can edit the generated password +- :php:`passwordRules`: Define rules for the password. + +Available password rules: + +- :php:`length`: Defines the number of characters for the password + (minimum: :php:`8` - default: :php:`16`). +- :php:`random`: Defines the encoding of random bytes. Overrules character + definitions. Available encodings are :php:`hex` and :php:`base64`. +- :php:`digitCharacters`: Whether digits should be used (Default: :php:`true`) +- :php:`lowerCaseCharacters`: Whether lowercase characters should be used + (Default: :php:`true`) +- :php:`upperCaseCharacters`: Whether uppercase characters should be used + (Default: :php:`true`) +- :php:`specialCharacters`: Whether special characters should be used + (Default: :php:`false`) + +Random bytes +------------ + +The following example will generate a 40 characters long random hex string, which +could be used e.g. for secret tokens or similar: + +.. code-block:: php + + 'random_hex' => [ + 'label' => 'Random hex', + 'config' => [ + 'type' => 'password', + 'fieldControl' => [ + 'passwordGenerator' => [ + 'renderType' => 'passwordGenerator', + 'options' => [ + 'passwordRules' => [ + 'length' => 40, + 'random' => 'hex', + ], + ], + ], + ], + ], + ], + +.. note:: + + Defining the special :php:`random` password rule always takes + precedence over any character definition, which should therefore + be omitted as soon as :php:`random` is set to one of the available + encodings: :php:`hex` or :php:`base64`. + +Impact +====== + +It is now possible to enhance the TCA type `password` with a field control +to generate a random password based on defined password rules. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-98912-Installation-wide-ServicesConfiguration.rst b/Documentation/Changelog/12.1/Feature-98912-Installation-wide-ServicesConfiguration.rst new file mode 100644 index 0000000..b9d3f0b --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98912-Installation-wide-ServicesConfiguration.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _feature-98912-1667814888: + +========================================================== +Feature: #98912 - Installation-wide services configuration +========================================================== + +See :issue:`98912` + +Description +=========== + +It is possible to set up a global services configuration for a +project that can be used in multiple project-specific extensions. This way you +can, for example, alias an interface with a concrete implementation to be used in +several extensions. It is also possible to register project-specific CLI commands +without having the need for a project-specific extension. + +However, this only works - due to security restrictions - if TYPO3 is configured +in a way that the project root is outside the document root, which usually +happens in Composer-based installations. + +Impact +====== + +The global services configuration files :file:`services.yaml` and +:file:`services.php` are now read within the :file:`config/system/` path +of a TYPO3 project in Composer-based installations. + +Example +------- + +You want to use the interface of the PHP package `stella-maris/clock` as type +hint for DI in the service classes of your project's various extensions. Then +the concrete implementation may change without touching your code. In this +example we use `lcobucci/clock` for the concrete implementation. + +.. code-block:: php + :caption: config/system/services.php + + use Lcobucci\Clock\SystemClock; + use StellaMaris\Clock\ClockInterface; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return static function (ContainerConfigurator $containerConfigurator, ContainerBuilder $containerBuilder): void { + $services = $containerConfigurator->services(); + $services->set(ClockInterface::class) + ->factory([SystemClock::class, 'fromUTC']); + }; + + +.. index:: ext:core diff --git a/Documentation/Changelog/12.1/Feature-98914-TypoScriptAsRequestAttribute.rst b/Documentation/Changelog/12.1/Feature-98914-TypoScriptAsRequestAttribute.rst new file mode 100644 index 0000000..a5e955a --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98914-TypoScriptAsRequestAttribute.rst @@ -0,0 +1,94 @@ +.. include:: /Includes.rst.txt + +.. _feature-98914-1666689687: + +================================================= +Feature: #98914 - TypoScript as request attribute +================================================= + +See :issue:`98914` + +Description +=========== + +The TYPO3 frontend middleware chain now sets up the request +attribute :php:`frontend.typoscript`. This is an instance of +:php:`\TYPO3\CMS\Core\TypoScript\FrontendTypoScript` and contains +the calculated TypoScript :php:`settings` (formerly "constants") and +sometimes :php:`setup`, depending on page cache status. + +When a content object or plugin (plugins are content objects as well) needs the +current TypoScript, it can retrieve it using this API: + +.. code-block:: php + + // New substitution of $GLOBALS['TSFE']->tmpl->setup + $frontendTypoScriptSetupArray = $request->getAttribute('frontend.typoscript')->getSetupArray(); + +The :php:`FrontendTypoScript` attribute contains some more getters: + +* :php:`getSettingsTree()`: The TypoScript settings as object tree. This tree is still + a bit experimental in TYPO3 v12 and should only be used if really needed for now, it + may still change. It is marked internal at the moment. + + The constants tree is *always* set up in frontend requests: It is needed early for page + cache determination, content objects can expect it to be set. + +* :php:`getFlatSettings()`: The TypoScript settings as flat array. Example TypoScript: + + .. code-block:: typoscript + + mySettings { + foo = fooValue + bar = barValue + } + + Result array: + + .. code-block:: php + + $flatSettings = [ + 'mySettings.foo' => 'fooValue', + 'mySettings.bar' => 'barValue', + ]; + + The settings array is *always* set up in frontend requests: It is needed early for page + cache determination, content objects can expect it to be set. + +* :php:`getSetupTree()`: The TypoScript setup as object tree. This tree is still + a bit experimental in TYPO3 v12 and should only be used if really needed for now, it + may still change. It is marked internal at the moment. + + The setup tree is only set up, if a frontend request could not be satisfied from page + cache and a full page content calculation is required, or if a page cache does exist, + but contains :typoscript:`USER_INT` or :typoscript:`COA_INT` that have to be calculated + for each call. Effectively, when a content object rendering is called, the object can + expect the setup object tree to be set. + +* :php:`getSetupArray()`: An array representation of the setup tree. This is identical to + the old :php:`TYPO3\CMS\Core\TypoScript\TemplateService->setup` that was usually accessed + using :php:`$GLOBALS['TSFE']->tmpl->setup`. + + This is the main API to retrieve frontend TypoScript for now. Content objects do receive + the current request from the rendering chain and can retrieve the full TypoScript this way, + if needed. Note that content objects also retrieve the "local" content object configuration + already, an access to the full TypoScript in general is only needed in seldom cases. + + The setup array is only set up if a frontend request could not be satisfied from page + cache and a full page content calculation is required, or if a page cache does exist + but contains :typoscript:`USER_INT` or :typoscript:`COA_INT` that have to be calculated + for each call. Effectively, when a content object rendering is called, the object can + expect the setup object tree to be set. + + +Impact +====== + +This is a substitution especially of the deprecated :php:`TYPO3\CMS\Core\TypoScript\TemplateService`, +typical old calls were :php:`TypoScriptFrontendController->tmpl` or :php:`$GLOBALS['TSFE']->tmpl`, +often reading the :php:`setup` property using :php:`tmpl->setup` to grab the current TypoScript +setup array. These calls should be avoided, the :php:`TemplateService` and the :php:`tmpl` +property will be removed in TYPO3 v13. + + +.. index:: Frontend, PHP-API, TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-98921-GetMultipleItemsByCommonKeyPrefixFromLocalStorages.rst b/Documentation/Changelog/12.1/Feature-98921-GetMultipleItemsByCommonKeyPrefixFromLocalStorages.rst new file mode 100644 index 0000000..77b9cb8 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98921-GetMultipleItemsByCommonKeyPrefixFromLocalStorages.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-98921-1666766437: + +============================================================================= +Feature: #98921 - Get multiple items by common key prefix from local storages +============================================================================= + +See :issue:`98921` + +Description +=========== + +A new method :js:`getByPrefix()` is added to the module +:js:`@typo3/backend/storage/abstract-client-storage`, affecting its +implementations + +* :js:`@typo3/backend/storage/browser-session` +* :js:`@typo3/backend/storage/client` + + +Impact +====== + +A developer is now able to obtain multiple items prefixed by a given key either +from :js:`localStorage` or :js:`sessionStorage`. + +Example: + +.. code-block:: js + + import Client from '@typo3/backend/storage/client'; + + Client.set('common-prefix-a', 'a'); + Client.set('common-prefix-b', 'b'); + Client.set('common-prefix-c', 'c'); + + const entries = Client.getByPrefix('common-prefix-'); + // {'common-prefix-a': 'a', 'common-prefix-b': 'b', 'common-prefix-c': 'c'} + + +.. index:: JavaScript, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-98957-RespectWrite-protectedSettingsphpInInstallTool.rst b/Documentation/Changelog/12.1/Feature-98957-RespectWrite-protectedSettingsphpInInstallTool.rst new file mode 100644 index 0000000..90abdf4 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-98957-RespectWrite-protectedSettingsphpInInstallTool.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-98957-1667131640: + +====================================================================== +Feature: #98957 - Respect write-protected settings.php in Install Tool +====================================================================== + +See :issue:`98957` + +Description +=========== + +The :guilabel:`Admin Tools > Settings` backend module now informs a system +maintainer if the :file:`system/settings.php` file is write-protected. + +This allows to make the settings file read-only after deployments. + + +Impact +====== + +An info box is rendered in the module and each submodule, informing the system +maintainer that the :file:`system/settings.php` file is write-protected. +In that case, all input fields are disabled and the submit buttons are not +available. + +.. index:: LocalConfiguration, ext:install diff --git a/Documentation/Changelog/12.1/Feature-99011-AllowDescriptionsForRedirects.rst b/Documentation/Changelog/12.1/Feature-99011-AllowDescriptionsForRedirects.rst new file mode 100644 index 0000000..3f8053e --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99011-AllowDescriptionsForRedirects.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-99011-1667944274: + +================================================== +Feature: #99011 - Allow descriptions for redirects +================================================== + +See :issue:`99011` + +Description +=========== + +A new field :sql:`description` has been added to the :sql:`sys_redirect` table. + +In the backend edit form, the new field is located under the :guilabel:`Notes` +tab. It can be used to add context to the corresponding redirect. Since the +field is defined as the record's :php:`descriptionColumn`, the added +information is also displayed in the "Record information" info box +above the edit form, like known from e.g. content elements or pages. + +Impact +====== + +It is now possible to add additional information to a redirect +using the new description field, whose value is also displayed +in the corresponding backend edit form. + +.. index:: Database, TCA, Backend diff --git a/Documentation/Changelog/12.1/Feature-99033-AddTableFilterForBackendSearch.rst b/Documentation/Changelog/12.1/Feature-99033-AddTableFilterForBackendSearch.rst new file mode 100644 index 0000000..e7405b8 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99033-AddTableFilterForBackendSearch.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _feature-99033-1668008969: + +===================================================== +Feature: #99033 - Add table filter for backend search +===================================================== + +See :issue:`99033` + +Description +=========== + +The TYPO3 backend search (aka "Live Search") is using the +:php:`\TYPO3\CMS\Backend\Search\LiveSearch\DatabaseRecordProvider` to search +for records in database tables, having :php:`searchFields` configured in TCA. + +In some individual cases, it may not be desired to search in a certain table. +Therefore, the new event :php:`\TYPO3\CMS\Backend\Search\Event\BeforeSearchInDatabaseRecordProviderEvent` +has been introduced, which allows to exclude / ignore such tables by adding them +to a deny list. Additionally, the new PSR-14 event can be used to further +limit the search result on certain page IDs or to modify the search query +altogether. + +The event features the following methods: + +- :php:`getSearchPageIds()`: Returns the page ids to search in +- :php:`setSearchPageIds()`: Allows to define page ids to search in +- :php:`getSearchDemand()`: Returns the :php:`SearchDemand`, used by the live search +- :php:`setSearchDemand()`: Allows to set a custom :php:`SearchDemand` object +- :php:`ignoreTable()`: Allows to ignore / exclude a table from the lookup +- :php:`setIgnoredTables()`: Allows to overwrite the ignored tables +- :php:`isTableIgnored()`: Returns whether a specific table is ignored +- :php:`getIgnoredTables()`: Returns all tables to be ignored from the lookup + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + + MyVendor\MyPackage\EventListener\BeforeSearchInDatabaseRecordProviderEventListener: + tags: + - name: event.listener + identifier: 'my-package/before-search-in-database-record-provider-event-listener' + +The corresponding event listener class: + +.. code-block:: php + + use TYPO3\CMS\Backend\Search\Event\BeforeSearchInDatabaseRecordProviderEvent; + + final class ModifyEditFileFormDataEventListener + { + public function __invoke(BeforeSearchInDatabaseRecordProviderEvent $event): void + { + $event->ignoreTable('my_custom_table'); + } + } + +Impact +====== + +It is now possible to ignore specific tables from the backend search using +the new PSR-14 event :php:`BeforeSearchInDatabaseRecordProviderEvent`. The event +also allows to adjust the page IDs to search in as well as to modify the +corresponding :php:`SearchDemand` object. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99038-SubmoduleForFilemountsInBeUsermodule.rst b/Documentation/Changelog/12.1/Feature-99038-SubmoduleForFilemountsInBeUsermodule.rst new file mode 100644 index 0000000..7f4d7bf --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99038-SubmoduleForFilemountsInBeUsermodule.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-99038-1668093242: + +========================================= +Feature: #99038 - Overview for file mounts +========================================= + +See :issue:`99038` + +Description +=========== + +A new submodule was added to the :guilabel:`Backend Users` module. It provides an +overview over all available file mounts with detailed data. Additionally an info +window displays also the references to the file mount. + + +Impact +====== + +It is now possible to get an overview about available file mounts +without switching to the :guilabel:`List` module on pid=0. + +.. index:: Database, TCA, Backend diff --git a/Documentation/Changelog/12.1/Feature-99047-LoadSiteSettingsFromSeparateSettingsyaml.rst b/Documentation/Changelog/12.1/Feature-99047-LoadSiteSettingsFromSeparateSettingsyaml.rst new file mode 100644 index 0000000..bf0cd55 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99047-LoadSiteSettingsFromSeparateSettingsyaml.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-99047-1668081474: + +================================================================ +Feature: #99047 - Load site settings from separate settings.yaml +================================================================ + +See :issue:`99047` + +Description +=========== + +Site settings have been introduced with TYPO3 v10 as part of the configuration +of a site. In contrast to the site configuration, they are mostly used to +provide sane defaults for TypoScript constants and have a layer of arbitrary +configuration available in any context. + +In order to separate these settings from the system site configuration, make them +accessible and editable in the TYPO3 backend, and to distinguish between required +site configuration and optional settings, the "settings" part of the settings are +copied to a separate :file:`settings.yaml` file in the site configuration folder. + +A migration wizard is provided as upgrade wizard to migrate settings into the +new file. + +.. note:: + + Settings are not removed from the :file:`config.yaml` for now but will not + have any effect anymore as soon as a :file:`settings.yaml` exists. + + Please review your settings in the :file:`config.yaml` and remove them + manually. Eventually, you need and/or want to adopt your deployment + workflow. + +Impact +====== + +Settings are now loaded from a separate file called :file:`settings.yaml` residing +next to the :file:`config.yaml` of a site. +Executing the upgrade wizard will load all settings of a site and create that +file for the user. The migration wizard will not remove / rewrite the +:file:`config.yaml` - the user should do that on their own, to avoid breaking +custom-built functionality. + +.. index:: Backend, YAML, ext:core diff --git a/Documentation/Changelog/12.1/Feature-99048-SiteSettingsReadAPI.rst b/Documentation/Changelog/12.1/Feature-99048-SiteSettingsReadAPI.rst new file mode 100644 index 0000000..7eab79a --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99048-SiteSettingsReadAPI.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _feature-99048-1668081533: + +======================================== +Feature: #99048 - Site settings read API +======================================== + +See :issue:`99048` + +Description +=========== + +Settings for site-specific functionality can now be retrieved by a dedicated +:php:`\TYPO3\CMS\Core\Site\Entity\SiteSettings` object, accessible via a +:php:`\TYPO3\CMS\Core\Site\Entity\Site` object like :php:`$site->getSettings()`. + +Settings can be used in custom frontend code to deliver features which might +vary per-site for extensions. + + +Impact +====== + +Accessing site settings, which was previously possible via: + +:php:`$site->getConfiguration()['settings']['redirects'] ?? []` + +in custom PHP code, is now easier via the :php:`SiteSettings` PHP object. + +The :php:`SiteSettings` object can be used to access settings either by +the dot notation ("flat", a default value can be given as optional second +argument), e.g.: + +.. code-block:: php + + $redirectStatusCode = (int)$siteSettings->get('redirects.httpStatusCode', 307); + +or by accessing all options for a certain group: + +.. code-block:: php + + $allSettingsRelatedToRedirects = $siteSettings->get('redirects'); + +or even fetching all settings: + +.. code-block:: php + + $allSettings = $siteSettings->all(); + +In addition, settings can now be accessed in TypoScript via :typoscript:`getData` +with the key :typoscript:`siteSettings`: + +.. code-block:: typoscript + + page.10 = TEXT + page.10.data = siteSettings:redirects.httpStatusCode + +.. index:: PHP-API, TypoScript ext:core diff --git a/Documentation/Changelog/12.1/Feature-99053-RouteAspectFallbackValueHandling.rst b/Documentation/Changelog/12.1/Feature-99053-RouteAspectFallbackValueHandling.rst new file mode 100644 index 0000000..d0a5078 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99053-RouteAspectFallbackValueHandling.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _feature-99053-1668163567: + +====================================================== +Feature: #99053 - Route aspect fallback value handling +====================================================== + +See :issue:`99053` + +Description +=========== + +Imagine a route like `/news/{news_title}` that has been filled with an +"invalid" value for the `news_title` part. Often these are outdated, deleted +or hidden records. Usually TYPO3 reacts to these "invalid" URL sections at a +very early stage with an HTTP status code `404` (resource not found). + +The new property `fallbackValue = [string|null]` can prevent the above scenario +in several ways. By specifying an alternative value, a different record, +language or other detail can be represented. Specifying `null` removes the +corresponding parameter from the route result. In this way, it is up to the +developer to react accordingly. + +In the case of Extbase extensions, the developer can define the parameters in +their calling controller action as nullable and deliver corresponding +flash messages that explain the current scenario better than a 404 HTTP +status code. + +Examples +-------- + +.. code-block:: yaml + + routeEnhancers: + NewsPlugin: + type: Extbase + extension: News + plugin: Pi1 + routes: + - routePath: '/detail/{news_title}' + _controller: 'News::detail' + _arguments: + news_title: 'news' + aspects: + news_title: + type: PersistedAliasMapper + tableName: tx_news_domain_model_news + routeFieldName: path_segment + + # string values lead to parameter `&tx_news_pi1[news]=0` + fallbackValue: '0' + + # null values lead to parameter `&tx_news_pi1[news]` being removed + fallbackValue: null + +Custom mapper implementations can incorporate this behavior by implementing +the new :php:`\TYPO3\CMS\Core\Routing\Aspect\UnresolvedValueInterface` which +is provided by :php:`\TYPO3\CMS\Core\Routing\Aspect\UnresolvedValueTrait`. + +.. code-block:: php + + use TYPO3\CMS\Core\Routing\Aspect\MappableAspectInterface; + use TYPO3\CMS\Core\Routing\Aspect\UnresolvedValueInterface; + use TYPO3\CMS\Core\Routing\Aspect\UnresolvedValueTrait; + + class MyCustomEnhancer implements MappableAspectInterface, UnresolvedValueInterface + { + use UnresolvedValueTrait; + // ... + } + + +.. index:: Frontend, YAML, ext:core diff --git a/Documentation/Changelog/12.1/Feature-99055-BackendControllerServiceTagAttribute.rst b/Documentation/Changelog/12.1/Feature-99055-BackendControllerServiceTagAttribute.rst new file mode 100644 index 0000000..131b778 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99055-BackendControllerServiceTagAttribute.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _feature-99055-1668096727: + +========================================================= +Feature: #99055 - BackendController service tag attribute +========================================================= + +See :issue:`99055` + +Description +=========== + +A new PHP attribute :php:`TYPO3\CMS\Backend\Attribute\AsController` has +been added in order to register services to the BackendController dependency +injection container. + +.. note:: + + In early TYPO3 v12 versions the attribute was named :php:`#[Controller]` and + has later been renamed to :php:`#[AsController]`. Both work with TYPO3 v12, + but developers should use :php:`#[AsController]` for upwards compatibility + since :php:`#[Controller]` has been deprecated with TYPO3 v13. + +In addition to tag :yaml:`backend.controller` in the :file:`Services.yaml` file, +tagging services as backend controller can be done like: + +Example implementation +---------------------- + +.. code-block:: php + + use TYPO3\CMS\Backend\Attribute\AsController; + + #[AsController] + class MyBackendController { + + } + +Impact +====== + +It is now possible to tag services as backend controller by the PHP attribute +:php:`TYPO3\CMS\Backend\Attribute\AsController` instead of tagging them with +:yaml:`backend.controller` in the :file:`Services.yaml` file. + +.. index:: Backend diff --git a/Documentation/Changelog/12.1/Feature-99062-NativeJSONDatabaseFieldSupportInDoctrineDBAL.rst b/Documentation/Changelog/12.1/Feature-99062-NativeJSONDatabaseFieldSupportInDoctrineDBAL.rst new file mode 100644 index 0000000..169dfd1 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99062-NativeJSONDatabaseFieldSupportInDoctrineDBAL.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-99062-1668170141: + +===================================================================== +Feature: #99062 - Native JSON database field support in Doctrine DBAL +===================================================================== + +See :issue:`99062` + +Description +=========== + +TYPO3 Core's Database API based on Doctrine DBAL now supports the native +database field type `json`, which is already available for all supported DBMS +of TYPO3 v12. + +JSON-like objects or arrays are automatically serialized during writing a +dataset to the database, when the native JSON type was used in the database +schema definition. + + +Impact +====== + +By using the native database field declaration `json` in e.g. :file:`ext_tables.sql` +files within an extension, TYPO3 now converts arrays or objects of type +:php:`\JsonSerializable` into a serialized JSON value in the database when +persisting such values via :php:`Connection->insert()` or +:php:`Connection->update()`, if no explicit database types are handed in as additional +method argument. + +TYPO3 now utilizes the native type mapping of Doctrine to convert special types, +such as JSON database field types automatically for writing. + +Example :file:`ext_tables.sql`: + +.. code-block:: sql + + CREATE TABLE tx_myextension_domain_model_book ( + title varchar(200) DEFAULT '', + contents json + ); + +.. note:: + + However, when reading a record from the database via QueryBuilder, it is + still necessary to transfer the serialized value to an array or object, + performing a custom serialization for the time being. + + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.1/Feature-99084-MakeContextMenuTriggerConfigurable.rst b/Documentation/Changelog/12.1/Feature-99084-MakeContextMenuTriggerConfigurable.rst new file mode 100644 index 0000000..b93453a --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99084-MakeContextMenuTriggerConfigurable.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-99084-1667981931: + +======================================================== +Feature: #99084 - Make context menu trigger configurable +======================================================== + +See :issue:`99084` + +Description +=========== + +The context menu JavaScript API was adapted to also support opening +the menu through the "contextmenu" event type (right click) only. +Configuration for the context menu was streamlined and now reflects +its purpose. The trigger can now be set to "click" or "contextmenu". + +New options +----------- + +:html:`data-contextmenu-trigger`: + +- :html:`click`: Opens the context menu on "click" and on "contextmenu" +- :html:`contextmenu`: Opens the context menu only on "contextmenu" + +Examples +-------- + +.. code-block:: html + + <a href="#" + data-contextmenu-trigger="click" + data-contextmenu-table="pages" + data-contextmenu-uid="10" + >Click and Contextmenu</a> + +.. code-block:: html + + <a href="#" + data-contextmenu-trigger="contextmenu" + data-contextmenu-table="pages" + data-contextmenu-uid="10" + >Contextmenu only</a> + +Impact +====== + +It is now possible to bind the context menu only to the +event type "contextmenu". + + +.. index:: Backend, JavaScript diff --git a/Documentation/Changelog/12.1/Feature-99092-AllowStaticBackdropsInModals.rst b/Documentation/Changelog/12.1/Feature-99092-AllowStaticBackdropsInModals.rst new file mode 100644 index 0000000..0641d65 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99092-AllowStaticBackdropsInModals.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _feature-99092-1668509154: + +================================================== +Feature: #99092 - Allow static backdrops in modals +================================================== + +See :issue:`99092` + +Description +=========== + +The Modal API is now able to render a static backdrop to avoid closing the modal +when clicking it. This may be handy in case closing the modal would result in +a negative user experience, e.g. in the image cropper. + + +Impact +====== + +The new boolean configuration option :js:`staticBackdrop` controls whether a +static backdrop should be rendered or not; the default is :js:`false`. + +Example: + +.. code-block:: js + + import Modal from '@typo3/backend/modal'; + + Modal.advanced({ + title: 'Hello', + content: 'This modal is not closable via clicking the backdrop.', + size: Modal.sizes.small, + staticBackdrop: true + }); + +Templates using the HTML class :html:`.t3js-modal-trigger` to initialize +a modal dialog can also use the new option by adding the +:html:`data-static-backdrop` attribute to the corresponding element. + +Example: + +.. code-block:: html + + <button class="btn btn-default t3js-modal-trigger" + data-title="Hello" + data-bs-content="This modal is not closable via clicking the backdrop." + data-static-backdrop> + Open modal + </button> + +.. index:: Backend, JavaScript, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99093-IntroduceDropDownButtonComponent.rst b/Documentation/Changelog/12.1/Feature-99093-IntroduceDropDownButtonComponent.rst new file mode 100644 index 0000000..9ad33e5 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99093-IntroduceDropDownButtonComponent.rst @@ -0,0 +1,160 @@ +.. include:: /Includes.rst.txt + +.. _feature-99093-1668065501: + +==================================================== +Feature: #99093 - Introduce DropDownButton component +==================================================== + +See :issue:`99093` + +Description +=========== + +The module menu button bar now can display dropdowns. +This enables new interface interactions, like switching +the current view from list to tiles or group actions +like clipboard and thumbnail visibility. It make the views +clearer and the user to see more information at a glance. + +Each dropdown consists of different items ranging from +headlines to item links that can display the current +status. The button automatically changes the icon +representation to the icon of the first active radio +icon in the dropdown list. + + +DropDownButton +-------------- + +This button type is a container for dropdown items. +It will render a dropdown containing all items attached +to it. There are different kinds available, each item +needs to implement the +:php:`\TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownItemInterface`. +When this type contains elements of type +:php:`\TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownRadio` it +will use the icon of the first active item of this type. + +.. code-block:: php + + $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $dropDownButton = $buttonBar->makeDropDownButton() + ->setLabel('Dropdown') + ->setTitle('Save') + ->setIcon($this->iconFactory->getIcon('actions-heart')) + ->addItem( + GeneralUtility::makeInstance(DropDownItem::class) + ->setLabel('Item') + ->setHref('#') + ); + $buttonBar->addButton($dropDownButton, ButtonBar::BUTTON_POSITION_RIGHT, 2); + + +DropDown\DropDownDivider +------------------------ + +This dropdown item type renders the divider element. + +.. code-block:: php + + // use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownDivider; + + $item = GeneralUtility::makeInstance(DropDownDivider::class); + $dropDownButton->addItem($item); + + +DropDown\DropDownHeader +----------------------- + +This dropdown item type renders a non-interactive text +element to group items and gives more meaning to a set +of options. + +.. code-block:: php + + // use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownHeader; + + $item = GeneralUtility::makeInstance(DropDownHeader::class) + ->setLabel('Label'); + $dropDownButton->addItem($item); + + +DropDown\DropDownItem +--------------------- + +This dropdown item type renders a simple element. +Use this element if you need a link, button. + +.. code-block:: php + + // use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownItem; + + $item = GeneralUtility::makeInstance(DropDownItem::class) + ->setTag('a') + ->setHref('#') + ->setLabel('Label') + ->setTitle('Title') + ->setIcon($this->iconFactory->getIcon('actions-heart')) + ->setAttributes(['data-value' => '123']); + $dropDownButton->addItem($item); + + +DropDown\DropDownRadio +---------------------- + +This dropdown item type renders an element with an active state. +Use this element to display a radio-like selection of a state. +When set to active, it will show a dot in front of the icon and +text to indicate that this is the current selection. + +At least 2 of these items need to exist within a dropdown button, +so a user has a choice of a state to select. + +Example: Viewmode -> List / Tiles + +.. code-block:: php + + // use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownRadio; + + $item = GeneralUtility::makeInstance(DropDownRadio::class) + ->setHref('#') + ->setActive(true) + ->setLabel('List') + ->setTitle('List') + ->setIcon($this->iconFactory->getIcon('actions-viewmode-list')) + ->setAttributes(['data-type' => 'list']); + $dropDownButton->addItem($item); + + $item = GeneralUtility::makeInstance(DropDownRadio::class) + ->setHref('#') + ->setActive(false) + ->setLabel('Tiles') + ->setTitle('Tiles') + ->setIcon($this->iconFactory->getIcon('actions-viewmode-tiles')) + ->setAttributes(['data-type' => 'tiles']); + $dropDownButton->addItem($item); + + +DropDown\DropDownToggle +----------------------- + +This dropdown item type renders an element with an active state. +When set to active, it will show a checkmark in front of the icon +and text to indicate the current state. + +.. code-block:: php + + // use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownToggle; + + $item = GeneralUtility::makeInstance(DropDownToggle::class) + ->setHref('#') + ->setActive(true) + ->setLabel('Label') + ->setTitle('Title') + ->setIcon($this->iconFactory->getIcon('actions-heart')) + ->setAttributes(['data-value' => '123']); + $dropDownButton->addItem($item); + + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99118-PSR-14EventToDefineWhetherFilesAreSelectable.rst b/Documentation/Changelog/12.1/Feature-99118-PSR-14EventToDefineWhetherFilesAreSelectable.rst new file mode 100644 index 0000000..5c23cd5 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99118-PSR-14EventToDefineWhetherFilesAreSelectable.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _feature-99118: + +===================================================================== +Feature: #99118 - PSR-14 event to define whether files are selectable +===================================================================== + +See :issue:`99118` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\ElementBrowser\Event\IsFileSelectableEvent` +has been introduced. It allows to define whether a file can be selected in the +file browser. Previously, this was only possible by overriding the +:php:`\TYPO3\CMS\Backend\ElementBrowser\FileBrowser->fileIsSelectableInFileList()` +method via an XCLASS. + +The event features the following methods: + +- :php:`getFile()`: Returns the :php:`\TYPO3\CMS\Core\Resource\FileInterface` in question +- :php:`isFileSelectable()`: Whether the file is allowed to be selected +- :php:`allowFileSelection()`: Allow selection of the file in question +- :php:`denyFileSelection()`: Deny selection of the file in question + +.. note:: + + The :php:`fileIsSelectableInFileList()` method allowed to access the image + dimensions (`width` and `height`) via the second parameter :php:`$imgInfo`. + Those information however can be retrieved directly from the :php:`FileInterface` + in a more convenient way using the :php:`getProperty()` method. Therefore, + the new Event does not provide this parameter explicitly. + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/backend/modify-file-is-selectable' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Backend/MyEventListener.php + + namespace MyVendor\MyExtension\Backend; + + use TYPO3\CMS\Backend\ElementBrowser\Event\IsFileSelectableEvent; + + final class MyEventListener { + + public function __invoke(IsFileSelectableEvent $event): void + { + // Deny selection of "png" images + if ($event->getFile()->getExtension() === 'png') { + $event->denyFileSelection(); + } + } + } + +Impact +====== + +It is now possible to decide whether a file can be selected in the +file browser, using an improved PSR-14 approach instead +of cross classing. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99155-AddTileViewToFilelist.rst b/Documentation/Changelog/12.1/Feature-99155-AddTileViewToFilelist.rst new file mode 100644 index 0000000..ff2c706 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99155-AddTileViewToFilelist.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-99155-1669116236: + +=========================================== +Feature: #99155 - Add tile view to filelist +=========================================== + +See :issue:`99155` + +Description +=========== + +The listing of resources in a table has some specific use +cases, but it is hard for the editor to get an overview of file +resources since the thumbnails are small. + +To provide a better overview of assets in the :guilabel:`Filelist` module we +are introducing a tile view with bigger thumbnails and reduced +meta information. The user can now choose the desired display +mode of the assets depending on the current requirements. + +The user can change the view mode in the view menu in the +module menu bar. By default the tile view is enabled for +new and existing users. + +TYPO3 will remember the choice of the user. + + +Impact +====== + +The user can now change the display mode of resources +in the :guilabel:`Filelist` module. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/12.1/Feature-99169-AddBackendUserGroupFilter.rst b/Documentation/Changelog/12.1/Feature-99169-AddBackendUserGroupFilter.rst new file mode 100644 index 0000000..ac00395 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99169-AddBackendUserGroupFilter.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-99169-1669832578: + +=============================================== +Feature: #99169 - Add backend user group filter +=============================================== + +See :issue:`99169` + +Description +=========== + +The :guilabel:`Backend Users` module now offers the possibility to filter for +backend user groups. A new search field allows to filter by arbitrary string or +user group ID. + + +Impact +====== + +Administrators in the backend are now able to filter the backend user groups in +the :guilabel:`Backend Users` module. + +.. index:: Backend, ext:beuser diff --git a/Documentation/Changelog/12.1/Feature-99194-SupportForVariousStringComparisonsForStdWrapifTypoScriptFunction.rst b/Documentation/Changelog/12.1/Feature-99194-SupportForVariousStringComparisonsForStdWrapifTypoScriptFunction.rst new file mode 100644 index 0000000..b03651b --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99194-SupportForVariousStringComparisonsForStdWrapifTypoScriptFunction.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _feature-99194-1669413174: + +=========================================================================================== +Feature: #99194 - Support for various string comparisons for stdWrap.if TypoScript function +=========================================================================================== + +See :issue:`99194` + +Description +=========== + +The TypoScript function :typoscript:`if.` now supports several new sub-properties +for comparing a value (provided via :typoscript:`if.value = ...`), +if it contains a certain part of a string, or starts with a certain +part, or ends with a certain part. All of these properties also work with +the :typoscript:`if.negate` flag. + +The new TypoScript properties for `if.` are called: + +* :typoscript:`if.contains` +* :typoscript:`if.startsWith` +* :typoscript:`if.endsWith` + +All of the mentioned properties can be assigned a static value, and support +:typoscript:`stdWrap` as their sub-properties. + + +Impact +====== + +As :typoscript:`if.` is available in most content objects, :typoscript:`stdWrap` or +data processors, it can now be used more exhaustive. + +Example for :typoscript:`ìf.contains`: + +.. code-block:: typoscript + + # Add a span tag before the page title if the page title + # contains the string "media" + page.10 = TEXT + page.10.data = page:title + page.10.htmlSpecialChars = 1 + page.10.prepend = TEXT + page.10.prepend.value = <span class="icon-video"></span> + page.10.prepend.if.value.data = page:title + page.10.prepend.if.contains = Media + page.10.outerWrap = <h1>|</h1> + +Example for :typoscript:`ìf.endsWith`: + +.. code-block:: typoscript + + # Add a footer note, if the page author ends with "Kott" + page.100 = TEXT + page.100.value = This is an article from Benji + page.100.htmlSpecialChars = 1 + page.100.if.value.data = page:author + page.100.if.endsWith = Kott + page.100.wrap = <footer>|</footer> + +Example for :typoscript:`ìf.startsWith`: + +.. code-block:: typoscript + + page.10 = TEXT + page.10.value = Your editor added the magic word in the header field + page.10.htmlSpecialChars = 1 + page.10.if.value.data = DB:tt_content:1234:header + page.10.if.startsWith = Bazinga + +.. index:: TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.1/Feature-99212-GroupSelectItemInFormEngineViaTSconfig.rst b/Documentation/Changelog/12.1/Feature-99212-GroupSelectItemInFormEngineViaTSconfig.rst new file mode 100644 index 0000000..69480d2 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99212-GroupSelectItemInFormEngineViaTSconfig.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-99212-1669896293: + +============================================================== +Feature: #99212 - Group select item in FormEngine via TSconfig +============================================================== + +See :issue:`99212` + +Description +=========== + +The existing TSconfig feature :typoscript:`TCEFORM.{tablename}.{fieldname}.addItems` +can now be used to add new items into existing select item groups by using the +:typoscript:`.group` sub-property set to the group identifier. This grouping is +usually shown in select fields with groups available. + + +Impact +====== + +When using the TSconfig :typoscript:`addItems` feature, the :typoscript:`group` +property can now be used: + +Example: + +.. code-block:: typoscript + + TCEFORM.tt_content.layout.addItems { + new-layout = My new layout + new-layout.icon = icon-identifier + new-layout.group = special + } + +.. index:: TSConfig, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99221-AddCliInstallSetupCommand.rst b/Documentation/Changelog/12.1/Feature-99221-AddCliInstallSetupCommand.rst new file mode 100644 index 0000000..a9368a6 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99221-AddCliInstallSetupCommand.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-97747-1669740094: + +============================================= +Feature: #99221 - Introduce CLI setup command +============================================= + +See :issue:`99221` + +Description +=========== + +To be able to automate the setup process for new TYPO3 installations, +a new CLI command `setup` is introduced as an alternative to the existing +GUI based web installer. + +Impact +====== + +You can now use `./bin/typo3 setup` to set up your TYPO3 installation without +needing to run through the web installer. + +Example +------- + +Interactive / guided setup (questions/answers): + +.. code-block:: bash + + ./bin/typo3 setup + +Automated setup: + +.. code-block:: bash + + TYPO3_DB_DRIVER=mysqli \ + TYPO3_DB_USERNAME=db \ + TYPO3_DB_PORT=3306 \ + TYPO3_DB_HOST=db \ + TYPO3_DB_DBNAME=db \ + TYPO3_SETUP_ADMIN_EMAIL=admin@example.com \ + TYPO3_SETUP_ADMIN_USERNAME=admin \ + TYPO3_SETUP_CREATE_SITE="https://your-typo3-site.com/" \ + TYPO3_PROJECT_NAME="Automated Setup" \ + TYPO3_SERVER_TYPE="apache" \ + ./bin/typo3 setup --force + +.. warning:: + Variable `TYPO3_DB_PASSWORD` (option `--password`) can be used to provide a + password for the database and `TYPO3_SETUP_ADMIN_PASSWORD` + (option `--admin-user-password`) for the admin user 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`. + +.. index:: ext:install diff --git a/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst b/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst new file mode 100644 index 0000000..03f4622 --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99226-IntroduceDbTypeJsonForTCATypeUser.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _feature-99226-1669801019: + +========================================================= +Feature: #99226 - Introduce dbType json for TCA type user +========================================================= + +See :issue:`99226` + + +.. attention:: + + This TCA option is **no longer available**! It has been + :ref:`replaced <important-100088-1677950866>` by the dedicated + :ref:`json <feature-100088-1677965005>` TCA type. Do not use + this option in your installation, but use the new TCA type. + +Description +=========== + +To allow storage and usage of JSON data in TCA type `user` without needing to +decode the JSON in each user implementation manually, a dbType is introduced +for TCA type `user`. + + +Impact +====== + +When creating TCA type `user` fields with a database JSON field, the +dbType `json` can now be set. After setting the dbType, the form engine will +automatically provide the decoded JSON to the RecordProviders and the `user` +PHP implementation can then use the field value. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99234-DynamicURLPartsInTYPO3BackendURLs.rst b/Documentation/Changelog/12.1/Feature-99234-DynamicURLPartsInTYPO3BackendURLs.rst new file mode 100644 index 0000000..8d8e70e --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99234-DynamicURLPartsInTYPO3BackendURLs.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-99234-1669840449: + +========================================================= +Feature: #99234 - Dynamic URL parts in TYPO3 backend URLs +========================================================= + +See :issue:`99234` + +Description +=========== + +TYPO3's backend URL routing now uses Symfony's routing component for resolving +and generating URLs. + +This way, it is possible for extension authors to register backend routes with +path segments that contain dynamic parts, which are then resolved into a request +attribute called "routing". + +These routes are defined within the route path as named placeholders. + + +Impact +====== + +It is possible to define routes with placeholders in an extension's :file:`Routes.php`: + +.. code-block:: php + + return [ + 'my_route' => [ + 'path' => '/rollback-item/{identifier}', + 'target' => \MyVendor\MyPackage\Controller\RollbackController::class . '::handle', + ], + ]; + + +Within the controller: + +.. code-block:: php + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $routing = $request->getAttribute('routing'); + $myIdentifier = $routing['identifier']; + $route = $routing->getRoute(); + // ... + } + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.1/Feature-99245-RegisteredReactionsInConfigurationModule.rst b/Documentation/Changelog/12.1/Feature-99245-RegisteredReactionsInConfigurationModule.rst new file mode 100644 index 0000000..a4be4df --- /dev/null +++ b/Documentation/Changelog/12.1/Feature-99245-RegisteredReactionsInConfigurationModule.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-99245-1669974318: + +============================================================== +Feature: #99245 - Registered reactions in configuration module +============================================================== + +See :issue:`99245` + +Description +=========== + +With :issue:`98373`, the new reactions component has been introduced to TYPO3 +Core. Since reactions allow to hook into the system, it's important for site +administrators to have an overview of registered reactions. + +Therefore, the configuration module does now list all registered reactions +with their type identifier and corresponding configuration. + +Impact +====== + +It's now possible for site administrators to get an overview of all registered +reactions in the configuration module. + +.. index:: Backend, ext:reactions diff --git a/Documentation/Changelog/12.1/Important-88158-ReplacedMomentJsWithLuxon.rst b/Documentation/Changelog/12.1/Important-88158-ReplacedMomentJsWithLuxon.rst new file mode 100644 index 0000000..5f1537a --- /dev/null +++ b/Documentation/Changelog/12.1/Important-88158-ReplacedMomentJsWithLuxon.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + +.. _important-88158-1668433741: + +================================================= +Important: #88158 - Replaced moment.js with luxon +================================================= + +See :issue:`88158` + +Description +=========== + +The JavaScript library `luxon` is added to TYPO3 as a replacement for `moment.js` +that is `declared legacy`_. All code shipped by TYPO3 is migrated to `luxon`. + +Albeit shipped `moment.js` is not considered being public API, it is worth +mentioning that said library is removed with TYPO3 v12.1. + +.. _declared legacy: https://momentjs.com/docs/#/-project-status/ + +.. index:: Backend, JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.1/Important-98502-CorrectFallbackToDefaultErrorHandler.rst b/Documentation/Changelog/12.1/Important-98502-CorrectFallbackToDefaultErrorHandler.rst new file mode 100644 index 0000000..2dc2893 --- /dev/null +++ b/Documentation/Changelog/12.1/Important-98502-CorrectFallbackToDefaultErrorHandler.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _important-98502-1664738430: + +============================================================= +Important: #98502 - Correct fallback to default error handler +============================================================= + +See :issue:`98502` + +Description +=========== + +The site configuration allows to define the HTTP error status code to be +handled, e.g. by showing the content of a given page. + +The backend module states "Make sure to have at least 0 (not defined otherwise) +configured in order to serve helpful error messages to your visitors." but the +fallback to "0" has not been implemented yet until now. + +If no error handling for the given HTTP error status code is configured, but +one for "any error not defined otherwise", the latter is used as fallback. This +reduces the configuration effort if only one error handling configuration is +used for all kind of error codes. + +.. index:: Frontend, ext:core diff --git a/Documentation/Changelog/12.1/Important-99044-EnsureAuto-createdRedirectAreStoredOnConnectedSiteRoot.rst b/Documentation/Changelog/12.1/Important-99044-EnsureAuto-createdRedirectAreStoredOnConnectedSiteRoot.rst new file mode 100644 index 0000000..b9b987b --- /dev/null +++ b/Documentation/Changelog/12.1/Important-99044-EnsureAuto-createdRedirectAreStoredOnConnectedSiteRoot.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _important-99044-1668077928: + +================================================================================== +Important: #99044 - Ensure auto-created redirect are stored on connected site root +================================================================================== + +See :issue:`99044` + +Description +=========== + +Long time ago, automatically created redirects were created on the top root page +:php:`pid=0`, which has been changed meanwhile to create them +using the page ID of the changed page as :sql:`pid` with :issue:`91776`. + +This led to some issues, like permissions during copying and pasting pages. + +Automatically created redirects are now stored using the root page ID of the +site configurations as :sql:`pid` to minimize side-effect issues and prepare +follow-up features. + +.. index:: ext:redirects diff --git a/Documentation/Changelog/12.1/Index.rst b/Documentation/Changelog/12.1/Index.rst new file mode 100644 index 0000000..c4ec6f3 --- /dev/null +++ b/Documentation/Changelog/12.1/Index.rst @@ -0,0 +1,54 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-1: + +============= +12.1 Changes +============= + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v12.0 release. + +.. attention:: + + After TYPO3 v12.0, only new functionality with a solid migration path + can be added on top, with aiming for as little as possible breaking changes + after the initial v12.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.2/Deprecation-97923-DeprecateUserFileMountService.rst b/Documentation/Changelog/12.2/Deprecation-97923-DeprecateUserFileMountService.rst new file mode 100644 index 0000000..40f99e3 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-97923-DeprecateUserFileMountService.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97923-1673529717: + +==================================================== +Deprecation: #97923 - Deprecate UserFileMountService +==================================================== + +See :issue:`97923` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Resource\Service\UserFileMountService` is not +used anymore within the TYPO3 Core and has been marked deprecated. The class +will finally be removed in TYPO3 v13. + +Impact +====== + +Using the class will raise a deprecation level log entry and will stop +working with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that use the class are affected. + +The extension scanner reports affected extensions. + + +Migration +========= + +Instead of using the class in TCA for an :php:`itemsProcFunc`, the TCA +type `folder` should be used, to improve the usability of selecting a folder. + +.. code-block:: php + + 'identifier' => [ + 'label' => 'Folder selection', + 'config' => [ + 'type' => 'folder', + 'elementBrowserEntryPoints' => [ + '_default' => '1:/user_upload/' + ] + ] + ], + +.. index:: Backend, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99120-DeprecateOldTypoScriptParser.rst b/Documentation/Changelog/12.2/Deprecation-99120-DeprecateOldTypoScriptParser.rst new file mode 100644 index 0000000..5ef44e7 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99120-DeprecateOldTypoScriptParser.rst @@ -0,0 +1,162 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99120-1670428555: + +==================================================== +Deprecation: #99120 - Deprecate old TypoScriptParser +==================================================== + +See :issue:`99120` + +Description +=========== + +To phase out usages of the old TypoScript parser by switching to the +:ref:`new parser approach <breaking-97816-1664800747>`, a couple of classes +and methods have been marked deprecated in TYPO3 v12 that will be +removed in TYPO3 v13: + +* Class :php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser` +* Class :php:`\TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` +* Class :php:`\TYPO3\CMS\Core\Configuration\PageTsConfig` +* Class :php:`\TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` +* Event :php:`\TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` +* Method :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getPagesTSconfig()` + +The existing main API to retrieve page TSconfig using +:php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfig()` +and user TSconfig using :php:`$backendUser->getTSConfig()` is kept. + + +Impact +====== + +Using one of the above classes will raise a deprecation level log entry and +will stop working with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that use one of the above classes are affected. The +extension scanner will find usages with a mixture of weak and strong matches +depending on the usage. + +Most deprecations are rather "internal" since extensions most likely use the +existing outer API already. Some may be affected when using the +:php:`\TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` event +or the frontend related method :php:`TypoScriptFrontendController->getPagesTSconfig()`, +though. + + +Migration +========= + +:php:`\TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` +------------------------------------------------------------------------ + +This event is consumed by some extensions to modify calculated page TSconfig strings +before parsing. The old event :php:`\TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` +has been marked as deprecated and will be removed with TYPO3 v13, the new event +:php:`\TYPO3\CMS\Core\TypoScript\IncludeTree\Event\ModifyLoadedPageTsConfigEvent` has been +created with same signature. The TYPO3 v12 Core triggers *both* the old +and the new event, and TYPO3 v13 will stop calling the old event. + +Extension that want to stay compatible with both TYPO3 v11 and v12 and prepare v13 +compatibility as much as possible should start listening for the new event as well, +and suppress handling of the old event in TYPO3 v12 to not handle things twice. + +Example from b13/bolt extension: + +Register for both events in Services.yaml: + +.. code-block:: yaml + + B13\Bolt\TsConfig\Loader: + public: true + tags: + # Remove when TYPO3 v11 compat is dropped + - name: event.listener + identifier: 'add-site-configuration-v11' + event: TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent + method: 'addSiteConfigurationCore11' + # TYPO3 v12 and above + - name: event.listener + identifier: 'add-site-configuration' + event: TYPO3\CMS\Core\TypoScript\IncludeTree\Event\ModifyLoadedPageTsConfigEvent + method: 'addSiteConfiguration' + +Handle old event in TYPO3 v11, but skip old event with TYPO3 v12: + +.. code-block:: php + + use TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent as LegacyModifyLoadedPageTsConfigEvent; + use TYPO3\CMS\Core\TypoScript\IncludeTree\Event\ModifyLoadedPageTsConfigEvent; + + class Loader + { + public function addSiteConfigurationCore11(LegacyModifyLoadedPageTsConfigEvent $event): void + { + if (class_exists(ModifyLoadedPageTsConfigEvent::class)) { + // TYPO3 v12 calls both old and new event. Check for class existence of new event to + // skip handling of old event in v12, but continue to work with < v12. + // Simplify this construct when v11 compat is dropped, clean up Services.yaml. + return; + } + $this->findAndAddConfiguration($event); + } + + public function addSiteConfiguration(ModifyLoadedPageTsConfigEvent $event): void + { + $this->findAndAddConfiguration($event); + } + + protected function findAndAddConfiguration($event): void + { + // Business code + } + } + + +:php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getPagesTSconfig()` +-------------------------------------------------------------------------------------- + +The TYPO3 frontend should usually not need to retrieve backend related page TSconfig. +Extensions using the method should avoid relying on bringing backend related configuration +into frontend scope. However, the TYPO3 Core comes with one place that does this: Class +:php:`\TYPO3\CMS\Frontend\Typolink\DatabaseRecordLinkBuilder` uses page TSconfig related +information in frontend scope. Extensions with similar use cases could have a similar +implementation as done with :php:`DatabaseRecordLinkBuilder->getPageTsConfig()`. Note that +any implementation of this will have to rely on :php:`@internal` usages of the new +TypoScript parser approach, and using this low level API may thus break without +further notice. Extensions are encouraged to cover usages with functional tests to find +issues quickly in case the TYPO3 Core still changes used classes. + +:php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser` +--------------------------------------------------------- + +In general, extensions probably don't need to use the old :php:`TypoScriptParser` +often: Frontend TypoScript is :ref:`available as request attribute <deprecation-99020-1667911024>`, +page TSconfig should be retrieved using :php:`BackendUtility::getPagesTSconfig()` and +user TSconfig should be retrieved using :php:`$backendUser->getTSConfig()`. + +In case extensions want to parse any other strings that follow a TypoScript-a-like syntax, +they can use :php:`\TYPO3\CMS\Core\TypoScript\TypoScriptStringFactory`, or could set up +their own factory using the new parser classes for more complex scenarios. Note that the new parser +approach is still marked :php:`@internal`, using this low level API may thus break without +further notice. Extensions are encouraged to cover usages with functional tests to find +issues quickly in case the TYPO3 Core still changes used classes. + +:php:`\TYPO3\CMS\Core\Configuration\PageTsConfig` +------------------------------------------------- + +There is little need to use :php:`\TYPO3\CMS\Core\Configuration\PageTsConfig` and their helper +classes :php:`\TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` and +:php:`\TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` directly: The main API +in backend context is :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getPagesTSconfig()`. + +See the hint on :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getPagesTSconfig()` +above for notes on how to migrate usages in frontend context. + + +.. index:: PHP-API, TSConfig, TypoScript, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99416-VariousDoctypeRelatedPropertiesAndMethods.rst b/Documentation/Changelog/12.2/Deprecation-99416-VariousDoctypeRelatedPropertiesAndMethods.rst new file mode 100644 index 0000000..620555d --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99416-VariousDoctypeRelatedPropertiesAndMethods.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99416-1671746489: + +==================================================================== +Deprecation: #99416 - Various doctype related properties and methods +==================================================================== + +See :issue:`99416` + +Description +=========== + +Due to the introduction of a unified definition of the DocType that should render +HTML, XML or XHTML-compliant content either in TYPO3 frontend rendering or +backend rendering, various methods and properties have been marked as +deprecated, as they are superfluous now: + +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->setRenderXhtml()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->getRenderXhtml()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->setMetaCharsetTag()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->getMetaCharsetTag()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->setCharSet()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer->getCharSet()` +* :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->xhtmlDoctype` +* :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->xhtmlVersion` + + +Impact +====== + +Calling one of the methods or accessing / writing one of the properties mentioned +will trigger a PHP deprecation message. + + +Affected installations +====================== + +TYPO3 installations with custom extensions reading or writing these properties or +methods directly in PHP, which is unlikely. + + +Migration +========= + +Use :php:`PageRenderer->setDocType()` to manipulate the output in +a programmatic way, or use :php:`PageRenderer->getDocType()` to read the +current doctype — for example "is the current page HTML5 compliant". + +Various TypoScript properties will instruct the :php:`PageRenderer` as before, +there is no need to use other configuration options. However, it is recommended to use +:typoscript:`config.doctype` in favor of :typoscript:`config.xhtmlDoctype` in +TypoScript as it considers more possible options. + +.. index:: Frontend, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.2/Deprecation-99454-RestoreVisibilityForSoftHyphensAndNonBreakingSpaces.rst b/Documentation/Changelog/12.2/Deprecation-99454-RestoreVisibilityForSoftHyphensAndNonBreakingSpaces.rst new file mode 100644 index 0000000..78340e0 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99454-RestoreVisibilityForSoftHyphensAndNonBreakingSpaces.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99454-1672842347: + +================================================================================= +Deprecation: #99454 - Restore visibility for soft hyphens and non-breaking spaces +================================================================================= + +See :issue:`99454` + +Description +=========== + +Non-breaking spaces and soft hyphens are now visible in +the rich-text editor to help the editor to identify them visually. + +Keyboard shortcuts are now working for non-breaking spaces +and soft hyphens and use more common defaults: + +* :kbd:`ctrl` + :kbd:`shift` + :kbd:`space` for non-breaking space +* :kbd:`ctrl` + :kbd:`shift` + :kbd:`dash` for soft hyphen + +The :js:`SoftHyphen` plugin for the CKEditor is now deprecated and +replaced with a new whitespace plugin that handles +non-breaking spaces and soft hyphens. Loading the +:js:`SoftHyphen` will trigger a console warning. + + +Impact +====== + +Including the :js:`SoftHyphen` plugin will trigger a deprecation warning. + + +Affected installations +====================== + +All installations that include the :js:`SoftHyphen` plugin manually. + + +Migration +========= + +Replace the module to resolve the deprecation. + +Before +------ + +.. code-block:: yaml + + editor: + config: + importModules: + - '@typo3/rte-ckeditor/plugin/soft-hyphen.js' + +After +----- + +.. code-block:: yaml + + editor: + config: + importModules: + - { module: '@typo3/rte-ckeditor/plugin/whitespace.js', exports: ['Whitespace'] } + + +.. index:: Backend, JavaScript, RTE, NotScanned, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.2/Deprecation-99519-DeprecatedBackendUtilitygetFuncMenu.rst b/Documentation/Changelog/12.2/Deprecation-99519-DeprecatedBackendUtilitygetFuncMenu.rst new file mode 100644 index 0000000..f232d6a --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99519-DeprecatedBackendUtilitygetFuncMenu.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99519-1673444609: + +============================================================== +Deprecation: #99519 - Deprecated BackendUtility::getFuncMenu() +============================================================== + +See :issue:`99519` + +Description +=========== + +Method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getFuncMenu()` has +been marked deprecated and should no longer be used. + + +Impact +====== + +Calling the method will raise a deprecation level log error and will +stop working with TYPO3 v13. + + +Affected installations +====================== + +The method may be used in extensions that add backend modules. The +extension scanner finds usages with a strong match. + + +Migration +========= + +:php:`BackendUtility::getFuncMenu()` is a helper method that renders a +select drop down and is typically used to trigger a `GET` request when +the user selects an option. + +In general, such HTML should not be generated by PHP, but by Fluid templating. +The method is thus typically used by old school backend modules that have not +or only partially been transferred to Fluid, improving the controller-view +separation. + +The most simple and ugly migration is to copy the method to an own controller +that consumes the method. The better solution is to add the arguments as variables +to the Fluid template and render the dropdown in Fluid. This should be a pretty +straight transition as well and typically avoids another :html:`<f:format.raw>` +ViewHelper usage. + +Note that requests send by these dropdowns should switch from GET to POST in +case they change server state (for example, changing records) along the way: State changing +requests should be restricted to `POST` as an additional security measure, and to +not violate the HTTP protocol. The TYPO3 Core comes with more and more examples +on how to handle this properly. For instance all dropdowns and checkbox toggles +of the `tstemplate` extension (:guilabel:`Site Management > TypoScript` backend module) +have been changed to do this and can be studied on how to trigger immediate +`POST` actions when clicking such elements. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99523-DeprecateTypenonePass_content.rst b/Documentation/Changelog/12.2/Deprecation-99523-DeprecateTypenonePass_content.rst new file mode 100644 index 0000000..c3e156e --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99523-DeprecateTypenonePass_content.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99523-1673454068: + +======================================================== +Deprecation: #99523 - Deprecate type="none" pass_content +======================================================== + +See :issue:`99523` + +Description +=========== + +The TCA option :php:`pass_content` for :php:`type="none"` fields has been +marked deprecated in TYPO3 v12 and will be removed with v13. + +Impact +====== + +Using the option should be avoided and has no impact anymore. + +Instances with field configurations, section `config` of :php:`type="none"` +having key :php:`pass_content` will trigger a deprecation warning during +TCA cache warmup. + + +Affected installations +====================== + +The :php:`type="none"` TCA field is a rarely used type, its main purpose is +to allow virtual fields (a field without corresponding database column). + +Instances are affected when the backend "lowlevel" search +in :php:`$GLOBALS['TCA']` for "pass_content" reveals matches. + +Migration +========= + +The :php:`pass_content=true` option was documented to not :php:`htmlspecialchars()` +the value. This is an edge case anyways, since the :php:`type="none"` is designed +to not have a database field at all, so there is usually no value. Additionally, +the current behavior still applies :php:`htmlspecialchars()` to the value. This has +not been fixed in TYPO3 v11 and v12 since it may open a security issue with existing +instances. + +Instances that need non-HTML escaped output with :php:`type="none"` should register an +own :php:`renderType` element for the field, as documented in +the :ref:`TYPO3 explained FormEngine chapter<t3coreapi:FormEngine-Rendering-NodeFactory>`. + +.. index:: Backend, TCA, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99531-Backwards-compatibleLanguageKeyMapping.rst b/Documentation/Changelog/12.2/Deprecation-99531-Backwards-compatibleLanguageKeyMapping.rst new file mode 100644 index 0000000..9beace5 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99531-Backwards-compatibleLanguageKeyMapping.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99531-1673606839: + +=============================================================== +Deprecation: #99531 - Backwards-compatible language key mapping +=============================================================== + +See :issue:`99531` + +Description +=========== + +Before TYPO3 v4.0, TYPO3 had inconsistencies in its language keys, such as "ja" (= Japan) +instead of "jp" (= Japanese), which was still applicable. However, the old language keys +have not been in use for TYPO3's built-in translation servers. + +In recent years, it was not even possible to use these keys for custom label files anymore. + +However, the mapping was still used to detect the language of the user agent, +primarily for the backend login screen when no language was detected. + +For this reason, the method :php:`Locales->getIsoMapping()` has been deprecated. + + +Impact +====== + +Calling the method above will trigger a PHP deprecation warning. + + +Affected installations +====================== + +TYPO3 installations which have been maintained for more than 15 years, still +using this method, or still using this legacy language keys. + + +Migration +========= + +Migrate to the official language keys / locales by renaming the language files. +It is highly unlikely that the outdated language keys worked in the past major +versions of TYPO3. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99558-DeprecatePageRepository-getExtURL.rst b/Documentation/Changelog/12.2/Deprecation-99558-DeprecatePageRepository-getExtURL.rst new file mode 100644 index 0000000..6e99127 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99558-DeprecatePageRepository-getExtURL.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99558-1673887807: + +=========================================================== +Deprecation: #99558 - Deprecate PageRepository->getExtURL() +=========================================================== + +See :issue:`99558` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->getExtURL()` +has been marked as deprecated and should not be used any longer. + + +Impact +====== + +Calling the method triggers a deprecation level log message since +TYPO3 v12 and will stop working in v13. + + +Affected installations +====================== + +:php:`PageRepository->getExtURL()` is a detail method and relatively unlikely +to be used by extensions. The extension scanner will find affected code places. + + +Migration +========= + +The method has been discontinued and there is no direct migration. + +If needed, the most simple solution is to copy the method to an extensions +code base and maintain it within the extension. + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99564-DeprecatedBackendUtilityGetDropdownMenu.rst b/Documentation/Changelog/12.2/Deprecation-99564-DeprecatedBackendUtilityGetDropdownMenu.rst new file mode 100644 index 0000000..b00984e --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99564-DeprecatedBackendUtilityGetDropdownMenu.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99564-1673958788: + +================================================================== +Deprecation: #99564 - Deprecated BackendUtility::getDropdownMenu() +================================================================== + +See :issue:`99564` + +Description +=========== + +Method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getDropdownMenu()` has +been marked deprecated and should no longer be used. + + +Impact +====== + +Calling the method will raise a deprecation level log error and will +stop working with TYPO3 v13. + + +Affected installations +====================== + +The method may be used in extensions that add backend modules. The +extension scanner finds usages with a strong match. + +Migration +========= + +:php:`BackendUtility::getDropdownMenu()` is a helper method that renders a +select dropdown and is typically used to trigger a `GET` request when +the user selects an option. + +In general, such HTML should not be generated by PHP, but by Fluid templating. +The method is thus typically used by old school backend modules that have not +or only partially been transferred to Fluid, improving the controller-view +separation. + +The most simple and ugly migration is to copy the method to an own controller +that consumes the method. The better solution is to add the arguments as variables +to the Fluid template and render the dropdown in Fluid. This should be a pretty +straight transition as well and typically avoids another :html:`<f:format.raw>` +ViewHelper usage. + +Note that requests send by these dropdowns should switch from `GET` to `POST` in +case they change the server state (e. g. changing records) along the way: State changing +requests should be restricted to POST as an additional security measure, and to +not violate the HTTP protocol. The TYPO3 Core comes with more and more examples +on how to handle this properly. For instance, all drop downs and checkbox toggles +of the `tstemplate` extension (:guilabel:`Site Management > TypoScript` backend module) +have been changed to do this and can be studied on how to trigger immediate +`POST` actions when clicking such elements. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99579-BackendUtilityGetFuncCheck.rst b/Documentation/Changelog/12.2/Deprecation-99579-BackendUtilityGetFuncCheck.rst new file mode 100644 index 0000000..1d61396 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99579-BackendUtilityGetFuncCheck.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99579-1673983578: + +==================================================== +Deprecation: #99579 - BackendUtility::getFuncCheck() +==================================================== + +See :issue:`99579` + +Description +=========== + +The method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getFuncCheck()` has +been marked as deprecated and should not be used any longer. + + +Impact +====== + +Calling the method will raise a deprecation level log error and will +stop working with TYPO3 v13. + + +Affected installations +====================== + +The method may be used in extensions that add backend modules. The +extension scanner finds usages with a strong match. + + +Migration +========= + +:php:`BackendUtility::getFuncCheck()` is a helper method that renders a +select dropdown and is typically used to trigger a `GET` request when +the user selects an option. + +In general, such HTML should not be generated by PHP, but by Fluid templating. +The method is thus typically used by old school backend modules that have not +or only partially been transferred to Fluid, improving the controller-view +separation. + +The most simple and ugly migration is to copy the method to an own controller +that consumes the method. The better solution is to add the arguments as variables +to the Fluid template and render the dropdown in Fluid. This should be a pretty +straight transition as well and typically avoids another :html:`<f:format.raw>` +ViewHelper usage. + +Note that requests send by these drop-downs should switch from `GET` to `POST` in +case they change the server state (e. g. changing records) along the way: State changing +requests should be restricted to POST as an additional security measure, and to +not violate the HTTP protocol. The TYPO3 Core comes with more and more examples +on how to handle this properly. For instance, all dropdowns and checkbox toggles +of the `tstemplate` extension (:guilabel:`Site Management > TypoScript` backend module) +have been changed to do this and can be studied on how to trigger immediate +`POST` actions when clicking such elements. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99586-RegistrationOfUpgradeWizardsViaGLOBALS.rst b/Documentation/Changelog/12.2/Deprecation-99586-RegistrationOfUpgradeWizardsViaGLOBALS.rst new file mode 100644 index 0000000..54661be --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99586-RegistrationOfUpgradeWizardsViaGLOBALS.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99586-1673990657: + +================================================================== +Deprecation: #99586 - Registration of upgrade wizards via $GLOBALS +================================================================== + +See :issue:`99586` + +Description +=========== + +Registration of upgrade wizards via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']`, +usually placed in an extension's :file:`ext_localconf.php` has been deprecated +in favor of the :ref:`new service tag <feature-99586-1673989775>`. + +Additionally, the :php:`\TYPO3\CMS\Install\Updates\UpgradeWizardInterface`, which all upgrade wizards must +implement, does no longer require the :php:`getIdentifier()` method. TYPO3 does +not use this method anymore since an upgrade wizard's identifier is now +defined using the new service tag. + + +Impact +====== + +Upgrade wizards, registered via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']` +will no longer be recognized in TYPO3 v13. + +The definition of the :php:`getIdentifier()` method has no effect anymore. + + +Affected installations +====================== + +All installations registering custom upgrade wizards using +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']`. + +All installations implementing the :php:`getIdentifier()` method in their +upgrade wizards. + + +Migration +========= + +Use the new service tag to register custom upgrade wizards and remove the +registration via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']`. + +Before +~~~~~~ + +.. code-block:: php + :caption: EXT:my_extension/ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']['myUpgradeWizard'] + = \MyVendor\MyExtension\Updates\MyUpgradeWizard::class; + +After +~~~~~ + +.. code-block:: php + :caption: EXT:my_extension/Classes/Updates/MyUpgradeWizard.php + + namespace MyVendor\MyExtension\Updates; + + use TYPO3\CMS\Install\Attribute\UpgradeWizard; + use TYPO3\CMS\Install\Updates\UpgradeWizardInterface; + + #[UpgradeWizard('myUpgradeWizard')] + class MyUpgradeWizard implements UpgradeWizardInterface + { + + } + +Drop any :php:`getIdentifier()` method in custom upgrade wizards. + +.. index:: Backend, PHP-API, FullyScanned, ext:install diff --git a/Documentation/Changelog/12.2/Deprecation-99588-PublicPropertiesInPageRepository.rst b/Documentation/Changelog/12.2/Deprecation-99588-PublicPropertiesInPageRepository.rst new file mode 100644 index 0000000..39f524d --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99588-PublicPropertiesInPageRepository.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99588-1673995832: + +========================================================= +Deprecation: #99588 - Public Properties in PageRepository +========================================================= + +See :issue:`99588` + +Description +=========== + +One of TYPO3's main classes for fetching page records and content in the TYPO3 frontend is +:php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository` previously known as `sys_page`. + +This class has been around for a long time, and due to several improvements in TYPO3 v8 +with Doctrine DBAL and in TYPO3 v9 with Context API and defining state via the Context API +and multiple instances of this class, it is not necessary to define public properties to modify +the behaviour of this class anymore. + +For this reason, the following public properties are marked deprecated: + +* :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->where_hid_del` +* :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->where_groupAccess` + + +Impact +====== + +Setting or reading these properties via PHP code in custom extensions will +trigger a PHP deprecation notice, however they continue to work in +TYPO3 v12. + + +Affected installations +====================== + +TYPO3 installations with custom extensions making use of these properties, +which is highly unlikely. + + +Migration +========= + +It is recommended to migrate towards creating custom instances of this class with custom +contexts (for example, to show hidden records, or to use other workspace constraints), +as this is already done in TYPO3 Core since various versions. + +If it is needed to build queries with the common restrictions, it is recommended to use +the API methods of this class, where most of the methods already have a +`$disableGroupAccessCheck` argument, or `enableFields()` which allows to return +common constraints, or to use the :php:`FrontendRestrictionContainer` when building +custom SQL queries with TYPO3's database layer directly. + +.. index:: Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99592-DeprecatedFlushByTagHook.rst b/Documentation/Changelog/12.2/Deprecation-99592-DeprecatedFlushByTagHook.rst new file mode 100644 index 0000000..c9ea2b8 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99592-DeprecatedFlushByTagHook.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99592-1674033859: + +================================================== +Deprecation: #99592 - Deprecated "flushByTag" hook +================================================== + +See :issue:`99592` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/cache/frontend/class.t3lib_cache_frontend_abstractfrontend.php']['flushByTag']` +has been marked as deprecated. + +It is recommended to implement a custom cache +frontend using :ref:`Frontend API<t3coreapi:caching-frontend>` when custom cache +functionality is required. + +Impact +====== + +Any hook implementation registered will not be executed anymore +in TYPO3 v13. The extension scanner will report possible usages. + +Affected installations +====================== + +All installations making use of the deprecated hook. + +Migration +========= + +Migrate corresponding cache functionality in the :php:`flushByTag()` method of your +own cache frontend implementation. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99615-GeneralUtilityGPMerged.rst b/Documentation/Changelog/12.2/Deprecation-99615-GeneralUtilityGPMerged.rst new file mode 100644 index 0000000..1f3d64f --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99615-GeneralUtilityGPMerged.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99615-1674056024: + +================================================= +Deprecation: #99615 - GeneralUtility::_GPmerged() +================================================= + +See :issue:`99615` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GPmerged()` has +been marked as deprecated and should not be used any longer. + +Modern code should access `GET` and `POST` data from the PSR-7 +:php:`\Psr\Http\Message\ServerRequestInterface`, and should avoid accessing +super-globals :php:`$_GET` and :php:`$_POST` directly. This helps creating +controller classes with a clean architecture. Some +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility` related helper methods like +:php:`_GPmerged()` violate this, using them is considered a technical debt. +They are being phased out. + + +Impact +====== + +Calling the method will raise a deprecation level log error and will +stop working with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions using :php:`GeneralUtility::_GPmerged()` are affected. +The extension scanner will find usages with a strong match. + + +Migration +========= + +:php:`GeneralUtility::_GPmerged()` is a helper method that retrieves +request parameters and returns the value, while `POST` parameters take +precedence over `GET` parameters, if both exist. + +The same result can be achieved by retrieving arguments from the request object. +An instance of the PSR-7 :php:`ServerRequestInterface` is handed over to +controllers by TYPO3 Core's PSR-15 :php:`\TYPO3\CMS\Core\Http\RequestHandlerInterface` +and middleware implementations, and is available in various related scopes +like the frontend :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`. + +Typical code: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + use TYPO3\CMS\Core\Utility\ArrayUtility; + + // Before + $getMergedWithPost = GeneralUtility::_GPmerged('tx_scheduler'); + + // After + $getMergedWithPost = $request->getQueryParams()['tx_scheduler']; + ArrayUtility::mergeRecursiveWithOverrule($getMergedWithPost, $request->getParsedBody()['tx_scheduler']); + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99633-GeneralUtilityPOST.rst b/Documentation/Changelog/12.2/Deprecation-99633-GeneralUtilityPOST.rst new file mode 100644 index 0000000..06b99d4 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99633-GeneralUtilityPOST.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99633-1674121794: + +============================================= +Deprecation: #99633 - GeneralUtility::_POST() +============================================= + +See :issue:`99633` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_POST()` has +been marked deprecated and should not be used any longer. + +Modern code should access GET and POST data from the PSR-7 +:php:`\Psr\Http\Message\ServerRequestInterface`, and should avoid accessing +super-globals :php:`$_GET` and :php:`$_POST` +directly. This will avoid future side-effects when using sub-requests. Some +:php:`GeneralUtility` related helper methods like :php:`_POST()` violate this, +using them is considered a technical debt. They are being phased out. + + +Impact +====== + +Calling the method from PHP code will trigger a PHP deprecation notice. + + +Affected installations +====================== + +TYPO3 installations with third-party extensions using :php:`GeneralUtility::_POST()` +are affected. This typically occurs in TYPO3 installations which +have been migrated to latest TYPO3 Core versions and +haven't been adapted properly yet. + +The extension scanner will find usages with a strong match. + + +Migration +========= + +:php:`GeneralUtility::_POST()` is a helper method that retrieves +incoming HTTP body parameters / `POST` parameters and returns the value. + +The same result can be achieved by retrieving arguments from the request object. +An instance of the PSR-7 :php:`ServerRequestInterface` is handed over to +controllers by TYPO3 Core's PSR-15 :php:`\TYPO3\CMS\Core\Http\RequestHandlerInterface` +and middleware implementations, and is available in various related scopes +like the frontend :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`. + +Typical code: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // Before + $value = GeneralUtility::_POST('tx_scheduler'); + + // After + $value = $request->getParsedBody()['tx_scheduler']); + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Deprecation-99638-EnvironmentgetBackendPath.rst b/Documentation/Changelog/12.2/Deprecation-99638-EnvironmentgetBackendPath.rst new file mode 100644 index 0000000..25653a8 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99638-EnvironmentgetBackendPath.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99638-1674127318: + +=================================================== +Deprecation: #99638 - Environment::getBackendPath() +=================================================== + +See :issue:`99638` + +Description +=========== + +TYPO3's backend path `/typo3` is currently resolved statically in +:php:`Environment::getBackendPath()` to return the full path to the +backend entrypoint. + +However, as TYPO3's code base is evolving, the usages to the hardcoded path have been reduced +and the functionality is now migrated into a new :php:`BackendEntryPointResolver` class, +which allows for dynamically adjusting the entry point in the future. + + +Impact +====== + +Calling the method will trigger a PHP deprecation warning. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using the method in PHP code. + + +Migration +========= + +Check for the extension scanner, and see if the code is necessary, or if any alternative, +such as the :php:`\TYPO3\CMS\Core\Routing\BackendEntryPointResolver` +or :php:`\TYPO3\CMS\Core\Http\NormalizedParams` might be better +suited as third-party extensions should not rely on the hard-coded paths to resources from +:file:`typo3/*` anymore. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99650-ExtbaseUriBuilderGlobalRequest.rst b/Documentation/Changelog/12.2/Deprecation-99650-ExtbaseUriBuilderGlobalRequest.rst new file mode 100644 index 0000000..b172941 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99650-ExtbaseUriBuilderGlobalRequest.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99650-1674205203: + +======================================================================= +Deprecation: #99650 - Global Request object usage in Extbase UriBuilder +======================================================================= + +See :issue:`99650` + +Description +=========== + +Usage of the global request object (:php:`$GLOBALS['TYPO3_REQUEST']`) as +fallback in the EXT:extbase :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder` +has been deprecated and will be removed in TYPO3 v13. The :php:`UriBuilder` will +then solely rely on a locally set request object. + +Impact +====== + +Using the :php:`UriBuilder` class of Extbase without a local +request object will trigger a PHP deprecation warning. + +Additionally, when using the :php:`UriBuilder` to build frontend URLs, the +current content object is required. It is initialized from the handed in local +request object. This means, in case extensions do set the request object, +a automatic fallback is applied in v12, triggering a PHP deprecation warning, as +it will be removed in v13, too. + + +Affected installations +====================== + +TYPO3 installations with custom extensions initializing the :php:`UriBuilder` +without handing in a request object and using it to build URIs. + + +Migration +========= + +Make sure to call :php:`setRequest($request)` before using the +:php:`UriBuilder`, when no other component has done this already. + +Using ViewHelpers will not trigger the warning, as the TYPO3 Core ensures +the proper setup. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99685-RemoveLineBreaksFromTemplate.rst b/Documentation/Changelog/12.2/Deprecation-99685-RemoveLineBreaksFromTemplate.rst new file mode 100644 index 0000000..b64c477 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99685-RemoveLineBreaksFromTemplate.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99685-1674497039: + +================================================================ +Deprecation: #99685 - PageRenderer::removeLineBreaksFromTemplate +================================================================ + +See :issue:`99685` + +Description +=========== + +The following methods have been marked as deprecated and will be removed +in TYPO3 v13: + +* :php:`\TYPO3\CMS\Core\Page\PageRenderer::enableRemoveLineBreaksFromTemplate()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer::disableRemoveLineBreaksFromTemplate()` +* :php:`\TYPO3\CMS\Core\Page\PageRenderer::getRemoveLineBreaksFromTemplate()` + +The methods provide a means to remove line break characters from the rendered output, +what would reduce the size of the response. There are better options available nowadays +though and no need to rely on a static code replacement. + +Impact +====== + +Using the methods will raise a deprecation level log entry and will stop +working with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that call these methods are affected. + +The extension scanner reports shows usages found. + + +Migration +========= + +These methods only remove linebreaks from the rendered HTML output. They are not +much use in terms of reducing response size. Migrate to a proper output +optimization tool like `tidy <https://www.html-tidy.org/>`__. + +All calls to the deprecated messages should be removed from the codebase. + +.. index:: Backend, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.2/Deprecation-99717-DeprecatedModifyBlindedConfigurationOptionsHook.rst b/Documentation/Changelog/12.2/Deprecation-99717-DeprecatedModifyBlindedConfigurationOptionsHook.rst new file mode 100644 index 0000000..3d33429 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99717-DeprecatedModifyBlindedConfigurationOptionsHook.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99717-1674654675: + +========================================================================= +Deprecation: #99717 - Deprecated "modifyBlindedConfigurationOptions" hook +========================================================================= + +See :issue:`99717` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Lowlevel\Controller\ConfigurationController']['modifyBlindedConfigurationOptions']` +has been deprecated in favor of the new PSR-14 +:php:`\TYPO3\CMS\Lowlevel\Event\ModifyBlindedConfigurationOptionsEvent`, +which acts as a direct replacement. + + +Impact +====== + +Using the hook will trigger a deprecation log entry and any hook +implementation registered will not be executed anymore in TYPO3 v13. + + +Affected installations +====================== + +All installations using the deprecated hook. The extension scanner +will report possible usages. + + +Migration +========= + +Use the :ref:`PSR-14 event <feature-99717-1674654720>` as a direct replacement. + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:lowlevel diff --git a/Documentation/Changelog/12.2/Deprecation-99811-DeprecateBootstrapTooltip.rst b/Documentation/Changelog/12.2/Deprecation-99811-DeprecateBootstrapTooltip.rst new file mode 100644 index 0000000..4182f45 --- /dev/null +++ b/Documentation/Changelog/12.2/Deprecation-99811-DeprecateBootstrapTooltip.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99811-1675447357: + +============================================================ +Deprecation: #99811 - Deprecate JavaScript bootstrap tooltip +============================================================ + +See :issue:`99811` + +Description +=========== + +Bootstrap-related backend tooltips initiated with +:html:`data-bs-toggle="tooltip"` together with the core +JavaScript class :js:`typo3/backend/tooltip.js` have been marked +as deprecated and should not be used anymore. + + +Impact +====== + +Loading :js:`typo3/backend/tooltip.js` in a backend-related module +will trigger a :js:`console.warn()`. The module will vanish in TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that add backend modules using the bootstrap-related +tooltips plugin may be affected. A typical sign for this is +using the :html:`data-bs-toggle="tooltip"` attribute on elements, loading the +JavaScript module :js:`typo3/backend/tooltip.js` and calling :js:`Tooltip.initialize()`. + + +Migration +========= + +Some parts of the Core will fall back to the :html:`title` attribute for now. However, +both the bootstrap tooltips as well as the title attribute raise accessibility +concerns. See `MDN <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title#accessibility_concerns>`_ +for more information on this. The Core will continue to improve the situation. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.2/Feature-77072-UsePasswordPolicyForInstall.rst b/Documentation/Changelog/12.2/Feature-77072-UsePasswordPolicyForInstall.rst new file mode 100644 index 0000000..e303d80 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-77072-UsePasswordPolicyForInstall.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-77072-1671089957: + +============================================================================== +Feature: #97390 - Use password policy for backend user password in ext:install +============================================================================== + +See :issue:`77072` + +Description +=========== + +The password used to create the backend user during install (GUI and setup +command) now considers the configurable password policy introduced in +:ref:`#97388 <feature-97388>`. + +Impact +====== + +The globally configured password policy is now taken into account +when the backend user is created during the install process. + +For each violation of the password policy a message will be +displayed to the user (GUI and setup command). + +.. index:: Backend, ext:install diff --git a/Documentation/Changelog/12.2/Feature-86913-AutomaticSupportForLanguageFilesOfLanguagesWithRegionSuffix.rst b/Documentation/Changelog/12.2/Feature-86913-AutomaticSupportForLanguageFilesOfLanguagesWithRegionSuffix.rst new file mode 100644 index 0000000..fbcc3ec --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-86913-AutomaticSupportForLanguageFilesOfLanguagesWithRegionSuffix.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _feature-86913-1673955088: + +====================================================================================== +Feature: #86913 - Automatic support for language files of languages with region suffix +====================================================================================== + +See :issue:`86913` + +Description +=========== + +TYPO3's native support for label files - that is: translatable text for system +labels such as from plugins, and for texts within TYPO3 backend - supports over +50 languages. Languages are identified by their "language key" of the ISO 639-1 +standard, which also allows the use of a region-specific language. This happens +mostly in countries/regions that have a variation of the language, such +as "en-US" for American English, or "de-CH" for the German language +in Switzerland. + +To support these region-specific language keys, which are composed of ISO 639-1 +and ISO 3166-1 and separated with `-`, TYPO3 integrators had to configure the +additional language manually to translate region-specific terms. + +Common examples are "Behavior" (American English) vs. "Behaviour" +(British English), or "Offerte" (Swiss German) vs. "Angebot" (German), +where all labels except a few terms should stay the same. + + +Impact +====== + +TYPO3 now allows integrators to use a custom label file with the +locale prefix :file:`de_CH.locallang.xlf` in an extension next to +:file:`de.locallang.xlf` and :file:`locallang.xlf` +(default language English). + +When integrators then use `de-CH` within their site configuration, TYPO3 +first checks if a term is available in the translation file :file:`de_CH.locallang.xlf`, +and then automatically falls back to the non-region-specific `de` +translation file :file:`de.locallang.xlf` without any further configuration to +TYPO3. + +Previously, such region-specific locales had to be configured via: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['user'] = [ + 'de-CH' => 'German (Switzerland)', + ]; + +The same fallback functionality also works when overriding labels via TypoScript: + +.. code-block:: typoscript + + plugin.tx_myextension._LOCAL_LANG.de = Angebot + plugin.tx_myextension._LOCAL_LANG.de-CH = Offerte + +.. index:: LocalConfiguration, TypoScript, ext:core diff --git a/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst b/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst new file mode 100644 index 0000000..7788661 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-88137-Multi-levelFallbackForContentInFrontendRendering.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-88137-1673993076: + +======================================================================== +Feature: #88137 - Multi-level fallback for content in frontend rendering +======================================================================== + +See :issue:`88137` + +Description +=========== + +TYPO3's site handling was introduced in TYPO3 v9 and allows to define a +"fallback type". + +A fallback type allows to define the behavior of how pages and the content +should be fetched from the database when rendering a page in the frontend. + +The option `strict` only renders content which was explicitly translated or +created in the defined language, and keeps the sorting behavior of the +default language. + +The option `free` does not consider the default language or its sorting, +and only fetches directly content of the given language ID. + +The option `fallback` allows to define a fallback chain of languages. +If a certain page is not available in the given language, TYPO3 +first checks the fallback chain if a page is available in one of the languages +in the fallback chain. + +A common scenario is this: + +* German (Austria) - Language = 2 +* German (Germany) - Language = 1 +* English (Default) - Language = 0 + +TYPO3 now can deal with the language chain in fallback mode not only for pages, +but also for any kind of content. + + +Impact +====== + +When working in a scenario with `fallback` and multiple languages in the fallback +chain, TYPO3 now checks for each content if the target language is available, +and then checks for the same content if it is translated in the language of the +fallback chain (example above in "German (Germany)"), before falling back to +the default language - which was the behavior until now. + +The language chain processing works with fallback mode (a.k.a. "overlays in mixed mode"), +both in TypoScript and Extbase code. Under the hood, the method +:php:`PageRepository->getLanguageOverlay()` is responsible for the chaining. + +Current limitations: + +* Content fallback only works in `fallbackType=fallback` +* Content fallback always stops at the default language (as this was the + previous behavior) + +.. index:: Frontend, PHP-API, TypoScript, ext:core diff --git a/Documentation/Changelog/12.2/Feature-92517-CustomNamespaceForExtbasePluginEnhancer.rst b/Documentation/Changelog/12.2/Feature-92517-CustomNamespaceForExtbasePluginEnhancer.rst new file mode 100644 index 0000000..5e73534 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-92517-CustomNamespaceForExtbasePluginEnhancer.rst @@ -0,0 +1,30 @@ +.. include:: /Includes.rst.txt + +.. _feature-92517-1671616097: + +============================================================== +Feature: #92517 - Custom namespace for Extbase plugin enhancer +============================================================== + +See :issue:`92517` + +Description +=========== + +The Extbase plugin enhancer for frontend routing allows to either set `extension` +and `plugin` OR to set `namespace`. If `extension` and `plugin` were given, +those were used. + +However, the `namespace` option is automatically constituted by the extension and +plugin options if it was not set intentionally. It is mainly used when overriding +the custom extension and plugin options with a custom (usually shortened) namespace, +so that the `namespace` is now always respected and preferred if all three options +are set. + +Impact +====== + +If all of `namespace` and `extension` and `plugin` options are configured, +the namespace option is now preferred within the Extbase plugin enhancer. + +.. index:: Frontend, ext:frontend diff --git a/Documentation/Changelog/12.2/Feature-97392-UsePasswordPolicyForNewAdminUsersCreatedInExtinstall.rst b/Documentation/Changelog/12.2/Feature-97392-UsePasswordPolicyForNewAdminUsersCreatedInExtinstall.rst new file mode 100644 index 0000000..9f38589 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-97392-UsePasswordPolicyForNewAdminUsersCreatedInExtinstall.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-97392-1672220371: + +================================================================================ +Feature: #97392 - Use password policy for new admin users created in ext:install +================================================================================ + +See :issue:`97392` + +Description +=========== + +The password for a new administrative backend user created using EXT:install +now considers the configurable password policy introduced by +:ref:`#97388 <feature-97388>`. + + +Impact +====== + +The global password policy is now taken into account when a +new administrative backend user is created using EXT:install. +Password policy requirements are shown below the password field and a message +is shown, if the new password does not meet the password policy requirements. + +.. index:: Backend, ext:install diff --git a/Documentation/Changelog/12.2/Feature-97700-AdoptSymfonyMessengerAsAMessageBusAndQueue.rst b/Documentation/Changelog/12.2/Feature-97700-AdoptSymfonyMessengerAsAMessageBusAndQueue.rst new file mode 100644 index 0000000..6067b57 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-97700-AdoptSymfonyMessengerAsAMessageBusAndQueue.rst @@ -0,0 +1,266 @@ +.. include:: /Includes.rst.txt + +.. _feature-97700-1672214769: + +==================================================================== +Feature: #97700 - Adopt Symfony Messenger as a message bus and queue +==================================================================== + +See :issue:`97700` + +Description +=========== + +This feature provides a basic implementation of a message bus based on the +`Symfony Messenger component <https://symfony.com/doc/current/messenger.html>`__. +For backwards compatibility, the default implementation uses the synchronous +transport. This means that the message bus will behave exactly as before, but it +will be possible to switch to a different (async) transport on a per-project +base. To offer asynchronicity, the feature also provides a transport implementation +based on the Doctrine DBAL messenger transport from Symfony and a basic +implementation of a consumer command. + +As an example, the workspace :class:`StageChangeNotification` has been rebuilt as a +message and corresponding handler. + +"Everyday" usage - as a developer +--------------------------------- + +Dispatch a message +~~~~~~~~~~~~~~~~~~ + +- Add a PHP class for your message object (arbitrary PHP class) + (:php:`DemoMessage`) + + .. code-block:: php + + <?php + + namespace TYPO3\CMS\Queue\Message; + + final class DemoMessage + { + public function __construct(public readonly string $content) + { + } + } + +- Inject :php:`\Symfony\Component\Messenger\MessageBusInterface` into your class +- Call :php:`dispatch()` method with a message as argument + + .. code-block:: php + + public function __construct(private readonly MessageBusInterface $bus) + { + } + + public function yourMethod(): void + { + // ... + $this->bus->dispatch(new DemoMessage('test')); + // ... + } + +Register a handler +~~~~~~~~~~~~~~~~~~ + +Use a tag to register a handler. Use before/after to define order. +Define handled message by argument type reflection or by key `message`. + +.. code-block:: php + + namespace TYPO3\CMS\Queue\Handler; + + use TYPO3\CMS\Queue\Message\DemoMessage; + + class DemoHandler + { + public function __invoke(DemoMessage $message): void + { + // do something with $message + } + } + +.. code-block:: yaml + + TYPO3\CMS\Queue\Handler\DemoHandler: + tags: + - name: 'messenger.message_handler' + + TYPO3\CMS\Queue\Handler\DemoHandler2: + tags: + - name: 'messenger.message_handler' + before: 'TYPO3\CMS\Queue\Handler\DemoHandler' + +Everyday Usage - as a sysadmin/integrator +----------------------------------------- + +By default, the system behaves as before. This means that the message bus +uses the synchronous transport and all messages are handled immediately. +To benefit from the message bus, it is recommended to switch to an asynchronous +transport. Using asynchronous transports increases the resilience of the system +by decoupling external dependencies even further. + +The TYPO3 Core currently provides an asynchronous transport based on the +Doctrine DBAL messenger transport. This transport is configured to use the +default TYPO3 database connection. It is pre-configured and can be used +by changing the settings in :file:`config/settings.php`: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']['*'] = 'doctrine'; + +This will route all messages to the asynchronous transport. + +If you are using the Doctrine transport, make sure to take care of running the +consume command (see below). + + +Async message handling - The consume command +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Run the command :bash:`./bin/typo3 messenger:consume <receiver-name>` to consume messages. +By default, you should run `./bin/typo3 messenger:consume doctrine`. The command is a +slimmed-down wrapper for the Symfony command `messenger:consume`, it only provides +the basic consumption functionality. As this command is running as a worker, +it is stopped after 1 hour to avoid memory leaks. The command should therefore +be run from a service manager like `systemd` to automatically restart it after +the command exits due to the time limit. + +Create a service via :file:`/etc/systemd/system/typo3-message-consumer.service`: + +.. code-block:: ini + + [Unit] + Description=Run the TYPO3 message consumer + Requires=mariadb.service + After=mariadb.service + + [Service] + Type=simple + User=www-data + Group=www-data + ExecStart=/usr/bin/php8.1 /var/www/myproject/vendor/bin/typo3 messenger:consume doctrine --exit-code-on-limit 133 + # Generally restart on error + Restart=on-failure + # Restart on exit code 133 (which is returned by the command when limits are reached) + RestartForceExitStatus=133 + # ..but do not interpret exit code 133 as an error (as it's just a restart request) + SuccessExitStatus=133 + + [Install] + WantedBy=multi-user.target + +The message worker can than be enabled and started via +:bash:`systemctl enable --now typo3-message-consumer` + + +Advanced Usage +-------------- + +Configure a custom transport (senders/receivers) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set up transports in services configuration. To configure one transport per +message, the TYPO3 configuration (:file:`config/settings.php`, +:file:`config/additional.php` on system level or :file:`ext_localconf.php`) is +used. The transport/sender name used in the settings is +resolved to a service that has been tagged with `message.sender` and the +respective identifier. + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger'] = [ + 'routing' => [ + // use "messenger.transport.demo" as transport for DemoMessage + \TYPO3\CMS\Queue\Message\DemoMessage::class => 'demo', + // use "messenger.transport.default" as transport for all other messages + '*' => 'default', + ] + ]; + +.. code-block:: yaml + + messenger.transport.demo: + factory: [ '@TYPO3\CMS\Core\Messenger\DoctrineTransportFactory', 'createTransport' ] + class: 'Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineTransport' + arguments: + $options: + queue_name: 'demo' + tags: + - name: 'messenger.sender' + identifier: 'demo' + - name: 'messenger.receiver' + identifier: 'demo' + + messenger.transport.default: + factory: [ '@Symfony\Component\Messenger\Transport\InMemory\InMemoryTransportFactory', 'createTransport' ] + class: 'Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport' + arguments: + $dsn: 'in-memory://default' + $options: [ ] + tags: + - name: 'messenger.sender' + identifier: 'default' + - name: 'messenger.receiver' + identifier: 'default' + +The TYPO3 Core has been tested with three transports: + +- :php:`\Symfony\Component\Messenger\Transport\Sync\SyncTransport` (default) +- :php:`\Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineTransport` (using the Doctrine DBAL messenger transport) +- :php:`\Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport` (for testing) + +InMemoryTransport for testing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:php:`\Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport` is a +transport that should only be used while testing. See the `SymfonyCasts +tutorial <https://symfonycasts.com/screencast/messenger/test-in-memory>`__ +for more details. + +.. code-block:: yaml + + messenger.transport.default: + factory: [ '@Symfony\Component\Messenger\Transport\InMemory\InMemoryTransportFactory', 'createTransport' ] + class: 'Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport' + public: true + arguments: + $dsn: 'in-memory://default' + $options: [ ] + tags: + - name: 'messenger.sender' + identifier: 'default' + - name: 'messenger.receiver' + identifier: 'default' + + +Configure a custom middleware +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set up a middleware in the services configuration. By default, +:php:`\Symfony\Component\Messenger\Middleware\SendMessageMiddleware` +and :php:`\Symfony\Component\Messenger\Middleware\HandleMessageMiddleware` +are registered - see also `Symfony's documentation +<https://symfony.com/doc/current/components/messenger.html#bus>`__. +To add your own message middleware, tag it as :yaml:`messenger.middleware` +and set the order using TYPO3's `before` and `after` ordering mechanism. + +.. code-block:: yaml + + Symfony\Component\Messenger\Middleware\SendMessageMiddleware: + arguments: + $sendersLocator: '@Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface' + $eventDispatcher: '@Psr\EventDispatcher\EventDispatcherInterface' + tags: + - { name: 'messenger.middleware' } + + Symfony\Component\Messenger\Middleware\HandleMessageMiddleware: + arguments: + $handlersLocator: '@Symfony\Component\Messenger\Handler\HandlersLocatorInterface' + tags: + - name: 'messenger.middleware' + after: 'Symfony\Component\Messenger\Middleware\SendMessageMiddleware' + + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.2/Feature-97923-ImprovePerformanceAndUsabilityWhileEditingSys_file_collection.rst b/Documentation/Changelog/12.2/Feature-97923-ImprovePerformanceAndUsabilityWhileEditingSys_file_collection.rst new file mode 100644 index 0000000..da263f6 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-97923-ImprovePerformanceAndUsabilityWhileEditingSys_file_collection.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-97923-1673529192: + +===================================================================================== +Feature: #97923 - Improve performance and usability while editing sys_file_collection +===================================================================================== + +See :issue:`97923` + +Description +=========== + +The two fields :sql:`storage` and :sql:`folder` of the :sql:`sys_file_collection` +table are now combined into the new field :sql:`folder_identifier`. The field +contains the so-called combined identifier in the format `storage:folder`, +where `storage` is the :sql:`uid` of the corresponding :sql:`sys_file_storage` +record and `folder` the absolute path to the folder, e.g. `1:/user_upload`. + +An upgrade wizard is in place to migrate the two fields of the existing records +to the new field. + +The TCA type `folder` is now used in the backend editing form to improve the +usability on selecting the corresponding folder via the folder selector, when +using the file collections with type `folder`. + + +Impact +====== + +Editing :sql:`sys_file_collection` records for the record type `folder` in the +backend is improved. Instead of selecting the storage first, reloading the form +and selecting the folder in a possibly large list afterwards, are users now +able to select the folder using the folder selector in a single step. + +This additionally improves the performance of the backend form, especially for +storages with a huge amount of folders. + +Also working with such records is improved, since only one field has to be +taken into account. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.2/Feature-98394-IntroduceEventToPreventDownloadingOfLanguagePacks.rst b/Documentation/Changelog/12.2/Feature-98394-IntroduceEventToPreventDownloadingOfLanguagePacks.rst new file mode 100644 index 0000000..d89a5de --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-98394-IntroduceEventToPreventDownloadingOfLanguagePacks.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _feature-98394-1674070213: + +========================================================================== +Feature: #98394 - Introduce event to prevent downloading of language packs +========================================================================== + +See :issue:`98394` + +Description +=========== + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + services: + MyVendor\MyExtension\EventListener\ModifyLanguagePacks: + tags: + - name: event.listener + identifier: 'modifyLanguagePacks' + event: TYPO3\CMS\Install\Service\Event\ModifyLanguagePacksEvent + method: 'modifyLanguagePacks' + + +.. code-block:: php + :caption: EXT:my_extension/Classes/EventListener/ModifyLanguagePacks.php + + <?php + namespace MyVendor\MyExtension\EventListener; + + use TYPO3\CMS\Install\Service\Event\ModifyLanguagePacksEvent; + + final class ModifyLanguagePacks + { + public function modifyLanguagePacks(ModifyLanguagePacksEvent $event): void + { + $extensions = $event->getExtensions(); + foreach ($extensions as $key => $extension){ + if($extension['type'] === 'typo3-cms-framework'){ + $event->removeExtension($key); + } + } + $event->removeIsoFromExtension('de', 'styleguide'); + } + } + +Impact +====== + +With the newly introduced event, it is possible to ignore extensions or +individual language packs for extensions when downloading the language packs. +However, only language packs for extensions and languages +available in the system can be downloaded. The options of the `language:update` +command can be used to further restrict the download (ignore additional +extensions or download only specific languages), but not to ignore decisions +made by the event. + +.. index:: ext:install diff --git a/Documentation/Changelog/12.2/Feature-98528-NewFileLocationForENABLE_INSTALL_TOOL.rst b/Documentation/Changelog/12.2/Feature-98528-NewFileLocationForENABLE_INSTALL_TOOL.rst new file mode 100644 index 0000000..b88fdc2 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-98528-NewFileLocationForENABLE_INSTALL_TOOL.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-98528-1674126393: + +=========================================================== +Feature: #98528 - New file location for ENABLE_INSTALL_TOOL +=========================================================== + +See :issue:`98528` + +Description +=========== + +To access the standalone :guilabel:`Install Tool`, the file +:file:`typo3conf/ENABLE_INSTALL_TOOL` needed to be created. +With TYPO3 v12, the location of this file has been changed. + +For Composer-based installations the following file paths are checked: + +* :file:`var/transient/ENABLE_INSTALL_TOOL` +* :file:`config/ENABLE_INSTALL_TOOL` + +For legacy installations the following file paths are checked: + +* :file:`typo3temp/var/transient/ENABLE_INSTALL_TOOL` +* :file:`typo3conf/ENABLE_INSTALL_TOOL` + +Using the previous known path :file:`typo3conf/ENABLE_INSTALL_TOOL` is +still possible. + + +Impact +====== + +Especially for Composer-based installation this change allows to completely +drop the usage of the :file:`typo3conf/` directory. + +Add the new paths to your :php:`.gitignore` file to avoid deploying this file to +production environments. + +.. index:: Backend, ext:install diff --git a/Documentation/Changelog/12.2/Feature-99191-CreateFoldersViaModals.rst b/Documentation/Changelog/12.2/Feature-99191-CreateFoldersViaModals.rst new file mode 100644 index 0000000..9762850 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99191-CreateFoldersViaModals.rst @@ -0,0 +1,34 @@ +.. include:: /Includes.rst.txt + +.. _feature-99191-1669906308: + +=========================================== +Feature: #99191 - Create folders via modals +=========================================== + +See :issue:`99191` + +Description +=========== + +The creation of new folders in the :guilabel:`File > Filelist` module has been +improved. Instead of a new window, the :guilabel:`Create Folder` button +now opens a modal window to create a folder. +Both the button in the docheader and the corresponding option in the context +menu are affected. + +The modal window also contains the folder tree to select the parent folder. +To allow editors creating folders sequentially, the modal is not +automatically closed. + +Impact +====== + +With the new modal window, backend users are able to create folders in an +improved way: They do not lose focus of the current view anymore. +Additionally, the parent folder can easily be changed inside the modal window, +which allows to create folders for different levels without leaving the form. +After closing the modal window, the :guilabel:`File > Filelist` module +automatically reloads to instantly display the latest changes. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/12.2/Feature-99220-AddEventToModifySearchResults.rst b/Documentation/Changelog/12.2/Feature-99220-AddEventToModifySearchResults.rst new file mode 100644 index 0000000..6dce2e1 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99220-AddEventToModifySearchResults.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _feature-99220-1670250156: + +==================================================== +Feature: #99220 - Add event to modify search results +==================================================== + +See :issue:`99220` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Backend\Search\Event\ModifyResultItemInLiveSearchEvent` +is added to allow extension developers to take control over search result items +rendered in the backend search. + +The event has a public method called :php:`getResultItem()`, returning the +:php:`\TYPO3\CMS\Backend\Search\LiveSearch\ResultItem` instance of the search +result item. + +Impact +====== + +Search result items may be modified within a custom event listener, e.g. to add +custom actions. + +Example +------- + +.. code-block:: php + :caption: EXT:my_extension/Classes/Search/EventListener/AddLiveSearchResultActionsListener.php + + <?php + + namespace MyVendor\MyExtension\Search\EventListener; + + use TYPO3\CMS\Backend\Routing\UriBuilder; + use TYPO3\CMS\Backend\Search\Event\ModifyResultItemInLiveSearchEvent; + use TYPO3\CMS\Backend\Search\LiveSearch\DatabaseRecordProvider; + use TYPO3\CMS\Backend\Search\LiveSearch\ResultItemAction; + use TYPO3\CMS\Core\Imaging\Icon; + use TYPO3\CMS\Core\Imaging\IconFactory; + use TYPO3\CMS\Core\Localization\LanguageService; + use TYPO3\CMS\Core\Localization\LanguageServiceFactory; + + final class AddLiveSearchResultActionsListener + { + protected LanguageService $languageService; + + public function __construct( + protected readonly IconFactory $iconFactory, + protected readonly LanguageServiceFactory $languageServiceFactory, + protected readonly UriBuilder $uriBuilder + ) { + $this->languageService = $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER']); + } + + public function __invoke(ModifyResultItemInLiveSearchEvent $event): void + { + $resultItem = $event->getResultItem(); + if ($resultItem->getProviderClassName() !== DatabaseRecordProvider::class) { + return; + } + + if (($resultItem->getExtraData()['table'] ?? null) === 'tt_content') { + /** + * WARNING: THIS EXAMPLE OMITS ANY ACCESS CHECK FOR SIMPLICITY REASONS. + * DO NOT USE AS-IS! + */ + $showHistoryAction = (new ResultItemAction('view_history')) + ->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:history')) + ->setIcon($this->iconFactory->getIcon('actions-document-history-open', Icon::SIZE_SMALL)) + ->setUrl((string)$this->uriBuilder->buildUriFromRoute('record_history', [ + 'element' => $resultItem->getExtraData()['table'] . ':' . $resultItem->getExtraData()['uid'] + ])); + $resultItem->addAction($showHistoryAction); + } + } + } + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Search\EventListener\AddLiveSearchResultActionsListener: + tags: + - name: event.listener + identifier: 'site/add-live-search-result-actions-listener' + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.2/Feature-99285-AddFluidTrimViewhelper.rst b/Documentation/Changelog/12.2/Feature-99285-AddFluidTrimViewhelper.rst new file mode 100644 index 0000000..6863c1b --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99285-AddFluidTrimViewhelper.rst @@ -0,0 +1,72 @@ +.. include:: /Includes.rst.txt + +.. _feature-99285-1670321970: + +========================================== +Feature: #99285 - Add Fluid TrimViewHelper +========================================== + +See :issue:`99285` + +Description +=========== + +A trim ViewHelper to trim strings is now available. + +Possible sides are: + +* `both` Strip whitespace (or other characters) from the beginning and end of a string +* `left` Strip whitespace (or other characters) from the beginning of a string +* `right` Strip whitespace (or other characters) from the end of a string + + +Examples +-------- + +Trim from both sides +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + #<f:format.trim> String to be trimmed. </f:format.trim># + +Results in the output: + +.. code-block:: text + + #String to be trimmed.# + +Trim only one side +~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + #<f:format.trim side="right"> String to be trimmed. </f:format.trim># + +Results in the output: + +.. code-block:: text + + # String to be trimmed.# + +Trim special characters +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + #<f:format.trim characters=" St."> String to be trimmed. </f:format.trim># + +Results in the output: + +.. code-block:: text + + #ring to be trimmed# + + +Impact +====== + +The new ViewHelper can be used in all new projects. There is no interference +with any part of existing code. + +.. index:: Fluid, Frontend, ext:fluid diff --git a/Documentation/Changelog/12.2/Feature-99312-PSR-14EventForFetchingYoutubeVimeoPreviewImage.rst b/Documentation/Changelog/12.2/Feature-99312-PSR-14EventForFetchingYoutubeVimeoPreviewImage.rst new file mode 100644 index 0000000..1f88c5d --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99312-PSR-14EventForFetchingYoutubeVimeoPreviewImage.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _feature-99312: + +======================================================================= +Feature: #99312 - PSR-14 Event for fetching YouTube/Vimeo preview image +======================================================================= + +See :issue:`99312` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Resource\OnlineMedia\Event\AfterVideoPreviewFetchedEvent` +has been introduced. The purpose of this event is to modify the preview file +of online media previews (like YouTube and Vimeo). +If, for example, a processed file is bad (blank or outdated), this event can be +used to modify and/or update the preview file. + +The event features the following methods: + +- :php:`getFile()`: Returns the :php:`\TYPO3\CMS\Core\Resource\File` in question +- :php:`getOnlineMediaId()`: Returns the video ID +- :php:`getPreviewImageFilename()`: Returns the filename of the preview image +- :php:`setPreviewImageFilename()`: Set the filename for the preview image + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\EventListener\ExampleEventListener: + tags: + - name: event.listener + identifier: 'exampleEventListener' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/EventListener/ExampleEventListener.php + + namespace MyVendor\MyExtension\EventListener; + + use TYPO3\CMS\Core\Resource\OnlineMedia\Event\AfterVideoPreviewFetchedEvent; + + final class ExampleEventListener + { + public function __invoke(AfterVideoPreviewFetchedEvent $event): void + { + $event->setPreviewImageFilename( + '/var/www/websites/typo3temp/assets/online_media/new-preview-image.jpg' + ); + // An extension could use this to fetch new images again. + } + } + +Impact +====== + +It is now possible to change the filename for the preview image of a YouTube +or Vimeo thumbnail image. + +.. index:: Backend, Frontend, ext:core diff --git a/Documentation/Changelog/12.2/Feature-99341-AddCliCreateBeUserCommand.rst b/Documentation/Changelog/12.2/Feature-99341-AddCliCreateBeUserCommand.rst new file mode 100644 index 0000000..b85140b --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99341-AddCliCreateBeUserCommand.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-99341-1670827943: + +=================================================== +Feature: #99341 - Introduce CLI create user command +=================================================== + +See :issue:`99341` + +Description +=========== + +A new CLI command `backend:user:create`, which automates backend user creation, +is introduced as an alternative to the existing backend module. + +Impact +====== + +You can now use `./bin/typo3 backend:user:create` to create a backend user +without touching the GUI. + +Example +------- + +Interactive / guided setup (questions/answers): + +.. code-block:: bash + + ./bin/typo3 backend:user:create + +User creation using environment variables: + +.. code-block:: bash + + 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_ADMIN=0 \ + TYPO3_BE_USER_MAINTAINER=0 \ + ./bin/typo3 backend:user:create --no-interaction + +.. warning:: + + 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`. + +.. index:: ext:backend diff --git a/Documentation/Changelog/12.2/Feature-99430-AddEventAfterRecordPublishingInWorkspaces.rst b/Documentation/Changelog/12.2/Feature-99430-AddEventAfterRecordPublishingInWorkspaces.rst new file mode 100644 index 0000000..ecd58d3 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99430-AddEventAfterRecordPublishingInWorkspaces.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-99430-1672129914: + +================================================================= +Feature: #99430 - Add event after record publishing in workspaces +================================================================= + +See :issue:`99430` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent` +has been added to allow extension developers to react on record publishing +in workspaces. + +The new event is fired after a record has been published in a workspace and +provides the following methods: + +- :php:`getTable()`: The record's table name +- :php:`getRecordId()`: The record's UID +- :php:`getWorkspaceId()`: The workspace the record has been published in + +Example +======= + +Registration of the :php:`AfterRecordPublishedEvent` in your extension's +:file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Workspaces\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/after-record-published' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Workspaces/MyEventListener.php + + namespace MyVendor\MyExtension\Workspaces; + + use TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent; + + final class MyEventListener { + public function __invoke(AfterRecordPublishedEvent $event): void + { + // Do your magic here + } + } + +Impact +====== + +With the new PSR-14 event :php:`AfterRecordPublishedEvent` it is possible to +execute custom functionality after a record has been published in a workspace. + +.. index:: PHP-API, ext:workspaces diff --git a/Documentation/Changelog/12.2/Feature-99552-IntroduceMissingMetaDescriptionWidget.rst b/Documentation/Changelog/12.2/Feature-99552-IntroduceMissingMetaDescriptionWidget.rst new file mode 100644 index 0000000..899bd58 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99552-IntroduceMissingMetaDescriptionWidget.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-99552-1673955499: + +============================================================= +Feature: #99552 - Introduce "Missing Meta Description" widget +============================================================= + +See :issue:`99552` + +Description +=========== + +To make it more convenient for TYPO3 users to optimise their website for search +engines, TYPO3 now offers a dashboard widget that shows pages without +a meta description. + + +Impact +====== + +TYPO3 users who have access to the :guilabel:`Dashboard` module and are +granted access to the new widget can now add and use the widget. +The users will only see pages with missing meta description they are +allowed to edit. + +.. index:: Backend, ext:seo diff --git a/Documentation/Changelog/12.2/Feature-99584-AllowToProvideRealNameForNewAdminUsersInExtinstall.rst b/Documentation/Changelog/12.2/Feature-99584-AllowToProvideRealNameForNewAdminUsersInExtinstall.rst new file mode 100644 index 0000000..257c6c2 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99584-AllowToProvideRealNameForNewAdminUsersInExtinstall.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-99584-1673985938: + +========================================================================== +Feature: #99584 - Allow to provide name for new admin users in ext:install +========================================================================== + +See :issue:`99584` + +Description +=========== + +The field `realName` has been added to the "Create Administrative User" modal +in ext:install, so it is possible to provide the name of a new admin user. + +The notice in the header of the model has been removed, since it is +superfluous now. + +Impact +====== + +It is now possible to provide the field `realName`, when a new admin user +is created in ext:install. + +.. index:: Backend, ext:install diff --git a/Documentation/Changelog/12.2/Feature-99586-RegistrationOfUpgradeWizardsViaServiceTag.rst b/Documentation/Changelog/12.2/Feature-99586-RegistrationOfUpgradeWizardsViaServiceTag.rst new file mode 100644 index 0000000..a49bedf --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99586-RegistrationOfUpgradeWizardsViaServiceTag.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-99586-1673989775: + +================================================================= +Feature: #99586 - Registration of upgrade wizards via service tag +================================================================= + +See :issue:`99586` + +Description +=========== + +Upgrade wizards are used to execute one time migrations when +updating a TYPO3 installation. The registration was previously done +in an extension's :php:`ext_localconf.php` file. This has now been +improved by introducing the custom PHP attribute +:php:`\TYPO3\CMS\Install\Attribute\UpgradeWizard`. All upgrade wizards, +defining the new attribute, are automatically tagged and registered +in the service container. The registration via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']` +has been deprecated. + +The registration of an upgrade wizard is therefore now be done +directly in the class by adding the new attribute with the upgrade +wizard's unique identifier as constructor argument: + +.. code-block:: php + + use TYPO3\CMS\Install\Attribute\UpgradeWizard; + use TYPO3\CMS\Install\Updates\UpgradeWizardInterface; + + #[UpgradeWizard('myUpgradeWizard')] + class MyUpgradeWizard implements UpgradeWizardInterface + { + + } + +.. note:: + + All upgrade wizards have to implement the + :php:`\TYPO3\CMS\Install\Updates\UpgradeWizardInterface`. + +Impact +====== + +It is now possible to tag upgrade wizards with the PHP attribute +:php:`\TYPO3\CMS\Install\Attribute\UpgradeWizard` to have them +auto-configured and auto-registered. + +.. index:: Backend, PHP-API, ext:install diff --git a/Documentation/Changelog/12.2/Feature-99618-ListOfCountriesInTheWorldAndTheirLocalizedNames.rst b/Documentation/Changelog/12.2/Feature-99618-ListOfCountriesInTheWorldAndTheirLocalizedNames.rst new file mode 100644 index 0000000..55fed3f --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99618-ListOfCountriesInTheWorldAndTheirLocalizedNames.rst @@ -0,0 +1,124 @@ +.. include:: /Includes.rst.txt + +.. _feature-99618-1674063182: + +========================================================================== +Feature: #99618 - List of countries in the world and their localized names +========================================================================== + +See :issue:`99618` + +Description +=========== + +TYPO3 now ships a list of countries of the world. The list is based on the +ISO 3166-1 standard, with the alpha-numeric short name ("FR" or "FRA" in its +three-letter short name), the English name ("France"), the official name +("Republic of France"), also the numerical code, and the country's flag as +emoji (UTF-8 representation). + +This list is based on Debian's ISO code list https://salsa.debian.org/iso-codes-team/iso-codes, +and shipped statically as PHP content in a new country API. + + +Impact +====== + +It is now possible to load a list of all countries via PHP: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + + $countryProvider = GeneralUtility::makeInstance(CountryProvider::class); + $france = $countryProvider->getByIsoCode('FR'); + // or + $france = $countryProvider->getByEnglishName('France'); + // or + $france = $countryProvider->getByAlpha3IsoCode('FRA'); + // or + $allCountries = $countryProvider->getAll(); + // or + $filter = new CountryFilter(); + $filter + ->setOnlyCountries(['AT', 'DE', 'FR', 'DK']) + ->setExcludeCountries(['AUT', 'DK']); + $filteredCountries = $countryProvider->getFiltered($filter); // will be array with DE & FR + +A country object can be used to fetch all information about this, +also with translatable labels: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + use TYPO3\CMS\Core\Localization\LanguageServiceFactory; + + $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class)->create('de'); + echo $france->getName(); // "France" + echo $languageService->sL($france->getLocalizedNameLabel()); // "Frankreich" + echo $france->getOfficialName(); // "French Republic" + echo $languageService->sL($france->getLocalizedOfficalNameLabel()); // "Französische Republik" + echo $france->getNumericRepresentation(); // 250 + echo $france->getAlpha2IsoCode(); // "FR" + echo $france->getFlag(); // "🇫🇷" + +A Fluid ViewHelper is also shipped with TYPO3 to render a dropdown +for forms: + +.. code-block:: html + + <f:form.countrySelect + name="country" + value="AT" + sortByOptionLabel="true" + prioritizedCountries="{0: 'DE', 1: 'AT', 2: 'CH'}" + /> + +Available options +----------------- + +- :html:`disabled`: Specifies that the form element should be disabled when + the page loads. +- :html:`required`: If set no empty value is allowed. +- :html:`size`: Size of select field, a numeric value to show the amount of + items to be visible at the same time - equivalent to HTML :html:`<select>` + site attribute +- :html:`multiple`: If set multiple options may be selected +- :html:`errorClass`: Specify the CSS class to be set if there are errors for + this ViewHelper. +- :html:`sortByOptionLabel`: Whether the country list should be sorted by + option label or not. + +- :html:`optionLabelField`: Specify the type of label of the country list. + Available options are: "name", "localizedName", "officialName" or + "localizedOfficialName". Default option is "localizedName". +- :html:`alternativeLanguage`: If specified, the country list will be shown + in the given language. + +- :html:`prioritizedCountries`: Define a list of countries which should be + listed as first options in the form element. +- :html:`onlyCountries`: Restrict the countries to be rendered in the list. +- :html:`excludeCountries`: Define which countries should not be shown in + the list. + +- :html:`prependOptionLabel`: Provide an additional option at first position + with the specified label. +- :html:`prependOptionValue`: Provide an additional option at first position + with the specified value. + +.. hint:: + A combination of :html:`optionLabelField` and :html:`alternativeLanguage` is + possible. For instance, if you want to show the localized official names but + not in your default language but in French. + You can achieve this by using the following combination: + +.. code-block:: html + + <f:form.countrySelect + name="country" + optionLabelField="localizedOfficialName" + alternativeLanguage="fr" + sortByOptionLabel="true" + /> + +.. index:: Fluid, PHP-API, ext:core diff --git a/Documentation/Changelog/12.2/Feature-99626-SitesConfigurationYAMLInConfigurationModule.rst b/Documentation/Changelog/12.2/Feature-99626-SitesConfigurationYAMLInConfigurationModule.rst new file mode 100644 index 0000000..497b78c --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99626-SitesConfigurationYAMLInConfigurationModule.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _feature-99626-1674115749: + +==================================================================== +Feature: #99626 - Sites configuration (YAML) in configuration module +==================================================================== + +See :issue:`99626` + +Description +=========== + +Currently, it is difficult to have a global overview of the YAML configuration +of sites (especially the route enhancers). Since those configurations are basic +information, it is important for site administrators to have an overview of them. + +Therefore, the configuration module now lists all site configurations +with their identifier and corresponding configuration. + +Impact +====== + +It is now possible for site administrators to get an overview of the YAML +configuration of all sites in the configuration module. + +.. index:: Backend, ext:lowlevel diff --git a/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst b/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst new file mode 100644 index 0000000..14ea376 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-99632-1674121967: + +=================================================================== +Feature: #99632 - Introduce PHP attribute to mark a webhook message +=================================================================== + +See :issue:`99632` + +Description +=========== + +A new custom PHP attribute :php:`\TYPO3\CMS\Core\Attribute\WebhookMessage` has +been added in order to register a message as a specific webhook message, +to send as remote status. + +The attribute must have an identifier for the webhook type (unique), +and a description that explains the purpose of the message. + +Optionally, a property method can be set for the attribute, +that contains the factory method. By default this is `createFromEvent`, +which is typically used when creating a message by an event listener, see +webhooks documentation for more details. + +Example +------- + +.. code-block:: php + + use TYPO3\CMS\Core\Attribute\WebhookMessage; + + #[WebhookMessage( + identifier: 'typo3/file-updated', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-file-updated' + )] + final class AnyKindOfMessage + { + // ... + } + + +Impact +====== + +It is now possible to tag any PHP class as webhook message by the PHP attribute +:php:`\TYPO3\CMS\Core\Attribute\WebhookMessage`. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.2/Feature-99647-SpecificRoutesForBackendModules.rst b/Documentation/Changelog/12.2/Feature-99647-SpecificRoutesForBackendModules.rst new file mode 100644 index 0000000..1423291 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99647-SpecificRoutesForBackendModules.rst @@ -0,0 +1,123 @@ +.. include:: /Includes.rst.txt + +.. _feature-99647-1674134370: + +===================================================== +Feature: #99647 - Specific routes for backend modules +===================================================== + +See :issue:`99647` + +Description +=========== + +With :issue:`96733` the new module registration API has been introduced. One +of the main features is the explicit definition of the module routes. To +further improve the registration, it is now possible to define specific routes +for the modules, targeting any controller / action combination. Previously +the `target` of a module usually targeted a controller action like +:php:`handleRequest()`, which then forwarded the request internally to a +specific action, specified by e.g. a query argument. Such umbrella method can +now be omitted by directly using the target action as :php:`target` in the +module configuration. + +Additionally, this also makes any HTTP method check in the controller +superfluous, since the allowed methods can now also be defined directly in the +module configuration for each sub-route. + +Example +------- + +.. code-block:: php + :caption: EXT:my_extension/Configuration/Backend/Modules.php + + return [ + 'my_module' => [ + 'parent' => 'web', + 'path' => '/module/web/my-module', + 'routes' => [ + '_default' => [ + 'target' => MyModuleController::class . '::overview', + ], + 'edit' => [ + 'path' => '/custom-path', + 'target' => MyModuleController::class . '::edit', + ], + 'manage' => [ + 'target' => AnotherController::class . '::manage', + 'methods' => ['POST'], + ], + ], + ], + ]; + +In case the :php:`path` option is omitted for a sub-route, its identifier is +automatically used as :php:`path`, e.g. :php:`/manage`. + +All sub-routes are automatically registered in a :php:`\TYPO3\CMS\Core\Routing\RouteCollection`. +The full route identifier syntax is :php:`<module_identifier>.<sub_route>`, for +example :php:`my_module.edit`. Using the :php:`\TYPO3\CMS\Backend\Routing\UriBuilder` +to create a link to such sub-route could therefore look like this: + +.. code-block:: php + + UriBuilder->buildUriFromRoute('my_module.edit') + +Extbase modules +^^^^^^^^^^^^^^^ + +Also Extbase backend modules are enhanced and define now automatically +explicit routes for each controller / action combination, +as long as the :typoscript:`enableNamespacedArgumentsForBackend` +feature toggle is turned off, which is the default. This means, +the following module configuration + +.. code-block:: php + :caption: EXT:my_extension/Configuration/Backend/Modules.php + + return [ + 'web_ExtkeyExample' => [ + 'parent' => 'web', + 'position' => ['after' => 'web_info'], + 'access' => 'admin', + 'workspaces' => 'live', + 'iconIdentifier' => 'module-example', + 'path' => '/module/web/ExtkeyExample', + 'labels' => 'LLL:EXT:beuser/Resources/Private/Language/locallang_mod.xlf', + 'extensionName' => 'Extkey', + 'controllerActions' => [ + MyModuleController::class => [ + 'list', + 'detail' + ], + ], + ], + ]; + +now leads to following URLs: + +- `https://example.com/typo3/module/web/ExtkeyExample` +- `https://example.com/typo3/module/web/ExtkeyExample/MyModuleController/list` +- `https://example.com/typo3/module/web/ExtkeyExample/MyModuleController/detail` + +The route identifier of corresponding routes is registered with similar syntax +as standard backend modules: :php:`<module_identifier>.<controller>_<action>`. +Above configuration will therefore register the following routes: + +- `web_ExtkeyExample` +- `web_ExtkeyExample.MyModuleController_list` +- `web_ExtkeyExample.MyModuleController_detail` + +Impact +====== + +It is now possible to configure specific routes for a module, which all can +target any controller / action combination. + +As long as :typoscript:`enableNamespacedArgumentsForBackend` is turned off +for Extbase backend modules, all controller / action combinations are explicitly +registered as individual routes. This effectively means human-readable URLs, +since the controller / action combinations are no longer defined via query +parameters but are now part of the path. + +.. index:: Backend, PHP-API, ext:backend diff --git a/Documentation/Changelog/12.2/Feature-99694-UnifiedLocaleHandlingForTranslationFilesXLF.rst b/Documentation/Changelog/12.2/Feature-99694-UnifiedLocaleHandlingForTranslationFilesXLF.rst new file mode 100644 index 0000000..58e5cbf --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99694-UnifiedLocaleHandlingForTranslationFilesXLF.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _feature-99694-1674552209: + +===================================================================== +Feature: #99694 - Unified Locale handling for translation files (XLF) +===================================================================== + +See :issue:`99694` + +Description +=========== + +TYPO3 now internally uses a "locale" format following the +`IETF RFC 5646 language tag standard <https://www.rfc-editor.org/rfc/rfc5646.html>`__. + +A locale supported by TYPO3 consists of the following parts (tags and subtags): + +* ISO 639-1 / ISO 639-2 compatible language key in lowercase (such as "fr" French, or "de" for German) +* optionally the ISO 15924 compatible language script system (4 letter, such as "Hans" as in "zh_Hans") +* optionally the region / country code according to ISO 3166-1 standard in upper camelcase such as "AT" for Austria. + +Examples for a locale string are: + +* "en" for English +* "pt" for Portuguese +* "da-DK" for Danish as used in Denmark +* "de-CH" for German as used in Switzerland +* "zh-Hans-CN" for Chinese with the simplified script as spoken in China (mainland) + +A new PHP object :php:`Locale` automatically separates each tag and subtag into +these parts. + +The :php:`\TYPO3\CMS\Core\Localization\Locale` object can now be used to instantiate a new +:php:`\TYPO3\CMS\Core\Localization\LanguageService` object for translating labels. +Previously, TYPO3 used the `default` language key instead of the locale `en` to +identify the English language. Both are supported, but it is encouraged to use +`en-US` or `en-GB` with the region subtag to identify the chosen language more +precisely. + + +Impact +====== + +Example for using the :php:`Locale` class for creating a :php:`LanguageService` +object for translations: + +.. code-block:: php + + $languageService = $languageServiceFactory->create(new Locale('de-AT')); + $myTranslatedString = $languageService->sL( + 'LLL:EXT:my_extension/Resources/Private/Language/myfile.xlf:my-label' + ); + +Using this service is highly recommended, as the wrappers +:php:`$GLOBALS['LANG']->sL()` and :php:`$GLOBALS['TSFE']->sL()` will be +deprecated in the future. + +.. index:: PHP-API, ext:core diff --git a/Documentation/Changelog/12.2/Feature-99717-NewPSR-14ModifyBlindedConfigurationOptionsEvent.rst b/Documentation/Changelog/12.2/Feature-99717-NewPSR-14ModifyBlindedConfigurationOptionsEvent.rst new file mode 100644 index 0000000..33f708b --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99717-NewPSR-14ModifyBlindedConfigurationOptionsEvent.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + +.. _feature-99717-1674654720: + +=================================================================== +Feature: #99717 - New PSR-14 ModifyBlindedConfigurationOptionsEvent +=================================================================== + +See :issue:`99717` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Lowlevel\Event\ModifyBlindedConfigurationOptionsEvent` +has been introduced which serves as a direct replacement for the +now deprecated hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Lowlevel\Controller\ConfigurationController']['modifyBlindedConfigurationOptions']`. + +The new PSR-14 event is fired in the +:php:`\TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\GlobalVariableProvider` and the +:php:`\TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\SitesYamlConfigurationProvider`, +while building the corresponding configuration array, which should be displayed +in the :guilabel:`Configuration` module. The event therefore allows to +blind (hide) any of those configuration options. Usually, such options are +passwords or any other sensitive information. + +Using the :php:`ModifyBlindedConfigurationOptionsEvent::getProviderIdentifier()` +method, listeners are able to determine the context, the event got dispatched +in. This is useful to prevent duplicate code execution, since the event is +dispatched for multiple providers. The method returns the identifier of +the configuration provider as registered in the +:ref:`service configuration <feature-92929>`. + +Example +======= + +Registration of the :php:`ModifyBlindedConfigurationOptionsEvent` in your +extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Backend\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/blind-configuration-options' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Backend/MyEventListener.php + + namespace MyVendor\MyExtension\Backend; + + use TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\GlobalVariableProvider; + use TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\SitesYamlConfigurationProvider; + use TYPO3\CMS\Lowlevel\Event\ModifyBlindedConfigurationOptionsEvent; + + final class MyEventListener { + public function __invoke(ModifyBlindedConfigurationOptionsEvent $event): void + { + $blindedConfigurationOptions = $event->getBlindedConfigurationOptions(); + + if ($event->getProviderIdentifier() === 'sitesYamlConfiguration') { + $blindedConfigurationOptions['my-site']['settings']['apiKey'] = '***'; + } elseif ($event->getProviderIdentifier() === 'confVars') { + $blindedConfigurationOptions['TYPO3_CONF_VARS']['EXTENSIONS']['my_extension']['password'] = '******'; + } + + $event->setBlindedConfigurationOptions($blindedConfigurationOptions); + } + } + +Impact +====== + +With the new :php:`ModifyBlindedConfigurationOptionsEvent`, it is +now possible to modify global configuration options as well as site +configuration options, displayed in the :guilabel:`Configuration` module. + +The event might be triggered by more configuration providers in the future. + +.. index:: Backend, LocalConfiguration, PHP-API, ext:lowlevel diff --git a/Documentation/Changelog/12.2/Feature-99733-DragDropBetweenDifferentFoldersInFileList.rst b/Documentation/Changelog/12.2/Feature-99733-DragDropBetweenDifferentFoldersInFileList.rst new file mode 100644 index 0000000..fd727d3 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99733-DragDropBetweenDifferentFoldersInFileList.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-99733-1675025218: + +==================================================================== +Feature: #99733 - Drag + Drop between different folders in file list +==================================================================== + +See :issue:`99733` + +Description +=========== + +When working with files in the :guilabel:`File > Filelist` module, editors can +now select one or multiple files or folders, and move them to a different +location within the folder tree on the navigation area via drag and drop. + +Impact +====== + +TYPO3 editors no longer need to use the clipboard functionality to move files, +as drag and drop support is included natively. It acts similar to +what editors know from their operating system functionality. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/12.2/Feature-99746-NewPSR-14SlugRedirectChangeItemCreatedEvent.rst b/Documentation/Changelog/12.2/Feature-99746-NewPSR-14SlugRedirectChangeItemCreatedEvent.rst new file mode 100644 index 0000000..d23b59c --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99746-NewPSR-14SlugRedirectChangeItemCreatedEvent.rst @@ -0,0 +1,121 @@ +.. include:: /Includes.rst.txt + +.. _feature-99746-1675059434: + +=============================================================== +Feature: #99746 - New PSR-14 SlugRedirectChangeItemCreatedEvent +=============================================================== + +See :issue:`99746` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent` +has been added to TYPO3 Core. This event is fired in the +:php:`\TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItemFactory` and +allows extension authors to manage the redirect sources for which redirects +should be created. + +The event features the following methods: + +- :php:`getSlugRedirectChangeItem()`: Returns the current + :php:`\TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem` +- :php:`setSlugRedirectChangeItem()`: Can be used to set a new or changed + :php:`SlugRedirectChangeItem` + +TYPO3 already implements the :php:`\TYPO3\CMS\Redirects\EventListener\AddPlainSlugReplacementSource` +listener. It is used to add the plain slug value based source type, which provides the same +behaviour like before. Implementing this as a Core listener gives extension authors the ability to +remove the source added by :php:`AddPlainSlugReplacementSource`, when their listeners are +registered and executed afterwards. See the example below. + +It is required for custom source class implementations to implement the +:php:`\TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface`. Using the +interface allows to detect custom source class implementations automatically. +Additionally, this allows to transport custom information and data. + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/redirects/add-redirect-source' + after: 'redirects-add-plain-slug-replacement-source' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use MyVendor\MyExtension\Redirects\CustomSource; + use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent; + use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource; + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceCollection; + + final class MyEventListener { + public function __invoke(SlugRedirectChangeItemCreatedEvent $event): void + { + // Retrieve change item and sources + $changeItem = $event->getSlugRedirectChangeItem(); + $sources = $changeItem->getSourcesCollection()->all(); + + // remove plain slug replacement redirect source from sources + $sources = array_filter( + $sources, + fn ($source) => !($source instanceof PlainSlugReplacementRedirectSource) + ); + + // add custom source implementation + $sources[] = new CustomSource(); + + // replace sources collection + $changeItem = $changeItem->withSourcesCollection( + new RedirectSourceCollection(...array_values($sources)) + ); + + // Update changeItem in the event + $event->setSlugRedirectChangeItem($changeItem); + } + } + +Custom source implementation (example): + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/CustomSource.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface; + + final class CustomSource implements RedirectSourceInterface + { + public function getHost(): string + { + return '*'; + } + + public function getPath(): string + { + return '/some-path'; + } + + public function getTargetLinkParameters(): array + { + return []; + } + } + +Impact +====== + +With the new :php:`SlugRedirectChangeItemCreatedEvent`, it is possible to manage +the redirect sources for which redirects should be created. It furthermore allows +to influence existing Core functionality. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.2/Feature-99806-IntroduceGenericButtonComponent.rst b/Documentation/Changelog/12.2/Feature-99806-IntroduceGenericButtonComponent.rst new file mode 100644 index 0000000..90c50a5 --- /dev/null +++ b/Documentation/Changelog/12.2/Feature-99806-IntroduceGenericButtonComponent.rst @@ -0,0 +1,32 @@ +.. include:: /Includes.rst.txt + +.. _feature-99806-1675673144: + +=================================================== +Feature: #99806 - Introduce GenericButton component +=================================================== + +See :issue:`99806` + +Description +=========== + +A new component :php:`TYPO3\CMS\Backend\Template\Components\Buttons\GenericButton` +is introduced that allows to render any markup in the module menu bar. + +Example: + +.. code-block:: php + + $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $genericButton = GeneralUtility::makeInstance(GenericButton::class) + ->setTag('a') + ->setHref('#') + ->setLabel('Label') + ->setTitle('Title') + ->setIcon($this->iconFactory->getIcon('actions-heart')) + ->setAttributes(['data-value' => '123']); + $buttonBar->addButton($genericButton, ButtonBar::BUTTON_POSITION_RIGHT, 2); + + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.2/Important-99490-ProvideTagToAddJavaScriptModulesToImportmapInBackendForm.rst b/Documentation/Changelog/12.2/Important-99490-ProvideTagToAddJavaScriptModulesToImportmapInBackendForm.rst new file mode 100644 index 0000000..6e34f69 --- /dev/null +++ b/Documentation/Changelog/12.2/Important-99490-ProvideTagToAddJavaScriptModulesToImportmapInBackendForm.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _important-99490-1673358047: + +====================================================================================== +Important: #99490 - Provide tag to add JavaScript Modules to importmap in backend form +====================================================================================== + +See :issue:`99490` + +Description +=========== + +The JavaScript module import map is static and only generated and +loaded in the first request to a document. All possible future +modules requested in later Ajax calls need to be registered already +in the first initial request. + +We are adding a new tag `backend.form` that is used to identify +JavaScript modules that can be used within the backend forms. This +will ensure that the import maps are available for these modules +even if the element is not displayed directly. + +A typical use case for this is an `InlineRelationRecord` where the +CKEditor is not part of the main record but needs to be loaded for +the child record. + +Example Configuration/JavaScriptModules.php +------------------------------------------- + +.. code-block:: php + + <?php + + return [ + 'dependencies' => [ + 'backend', + ], + 'tags' => [ + 'backend.form', + ], + 'imports' => [ + '@typo3/rte-ckeditor/' + => 'EXT:rte_ckeditor/Resources/Public/JavaScript/', + '@typo3/ckeditor5-bundle.js' + => 'EXT:rte_ckeditor/Resources/Public/Contrib/ckeditor5-bundle.js', + ], + ]; + + +.. index:: Backend, FlexForm, JavaScript, ext:backend diff --git a/Documentation/Changelog/12.2/Important-99609-StreamlineFlagIcons.rst b/Documentation/Changelog/12.2/Important-99609-StreamlineFlagIcons.rst new file mode 100644 index 0000000..ec9c5ae --- /dev/null +++ b/Documentation/Changelog/12.2/Important-99609-StreamlineFlagIcons.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _important-99609-1674123952: + +========================================= +Important: #99609 - Streamline flag icons +========================================= + +See :issue:`99609` + +Description +=========== + +We streamlined the flag icons and make them easier to handle. +The Core provides a range of flag icons that are representing countries, +regions, movements, islands, and more. The flags are mostly used in +conjunction with languages. + +We agree that a flag does not represent a language, but having a visual +identifier attached to languages makes it easier for editors to +identify the language they want to edit or translate. + +New flags added in this patch will express that we understand both, +the issue and the need to differentiate languages. We chose simple +colored flags to achieve this. It still allows differentiation while +variants like de-DE and de-CH can be maintained and identified. + +New flags: black, blue, cyan, green, indigo, orange, pink, purple, red, +teal, white, yellow, rainbow. + +Flags of historic countries have been removed: + +- AN, Netherlands Antilles (until 2010) +- CS, State Union of Serbia and Montenegro (until 2006) + +Flags for language codes have been removed: + +- kl, Greenlandic +- mi, Māori + +Flags for country regions have been aligned: + +- Spain, Catalonia: catalonia -> es-ct +- Canada, Quebec: qc -> ca-qc + +Please adjust your site configuration if you are using one of +the removed or renamed flag icons. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.2/Important-99660-RemoveContentSectionFromNewRecordController.rst b/Documentation/Changelog/12.2/Important-99660-RemoveContentSectionFromNewRecordController.rst new file mode 100644 index 0000000..f8ebd22 --- /dev/null +++ b/Documentation/Changelog/12.2/Important-99660-RemoveContentSectionFromNewRecordController.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-99660-1674251294: + +============================================================== +Important: #99660 - Remove content area from new record wizard +============================================================== + +See :issue:`99660` + +Description +=========== + +The TYPO3 backend comes with a distinction between "content elements" and +other records: While content is managed using the specialized :guilabel:`Page` +module, the :guilabel:`List` module is the main management interface for +other types of records. + +Managing content elements from within the :guilabel:`List` module is not +a good choice for editors, the :guilabel:`Page` module should be used. + +To foster this separation, the :guilabel:`Create new record` view reachable +from within the :guilabel:`List` module no longer allows to add content +elements. As a side effect, this avoids wrong or invalid default values +of the :guilabel:`Column` (colPos) field. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.2/Index.rst b/Documentation/Changelog/12.2/Index.rst new file mode 100644 index 0000000..5c8b37d --- /dev/null +++ b/Documentation/Changelog/12.2/Index.rst @@ -0,0 +1,54 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-2: + +============ +12.2 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v12.0 release. + +.. attention:: + + After TYPO3 v12.0, only new functionality with a solid migration path + can be added on top, with aiming for as little as possible breaking changes + after the initial v12.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.3/Deprecation-100014-FunctionGetParameterFromUrlOfTypo3backendutilityModule.rst b/Documentation/Changelog/12.3/Deprecation-100014-FunctionGetParameterFromUrlOfTypo3backendutilityModule.rst new file mode 100644 index 0000000..d5a0f34 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100014-FunctionGetParameterFromUrlOfTypo3backendutilityModule.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100014-1677078784: + +========================================================================================== +Deprecation: #100014 - Function `getParameterFromUrl()` of `@typo3/backend/utility` module +========================================================================================== + +See :issue:`100014` + +Description +=========== + +The function :js:`getParameterFromUrl()` of the :js:`@typo3/backend/utility` +module was used to obtain a query string argument from an arbitrary URL. +Meanwhile, browsers received the `URLSearchParams API`_ that can be used +instead. + +Therefore, :js:`getParameterFromUrl()` has been marked as deprecated. + +Impact +====== + +Calling :js:`getParameterFromUrl()` will trigger a deprecation warning. + + +Affected installations +====================== + +All installations using third-party extensions relying on the deprecated code are +affected. + + +Migration +========= + +Migrate to the following snippet to get the same result: + +.. code-block:: javascript + + const paramValue = new URL(url, window.location.origin).searchParams.get(parameter); + +.. _URLSearchParams API: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams + +.. index:: JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-100033-TBE_STYLESStylesheetAndStylesheet2.rst b/Documentation/Changelog/12.3/Deprecation-100033-TBE_STYLESStylesheetAndStylesheet2.rst new file mode 100644 index 0000000..0497cf9 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100033-TBE_STYLESStylesheetAndStylesheet2.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100033-1677433329: + +============================================================ +Deprecation: #100033 - TBE_STYLES stylesheet and stylesheet2 +============================================================ + +See :issue:`100033` + +Description +=========== + +The usage of :php:`$GLOBALS['TBE_STYLES']['stylesheet']` and +:php:`$GLOBALS['TBE_STYLES']['stylesheet2']` to add custom CSS files +to the TYPO3 backend has been marked as deprecated in TYPO3 v12 and will be +removed in TYPO3 v13. + + +Impact +====== + +Using any of the following configuration declarations + +* :php:`$GLOBALS['TBE_STYLES']['stylesheet']` +* :php:`$GLOBALS['TBE_STYLES']['stylesheet2']` + +will trigger a PHP deprecation notice and will throw a fatal PHP error in +TYPO3 v13. + + +Affected installations +====================== + +The extension scanner will find extensions using + +* :php:`$GLOBALS['TBE_STYLES']['stylesheet']` +* :php:`$GLOBALS['TBE_STYLES']['stylesheet2']` + +as "weak" matches. + + +Migration +========= + +Extensions should use :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets']['my_extension']` +where :php:`'my_extension'` is the extension key. + +Example +------- + +.. code-block:: php + :caption: EXT:my_extension/ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets']['my_extension'] = 'EXT:my_extension/Resources/Public/Css'; + +In the example above, all CSS files in the configured directory will be loaded +in TYPO3 backend. + +.. index:: Backend, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-100047-DeprecatedConditionMatcherClasses.rst b/Documentation/Changelog/12.3/Deprecation-100047-DeprecatedConditionMatcherClasses.rst new file mode 100644 index 0000000..f81fd14 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100047-DeprecatedConditionMatcherClasses.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100047-1677607925: + +========================================================== +Deprecation: #100047 - Deprecated ConditionMatcher classes +========================================================== + +See :issue:`100047` + +Description +=========== + +The following classes have been marked as deprecated in TYPO3 v12 and will +be removed with v13: + +* :php:`\TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\ConditionMatcherInterface` +* :php:`\TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher` +* :php:`\TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher` +* :php:`\TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher` + + +Impact +====== + +The TYPO3 Core only uses these classes within the old TypoScript parser classes, +which have been :ref:`deprecated <deprecation-99120-1670428555>` as well. +Using the classes will trigger a deprecation level log entry. + + +Affected installations +====================== + +There was probably little need to implement new variants of the above classes as +the underlying :php:`ExpressionLanguage` construct has its own API to add new +variables and functions for this TypoScript condition related to Symfony expression +language usage. + + +Migration +========= + +No direct migration possible. These classes have been merged into the new +TypoScript parser approach, specifically for class +:php:`\TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionMatcherVisitor`. + +Adding TypoScript related expression language variables and functions should be +done using :php:`\TYPO3\CMS\Core\ExpressionLanguage\ProviderInterface`. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-100047-PageTsConfigAndUserTsConfigMustNotRelyOnRequest.rst b/Documentation/Changelog/12.3/Deprecation-100047-PageTsConfigAndUserTsConfigMustNotRelyOnRequest.rst new file mode 100644 index 0000000..c5ae69f --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100047-PageTsConfigAndUserTsConfigMustNotRelyOnRequest.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100047-1677608959: + +=============================================================================== +Deprecation: #100047 - Page TSconfig and user TSconfig must not rely on request +=============================================================================== + +See :issue:`100047` + +Description +=========== + +Using :typoscript:`request` and function :typoscript:`ip()` in page TSconfig or +user TSconfig conditions has been marked as deprecated in TYPO3 v12. Such conditions +will stop working in TYPO3 v13 and will always evaluate to false. + +Page TSconfig and user TSconfig should not rely on request related data: They should +not check for given arguments or similar: the main reason is that the Backend +:php:`DataHandler` makes heavy use of page TSconfig, but the DataHandler itself is +not request-aware. The DataHandler (the code logic that updates data in the database +in the backend) can be used and must work in a CLI context, so any page TSconfig that +depends on a given request is flawed by design since it will never act as expected +in a CLI context. + +To avoid further issues with the DataHandler in web and CLI contexts, +TSconfig-related conditions must no longer be request-aware. + + +Impact +====== + +Using request-related conditions in page TSconfig or user TSconfig will raise a +deprecation level warning in TYPO3 v12 and will always evaluate to false in +TYPO3 v13. + + +Affected installations +====================== + +There may be instances of page TSconfig using conditions using +request-related conditions. These need to look for different solutions +that achieve a similar goal. + + +Migration +========= + +Try to get rid of :typoscript:`ip()` or request related information in +page TSconfig conditions. + +A typical example is highlighting something when a developer is +using the live domain: + +.. code-block:: typoscript + + [request.getRequestHost() == 'development.my.site'] + mod.foo = bar + [end] + +Switch to the application context in such cases: + +.. code-block:: typoscript + + [applicationContext == "Development"] + mod.foo = bar + [end] + +There are similar alternatives for other use cases: You can not rely on given +GET / POST arguments anymore, but it should be possible to switch to +:typoscript:`backend.user.isAdmin` or similar conditions in most cases, or to +handle related switches within controller classes in PHP. + +Relying on request arguments for page TSconfig conditions is fiddly, +especially when using this for core related controllers: those are not considered +API and may change at anytime. Instead, needs should be dealt with explicitly using +toggles within controllers. + + +.. index:: TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-100053-GeneralUtility_GP.rst b/Documentation/Changelog/12.3/Deprecation-100053-GeneralUtility_GP.rst new file mode 100644 index 0000000..6585bf2 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100053-GeneralUtility_GP.rst @@ -0,0 +1,67 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100053-1677670333: + +============================================ +Deprecation: #100053 - GeneralUtility::_GP() +============================================ + +See :issue:`100053` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GP()` has +been marked as deprecated and should not be used any longer. + +Modern code should access GET and POST data from the PSR-7 :php:`ServerRequestInterface`, +and should avoid accessing superglobals :php:`$_GET` and :php:`$_POST` +directly. This also avoids future side-effects when using sub-requests. Some +:php:`GeneralUtility` related helper methods like :php:`_GP()` violate this, +using them is considered a technical debt. They are being phased out. + + +Impact +====== + +Calling the method from PHP code will log a PHP deprecation level entry, +the method will be removed with TYPO3 v13. + + +Affected installations +====================== + +TYPO3 installations with third-party extensions using :php:`GeneralUtility::_GP()` +are affected, typically in TYPO3 installations which +have been migrated to the latest TYPO3 Core versions and +haven't been adapted properly yet. + +The extension scanner will find usages with a strong match. + + +Migration +========= + +:php:`GeneralUtility::_GP()` is a helper method that retrieves +incoming HTTP `GET` query arguments and `POST` body parameters and returns the value. + +The same result can be achieved by retrieving arguments from the request object. +An instance of the PSR-7 :php:`ServerRequestInterface` is handed over to +controllers by TYPO3 Core's PSR-15 :php:`\TYPO3\CMS\Core\Http\RequestHandlerInterface` +and middleware implementations, and is available in various related scopes +like the frontend :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`. + +Typical code: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // Before + $value = GeneralUtility::_GP('tx_scheduler'); + + // After + $value = $request->getParsedBody()['tx_scheduler'] ?? $request->getQueryParams()['tx_scheduler'] ?? null; + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-100071-MagicRepositoryFindByMethods.rst b/Documentation/Changelog/12.3/Deprecation-100071-MagicRepositoryFindByMethods.rst new file mode 100644 index 0000000..13c442e --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100071-MagicRepositoryFindByMethods.rst @@ -0,0 +1,88 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100071-1677853787: + +======================================================== +Deprecation: #100071 - Magic repository findBy() methods +======================================================== + +See :issue:`100071` + +Description +=========== + +Extbase repositories come with a magic :php:`__call()` method to allow calling +the following methods without implementing them: + +- :php:`findBy[PropertyName]($propertyValue)` +- :php:`findOneBy[PropertyName]($propertyValue)` +- :php:`countBy[PropertyName]($propertyValue)` + +These have now been marked as deprecated, as they are "magic", meaning +that proper IDE support is not possible, and other PHP-related tool +functionality such as PhpStorm. + +In addition, it is not possible for Extbase repositories +to build their own magic method functionality as the logic is already +in use. + +Impact +====== + +As these methods are widely used in almost all Extbase-based extensions, +they are marked as deprecated in TYPO3 v12, but will only trigger a deprecation +notice in TYPO3 v13, as they will be removed in TYPO3 v14. + +This way, migration towards the new API methods can be made without +pressure. + + +Affected installations +====================== + +All installations with third-party extensions that use those magic methods. + + +Migration +========= + +A new set of methods without all the downsides have been added: + +- :php:`findBy(array $criteria, ...): QueryResultInterface` +- :php:`findOneBy(array $criteria, ...):object|null` +- :php:`count(array $criteria, ...): int` + +The naming of the methods follows those of `doctrine/orm` and only +:php:`count()` differs from the formerly :php:`countBy()`. While all magic +methods only allow for a single comparison (`propertyName` = `propertyValue`), +those methods allow for multiple comparisons, called constraints. + + +`findBy[PropertyName]($propertyValue)` can be replaced with a call to `findBy`: + +.. code-block:: php + + $this->blogRepository->findBy(['propertyName' => $propertyValue]); + + +`findOneBy[PropertyName]($propertyValue)` can be replaced with a call to `findOneBy`: + +.. code-block:: php + + $this->blogRepository->findOneBy(['propertyName' => $propertyValue]); + + +`countBy[PropertyName]($propertyValue)` can be replaced with a call to `count`: + +.. code-block:: php + + $this->blogRepository->count(['propertyName' => $propertyValue]); + +.. attention:: + + Please note that the (not-magic) methods `findByUid()` and `findByIdentifier()` did **not** + get deprecated or removed, and are still valid to be used. + Using these methods will fetch a given domain object by it's UID, ignoring possible storage + page settings - unlike `findBy([...])`, which does respect those settings. + +.. index:: PHP-API, NotScanned, ext:extbase \ No newline at end of file diff --git a/Documentation/Changelog/12.3/Deprecation-100232-TBE_STYLESSkinningFunctionality.rst b/Documentation/Changelog/12.3/Deprecation-100232-TBE_STYLESSkinningFunctionality.rst new file mode 100644 index 0000000..54e438a --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100232-TBE_STYLESSkinningFunctionality.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100232-1679344508: + +========================================================= +Deprecation: #100232 - $TBE_STYLES skinning functionality +========================================================= + +See :issue:`100232` + +Description +=========== + +The global configuration array :php:`$TBE_STYLES` has been deprecated in favor of a new +setting :php:`$TYPO3_CONF_VARS['BE']['stylesheets']`. Previously, before +TYPO3 v6.0, :php:`$TBE_STYLES` allowed for defining more styles within PHP instead +of using CSS. +However, now that CSS has become been much more powerful than 10 years ago, +it is time to change the logic and also consolidate TYPO3's internal configuration +settings. + +This deprecation is in order to be more flexible for styling purposes, as +the registration of custom stylesheets can now be handled on a per-project +basis. + +Extensions can use almost the same syntax, however registration is now done +in an extension's :file:`ext_localconf.php` to reduce loading times for +:file:`ext_tables.php` files. + + +Impact +====== + +Registration of backend styles via :php:`$GLOBALS['TBE_STYLES']['skins']` in +an extension's :file:`ext_tables.php` file will trigger a PHP +deprecation notice. + +Setting :php:`$GLOBALS['TBE_STYLES']['stylesheets']['admPanel']` will also +trigger a deprecation notice every time the Admin Panel is loaded in the +TYPO3 frontend. + + +Affected installations +====================== + +TYPO3 installations with custom styling in the TYPO3 backend or the Admin Panel +via :php:`$GLOBALS['TBE_STYLES']`. + + +Migration +========= + +Migrate to the new configuration setting :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets']` +which can be set per site or within an extension's :file:`ext_localconf.php`. + +For a custom stylesheet in the TYPO3 Admin Panel, it is recommended to use the +new AdminPanel Module API (available since TYPO3 v9 LTS) where custom CSS and +JavaScript files can be registered dynamically. + +.. index:: Backend, LocalConfiguration, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-100237-TypoScriptRelatedExceptions.rst b/Documentation/Changelog/12.3/Deprecation-100237-TypoScriptRelatedExceptions.rst new file mode 100644 index 0000000..e78ff72 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100237-TypoScriptRelatedExceptions.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100237-1679393509: + +==================================================== +Deprecation: #100237 - TypoScript-related exceptions +==================================================== + +See :issue:`100237` + +Description +=========== + +Two exception classes related to the TypoScript condition matching logic have been +marked as deprecated in TYPO3 v12 and will be removed in v13: + +* :php:`\TYPO3\CMS\Core\Exception\MissingTsfeException` +* :php:`\TYPO3\CMS\Core\Configuration\TypoScript\Exception\InvalidTypoScriptConditionException` + + +Impact +====== + +Both exceptions should have been marked :php:`@internal` within the core, but +were not. + +The exception :php:`\TYPO3\CMS\Core\Exception\MissingTsfeException` was an internal +communication class and was caught internally, the use case was solved in a more +simple way avoiding the exception. + +The exception :php:`\TYPO3\CMS\Core\Configuration\TypoScript\Exception\InvalidTypoScriptConditionException` +was related to conditions which triggered a warning within the symfony expression language. Those were +turned into this exception in TYPO3 v11. In TYPO3 v12, the original exception will bubble up, forcing +developers to fix the broken Symfony condition syntax. + + +Affected installations +====================== + +Third-party extensions most likely neither throw nor catch these exceptions, the +extension scanner will find possible usages. + + +Migration +========= + +No direct migration available. + +.. note:: + + Using the :typoscript:`getTSFE()` function, developers have to ensure + that "TSFE" is available before accessing its properties. A missing "TSFE", + e.g. in backend context, does no longer automatically evaluate the whole + condition to :php:`FALSE`. Instead, the function returns :php:`NULL`, + which can be checked using either :typoscript:`[getTSFE() && getTSFE().id == 42]` + or the null-safe operator :typoscript:`[getTSFE()?.id == 42]`. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-100247-VariousInterconnectedMethodsInEXTscheduler.rst b/Documentation/Changelog/12.3/Deprecation-100247-VariousInterconnectedMethodsInEXTscheduler.rst new file mode 100644 index 0000000..704c555 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100247-VariousInterconnectedMethodsInEXTscheduler.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100247-1679480707: + +====================================================================== +Deprecation: #100247 - Various interconnected methods in EXT:scheduler +====================================================================== + +See :issue:`100247` + +Description +=========== + +The scheduler system extension, responsible for executing long-running, timed +or recurring tasks, has been included since TYPO3 v4.3, but never received an +overhaul of its code base. + +Back then, the main :php:`\TYPO3\CMS\Scheduler\Scheduler` class and the +:php:`\TYPO3\CMS\Scheduler\Task\AbstractTask` class were the main API classes, all logic being included, +whereas :php:`AbstractTask` is the main class that all custom tasks within +extensions derive from. + +However, in the past 15 years TYPO3's code base has undergone a lot of API +design changes related to separation of concerns. In order to achieve this in +the scheduler extension, almost all access to the actual database access around +task retrieving and scheduling has been moved into its own +:php:`\TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository` class. + +For this reason, the following methods within the original API classes are now +either marked as deprecated or internal - not part of TYPO3's public API +anymore - as they have now been moved into the new repository class. + +* :php:`Scheduler->addTask()` +* :php:`Scheduler->log()` - marked as internal +* :php:`Scheduler->removeTask()` +* :php:`Scheduler->saveTask()` +* :php:`Scheduler->fetchTask()` +* :php:`Scheduler->fetchTaskRecord()` +* :php:`Scheduler->fetchTaskWithCondition()` +* :php:`Scheduler->isValidTaskObject()` +* :php:`Scheduler->log()` - marked as internal +* :php:`AbstractTask->isExecutionRunning()` +* :php:`AbstractTask->markExecution()` +* :php:`AbstractTask->unmarkExecution()` +* :php:`AbstractTask->unmarkAllExecutions()` +* :php:`AbstractTask->save()` - marked as internal +* :php:`AbstractTask->remove()` +* :php:`AbstractTask->setScheduler()` - marked as internal +* :php:`AbstractTask->unsetScheduler()` - marked as internal +* :php:`AbstractTask->registerSingleExecution()` - marked as internal +* :php:`AbstractTask->getExecution()` - marked as internal +* :php:`AbstractTask->setExecution()` - marked as internal +* :php:`AbstractTask->getNextDueExecution()` - marked as internal +* :php:`AbstractTask->areMultipleExecutionsAllowed()` - marked as internal +* :php:`AbstractTask->stop()` - marked as internal + + +Impact +====== + +Calling any of the deprecated methods will trigger a PHP warning. Using the +internal methods should be avoided and is not covered by the TYPO3 backwards +compatibility promise. + + +Affected installations +====================== + +TYPO3 installations with extensions that include custom scheduler tasks accessing +these methods. The Extension Scanner might be helpful to detect these usages. + + +Migration +========= + +Use the :php:`SchedulerTaskRepository` methods instead. + +.. index:: PHP-API, PartiallyScanned, ext:scheduler diff --git a/Documentation/Changelog/12.3/Deprecation-100278-PostLoginFailureProcessingHook.rst b/Documentation/Changelog/12.3/Deprecation-100278-PostLoginFailureProcessingHook.rst new file mode 100644 index 0000000..5baae28 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100278-PostLoginFailureProcessingHook.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100278-1679605129: + +====================================================== +Deprecation: #100278 - PostLoginFailureProcessing hook +====================================================== + +See :issue:`100278` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing']` +which can be used to handle custom notifications that a login in a frontend or +backend context failed, has been marked as deprecated. + + +Impact +====== + +If the hook is registered in a TYPO3 installation, a PHP :php:`E_USER_DEPRECATED` +error is triggered. + +The extension scanner also detects any usage of the deprecated interface as +a strong match, and the definition of the hook as a weak match. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +Migrate to the newly introduced PSR-14 event +:ref:`\\TYPO3\\CMS\\Core\\Authentication\\Event\\LoginAttemptFailedEvent <feature-100278-1679604666>`. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-100307-VariousHooksRelatedToAuthenticationUsers.rst b/Documentation/Changelog/12.3/Deprecation-100307-VariousHooksRelatedToAuthenticationUsers.rst new file mode 100644 index 0000000..662bb56 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-100307-VariousHooksRelatedToAuthenticationUsers.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100307-1679924603: + +==================================================================== +Deprecation: #100307 - Various hooks related to authentication users +==================================================================== + +See :issue:`100307` + +Description +=========== + +The following hooks have been marked as deprecated: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_pre_processing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_post_processing']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['backendUserLogin']` + +They can be used to add notifications or actions to a TYPO3 installation +after a frontend user or a backend user has actively logged +in or logged out. + +Impact +====== + +If one of the hooks is registered in a TYPO3 installation, +a PHP :php:`E_USER_DEPRECATED` error is triggered when a user logs +in or logs out. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using one of these hooks. + +The extension scanner detects any usage of the hooks. + + +Migration +========= + +Migrate to the newly introduced PSR-14 events: + +* :php:`\TYPO3\CMS\Core\Authentication\Event\BeforeUserLogoutEvent` +* :php:`\TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedOutEvent` +* :php:`\TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent` + +.. seealso:: + :ref:`feature-100307-1679924551` + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-83608-BackendUsersGetDefaultUploadFolderHook.rst b/Documentation/Changelog/12.3/Deprecation-83608-BackendUsersGetDefaultUploadFolderHook.rst new file mode 100644 index 0000000..23d8ad9 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-83608-BackendUsersGetDefaultUploadFolderHook.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-83608-1679521195: + +================================================================ +Deprecation: #83608 - Backend user's getDefaultUploadFolder hook +================================================================ + +See :issue:`83608` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['getDefaultUploadFolder']` has been marked +as deprecated in favor of a new PSR-14 event :php:`AfterDefaultUploadFolderWasResolvedEvent`. + +Along with the hook, the two methods: + +* :php:`BackendUserAuthentication->getDefaultUploadFolder()` +* :php:`BackendUserAuthentication->getDefaultUploadTemporaryFolder()` + +have been marked as internal, as they are not considered part of the public TYPO3 API anymore. + + +Impact +====== + +Using this hook will trigger a PHP deprecation notice every time the method +:php:`BackendUserAuthentication->getDefaultUploadFolder()` is called, + + +Affected installations +====================== + +TYPO3 installations with special functionality in extensions using these methods or the hook. + + +Migration +========= + +Migrate to the PSR-14 event :ref:`AfterDefaultUploadFolderWasResolvedEvent <feature-83608-1669634686>` +in your custom extensions. + +It is fired after various page TSconfig settings have been applied and allows for more +fine-grained control. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-97390-TypoScriptValidatorsForPasswordResetInExtfelogin.rst b/Documentation/Changelog/12.3/Deprecation-97390-TypoScriptValidatorsForPasswordResetInExtfelogin.rst new file mode 100644 index 0000000..3c91a68 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-97390-TypoScriptValidatorsForPasswordResetInExtfelogin.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-97390-1667657114: + +============================================================================= +Deprecation: #97390 - TypoScript validators for password reset in ext:felogin +============================================================================= + +See :issue:`97390` + +Description +=========== + +The TypoScript password validation configured through +:typoscript:`plugin.tx_felogin_login.settings.passwordValidators` has been +marked as deprecated. + +The TypoScript validators are used when the feature toggle +`security.usePasswordPolicyForFrontendUsers` is set to `false` (default for +existing TYPO3 installations). + +An upgrade wizard will ask the user during the TYPO3 upgrade +if `security.usePasswordPolicyForFrontendUsers` should be activated or, if +deprecated, TypoScript validators should be used. + +Impact +====== + +Validators configured in +:typoscript:`plugin.tx_felogin_login.settings.passwordValidators` will +trigger a deprecation log entry when a password reset is performed. + + +Affected installations +====================== + +TYPO3 installations using validators configured in +:typoscript:`plugin.tx_felogin_login.settings.passwordValidators`. + + +Migration +========= + +Special password requirements configured using custom validators in TypoScript +must be migrated to a custom password policy validator as described +in :ref:`#97388 <feature-97388>`. + +Before creating a custom password policy validator, it is recommended to +check if the :php:`CorePasswordValidator` used in the default password +policy suits current password requirements. + +.. index:: Frontend, NotScanned, ext:felogin diff --git a/Documentation/Changelog/12.3/Deprecation-99739-IndexedArrayKeysForTCAItems.rst b/Documentation/Changelog/12.3/Deprecation-99739-IndexedArrayKeysForTCAItems.rst new file mode 100644 index 0000000..62b06a0 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99739-IndexedArrayKeysForTCAItems.rst @@ -0,0 +1,279 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99739-1674869090: + +====================================================== +Deprecation: #99739 - Indexed array keys for TCA items +====================================================== + +See :issue:`99739` + +Description +=========== + +Using indexed array keys for the :php:`items` configuration of TCA types +:php:`select`, :php:`radio` and :php:`check` is now deprecated. + +Impact +====== + +Using indexed array keys for the :php:`items` configuration array items of TCA +types :php:`select`, :php:`radio` and :php:`check` will trigger a deprecation +level log entry. A TCA migration is in place. + +Affected installations +====================== + +All installations having custom extensions that make use of TCA types +:php:`select`, :php:`radio` or :php:`check` and define at least one entry in +the :php:`items` array. + +itemsProcFunc +_____________ + +The :php:`items` array handed over to custom :php:`itemsProcFunc` functions +contains the new object type :php:`TYPO3\CMS\Core\Schema\Struct\SelectionItem` +which acts as a compatibility layer for old style indexed keys. Accessing, +writing and reading items still work in the old way. Added items will be +automatically converted. For third-party extensions supporting both TYPO3 v11 +(or lower) and v12 it is recommended to keep using indexed keys. + +Migration +========= + +To migrate your TCA, change all indexed keys according to the following mapping +table: + ++--------+-------------+ +| Before | After | ++--------+-------------+ +| 0 | label | ++--------+-------------+ +| 1 | value | ++--------+-------------+ +| 2 | icon | ++--------+-------------+ +| 3 | group | ++--------+-------------+ +| 4 | description | ++--------+-------------+ + +Examples: + +.. code-block:: php + + // Before + 'select' => [ + 'label' => 'My select field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'Selection 1', + '1', + 'my-icon-identifier', + 'default', + ], + [ + 0 => 'Selection 2', + 1 => '2', + ], + ], + ], + ], + + // After + 'select' => [ + 'label' => 'My select field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'Selection 1', + 'value' => '1', + 'icon' => 'my-icon-identifier', + 'group' => 'default', + ], + [ + 'label' => 'Selection 2', + 'value' => '2', + ], + ], + ], + ], + + // Before + 'select_checkbox' => [ + 'label' => 'My select checkbox field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'items' => [ + [ + 'My select checkbox field', + '1', + 'my-icon-identifier', + 'default', + 'My custom description', + ], + [ + 0 => 'My select checkbox field', + 1 => 'value' => '2', + ], + ], + ], + ], + + // After + 'select_checkbox' => [ + 'label' => 'My select checkbox field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'items' => [ + [ + 'label' => 'My select checkbox field', + 'value' => '1', + 'icon' => 'my-icon-identifier', + 'group' => 'default', + 'description' => 'My custom description', + ], + [ + 'label' => 'My select checkbox field', + 'value' => '2', + ], + ], + ], + ], + + // Before + 'radio' => [ + 'label => 'My radio field', + 'config' => [ + 'type' => 'radio', + 'items' => [ + [ + 'Radio 1', + '1', + ], + [ + 0 => 'Radio 2', + 1 => '2', + ], + ], + ], + ], + + // After + 'radio' => [ + 'label => 'My radio field', + 'config' => [ + 'type' => 'radio', + 'items' => [ + [ + 'label' => 'Radio 1', + 'value' => '1', + ], + [ + 'label' => 'Radio 2', + 'value' => '2', + ], + ], + ], + ], + + // Before + 'check' => [ + 'config' => [ + 'type' => 'check', + 'items' => [ + ['Click on me'], + ], + ], + ], + + // After + 'check' => [ + 'config' => [ + 'type' => 'check', + 'items' => [ + ['label' => 'Click on me'], + ], + ], + ], + + // Before + 'check' => [ + 'config' => [ + 'type' => 'check', + 'items' => [ + [ + 'invertStateDisplay' => true, + 0 => 'Click on me', + ], + ], + ], + ], + + // After + 'check' => [ + 'config' => [ + 'type' => 'check', + 'items' => [ + [ + 'invertStateDisplay' => true, + 'label' => 'Click on me', + ], + ], + ], + ], + +Before: + +.. code-block:: xml + + <select_single_1> + <label>select_single_1 description</label> + <description>field description</description> + <config> + <type>select</type> + <renderType>selectSingle</renderType> + <items> + <numIndex index="0"> + <numIndex index="0">foo1</numIndex> + <numIndex index="1">foo1</numIndex> + </numIndex> + <numIndex index="1"> + <numIndex index="0">foo2</numIndex> + <numIndex index="1">foo2</numIndex> + </numIndex> + </items> + </config> + </select_single_1> + +After: + +.. code-block:: xml + + <select_single_1> + <label>select_single_1 description</label> + <description>field description</description> + <config> + <type>select</type> + <renderType>selectSingle</renderType> + <items> + <numIndex index="0"> + <label>foo1</label> + <value>foo1</value> + </numIndex> + <numIndex index="1"> + <label>foo2</label> + <value>foo2</value> + </numIndex> + </items> + </config> + </select_single_1> + +.. index:: TCA, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.3/Deprecation-99810-VersionNumberedFilenameOptionNowBoolean.rst b/Documentation/Changelog/12.3/Deprecation-99810-VersionNumberedFilenameOptionNowBoolean.rst new file mode 100644 index 0000000..b146191 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99810-VersionNumberedFilenameOptionNowBoolean.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99810-1675704638: + +================================================================== +Deprecation: #99810 - "versionNumberInFilename" option now boolean +================================================================== + +See :issue:`99810` + +Description +=========== + +The system-wide setting :php:`$TYPO3_CONF_VARS['FE']['versionNumberInFilename']` +was previously evaluated as a "string" value, having three possible options: + +* "" +* "querystring" +* "embed" + +Depending on the option, resources used in TYPO3's frontend templates, such as +JavaScript or CSS assets, had their "modification time" in either the +querystring (:samp:`myfile.js?1675703622`), or in the file name itself +:samp:`myfile.1675703622.js` - the "embed" option). The latter option required a +:file:`.htaccess` rule. + +This existing feature ("cachebusting") is especially important for proxy / CDN +setups. + +For the sake of simplicity, the option is now a boolean option - and behaves +similarly to the backend variant :php:`$TYPO3_CONF_VARS['BE']['versionNumberInFilename']`. + + +Impact +====== + +If the option is now set to "false", it behaves as "querystring" did before, setting +it to "true", the feature behaves exactly as "embed". The original empty option +is removed, so all assets within the TYPO3 frontend rendering always include +cachebusting, by default a querystring, which is fully backwards-compatible. + + +Affected installations +====================== + +TYPO3 installations that have actively set this option in +:file:`LocalConfiguration.php`, :file:`AdditionalConfiguration.php` or in an +extension :file:`ext_localconf.php`. + + +Migration +========= + +When updating TYPO3 and accessing the maintenance area, an explicitly set option +is automatically migrated. If this is not possible - for example, configuration in +:file:`AdditionalConfiguration.php` is set - the value is always migrated +on-the-fly when the setting is evaluated. + +.. index:: LocalConfiguration, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-99882-SiteLanguageTypo3LanguageSetting.rst b/Documentation/Changelog/12.3/Deprecation-99882-SiteLanguageTypo3LanguageSetting.rst new file mode 100644 index 0000000..071f37c --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99882-SiteLanguageTypo3LanguageSetting.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99882-1675873624: + +=========================================================== +Deprecation: #99882 - Site language "typo3Language" setting +=========================================================== + +See :issue:`99882` + +Description +=========== + +A language configuration defined for a site has had various settings, one of +them being :yaml:`typo3Language`. The setting is used to define the language key which +should be used for fetching the proper XLF file (such as :file:`de_AT.locallang.xlf`). + +Since TYPO3 v12 it is unnecessary to set this property in the site configuration +and it is removed from the backend UI. The information is now automatically +derived from the :yaml:`locale` setting of the site configuration. + +The previous value "default", which matched "en" as language key is now unnecessary +as "default" is now a synonym for "en". + +As a result, the amount of options in the user interface for integrators is +reduced. + + +Impact +====== + +An administrator cannot select a value for the :yaml:`typo3Language` setting anymore +via the TYPO3 backend. If a custom value is required, the site configuration +needs to be manually edited and the :yaml:`typo3Language` setting needs to be added. + +If this is the case, please file a bug report in order to give the TYPO3 +development team feedback on what use case is required. + +However, saving a site configuration via the TYPO3 backend will still +keep the :yaml:`typo3Language` setting so no values will be lost. + + +Affected installations +====================== + +TYPO3 installations created before TYPO3 v12.3. + + +Migration +========= + +No migration is needed as the explicit option is still evaluated. It is however +recommended to check if the setting is really necessary. + +Examples: + +#. If :yaml:`typo3Language: "default"` and :yaml:`locale: "en_US.UTF-8"`, the setting can be removed. +#. If :yaml:`typo3Language: "pt_BR"` and :yaml:`locale: "pt_BR.UTF-8"`, the setting can be removed. +#. If :yaml:`typo3Language: "de"` and :yaml:`locale: "de_AT.UTF-8"` , the setting can be removed, + plus the label files check for :file:`de_AT.locallang.xlf` and :file:`de.locallang.xlf` + as fallback when accessing a translated label. +#. If :yaml:`typo3Language: "pt_BR"` and :yaml:`locale: "de_DE.UTF-8"` it is likely + a misconfiguration in the setup, and should be analyzed if the custom value is really needed. + +.. index:: YAML, NotScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-99900-GeneralUtilityIntExplodeLimitParameter.rst b/Documentation/Changelog/12.3/Deprecation-99900-GeneralUtilityIntExplodeLimitParameter.rst new file mode 100644 index 0000000..192d591 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99900-GeneralUtilityIntExplodeLimitParameter.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99900-1676292952: + +====================================================================== +Deprecation: #99900 - $limit parameter of GeneralUtility::intExplode() +====================================================================== + +See :issue:`99900` + +Description +=========== + +The static method :php:`GeneralUtility::intExplode()` has a lesser known fourth +parameter :php:`$limit`. The reason it was added to the :php:`intExplode()` method +is purely historical, when it used to extend the :php:`trimExplode()` method. The +dependency was resolved, but the parameter stayed. As this method is supposed to +only return :php:`int` values in an array, the :php:`$limit` parameter is now +deprecated. + +Impact +====== + +Calling :php:`GeneralUtility::intExplode()` with the fourth parameter +:php:`$limit` will trigger a deprecation warning and will add an entry to the +deprecation log. + +Affected installations +====================== + +TYPO3 installations that call :php:`GeneralUtility::intExplode()` with the +fourth parameter :php:`$limit`. + +Migration +========= + +In the rare case that you are using the :php:`$limit` parameter you will need to +switch to PHP's native :php:`explode()` function, and then use +:php:`array_map()` to convert the resulting array to integers. If that's +impractical, you can simply copy the old :php:`intExplode` method to your own +code. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-99905-SiteLanguageIso-639-1Setting.rst b/Documentation/Changelog/12.3/Deprecation-99905-SiteLanguageIso-639-1Setting.rst new file mode 100644 index 0000000..9ffcb51 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99905-SiteLanguageIso-639-1Setting.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99905-1675963182: + +======================================================= +Deprecation: #99905 - Site language "iso-639-1" setting +======================================================= + +See :issue:`99905` + +Description +=========== + +A language configuration defined for a site has had various settings, one of +them being :yaml:`iso-639-1` (also known as "twoLetterIsoCode"). + +This setting was previously introduced to define the current ISO 639-1 code, which +was different from the :yaml:`locale` or the :yaml:`typo3Language` setting. However, +this information is now properly retrieved with the method: +:php:`SiteLanguage->getLocale()->getLanguageCode()`. + +Since TYPO3 v12 it is not necessary to set this property in the site configuration +anymore, and it has been removed from the backend UI. The information is now automatically +derived from the :yaml:`locale` setting of the site configuration. + +This property originally came from an option in TypoScript called +:typoscript:`config.sys_language_isocode` which in turn was created in favor of +the previous :sql:`sys_language` database table. The TYPO3 Core never evaluated this +setting properly before TYPO3 v9. + +As a result, the amount of options in the user interface for integrators is +reduced. + +The PHP method :php:`SiteLanguage->getTwoLetterIsoCode()` serves no purpose +anymore and is deprecated. + +This also affects the TypoScript :typoscript:`getData` property :typoscript:`siteLanguage:twoLetterIsoCode`, +and the TypoScript condition :typoscript:`[siteLanguage("twoLetterIsoCode")]`. + + +Impact +====== + +Using the TypoScript settings or the PHP method will trigger a PHP deprecation notice. + +An administrator cannot select a value for the :yaml:`iso-639-1` setting anymore +via the TYPO3 backend. However, saving a site configuration via the +TYPO3 backend will still keep the :yaml:`iso-639-1` setting so no information is lost. + + +Affected installations +====================== + +TYPO3 installations actively accessing this property via PHP or TypoScript. + + +Migration +========= + +No migration is needed as the explicit option is still evaluated. It is however +recommended to check if the setting is really necessary, and if the first part of the +:yaml:`locale` setting matches the :yaml:`iso-639-1` setting. If so, the line with +:yaml:`iso-639-1` can be removed. + +As for TypoScript, it is recommended to use :typoscript:`siteLanguage:locale:languageCode` +instead of :typoscript:`siteLanguage:twoLetterIsoCode`. + +.. index:: PHP-API, TypoScript, YAML, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/12.3/Deprecation-99908-SiteLanguageHreflangSetting.rst b/Documentation/Changelog/12.3/Deprecation-99908-SiteLanguageHreflangSetting.rst new file mode 100644 index 0000000..161c0c3 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99908-SiteLanguageHreflangSetting.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99908-1675976983: + +====================================================== +Deprecation: #99908 - Site language "hreflang" setting +====================================================== + +See :issue:`99908` + +Description +=========== + +A language configuration defined for a site has had various settings, one of +them being :yaml:`hreflang`. The setting is used to generate hreflang meta tags to +link to alternative language versions of a translated page, and to add the +:html:`lang` attribute to the :html:`<html>` tag of a frontend page in HTML format. + +Since TYPO3 v12 it is not necessary to set this property in the site configuration +anymore. The information is now automatically +derived from the :yaml:`locale` setting of the site configuration if not set +in the site configuration. + +This also affects the TypoScript :typoscript:`getData` property +:typoscript:`siteLanguage:hrefLang`, and the TypoScript condition +:typoscript:`[siteLanguage("hrefLang")]`. + + +Impact +====== + +Using the TypoScript settings or the PHP method will trigger a PHP deprecation +notice. + +An administrator cannot select a value for the :yaml:`hreflang` setting anymore +via the TYPO3 backend. However, when saving a site configuration via the +TYPO3 backend it will still keep the :yaml:`hreflang` setting so no information is lost. + + +Affected installations +====================== + +TYPO3 installations actively accessing this property via PHP or TypoScript. + + +Migration +========= + +No migration is needed as the explicit option is still evaluated. It is however +recommended to check if the setting is really necessary, and if the locale of +the site language in the :file:`config.yaml` matches the same value - even in a +different format (:yaml:`locale: "de_AT.UTF-8"`, :yaml:`hreflang: "de-AT"`) - the setting +:yaml:`hreflang` can be removed. + +Any calls to :php:`SiteLanguage->getHrefLang()` can be replaced by +:php:`SiteLanguage->getLocale()->getName()`. + +As for TypoScript, it is recommended to use :typoscript:`siteLanguage:locale:full` +instead of :typoscript:`siteLanguage:hrefLang`. + +.. index:: Frontend, PHP-API, TypoScript, YAML, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-99916-SiteLanguageDirectionSetting.rst b/Documentation/Changelog/12.3/Deprecation-99916-SiteLanguageDirectionSetting.rst new file mode 100644 index 0000000..f397dc0 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99916-SiteLanguageDirectionSetting.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99916-1676027922: + +======================================================= +Deprecation: #99916 - Site language "direction" setting +======================================================= + +See :issue:`99916` + +Description +=========== + +A language configuration defined for a site has had various settings, one of +them being :yaml:`direction`. The setting is used to add the :html:`dir` attribute to the +:html:`<html>` tag of a frontend page in HTML format, defining the direction of the +language. + +However, according to https://meta.wikimedia.org/wiki/Template:List_of_language_names_ordered_by_code +the list of languages that have a directionality of "right-to-left" +is fixed and does not need to be configured anymore. + +Since TYPO3 v12 it is not necessary to set this property in the site configuration +anymore, and has been removed from the backend UI. The information is now automatically +derived from the :yaml:`locale` setting of the site configuration. + +As a result, the amount of options in the user interface for integrators is +reduced. + +The PHP method :php:`SiteLanguage->getDirection()` serves no purpose anymore and +is deprecated. + + +Impact +====== + +Using the PHP method will trigger a PHP deprecation notice. + +An administrator can not select a value for the :yaml:`direction` setting anymore +via the TYPO3 backend. However, when saving a site configuration via the +TYPO3 backend it will still keep the :yaml:`direction` setting so no information is lost. + + +Affected installations +====================== + +TYPO3 installations actively accessing this property via PHP or TypoScript, and +mainly related to TYPO3 installations with languages that have a "right-to-left" +reading direction. + + +Migration +========= + +No migration is needed as the explicit option is still evaluated. It is however +not necessary in 99.99% of the use cases. If the locale of the site language in the +site's :file:`config.yaml` matches the natural direction of the language +(Arabic and direction = rtl), the setting :yaml:`direction` can be removed. + +Any calls to :php:`SiteLanguage->getDirection()` can be replaced by +:php:`SiteLanguage->getLocale()->isRightToLeftLanguageDirection() ? 'rtl' : 'ltr'`. + +The frontend output does not set :html:`ltr` in the :html:`<html>` tag anymore, as this is the default +for HTML documents (see https://www.w3.org/International/questions/qa-html-dir). + +.. index:: PHP-API, YAML, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Deprecation-99932-PageRendererEnableDebugMode.rst b/Documentation/Changelog/12.3/Deprecation-99932-PageRendererEnableDebugMode.rst new file mode 100644 index 0000000..b7fac50 --- /dev/null +++ b/Documentation/Changelog/12.3/Deprecation-99932-PageRendererEnableDebugMode.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99932-1676186779: + +================================================================ +Deprecation: #99932 - PageRenderer::removeLineBreaksFromTemplate +================================================================ + +See :issue:`99932` + +Description +=========== + +The following method has been marked as deprecated and will be removed +in TYPO3 v13: + +* :php:`\TYPO3\CMS\Core\Page\PageRenderer::enableDebugMode()` + +The method acts as as shortcut to quickly disable some functions in the backend +context to ease output inspection. However, the properties set by the +method are ignored in the backend context anyway, the method is obsolete. + +Impact +====== + +Using the method will raise a deprecation level log entry and will stop +working in TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that call the method are affected. + +The extension scanner reports usages as a weak match. + + +Migration +========= + +All calls to the deprecated messages should be removed from the codebase. + +.. index:: Backend, TCA, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.3/Feature-100027-CopyFilesAndFoldersWithinTheFileListModule.rst b/Documentation/Changelog/12.3/Feature-100027-CopyFilesAndFoldersWithinTheFileListModule.rst new file mode 100644 index 0000000..a164fa4 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100027-CopyFilesAndFoldersWithinTheFileListModule.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-100027-1677251094: + +======================================================================= +Feature: #100027 - Copy files and folders within the File > List module +======================================================================= + +See :issue:`100027` + +Description +=========== + +With TYPO3 v12.2, the feature to +:ref:`drag+drop files and folders <feature-99733-1675025218>` between the tree +structure was added. Now it is also possible to copy or move resources within +the actual file listing (tile view or list view), for example, into a different subfolder +by selecting them, and using the mouse to drop them on to a target folder. + +Impact +====== + +The :guilabel:`File > List` module is now fully usable with drag+drop between +the tree and within the listing itself. + +All features make it easier for editors to manage and organize the digital +assets used within TYPO3. + +.. index:: Backend, ext:filelist diff --git a/Documentation/Changelog/12.3/Feature-100071-IntroduceNon-magicRepositoryFindMethods.rst b/Documentation/Changelog/12.3/Feature-100071-IntroduceNon-magicRepositoryFindMethods.rst new file mode 100644 index 0000000..a4c549a --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100071-IntroduceNon-magicRepositoryFindMethods.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _feature-100071-1677853567: + +============================================================== +Feature: #100071 - Introduce non-magic repository find methods +============================================================== + +See :issue:`100071` + +Description +=========== + +Extbase repositories come with a magic :php:`__call()` method to allow calling +the following methods without implementing: + +- :php:`findBy[PropertyName]($propertyValue)` +- :php:`findOneBy[PropertyName]($propertyValue)` +- :php:`countBy[PropertyName]($propertyValue)` + +Magic methods are quite handy but they have a huge disadvantage. There is no +proper IDE support i.e. most IDEs show an error or at least a warning, +saying method :php:`findByAuthor()` does not exist. Also, type declarations are +impossible to use because with :php:`__call()` everything is :php:`mixed`. And +last but not least, static code analysis - like PHPStan - cannot properly +analyze those and give meaningful errors. + +Therefore, there is a new set of methods without all those downsides: + +- :php:`findBy(array $criteria, ...): QueryResultInterface` +- :php:`findOneBy(array $criteria, ...): object|null` +- :php:`count(array $criteria, ...): int` + +The naming of those methods follows those of `doctrine/orm` and only +:php:`count()` differs from the formerly :php:`countBy()`. While all magic +methods only allow for a single comparison (`propertyName` = `propertyValue`), +those methods allow for multiple comparisons, called constraints. + +Example: + +.. code-block:: php + + $this->blogRepository->findBy(['author' => 1, 'published' => true]); + +Impact +====== + +The new methods support a broader feature set, support IDEs, static code +analyzers and type declarations. + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst b/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst new file mode 100644 index 0000000..63f8311 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100088-NewTCATypeJson.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _feature-100088-1677965005: + +====================================== +Feature: #100088 - New TCA type "json" +====================================== + +See :issue:`100088` + +Description +=========== + +In our effort of introducing dedicated TCA types for special use cases, +a new TCA field type called :php:`json` has been added to TYPO3 Core. +Its main purpose is to simplify the TCA configuration when working with +fields, containing JSON data. It therefore :ref:`replaces <important-100088-1677950866>` +the previously introduced :php:`dbtype=json` of TCA type :php:`user`. + +Using the new type, TYPO3 automatically takes care of adding the corresponding +database column. + +The TCA type :php:`json` features the following column configuration: + +- :php:`behaviour`: :php:`allowLanguageSynchronization` +- :php:`cols` +- :php:`default` +- :php:`enableCodeEditor` +- :php:`fieldControl` +- :php:`fieldInformation` +- :php:`fieldWizard` +- :php:`placeholder` +- :php:`readOnly` +- :php:`required` +- :php:`rows` + +.. note:: + + In case :php:`enableCodeEditor` is set to :php:`true`, which is the default + and the system extension `t3editor` is installed and active, the JSON value + is rendered in the corresponding code editor. Otherwise it is rendered in a + standard `textarea` HTML element. + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`cols` +- :typoscript:`rows` +- :typoscript:`readOnly` + + + +Impact +====== + +It is now possible to use a dedicated TCA type for rendering of JSON fields. +Using the new TCA type, corresponding database columns are added automatically. + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst b/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst new file mode 100644 index 0000000..d32ef55 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _feature-100089-1677961107: + +================================================================ +Feature: #100089 - Introduce Doctrine DBAL v3 driver middlewares +================================================================ + +See :issue:`100089` + +Description +=========== + +Since v3, Doctrine DBAL supports adding custom driver middlewares. These +middlewares act as a decorator around the actual `Driver` component. +Subsequently, the `Connection`, `Statement` and `Result` components can be +decorated as well. These middlewares must implement the +:php:`\Doctrine\DBAL\Driver\Middleware` interface. +A common use case would be a middleware for implementing SQL logging capabilities. + +For more information on driver middlewares, +see https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/architecture.html. +Furthermore, you can look up the implementation of the +:php:`\TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware` in ext:adminpanel +as an example. + +Registering a new driver middleware +=================================== + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares']['adminpanel_loggingmiddleware'] + = \TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware::class; + +Impact +====== + +Using custom middlewares allows to enhance the functionality of Doctrine +components. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.3/Feature-100093-ShowPathToRecordLocationInGroupElements.rst b/Documentation/Changelog/12.3/Feature-100093-ShowPathToRecordLocationInGroupElements.rst new file mode 100644 index 0000000..36eb3ac --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100093-ShowPathToRecordLocationInGroupElements.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _feature-100093-1678091347: + +================================================================= +Feature: #100093 - Show path to record location in group elements +================================================================= + +See :issue:`100093` + +Description +=========== + +To ease the usage of `group` fields in the FormEngine, for example, like in the +"Insert records" content element, the record overview now shows the path to the +location where each assigned record is stored, respectively. + + +Impact +====== + +Elements of type `group` now show the path to the page where any assigned record +is stored in. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-100116-MakePSR-7RequestAccessibleForAuthenticationServices.rst b/Documentation/Changelog/12.3/Feature-100116-MakePSR-7RequestAccessibleForAuthenticationServices.rst new file mode 100644 index 0000000..bccb64c --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100116-MakePSR-7RequestAccessibleForAuthenticationServices.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _feature-100116-1678299307: + +============================================================================ +Feature: #100116 - Make PSR-7 request accessible for authentication services +============================================================================ + +See :issue:`100116` + +Description +=========== + +Authentication services can now access the PSR-7 request object via the +:php:`$authInfo` array. Previously, custom TYPO3 authentication services +did not have direct access to the object and therefore had to either +use PHP super globals or TYPO3's `GeneralUtility::getIndpEnv()` method. + +The following example shows how to retrieve the PSR-7 request in the +`initAuth()` method of a custom authentication service: + +.. code-block:: php + + public function initAuth($mode, $loginData, $authInfo, $pObj) + { + /** @var ServerRequestInterface $request */ + $request = $authInfo['request']; + + /** @var NormalizedParams $normalizedParams */ + $normalizedParams = $request->getAttribute('normalizedParams'); + $isHttps = $normalizedParams->isHttps(); + } + + +Impact +====== + +Custom TYPO3 authentication services can now directly access the PSR-7 +request object from the authentication process. It is available via the +:php:`request` key of the :php:`$authInfo` array, which is handed over +to the :php:`initAuth()` method. + +.. index:: ext:core diff --git a/Documentation/Changelog/12.3/Feature-100143-SchedulerCommandExecuteAndList.rst b/Documentation/Changelog/12.3/Feature-100143-SchedulerCommandExecuteAndList.rst new file mode 100644 index 0000000..4cb6f63 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100143-SchedulerCommandExecuteAndList.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _feature-100143-1678575248: + +================================================================== +Feature: #100143 - Add scheduler command to execute and list tasks +================================================================== + +See :issue:`100143` + +Description +=========== + +The CLI command :bash:`scheduler:run` of EXT:scheduler offers a way to run a +task using a cronjob. It also allows to run tasks if the UID of the task +is known. + +To make it more convenient to use the command, :bash:`scheduler:list` and +:bash:`scheduler:execute` were introduced. + +The :bash:`scheduler:list` command shows an overview of all available tasks or +a given group with an option to watch and reload the list every X seconds +(default every 1 second). + +Example: + +.. code-block:: bash + + # List all tasks in group 1 and group 2 and watch for changes every second. + vendor/bin/typo3 scheduler:list --group 1 --group 2 --watch + + # List all tasks without a group and watch for changes every 2 seconds. + vendor/bin/typo3 scheduler:list --group 0 --watch 2 + + # Same as above with shortcut parameter + vendor/bin/typo3 scheduler:list -g 0 -w 2 + + +The :bash:`scheduler:execute` command displays a list of groups and available +tasks for the selection. If a group is selected all tasks within this group are +executed. + +Example: + +.. code-block:: bash + + # Run alls tasks without a group and task 8 + vendor/bin/typo3 scheduler:execute --task g:0 --task 8 + + # Same as above with shortcut parameter + vendor/bin/typo3 scheduler:execute -t g:0 -t 8 + +Impact +====== + +The new commands :bash:`scheduler:list` and :bash:`scheduler:execute` enable +the user to manage and run tasks without leaving the terminal. + +.. index:: Backend, ext:scheduler diff --git a/Documentation/Changelog/12.3/Feature-100167-AdminPanelAddSQLAndMemoryInfosToToolbar.rst b/Documentation/Changelog/12.3/Feature-100167-AdminPanelAddSQLAndMemoryInfosToToolbar.rst new file mode 100644 index 0000000..dccba06 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100167-AdminPanelAddSQLAndMemoryInfosToToolbar.rst @@ -0,0 +1,26 @@ +.. include:: /Includes.rst.txt + +.. _feature-100167-1679005733: + +==================================================================== +Feature: #100167 - AdminPanel: Add SQL and memory metrics to toolbar +==================================================================== + +See :issue:`100167` + +Description +=========== + +This extends the AdminPanel toolbar with more metrics: + +* Peak memory usage +* Amount of SQL queries +* Time spent processing SQL queries + + +Impact +====== + +The AdminPanel toolbar now shows more information. + +.. index:: Frontend, ext:adminpanel diff --git a/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst b/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst new file mode 100644 index 0000000..e3cb837 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100171-IntroduceTCATypeUuid.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _feature-100171-1678869689: + +========================================== +Feature: #100171 - Introduce TCA type uuid +========================================== + +See :issue:`100171` + +Description +=========== + +In our effort of introducing dedicated TCA types for special use cases, +a new TCA field type called :php:`uuid` has been added to TYPO3 Core. +Its main purpose is to simplify the TCA configuration when working with +fields, containing a UUID. + +The TCA type :php:`uuid` features the following column configuration: + +- :php:`enableCopyToClipboard` +- :php:`fieldInformation` +- :php:`required`: Defaults to :php:`true` +- :php:`size` +- :php:`version` + +.. note:: + + In case :php:`enableCopyToClipboard` is set to :php:`true`, which is the + default, a button is rendered next to the input field, which allows to copy + the UUID to the clipboard of the operating system. + +.. note:: + + The :php:`version` option defines the UUID version to be used. Allowed + values are `4`, `6` or `7`. The default is `4`. For more information + about the different versions, have a look at the corresponding + `symfony documentation`_. + +The following column configuration can be overwritten by page TSconfig: + +- :typoscript:`size` +- :typoscript:`enableCopyToClipboard` + +An example configuration looks like the following: + +.. code-block:: php + + 'identifier' => [ + 'label' => 'My record identifier', + 'config' => [ + 'type' => 'uuid', + 'version' => 6, + ], + ], + +Impact +====== + +It is now possible to use a dedicated TCA type for rendering of a UUID field. +Using the new TCA type, corresponding database columns are added automatically. + +.. _symfony documentation: https://symfony.com/doc/current/components/uid.html#uuids + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst b/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst new file mode 100644 index 0000000..f94ed78 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100187-ICU-basedDateAndTimeFormatting.rst @@ -0,0 +1,97 @@ +.. include:: /Includes.rst.txt + +.. _feature-100187-1679001588: + +===================================================== +Feature: #100187 - ICU-based date and time formatting +===================================================== + +See :issue:`100187` + +Description +=========== + +TYPO3 now supports rendering date and time based on formats/patterns defined by +the International Components for Unicode standard (ICU). + +TYPO3 previously only supported rendering of dates based on the PHP-native +functions :php:`date()` and :php:`strftime()`. + +However, :php:`date()` can only format dates with English texts, such as +"December" as non-localized values, the C-based :php:`strftime()` function works +only with the locale defined in PHP and availability in the underlying operating +system. + +In addition, ICU-based date and time formatting is much more flexible in +rendering, as it ships with default patterns for date and time (namely +`FULL`, `LONG`, `MEDIUM` and `SHORT`) which are based on the given locale. + +This means, that when the locale `en-US` is given, the short date is rendered +as `mm/dd/yyyy` whereas `de-AT` uses the `dd.mm.yyyy` syntax automatically, +without having to define a custom pattern just by using the SHORT default +pattern. + +In addition, the patterns can be adjusted more fine-grained, and can easily +deal with time zones for output when DateTime objects are handed in. + +TYPO3 also adds prepared custom patterns: + +* `FULLDATE` (like `FULL`, but only the date information) +* `FULLTIME` (like `FULL`, but only the time information) +* `LONGDATE` (like `LONG`, but only the date information) +* `LONGTIME` (like `LONG`, but only the time information) +* `MEDIUMDATE` (like `MEDIUM`, but only the date information) +* `MEDIUMTIME` (like `MEDIUM`, but only the time information) +* `SHORTDATE` (like `SHORT`, but only the date information) +* `SHORTTIME` (like `SHORT`, but only the time information) + +See https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax +for more information on the patterns. + + +Impact +====== + +A new stdWrap feature called `formattedDate` is added, and the new formatting +can also be used in Fluid's :html:`<f:format.date>` ViewHelper. + +The locale is typically fetched from the locale of the site language (stdWrap or +ViewHelper), or the backend user's language (in backend context) for the +ViewHelper usages. + +Examples for stdWrap: + +.. code-block:: typoscript + + page.10 = TEXT + page.10.value = 1998-02-20 3:00:00 + # see all available options https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + page.10.formattedDate = FULL + # optional, if a different locale is wanted other than the Site Language's locale + page.10.formattedDate.locale = de-DE + +will result in "Freitag, 20. Februar 1998 um 03:00:00 Koordinierte Weltzeit". + +.. code-block:: typoscript + + page.10 = TEXT + page.10.value = -5 days + page.10.formattedDate = FULL + page.10.formattedDate.locale = fr-FR + +will result in "jeudi 9 mars 2023 à 21:40:49 temps universel coordonné". + +Examples for Fluid `<f:format.date>` ViewHelper: + +.. code-block:: html + + <f:format.date pattern="dd. MMMM yyyy" locale="de-DE">{date}</f:format.date> + +will result in "20. Februar 1998". + +As soon as the :html:`pattern` attribute is used, the :html:`format` attribute +is disregarded. + +Both new ViewHelper arguments are optional. + +.. index:: Fluid, PHP-API, TypoScript, ext:core diff --git a/Documentation/Changelog/12.3/Feature-100206-EnableListtileViewForResourcesInLinkbrowser.rst b/Documentation/Changelog/12.3/Feature-100206-EnableListtileViewForResourcesInLinkbrowser.rst new file mode 100644 index 0000000..7ce25a5 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100206-EnableListtileViewForResourcesInLinkbrowser.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-100206-1679299435: + +====================================================================== +Feature: #100206 - Enable list/tile view for resources in link browser +====================================================================== + +See :issue:`100206` + +Description +=========== + +With this change, we are rolling out the universal file-list +rendering for files and folders to the link browser. The link +browser implementation for files and folders is now part of the +filelist extension. + +The link browser now allows the user to choose the display type +of resources to match the personal preference between list and +tile rendering. + +When the user now edits a link for a folder, the entry point is +the parent folder of the selected element folder instead of +showing the contents of the selected resource. The user sees +the selected folder in the presented list, this behavior mimics +the handling of selected files. + + +Impact +====== + +The user is now presented a unified experience when handling +resources. The modern filelist rendering is now rolled out to +the link browser and now covers, the filelist module, element +browser and link browser. + +.. index:: Backend, FAL, RTE, ext:filelist diff --git a/Documentation/Changelog/12.3/Feature-100218-ImprovedTypoScriptAndPageTsConfigModules.rst b/Documentation/Changelog/12.3/Feature-100218-ImprovedTypoScriptAndPageTsConfigModules.rst new file mode 100644 index 0000000..dd08866 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100218-ImprovedTypoScriptAndPageTsConfigModules.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _feature-100218-1679312518: + +================================================================ +Feature: #100218 - Improved TypoScript and page TSconfig modules +================================================================ + +See :issue:`100218` + +Description +=========== + +TYPO3 v12 comes with a rewritten TypoScript syntax parser. +See :ref:`breaking-97816-1656350406` and :ref:`feature-97816-1656350667` +for more details on this. + +The new parser allowed us to refactor the related backend modules along the way: +While many of these have been done with earlier v12 releases already, v12.3 now +finishes the basic feature set of these new and refactored modules. + +This is a summary of these UI changes: + +Frontend TypoScript +------------------- + +* The well-known main module :guilabel:`Web > Template` has been renamed and moved, + and can be found as :guilabel:`Site Management > TypoScript`. + +* "TypoScript records overview": This submodule was more hidden in previous versions. + It gives an overview which page records have TypoScript template records. + +* "Constant Editor": This submodule is mainly kept as-is from previous versions. + +* "Edit TypoScript Record": This submodule was known as "Info / Modify" from previous + versions. Its main functionality is kept. + +* "Active TypoScript": This submodule was known as "TypoScript Object Browser" in + previous versions. The UI of this module received a major streamlining and gives + a better overview of the compiled TypoScript on a page: The module now shows + both "constants" and "setup" at the same time, gives more detail information, + and the tree is quicker to navigate. + +* "Included TypoScript": This submodule was known as "Template Analyzer" in + previous versions. Similar to "Active TypoScript", it shows "constants" and + "setup" at the same time. It allows to simulate the effect of conditions + to the include tree, and shows sub-includes from :typoscript:`@import` and + similar as nodes within the tree. A basic syntax scanner finds broken TypoScript + syntax snippets. + + +Page TSconfig +------------- + +* The previous submodule :guilabel:`Web > Info > Page TSconfig` has been heavily refactored + and can be found as new main module :guilabel:`Site Management > Page TSconfig`. + +* The new page TSconfig module is similar in its look and feel to the TypoScript + module. + +* "Page TSconfig Records": This submodule did not exist as such in previous versions + and gives an overview which page records in the system contain page TSconfig settings. + +* "Active Page TSconfig": This is similar to "Active TypoScript" from the "TypoScript" + module. It allows browsing current page TSconfig and allows simulating the effect + of conditions. + +* "Included page TSconfig": This is similar to the "Included TypoScript" from the + "TypoScript" module. It shows all source files and records that create the final + page TSconfig of a page. A basic syntax scanner finds broken syntax snippets. + +Impact +====== + +The refactored modules allow more fine grained analysis +of page TSconfig and TypoScript. + +.. index:: Backend, TSConfig, TypoScript, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-100232-LoadAdditionalStylesheetsInTYPO3Backend.rst b/Documentation/Changelog/12.3/Feature-100232-LoadAdditionalStylesheetsInTYPO3Backend.rst new file mode 100644 index 0000000..fd9c5cd --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100232-LoadAdditionalStylesheetsInTYPO3Backend.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _feature-100232-1679344020: + +=============================================================== +Feature: #100232 - Load additional stylesheets in TYPO3 backend +=============================================================== + +See :issue:`100232` + +Description +=========== + +It is now possible to load additional CSS files for the TYPO3 +backend interface via regular :php:`$TYPO3_CONF_VARS` settings in a +:file:`settings.php` file of a project (previously known as :file:`LocalConfiguration.php`) +file or in an extension's :file:`ext_localconf.php`. + +Previously this was done via the outdated :php:`$TBE_STYLES` +global array which has been deprecated. + + +Impact +====== + +By defining a specific stylesheet, a single CSS file or all CSS files +of a folder, extension authors can now modify the styling via: + +.. code-block:: php + :caption: EXT:my_extension/ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets'][my_extension] + = 'EXT:myextension/Resources/Public/Css/myfile.css'; + + $GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets'][my_extension] + = 'EXT:myextension/Resources/Public/Css/'; + +in their extension's :file:`ext_localconf.php` file. + +Site administrators can handle this in their :php:`settings.php` or :php:`additional.php` file. + +.. index:: LocalConfiguration, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-100278-PSR-14EventAfterFailedLoginsInBackendOrFrontendUsers.rst b/Documentation/Changelog/12.3/Feature-100278-PSR-14EventAfterFailedLoginsInBackendOrFrontendUsers.rst new file mode 100644 index 0000000..1cf619b --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100278-PSR-14EventAfterFailedLoginsInBackendOrFrontendUsers.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _feature-100278-1679604666: + +================================================================================ +Feature: #100278 - PSR-14 Event after failed logins in backend or frontend users +================================================================================ + +See :issue:`100278` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Authentication\Event\LoginAttemptFailedEvent` +has been introduced. The event allows to notify remote systems about failed logins. + +The event features the following methods: + +- :php:`isFrontendAttempt()`: Whether this was a login attempt from a frontend login form +- :php:`isBackendAttempt()`: Whether this was a login attempt in the backend +- :php:`getUser()`: Returns the :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` derivative in question +- :php:`getRequest()`: Returns the current PSR-7 request object +- :php:`getLoginData()`: The attempted login data without sensitive information + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Authentication\EventListener\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/login-attempt-failed' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Authentication/EventListener/MyEventListener.php + + namespace MyVendor\MyExtension\Authentication\EventListener; + + use TYPO3\CMS\Core\Authentication\Event\LoginAttemptFailedEvent; + + final class MyEventListener + { + public function __invoke(LoginAttemptFailedEvent $event): void + { + if ($event->getRequest()->getAttribute('normalizedParams')->getRemoteAddress() !== '198.51.100.42') { + // send an email because an external user login attempt failed + } + } + } + + +Impact +====== + +It is now possible to notify external loggers about failed login attempts +while having the full request. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.3/Feature-100284-AddCKEditorInspectorForBackendRTEForms.rst b/Documentation/Changelog/12.3/Feature-100284-AddCKEditorInspectorForBackendRTEForms.rst new file mode 100644 index 0000000..8980561 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100284-AddCKEditorInspectorForBackendRTEForms.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _feature-100284-1679681558: + +=============================================================== +Feature: #100284 - Add CKEditor Inspector for backend RTE forms +=============================================================== + +See :issue:`100284` + +Description +=========== + +This feature introduces the ability to show the CKEditor Inspector for backend RTE forms. + +With CKEditor 5 and the introduction of the intermediate CKEditor model, knowing +the internals is a requirement to build plugins. The best way to debug during the plugin +development is the `CKEditor Inspector <https://ckeditor.com/docs/ckeditor5/latest/framework/development-tools.html#ckeditor-5-inspector>`_. + +For regular pages, there is a simple bookmarklet that can be included to show +the Inspector, but in the TYPO3 backend the usage of frames does not allow this +option. Giving developers a config option in the RTE simplifies this process. + +The Inspector can be activated in two different ways: + +* By enabling :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['debug']` and + being in the `Development` context +* By setting the option :yaml:`editor.config.debug` to :yaml:`true` in your + CKEditor configuration + +Example for setting the CKEditor configuration: + +.. code-block:: yaml + + editor: + config: + debug: true + + +Impact +====== + +Being in the right context or enabling the given option, it is now possible +to debug CKEditor instances for plugin development in an easier way. + +.. index:: Backend, RTE, YAML, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.3/Feature-100293-NewContentObjectEXTBASEPLUGINInTypoScript.rst b/Documentation/Changelog/12.3/Feature-100293-NewContentObjectEXTBASEPLUGINInTypoScript.rst new file mode 100644 index 0000000..ef53f9b --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100293-NewContentObjectEXTBASEPLUGINInTypoScript.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-100293-1679673289: + +================================================================ +Feature: #100293 - New ContentObject EXTBASEPLUGIN in TypoScript +================================================================ + +See :issue:`100293` + +Description +=========== + +In order to lower the barrier for newcomers in the TYPO3 world, TYPO3 now has +a custom ContentObject in TypoScript called :typoscript:`EXTBASEPLUGIN`. + +Previously, TypoScript code for Extbase plugins looked like this: + +.. code-block:: typoscript + + page.10 = USER + page.10 { + userFunc = TYPO3\\CMS\\Extbase\\Core\\Bootstrap->run + extensionName = shop + pluginName = cart + } + +The new way, which Extbase plugin registration uses under the hood now, looks +like this: + +.. code-block:: typoscript + + page.10 = EXTBASEPLUGIN + page.10.extensionName = shop + page.10.pluginName = cart + +The old way still works, but it is recommended to use the :typoscript:`EXTBASEPLUGIN` +ContentObject, as the direct reference to a PHP class (Bootstrap) might be +optimized in future versions. + + +Impact +====== + +This change is an effort to distinguish between plugins and regular other +more static content. + +Extbase is the de-facto standard for plugins, which serve dynamic content by +custom PHP code divided in controllers and actions by extension developers. + +Regular other content can be written in pure TypoScript, such as ContentObjects +like FLUIDTEMPLATE, HMENU, COA or TEXT is used for other kind of renderings +in the frontend. + +.. index:: TypoScript, ext:extbase diff --git a/Documentation/Changelog/12.3/Feature-100294-AddPSR-14EventToEnrichPasswordValidationContextData.rst b/Documentation/Changelog/12.3/Feature-100294-AddPSR-14EventToEnrichPasswordValidationContextData.rst new file mode 100644 index 0000000..8102001 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100294-AddPSR-14EventToEnrichPasswordValidationContextData.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _feature-100294-1679766730: + +============================================================================= +Feature: #100294 - Add PSR-14 event to enrich password validation ContextData +============================================================================= + +See :issue:`100294` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\PasswordPolicy\Event\EnrichPasswordValidationContextDataEvent` +has been added, which allows extension authors to enrich the +:php:`\TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData` +DTO used in password policy validation. + +The PSR-14 event is dispatched in all classes, where a user password is +validated against the globally configured password policy. + +The event features the following methods: + +- :php:`getContextData()` returns the current :php:`ContextData` DTO +- :php:`getUserData()` returns an array with user data available from the + initiating class +- :php:`getInitiatingClass()` returns the class name, where the + :php:`ContextData` DTO is created + +The event can be used to enrich the :php:`ContextData` DTO with additional data +used in custom password policy validators. + +.. note:: + + The user data returned by :php:`getUserData()` will include user data + available from the initiating class only. Therefore, event listeners should + always consider the initiating class name when accessing data from + :php:`getUserData()`. If required user data is not available via + :php:`getUserData()`, it can possibly be retrieved by a custom database + query (e.g. data from user table in the password reset process by fetching + the user with the :php:`uid` given in :php:`getUserData()` array). + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\PasswordPolicy\EventListener\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/enrich-context-data' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/PasswordPolicy/EventListener/MyEventListener.php + + use TYPO3\CMS\Core\DataHandling\DataHandler; + use TYPO3\CMS\Core\PasswordPolicy\Event\EnrichPasswordValidationContextDataEvent; + + final class MyEventListener + { + public function __invoke(EnrichPasswordValidationContextDataEvent $event): void + { + if ($event->getInitiatingClass() === DataHandler::class) { + $event->getContextData()->setData('currentMiddleName', $event->getUserData()['middle_name'] ?? ''); + $event->getContextData()->setData('currentEmail', $event->getUserData()['email'] ?? ''); + } + } + } + + +Impact +====== + +With the new :php:`EnrichPasswordValidationContextDataEvent`, it is now +possible to enrich the :php:`ContextData` DTO used in password policy +validation with additional data. + +.. index:: ext:core diff --git a/Documentation/Changelog/12.3/Feature-100307-PSR-14EventsForUserLoginLogout.rst b/Documentation/Changelog/12.3/Feature-100307-PSR-14EventsForUserLoginLogout.rst new file mode 100644 index 0000000..6e27c49 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-100307-PSR-14EventsForUserLoginLogout.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _feature-100307-1679924551: + +======================================================== +Feature: #100307 - PSR-14 events for user login & logout +======================================================== + +See :issue:`100307` + +Description +=========== + +Three new PSR-14 events have been added: + +- :php:`\TYPO3\CMS\Core\Authentication\Event\BeforeUserLogoutEvent` +- :php:`\TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedOutEvent` +- :php:`\TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent` + +The purpose of these events is to trigger any kind of action when a user +has been successfully logged in or logged out. + +TYPO3 Core itself uses :php:`AfterUserLoggedInEvent` in the TYPO3 backend +to send an email to a user, if the login was successful. + +The event features the following methods: + +- :php:`getUser()`: Returns the :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication` derivative in question + +The PSR-14 event :php:`BeforeUserLogoutEvent` on top has the possibility +to bypass the regular logout process by TYPO3 (removing the cookie and +the user session) by calling :php:`$event->disableRegularLogoutProcess()` +in an event listener. + +The PSR-14 event :php:`AfterUserLoggedInEvent` contains the method +:php:`getRequest()` to return PSR-7 request object of the current request. + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Authentication\EventListener\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/after-user-logged-in' + +The corresponding event listener class for :php:`AfterUserLoggedInEvent`: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Authentication/EventListener/MyEventListener.php + + namespace MyVendor\MyExtension\Authentication\EventListener; + + use TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent; + + final class MyEventListener + { + public function __invoke(AfterUserLoggedInEvent $event): void + { + if ( + $event->getUser() instanceof BackendUserAuthentication + && $event->getUser()->isAdmin() + ) + { + // Do something like: Clear all caches after login + } + } + } + + +Impact +====== + +It is now possible to modify and adapt user functionality based on successful +login or active logout. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.3/Feature-19856-SetSpecialATagParamsForLinksToAccessRestrictedPages.rst b/Documentation/Changelog/12.3/Feature-19856-SetSpecialATagParamsForLinksToAccessRestrictedPages.rst new file mode 100644 index 0000000..60b19ab --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-19856-SetSpecialATagParamsForLinksToAccessRestrictedPages.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _feature-19856-1679091117: + +============================================================================= +Feature: #19856 - Set special ATagParams for links to access restricted pages +============================================================================= + +See :issue:`19856` + +Description +=========== + +A new TypoScript option is introduced which allows additional tag attributes to be set +to links of pages which are access restricted by frontend user group +restriction. Usually these links will not be generated, but it is possible to +link them to another page, for example, a special login page: + +.. code-block:: typoscript + + config.typolinkLinkAccessRestrictedPages = 13 + config.typolinkLinkAccessRestrictedPages_addParams = &originalPage=###PAGE_ID### + +The resulting link to an access-restricted page (e.g. `22`) looks like this: +:html:`<a href="/login?originalPage=22">My page</a>` + +The newly introduced option +:typoscript:`config.typolinkLinkAccessRestrictedPages.ATagParams` allows +custom attributes to be added to the current anchor tag. + +.. code-block:: typoscript + + config.typolinkLinkAccessRestrictedPages.ATagParams = class="restricted" + +This will result in +:html:`<a href="/login?originalPage=22" class="restricted">My page</a>`. + +When generating menus via HMENU, the new :typoscript:`ATagParams` option is +also available for custom settings: + +.. code-block:: typoscript + + page.10 = HMENU + page.10.showAccessRestrictedPages = 13 + page.10.showAccessRestrictedPages.ATagParams = class="access-restricted" + + +Impact +====== + +Allowing integrators to set custom :typoscript:`ATagParams` such as class attributes or +arbitrary data attributes to use client-side styling via CSS or JavaScript event +listeners to handle such links differently. + +.. index:: TypoScript, ext:frontend diff --git a/Documentation/Changelog/12.3/Feature-45039-CommandToCleanUpLocalProcessedFiles.rst b/Documentation/Changelog/12.3/Feature-45039-CommandToCleanUpLocalProcessedFiles.rst new file mode 100644 index 0000000..ef6c297 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-45039-CommandToCleanUpLocalProcessedFiles.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-45039-1674297405: + +=========================================================== +Feature: #45039 - Command to clean up local processed files +=========================================================== + +See :issue:`45039` + +Description +=========== + +It is now possible to set up a recurring scheduler task or execute a CLI command +to clean up locally processed files and their database records. + + +Impact +====== + +The command will delete :sql:`sys_file_processedfile` records with references to +non-existing files. Also, files in the configured temporary directory +(typically :file:`_processed_`) will be deleted if there are no references to them. + + +Example +======= + +Delete files and records with confirmation: + +.. code-block:: bash + + ./bin/typo3 cleanup:localprocessedfiles + +Delete files and records: + +.. code-block:: bash + + ./bin/typo3 cleanup:localprocessedfiles -f + +Only show which files and records would be deleted: + +.. code-block:: bash + + ./bin/typo3 cleanup:localprocessedfiles --dry-run -v + +Please note that the command currently only works for local drivers. + +.. index:: CLI, PHP-API, ext:lowlevel diff --git a/Documentation/Changelog/12.3/Feature-65020-ChangeButtonLabelsWithinTCATypefile.rst b/Documentation/Changelog/12.3/Feature-65020-ChangeButtonLabelsWithinTCATypefile.rst new file mode 100644 index 0000000..40fee2d --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-65020-ChangeButtonLabelsWithinTCATypefile.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _feature-65020-1679498591: + +=========================================================== +Feature: #65020 - Change button labels within TCA type=file +=========================================================== + +See :issue:`65020` + +Description +=========== + +When working with file references (:sql:`sys_file_reference` records) within FormEngine, +there are up to three buttons available: + +* "Create new relation" +* "Select & upload files" +* "Add media by URL" + +Whereas the first button text can be changed via TCA on a per-field basis via +:php:`[config][appearance][createNewRelationLinkTitle] = 'LLL:my_extension/...';` +the two other label fields are hard-coded. It is especially useful to override such a label +when only a certain type of media is required (for example, just images) or online media of type YouTube. + +It is now possible to do so by using two new TCA configuration settings for TCA type=file + +* :php:`[config][appearance][uploadFilesLinkTitle]` +* :php:`[config][appearance][addMediaLinkTitle]` + + +Impact +====== + +An extension author can now completely modify the label texts of all buttons. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-83608-PSR-14EventToModifyResolvedDefaultUploadFolder.rst b/Documentation/Changelog/12.3/Feature-83608-PSR-14EventToModifyResolvedDefaultUploadFolder.rst new file mode 100644 index 0000000..143c896 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-83608-PSR-14EventToModifyResolvedDefaultUploadFolder.rst @@ -0,0 +1,65 @@ +.. include:: /Includes.rst.txt + +.. _feature-83608-1669634686: + +======================================================================= +Feature: #83608 - PSR-14 event to modify resolved default upload folder +======================================================================= + +See :issue:`83608` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Core\Resource\Event\AfterDefaultUploadFolderWasResolvedEvent` +has been added, which allows the default upload folder to be modified after it has +been resolved for the current page or user. + +The new event can be used as a better alternative to the +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['getDefaultUploadFolder']` +hook, serving the same purpose. + +The event features the following methods: + +- :php:`getUploadFolder()` returns the currently resolved :php:`$uploadFolder` +- :php:`setUploadFolder()` sets a new upload folder +- :php:`getPid()` returns the PID of the record we fetch the upload folder for +- :php:`getTable()` returns the table name of the record we fetch the upload folder for +- :php:`getFieldName()` returns the field name of the record we fetch the upload folder for + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Resource\EventListener\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/after-default-upload-folder-was-resolved-event-listener' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Resources/EventListener/MyEventListener.php + + namespace MyVendor\MyExtension\Resource\EventListener; + + use TYPO3\CMS\Core\Resource\Event\AfterDefaultUploadFolderWasResolvedEvent; + + final class MyEventListener + { + public function __invoke(AfterDefaultUploadFolderWasResolvedEvent $event): void + { + $event->setUploadFolder($event->getUploadFolder()->getStorage()->getFolder('/')); + } + } + + +Impact +====== + +As resolving the event was moved from :php:`BackendUserAuthentication` to its own +:php:`DefaultUploadFolderResolver` class, this event is now the preferred way +of modifying the default upload folder. + +.. index:: Backend, PHP-API, ext:core diff --git a/Documentation/Changelog/12.3/Feature-83608-PageTSconfigSettingOptionsdefaultUploadFolderAdded.rst b/Documentation/Changelog/12.3/Feature-83608-PageTSconfigSettingOptionsdefaultUploadFolderAdded.rst new file mode 100644 index 0000000..06132e5 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-83608-PageTSconfigSettingOptionsdefaultUploadFolderAdded.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-83608-1668162306: + +=========================================================================== +Feature: #83608 - Page TSconfig setting "options.defaultUploadFolder" added +=========================================================================== + +See :issue:`83608` + +Description +=========== + +A new page TSconfig option :typoscript:`options.defaultUploadFolder` is added. + + +Impact +====== + +Identical to the user TSconfig setting :typoscript:`options.defaultUploadFolder`, +this allows default upload folder per page to be set. + +If specified and the given folder exists, this setting will override the value +defined in user TSconfig. + +Example +------- + +.. code-block:: typoscript + + # Set default upload folder to "fileadmin/page_upload" on PID 1 + [page["uid"] == 1] + options.defaultUploadFolder = 1:/page_upload/ + [end] + +.. index:: TSConfig, ext:core diff --git a/Documentation/Changelog/12.3/Feature-84594-AdditionalParametersToEmailLinks.rst b/Documentation/Changelog/12.3/Feature-84594-AdditionalParametersToEmailLinks.rst new file mode 100644 index 0000000..484b0a3 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-84594-AdditionalParametersToEmailLinks.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _feature-84594-1674211080: + +====================================================== +Feature: #84594 - Additional parameters to email links +====================================================== + +See :issue:`84594` + +Description +=========== + +Editors in TYPO3 now have more possibilities to set options when +creating a link to a specific email address, in accordance with the "mailto:" +protocol. + +This way, editors can now pre-fill the fields "subject", "CC", "BCC" +and "body" in the TYPO3 backend when creating a link to an email +address, which are then percent-encoded to the actual email link. + +In addition, the `<f:link.email>` ViewHelper has the same additional +attributes as well: + +.. code-block:: html + + <f:link.email + email="foo@bar.tld" + subject="Check out this website" + cc="foo@example.com" + bcc="bar@example.com" + > + some custom content + </f:link.email> + +All of the properties and the link fields are optional. + +For custom email links, it is now also possible to restrict the additional +options via TCA: + +Example configuration +--------------------- + +.. code-block:: php + + 'header_link' => [ + 'label' => 'Link', + 'config' => [ + 'type' => 'link', + 'allowedTypes' => ['email'], + 'size' => 50, + 'appearance' => [ + // new options are "body", "cc", "bcc" and "subject" + 'allowedOptions' => ['body', 'cc'], + ], + ], + ], + +Impact +====== + +Editors now have more flexibility when creating links to emails in the +TYPO3 backend. + +Integrators have more flexibility when creating links within Fluid +templates. + +.. index:: Backend diff --git a/Documentation/Changelog/12.3/Feature-86880-EnablePasswordViewAtBackendLogin.rst b/Documentation/Changelog/12.3/Feature-86880-EnablePasswordViewAtBackendLogin.rst new file mode 100644 index 0000000..ba9e1b9 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-86880-EnablePasswordViewAtBackendLogin.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-86880-1659742357: + +======================================================= +Feature: #86880 - Enable password view at backend login +======================================================= + +See :issue:`86880` + +Description +=========== + +On clicking, the TYPO3 backend login now displays an additional button to reveal the user's +password, once something has been typed in the password field. + +Impact +====== + +A user who is about to log in to the backend is now able to reveal the typed +password. Once the password field is cleared, the visibility mode automatically +switches back to its default to avoid revealing sensitive data by accident. + +.. warning:: + Revealing login credentials is always a security risk. Please use this + feature with caution when nobody can watch your input, either remotely or by + looking over your shoulders! + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-94499-ImplementAddPageTypeZeroSourceEventListener.rst b/Documentation/Changelog/12.3/Feature-94499-ImplementAddPageTypeZeroSourceEventListener.rst new file mode 100644 index 0000000..d32fbde --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-94499-ImplementAddPageTypeZeroSourceEventListener.rst @@ -0,0 +1,136 @@ +.. include:: /Includes.rst.txt + +.. _feature-94499-1675615684: + +================================================================ +Feature: #94499 - Implement AddPageTypeZeroSource event listener +================================================================ + +See :issue:`94499` + +Description +=========== + +A new event listener for :ref:`\\TYPO3\\CMS\\Redirects\\Event\\SlugRedirectChangeItemCreatedEvent <feature-99746-1675059434>` +is introduced, which creates a :ref:`\\TYPO3\\CMS\\Redirects\\RedirectUpdate\\PageTypeSource <feature-94499-1675615570>` for a page +before the slug has been changed. The full URI is built to fill the `source_host` +and `source_path`, which takes configured `RouteEnhancers` and `RouteDecorators` +into account, for example, the `PageType route decorator`. + +.. note:: + + If `source_host` and `source_path` lead to the same outcome for page type 0 + using full URI building, like the :php:`\TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementSource`, the + :php:`PlainSlugReplacementSource` is replaced with the :php:`PageTypeSource`. + +It is not possible to configure page types for which sources should be added. If +you need to do so, read :ref:`additional PageTypeSource auto-create redirect source type <feature-94499-1675615570>` +which provides an example of how to implement custom event listeners based on +:php:`PageTypeSource`. + +If :php:`PageTypeSource` for page type `0` results in a different +source, the :php:`PlainSlugReplacementSource` is not removed to keep the original +behaviour, which some instances may rely on. + +This behaviour can be modified by adding an event listener for +:ref:`SlugRedirectChangeItemCreatedEvent <feature-99746-1675059434>` + +Remove plain slug source if page type 0 differs: +------------------------------------------------ + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyExtension\MyPackage\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/custom-page-type-redirect' + # Registering after core listener is important, otherwise we would + # not know if there is a PageType source for page type 0 + after: 'redirects-add-page-type-zero-source' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_package/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent; + use TYPO3\CMS\Redirects\RedirectUpdate\PageTypeSource; + use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource; + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceCollection; + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface; + + final class MyEventListener + { + public function __invoke( + SlugRedirectChangeItemCreatedEvent $event + ): void { + $changeItem = $event->getSlugRedirectChangeItem(); + $sources = $changeItem->getSourcesCollection()->all(); + $pageTypeZeroSource = $this->getPageTypeZeroSource( + ...array_values($sources) + ); + if ($pageTypeZeroSource === null) { + // nothing we can do - no page type 0 source found + return; + } + + // Remove plain slug replacement redirect source from sources. We + // already know, that if it is there it differs from the page type + // 0 source, therefor it is safe to simply remove it by class check. + $sources = array_filter( + $sources, + static fn ($source) => !($source instanceof PlainSlugReplacementRedirectSource) + ); + + // update sources + $changeItem = $changeItem->withSourcesCollection( + new RedirectSourceCollection( + ...array_values($sources) + ) + ); + + // update change item with updated sources + $event->setSlugRedirectChangeItem($changeItem); + } + + private function getPageTypeZeroSource( + RedirectSourceInterface ...$sources + ): ?PageTypeSource { + foreach ($sources as $source) { + if ($source instanceof PageTypeSource + && $source->getPageType() === 0 + ) { + return $source; + } + } + return null; + } + } + +Impact +====== + +An additional redirect source is automatically added if a `PageType suffix` +is configured in the :php:`SiteConfiguration` for page type `0`. In that case +two redirects are created, one for the plain slug change and one with the suffix +in the `source_path`. That way it does not break instances relying on the +fact that plain slug based redirects are created. + +.. note:: + + This behaviour can be modified by adding an event listener for + :ref:`SlugRedirectChangeItemCreatedEvent <feature-99746-1675059434>`. + It can check if both variants are in the source collection and remove the + :php:`PlainSlugReplacementSource`, as found in the example above. + +.. todo: + + Add link to main documentation or EXT:redirects once this contains more examples for the new events. + The documentation will later be modified to include more examples. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-94499-ProvideAdditionalPageTypeSourceAuto-createRedirectSourceType.rst b/Documentation/Changelog/12.3/Feature-94499-ProvideAdditionalPageTypeSourceAuto-createRedirectSourceType.rst new file mode 100644 index 0000000..7675d5b --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-94499-ProvideAdditionalPageTypeSourceAuto-createRedirectSourceType.rst @@ -0,0 +1,214 @@ +.. include:: /Includes.rst.txt + +.. _feature-94499-1675615570: + +====================================================================================== +Feature: #94499 - Provide additional `PageTypeSource` auto-create redirect source type +====================================================================================== + +See :issue:`94499` + +Description +=========== + +A new source type implementation based on :php:`\TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface` +is added, providing the page type number as an additional value. The main use case +for this source type is to provide additional source types where the source host +and path are taken from a fully built URI before the page slug change occurred for +a specific page type. That avoids the need for extension authors to implement a +custom source type for the same task, and instead provides a custom event +listener to build sources for non-zero page types. Sources can be added by +implementing an event listener for +:ref:`\\TYPO3\\CMS\\Redirects\\Event\\SlugRedirectChangeItemCreatedEvent <feature-99746-1675059434>`. + +.. note:: + + TYPO3 Core implements a listener to add a :php:`PageTypeSource` for page + type `0` with :ref:`AddPageTypeZeroSource Event Listener <feature-94499-1675615684>`. + This source class can be re-used, if page type related sources should be added + for non-zero page types. + +This class features the following methods: + +- :php:`getHost()`: Returns the source host for the redirect +- :php:`getPath()`: Returns the source path for the redirect +- :php:`getPageType()`: Returns the page type used to provide the host/path +- :php:`getTargetLinkParameters()`: Returns the link parameters which should + be used to create the target based on `t3://` syntax + +Values can be set only by the constructor. + +Example: +-------- + +Registration of the event in your extension's :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/custom-page-type-redirect' + after: 'redirects-add-page-type-zero-source' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Core\Context\Context; + use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException; + use TYPO3\CMS\Core\Routing\RouterInterface; + use TYPO3\CMS\Core\Routing\UnableToLinkToPageException; + use TYPO3\CMS\Core\Site\Entity\Site; + use TYPO3\CMS\Core\Site\Entity\SiteLanguage; + use TYPO3\CMS\Core\Utility\GeneralUtility; + use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent; + use TYPO3\CMS\Redirects\RedirectUpdate\PageTypeSource; + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceCollection; + use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface; + + final class MyEventListener + { + protected array $customPageTypes = [ 1234, 169999 ]; + + public function __invoke( + SlugRedirectChangeItemCreatedEvent $event + ): void { + $changeItem = $event->getSlugRedirectChangeItem(); + $sources = $changeItem->getSourcesCollection()->all(); + + foreach ($this->customPageTypes as $pageType) { + try { + $pageTypeSource = $this->createPageTypeSource( + $changeItem->getPageId(), + $pageType, + $changeItem->getSite(), + $changeItem->getSiteLanguage(), + ); + if ($pageTypeSource === null) { + continue; + } + } catch (UnableToLinkToPageException) { + // Could not properly link to page. Continue to next page type + continue; + } + + if ($this->isDuplicate($pageTypeSource, ...$sources)) { + // not adding duplicate, + continue; + } + + $sources[] = $pageTypeSource; + } + + // update sources + $changeItem = $changeItem->withSourcesCollection( + new RedirectSourceCollection( + ...array_values($sources) + ) + ); + + // update change item with updated sources + $event->setSlugRedirectChangeItem($changeItem); + } + + private function isDuplicate( + PageTypeSource $pageTypeSource, + RedirectSourceInterface ...$sources + ): bool { + foreach ($sources as $existingSource) { + $existingHost = $existingSource->getHost(); + $pageTypeSourceHost = $pageTypeSource->getHost(); + $existingPath = rtrim($existingSource->getPath(), '/'); + $pageTypeSourcePath = rtrim($pageTypeSource->getPath(), '/'); + if ($existingSource instanceof PageTypeSource + && $existingHost === $pageTypeSourceHost + && $existingPath === $pageTypeSourcePath + ) { + // we do not check for the type, as that is irrelevant. Same + // host+path tuple would lead to duplicated redirects if + // type differs. + return true; + } + } + return false; + } + + private function createPageTypeSource( + int $pageUid, + int $pageType, + Site $site, + SiteLanguage $siteLanguage + ): ?PageTypeSource { + if ($pageType === 0) { + // pageType 0 is handled by \TYPO3\CMS\Redirects\EventListener\AddPageTypeZeroSource + return null; + } + + try { + $context = $this->getAdjustedContext(); + $uri = $site->getRouter($context)->generateUri( + $pageUid, + [ + '_language' => $siteLanguage, + 'type' => $pageType, + ], + '', + RouterInterface::ABSOLUTE_URL + ); + return new PageTypeSource( + $uri->getHost() ?: '*', + $uri->getPath(), + $pageType, + [ + 'type' => $pageType, + ], + ); + } catch (\InvalidArgumentException | InvalidRouteArgumentsException $e) { + throw new UnableToLinkToPageException( + sprintf( + 'The link to the page with ID "%d" and type "%d" could not be generated: %s', + $pageUid, + $pageType, + $e->getMessage() + ), + 1675618235, + $e + ); + } + } + + /** + * Returns the adjusted current context with modified visibility settings + * to build source url for hidden or scheduled pages. + */ + private function getAdjustedContext(): Context + { + $adjustedVisibility = new VisibilityAspect( + true, + true, + false, + true, + ); + $originalContext = GeneralUtility::makeInstance(Context::class); + $context = clone $originalContext; + $context->setAspect('visibility', $adjustedVisibility); + return $context; + } + } + + +Impact +====== + +The new :php:`PageTypeSource` can be used to provide additional sources, for example, +based on custom page types using full URI building, which would take +configured PageTypeSuffix decorators into account. For page type `0` (default), the Core +implements an event listener which adds the source based on this source class for +page type `0` with :ref:`AddPageTypeZeroSource event listener <feature-94499-1675615684>`. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-97389-AddPasswordPolicyValidationForTCATypepassword.rst b/Documentation/Changelog/12.3/Feature-97389-AddPasswordPolicyValidationForTCATypepassword.rst new file mode 100644 index 0000000..97841d6 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-97389-AddPasswordPolicyValidationForTCATypepassword.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _feature-97389-1673972552: + +====================================================================== +Feature: #97389 - Add password policy validation for TCA type=password +====================================================================== + +See :issue:`97389` + +Description +=========== + +It is now possible to assign a password policy to TCA fields of type +`password`. For configured fields, the password policy validator will be used +in `DataHandler` to ensure that the new password complies with the configured +password policy. + +Password policy requirements are shown below the password field when the focus +is changed to the password field. + +The TCA field `password` for tables :sql:`be_users` and :sql:`fe_users` uses +now by default the password policy configured in +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy']` (fe_users) or +:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy']` (be_users). + +Example configuration +--------------------- + +.. code-block:: php + + 'password_field' => [ + 'label' => 'Password', + 'config' => [ + 'type' => 'password', + 'passwordPolicy' => 'default', + ], + ], + +This example will use the password policy `default` for the field. + +Impact +====== + +For TYPO3 frontend and backend users, the global password policy is used. A +new password is not saved if it does not comply with the password policy. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.3/Feature-97390-UsePasswordPolicyForPasswordResetInExtfelogin.rst b/Documentation/Changelog/12.3/Feature-97390-UsePasswordPolicyForPasswordResetInExtfelogin.rst new file mode 100644 index 0000000..4c8b971 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-97390-UsePasswordPolicyForPasswordResetInExtfelogin.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _feature-97390-1667653394: + +======================================================================= +Feature: #97390 - Use password policy for password reset in ext:felogin +======================================================================= + +See :issue:`97390` + +Description +=========== + +The password reset feature for TYPO3 frontend users now takes into account the +configurable password policy introduced in :ref:`#97388 <feature-97388>`, +if the feature toggle `security.usePasswordPolicyForFrontendUsers` is +set to `true` (default for new TYPO3 websites). + +Impact +====== + +Password validation configured through +:typoscript:`plugin.tx_felogin_login.settings.passwordValidators` has been +marked as deprecated, but will still be used for password validation, if +the feature toggle `security.usePasswordPolicyForFrontendUsers` is set +to `false`. + +TYPO3 websites, which have the feature toggle +`security.usePasswordPolicyForFrontendUsers` set to `true`, will use the globally +configured password policy when a TYPO3 frontend user resets their password. +The TYPO3 default password policy contains the following password requirements: + +* At least 8 chars +* At least one number +* At least one upper case char +* At least one special char +* Must be different than current password (if available) + +.. index:: Frontend, ext:felogin diff --git a/Documentation/Changelog/12.3/Feature-97667-AddKeyboardSupportForSelectMultipleSideBySideGroupFolder.rst b/Documentation/Changelog/12.3/Feature-97667-AddKeyboardSupportForSelectMultipleSideBySideGroupFolder.rst new file mode 100644 index 0000000..c188d6b --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-97667-AddKeyboardSupportForSelectMultipleSideBySideGroupFolder.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-97667-1678967840: + +====================================================== +Feature: #97667 - Add keyboard support for Multiselect +====================================================== + +See :issue:`97667` + +Description +=========== + +You are able to use the keyboard for selecting and deselecting options in +Multiselect. + +- :kbd:`Enter` adds options, either from right to left or left to right +- :kbd:`Delete` or :kbd:`Backspace` removes an option for windows/mac users +- :kbd:`Alt` + :kbd:`ArrowUp` moves the option one up +- :kbd:`Alt` + :kbd:`ArrowDown` moves the option one down +- :kbd:`Alt` + :kbd:`Shift` + :kbd:`ArrowUp` moves it to the top +- :kbd:`Alt` + :kbd:`Shift` + :kbd:`ArrowDown` moves it to the bottom + +More combinations are possible by default: + +- :kbd:`Shift` + :kbd:`ArrowUp` includes the upper option +- :kbd:`Shift` + :kbd:`ArrowDown` includes the lower option +- :kbd:`Home` moves the cursor to the top +- :kbd:`End` move the cursor to the bottom + +Impact +====== + +This currently affects the following TCA configurations: + +- :php:`'type' => 'select', 'renderType' => 'selectMultipleSideBySide'` +- :php:`'type' => 'group'` +- :php:`'type' => 'folder'` + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-98132-LetClassSchemaDetectMultiplePropertyTypes.rst b/Documentation/Changelog/12.3/Feature-98132-LetClassSchemaDetectMultiplePropertyTypes.rst new file mode 100644 index 0000000..c0de041 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-98132-LetClassSchemaDetectMultiplePropertyTypes.rst @@ -0,0 +1,97 @@ +.. include:: /Includes.rst.txt + +.. _feature-98132-1677928250: + +=============================================================== +Feature: #98132 - Extbase entity properties support union types +=============================================================== + +See :issue:`98132` + +Description +=========== + +Extbase reflection now supports the detection of union types in entity properties. + +Previously, whenever a union type was needed, union type declarations led to Extbase +not detecting any type at all, resulting in the property not being mapped. Union +types could be resolved via doc blocks however: + +.. code-block:: php + + class Entity extends AbstractEntity + { + /** + * @var ChildEntity|LazyLoadingProxy + */ + private $property; + } + +Now this is possible: + +.. code-block:: php + + class Entity extends AbstractEntity + { + private ChildEntity|LazyLoadingProxy $property; + } + +This is especially useful for lazy loaded relations where the property type is `LazyLoadingProxy|ChildEntity`. + +There is something important to understand about how Extbase detects unions when +it comes to property mapping, i.e. when a database row is mapped onto an object. +In this case, Extbase needs to know the desired target type - no union, no +intersection, just one type. In order to achieve this, Extbase uses the first +declared type as a so-called primary type. + +.. code-block:: php + + class Entity extends AbstractEntity + { + private string|int $property; + } + +In this case, `string` is the primary type. `int|string` would result in `int` as primary type. + +There is one important thing to note and one exception to this rule. First of +all, `null` is not considered a type. `null|string` results in primary type +`string`, which is nullable. `null|string|int` also results in primary type +`string`. In fact, `null` means that all other types are nullable. +`null|string|int` boils down to `?string` or `?int`. + +Secondly, `LazyLoadingProxy` is never detected as primary type because it is +just a proxy and not the actual target type, once loaded. + +.. code-block:: php + + class Entity extends AbstractEntity + { + private LazyLoadingProxy|ChildEntity $property; + } + +Extbase supports this and detects `ChildEntity` as primary type, although +`LazyLoadingProxy` is the first item in the list. However, it is recommended to +place the actual type first, for consistency reasons: `ChildEntity|LazyLoadingProxy`. + +A final word on `LazyObjectStorage`: `LazyObjectStorage` is a subclass of +`ObjectStorage`, therefore the following code works and has always worked: + +.. code-block:: php + + class Entity extends AbstractEntity + { + /** + * @var ObjectStorage<ChildEntity> + * @TYPO3\CMS\Extbase\Annotation\ORM\Lazy + */ + private ObjectStorage $property; + } + + +Impact +====== + +As described above, the main impact is Extbase being able to detect and support +union type declarations for entity properties. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.3/Feature-98517-UsernameInBackendPasswordResetMail.rst b/Documentation/Changelog/12.3/Feature-98517-UsernameInBackendPasswordResetMail.rst new file mode 100644 index 0000000..e81006b --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-98517-UsernameInBackendPasswordResetMail.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _feature-98517-1675861888: + +========================================================= +Feature: #98517 - Username in backend password reset mail +========================================================= + +See :issue:`98517` + +Description +=========== + +Many users forget their login username and try to login with their email address. +The username of the backend user is now displayed in the password recovery email +alongside the reset link. + +Impact +====== + +The username of the backend user is displayed in the password recovery email +alongside the reset link. + +.. note:: + + Be aware, this feature comes with security risks: + + Previously, a third-party that gained access to the email account could only + reset the password of the TYPO3 backend user, but not login if the username + was different to the email address. + + Now it has all the information needed to login into the TYPO3 backend and + potentially could cause damage to the website. + + We highly recommend protecting backend accounts using :doc:`MFA <../11.1/Feature-93526-MultiFactorAuthentication>`. + + It is also possible to override the ResetPassword email template to remove + the username and customize the result. + +.. index:: LocalConfiguration, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-99258-AddMinimumAgeOptionToRecordDeletionCommand.rst b/Documentation/Changelog/12.3/Feature-99258-AddMinimumAgeOptionToRecordDeletionCommand.rst new file mode 100644 index 0000000..c803a62 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99258-AddMinimumAgeOptionToRecordDeletionCommand.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-99258-1670017157: + +======================================================================================= +Feature: #99258 - Add minimum age option to EXT:lowlevel cleanup:deletedrecords command +======================================================================================= + +See :issue:`99258` + +Description +=========== + +Using the CLI command `cleanup:deletedrecords` to clean up the database +periodically is not really possible with EXT:recycler, because all +records marked for deletion are deleted immediately and thus the recycler seems +less useful. + +The new option `--min-age` added to the `cleanup:deletedrecords` CLI command +allows a minimum age of the X days that a record needs to be marked as deleted +before it really gets deleted to be defined. + +Impact +====== + +Executing `bin/typo3 cleanup:deletedrecords --min-age 30` will only delete +records that have been marked for more than 30 days for deletion. + +.. index:: CLI, ext:lowlevel diff --git a/Documentation/Changelog/12.3/Feature-99321-AddSiteLanguagePresets.rst b/Documentation/Changelog/12.3/Feature-99321-AddSiteLanguagePresets.rst new file mode 100644 index 0000000..43fac9f --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99321-AddSiteLanguagePresets.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _feature-99321-1670525282: + +================================================ +Feature: #99321 - Add presets for site languages +================================================ + +See :issue:`99321` + +Description +=========== + +When adding a new language to a site, an integrator can now +choose + +a) to create a new language by defining all values themselves +b) from a list of default language settings ("presets") +c) to use an existing language if it is already used in a different site + +Although c) is always recommended when working with multi-site setups, +to keep language IDs between sites in sync, b) is now a quick start +to setup a new site. + +Impact +====== + +Integrators spend less time adding new site languages. + + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.3/Feature-99436-ListCommandsInSchedulerModule.rst b/Documentation/Changelog/12.3/Feature-99436-ListCommandsInSchedulerModule.rst new file mode 100644 index 0000000..f907ee8 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99436-ListCommandsInSchedulerModule.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-99436-1672410981: + +=================================================== +Feature: #99436 - List commands in scheduler module +=================================================== + +See :issue:`99436` + +Description +=========== + +Commands based on Symfony commands are the successor of regular tasks since TYPO3 v8. + +The scheduler submodule :guilabel:`Available scheduler commands & tasks` has been extended +to list not only available scheduler tasks, but CLI commands that can be added +as scheduler tasks. + + +Impact +====== + +The submodule :guilabel:`Available scheduler commands & tasks` has been improved to +list schedulable commands as well. This improves the overview and makes it easier +to set up commands. + +.. index:: Backend, ext:scheduler diff --git a/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst b/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst new file mode 100644 index 0000000..18dca72 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst @@ -0,0 +1,198 @@ +.. include:: /Includes.rst.txt + +.. _feature-99499-1677703100: + +============================================================ +Feature: #99499 - Introduce Content-Security-Policy handling +============================================================ + +See :issue:`99499` + +Description +=========== + +A corresponding representation of the W3C standard of +`Content-Security-Policy (CSP) <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy>`__ +has been introduced to TYPO3. Content-Security-Policy declarations can either be provided by using +the general builder pattern of :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy`, extension-specific +mutations (changes to the general policy) via :file:`Configuration/ContentSecurityPolicies.php` +located in corresponding extension directories, or YAML path :yaml:`contentSecurityPolicies.mutations` for +site-specific declarations in the website frontend. + +The PSR-15 middlewares :php:`ContentSecurityPolicyHeaders` apply `Content-Security-Policy` HTTP headers +to each response in the frontend and backend scope. In the case that other components have already added either the +header `Content-Security-Policy` or `Content-Security-Policy-Report-Only`, those existing headers will be +kept without any modification - these events will be logged with an `info` severity. + +To delegate CSP handling to TYPO3, the scope-specific feature flags need to be enabled: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.backend.enforceContentSecurityPolicy']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.enforceContentSecurityPolicy']` + +For new installations `security.backend.enforceContentSecurityPolicy` is enabled via factory default settings. + +Potential CSP violations are reported back to the TYPO3 system and persisted internally in the database table +:sql:`sys_http_report`. A corresponding Content-Security-Policy backend module supports users to keep track of +recent violations and - if applicable - to select potential resolutions (stored in database table +:sql:`sys_csp_resolution`) which extends the Content-Security-Policy for the given scope during runtime. + +As an alternative, the reporting URL can be configured to use third-party services as well: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['BE']['contentSecurityPolicyReportingUrl'] + = 'https://csp-violation.example.org/'; + + $GLOBALS['TYPO3_CONF_VARS']['FE']['contentSecurityPolicyReportingUrl'] + = 'https://csp-violation.example.org/'; + +Impact +====== + +Introducing CSP to TYPO3 aims to reduce the risk of being affected by Cross-Site-Scripting +due to the lack of proper encoding of user-submitted content in corresponding outputs. + +Configuration +============= + +`Policy` builder approach +------------------------- + +.. code-block:: php + + <?php + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + use TYPO3\CMS\Core\Security\Nonce; + + $nonce = Nonce::create(); + $policy = (new Policy()) + // results in `default-src 'self'` + ->default(SourceKeyword::self) + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `img-src 'self' data: https://*.typo3.org` + ->extend(Directive::ImgSrc, SourceScheme::data, new UriValue('https://*.typo3.org')) + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + ->extend(Directive::ScriptSrc, SourceKeyword::nonceProxy) + // sets (overrides) the directive, thus ignores 'self' of the 'default-src' directive + // results in `worker-src blob:` + ->set(Directive::WorkerSrc, SourceScheme::blob); + header('Content-Security-Policy: ' . $policy->compile($nonce)); + +The result of the compiled and serialized result as HTTP header would look similar to this +(the following sections are using the same example, but utilize different techniques for the declarations). + +.. code-block:: text + + Content-Security-Policy: default-src 'self'; + img-src 'self' data: https://*.typo3.org; script-src 'self' 'nonce-[random]'; + worker-src blob: + +Extension-specific +------------------ + +A file :file:`Configuration/ContentSecurityPolicies.php` in the base directory +of any extension will automatically provide and apply corresponding settings. + +.. code-block:: php + :caption: EXT:my_extension/Configuration/ContentSecurityPolicies.php + + <?php + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + use TYPO3\CMS\Core\Type\Map; + + return Map::fromEntries([ + // provide declarations for the backend + Scope::backend(), + + // NOTICE: When using `MutationMode::Set` existing declarations will be overridden + new MutationCollection( + // results in `default-src 'self'` + new Mutation(MutationMode::Set, Directive::DefaultSrc, SourceKeyword::self), + + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `img-src 'self' data: https://*.typo3.org` + new Mutation(MutationMode::Extend, Directive::ImgSrc, SourceScheme::data, new UriValue('https://*.typo3.org')), + // NOTICE: the following two instructions for `Directive::ImgSrc` are identical to the previous instruction, + // `MutationMode::Extend` is a shortcut for `MutationMode::InheritOnce` and `MutationMode::Append` + // new Mutation(MutationMode::InheritOnce, Directive::ImgSrc, SourceScheme::data), + // new Mutation(MutationMode::Append, Directive::ImgSrc, SourceScheme::data, new UriValue('https://*.typo3.org')), + + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + new Mutation(MutationMode::Extend, Directive::ScriptSrc, SourceKeyword::nonceProxy), + + // sets (overrides) the directive, thus ignores 'self' of the 'default-src' directive + // results in `worker-src blob:` + new Mutation(MutationMode::Set, Directive::WorkerSrc, SourceScheme::blob), + ), + ]); + +Site-specific (frontend) +------------------------ + +In the frontend, the dedicated :file:`sites/<my-site>/csp.yaml` can be used to declare CSP for a specific site as well. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + # inherits default site-unspecific frontend policy mutations (enabled per default) + inheritDefault: true + mutations: + # results in `default-src 'self'` + - mode: set + directive: 'default-src' + sources: + - "'self'" + # extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + # results in `img-src 'self' data: https://*.typo3.org` + - mode: extend + directive: 'img-src' + sources: + - 'data:' + - 'https://*.typo3.org' + # extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + # results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + - mode: extend + directive: 'script-src' + sources: + - "'nonce-proxy'" + # results in `worker-src blob:` + - mode: set + directive: 'worker-src' + sources: + - 'blob:' + +PSR-14 events +============= + +PolicyMutatedEvent +------------------ + +The :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent` will +be dispatched once all mutations have been applied to the current policy object, just +before the corresponding HTTP header is added to the HTTP response object. +This allows individual changes for custom implementations. Next to the :php:`Scope`, the +:php:`Policy`'s and the :php:`MutationCollection`'s might the Event also provide +the current PSR-7 :php:`ServerRequestInterface` for additional context. + +InvestigateMutationsEvent +------------------------- + +The :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\InvestigateMutationsEvent` will +be dispatched when the Content-Security-Policy backend module searches for potential resolutions +to a specific CSP violation report. This way, third-party integrations that rely on external resources +(for example, maps, file storage, content processing/translation, ...) can provide the necessary mutations. + +.. index:: Backend, Fluid, Frontend, LocalConfiguration, PHP-API, ext:core diff --git a/Documentation/Changelog/12.3/Feature-99608-AddPasswordPolicyActionToExcludeValidatorsInSUMode.rst b/Documentation/Changelog/12.3/Feature-99608-AddPasswordPolicyActionToExcludeValidatorsInSUMode.rst new file mode 100644 index 0000000..6fe4ec4 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99608-AddPasswordPolicyActionToExcludeValidatorsInSUMode.rst @@ -0,0 +1,28 @@ +.. include:: /Includes.rst.txt + +.. _feature-99608-1674053552: + +============================================================================= +Feature: #99608 - Add password policy action to exclude validators in SU mode +============================================================================= + +See :issue:`99608` + +Description +=========== + +The new password policy action `UPDATE_USER_PASSWORD_SWITCH_USER_MODE` has been +added in order to allow administrators to exclude a password policy validator, +if the current user is in switch user mode. + +The new password policy action is used in the global default password policy for +the `NotCurrentPasswordValidator`. + + +Impact +====== + +When the current backend user is in switch user mode, it is not validated, +if the new password equals the current user password in ext:setup. + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst b/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst new file mode 100644 index 0000000..9cbc020 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst @@ -0,0 +1,232 @@ +.. include:: /Includes.rst.txt + +.. _feature-99629-1674550092: + +======================================================== +Feature: #99629 - Webhooks - Outgoing webhooks for TYPO3 +======================================================== + +See :issue:`99629` + +Description +=========== + +A webhook is an automated message sent from one application to another via HTTP. + +This feature adds the possibility to configure webhooks in TYPO3. + +A new backend module :guilabel:`System > Webhooks` provides the possibility to +configure webhooks. The module is available in the TYPO3 backend for users with +administrative rights. + +A webhook is defined as an authorized POST or GET request to a defined URL. +For example, a webhook can be used to send a notification to a Slack channel +when a new page is created in TYPO3. + +Any webhook record is defined by a universally unique identifier (UUID), a speaking name, an optional +description, a trigger, the target URL and a signing-secret. +Both the unique identifier and the signing-secret are generated in the backend +when a new webhook is created. + +Triggers provided by the TYPO3 Core +----------------------------------- + +The TYPO3 Core currently provides the following triggers for webhooks: + +* Page Modification: Triggers when a page is created, updated or deleted +* File Added: Triggers when a file is added +* File Updated: Triggers when a file is updated +* File Removed: Triggers when a file is removed +* Login Error Occurred: Triggers when a login error occurred +* Redirect Was Hit: Triggers when a redirect has been hit + +These triggers are meant as a first set of triggers that can be used to send webhooks, +further triggers will be added in the future. In most projects however, it is likely +that custom triggers are required. + +Custom triggers +--------------- + +Trigger by PSR-14 events +~~~~~~~~~~~~~~~~~~~~~~~~ + +Custom triggers can be added by creating a `Message` for an specific PSR-14 event and +tagging that message as a webhook message. + +The following example shows how to create a simple webhook message for the +:php:`\TYPO3\CMS\Core\Resource\Event\AfterFolderAddedEvent`: + +.. code-block:: php + + namespace TYPO3\CMS\Webhooks\Message; + + use TYPO3\CMS\Core\Attribute\WebhookMessage; + use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; + use TYPO3\CMS\Core\Resource\Event\AfterFolderAddedEvent; + + #[WebhookMessage( + identifier: 'typo3/folder-added', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-folder-added' + )] + final class FolderAddedMessage implements WebhookMessageInterface + { + public function __construct( + private readonly int $storageUid, + private readonly string $identifier, + private readonly string $publicUrl + ) { + } + + public static function createFromEvent(AfterFolderAddedEvent $event): self + { + $file = $event->getFile(); + return new self($file->getStorage()->getUid(), $file->getIdentifier(), $file->getPublicUrl()); + } + + public function jsonSerialize(): array + { + return [ + 'storage' => $this->storageUid, + 'identifier' => $this->identifier, + 'url' => $this->publicUrl, + ]; + } + } + +#. Create a final class implementing `\TYPO3\CMS\Core\Messaging\WebhookMessageInterface`. +#. Add the :php:`\TYPO3\CMS\Core\Attribute\WebhookMessage` attribute to the class. + The attribute requires the following information: + + * `identifier`: The identifier of the webhook message. + * `description`: The description of the webhook message. This description + is used to describe the trigger in the TYPO3 backend. + +#. Add a static method `createFromEvent()` that creates a new instance of the + message from the event you want to use as a trigger. +#. Add a method `jsonSerialize()` that returns an array with the data that + should be sent with the webhook. + +Trigger by hooks or custom code +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In case a trigger is not provided by the TYPO3 Core or a PSR-14 event is not available, +it is possible to create a custom trigger - for example by using a TYPO3 hook. + +The message itself should look similar to the example above, but does not need the +:php:`createFromEvent()` method. + +Instead, the custom code (hook implementation) will create the message +and dispatch it. + +Example hook implementation for a DataHandler hook (see :php:`\TYPO3\CMS\Webhooks\Listener\PageModificationListener`): + +.. code-block:: php + + public function __construct( + protected readonly \Symfony\Component\Messenger\MessageBusInterface $bus + ) { + } + + public function processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, DataHandler $dataHandler) + { + if ($table !== 'pages') { + return; + } + // ... + $message = new PageModificationMessage( + 'new', + $id, + $fieldArray, + $site->getIdentifier(), + (string)$site->getRouter()->generateUri($id), + $dataHandler->BE_USER, + ); + // ... + $this->bus->dispatch($message); + } + +Use :file:`Services.yaml` instead of the PHP attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of the PHP attribute the :file:`Services.yaml` can be used to define the +webhook message. The following example shows how to define the webhook message +from the example above in the :file:`Services.yaml`: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + TYPO3\CMS\Webhooks\Message\FolderAddedMessage: + tags: + - name: 'core.webhook_message' + identifier: 'typo3/folder-added' + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-folder-added' + + +HTTP headers of every webhook +----------------------------- + +With every webhook request, the following HTTP headers are sent: + +* Content-Type: application/json +* Webhook-Signature-Algo: sha256 +* Webhook-Signature: <hash> + +The hash is calculated with the secret of the webhook and the JSON encoded data +of the request. The hash is created with the PHP function :php:`hash_hmac`. +See the following section about the hash calculation. + +Hash calculation +---------------- + +The hash is calculated with the following PHP code: + +.. code-block:: php + + $hash = hash_hmac('sha256', sprintf( + '%s:%s', + $identifier, // The identifier of the webhook (uuid) + $body // The JSON encoded body of the request + ), $secret); // The secret of the webhook + +The hash is sent as HTTP header `Webhook-Signature` and should be used to +validate that the request was sent from the TYPO3 instance and has not been +manipulated. +To verify this on the receiving end, build the hash with the same algorithm and +secret and compare it with the hash that was sent with the request. + +The hash is not meant to be used as a security mechanism, but as a way to verify +that the request was sent from the TYPO3 instance. + +Technical background and advanced usage +--------------------------------------- + +The webhook system is based on the Symfony Messenger component. The messages +are simple PHP objects that implement an interface that denotes +them as webhook messages. + +That message is then dispatched to the Symfony Messenger bus. The TYPO3 Core +provides a :php:`\TYPO3\CMS\Webhooks\MessageHandler\WebhookMessageHandler` +that is responsible for sending the webhook +requests to the third-party system, if configured to do so. The handler looks up +the webhook configuration and sends the request to the configured URL. + +Messages are sent to the bus in any case. The handler is then responsible for checking +whether or not an external request (webhook) should be sent. + +If advanced request handling is necessary or a custom implementation should be used, +a custom handler can be created that handles :php:`WebhookMessageInterface` +messages. + +.. seealso:: + :ref:`More information on messages and their handlers <t3coreapi:message-bus>` + +Impact +====== + +The TYPO3 Core now provides a convenient GUI to create and send webhooks to +third-party systems. +In combination with the system extension :doc:`reactions <ext_reactions:Index>` +TYPO3 can now be used as a +low-code/no-code integration platform between multiple systems. + +.. index:: Backend, Frontend, PHP-API, ext:webhooks diff --git a/Documentation/Changelog/12.3/Feature-99735-NewCountrySelectFormElement.rst b/Documentation/Changelog/12.3/Feature-99735-NewCountrySelectFormElement.rst new file mode 100644 index 0000000..4a50a82 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99735-NewCountrySelectFormElement.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _feature-99735-1678701694: + +================================================= +Feature: #99735 - New Country Select form element +================================================= + +See :issue:`99735` + +Description +=========== + +Since :ref:`feature-99618-1674063182`, TYPO3 provides a list of countries, together with an API +and a Fluid form ViewHelper. A new "Country select" form element has now been +added to the TYPO3 Form Framework for creating a country select in a form +easily. The new form element features a couple of configuration options, which +can either be configured via the :guilabel:`Forms` module or directly in the +corresponding YAML file. + +Available options +----------------- + +- `First option` (:yaml:`prependOptionLabel`): Define the "empty option", i.e. the first element of the select. You can use this to provide additional guidance for the user. +- `Prioritized countries` (:yaml:`prioritizedCountries`): Define a list of countries which should be listed as first options in the form element. +- `Only countries` (:yaml:`onlyCountries`): Restrict the countries to be rendered in the list. +- `Exclude countries` (:yaml:`excludeCountries`): Define which countries should not be shown in the list. + +The new element will be rendered as single select (:html:`<select>`) HTML +element in the frontend. + +Impact +====== + +The new "Country select" form element is now available in the Form +Framework with a couple of specific configuration options. + +.. index:: ext:form diff --git a/Documentation/Changelog/12.3/Feature-99739-AssociativeArrayKeysForTCAItems.rst b/Documentation/Changelog/12.3/Feature-99739-AssociativeArrayKeysForTCAItems.rst new file mode 100644 index 0000000..45f6825 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99739-AssociativeArrayKeysForTCAItems.rst @@ -0,0 +1,101 @@ +.. include:: /Includes.rst.txt + +.. _feature-99739-1674867455: + +====================================================== +Feature: #99739 - Associative array keys for TCA items +====================================================== + +See :issue:`99739` + +Description +=========== + +It is now possible to define associative array keys for the :php:`items` +configuration of TCA types :php:`select`, :php:`radio` and :php:`check`. The +new keys are called: :php:`label`, :php:`value`, :php:`icon`, :php:`group` and +:php:`description`. + +Examples: + +.. code-block:: php + + 'columns' => [ + 'select' => [ + 'label' => 'My select field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'Selection 1', + 'value' => '1', + 'icon' => 'my-icon-identifier', + 'group' => 'default', + ], + [ + 'label' => 'Selection 2', + 'value' => '2', + ], + ], + ], + ], + 'select_checkbox' => [ + 'label' => 'My select checkbox field', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectCheckBox', + 'items' => [ + [ + 'label' => 'My select checkbox field', + 'value' => '1', + 'icon' => 'my-icon-identifier', + 'group' => 'default', + 'description' => 'My custom description', + ], + [ + 'label' => 'My select checkbox field', + 'value' => '2', + ], + ], + ], + ], + 'radio' => [ + 'label' => 'My radio field', + 'config' => [ + 'type' => 'radio', + 'items' => [ + [ + 'label' => 'Radio 1', + 'value' => '1', + ], + [ + 'label' => 'Radio 2', + 'value' => '2', + ], + ], + ], + ], + 'check' => [ + 'config' => [ + 'type' => 'check', + 'items' => [ + [ + 'invertStateDisplay' => true, + 'label' => 'Click on me', + ], + ], + ], + ], + ], + +Impact +====== + +It is now much easier and clearer to define the TCA :php:`items` configuration +with associative array keys. The struggle to remember which option is first, +label or value, is now over. In addition, optional keys like :php:`icon` and +:php:`group` can be omitted, for example, when one desires to set the +:php:`description` option. + +.. index:: TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Feature-99802-NewPSR-14ModifyRedirectManagementControllerViewDataEvent.rst b/Documentation/Changelog/12.3/Feature-99802-NewPSR-14ModifyRedirectManagementControllerViewDataEvent.rst new file mode 100644 index 0000000..748f508 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99802-NewPSR-14ModifyRedirectManagementControllerViewDataEvent.rst @@ -0,0 +1,92 @@ +.. include:: /Includes.rst.txt + +.. _feature-99802-1675370033: + +============================================================================ +Feature: #99802 - New PSR-14 ModifyRedirectManagementControllerViewDataEvent +============================================================================ + +See :issue:`99802` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Redirects\Event\ModifyRedirectManagementControllerViewDataEvent` +is introduced, allowing extension authors to modify or enrich view data for the +:php:`\TYPO3\CMS\Redirects\Controller\ManagementController`. This allows to +display more or other information along the way. + +This event features the following methods: + +- :php:`getDemand()`: Return the demand object used to retrieve the redirects +- :php:`getRedirects()`: Return the retrieved redirects +- :php:`setRedirects()`: Can be used to set the redirects, for example, after enriching redirect fields +- :php:`getRequest()`: Return the current request +- :php:`getHosts()`: Returns the hosts to be used for the host filter select-box +- :php:`setHosts()`: Can be used to update which hosts are available in the filter select-box +- :php:`getStatusCodes()`: Returns the status codes for the filter select box +- :php:`setStatusCodes()`: Can be used to update which status codes are available in the filter select-box +- :php:`getCreationTypes()`: Returns creation types for the filter select box +- :php:`setCreationTypes()`: Can be used to update which creation types are available in the filter select-box +- :php:`getShowHitCounter()`: Returns if hit counter should be displayed +- :php:`setShowHitCounter()`: Can be used to manage if the hit counter should be displayed +- :php:`getView()`: Returns the current view object, without controller data assigned yet +- :php:`setView()`: Can be used to assign additional data to the view + +For example, this event can be used to add additional information to current page records. + +Therefore, it can be used to generate custom data, directly assigning to the view. +With overriding the backend view template via page TSconfig this custom data can +be displayed where it is needed, and rendered the way it is wanted. + + +Example: +-------- + +Registration of the event listener: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/modify-redirect-management-controller-view-data' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + <?php + + declare(strict_types=1); + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Redirects\Event\ModifyRedirectManagementControllerViewDataEvent; + + final class MyEventListener { + + public function __invoke( + ModifyRedirectManagementControllerViewDataEvent $event + ): void { + $hosts = $event->getHosts(); + + // remove wildcard host from list + $hosts = array_filter($hosts, static fn ($host) => $host['name'] !== '*'); + + // update changed hosts list + $event->setHosts($hosts); + } + } + + +Impact +====== + +With the new :php:`ModifyRedirectManagementControllerViewDataEvent`, it is +now possible to modify view data or inject further data to the view for the +management view of redirects. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-99803-NewPSR-14BeforeRedirectMatchDomainEvent.rst b/Documentation/Changelog/12.3/Feature-99803-NewPSR-14BeforeRedirectMatchDomainEvent.rst new file mode 100644 index 0000000..942ddcb --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99803-NewPSR-14BeforeRedirectMatchDomainEvent.rst @@ -0,0 +1,108 @@ +.. include:: /Includes.rst.txt + +.. _feature-99803-1675373908: + +=========================================================== +Feature: #99803 - New PSR-14 BeforeRedirectMatchDomainEvent +=========================================================== + +See :issue:`99803` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Redirects\Event\BeforeRedirectMatchDomainEvent` +is introduced to the :php:`\TYPO3\CMS\Redirects\Service\RedirectService`, allowing extension authors to implement a +custom redirect matching upon the loaded redirects or return matched redirect +record from other sources. + +This event features following methods: + +- :php:`getDomain()`: Returns the domain for which redirects should be + checked for, "*" for all domains. +- :php:`getPath()`: Returns the path which should be checked. +- :php:`getQuery()`: Returns the query part which should be checked. +- :php:`getMatchDomainName()`: Returns current check domain name. +- :php:`getMatchedRedirect()`: Returns the matched :sql:`sys_redirect` record, + set by another event listener or null. +- :php:`setMatchedRedirect()`: Can be used to clear prior matched redirect + by setting it to :php:`null` or set a matched :sql:`sys_redirect` record. + +.. note:: + + Full :sql:`sys_redirect` record must be set using `setMatchedRedirect()` method. + Otherwise later Core code would fail, as it expects, for example, the uid of the record + to set the `X-Redirect-By` response header. Therefore, the `getMatchedRedirect()` + method returns null or a full :sql:`sys_redirect` record. + +.. note:: + + The :php:`BeforeRedirectMatchDomainEvent` is dispatched before cached redirects + are retrieved. That means, that the event does not contain any :sql:`sys_redirect` + records. Internal redirect cache may vanish eventually if possible. Therefore, + it is left out to avoid a longer bound state to the event by properly deprecate it. + +Example: +-------- + +Registration of the event listener: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/before-redirect-match-domain' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Backend\Utility\BackendUtility; + use TYPO3\CMS\Redirects\Event\BeforeRedirectMatchDomainEvent; + + final class MyEventListener + { + public function __invoke(BeforeRedirectMatchDomainEvent $event): void + { + $matchedRedirectRecord = $this->customRedirectMatching($event); + if ($matchedRedirectRecord !== null) { + $event->setMatchedRedirect($matchedRedirectRecord); + } + } + + private function customRedirectMatching( + BeforeRedirectMatchDomainEvent $event + ): ?array { + + // @todo Implement custom redirect record loading and matching. If + // a redirect based on custom logic is determined, return the + // :sql:`sys_redirect` tables conform redirect record. + + // Note: Below is simplified example code with no real value. + $record = BackendUtility::getRecord('sys_redirect', 123); + + // Do custom matching logic against the record and return matched + // record - if there is one. + if ($record + && /* custom condition against the record */ + ) { + return $record; + } + + // return null to indicate that no matched redirect could be found + return null; + } + } + +Impact +====== + +With the new :php:`BeforeRedirectMatchDomainEvent` it is now possible to +implement custom redirect matching methods before core matching is processed. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-99834-NewPSR-14AfterAutoCreateRedirectHasBeenPersistedEvent.rst b/Documentation/Changelog/12.3/Feature-99834-NewPSR-14AfterAutoCreateRedirectHasBeenPersistedEvent.rst new file mode 100644 index 0000000..ec8796a --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99834-NewPSR-14AfterAutoCreateRedirectHasBeenPersistedEvent.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _feature-99834-1675612921: + +========================================================================= +Feature: #99834 - New PSR-14 AfterAutoCreateRedirectHasBeenPersistedEvent +========================================================================= + +See :issue:`99834` + +Description +=========== + +A new PSR-14 event :php:`\TYPO3\CMS\Redirects\Event\AfterAutoCreateRedirectHasBeenPersistedEvent` +is introduced, allowing extension authors to react on persisted auto-created redirects. This +can be used to call external API or do other tasks based on the real persisted redirects. + +.. note:: + + To handle later updates or react on manual created redirects in the backend + module, available hooks of :php:`\TYPO3\CMS\Core\DataHandling\DataHandler` + can be used. + +Example: +-------- + +Registration of the event listener: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/after-auto-create-redirect-has-been-persisted' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Redirects\Event\AfterAutoCreateRedirectHasBeenPersistedEvent; + use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource; + + class MyEventListener { + + public function __invoke( + AfterAutoCreateRedirectHasBeenPersistedEvent $event + ): void { + $redirectUid = $event->getRedirectRecord()['uid'] ?? null; + if ($redirectUid === null + && !($event->getSource() instanceof PlainSlugReplacementRedirectSource) + ) { + return; + } + + // Implement code what should be done with this information. E.g. + // write to another table, call a rest api or similar. Find your + // use-case. + } + } + + +Impact +====== + +With the new :php:`AfterAutoCreateRedirectHasBeenPersistedEvent`, it is now possible +to react on persisted auto-created redirects. Manually created redirects can be handled +by using one of the available :php:`\TYPO3\CMS\Core\DataHandling\DataHandler` hooks, +not suitable for auto-created redirects. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-99834-NewPSR-14ModifyAutoCreateRedirectRecordBeforePersistingEvent.rst b/Documentation/Changelog/12.3/Feature-99834-NewPSR-14ModifyAutoCreateRedirectRecordBeforePersistingEvent.rst new file mode 100644 index 0000000..04b818e --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99834-NewPSR-14ModifyAutoCreateRedirectRecordBeforePersistingEvent.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _feature-99834-1675612872: + +================================================================================ +Feature: #99834 - New PSR-14 ModifyAutoCreateRedirectRecordBeforePersistingEvent +================================================================================ + +See :issue:`99834` + +Description +=========== + +A new PSR-14 :php:`\TYPO3\CMS\Redirects\Event\ModifyAutoCreateRedirectRecordBeforePersistingEvent` +is introduced, allowing extension authors to modify the redirect record before it is persisted to +the database. This can be used to change values based on circumstances, for example, like +different sub tree settings, not covered by the Core site configuration. Another use-case +could be to write data to additional :sql:`sys_redirect` columns added by a custom +extension for later use. + +.. note:: + + To handle later updates or react on manually created redirects in the backend + module, available hooks of :php:`\TYPO3\CMS\Core\DataHandling\DataHandler` + can be used. + +Example: +-------- + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Redirects\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/modify-auto-create-redirect-record-before-persisting' + +The corresponding event listener class: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Redirects/MyEventListener.php + + namespace MyVendor\MyExtension\Redirects; + + use TYPO3\CMS\Redirects\Event\ModifyAutoCreateRedirectRecordBeforePersistingEvent; + use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource; + + final class MyEventListener { + + public function __invoke( + ModifyAutoCreateRedirectRecordBeforePersistingEvent $event + ): void { + + // only work on plain slug replacement redirect sources. + if (!($event->getSource() instanceof PlainSlugReplacementRedirectSource)) { + return; + } + + // Get prepared redirect record and change some values + $record = $event->getRedirectRecord(); + + // override the status code, eventually to another value than + // configured in the site configuration + $record['status_code'] = 307; + + // Set value to a field extended by a custom extension, to persist + // additional data to the redirect record. + $record['custom_field_added_by_a_extension'] + = 'page_' . $event->getSlugRedirectChangeItem()->getPageId(); + + // Update changed record in event to ensure changed values are saved. + $event->setRedirectRecord($record); + } + } + + +Impact +====== + +With the new :php:`ModifyAutoCreateRedirectRecordBeforePersistingEvent`, it is now +possible to modify the auto-create redirect record before it is persisted to the database. +Manually created redirects or updated redirects can be handled by using the well-known +:php:`\TYPO3\CMS\Core\DataHandling\DataHandler` and the available hooks. + +.. index:: PHP-API, ext:redirects diff --git a/Documentation/Changelog/12.3/Feature-99861-AddTileViewToElementBrowser.rst b/Documentation/Changelog/12.3/Feature-99861-AddTileViewToElementBrowser.rst new file mode 100644 index 0000000..bd65f97 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99861-AddTileViewToElementBrowser.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _feature-99861-1675757796: + +================================================== +Feature: #99861 - Add tile view to element browser +================================================== + +See :issue:`99861` + +Description +=========== + +The file list is the default implementation for TYPO3 to navigate and +manage assets. This patch extends the usage of the file list to the +element browser, the build-in component to select the assets for file +fields and folder fields in the backend. + +Impact +====== + +The rendering of files and folder now deliver a unified experience and +allow the user to use the tile view to select assets. + +The search within the file browser now respects the selected folder and +searches all subfolders for the provided search term. + +To have an even more reliable experience, the user will now always start +the selection process in the root folder of the default storage. + +Resource tiles are now adapting to the surrounding container instead of +the viewport, to make better use of the available space. + +The file list now holds all related code to the file and folder browser. + +.. index:: Backend, FAL, ext:filelist diff --git a/Documentation/Changelog/12.3/Feature-99874-ManageSchdulerGroupsWithinBeModule.rst b/Documentation/Changelog/12.3/Feature-99874-ManageSchdulerGroupsWithinBeModule.rst new file mode 100644 index 0000000..b1181b7 --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99874-ManageSchdulerGroupsWithinBeModule.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _feature-99874-1678720364: + +============================================================== +Feature: #99874 - Edit task groups within the Scheduler module +============================================================== + +See :issue:`99874` + +Description +=========== + +Task groups can be managed in the backend module itself. Users can create, update and +delete task groups within the :guilabel:`Scheduler` module. Sorting is done via drag&drop (drag the panel header) +and inline-style editing is used to change the title name. Only empty groups may be deleted. + +Impact +====== + +Users may edit groups in the :guilabel:`Scheduler` module. + +.. note:: + + The group's description has never been displayed in the :guilabel:`Scheduler` module and has been + deprecated. Editing the description is and has always been only possible via the :guilabel:`List` module. + + +.. index:: ext:scheduler diff --git a/Documentation/Changelog/12.3/Feature-99976-IntroduceignoreFlexFormSettingsIfEmptyExtbaseConfiguration.rst b/Documentation/Changelog/12.3/Feature-99976-IntroduceignoreFlexFormSettingsIfEmptyExtbaseConfiguration.rst new file mode 100644 index 0000000..cb8af3f --- /dev/null +++ b/Documentation/Changelog/12.3/Feature-99976-IntroduceignoreFlexFormSettingsIfEmptyExtbaseConfiguration.rst @@ -0,0 +1,114 @@ +.. include:: /Includes.rst.txt + +.. _feature-99976-1676660028: + +=============================================================================== +Feature: #99976 - Introduce ignoreFlexFormSettingsIfEmpty Extbase configuration +=============================================================================== + +See :issue:`99976` + +Description +=========== + +It is now possible to exclude empty FlexForm settings from being merged into +Extbase extension settings. Extension authors and integrators can use the new +Extbase TypoScript configuration :typoscript:`ignoreFlexFormSettingsIfEmpty` +to define FlexForm settings, which will be ignored in the merge process of the +extension settings, if their value is considered empty (either an empty string or a +string containing `0`). + +In the following example, :xml:`settings.showForgotPassword` and +:xml:`settings.showPermaLogin` from FlexForm will not be merged into extension +settings, if the individual value is empty: + +.. code-block:: typoscript + + plugin.tx_felogin_login.ignoreFlexFormSettingsIfEmpty = showForgotPassword,showPermaLogin + +If an extension already defined :typoscript:`ignoreFlexFormSettingsIfEmpty`, +integrators are advised to use :typoscript:`addToList` or +:typoscript:`removeFromList` to modify existing settings as shown in the +following example: + +.. code-block:: typoscript + + plugin.tx_felogin_login.ignoreFlexFormSettingsIfEmpty := removeFromList(showForgotPassword) + plugin.tx_felogin_login.ignoreFlexFormSettingsIfEmpty := addToList(domains) + +It is possible to define the :typoscript:`ignoreFlexFormSettingsIfEmpty` +configuration globally for an extension using the +:typoscript:`plugin.tx_extension` TypoScript configuration or for an individual +plugin using the :typoscript:`plugin.tx_extension_plugin` TypoScript +configuration. + +Extension authors can use the new PSR-14 event +:php:`\TYPO3\CMS\Extbase\Event\Configuration\BeforeFlexFormConfigurationOverrideEvent` +to implement a FlexForm override process in a custom extension based on the original +FlexForm configuration and the framework configuration. + +Additionally, the new Extbase TypoScript configuration is used in EXT:felogin to +ensure that empty FlexForm settings are not merged into extension settings. + +Event example +------------- + +Register an event listener in your :file:`Services.yaml` file: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\FlexForm\EventListener\MyEventListener: + tags: + - name: event.listener + identifier: 'my-extension/custom-absolute-path' + + +Implement the event listener: + +.. code-block:: php + :caption: EXT:my_extension/Classes/FlexForm/EventListener/MyEventListener.php + + <?php + + declare(strict_types=1); + + namespace MyVendor\MyExtension\FlexForm\EventListener; + + use TYPO3\CMS\Extbase\Event\Configuration\BeforeFlexFormConfigurationOverrideEvent; + + final class MyEventListener + { + public function __invoke(BeforeFlexFormConfigurationOverrideEvent $event): void + { + // Configuration from TypoScript + $frameworkConfiguration = $event->getFrameworkConfiguration(); + + // Configuration from FlexForm + $originalFlexFormConfiguration = $event->getOriginalFlexFormConfiguration(); + + // Currently merged configuration + $flexFormConfiguration = $event->getFlexFormConfiguration(); + + // Implement custom logic + $flexFormConfiguration['settings']['foo'] = 'set from event listener'; + $event->setFlexFormConfiguration($flexFormConfiguration); + } + } + + +Impact +====== + +Empty FlexForm extension settings can now conditionally be excluded from the +FlexForm configuration merge process. + +Also, it is now possible again to use global TypoScript extension settings +in EXT:felogin, which previously might have been overridden by empty FlexForm +settings. + +In addition, with the new :php:`BeforeFlexFormConfigurationOverrideEvent` it is +now possible to further manipulate the merged configuration after standard +override logic is applied. + +.. index:: ext:extbase diff --git a/Documentation/Changelog/12.3/Important-100032-AddHTTPSecurityHeadersForBackendByDefault.rst b/Documentation/Changelog/12.3/Important-100032-AddHTTPSecurityHeadersForBackendByDefault.rst new file mode 100644 index 0000000..eff8333 --- /dev/null +++ b/Documentation/Changelog/12.3/Important-100032-AddHTTPSecurityHeadersForBackendByDefault.rst @@ -0,0 +1,33 @@ +.. include:: /Includes.rst.txt + +.. _important-100032-1677331239: + +===================================================================== +Important: #100032 - Add HTTP security headers for backend by default +===================================================================== + +See :issue:`100032` + +Description +=========== + +The following HTTP security headers are now added by default for the TYPO3 +backend: + +* `Strict-Transport-Security: max-age=31536000` (only if + :php:`$GLOBALS[TYPO3_CONF_VARS][BE][lockSSL]` is active) +* `X-Content-Type-Options: nosniff` +* `Referrer-Policy: strict-origin-when-cross-origin` + +The default HTTP security headers are configured globally in +`$GLOBALS['TYPO3_CONF_VARS']['BE']['HTTP']['Response']['Headers']` and include +a unique array key, so it is possible to individually unset/remove unwanted +headers. + +.. important:: + + TYPO3 websites, which already use custom HTTP headers for the TYPO3 backend, + must ensure that individual HTTP security headers are not sent multiple + times. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst b/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst new file mode 100644 index 0000000..2da9b3e --- /dev/null +++ b/Documentation/Changelog/12.3/Important-100088-RemoveDbTypeJsonForTCATypeUser.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _important-100088-1677950866: + +========================================================= +Important: #100088 - Remove dbType json for TCA type user +========================================================= + +See :issue:`100088` + +Description +=========== + +With :issue:`99226` the `dbType=json` option has been added for +TCA type `user`. After some reconsideration, it has been decided +to drop this option again in favor of the dedicated TCA type `json`. +Have a look to the according :ref:`changelog <feature-100088-1677965005>` +for further information. + +Since the `dbType` option has not been released in any LTS version yet, +the option is dropped without further deprecation. Also no TCA migration +is applied. + +In case you make already use of this `dbType` in your custom extension, +you need to migrate to the new TCA type. + +Example: + +.. code-block:: php + + // Before + 'myField' => [ + 'config' => [ + 'type' => 'user', + 'renderType' => 'myRenderType', + 'dbType' => 'json', + ], + ], + + // After + 'myField' => [ + 'config' => [ + 'type' => 'json', + 'renderType' => 'myRenderType', + ], + ], + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/Documentation/Changelog/12.3/Important-100135-RemoveCookieWarningInExtfelogin.rst b/Documentation/Changelog/12.3/Important-100135-RemoveCookieWarningInExtfelogin.rst new file mode 100644 index 0000000..42bc715 --- /dev/null +++ b/Documentation/Changelog/12.3/Important-100135-RemoveCookieWarningInExtfelogin.rst @@ -0,0 +1,23 @@ +.. include:: /Includes.rst.txt + +.. _important-100135-1678453394: + +========================================================= +Important: #100135 - Remove cookie warning in ext:felogin +========================================================= + +See :issue:`100135` + +Description +=========== + +The cookie warning message in ext:felogin is never shown, since it depends on +conditions, which will never be met. The cookie warning message can also be +considered superfluous, since a similar message is already shown, if +authentication was not successful. + +Code affecting the non-working cookie warning message has therefore been +removed from ext:felogin. TYPO3 users should remove code from custom templates, +which depend on the `{cookieWarning}` variable. + +.. index:: ext:felogin diff --git a/Documentation/Changelog/12.3/Index.rst b/Documentation/Changelog/12.3/Index.rst new file mode 100644 index 0000000..3eefc37 --- /dev/null +++ b/Documentation/Changelog/12.3/Index.rst @@ -0,0 +1,54 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-3: + +============ +12.3 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + +Breaking Changes +================ + +None since TYPO3 v12.0 release. + +.. attention:: + + After TYPO3 v12.0, only new functionality with a solid migration path + can be added on top, with aiming for as little as possible breaking changes + after the initial v12.0 release on the way to LTS. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.4.x/Deprecation-102099-DeprecateCKEditor5BundleModule.rst b/Documentation/Changelog/12.4.x/Deprecation-102099-DeprecateCKEditor5BundleModule.rst new file mode 100644 index 0000000..12bf8bb --- /dev/null +++ b/Documentation/Changelog/12.4.x/Deprecation-102099-DeprecateCKEditor5BundleModule.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-102099: + +========================================================= +Deprecation: #102099 - Deprecate CKEditor 5 bundle module +========================================================= + +See :issue:`102099` + +Description +=========== + +With the CKEditor 5 integration in TYPO3 v12 a custom CKEditor 5 build in form of +a bundle has been introduced. Missing plugins had to be merged into that bundle +again and again which lead to an increased bundle size. Also plugin authors had +to reference the bundle module in order to fetch plugin exports from CKEditor. + +With CKEditor 5 suggestion to use named exports from the CKEditor 5 package entry +point modules, it became feasible to create smaller bundles. One bundle per +scoped subpackage. For that reason :js:`@typo3/ckeditor5-bundle.js` is now +deprecated. + + +Impact +====== + +TYPO3 can ship all available CKEditor 5 modules and only actually requested modules +are loaded. Developers can write plugins as suggested by upstream documentation. + + +Affected Installations +====================== + +Installations having custom extensions activated, that provide custom CKEditor 5 +plugins. Extensions that use:js:`@typo3/ckeditor5-bundle.js` will still work +as before (as the bundle module re-exports the exports of the split bundles) +but will trigger a deprecation log message to the browser console. + + +Migration +========= + +Extension authors should import from scoped :js:`@ckeditor/ckeditor5-*` packages +directly. + +.. code-block:: javascript + + // Before + import {Core, UI} from '@typo3/ckeditor5-bundle.js'; + + // After + import * as Core from '@ckeditor/ckeditor5-core'; + import * as UI from '@ckeditor/ckeditor5-ui'; + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4.x/Feature-106743-IntroduceSudoModeEvents.rst b/Documentation/Changelog/12.4.x/Feature-106743-IntroduceSudoModeEvents.rst new file mode 100644 index 0000000..81d3cc3 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Feature-106743-IntroduceSudoModeEvents.rst @@ -0,0 +1,151 @@ +.. include:: /Includes.rst.txt + +.. _feature-106743-1747931468: + +============================================= +Feature: #106743 - Introduce Sudo-Mode Events +============================================= + +See :issue:`106743` + +Description +=========== + +The fix for the security advisory `TYPO3-CORE-SA-2025-013 <https://typo3.org/security/advisory/typo3-core-sa-2025-013>`_ +requires step-up authentication when attempting to manipulate backend user accounts. +However, this behavior may pose challenges when integrating remote single sign-on (SSO) +providers, as these typically do not support a dedicated step-up authentication process. + +To address this, new PSR-14 events have been introduced: + +* :php:`TYPO3\CMS\Backend\Security\SudoMode\Event\SudoModeRequiredEvent` is triggered before + showing the sudo-mode verification dialog +* :php:`TYPO3\CMS\Backend\Security\SudoMode\Event\SudoModeVerifyEvent` is triggered before + actually verifying the submitted password + +This event allows developers to conditionally bypass and adjust the step-up authentication +process based on custom logic, such as identifying users authenticated through an SSO system. + +Example +------- + +The following example demonstrates how to use an event listener to skip the step-up authentication +for persisted `be_users` records with an active `is_sso` flag: + + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + services: + Vendor\MyExtension\EventListener\SkipSudoModeDialog: + tags: + - name: event.listener + identifier: 'ext-myextension/skip-sudo-mode-dialog' + Vendor\MyExtension\EventListener\StaticPasswordVerification: + tags: + - name: event.listener + identifier: 'ext-myextension/static-password-verification' + +.. code-block:: php + :caption: EXT:my_extension/Classes/EventListener/SkipSudoModeDialog.php + + <?php + declare(strict_types=1); + + namespace Vendor\MyExtension\EventListener; + + use TYPO3\CMS\Backend\Hooks\DataHandlerAuthenticationContext; + use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessSubjectInterface; + use TYPO3\CMS\Backend\Security\SudoMode\Access\TableAccessSubject; + use TYPO3\CMS\Backend\Security\SudoMode\Event\SudoModeRequiredEvent; + use TYPO3\CMS\Backend\Utility\BackendUtility; + use TYPO3\CMS\Core\Utility\MathUtility; + + final class SkipSudoModeDialog + { + public function __invoke(SudoModeRequiredEvent $event): void + { + // Ensure the event context matches DataHandler operations + if ($event->getClaim()->origin !== DataHandlerAuthenticationContext::class) { + return; + } + + // Filter for TableAccessSubject types only + $tableAccessSubjects = array_filter( + $event->getClaim()->subjects, + static fn (AccessSubjectInterface $subject): bool => $subject instanceof TableAccessSubject + ); + + // Abort if there are unhandled subject types + if ($event->getClaim()->subjects !== $tableAccessSubjects) { + return; + } + + /** @var list<TableAccessSubject> $tableAccessSubjects */ + foreach ($tableAccessSubjects as $subject) { + // Expecting format: tableName.fieldName.id + if (substr_count($subject->getSubject(), '.') !== 2) { + return; + } + + [$tableName, $fieldName, $id] = explode('.', $subject->getSubject()); + + // Only handle be_users table + if ($tableName !== 'be_users') { + return; + } + + // Skip if ID is not a valid integer (e.g., 'NEW' records) + if (!MathUtility::canBeInterpretedAsInteger($id)) { + continue; + } + + $record = BackendUtility::getRecord($tableName, $id); + + // Abort if any record does not use SSO + if (empty($record['is_sso'])) { + return; + } + } + + // All conditions met — disable verification + $event->setVerificationRequired(false); + } + } + +.. code-block:: php + :caption: EXT:my_extension/Classes/EventListener/StaticPasswordVerification.php + + <?php + declare(strict_types=1); + + namespace Example\Demo\EventListener; + + use TYPO3\CMS\Backend\Security\SudoMode\Event\SudoModeVerifyEvent; + + final class StaticPasswordVerification + { + public function __invoke(SudoModeVerifyEvent $event): void + { + $calculatedHash = hash('sha256', $event->getPassword()); + // static hash of `dontdothis` - just used as proof-of-concept + // side-note: in production, make use of strong salted password + $expectedHash = '3382f2e21a5471b52a85bc32ab59ab2c467f6e3cb112aef295323874f423994c'; + + if (hash_equals($expectedHash, $calculatedHash)) { + $event->setVerified(true); + } + } + } + + +Impact +====== + +This feature provides extension developers with a flexible mechanism to skip or adjust +step-up authentication during sensitive backend operations. It is especially useful in +environments utilizing SSO, where enforcing additional verification might not be feasible +or necessary. By hooking into the new :php:`SudoModeRequiredEvent` and :php:`SudoModeVerifyEvent` +custom logic and behavior can be applied on a case-by-case basis. + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.4.x/Important-100847-AddedFontPluginToCKEditor5.rst b/Documentation/Changelog/12.4.x/Important-100847-AddedFontPluginToCKEditor5.rst new file mode 100644 index 0000000..9672c99 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-100847-AddedFontPluginToCKEditor5.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _important-100847-1686218342: + +==================================================== +Important: #100847 - Added font plugin to CKEditor 5 +==================================================== + +See :issue:`100847` + +Description +=========== + +The font plugin has been added to the CKEditor 5. +In order to use the font plugin, the RTE configuration needs to be adapted: + +.. code-block:: yaml + + editor: + config: + toolbar: + items: + # add button to select font family + - fontFamily + # add button to select font size + - fontSize + # add button to select font color + - fontColor + # add button to select font background color + - fontBackgroundColor + + fontColor: + colors: + - { label: 'Orange', color: '#ff8700' } + - { label: 'Blue', color: '#0080c9' } + - { label: 'Green', color: '#209d44' } + + fontBackgroundColor: + colors: + - { label: 'Stage orange light', color: '#fab85c' } + + fontFamily: + options: + - 'default' + - 'Arial, sans-serif' + + fontSize: + options: + - 'default' + - 18 + - 21 + + importModules: + - { 'module': '@ckeditor/ckeditor5-font', 'exports': ['Font'] } + + +More information can be found in the official documentation_. + + +.. _documentation: https://ckeditor.com/docs/ckeditor5/latest/features/font.html + +.. index:: RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.4.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst b/Documentation/Changelog/12.4.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst new file mode 100644 index 0000000..7dcfc90 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-100889-AllowInsecureSiteResolutionByQueryParameters.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _important-100889-1690476871: + +======================================================================= +Important: #100889 - Allow insecure site resolution by query parameters +======================================================================= + +See :issue:`100889` + +.. important:: + This change was introduced as part of the + `TYPO3 v12.4.4 and v11.5.30 security releases <https://typo3.org/security/advisory/typo3-core-sa-2023-003>`__. + +Description +=========== + +Resolving sites by the `id` and `L` HTTP query parameters is now denied by +default. However, it is still allowed to resolve a particular page by, for +example, "example.org" - as long as the page ID `123` is in the scope of the +site configured for the base URL "example.org". + +The new feature flag +`security.frontend.allowInsecureSiteResolutionByQueryParameters` - which is +disabled per default - can be used to reactivate the previous behavior: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.allowInsecureSiteResolutionByQueryParameters'] = true; + + +Impact +====== + +Resolving a page via query parameters is now restricted to the specific +site where the page is located. + +Affected installations +====================== + +Installations which resolve pages from one domain via another domain. + +.. index:: Frontend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-100925-UseDedicatedCacheForDatabaseSchemaInformation.rst b/Documentation/Changelog/12.4.x/Important-100925-UseDedicatedCacheForDatabaseSchemaInformation.rst new file mode 100644 index 0000000..4abd63c --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-100925-UseDedicatedCacheForDatabaseSchemaInformation.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _important-100925-1686234441: + +======================================================================== +Important: #100925 - Use dedicated cache for database schema information +======================================================================== + +See :issue:`100925` + +Description +=========== + +To implement native JSON database field and TCA `type=json` +support for TYPO3 v12 the need to cache the database schema +information raised due to performance reason. + +Using the core cache for schema information comes with +various drawbacks: + +#. There is no way to flush single core cache entries, + thus the complete core cache needs to be flushed when + changing the database schema. +#. The PHP Frontend provides no benefit, when the to be cached + information has to be serialized anyway. + +Therefore, a new cache is introduced that can be flushed +individually after schema updates. + +Additionally, some internal steps taken to mitigate some side +effects are reverted. They are no longer needed with the dedicated +cache. + +Due to the nature of the chosen cache no database updates, +configuration changes or other steps are needed. + +.. index:: Database, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-101128-CkeditorsHighlightPluginIntroducesMarkHTMLTag.rst b/Documentation/Changelog/12.4.x/Important-101128-CkeditorsHighlightPluginIntroducesMarkHTMLTag.rst new file mode 100644 index 0000000..44926e8 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-101128-CkeditorsHighlightPluginIntroducesMarkHTMLTag.rst @@ -0,0 +1,31 @@ +.. include:: /Includes.rst.txt + +.. _important-101128-1723726464: + +=========================================================================== +Important: #101128 - CKEditor's highlight plugin introduces `mark` HTML tag +=========================================================================== + +See :issue:`101128` + +Description +=========== + +The introduction of the CKEditor plugin :js:`@ckeditor/ckeditor5-language` +allows an editor to use the :html:`mark` tag, as well as the :html:`s` tag. + +It may become necessary to explicitly allow this tag in the +:typoscript:`lib.parseFunc_RTE` TypoScript setup to allow the tag to be +rendered properly in the frontend: + +.. code-block:: typoscript + + lib.parseFunc_RTE { + allowTags := addToList(mark,s) + } + +Custom CSS styling for different markers classes needs to be +implemented in a sitepackage for example, as no frontend +CSS for this is emitted by default. + +.. index:: Frontend, RTE, TSConfig, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-101567-UseSymfonyAttributeToAutoconfigureCliCommands.rst b/Documentation/Changelog/12.4.x/Important-101567-UseSymfonyAttributeToAutoconfigureCliCommands.rst new file mode 100644 index 0000000..dc6d61c --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-101567-UseSymfonyAttributeToAutoconfigureCliCommands.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _important-101567-1691227840: + +======================================================================== +Important: #101567 - Use Symfony attribute to autoconfigure cli commands +======================================================================== + +See :issue:`101567` + +Description +=========== + +The Symfony PHP attribute :php:`\Symfony\Component\Console\Attribute\AsCommand` +is now accepted to register console commands. +This way CLI commands can be registered by setting the attribute on the command +class. Only the parameters `command`, `description`, `aliases` and `hidden` are +still viable. In order to overwrite the schedulable parameter use the old +:file:`Services.yaml` way to register console commands. By default `schedulable` +is true. + +Before: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Services.yaml + + MyVendor\MyExtension\Command\MyCommand: + tags: + - name: 'console.command' + command: 'myprefix:dofoo' + description: 'My description' + schedulable: true + - name: 'console.command' + command: 'myprefix:dofoo-alias' + alias: true + +After: + +The registration can be removed from the :file:`Services.yaml` file and the +attribute is assigned to the command class instead: + +.. code-block:: php + :caption: EXT:my_extension/Classes/Command/MyCommand.php + + <?php + + namespace MyVendor\MyExtension\Command; + + use Symfony\Component\Console\Command\Command; + use Symfony\Component\Console\Attribute\AsCommand; + + #[AsCommand( + name: 'myprefix:dofoo', + description: 'My description', + aliases: ['myprefix:dofoo-alias'] + )] + class MyCommand extends Command + { + } + + +Impact +====== + +The registration of cli commands is simplified that way. +When using this attribute there is no need to register the command in the +:file:`Services.yaml` file. Existing configurations work as before. + +.. index:: Backend, CLI, PHP-API, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst b/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst new file mode 100644 index 0000000..7381655 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _important-101580-1723653576: + +=========================================================================== +Important: #101580 - Introduce Content-Security-Policy-Report-Only handling +=========================================================================== + +See :issue:`101580` + +Description +=========== + +The feature flag `security.frontend.reportContentSecurityPolicy` +(:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.reportContentSecurityPolicy']`) +can be used to apply the `Content-Security-Policy-Report-Only` HTTP header for +frontend responses. + +When both feature flags are activated, both headers are sent. +You can deactivate one disposition in the site-specific configuration. + +This allows to test and assess the potential impact on introducing +Content-Security-Policy in the frontend - without actually blocking +any functionality. + +This behavior can be controlled on a site-specific scope as well, see +:ref:`Important: #104549 - Introduce site-specific Content-Security-Policy-Disposition <important-104549-1723461851>`. + +.. index:: Frontend, LocalConfiguration, ext:frontend diff --git a/Documentation/Changelog/12.4.x/Important-101776-ChangeInEmailValidation.rst b/Documentation/Changelog/12.4.x/Important-101776-ChangeInEmailValidation.rst new file mode 100644 index 0000000..1f9a638 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-101776-ChangeInEmailValidation.rst @@ -0,0 +1,27 @@ +.. include:: /Includes.rst.txt + +.. _important-101776-1694342579: + +=================================================================================================== +Important: #101776 - Email validation in GeneralUtility::validEmail() now rejects spaces before "@" +=================================================================================================== + +See :issue:`101776` + +Description +=========== + +The :php:`GeneralUtility::validEmail()` method uses the package :composer:`egulias/email-validator` +for validating emails. +This library treats an email address like :samp:`email @example.com` with a space before the `@` +character as valid, but issues a warning, which has previously not been caught by TYPO3. Warnings +like these are defined as "deviations from the RFC that in a broader interpretation are accepted." + +In the context of TYPO3, such non-RFC email address shall be rejected. +Thus, this specific warning (:php:`CFWSNearAt`) will now be caught, and the warning is turned into an +invalidation of the given email address. + +This will have the effect, that if integrators previously accepted email addresses formatted like +these, validation will now fail (as the RFC implies). + +.. index:: Backend, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-102314-TitleCoreIconViewhelper.rst b/Documentation/Changelog/12.4.x/Important-102314-TitleCoreIconViewhelper.rst new file mode 100644 index 0000000..c3542e3 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-102314-TitleCoreIconViewhelper.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _important-102314-1699259952: + +========================================================= +Important: #102314 - Add title argument to IconViewhelper +========================================================= + +See :issue:`102314` + +Description +=========== + +The `IconViewhelper` in EXT:core has been extended with a new argument `title`. +The new argument allows to set a corresponding title, which will be rendered +as `title` attribute in the icon HTML markup. The `title` attribute will only +be rendered, if explicitly passed. You can also pass an empty string. + +This `title` attribute will improve accessibility, since screenreaders can +choose not to ignore aria-hidden elements (e.g. the icons above the page tree), +which is a mode people with low visibility might choose. If a `title` attribute +is missing, a purely technical output will be given, which is very hard to +make sense of. + +Example +======= + +.. code-block:: html + + <core:icon title="Open actions menu" identifier="actions-menu" /> + +This will be rendered as: + +.. code-block:: html + + <span + title="Open actions menu" + class="t3js-icon icon icon-size-small icon-state-default icon-actions-menu" + data-identifier="actions-menu" aria-hidden="true" + > + <span class="icon-markup"> + <img + src="/typo3/sysext/core/Resources/Public/Icons/T3Icons/actions/actions-menu.svg" + width="16" + height="16" + > + </span> + </span> + +.. index:: Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-102507-DefaultCKEditor5AllowedClassesAndDataAttributesConfiguratedReverted.rst b/Documentation/Changelog/12.4.x/Important-102507-DefaultCKEditor5AllowedClassesAndDataAttributesConfiguratedReverted.rst new file mode 100644 index 0000000..5039d22 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-102507-DefaultCKEditor5AllowedClassesAndDataAttributesConfiguratedReverted.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _important-102507-1702317316: + +================================================================================================= +Important: #102507 - Default CKEditor 5 allowed classes and data attributes configurated reverted +================================================================================================= + +See :issue:`102507` + +Description +=========== + +With TYPO3 v12.4.7 (see :issue:`99738`) an option to allow all classes in +CKEditor 5 has been enabled in the TYPO3 default configuration which implicitly +caused all custom html elements to be allowed. This rule has now been dropped +from the default configuration: + +.. code-block:: yaml + + editor: + config: + htmlSupport: + allow: + - { classes: true, attributes: { pattern: 'data-.+' } } + +The configuration matched to any HTML element available in the CKEditor 5 General +HTML Support (GHS) schema definition. +This became an issue, since CKEditor 5 relies on the set of allowed elements and +classes when processing content that is pasted from Microsoft Office. + +Installations that relied on the fact that v12.4.7 allowed all CSS classes in +CKEditor 5 should encode the set of available style definitions via +:yaml:`editor.config.style.definitions` which will make them accessible to editors +via the style dropdown toolbar element: + +.. code-block:: yaml + + editor: + config: + style: + definitions: + - { name: "Descriptive Label", element: "p", classes: ['my-class'] } + + +Custom data attributes can be allowed via General HTML Support: + +.. code-block:: yaml + + editor: + config: + htmlSupport: + allow: + - { name: 'div', attributes: ['data-foobar'] } + +.. index:: RTE, YAML, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.4.x/Important-102904-UseTCAGroupFieldAsForeignSelector.rst b/Documentation/Changelog/12.4.x/Important-102904-UseTCAGroupFieldAsForeignSelector.rst new file mode 100644 index 0000000..173cca3 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-102904-UseTCAGroupFieldAsForeignSelector.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _important-102904-1706702424: + +============================================================ +Important: #102904 - Use TCA group field as foreign selector +============================================================ + +See :issue:`102904` + +Description +=========== + +When using TCA type :php:`inline`, developers have the possibility to use the +"foreign selector" feature by defining the :php:`foreign_selector` option, +pointing to a field on the foreign (child) table. This way, editors can +use the corresponding selector field to choose existing child records, +to create a new inline relation. This can be further extended, using the +:php:`useCombination` appearance option, which allows to modify the child record +via the parent record globally. + +The field referenced in :php:`foreign_selector` is usually a field with TCA type +:php:`select`, using the `foreign_table` option itself to provide the corresponding +items to choose. + +It's nevertheless also possible to use a TCA type :php:`group` field as +:php:`foreign_selector`. In this case, the child records have to be selected +from the table, defined via the :php:`allowed` option. For this use case, +**only one table** can be defined. This means, the first table name in +:php:`allowed` is taken, no matter if there are multiple table names defined. + +.. note:: + + This unfortunately does not work out of the box for Extbase. Therefore, the + corresponding table has to be defined additionally via the :php:`foreign_table` + option. This option is only used as a + `workaround <https://docs.typo3.org/m/typo3/reference-tca/main/en-us/ColumnsConfig/Type/Group/Properties/ForeignTable.html>`__ + by Extbase and is not sufficient for the TYPO3 Form editor, which will always + just consider the value from the :php:`allowed` option. + +Example using an intermediate table and the :php:`useCombination` feature: + +.. code-block:: php + + // Inline field in parent table "tx_extension_inline_usecombination" + 'inline' => [ + 'label' => 'inline', + 'config' => [ + 'type' => 'inline', + 'foreign_table' => 'tx_extension_inline_usecombination_mm', // Referencing the intermediate table + 'foreign_field' => 'group_parent', + 'foreign_selector' => 'group_child', + 'foreign_unique' => 'group_child', + 'appearance' => [ + 'useCombination' => true, + ], + ], + ], + + // Reference fields in intermediate table "tx_extension_inline_usecombination_mm" + 'group_parent' => [ + 'label' => 'group parent', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'tx_extension_inline_usecombination', // Referencing the parent table + ], + ], + 'group_child' => [ + 'label' => 'group child', + 'config' => [ + 'type' => 'group', + 'allowed' => 'tx_extension_inline_usecombination_child', // Referencing the child table + 'foreign_table' => 'tx_extension_inline_usecombination_child', // ONLY USED FOR extbase! + ], + ], + + // Child table "tx_extension_inline_usecombination_child" does not have any relation fields + +.. index:: Backend, PHP-API, TCA, ext:backend diff --git a/Documentation/Changelog/12.4.x/Important-103117-FormFrameworkSelectMarkupChanged.rst b/Documentation/Changelog/12.4.x/Important-103117-FormFrameworkSelectMarkupChanged.rst new file mode 100644 index 0000000..b33648e --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-103117-FormFrameworkSelectMarkupChanged.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _important-103392-1710345611: + +========================================================= +Important: #103392 - Form framework select markup changed +========================================================= + +See :issue:`103392` + +Description +=========== + +With :issue:`103117`, the `elementClassAttribute` of the "SingleSelect", +"CountrySelect" and "MultiSelect" fields got changed from `form-control` to +`form-select` in EXT:form, as defined by `Bootstrap`_, if the Bootstrap 5 markup +(:yaml:`templateVariant: version2`) is used. + +If needed, the old markup can be restored by overriding the configuration as +follows: + +.. code-block:: yaml + :emphasize-lines: 9,15,21 + + prototypes: + standard: + formElementsDefinition: + CountrySelect: + variants: + - + identifier: template-variant + properties: + elementClassAttribute: form-control + MultiSelect: + variants: + - + identifier: template-variant + properties: + elementClassAttribute: form-control + SingleSelect: + variants: + - + identifier: template-variant + properties: + elementClassAttribute: form-control + +.. _Bootstrap: https://getbootstrap.com/docs/5.3/forms/select/ + +.. index:: Frontend, ext:form diff --git a/Documentation/Changelog/12.4.x/Important-103496-ISOFormatUsedForDateRendering.rst b/Documentation/Changelog/12.4.x/Important-103496-ISOFormatUsedForDateRendering.rst new file mode 100644 index 0000000..e648f4e --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-103496-ISOFormatUsedForDateRendering.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _important-103496-1711623416: + +======================================================= +Important: #103496 - ISO format used for date rendering +======================================================= + +See :issue:`103496` + +Description +=========== + +The default format for date rendering configured in :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy']` has changed. + +The former arbitrary :php:`'d-m-y'` format was replaced with the standard ISO 8601 :php:`'Y-m-d'` format. + +Examples of dates where the :php:`'d-m-y'` format led to unclear dates: + +* A 2-digit year could also be a day in a month: `21-04-23` could be understood as `2021-04-23` instead of `2023-04-21`. +* The century of years could not be distinguished: `21-04-71` could be `2071-04-21` or `1971-04-21` + +This affects date display in various locations so code relying on the previous format (e.g. acceptance tests) must be updated accordingly. + +.. index:: Backend, CLI, Frontend, TCA, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst b/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst new file mode 100644 index 0000000..e740e31 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst @@ -0,0 +1,135 @@ +.. include:: /Includes.rst.txt + +.. _important-104549-1723461851: + +================================================================================ +Important: #104549 - Introduce site-specific Content-Security-Policy-Disposition +================================================================================ + +See :issue:`104549` + +Description +=========== + +The feature flags :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.enforceContentSecurityPolicy']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.reportContentSecurityPolicy']` apply +Content-Security-Policy headers to any frontend site. The dedicated :file:`sites/<my-site>/csp.yaml` can now be +used as alternative to declare the desired disposition of `Content-Security-Policy` and +`Content-Security-Policy-Report-Only` individually. + +It now is also possible, to apply both `Content-Security-Policy` and `Content-Security-Policy-Report-Only` +HTTP headers at the same time with different directives for a particular site. Besides that it is possible +to disable the disposition completely for a site. + +The following new configuration schemes were introduced for :file:`sites/<my-site>/csp.yaml`: + +* `active (false)` for disabling CSP for a particular site, which overrules any other setting for `enforce` or `report` +* `enforce (bool|disposition-array)` for compiling the `Content-Security-Policy` HTTP header +* `report (bool|disposition-array)` for compiling the `Content-Security-Policy-Report-Only` HTTP header + +The `disposition-array` for `enforce` and `report` allows these properties: + +* `inheritDefault (bool)` inherits default site-unspecific frontend policy mutations (`true` per default) +* `includeResolutions (bool)` includes dynamic resolutions, as persisted in the database via backend module (`true` per default) +* `mutations (mutation-item-array)` defines additional directive mutations to be applied to the specific site +* `packages (package-item-array)` defines packages/extensions whose static CSP mutations shall be dropped or included + +Example: Disable Content-Security-Policy +---------------------------------------- + +The following example would completely disable CSP for a particular site. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + # `active` is enabled per default if omitted + active: false + +Example: Use `report` disposition +--------------------------------- + +The following example would dispose only `Content-Security-Policy-Report-Only` +for a particular site (since the `enforce` property is not given). + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + report: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + +The following example is equivalent to the previous, but shows that the +legacy configuration (having `inheritDefault` and `mutations` on the top-level) +is still supported. + +The effective HTTP headers would then be resolved from the active feature flags +`security.frontend.enforceContentSecurityPolicy` and +`security.frontend.reportContentSecurityPolicy` - in case both flags are active, +both HTTP headers `Content-Security-Policy` and `Content-Security-Policy-Read-Only` +would be used. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + +Example: Use `enforce` and `report` dispositions at the same time +----------------------------------------------------------------- + +The following example would dispose `Content-Security-Policy` (`enforce`) +and `Content-Security-Policy-Report-Only` (`report`) for a particular site. + +This allows to test new CSP directives in the frontend - the example drops +the static CSP directives of the package `my-vendor/my-package` in the +enforced disposition and only applies it to the reporting disposition. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + enforce: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + # `includeResolutions` is enabled per default if omitted + includeResolutions: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + packages: + # all (`*`) packages shall be included (`true`) + '*': true + # the package `my-vendor/my-package` shall be dropped (`false`) + my-vendor/my-package: false + + report: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + # `includeResolutions` is enabled per default if omitted + includeResolutions: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.my-vendor.example.org/ + # the `packages` section can be omitted in this case, since all packages + # listed there shall be included - which is the default behavior in case + # `packages` would not be configured + packages: + # all (`*`) packages shall be included (`true`) + '*': true + # the package `my-vendor/my-package` shall be included (`true`) + my-vendor/my-package: true + +.. index:: Frontend, YAML, ext:frontend diff --git a/Documentation/Changelog/12.4.x/Important-104693-SettingAllowLanguageSynchronizationViaColumnsOverrides.rst b/Documentation/Changelog/12.4.x/Important-104693-SettingAllowLanguageSynchronizationViaColumnsOverrides.rst new file mode 100644 index 0000000..c194806 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-104693-SettingAllowLanguageSynchronizationViaColumnsOverrides.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _important-104693-1725960199: + +============================================================================== +Important: #104693 - Setting allowLanguageSynchronization via columnsOverrides +============================================================================== + +See :issue:`104693` + +Description +=========== + +Setting the TCA option :php:`allowLanguageSynchronization` for a specific +column in a record type via :php:`columnsOverrides` is currently not supported +by TYPO3 and therefore might lead to exceptions in the corresponding field wizard +(:php:`LocalizationStateSelector`). To mitigate this, the option is now +automatically removed from the TCA configuration via a TCA migration. A +corresponding deprecation log entry is added to inform integrators about +the necessary code adjustments. + +Migration +========= + +Remove the :php:`allowLanguageSynchronization` option from :php:`columnsOverrides` +for now. + +.. code-block:: php + + // Before + 'types' => [ + 'text' => [ + 'showitem' => 'header', + 'columnsOverrides' => [ + 'header' => [ + 'config' => [ + 'behaviour' => [ + 'allowLanguageSynchronization' => true + ], + ], + ], + ], + ], + ], + + // After + 'types' => [ + 'text' => [ + 'showitem' => 'header', + ], + ], + +.. index:: Backend, TCA, ext:backend diff --git a/Documentation/Changelog/12.4.x/Important-104827-AllowToUseRegularExpressionsInCKEditorYAML.rst b/Documentation/Changelog/12.4.x/Important-104827-AllowToUseRegularExpressionsInCKEditorYAML.rst new file mode 100644 index 0000000..afa9990 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-104827-AllowToUseRegularExpressionsInCKEditorYAML.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _important-104827-1725611875: + +====================================================================== +Important: #104827 - Allow to use Regular Expressions in CKEditor YAML +====================================================================== + +See :issue:`104827` + +Description +=========== + +The CKEditor plugin can now be configured with YAML syntax utilizing +Regular Expression objects for certain keys. By defining a Regular Expression, +the CKEditor replacement/transformation functionality feature is now fully +usable. + +The CKEditor 5 configuration API allows to specify +Regular Expression JavaScript objects, for example in +:javascript:`editor.config.typing.transformations.extra.from` or +:javascript:`editor.config.htmlSupport.allow.name`: + +.. code-block:: javascript + :caption: Example CKEditor JavaScript configuration excerpt + + // part of `editor.config` + { + typing: { + transformations: { + extra: { + from: /(tsconf|t3ts)$/, + to: 'TYPO3 TypoScript TSConfig' + } + } + } + htmlSupport: { + allow: { + name: /^(div|section|article)$/ + } + } + } + +When TYPO3 passes YAML configuration of the CKEditor forward +to JavaScript, it uses a html-entity encoded representation, +which does not allow to utilize Regular Expression objects, +and also the CKEditor API method `buildQuotesRegExp()` is not +usable in this scenario. + +This was remedied already for the configuration key :yaml:`htmlSupport` +with its sub-keys, so that when a YAML key named :yaml:`pattern` +was found, TYPO3 automatically converted that to a proper JavaScript +Regular Expression: + +.. code-block:: yaml + :caption: Example YAML RTE configuration excerpt + + editor: + config: + htmlSupport: + allow: + - { name: { pattern: '^(div|section|article)$', flags: '' } } + +.. important:: + + Please note that the `/` character from the beginning and end + of the regular expression must not be specified manually in YAML. + Also take care of the ending `$` character, which is vital to CKEditor's + proper parsing of a rule. The :yaml:`flags` key can contain Regular + Expression flags, and can also be omitted. + +This is now also possible for the `editor.config.typing.transformations` +structure: + +.. code-block:: yaml + :caption: Example YAML RTE configuration excerpt + + editor: + config: + typing: + transformations: + extra: + - { from: { pattern: '(tsconf|t3ts)$', flags: '' }, to: 'TYPO3 TypoScript TSConfig' } + +This conversion of Regular Expressions must be explicitly applied to +CKEditor configuration keys within the TYPO3 API, and cannot be used +generally for every key. + +Thus, using a :yaml:`pattern` sub-key is currently applied only to the following +configuration structures (and recursively their sub-structures): + +* :yaml:`editor.config.typing.transformations` +* :yaml:`editor.config.htmlSupport` + +.. hint:: + + This means, that the `pattern` sub-key can be used for all of: + + * :yaml:`editor.config.htmlSupport.[...].name` + * :yaml:`editor.config.htmlSupport.[...].styles` + * :yaml:`editor.config.htmlSupport.[...].classes` + * :yaml:`editor.config.htmlSupport.[...].attributes` + * :yaml:`editor.config.typing.transformations.extra[...].from` + + +.. index:: RTE, , ext:rte_ckeditor diff --git a/Documentation/Changelog/12.4.x/Important-104839-RTEProcessingConfigurationRespectsRemoveTags.rst b/Documentation/Changelog/12.4.x/Important-104839-RTEProcessingConfigurationRespectsRemoveTags.rst new file mode 100644 index 0000000..aece7fd --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-104839-RTEProcessingConfigurationRespectsRemoveTags.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _important-104839-1726124400: + +================================================================================ +Important: #104839 - RTE processing YAML configuration now respects `removeTags` +================================================================================ + +See :issue:`104839` + +Description +=========== + +.. important:: + + Short version: With this bugfix, any save process + to the contents of an existing RTE element will now properly apply + the :yaml:`removeTags` default configuration (unless configured otherwise). + + To prevent a breaking change, the tags :html:`center`, :html:`font`, :html:`strike` and + :html:`u` are now allowed to be saved by default (like it was with the bug in + effect). This is planned to be changed with TYPO3 v14 as a breaking change. + + The unexpected tags :html:`link`, :html:`meta`, :html:`o:p`, :html:`sdfield`, + :html:`style`, :html:`title` will now be removed. + + This behaviour can always be customized by setting :yaml:`removeTags` appropriately. + +TYPO3 allows to configure which HTML tags are allowed to be persisted +to the database in case of Richtext-elements. This can be configured +either within the CKEditor YAML context, or via Page TSconfig: + +.. code-block:: yaml + :caption: EXT:rte_ckeditor/Configuration/RTE/Processing.yaml + + processing: + HTMLparser_db: + # previous default: center, font, link, meta, o:p, sdfield, + # style, title, strike, u + removeTags: [link, meta, o:p, sdfield, style, title] + +.. code-block:: typoscript + :caption: EXT:my_extension/Configuration/TypoScript/page.tsconfig + + RTE.default.proc { + HTMLparser_db { + removeTags = link, meta, o:p, sdfield, style, title + } + } + +Due to a bug in interpreting the YAML configuration, the syntax using +an array was actually never in effect. + +This means, any implementation relying on such a YAML configuration (without +providing Page TSconfig), would not have removed the listed tags. + +Due to TYPO3's internal processing, from those tags listed above, +the previous default tags :html:`center`, :html:`font`, :html:`strike` and + :html:`u` were persisted to the database and also later evaluated in the frontend. + +The other tags :html:`link`, :html:`meta`, :html:`o:p`, :html:`sdfield`, +:html:`style` and :html:`title` were displayed as HTML encoded entities +due to other sanitizing in the output (but still stored as HTML tags in the database). + +These tags will no longer be stored by default now, and is considered a non-breaking +bugfix, because these tags should not occur within an RTE. + +This wrong parsing has now been fixed, so that now both an `array` syntax as +well as `string` syntax is allowed in the YAML processing and will +be applied. Adapting the :yaml:`removeTags` setting allows to change +the now applied defaults to any tag configuration needed. + +.. hint:: + + Custom YAML configuration that used a `string` representation of :yaml:`removeTags` + (instead of an `array`) was already properly evaluated. + + This bugfix has not been backported to TYPO3 v11 installations, to prevent + a change of behaviour in a security-maintenance-only environment. If this fix + is needed, you can convert the CKEditor array syntax by removing the square + brackets in a :file:`Processing.yaml` override: + + .. code-block:: yaml + + removeTags: [center, font, link, meta, o:p, sdfield, strike, style, title, u] + + to: + + .. code-block:: yaml + + removeTags: center, font, link, meta, o:p, sdfield, strike, style, title, u + +Affected installations +====================== + +TYPO3 setups with RTE YAML configurations utilizing either a custom +:yaml:`removeTags` processing directive or the default, defined via `array` +notation instead of `string`. + +Migration +========= + +Adjust the RTE YAML configuration processing directive `removeTags` +to suit the expected tag removal, or accept the new defaults. + +.. index:: RTE, TSConfig, Backend, NotScanned diff --git a/Documentation/Changelog/12.4.x/Important-105856-AllowSite-specificContent-Security-PolicyEndpoints.rst b/Documentation/Changelog/12.4.x/Important-105856-AllowSite-specificContent-Security-PolicyEndpoints.rst new file mode 100644 index 0000000..acb8cd3 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-105856-AllowSite-specificContent-Security-PolicyEndpoints.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _important-105856-1737555887: + +========================================================================== +Important: #105856 - Allow site-specific Content-Security-Policy endpoints +========================================================================== + +See :issue:`105856` + +Description +=========== + +The way Content-Security-Policy reporting endpoints are configured has +been enhanced. Administrators can now disable the reporting endpoint +globally or configure it per site as needed. + +The global scope-specific setting `contentSecurityPolicyReportingUrl` can +be set to zero ('0') to disable the CSP reporting endpoint: + +* :php:`[TYPO3_CONF_VARS][FE][contentSecurityPolicyReportingUrl] = '0'` +* :php:`[TYPO3_CONF_VARS][BE][contentSecurityPolicyReportingUrl] = '0'` + +Additionally, the behavior of the reporting endpoint can also be +configured per site via :file:`sites/<my-site>/csp.yaml`. + +The new disposition-specific property `reportingUrl` can either be: + +* `reportingUrl (true)` to enable the reporting endpoint +* `reportingUrl (false)` to disable the reporting endpoint +* `reportingUrl (string)` to use the given value as external reporting endpoint + +If defined, the site-specific configuration takes precedence over +the global configuration. + +In case the explicitly disabled endpoint still would be called, the +server-side process responds with a 403 HTTP error message. + +Example: Disabling the reporting endpoint +----------------------------------------- + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + enforce: + inheritDefault: true + mutations: {} + reportingUrl: false + +Example: Using custom external reporting endpoint +------------------------------------------------- + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + enforce: + inheritDefault: true + mutations: {} + reportingUrl: https://example.org/csp-report + +.. index:: Backend, Frontend, YAML, ext:backend diff --git a/Documentation/Changelog/12.4.x/Important-106229-AllowFilteringRequestHostsInWebhookMessages.rst b/Documentation/Changelog/12.4.x/Important-106229-AllowFilteringRequestHostsInWebhookMessages.rst new file mode 100644 index 0000000..445cb73 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-106229-AllowFilteringRequestHostsInWebhookMessages.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _important-106229-1747304339: + +====================================================================== +Important: #106229 - Allow filtering request hosts in webhook messages +====================================================================== + +See :issue:`106229` + +Description +=========== + +To protect against DNS rebinding, the list of allowed hostnames that webhook +handlers will connect to can be configured as a list in +:php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']['webhooks']`. + +To add a host to the allowlist, it can be appended to the mentioned array. + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']['webhooks'][] = 'example.com'; + + +You can substitute parts of the domain with a wildcard character :php:`'*'` +(matches one or multiple characters, no regex syntax supported). +For example, :php:`'*.example.com'` is valid, and accepts all domains ending in +`.example.com`, also `foo.bar.example.com`: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']['webhooks'][] = '*.example.com'; + +By default – when the `webhooks` key in `allowed_hosts` is unset or null – all +hosts are allowed. + +An empty array will cause all webhooks requests to be blocked: + +.. code-block:: php + + // Block all webhook targets by specifying an empty array. + // You might better want to remove ext:webhooks if you want to do this. + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']['webhooks'] = []; + +.. index:: LocalConfiguration, ext:webhooks diff --git a/Documentation/Changelog/12.4.x/Important-106240-EnforceFile-extensionsAndMime-typeConsistencyInFileAbstractionLayer.rst b/Documentation/Changelog/12.4.x/Important-106240-EnforceFile-extensionsAndMime-typeConsistencyInFileAbstractionLayer.rst new file mode 100644 index 0000000..762a11c --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-106240-EnforceFile-extensionsAndMime-typeConsistencyInFileAbstractionLayer.rst @@ -0,0 +1,79 @@ +.. include:: /Includes.rst.txt + +.. _important-106240-1747316969: + +=============================================================================================== +Important: #106240 - Enforce File Extension and MIME-Type Consistency in File Abstraction Layer +=============================================================================================== + +See :issue:`106240` + +Description +=========== + +The following methods of :php:`ResourceStorage` have been improved to enhance +consistency and security for both existing and uploaded files: + +* :php:`addFile` +* :php:`renameFile` +* :php:`replaceFile` +* :php:`addUploadedFile` + +Key enhancements +---------------- + +* Only explicitly allowed file extensions are accepted. These must be configured + under the following sub-properties in :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']`: + :php:`textfile_ext`, :php:`mediafile_ext`, or :php:`miscfile_ext`. +* Files are only accepted if their MIME type matches the expected file extension. + The MIME type is determined based on the actual file content. For example, + uploading a real PNG image with the filename `image.exe` will be rejected, + because `image/png` is not a valid MIME type for the `exe` extension. + +New Configuration Property in `$GLOBALS['TYPO3_CONF_VARS']['SYS']` +------------------------------------------------------------------ + +A new configuration property, :php:`miscfile_ext`, has been introduced. It +allows specifying file extensions that don't belong to either `textfile_ext` +or `mediafile_ext`, such as `zip` or `xz`. + +New Feature Flags +----------------- + +* :php:`security.system.enforceAllowedFileExtensions`: + Controls whether only the configured file extensions are permitted. + - **Disabled by default** in existing installations. + - **Enabled by default** in new installations. +* :php:`security.system.enforceFileExtensionMimeTypeConsistency`: + Controls whether the MIME type and file extension consistency check + is enforced. + +Exemptions +---------- + +Some use cases—such as importing files through internal low-level system +components—may require temporary exemptions from the above restrictions. + +The following example shows how to define a one-time exemption for a known +and controlled operation: + +.. code-block:: php + + <?php + class ImportCommand + { + use \TYPO3\CMS\Core\Resource\ResourceInstructionTrait; + + protected function execute(): void + { + // ... + + // Skip the consistency check once for the specified storage, source, and target + $this->skipResourceConsistencyCheckForCommands($storage, $temporaryFileName, $targetFileName); + + /** @var \TYPO3\CMS\Core\Resource\File $file */ + $file = $storage->addFile($temporaryFileName, $targetFolder, $targetFileName); + } + } + +.. index:: FAL, LocalConfiguration, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-106715-ApplyCSPSandboxModeToFileadminsHtaccessConfiguration.rst b/Documentation/Changelog/12.4.x/Important-106715-ApplyCSPSandboxModeToFileadminsHtaccessConfiguration.rst new file mode 100644 index 0000000..8843763 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-106715-ApplyCSPSandboxModeToFileadminsHtaccessConfiguration.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _important-106715-1747646438: + +================================================================================== +Important: #106715 - Apply CSP sandbox mode to fileadmin's .htaccess configuration +================================================================================== + +See :issue:`106715` + +Description +=========== + +The directive `Content-Security-Policy: sandbox;` restricts +several client-side actions for files that may contain markup +(e.g., HTML, SVG): + +* Disallows downloads +* Disallows form submissions +* Disallows modals and popups +* Disallows orientation and pointer lock +* Disallows presentation sessions +* Disallows navigation of the top-level browsing context + +This applies only to resources located in the default file storage +location (e.g., `/fileadmin/`). Rendering Fluid templates from a +different location within the CMS application uses TYPO3’s dynamic +CSP feature instead. + +Since the file :file:`/fileadmin/.htaccess` is not automatically updated +once it has been created in a TYPO3 installation, maintainers must manually +adjust the web server configuration. + +Below are the required changes to introduce the `sandbox` directive: + +.. code-block:: diff + + <IfModule mod_headers.c> + # matching requested *.pdf files only (strict rules block Safari showing PDF documents) + <FilesMatch "\.pdf$"> + Header set Content-Security-Policy "default-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'self'; plugin-types application/pdf;" + </FilesMatch> + # matching requested *.svg files only (allows using inline styles when serving SVG files) + <FilesMatch "\.svg"> + - Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; object-src 'none';" + + Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; object-src 'none'; sandbox;" + </FilesMatch> + # matching anything else, using negative lookbehind pattern + <FilesMatch "(?<!\.(?:pdf|svg))$"> + - Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'none'; object-src 'none';" + + Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'none'; object-src 'none'; sandbox;" + </FilesMatch> + +.. index:: ext:install diff --git a/Documentation/Changelog/12.4.x/Important-106735-FileMIMETypeCompatiblityMapping.rst b/Documentation/Changelog/12.4.x/Important-106735-FileMIMETypeCompatiblityMapping.rst new file mode 100644 index 0000000..5aa8206 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-106735-FileMIMETypeCompatiblityMapping.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _important-106735-1748270977: + +======================================================== +Important: #106735 - File MIME Type compatiblity mapping +======================================================== + +See :issue:`106735` + +Description +=========== + +With :issue:`106240` mime type hardening has been established in order to ensure +that file extensions of uploaded files and their contents are consistent in +order to avoid sneaking in malicious files with faked file extensions or to +bypass file extension limitations. + +Since PHP file detection methods can not reliable detect all IANA defined MIME +types, mime-db based heuristics are now applied to map generic MIME types like +text/plain to text/csv for `*.csv` files. + +This mapping has been made adjustable for MIME types via +:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility']` +where for each generic MIME type (as detected by PHP MIME type detection) a +map from file extension to allowed concrete MIME type can be supplied. + + +.. code-block:: php + :caption: Configure a custom MIME type to be mapped from a detected generic type + + // Example that is already shipped with TYPO3, a *.jfif file that is + // detected as image/jpeg is mapped to image/pjpeg, which is the + // defined MIME type per IANA and enforced by the FAL persistence layer. + $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility']['image/jpeg']['jfif'] = + 'image/pjpeg'; + + // Generic example, which allows a file ending in `*.foo` that is detected + // to contain text/plain contents to be mapped to the MIME type text/x-foo, + // other contents (e.g. if the file contains binary data) will not be mapped + $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility']['text/plain']['foo'] = + 'text/x-foo'; + + +.. index:: FAL, ext:core diff --git a/Documentation/Changelog/12.4.x/Important-106983-HardenedAccessToModule-relatedAJAXRoutes.rst b/Documentation/Changelog/12.4.x/Important-106983-HardenedAccessToModule-relatedAJAXRoutes.rst new file mode 100644 index 0000000..63ef815 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-106983-HardenedAccessToModule-relatedAJAXRoutes.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _important-106983-1750962567: + +================================================================== +Important: #106983 - Hardened access to module-related AJAX routes +================================================================== + +See :issue:`106983` + +Description +=========== + +AJAX routes which are exclusively used in a specific backend module can now be +configured to inherit access from the respective module. A new configuration +option :php:`inheritAccessFromModule` is introduced to control this behavior. +It is already added to several existing AJAX routes shipped by TYPO3 core. + +Requests to routes with an appropriate access check in place will result in a +403 response if the current backend user lacks required permissions. + +Example configuration +===================== + +In the following example, the `mymodule_myroute` AJAX route inherits access +checks from the `mymodule` backend module: + +.. code-block:: php + :caption: EXT:my_extension/Configuration/Backend/AjaxRoutes.php + + return [ + 'mymodule_myroute' => [ + 'path' => '/mymodule/myroute', + 'target' => \MyVendor\MyExtension\Controller\MySpecialController::class . '::mySpecialAction', + 'inheritAccessFromModule' => 'mymodule', + ], + ]; + +.. index:: Backend diff --git a/Documentation/Changelog/12.4.x/Important-109176-CKEditorHardenedIframesByEnforcingSandboxBehavior.rst b/Documentation/Changelog/12.4.x/Important-109176-CKEditorHardenedIframesByEnforcingSandboxBehavior.rst new file mode 100644 index 0000000..8cd32b0 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-109176-CKEditorHardenedIframesByEnforcingSandboxBehavior.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _important-109176-1773075613: + +============================================================================ +Important: #109176 - CKEditor hardened iframes by enforcing sandbox behavior +============================================================================ + +See :issue:`109176` + +Description +=========== + +Security-related patches from CKEditor5 v47.6.0 have been back-ported to the +TYPO3 12.4.x branch. The patches harden the usage of iframes by enforcing the +sandbox_ behaviour in the General HTML Support feature's editing area. In +TYPO3 13.4 and later, the full CKEditor5 v47.6.0 upgrade_ is used instead. + +Installations that already allow iframes in their RTE configuration (e.g. for +integrating Google Maps widgets) may need to explicitly allow scripts via the +RTE YAML configuration for interactive iframes to work inside the HTML editing +area: + +.. code-block:: yaml + + editor: + config: + htmlSupport: + # If you already allow iframes in content area... + allow: + - { name: 'iframe', attributes: { src: true } } + # ...you may add `htmlIframeSandbox` to control the + # `<iframe sandbox="…">` when rendered by CKEditor + htmlIframeSandbox: [ 'allow-scripts', 'allow-same-origin' ] + + +This does not influence what is rendered in the frontend output, but only +affects the sandbox behaviour inside the CKEditor editing area. + + +.. _sandbox: https://ckeditor.com/docs/ckeditor5/latest/features/html/general-html-support.html#iframe-sandbox +.. _upgrade: https://ckeditor.com/blog/ckeditor-47-6-0-release-highlights/ + +.. index:: Backend, RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.4.x/Important-96218-UseProperSurroundingHTMLTagsForFluidSystemEmail.rst b/Documentation/Changelog/12.4.x/Important-96218-UseProperSurroundingHTMLTagsForFluidSystemEmail.rst new file mode 100644 index 0000000..3e1c4ea --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-96218-UseProperSurroundingHTMLTagsForFluidSystemEmail.rst @@ -0,0 +1,64 @@ +.. include:: /Includes.rst.txt + +.. _important-96218-1733990267: + +============================================================================ +Important: #96218 - Use proper surrounding "html" tags for Fluid SystemEmail +============================================================================ + +See :issue:`96218` + +Description +=========== + +Due to usage of :html:`data-namespace-typo3-fluid="true"` in the +:html:`<html>` declaration of the file +:file:`EXT:core/Resources/Private/Layouts/SystemEmail.html`, +the whole :html:`<html>..</html>` structure is removed from a sent +HTML mail. + +Validation and possibly utilities like SpamAssassin may fail +or negatively score these mails due to these tags being missing. + +Since the :html:`xmlns` declaration of the ViewHelpers is semantically +not wrong, it can actually be included in the email by removing +the :html:`data-namespace-typo3-fluid` attribute, instead of requiring +the alternate more intrusive Fluid ViewHelper declaration. + +Affected installations +====================== + +All setups with customizations of the file +:file:`EXT:core/Resources/Private/Layouts/SystemEmail.html` for sending +FluidEmails. + +Migration +========= + +Adjust custom copies of the file :file:`EXT:core/Resources/Private/Layouts/SystemEmail.html` +like this: + +.. code-block:: html + :caption: Before (EXT:your_extension/Resources/Private/Layouts/SystemEmail.html) + :emphasize-lines: 6 + + <html xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true"> + +into this: + +.. code-block:: html + :caption: After (EXT:your_extension/Resources/Private/Layouts/SystemEmail.html) + :emphasize-lines: 5 + + <html xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"> + +.. index:: Fluid, Frontend, ext:code, NotScanned diff --git a/Documentation/Changelog/12.4.x/Important-99781-ExportingAndDownloadingRecordsInTheListModule.rst b/Documentation/Changelog/12.4.x/Important-99781-ExportingAndDownloadingRecordsInTheListModule.rst new file mode 100644 index 0000000..20a6b92 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Important-99781-ExportingAndDownloadingRecordsInTheListModule.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _important-99781-1707215955: + +======================================================================== +Important: #99781 - Exporting and downloading records in the list module +======================================================================== + +See :issue:`99781` + +Description +=========== + +There are two different options for exporting records in the +:guilabel:`Web->List` module. + +One is using the export functionality, which is provided by EXT:impexp and is +available via the "Export" docheader button in the single table view. It is +possible to manage the display of the button using the Page TSconfig +:typoscript:`mod.web_list.noExportRecordsLinks` option. However, the export +functionality is by default disabled for non-admin users, making the button +not showing up unless the functionality is explicitly enabled for the user +with the user TSconfig :typoscript:`options.impexp.enableExportForNonAdminUser` +option. + +The "Download" functionality is available via the "Download" button in each +tables header row. It is available in both, the list and also the single table +view and can be managed using the Page TSconfig +:typoscript:`mod.web_list.displayRecordDownload` option, which is enabled by +default. Next to the general option is it also possible to set this option on +a per-table basis using the +:typoscript:`mod.web_list.table.<tablename>.displayRecordDownload` option. +In case this option is set, it takes precedence over the general option. + +.. code-block:: typoscript + + # Page TSconfig + mod.web_list { + # Disable "Export" button in docheader + noExportRecordsLinks = 1 + + # Generally disable "Download" button + displayRecordDownload = 0 + + # Enable "Download" button for table "tt_content" + table.tt_content.displayRecordDownload = 1 + } + +.. index:: Backend, PHP-API, TSConfig, ext:backend diff --git a/Documentation/Changelog/12.4.x/Index.rst b/Documentation/Changelog/12.4.x/Index.rst new file mode 100644 index 0000000..bfc7954 --- /dev/null +++ b/Documentation/Changelog/12.4.x/Index.rst @@ -0,0 +1,58 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-4-x: + +============== +12.4.x Changes +============== + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +================ + +None since TYPO3 v12.4.0 LTS release. + +.. attention:: + + Breaking changes are not planned after the TYPO3 v12.4.0 LTS release. + +Features +======== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Feature-* + +.. attention:: + + New features are not planned after the TYPO3 v12.4.0 LTS release. + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/12.4/Deprecation-100173-VariousMethodsAndPropertiesInUserAuthenticationClassesNowInternal.rst b/Documentation/Changelog/12.4/Deprecation-100173-VariousMethodsAndPropertiesInUserAuthenticationClassesNowInternal.rst new file mode 100644 index 0000000..0c5184e --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100173-VariousMethodsAndPropertiesInUserAuthenticationClassesNowInternal.rst @@ -0,0 +1,78 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100173-1680696124: + +================================================================================================ +Deprecation: #100173 - Various methods and properties in UserAuthentication classes now internal +================================================================================================ + +See :issue:`100173` + +Description +=========== + +Various methods and properties within the main classes regarding frontend +user and backend user (:php:`$GLOBALS[BE_USER]`) authentication handling +have been either marked as internal or have been deprecated for usage +outside of the classes. + +This is due to the further refactorings and decoupling work, as subclasses of +:php:`AbstractUserAuthentication` deal with many more functionality nowadays, +and therefore have been moved to service classes. The tight coupling of these +classes, for example, the database fields, or login form field names are now marked as +internal, as these properties should not be modified from the outside scope. + +Instead, functionality like :ref:`PSR-14 events <t3coreapi:EventDispatcher>` or +:ref:`Authentication Services <t3coreapi:authentication>` should influence the +authentication and authorization workflow. + +The following properties and methods are now marked as internal in all +user authentication related classes (extending +:php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication`): + +* :php:`lastLogin_column` +* :php:`formfield_uname` +* :php:`formfield_uident` +* :php:`formfield_status` +* :php:`loginSessionStarted` +* :php:`dontSetCookie` +* :php:`isSetSessionCookie()` +* :php:`isRefreshTimeBasedCookie()` +* :php:`removeCookie()` +* :php:`isCookieSet()` +* :php:`unpack_uc()` +* :php:`appendCookieToResponse()` + +Additionally, the following properties of the +:php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication` +implementation are marked as internal: + +* :php:`formfield_permanent` +* :php:`is_permanent` + + +Impact +====== + +The affected properties and methods have been marked as `@internal` and set to +:php:`protected`. With an additional trait, it is still possible to access them +in TYPO3 v12. In case third-party extensions call them, a PHP deprecation +warning is thrown. + + +Affected installations +====================== + +TYPO3 installations with custom extensions accessing the properties or methods. +The extension scanner reports corresponding places. + + +Migration +========= + +Depending on the specific requirements, it is recommended to use +:ref:`PSR-14 events <t3coreapi:EventDispatcher>` or +:ref:`authentication services <t3coreapi:authentication>` to modify behaviour +of the authentication classes. + +.. index:: Backend, Frontend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100335-TCAConfigMM_insert_fields.rst b/Documentation/Changelog/12.4/Deprecation-100335-TCAConfigMM_insert_fields.rst new file mode 100644 index 0000000..7b3ab07 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100335-TCAConfigMM_insert_fields.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100335-1679998903: + +================================================== +Deprecation: #100335 - TCA config MM_insert_fields +================================================== + +See :issue:`100335` + +Description +=========== + +The TCA option :php:`MM_insert_fields` has been marked +as deprecated and should not be used anymore. + + +Impact +====== + +Using :php:`MM_insert_fields` raises a deprecation level log message +during TCA cache warmup. Its functionality is kept in TYPO3 v12 but will +be removed in v13. + + +Affected installations +====================== + +There may be extensions that use this option when configuring database +MM relations. In most cases, the option can be removed. The migration +section gives more details. + + +Migration +========= + +General scope: :php:`MM_insert_fields` is used in combination with "true" +database MM intermediate tables to allow many-to-many relations between +two tables for :php:`group`, :php:`select` and sometimes even :php:`inline` +type fields. + +A core example is the :sql:`sys_category` to :sql:`tt_content` +relation, with :sql:`sys_category_record_mm` as intermediate table: The +intermediate table has field :sql:`uid_local` (pointing to a uid of +the "left" :sql:`sys_category` table), and :sql:`uid_foreign` (pointing to a +uid of the "right" :sql:`tt_content` table). Note this specific relation also +allows multiple different "right-side" table-field combinations, using the two +additional fields :sql:`tablenames` and :sql:`fieldname`. All this is configured +with TCA on the "left" and the "right" side table field, while table +:sql:`sys_category_record_mm` has no TCA itself. Rows within the intermediate +table are transparently handled by TYPO3 by the :php:`RelationHandler` and +extbase TCA-aware domain logic. + +The :php:`MM_insert_fields` now allows to configure a hard coded value for +an additional column within the intermediate table. This is obsolete: There is +no API to retrieve this value again, having a "stable" value in an additional +column is useless. This config option should be removed from TCA +definition. + +Note on the related option :php:`MM_match_fields`: This is important when an +MM relation allows multiple "right" sides. In the example above, when a category +is added to a :sql:`tt_content` record using the :sql:`categories` field, and when editing +this relation from the "right" side (editing a :sql:`tt_content` record), then this option +is used to select only relations for this :sql:`tt_content.categories` combination. The +TCA column :sql:`categories` thus uses :sql:`MM_match_fields` to restrict the +query. Note :sql:`MM_match_fields` is *not* set for the "left-side" :sql:`sys_category` +:sql:`items` fields, this would indicate a TCA misconfiguration. + +Various extensions in the wild did not get these details right, and often simply +set *both* :php:`MM_insert_fields` and :php:`MM_match_fields` to the same values. +Removing :php:`MM_insert_fields` helps reducing confusion and simplifies this +construct a bit. Affected extensions can simply remove the :php:`MM_insert_fields` +configuration and keep the :php:`MM_match_fields`. Note the Core strives to further +simplify these options and :php:`MM_match_fields` may become fully obsolete in the +future as well. + +.. index:: TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100349-TypoScriptLoginUserAndUsergroupConditions.rst b/Documentation/Changelog/12.4/Deprecation-100349-TypoScriptLoginUserAndUsergroupConditions.rst new file mode 100644 index 0000000..83eb373 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100349-TypoScriptLoginUserAndUsergroupConditions.rst @@ -0,0 +1,131 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100349-1680097287: + +======================================================================== +Deprecation: #100349 - TypoScript loginUser() and usergroup() conditions +======================================================================== + +See :issue:`100349` + +Description +=========== + +The two TypoScript / TSconfig related condition functions +:typoscript:`[loginUser()]` and :typoscript:`[usergroup()]` have +been marked as deprecated with TYPO3 v12, should not be used anymore +and will be removed in TYPO3 v13. They can be substituted using +conditions based on the variables :typoscript:`frontend.user` and +:typoscript:`backend.user`. + + +Impact +====== + +Using the old conditions in frontend TypoScript or TSconfig triggers a +deprecation level log entry in TYPO3 v12 and will stop working with +TYPO3 v13. + + +Affected installations +====================== + +Instances with TypoScript or TSconfig using one of the above functions +may be affected. This is a relatively common use case, but affected +instances can be adapted quite easily. + + +Migration +========= + +There is a rather straightforward migration path. In general, switch to +either :typoscript:`frontend.user` to test for frontend user state +(available in frontend TypoScript), or to :typoscript:`backend.user` (available +in frontend TypoScript and TSconfig). + +Note the transition can be done in existing TYPO3 v11 projects already. + +Some examples: + +.. code-block:: typoscript + + [loginUser('*')] + page = PAGE + page.20 = TEXT + page.20.value = User is logged in<br /> + [end] + [frontend.user.isLoggedIn] + page = PAGE + page.21 = TEXT + page.21.value = User is logged in<br /> + [end] + + [loginUser('*') === false] + page = PAGE + page.30 = TEXT + page.30.value = User is not logged in<br /> + [end] + [!frontend.user.isLoggedIn] + page = PAGE + page.31 = TEXT + page.31.value = User is not not logged in<br /> + [end] + + [loginUser(13)] + page = PAGE + page.40 = TEXT + page.40.value = Frontend user has the uid 13<br /> + [end] + [frontend.user.userId == 13] + page = PAGE + page.41 = TEXT + page.41.value = Frontend user has the uid 13<br /> + [end] + + [loginUser('1,13')] + page = PAGE + page.50 = TEXT + page.50.value = Frontend user uid is 1 or 13<br /> + [end] + [frontend.user.userId in [1,13]] + page = PAGE + page.51 = TEXT + page.51.value = Frontend user uid is 1 or 13<br /> + [end] + + [usergroup('*')] + page = PAGE + page.60 = TEXT + page.60.value = A Frontend user is logged in and belongs to some usergroup.<br /> + [end] + # Prefer [frontend.user.isLoggedIn] to not rely on magic array values. + [frontend.user.userGroupIds !== [0, -1]] + page = PAGE + page.61 = TEXT + page.61.value = A Frontend user is logged in and belongs to some usergroup.<br /> + [end] + + [usergroup(11)] + page = PAGE + page.70 = TEXT + page.70.value = Frontend user is member of group with uid 11<br /> + [end] + [11 in frontend.user.userGroupIds] + page = PAGE + page.71 = TEXT + page.71.value = Frontend user is member of group with uid 11<br /> + [end] + + [usergroup('1,11')] + page = PAGE + page.80 = TEXT + page.80.value = Frontend user is member of group 1 or 11<br /> + [end] + [1 in frontend.user.userGroupIds || 11 in frontend.user.userGroupIds] + page = PAGE + page.81 = TEXT + page.81.value = Frontend user is member of group 1 or 11<br /> + [end] + + +.. index:: TSConfig, TypoScript, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100355-DeprecateMethodsInPasswordChangeEventInExtfelogin.rst b/Documentation/Changelog/12.4/Deprecation-100355-DeprecateMethodsInPasswordChangeEventInExtfelogin.rst new file mode 100644 index 0000000..6d872bf --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100355-DeprecateMethodsInPasswordChangeEventInExtfelogin.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100355-1680608322: + +============================================================================== +Deprecation: #100355 - Deprecate methods in PasswordChangeEvent in ext:felogin +============================================================================== + +See :issue:`100355` + +Description +=========== + +The following methods in the PSR-14 event :php:`PasswordChangeEvent` of +ext:felogin have been marked as deprecated and should not be used any more: + +* :php:`setAsInvalid()` +* :php:`getErrorMessage()` +* :php:`isPropagationStopped()` +* :php:`setHashedPassword()` + + +Impact +====== + +Event listeners, who use one of the deprecated methods of the +:php:`PasswordChangeEvent` PSR-14 event, will raise a deprecation level log +message. The functionality is kept in TYPO3 v12 but will be removed in v13. + + +Affected installations +====================== + +Instances who use the PSR-14 event :php:`PasswordChangeEvent` for password +validation and who use one of the deprecated methods. + +The extension scanner reports usages as a weak match. + + +Migration +========= + +Password validation for the password recovery functionality in ext:felogin +must be implemented using a custom password policy validator. + +See :issue:`97388` for details. + +.. index:: Backend, FullyScanned, ext:felogin diff --git a/Documentation/Changelog/12.4/Deprecation-100405-PropertyTypoScriptFrontendController-type.rst b/Documentation/Changelog/12.4/Deprecation-100405-PropertyTypoScriptFrontendController-type.rst new file mode 100644 index 0000000..84c84b7 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100405-PropertyTypoScriptFrontendController-type.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100405-1680520177: + +================================================================== +Deprecation: #100405 - Property TypoScriptFrontendController->type +================================================================== + +See :issue:`100405` + +Description +=========== + +The public property :php:`type` of the main class in TYPO3 frontend +:php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController` has been +marked as internal, as it should not be used outside of this PHP class anymore +in the future. + +This is part of the overall part to reduce dependencies on this PHP class, as +it is not always available in TYPO3 frontend. + + +Impact +====== + +Accessing this property will trigger a PHP deprecation notice. Accessing this +property might also happen via TypoScript and TypoScript conditions. + + +Affected installations +====================== + +TYPO3 installations using this property on checking various typeNum settings +from TypoScript. + + +Migration +========= + +When using this property in PHP code via :php:`$GLOBALS['TSFE']->type`, it is +recommended to move to the PSR-7 request via +:php:`$request->getAttribute('routing')->getPageType()`, which is the property +of the :php:`PageArguments` object, as a result of the :php:`GET` parameter +:php:`type`, or `$GLOBALS['TSFE']->getPageArguments()->getPageType()` if +the request object is not available. + +Within TypoScript, conditions and getData properties need to be adapted: + +.. code-block:: typoscript + + # Before + [getTSFE() && getTSFE().type == 13] + + # After + [request.getPageArguments()?.getPageType() == 13] + +In TypoScript getData attributes: + +.. code-block:: typoscript + + # Before + page.10.data = TSFE:type + + # After + page.10.data = request:routing|pageType + + +.. index:: Frontend, TypoScript, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.4/Deprecation-100454-LegacyTreeImplementations.rst b/Documentation/Changelog/12.4/Deprecation-100454-LegacyTreeImplementations.rst new file mode 100644 index 0000000..46d5073 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100454-LegacyTreeImplementations.rst @@ -0,0 +1,77 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100454-1680685413: + +================================================== +Deprecation: #100454 - Legacy tree implementations +================================================== + +See :issue:`100454` + +Description +=========== + +Due to many refactorings in TYPO3's tree implementations in the past versions, +many implementations and functionality of the legacy rendering :php:`\TYPO3\CMS\Backend\Tree\AbstractTreeView` +is not needed anymore. + +The following PHP classes are not in use anymore and have been marked as deprecated: + +* :php:`\TYPO3\CMS\Backend\Tree\View\BrowseTreeView` +* :php:`\TYPO3\CMS\Backend\Tree\View\ElementBrowserPageTreeView` + +The base class is still available, but discouraged to be used or extended, +even though TYPO3 still uses this in a few places. + +The following properties and methods within the base class +:php:`AbstractTreeView` have either been marked as deprecated or +declared as internal: + +* :php:`AbstractTreeView->thisScript` +* :php:`AbstractTreeView->BE_USER` +* :php:`AbstractTreeView->clause` +* :php:`AbstractTreeView->title` +* :php:`AbstractTreeView->table` +* :php:`AbstractTreeView->parentField` +* :php:`AbstractTreeView->orderByFields` +* :php:`AbstractTreeView->fieldArray` +* :php:`AbstractTreeView->defaultList` +* :php:`AbstractTreeView->determineScriptUrl()` +* :php:`AbstractTreeView->getThisScript()` +* :php:`AbstractTreeView->PM_ATagWrap()` +* :php:`AbstractTreeView->addTagAttributes()` +* :php:`AbstractTreeView->getRootIcon()` +* :php:`AbstractTreeView->getIcon()` +* :php:`AbstractTreeView->getRootRecord()` +* :php:`AbstractTreeView->getTitleStr()` +* :php:`AbstractTreeView->getTitleAttrib()` + + +Impact +====== + +Instantiating the deprecated classes or calling the deprecated methods will +trigger a PHP deprecation warning, except for +:php:`AbstractTreeView->getThisScript()`, which is still used internally by +deprecated code. + +The Extension Scanner will find those usages and additionally also reports +usages of the corresponding public properties of the :php:`AbstractTreeView` +class. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this functionality. This is +usually the case for old installations from TYPO3 v6 or TYPO3 v4 times. + + +Migration +========= + +It is recommended to avoid generating the markup directly in PHP. Instead use +one of various other tree functionalities (for example, see PageTree implementations) +in PHP and render trees via web components or Fluid. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100459-BackendUtilitygetRecordToolTip.rst b/Documentation/Changelog/12.4/Deprecation-100459-BackendUtilitygetRecordToolTip.rst new file mode 100644 index 0000000..9fb4489 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100459-BackendUtilitygetRecordToolTip.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100459-1680683235: + +========================================================= +Deprecation: #100459 - BackendUtility::getRecordToolTip() +========================================================= + +See :issue:`100459` + +Description +=========== + +The method :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getRecordToolTip()` +has been marked as deprecated. + + +Impact +====== + +Calling this method will trigger a PHP deprecation warning. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this method. This is usually +the case for old installations where Fluid templates or Extbase backend modules +were not common. + + +Migration +========= + +As this method is just a wrapper around :php:`BackendUtility::getRecordIconAltText()` +with a "title" attribute for the markup, the replacement is straightforward: + +Before: + +.. code-block:: php + + $link = '<a href="..." ' . BackendUtility::getRecordToolTip(...) . '>my link</a>'; + +After: + +.. code-block:: php + + $link = '<a href="..." title="' . BackendUtility::getRecordIconAltText(...) . '">my link</a>'; + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100461-TypoScriptOptionConfigxhtmlDoctype.rst b/Documentation/Changelog/12.4/Deprecation-100461-TypoScriptOptionConfigxhtmlDoctype.rst new file mode 100644 index 0000000..741ce28 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100461-TypoScriptOptionConfigxhtmlDoctype.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100461-1680690006: + +============================================================ +Deprecation: #100461 - TypoScript option config.xhtmlDoctype +============================================================ + +See :issue:`100461` + +Description +=========== + +The TypoScript option :typoscript:`config.xhtmlDoctype` has been marked as +deprecated. This is done in order to consolidate TypoScript options, as the +option :typoscript:`config.doctype` is now the default. + + +Impact +====== + +Having :typoscript:`config.xhtmlDoctype` set, but not :typoscript:`config.doctype` +will trigger a TypoScript deprecation warning. + + +Affected installations +====================== + +TYPO3 installations having this TypoScript instruction set. + + +Migration +========= + +If the property :typoscript:`config.xhtmlDoctype` is set, replace it with +:typoscript:`config.doctype`. + +.. index:: TypoScript, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.4/Deprecation-100577-FormEngineNeedsRequestObject.rst b/Documentation/Changelog/12.4/Deprecation-100577-FormEngineNeedsRequestObject.rst new file mode 100644 index 0000000..1174452 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100577-FormEngineNeedsRequestObject.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100577-1681384407: + +====================================================== +Deprecation: #100577 - FormEngine needs request object +====================================================== + +See :issue:`100577` + +Description +=========== + +The backend FormEngine construct (editing records in the backend) +now expects the current :php:`ServerRequestInterface` object to +be hand over as initial data. + + +Impact +====== + +Backend modules that use the FormEngine data provider construct to +render records should provide the current request object. Failing +to do so will trigger a deprecation level log message and the system +will fall back to :php:`$GLOBALS['TYPO3_REQUEST']`. This will stop +working with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions that provide custom modules using the FormEngine +construct are affected. This is a relatively seldom case. + + +Migration +========= + +Provide the request object as "initial data" when using the +:php:`FormDataCompiler`: + +.. code-block:: php + + $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $myFormDataGroup); + $formDataCompilerInput = [ + 'request' => $request, + // further data, for example: + 'tableName' => $table, + 'vanillaUid' => $uid, + ]; + $formData = $formDataCompiler->compile($formDataCompilerInput); + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100581-AvoidConstructorArgumentInFormDataCompiler.rst b/Documentation/Changelog/12.4/Deprecation-100581-AvoidConstructorArgumentInFormDataCompiler.rst new file mode 100644 index 0000000..d6f747e --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100581-AvoidConstructorArgumentInFormDataCompiler.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100581-1681396349: + +===================================================================== +Deprecation: #100581 - Avoid constructor argument in FormDataCompiler +===================================================================== + +See :issue:`100581` + +Description +=========== + +When instantiating the backend FormEngine related :php:`FormDataCompiler`, +the constructor argument :php:`FormDataGroupInterface` should be omitted, +the form data group should be provided as second argument to :php:`compile()` +instead. + + +Impact +====== + +Handing over the form data group as second argument to :php:`compile()` +allows injecting :php:`FormDataCompiler` into controllers with TYPO3 v13 +since the manual constructor argument will be removed. + + +Affected installations +====================== + +Instances with own backend modules that use FormEngine to render records +may be affected. Handing over the form data group as constructor argument +to :php:`FormDataCompiler` will trigger a deprecation level log warning +with TYPO3 v12. With TYPO3 v13, the form data group must be provided as +second argument to :php:`compile()` and will not be optional anymore. + + +Migration +========= + +.. code-block:: php + + // before + $formDataCompiler = GeneralUtility::makeInstance( + FormDataCompiler::class, GeneralUtility::makeInstance(MyDataGroup::class) + ); + $formData = $formDataCompiler->compile($myFormDataCompilerInput); + + // after + $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class); + $formData = $formDataCompiler->compile( + $myFormDataCompilerInput, + GeneralUtility::makeInstance(MyDataGroup::class) + ); + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100584-GeneralUtilitylinkThisScript.rst b/Documentation/Changelog/12.4/Deprecation-100584-GeneralUtilitylinkThisScript.rst new file mode 100644 index 0000000..95c88c0 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100584-GeneralUtilitylinkThisScript.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100584-1681452843: + +======================================================= +Deprecation: #100584 - GeneralUtility::linkThisScript() +======================================================= + +See :issue:`100584` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::linkThisScript()` +has been marked as deprecated and should not be used any longer. + +The method uses the super global :php:`$_GET` which should be avoided. Instead, +data should be retrieved via the PSR-7 :php:`ServerRequestInterface`. + +Controllers should typically create URLs using the :php:`\TYPO3\CMS\Backend\Routing\UriBuilder`. + + +Impact +====== + +Using the method triggers a deprecation level log entry in TYPO3 v12, the +method will be removed with TYPO3 v13. + + +Affected installations +====================== + +The method was typically used in backend context: Extensions with own +backend modules may be affected. The extension scanner finds usages +with a strong match. + + +Migration +========= + +:php:`linkThisScript()` was typically used when a link to some view is +created that should return back to the current view later. + +Controllers usually "know" the route a view should return to and the relevant +GET parameters. + +A transition could look like this: + +.. code-block:: php + + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $queryParams = $request->getQueryParams(); + $url = $uriBuilder->buildUriFromRoute( + 'my_route', + [ + 'table' => $queryParams['table'] ?? '', + 'uid' => (int)($queryParams['uid'] ?? 0), + ] + ); + + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100587-DeprecateFormEngineAdditionalJavaScriptPostAndCustomEvalInlineJavaScript.rst b/Documentation/Changelog/12.4/Deprecation-100587-DeprecateFormEngineAdditionalJavaScriptPostAndCustomEvalInlineJavaScript.rst new file mode 100644 index 0000000..b82690c --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100587-DeprecateFormEngineAdditionalJavaScriptPostAndCustomEvalInlineJavaScript.rst @@ -0,0 +1,87 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100587-1681477405: + +======================================================================================================= +Deprecation: #100587 - Deprecate form engine additionalJavaScriptPost and custom eval inline JavaScript +======================================================================================================= + +See :issue:`100587` + +Description +=========== + +The result property `additionalJavaScriptPost` of the form engine result array +is deprecated. It was used, for instance, in custom eval definitions, that provided +inline JavaScript (configured via :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals']`). + + +Impact +====== + +Custom form engine components that assign the result property `additionalJavaScriptPost`, +or custom eval class implementations for method :php:`returnFieldJS()` that return a plain +string (which is used as inline JavaScript), will raise a deprecation level log message. + + +Affected installations +====================== + +Installations that use custom form engine components modifying the result array, +or custom eval class implementations for method :php:`returnFieldJS()` returning +a plain string. + + +Migration +========= + +Instead of using inline JavaScript, functionality has to be bundled in a static +JavaScript module. Custom eval class implementations for method :php:`returnFieldJS()` +have to return an instance of :php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction` +instead of a plain string. + +Example +------- + +Deprecated custom eval implementation: + +.. code-block:: php + + <?php + namespace TYPO3\CMS\Redirects\Evaluation; + + class SourceHost + { + public function returnFieldJS(): string + { + $jsCode = []; + $jsCode[] = 'if (value === \'*\') {return value;}'; + $jsCode[] = 'var parser = document.createElement(\'a\');'; + $jsCode[] = 'parser.href = value.indexOf(\'://\') != -1 ? value : \'http://\' + value;'; + $jsCode[] = 'return parser.host;'; + return implode(' ', $jsCode); + } + } + + +Migrated custom eval implementation (JavaScript is now bundled in module +:js:`@typo3/redirects/form-engine-evaluation.js`): + +.. code-block:: php + + <?php + namespace TYPO3\CMS\Redirects\Evaluation; + + class SourceHost + { + public function returnFieldJS(): JavaScriptModuleInstruction + { + return JavaScriptModuleInstruction::create( + '@typo3/redirects/form-engine-evaluation.js', + 'FormEngineEvaluation' + ); + } + } + + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100596-GeneralUtility_GET.rst b/Documentation/Changelog/12.4/Deprecation-100596-GeneralUtility_GET.rst new file mode 100644 index 0000000..ea998f4 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100596-GeneralUtility_GET.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100596-1681478199: + +============================================= +Deprecation: #100596 - GeneralUtility::_GET() +============================================= + +See :issue:`100596` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GET()` has +been marked as deprecated and should not be used any longer. + +Modern code should access GET and POST data from the PSR-7 :php:`ServerRequestInterface`, +and should avoid accessing superglobals :php:`$_GET` directly. This also avoids +future side-effects when using sub-requests. Some :php:`GeneralUtility` related +helper methods like :php:`_GET()` violate this, using them is considered a technical +debt. They are being phased out. + + + +Impact +====== + +Calling the method from PHP code will log a PHP deprecation level entry, +the method will be removed with TYPO3 v13. + + +Affected installations +====================== + +TYPO3 installations with third-party extensions using :php:`GeneralUtility::_GET()` +are affected, typically in TYPO3 installations which +have been migrated to the latest TYPO3 Core versions and +haven't been adapted properly yet. + +The extension scanner will find usages with a strong match. + + +Migration +========= + +:php:`GeneralUtility::_GET()` is a helper method that retrieves +incoming HTTP `GET` query arguments and returns the value. + +The same result can be achieved by retrieving arguments from the request object. +An instance of the PSR-7 :php:`ServerRequestInterface` is handed over to +controllers by TYPO3 Core's PSR-15 :php:`\TYPO3\CMS\Core\Http\RequestHandlerInterface` +and middleware implementations, and is available in various related scopes +like the frontend :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`. + +Typical code: + +.. code-block:: php + + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // Before + $value = GeneralUtility::_GET('tx_scheduler'); + + // After + $value = $request->getQueryParams()['tx_scheduler'] ?? null; + + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100597-BackendUtilityMethodsGetThumbnailUrlAndGetLinkToDataHandlerAction.rst b/Documentation/Changelog/12.4/Deprecation-100597-BackendUtilityMethodsGetThumbnailUrlAndGetLinkToDataHandlerAction.rst new file mode 100644 index 0000000..084d3f1 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100597-BackendUtilityMethodsGetThumbnailUrlAndGetLinkToDataHandlerAction.rst @@ -0,0 +1,84 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100597-1681480956: + +================================================================================================ +Deprecation: #100597 - BackendUtility methods getThumbnailUrl() and getLinkToDataHandlerAction() +================================================================================================ + +See :issue:`100597` + +Description +=========== + +The methods :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getThumbnailUrl()` +and :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getLinkToDataHandlerAction()` +have been marked as deprecated. + + +Impact +====== + +Calling those methods will trigger a PHP deprecation level log warning. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using those methods. The extension +scanner will report usages as strong match. + + +Migration +========= + +Instead of calling :php:`BackendUtility::getThumbnailUrl()`, inject and use +the :php:`\TYPO3\CMS\Core\Resource\ResourceFactory` directly: + +.. code-block:: php + + // before + $url = BackendUtility::getThumbnailUrl(2004, [ + 'width' => 20, + 'height' => 13, + '_context' => ProcessedFile::CONTEXT_IMAGEPREVIEW + ]); + + // after + $url = $this->resourceFactory + ->getFileObject(2004) + ->process(ProcessedFile::CONTEXT_IMAGEPREVIEW, ['width' => 20, 'height' => 13]) + ->getPublicUrl(); + +Instead of calling :php:`BackendUtility::getLinkToDataHandlerAction()`, inject +and use the :php:`\TYPO3\CMS\Backend\Routing\UriBuilder` directly: + +.. code-block:: php + + // before + $url = BackendUtility::getLinkToDataHandlerAction( + '&cmd[pages][123][localize]=10', + (string)$uriBuilder->buildUriFromRoute('some_route') + ); + + // after + $url = (string)$this->uriBuilder->buildUriFromRoute( + 'tce_db', + [ + 'cmd' => [ + 'pages' => [ + 123 => [ + 'localize' => 10, + ], + ], + ], + 'redirect' => (string)$uriBuilder->buildUriFromRoute('some_route'), + ] + ); + +In case the second parameter `$redirectUrl` was omitted, +:php:`getLinkToDataHandlerAction` automatically used the current request URI +as the return URL. In case you relied on this, make sure the `redirect` +parameter is set to :php:`$request->getAttribute('normalizedParams')->getRequestUri()`. + +.. index:: Backend, PHP-API, FullyScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100614-DeprecatePageRendererinlineJavascriptWrapAndInlineCssWrap.rst b/Documentation/Changelog/12.4/Deprecation-100614-DeprecatePageRendererinlineJavascriptWrapAndInlineCssWrap.rst new file mode 100644 index 0000000..3d61ddd --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100614-DeprecatePageRendererinlineJavascriptWrapAndInlineCssWrap.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100614-1681589901: + +======================================================================================= +Deprecation: #100614 - Deprecate PageRenderer::$inlineJavascriptWrap and $inlineCssWrap +======================================================================================= + +See :issue:`100614` + +Description +=========== + +The protected properties :php:`$inlineJavascriptWrap` and :php:`$inlineCssWrap` +of the class :php:`\TYPO3\CMS\Core\Page\PageRenderer` have been deprecated and +shall not be used any longer. + + +Impact +====== + +:php:`PageRenderer` specifics concerning rendering XHTML or non-HTML5 content are +not working any longer in affected installations having custom code extending +:php:`\TYPO3\CMS\Core\Page\PageRenderer`. + + +Affected installations +====================== + +Installations with custom code extending :php:`\TYPO3\CMS\Core\Page\PageRenderer` +that are reading from or writing to the mentioned protected properties +:php:`$inlineJavascriptWrap` or :php:`$inlineCssWrap`. + + +Migration +========= + +Avoid using the protected properties :php:`$inlineJavascriptWrap` and +:php:`$inlineCssWrap`. In case any custom code needs to wrap with inline +:html:`<script>` or :html:`<style>` tags, use the new protected methods +:php:`wrapInlineScript($content)` and :php:`wrapInlineStyle($content)` +within :php:`\TYPO3\CMS\Core\Page\PageRenderer`. + + +.. index:: Frontend, Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100622-ExtbaseFeatureToggles.rst b/Documentation/Changelog/12.4/Deprecation-100622-ExtbaseFeatureToggles.rst new file mode 100644 index 0000000..123942d --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100622-ExtbaseFeatureToggles.rst @@ -0,0 +1,102 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100622-1681664078: + +============================================== +Deprecation: #100622 - Extbase feature toggles +============================================== + +See :issue:`100622` + +Description +=========== + +Extbase has an own system for feature toggles next to the Core feature +toggle API. It has always been marked as internal, but is used for +a couple of toggles within the Extbase framework. + +All toggles and the internal PHP API have been marked as deprecated +in TYPO3 v12 and should be avoided. + + +Impact +====== + +The PHP API for Extbase toggles has always been marked as internal. It will +be removed with TYPO3 v13. + +The single toggles can still be used in TYPO3 v12, but their triggered +functionality will be removed with TYPO3 v13, if set to `1`. + + +Affected installations +====================== + +Extensions should not rely on +:php:`\TYPO3\CMS\Extbase\ConfigurationConfigurationManagerInterface->isFeatureEnabled()`. +The method is marked as internal and should never have been used by extensions. The +extension scanner still finds usages of this method in extensions as weak match. + +All feature toggles have been marked as deprecated. Setting one of them to `1` in +TypoScript will trigger a deprecation level log message, they will stop working with +TYPO3 v13. + + +Migration +========= + +Extbase has three feature toggles in TYPO3 v12. All of them will be removed +with TYPO3 v13. Instances with extensions setting those to `1` in TypoScript +may need adaptions. Instances setting the toggles to `0` can simply remove them +from TypoScript. + +skipDefaultArguments = 1 +------------------------ + +This is an ancient toggle that was used before routing has been added with +TYPO3 v9. It allowed to skip the `controller` and `action` argument in frontend +plugin links, when linking to the default Extbase controller / action combination. +This toggle has been documented as being broken in combination with +:ref:`Extbase plugin enhancer <t3coreapi:routing-extbase-plugin-enhancer>` already. +Consuming instances should switch to proper routing configuration instead. + +ignoreAllEnableFieldsInBe = 1 +----------------------------- + +This is another ancient toggle that triggers +:php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->setIgnoreEnableFields(true)` +for Extbase repositories when used in backend scope. It allows ignoring default :php:`TCA` +flags like suppressing of deleted records in queries. + +Extbase-based backend modules that rely on this toggle being set to `1` can easily +migrate this: When the repository in question is only used in backend context, the +code below should trigger the same behavior. Note as with other query settings, +this toggle needs to be used with care, otherwise backend users may see records +they are not supposed to see. + +.. code-block::php + + /** + * Overwrite createQuery to not respect enable fields. + */ + public function createQuery(): QueryInterface + { + $query = parent::createQuery(); + $query->getQuerySettings()->setIgnoreEnableFields(true); + return $query; + } + +When the repository is used in both backend and frontend context, the code +should be refactored a bit towards a public method that can be set by the +Extbase backend controller only. + +enableNamespacedArgumentsForBackend = 1 +--------------------------------------- + +This toggle has been introduced in TYPO3 v12. See :ref:`feature-97096` +for more details. Extbase backend modules should no longer expect the +namespace to be set. It may be necessary to adapt some Ajax calls and +request-related argument checks in custom modules. + + +.. index:: PHP-API, TypoScript, PartiallyScanned, ext:extbase diff --git a/Documentation/Changelog/12.4/Deprecation-100637-ThirdArgumentContentObjectRenderer-start.rst b/Documentation/Changelog/12.4/Deprecation-100637-ThirdArgumentContentObjectRenderer-start.rst new file mode 100644 index 0000000..e6c4d11 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100637-ThirdArgumentContentObjectRenderer-start.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100637-1681737971: + +==================================================================== +Deprecation: #100637 - Third argument ContentObjectRenderer->start() +==================================================================== + +See :issue:`100637` + +Description +=========== + +When creating instances of the +:php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer`, the +third argument :php:`$request` when calling :php:`start()` should not be +handed over anymore. Instead, :php:`setRequest()` should be used +after creating the object. + + +Impact +====== + +Handing over the third argument to :php:`start()` has been marked as deprecated +in TYPO3 v12, it will be ignored with TYPO3 v13. + + +Affected installations +====================== + +Instances with casual extensions are probably not affected by this: Instances +of :php:`ContentObjectRenderer` are usually set-up framework internally. + +Using the third argument on :php:`start()` triggers a deprecation level log +message. The extension scanner will *not* find usages, since the method +name :php:`start()` is used in different context as well and would lead to +too many false positives. + + +Migration +========= + +Ensure the request is an instance of :php:`Psr\Http\Message\ServerRequestInterface`, +and call :php:`setRequest()` after instantiation instead of calling +:php:`start()` with three arguments. + + +.. index:: Frontend, PHP-API, NotScanned, ext:frontend diff --git a/Documentation/Changelog/12.4/Deprecation-100639-DeprecateAbstractPlugin.rst b/Documentation/Changelog/12.4/Deprecation-100639-DeprecateAbstractPlugin.rst new file mode 100644 index 0000000..29b834a --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100639-DeprecateAbstractPlugin.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100639-1681740974: + +=============================================== +Deprecation: #100639 - Deprecate AbstractPlugin +=============================================== + +See :issue:`100639` + +Description +=========== + +Abstract "pibase" class :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` +has been marked @internal with +:ref:`changelog-Breaking-98281-MakeAbstractPluginInternal <breaking-98281-1662549900>` in TYPO3 v12.0 +already and should not be used anymore. + +It has now been fully deprecated with TYPO3 v12.4 and will be removed with TYPO3 v13.0. + + +Impact +====== + +Extending :php:`AbstractPlugin` will trigger a deprecation level log warning +since TYPO3 v12.4. The class will be removed with TYPO3 v13.0. + + +Affected installations +====================== + +Instances with frontend plugin extensions that extend +:php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` are affected. + +The extension scanner will find usages with a strong match. + + +Migration +========= + +Stop extending the class. A simple way to migrate is by copying needed methods +over to an own controller class. See +:ref:`changelog-Breaking-98281-MakeAbstractPluginInternal <breaking-98281-1662549900>` +for more details on this. + + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/12.4/Deprecation-100653-DeprecatedSomeMethodsInDebugUtility.rst b/Documentation/Changelog/12.4/Deprecation-100653-DeprecatedSomeMethodsInDebugUtility.rst new file mode 100644 index 0000000..6cc1474 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100653-DeprecatedSomeMethodsInDebugUtility.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100653-1681805677: + +============================================================== +Deprecation: #100653 - Deprecated some methods in DebugUtility +============================================================== + +See :issue:`100653` + +Description +=========== + +The following methods in :php:`\TYPO3\CMS\Core\Utility\DebugUtility` have been +marked as deprecated: + +* :php:`debugInPopUpWindow()` +* :php:`debugRows()` +* :php:`printArray()` + +While :php:`debugRows()` and :php:`printArray()` duplicate already existing +methods, :php:`debugInPopUpWindow()` is discouraged to use as either external +debuggers, e.g. Xdebug or :php:`\TYPO3\CMS\Extbase\Utility\DebuggerUtility` may +be used instead. + + +Impact +====== + +Calling any of the aforementioned methods will trigger deprecation log entries. + + +Affected installations +====================== + +Instances using any of the aforementioned methods are affected. + +The extension scanner will find and report usages. + + +Migration +========= + +In case of :php:`debugRows()`, the identical method :php:`debug()` can be used. +The method :php:`printArray()` can be replaced with :php:`viewArray()`. However, +the former method directly outputs the contents, which is not the case with +:php:`viewArray()`. + +The method :php:`debugInPopUpWindow()` is deprecated without a direct +replacement, consider using an external debugger or +:php:`\TYPO3\CMS\Extbase\Utility\DebuggerUtility::var_dump()` instead. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100657-TYPO3_CONF_VARSBElanguageDebug.rst b/Documentation/Changelog/12.4/Deprecation-100657-TYPO3_CONF_VARSBElanguageDebug.rst new file mode 100644 index 0000000..f2577c1 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100657-TYPO3_CONF_VARSBElanguageDebug.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100657-1681816063: + +============================================================= +Deprecation: #100657 - TYPO3_CONF_VARS['BE']['languageDebug'] +============================================================= + +See :issue:`100657` + +Description +=========== + +The configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['languageDebug']` +has been marked as deprecated in TYPO3 v12, it will be removed with TYPO3 v13 +along with the property :php:`\TYPO3\CMS\Core\Localization->debugKey`. + +Setting the configuration option `languageDebug` to true adds the label name +including the path to the :file:`.xlf` file to the output in the backend. + +The intention was to allow translators to see where a specific localized +string comes from in the backend to allow locating missing localization +sources. + +Judging from translators feedback, the option isn't used in practice, though: +Setting the toggle to true leads to a massively convoluted backend experience +that breaks tons of CSS and renders the backend so unusable that it's hardly +a benefit at all. + +TYPO3 v12 cleaned up lots of label usages and makes them more unique. +Translators should find single label usages much more easily by searching +the code base for label names and label files. Also, many Fluid templates are +located more transparently and are easier to find, localizing labels within +PHP classes is also improving a lot. Translators should in general have +less headaches to see where labels are used, and this will improve further. + + +Impact +====== + +The option has been marked as deprecated in TYPO3 v12 and does not have any +effect anymore with TYPO3 v13. + + +Affected installations +====================== + +The target of this toggle were translators, production sites are not affected +by this. Extensions using the property :php:`\TYPO3\CMS\Core\Localization->debugKey` +are found by the extension scanner as weak match. + + +Migration +========= + +Remove access to :php:`\TYPO3\CMS\Core\Localization->debugKey`. + +.. index:: Backend, LocalConfiguration, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-100662-ConfigurationManager-getContentObject.rst b/Documentation/Changelog/12.4/Deprecation-100662-ConfigurationManager-getContentObject.rst new file mode 100644 index 0000000..d5db32d --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100662-ConfigurationManager-getContentObject.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100662-1681906563: + +=============================================================== +Deprecation: #100662 - ConfigurationManager->getContentObject() +=============================================================== + +See :issue:`100662` + +Description +=========== + +The Extbase-related method +:php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface->getContentObject()` +has been marked as deprecated in TYPO3 v12 and should not be used anymore. + +Impact +====== + +Calling :php:`ConfigurationManagerInterface->getContentObject()` will trigger +a deprecation level log message in TYPO3 v12, the method will be removed +from the interface together with their implementations with TYPO3 v13. + + +Affected installations +====================== + +Instances with Extbase extensions that use :php:`getContentObject()` on +injected :php:`ConfigurationManager` instances are affected. The extension +scanner has not been configured to find these calls, since the method +name is used in different scope as well and would trigger too many +false positives. + + +Migration +========= + +There may be instances with Extbase controllers that need to retrieve +data from the current content object that initiated the frontend Extbase +plugin call. + +In this case, controllers can access the current content object from the +Extbase request object using :php:`$request->getAttribute('currentContentObject')` +instead. + + +.. index:: PHP-API, NotScanned, ext:extbase diff --git a/Documentation/Changelog/12.4/Deprecation-100670-DIAwareFormEngineNodes.rst b/Documentation/Changelog/12.4/Deprecation-100670-DIAwareFormEngineNodes.rst new file mode 100644 index 0000000..92222a9 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100670-DIAwareFormEngineNodes.rst @@ -0,0 +1,138 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100670-1681916011: + +================================================ +Deprecation: #100670 - DI-aware FormEngine nodes +================================================ + +See :issue:`100670` + +Description +=========== + +When the FormEngine construct (used when editing records in the backend) has +been rewritten back in TYPO3 v7, dependency injection for non-Extbase +constructs has not been a thing, yet. + +With dependency injection being part of the TYPO3 Core extension since TYPO3 v10, +and the Extbase solution being out-phased, it is time to make FormEngine +dependency injection aware as well. + +This has some impact on classes implementing +:php:`\TYPO3\CMS\Backend\Form\NodeInterface` directly, or indirectly by +extending :php:`\TYPO3\CMS\Backend\Form\AbstractNode` and +:php:`\TYPO3\CMS\Backend\Form\Element\AbstractFormElement`. Custom +implementations *can* use this already, but the full power will +only be leveraged with TYPO3 v13. + +Similar changes as described below can be done for classes implementing +:php:`\TYPO3\CMS\Backend\Form\NodeResolverInterface` as well, but the +impact is much smaller since this construct is used less often in the wild. + +Additionally, classes should either implement one of the interfaces +directly, or extend an appropriate abstract. They must not extend any +of the existing "leaf" classes the core provides, since those will be +declared :php:`final` with TYPO3 v13. + + +Impact +====== + +Using dependency injection within FormEngine related classes +becomes possible in TYPO3 v12. + + +Affected installations +====================== + +Instances with extensions that come with own FormEngine additions +may be affected. The extensions scanner is not configured to find +affected classes. + + +Migration +========= + +Compatibility with TYPO3 v11 and v12 +------------------------------------ + +Extensions that strive for both TYPO3 v11 and v12 compatibility should +just keep their implementation as is. + +Compatibility with TYPO3 v12 and v13 +------------------------------------ + +Extensions that strive for TYPO3 v12 compatibility, skipping v11, that +want to support v13 as well, must adapt their implementations. + +As main change, :php:`NodeInterface` no longer declares :php:`__construct()`, +the class constructor is now "free" for injection. The :php:`NodeFactory` uses +the existence of method :php:`setData()` as indicator if :php:`NodeFactory` and +:php:`$data` array should be hand over as manual constructor argument (old way), +or if :php:`setData()` should be called after object instantiation. Note +:php:`setData()` will be activated as interface method with TYPO3 v13. + +A class with both TYPO3 v12 and v13 compatibility should look like this: + +.. code-block:: php + + public function __construct( + // If the class creates sub elements + NodeFactory $nodeFactory, + // If the class needs IconFactory + IconFactory $iconFactory, + // Further dependencies + private readonly MyService $myService, + ) { + $this->nodeFactory = $nodeFactory; + $this->iconFactory = $iconFactory; + } + + public function setData(array $data): void + { + $this->data = $data; + } + + public function render(): array + { + // Implement render(), note the "array" return type hint, + // which will be mandatory in TYPO3 v13. + } + +The class has to be registered for public DI in :file:`Services.yaml` as well, since +it is instantiated by :php:`NodeFactory` using :php:`GeneralUtility::makeInstance()`: + +.. code-block:: yaml + + MyVendor\MyExtension\Form\Element\MyElementClass: + public: true + + +Compatibility with v13 +---------------------- + +Extensions dropping TYPO3 v12 compatibility and going with v13 and up, can +simplify the construct: In v13, :php:`setData()` will be added to :php:`AbstractNode`, +extending classes don't need to implement it anymore. The class +property :php:`$iconFactory` (:php:`AbstractFormElement` +only) will be removed from the abstracts, constructor property promotion +can be used. :php:`NodeFactory` will be injected in the abstracts, without +polluting :php:`__construct()`. Also, a dependency injection service provider pass will +be added, to automatically set classes public that implement implement :php:`NodeInterface`, +so a :yaml:`public: true` entry in :file:`Services.yaml` can be skipped. + +A typical class extending :php:`AbstractNode` looks like this: + +.. code-block:: php + + public function __construct( + private readonly IconFactory $iconFactory, + private readonly MyService $myService, + ) { + } + + // Implement render(). + + +.. index:: PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/12.4/Deprecation-100721-LabelRelatedMethodsAndArguments.rst b/Documentation/Changelog/12.4/Deprecation-100721-LabelRelatedMethodsAndArguments.rst new file mode 100644 index 0000000..c7de546 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-100721-LabelRelatedMethodsAndArguments.rst @@ -0,0 +1,74 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-100721-1682333511: + +========================================================== +Deprecation: #100721 - Label-related methods and arguments +========================================================== + +See :issue:`100721` + +Description +=========== + +The method :php:`\TYPO3\CMS\Core\Localization\LanguageService->getLL()` has been +marked as deprecated. + +Along with the deprecation the method +:php:`\TYPO3\CMS\Core\Localization\LanguageService->includeLLFile()` has been +marked as internal, as it is still used in TYPO3 Core for backwards-compatibility +internally, but not part of TYPO3's Core API anymore. + +With the introduction of :ref:`Locales <feature-99694-1674552209>`, it is also now not recommended anymore to use +custom alternative language keys. + +For this reason the argument "alternativeLanguageKeys" of the +:html:`<f:translate>` ViewHelper has been deprecated as well, along with the +method argument of the same name in +:php:`\TYPO3\CMS\Extbase\Utility\LocalizationUtility::translate()`. + + +Impact +====== + +Calling the method :php:`\TYPO3\CMS\Core\Localization\LanguageService->getLL()` +will trigger a PHP deprecation warning. + +Calling :php:`\TYPO3\CMS\Extbase\Utility\LocalizationUtility::translate()` with +the argument "alternativeLanguageKeys" will also trigger a PHP deprecation warning, +which is the underlying deprecation warning when using the argument +"alternativeLanguageKeys" of the :html:`<f:translate>` ViewHelper. + + +Affected installations +====================== + +TYPO3 installations within backend modules using the method :php:`getLL()` or +extensions or templates using the translate methods. + +The former usually happens in extensions which have been migrated from older +TYPO3 versions with legacy functionality in backend modules along +with :php:`$GLOBALS['LANG']` as :php:`LanguageService` object. + + +Migration +========= + +It is highly recommended to use the full path to a label file along +with the :php:`sL()` method of :php:`\TYPO3\CMS\Core\Localization\LanguageService`: + +Before: + +.. code-block:: php + + $GLOBALS['LANG']->includeLLfile('EXT:my_extension/Resources/Private/Language/db.xlf'); + $label = htmlspecialchars($GLOBALS['LANG']->getLL('my_label')); + +After: + +.. code-block:: php + + $label = $GLOBALS['LANG']->sL('LLL:EXT:my_extension/Resources/Private/Language/db.xlf:my_label'); + $label = htmlspecialchars($label); + +.. index:: PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-98093-Ext_iconAsExtensionIconFileLocation.rst b/Documentation/Changelog/12.4/Deprecation-98093-Ext_iconAsExtensionIconFileLocation.rst new file mode 100644 index 0000000..ce778bc --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-98093-Ext_iconAsExtensionIconFileLocation.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-98093-1681741493: + +================================================================ +Deprecation: #98093 - ext_icon.* as extension icon file location +================================================================ + +See :issue:`98093` + +Description +=========== + +Since :issue:`77349` it is possible to place the extension icon, which is +displayed at various places in the backend, e.g. in the extension manager, in +an extension's :file:`Resources/Public/Icons/` directory. The :file:`Resources/` directory +is :ref:`by convention <t3coreapi:extension-files-locations>` the place to +store such files. To simplify the extension registration and to fully follow the +convention the following file locations have been deprecated: + +* :file:`ext_icon.png` +* :file:`ext_icon.svg` +* :file:`ext_icon.gif` + +Impact +====== + +Adding an extension icon using one of the mentioned file locations will raise +a deprecation level log message and will stop working with TYPO3 v13. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using the deprecated file locations. + + +Migration +========= + +Place your extension icon as :file:`Extension.*` into :file:`Resources/Public/Icons/`, +as described in :ref:`Feature: #77349 - Additional locations for extension icons <feature-77349>`. + +.. index:: Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4/Deprecation-99237-MagicImageService.rst b/Documentation/Changelog/12.4/Deprecation-99237-MagicImageService.rst new file mode 100644 index 0000000..652af77 --- /dev/null +++ b/Documentation/Changelog/12.4/Deprecation-99237-MagicImageService.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _deprecation-99237-1681640732: + +======================================= +Deprecation: #99237 - MagicImageService +======================================= + +See :issue:`99237` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Resource\Service\MagicImageService`, which was +previously used for inline images by EXT:rtehtmlarea has been marked as +deprecated, since its functionality is no longer needed for `CKEditor`. + +Impact +====== + +Using :php:`\TYPO3\CMS\Core\Resource\Service\MagicImageService` or one of its +public methods will raise a deprecation level log message. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using the class or its public +methods. The extension scanner will report usages as strong match. + +Migration +========= + +There is no direct migration. In case you rely on any of the provided +functionality, just copy the corresponding code into your custom extension. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/12.4/Important-100207-LetDataMappercreateEmptyObjectUseDoctrineinstantiator.rst b/Documentation/Changelog/12.4/Important-100207-LetDataMappercreateEmptyObjectUseDoctrineinstantiator.rst new file mode 100644 index 0000000..815c90a --- /dev/null +++ b/Documentation/Changelog/12.4/Important-100207-LetDataMappercreateEmptyObjectUseDoctrineinstantiator.rst @@ -0,0 +1,175 @@ +.. include:: /Includes.rst.txt + +.. _important-100207-1679414752: + +================================================================================== +Important: #100207 - Let DataMapper::createEmptyObject() use doctrine/instantiator +================================================================================== + +See :issue:`100207` + +Description +=========== + +Introduction +------------ + +This document explains the intended way in which the Extbase ORM thaws/hydrates objects. + +Hydrating objects +----------------- + +Hydrating (the term originates from `doctrine/orm`), or in Extbase terms thawing, is +the act of creating an object from a given database row. The responsible class involved +is the :php:`DataMapper`. During the process of hydrating, the :php:`DataMapper` creates +objects to map the raw database data onto. + +Before diving into the framework internals, let's take a look at models from the +user's perspective. + +Creating objects with constructor arguments +------------------------------------------- + +Imagine you have a table :sql:`tx_extension_domain_model_blog` and a corresponding model +or entity (entity is used as a synonym here) :php:`Vendor\Extension\Domain\Model\Blog`. + +Now, also imagine there is a domain rule which states, that all blogs must have a +title. This rule can easily be followed by letting the blog class have a constructor +with a required argument :php:`string $title`. + +.. code-block:: php + + class Blog extends AbstractEntity + { + protected ObjectStorage $posts; + + public function __construct(protected string $title) + { + $this->posts = new ObjectStorage(); + } + } + +This example also shows how the :php:`posts` property is initialized. It is done in +the constructor because PHP does not allow setting a default value that is of +type object. + +Hydrating objects with constructor arguments +-------------------------------------------- + +Whenever the user creates new blog objects in extension code, the aforementioned +domain rule is followed. It is also possible to work on the :php:`posts` :php:`ObjectStorage` +without further initialization. :php:`new Blog('title')` is all I need to create +a blog object with a valid state. + +What happens in the :php:`DataMapper` however, is a totally different thing. When +hydrating an object, the :php:`DataMapper` cannot follow any domain rules. Its only +job is to map the raw database values onto a `Blog` instance. The :php:`DataMapper` +could of course detect constructor arguments and try to guess which argument +corresponds to what property but only if there is an easy mapping, i.e. if the +constructor takes argument :php:`string $title` and updates property `title` with it. + +To avoid possible errors due to guessing, the :php:`DataMapper` simply +ignores the constructor at all. It does so with the help of the library `doctrine/instantiator`_. + +.. _doctrine/instantiator: https://github.com/doctrine/instantiator + +This pretty much explains the title of this document in detail. But there is more +to all this. + +Initializing objects +-------------------- + +Have a look at the :php:`$posts` property in the example above. If the :php:`DataMapper` +ignores the constructor, that property is in an invalid state, i.e. uninitialized. + +To address this problem and possible others, the :php:`DataMapper` will call the method +`initializeObject(): void` on models, if it exists. + +Here is an updated version of the model: + +.. code-block:: php + + class Blog extends AbstractEntity + { + protected ObjectStorage $posts; + + public function __construct(protected string $title) + { + $this->initializeObject(); + } + + public function initializeObject(): void + { + $this->posts = new ObjectStorage(); + } + } + +This example demonstrates how Extbase expects the user to set up their model(s). If +method :php:`initializeObject()` is used for initialization logic that needs to be +triggered on initial creation AND on hydration. Please mind that :php:`__construct()` +**SHOULD** call :php:`initializeObject()`. + +If there are no domain rules to follow, the recommended way to set up a model +would then still be to define a :php:`__construct()` and :php:`initializeObject()` +method like this: + +.. code-block:: php + + class Blog extends AbstractEntity + { + protected ObjectStorage $posts; + + public function __construct() + { + $this->initializeObject(); + } + + public function initializeObject(): void + { + $this->posts = new ObjectStorage(); + } + } + +Mutating objects +---------------- + +I'd like to add a few more words on mutators (setter, adder, etc.). One might think that +:php:`DataMapper` uses mutators during object hydration but it DOES NOT. `mutators` +are the only way for the user (developer) to implement business rules besides +using the constructor. + +The :php:`DataMapper` uses the `@internal` method :php:`AbstractDomainObject::_setProperty()` +to update object properties. This looks a bit dirty and is a way around all business +rules but that's what the :php:`DataMapper` needs in order to leave the `mutators` to +the users. + +.. warning:: + + While :php:`DataMapper` does not use any mutators, other parts of Extbase do. + Both, validation and property mapping, either use existing mutators or gather + type information from them. This will change in the future but as of TYPO3 v12 LTS + this information is correct. + +Property visibility +------------------- + +One important thing to know is that Extbase needs entity properties to be protected +or public. As written in the former paragraph, :php:`AbstractDomainObject::_setProperty()` +is used to bypass setters. :php:`AbstractDomainObject` however, is not able to access +private properties of child classes, hence the need to have protected or public +properties. + + +Dependency injection +-------------------- + +Without digging too deep into this topic the following statements have to be made. +Extbase expects entities to be so called prototypes, i.e. classes that do have a +different state per instance. DataMapper DOES NOT use dependency injection for the +creation of entities, i.e. it does not query the object container. This also means, +that dependency injection is not possible in entities. + +If you think that your entities need to use/access services, you need to find other +ways to implement it. + +.. index:: PHP-API, ext:extbase diff --git a/Documentation/Changelog/12.4/Important-100525-DropUsageOfTextRightAndTextLeft.rst b/Documentation/Changelog/12.4/Important-100525-DropUsageOfTextRightAndTextLeft.rst new file mode 100644 index 0000000..2db6ac4 --- /dev/null +++ b/Documentation/Changelog/12.4/Important-100525-DropUsageOfTextRightAndTextLeft.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _important-100525-1681029540: + +================================================================================ +Important: #100525 - Dropped usage of .text(-*)-right and .text(-*)-left classes +================================================================================ + +See :issue:`100525` + +Description +=========== + +The Core has dropped support for directional class names to +better support RTL languages. We are now preferring the logical +class names over the directional ones. This change also affects +the default RTE configuration. + +In summary, that means we are dropping the classes :css:`.text-right` +and :css:`.text-left` and replacing them with their logical counterparts +:css:`.text-end` and :css:`.text-start`. + +We are still shipping the :css:`.text-right` and :css:`.text-left` classes +with the default RTE content styling. Your content is +persisted as is and we have no intention of changing this. + +You will see the following: + +- Your content is still aligned as you set it once +- The alignment button will not be active anymore for :css:`.text-left` + and :css:`.text-right` +- New alignments will now use :css:`.text-end` and :css:`.text-start` + +While there is never a good time to introduce such a change, +we still think this will benefit us all over time. + +If you want to follow us on that route, we suggest that you +add the following CSS to your frontend and or the custom +CSS for your RTE. + +.. code-block:: css + + .text-end { + text-align: end; + } + .text-start { + text-align: start; + } + +See caniuse for compatibility, which is 96.23% at the time of writing. +For example: https://caniuse.com/?search=text-align%3A%20start + +You need to adjust your RTE config, if you want to use +the old classes. + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/RTE/MyPreset.yaml + + editor: + config: + alignment: + options: + - { name: 'left', className: 'text-left' } + - { name: 'center', className: 'text-center' } + - { name: 'right', className: 'text-right' } + - { name: 'justify', className: 'text-justify' } + + +.. index:: RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/12.4/Important-100634-Rich-Text-EditorAlwaysEnabledPerUser.rst b/Documentation/Changelog/12.4/Important-100634-Rich-Text-EditorAlwaysEnabledPerUser.rst new file mode 100644 index 0000000..6afab16 --- /dev/null +++ b/Documentation/Changelog/12.4/Important-100634-Rich-Text-EditorAlwaysEnabledPerUser.rst @@ -0,0 +1,37 @@ +.. include:: /Includes.rst.txt + +.. _important-100634-1681822129: + +============================================================= +Important: #100634 - Rich Text Editor always enabled per user +============================================================= + +See :issue:`100634` + +Description +=========== + +Back in TYPO3 v3.x there was an RTE integrated into TYPO3 which only worked +in Internet Explorer 4+, but not in Mozilla / Firefox browsers. This was a +huge mess, as not every user / client was able to use an RTE and instead to had +to write pure HTML in a :html:`<textarea>` input field with special tags ("typolink" etc). + +Since TYPO3 v4 a huge effort were made to integrate HTMLarea as Rich Text Editor, +which was forked and developed by the TYPO3 community. It was then possible for +most users working with a real RTE. + +In v8, TYPO3 migrated towards CKEditor 4 as a dependency, and :ref:`CKEditor 5 <feature-96874-1664488673>` with +TYPO3 v12, the Rich Text Editor is working very browser-native for modern browsers +without an iframe around the RTE. + +A lot of legacy code was moved and migrated, however, one option - the option +to deactivate the Rich Text Editor on a per-user basis - which was necessary in +TYPO3 v3, has now been removed, as it is not needed in 99.99% +of TYPO3 installations and users anymore nowadays. + +Impact +====== + +The previous user TSconfig setting :typoscript:`setup.edit_RTE` has no effect anymore. + +.. index:: TSConfig, ext:setup diff --git a/Documentation/Changelog/12.4/Important-100658-DropUserTSOptionsCreateFoldersInEBAndFolderTreehideCreateFolder.rst b/Documentation/Changelog/12.4/Important-100658-DropUserTSOptionsCreateFoldersInEBAndFolderTreehideCreateFolder.rst new file mode 100644 index 0000000..0a587d0 --- /dev/null +++ b/Documentation/Changelog/12.4/Important-100658-DropUserTSOptionsCreateFoldersInEBAndFolderTreehideCreateFolder.rst @@ -0,0 +1,20 @@ +.. include:: /Includes.rst.txt + +.. _important-100658-1681819486: + +==================================================================================================== +Important: #100658 - Drop use TSconfig options `createFoldersInEB` and `folderTree.hideCreateFolder` +==================================================================================================== + +See :issue:`100658` + +Description +=========== + +The user TSconfig options :typoscript:`createFoldersInEB` and :typoscript:`folderTree.hideCreateFolder` were +used in the past to control the existence of the "Create folder" form in Element +Browser instances. With the migration of the "Create folder" view into a separate +modal used in EXT:filelist, which is based on Element Browser as well, those +options became useless and are therefore dropped. + +.. index:: Backend, TSConfig, ext:backend diff --git a/Documentation/Changelog/12.4/Important-94246-GenericSudoModeConfiguration.rst b/Documentation/Changelog/12.4/Important-94246-GenericSudoModeConfiguration.rst new file mode 100644 index 0000000..27d2403 --- /dev/null +++ b/Documentation/Changelog/12.4/Important-94246-GenericSudoModeConfiguration.rst @@ -0,0 +1,106 @@ +.. include:: /Includes.rst.txt + +.. _important-94246-1681366863: + +=================================================== +Important: #94246 - Generic sudo mode configuration +=================================================== + +See :issue:`94246` + +Description +=========== + +:doc:`Sudo mode <../9.5.x/Important-92836-IntroduceSudoModeForInstallToolAccessedViaBackend>` +has been integrated since TYPO3 v9.5.x to protect only Install Tool components. With TYPO3 v12 +it has been changed to a generic configuration for backend routes (and implicitly modules). + +Besides that, access to the Extension Manager now needs to pass the sudo mode verification as well. + + +Process in a nutshell +--------------------- + +All simplified classnames below are located in the namespace :php:`\TYPO3\CMS\Backend\Security\SudoMode\Access`). +The low-level request orchestration happens in the middleware :php:`\TYPO3\CMS\Backend\Middleware\SudoModeInterceptor`, +markup rendering and payload processing in controller :php:`\TYPO3\CMS\Backend\Controller\Security\SudoModeController`. + +#. A backend route is processed, that requires sudo mode for route URI `/my/route` + in :php:`\TYPO3\CMS\Backend\Http\RouteDispatcher`. +#. Using :php:`AccessFactory` and :php:`AccessStorage`, the :php:`RouteDispatcher` + tries to find a valid and not expired :php:`AccessGrant` item for the specific + :php:`RouteAccessSubject('/my/route')` aspect in the current backend user session data. +#. In case no :php:`AccessGrant` can be determined, a new :php:`AccessClaim` is created + for the specific :php:`RouteAccessSubject` instance and temporarily persisted in the + current user session data - the claim also contains the originally requested route + as :php:`ServerRequestInstruction` (a simplified representation of a :php:`ServerRequestInterface`). +#. Next, the user is redirected to the user interface for providing either their own password, or + the global install tool password as alternative. +#. Given, the password was correct, the :php:`AccessClaim` is "converted" to an + :php:`AccessGrant`, which is only valid for the specific subject (URI `/my/route`) + and for a limited lifetime. + + +Configuration +------------- + +In general, the configuration for a particular route or module looks like this: + +.. code-block:: php + + <?php + // ... + 'sudoMode' => [ + 'group' => 'individual-group-name', + 'lifetime' => AccessLifetime::veryShort, + ], + +* `group` (optional): if given, grants access to other objects of the same `group` + without having to verify sudo mode again for a the given lifetime. Example: + Admin Tool modules :guilabel:`Maintainance` and :guilabel:`Settings` are configured with the same + `systemMaintainer` group - having access to one (after sudo mode verification) + grants access to the other automatically. +* `lifetime`: enum value of :php:`\TYPO3\CMS\Backend\Security\SudoMode\Access\AccessLifetime`, + defining the lifetime of a sudo mode verification, afterwards users have to go through + the process again - cases are `veryShort` (5 minutes), `short` (10 minutes), + `medium` (15 minutes), `long` (30 minutes), `veryLong` (60 minutes) + + +For backend routes declared via :file:`Configuration/Backend/Routes.php`, the +relevant configuration would look like this: + +.. code-block:: php + + <?php + return [ + 'my-route' => [ + 'path' => '/my/route', + 'target' => MyHandler::class . '::process', + 'sudoMode' => [ + 'group' => 'mySudoModeGroup', + 'lifetime' => AccessLifetime::short, + ], + ], + ]; + + +For backend modules declared via :file:`Configuration/Backend/Modules.php`, the +relevant configuration would look like this: + +.. code-block:: php + + <?php + return [ + 'tools_ExtensionmanagerExtensionmanager' => [ + // ... + 'routeOptions' => [ + 'sudoMode' => [ + 'group' => 'systemMaintainer', + 'lifetime' => AccessLifetime::medium, + ], + ], + ], + ]; + + +.. index:: Backend, ext:backend diff --git a/Documentation/Changelog/12.4/Index.rst b/Documentation/Changelog/12.4/Index.rst new file mode 100644 index 0000000..99fbe88 --- /dev/null +++ b/Documentation/Changelog/12.4/Index.rst @@ -0,0 +1,50 @@ +:template: changelogOverview.html +.. include:: /Includes.rst.txt +.. _changelog-12-4: + +============ +12.4 Changes +============ + +**Table of contents** + +.. contents:: + :local: + :depth: 1 + + +Breaking Changes +================ + +None since TYPO3 v12.0 release. + +.. attention:: + + After TYPO3 v12.0, only new functionality with a solid migration path + can be added on top, with aiming for as little as possible breaking changes + after the initial v12.0 release on the way to LTS. + +Features +======== + +None since TYPO3 v12.3 release. + +Deprecation +=========== + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Deprecation-* + +Important +========= + +.. toctree:: + :maxdepth: 1 + :titlesonly: + :glob: + + Important-* diff --git a/Documentation/Changelog/13.0/Breaking-100224-MfaViewTypeMigratedToBackedEnum.rst b/Documentation/Changelog/13.0/Breaking-100224-MfaViewTypeMigratedToBackedEnum.rst new file mode 100644 index 0000000..008e538 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-100224-MfaViewTypeMigratedToBackedEnum.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _breaking-100224-1688541732: + +======================================================= +Breaking: #100224 - MfaViewType migrated to backed enum +======================================================= + +See :issue:`100224` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Authentication\Mfa\MfaViewType` has been +migrated to a native PHP backed enum. + + +Impact +====== + +Since :php:`MfaViewType` is no longer a class, the existing class constants +are no longer available, but are enum instances instead. + +In addition, it's not possible to instantiate the class anymore or call +the :php:`equals()` method. + +The :php:`\TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface`, which +all MFA providers need to implement, does now require the third argument +:php:`$type` of the :php:`handleRequest()` method to be a :php:`MfaViewType` +instead of a :php:`string`. + + +Affected installations +====================== + +All installations directly using the class constants, instantiating the +class or calling the :php:`equals()` method. + +All extensions with custom MFA providers, which therefore implement the +:php:`handleRequest()` method. + +Migration +========= + +To access the string representation of a :php:`MfaViewType`, use the +corresponding :php:`value` property, e.g. +:php:`\TYPO3\CMS\Core\Authentication\Mfa\MfaViewType::SETUP->value` or on a +variable, use :php:`$type->value`. + +Replace class instantiation by :php:`\TYPO3\CMS\Core\Authentication\Mfa\MfaViewType::tryFrom('setup')`. + +Adjust your MFA providers :php:`handleRequest()` method to match the interface: + +.. code-block:: php + + public function handleRequest( + ServerRequestInterface $request, + MfaProviderPropertyManager $propertyManager, + MfaViewType $type + ): ResponseInterface; + +.. index:: Backend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-100229-ConvertJSConfirmationToBitSet.rst b/Documentation/Changelog/13.0/Breaking-100229-ConvertJSConfirmationToBitSet.rst new file mode 100644 index 0000000..1922ca9 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-100229-ConvertJSConfirmationToBitSet.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _breaking-JSConfirmation-1687503100: + +====================================================== +Breaking: #100229 - Convert JSConfirmation to a BitSet +====================================================== + +See :issue:`100229` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation` is replaced by +:php:`\TYPO3\CMS\Core\Authentication\JsConfirmation`. The new class is +extending the :php:`\TYPO3\CMS\Core\Type\BitSet` class instead of +:php:`\TYPO3\CMS\Core\TypeEnumeration\Enumeration`. + +Impact +====== + +Since :php:`JSConfirmation` is now extending the class :php:`\TYPO3\CMS\Core\Type\BitSet` +it's no longer possible to call the following public methods: + +- :php:`matches()` +- :php:`setValue()` +- :php:`isValid()` + +The only static method left is: +:php:`compare()` + +Affected installations +====================== + +Custom TYPO3 extensions calling public methods: + +- :php:`matches()` +- :php:`setValue()` +- :php:`isValid()` + +Custom TYPO3 extensions calling static methods in +:php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation` +except for the method :php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation::compare()`. + +Custom TYPO3 extensions calling +:php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->jsConfirmation()`, +if first argument passed is not an :php:`int`. + +Migration +========= + +Replace existing usages of :php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation` +with :php:`\TYPO3\CMS\Core\Authentication\JsConfirmation`. + +There is no migration for the methods: + +- :php:`matches()` +- :php:`setValue()` +- :php:`isValid()` + +Remove existing calls to static methods +:php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation::method()` +and where :php:`JSConfirmation::compare()` is used, replace the namespace from +:php:`\TYPO3\CMS\Core\Type\Bitmask\JSConfirmation` to +:php:`\TYPO3\CMS\Core\Authentication\JsConfirmation`. + +Ensure an int value is passed to: + +- :php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->jsConfirmation()` + +.. index:: Backend, NotScanned, ext:backend, ext:core, ext:filelist diff --git a/Documentation/Changelog/13.0/Breaking-100963-DeprecatedFunctionalityRemoved.rst b/Documentation/Changelog/13.0/Breaking-100963-DeprecatedFunctionalityRemoved.rst new file mode 100644 index 0000000..234adb2 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-100963-DeprecatedFunctionalityRemoved.rst @@ -0,0 +1,516 @@ +.. include:: /Includes.rst.txt + +.. _breaking-100963-1686129084: + +==================================================== +Breaking: #100963 - Deprecated functionality removed +==================================================== + +See :issue:`100963` + +Description +=========== + +The following PHP classes that have previously been marked as deprecated with v12 have been removed: + +- :php:`\TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher` +- :php:`\TYPO3\CMS\Backend\EventListener\SilentSiteLanguageFlagMigration` +- :php:`\TYPO3\CMS\Backend\Template\Components\Buttons\Action\HelpButton` +- :php:`\TYPO3\CMS\Backend\Tree\View\BrowseTreeView` +- :php:`\TYPO3\CMS\Backend\Tree\View\ElementBrowserPageTreeView` +- :php:`\TYPO3\CMS\Core\Configuration\Loader\PageTsConfigLoader` +- :php:`\TYPO3\CMS\Core\Configuration\PageTsConfig` +- :php:`\TYPO3\CMS\Core\Configuration\Parser\PageTsConfigParser` +- :php:`\TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\AbstractConditionMatcher` +- :php:`\TYPO3\CMS\Core\Configuration\TypoScript\Exception\InvalidTypoScriptConditionException` +- :php:`\TYPO3\CMS\Core\Controller\RequireJsController` +- :php:`\TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction` +- :php:`\TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction` +- :php:`\TYPO3\CMS\Core\Exception\MissingTsfeException` +- :php:`\TYPO3\CMS\Core\ExpressionLanguage\DeprecatingRequestWrapper` +- :php:`\TYPO3\CMS\Core\Resource\Service\MagicImageService` +- :php:`\TYPO3\CMS\Core\Resource\Service\UserFileInlineLabelService` +- :php:`\TYPO3\CMS\Core\Resource\Service\UserFileMountService` +- :php:`\TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser` +- :php:`\TYPO3\CMS\Core\TypoScript\TemplateService` +- :php:`\TYPO3\CMS\Core\Utility\ResourceUtility` +- :php:`\TYPO3\CMS\Dashboard\Views\Factory` +- :php:`\TYPO3\CMS\Fluid\ViewHelpers\Be\Buttons\CshViewHelper` +- :php:`\TYPO3\CMS\Fluid\ViewHelpers\Be\Labels\CshViewHelper` +- :php:`\TYPO3\CMS\Frontend\Configuration\TypoScript\ConditionMatching\ConditionMatcher` +- :php:`\TYPO3\CMS\Frontend\Plugin\AbstractPlugin` + +The following PHP classes have been declared :php:`final`: + +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOMySql\Driver` +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOPgSql\Driver` +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOSqlite\Driver` + +The following PHP interfaces that have previously been marked as deprecated with v12 have been removed: + +- :php:`\TYPO3\CMS\Backend\Form\Element\InlineElementHookInterface` +- :php:`\TYPO3\CMS\Backend\RecordList\RecordListGetTableHookInterface` +- :php:`\TYPO3\CMS\Backend\Wizard\NewContentElementWizardHookInterface` +- :php:`\TYPO3\CMS\Core\Configuration\TypoScript\ConditionMatching\ConditionMatcherInterface` +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepositoryGetPageOverlayHookInterface` +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepositoryGetRecordOverlayHookInterface` +- :php:`\TYPO3\CMS\Dashboard\Widgets\RequireJsModuleInterface` +- :php:`\TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuFilterPagesHookInterface` +- :php:`\TYPO3\CMS\Frontend\ContentObject\TypolinkModifyLinkConfigForPageLinksHookInterface` +- :php:`\TYPO3\CMS\Frontend\Http\UrlProcessorInterface` + +The following PHP interfaces changed: + +- :php:`\TYPO3\CMS\Adminpanel\ModuleApi\ShortInfoProviderInterface` method :php:`setModuleData()` added +- :php:`\TYPO3\CMS\Backend\Form\NodeInterface` method :php:`setData()` added +- :php:`\TYPO3\CMS\Backend\Form\NodeInterface` method :php:`render()` must return :php:`array` +- :php:`\TYPO3\CMS\Backend\Form\NodeResolverInterface` method :php:`setData()` added +- :php:`\TYPO3\CMS\Backend\Form\NodeResolverInterface` method :php:`resolve()` must return :php:`?string` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface` method `getContentObject()` removed +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface` method `isFeatureEnabled()` removed +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface` method `setContentObject()` removed +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface` method `setRequest()` added + +The following PHP class aliases that have previously been marked as deprecated with v12 have been removed: + +- :php:`\TYPO3\CMS\Backend\ElementBrowser\FileBrowser` +- :php:`\TYPO3\CMS\Backend\ElementBrowser\FolderBrowser` +- :php:`\TYPO3\CMS\Backend\Form\Element\InputColorPickerElement` +- :php:`\TYPO3\CMS\Backend\Form\Element\InputDateTimeElement` +- :php:`\TYPO3\CMS\Backend\Form\Element\InputLinkElement` +- :php:`\TYPO3\CMS\Backend\Provider\PageTsBackendLayoutDataProvider` +- :php:`\TYPO3\CMS\Frontend\Service\TypoLinkCodecService` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkResultFactory` +- :php:`\TYPO3\CMS\Recordlist\Browser\AbstractElementBrowser` +- :php:`\TYPO3\CMS\Recordlist\Browser\DatabaseBrowser` +- :php:`\TYPO3\CMS\Recordlist\Browser\ElementBrowserInterface` +- :php:`\TYPO3\CMS\Recordlist\Browser\ElementBrowserRegistry` +- :php:`\TYPO3\CMS\Recordlist\Browser\FileBrowser` +- :php:`\TYPO3\CMS\Recordlist\Browser\FolderBrowser` +- :php:`\TYPO3\CMS\Recordlist\Controller\AbstractLinkBrowserController` +- :php:`\TYPO3\CMS\Recordlist\Controller\AccessDeniedException` +- :php:`\TYPO3\CMS\Recordlist\Controller\ClearPageCacheController` +- :php:`\TYPO3\CMS\Recordlist\Controller\ElementBrowserController` +- :php:`\TYPO3\CMS\Recordlist\Controller\RecordDownloadController` +- :php:`\TYPO3\CMS\Recordlist\Controller\RecordListController` +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListHeaderColumnsEvent` +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListRecordActionsEvent` +- :php:`\TYPO3\CMS\Recordlist\Event\ModifyRecordListTableActionsEvent` +- :php:`\TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\AbstractLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\FileLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\FolderLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\LinkHandlerInterface` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\MailLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\PageLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\RecordLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\TelephoneLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\LinkHandler\UrlLinkHandler` +- :php:`\TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList` +- :php:`\TYPO3\CMS\Recordlist\RecordList\DownloadRecordList` +- :php:`\TYPO3\CMS\Recordlist\Tree\View\LinkParameterProviderInterface` +- :php:`\TYPO3\CMS\Recordlist\View\FolderUtilityRenderer` +- :php:`\TYPO3\CMS\Recordlist\View\RecordSearchBoxComponent` + +The following PHP class methods that have previously been marked as deprecated with v12 have been removed: + +- :php:`\TYPO3\CMS\Backend\Template\Components\ButtonBar->makeHelpButton()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->getBodyTag()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->getDynamicTabMenu()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->getView()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->header()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->isUiBlock()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->registerModuleMenu()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->renderContent()` +- :php:`\TYPO3\CMS\Backend\Template\ModuleTemplate->setContent()` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->addTagAttributes()` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->determineScriptUrl()` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->getRootIcon()` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->getRootRecord()` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->getThisScript()` +- :php:`\TYPO3\CMS\Core\Authentication\BackendUserAuthentication->modAccess()` +- :php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools->removeElementTceFormsRecursive()` +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOMySql\Driver->getName()` +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOPgSql\Driver->getName()` +- :php:`\TYPO3\CMS\Core\Database\Driver\PDOSqlite\Driver->getName()` +- :php:`\TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression->add()` +- :php:`\TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression->addMultiple()` +- :php:`\TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder->andX()` +- :php:`\TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder->orX()` +- :php:`\TYPO3\CMS\Core\Database\Query\QueryBuilder->execute()` +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->getExtURL()` +- :php:`\TYPO3\CMS\Core\Environment->getBackendPath()` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->getLL()` +- :php:`\TYPO3\CMS\Core\Localization\Locales->getIsoMapping()` +- :php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction->shallLoadRequireJs()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->loadRequireJs()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->loadRequireJsModule()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->setRenderXhtml()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->getRenderXhtml()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->setCharSet()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->getCharSet()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->setMetaCharsetTag()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->getMetaCharsetTag()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->setBaseUrl()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->getBaseUrl()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->enableRemoveLineBreaksFromTemplate()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->disableRemoveLineBreaksFromTemplate()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->getRemoveLineBreaksFromTemplate()` +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->enableDebugMode()` +- :php:`\TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter->filterInlineChildren()` +- :php:`\TYPO3\CMS\Core\Session\UserSessionManager->createFromGlobalCookieOrAnonymous()` +- :php:`\TYPO3\CMS\Core\Site\Entity\SiteLanguage->getTwoLetterIsoCode()` +- :php:`\TYPO3\CMS\Core\Site\Entity\SiteLanguage->getDirection()` +- :php:`\TYPO3\CMS\Core\Type\DocType->getXhtmlDocType()` +- :php:`\TYPO3\CMS\Dashboard\DashboardInitializationService->getRequireJsModules()` +- :php:`\TYPO3\CMS\Extbase\Configuration\BackendConfigurationManager->getContentObject()` +- :php:`\TYPO3\CMS\Extbase\Configuration\BackendConfigurationManager->setContentObject()` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager->getContentObject()` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager->isFeatureEnabled()` +- :php:`\TYPO3\CMS\Extbase\Configuration\ConfigurationManager->setContentObject()` +- :php:`\TYPO3\CMS\Extbase\Configuration\FrontendConfigurationManager->getContentObject()` +- :php:`\TYPO3\CMS\Extbase\Configuration\FrontendConfigurationManager->setContentObject()` +- :php:`\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder->getRequest()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->setLanguageOverlayMode()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->getLanguageOverlayMode()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->setLanguageUid()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings->getLanguageUid()` +- :php:`\TYPO3\CMS\Extbase\Property\AbstractTypeConverter->canConvertFrom()` +- :php:`\TYPO3\CMS\Extbase\Property\AbstractTypeConverter->getPriority()` +- :php:`\TYPO3\CMS\Extbase\Property\AbstractTypeConverter->getSupportedTargetType()` +- :php:`\TYPO3\CMS\Extbase\Property\AbstractTypeConverter->getSupportedSourceTypes()` +- :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getFormat()` +- :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getRequest()` +- :php:`\TYPO3\CMS\Fluid\View\StandaloneView->getTemplatePathAndFilename()` +- :php:`\TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent->getErrorMessage()` +- :php:`\TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent->isPropagationStopped()` +- :php:`\TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent->setAsInvalid()` +- :php:`\TYPO3\CMS\FrontendLogin\Event\PasswordChangeEvent->setHashedPassword()` +- :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication->getUserTSconf()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->baseUrlWrap()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->checkEnableFields()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->doWorkspacePreview()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getPagesTSconfig()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->initUserGroups()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->isBackendUserLoggedIn()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->isUserOrGroupSet()` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->whichWorkspace()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->createFromUriString()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getATagParams()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getMailTo()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getQueryArguments()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getTreeList()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getTypoLink_URL()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getTypoLink()` +- :php:`\TYPO3\CMS\Frontend\Typolink\LinkFactory->getUrlToCurrentLocation()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->addTask()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->fetchTaskRecord()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->fetchTaskWithCondition()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->fetchTask()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->isValidTaskObject()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->removeTask()` +- :php:`\TYPO3\CMS\Scheduler\Scheduler->saveTask()` +- :php:`\TYPO3\CMS\Scheduler\Task\AbstractTask->isExecutionRunning()` +- :php:`\TYPO3\CMS\Scheduler\Task\AbstractTask->markExecution()` +- :php:`\TYPO3\CMS\Scheduler\Task\AbstractTask->remove()` +- :php:`\TYPO3\CMS\Scheduler\Task\AbstractTask->unmarkAllExecutions()` +- :php:`\TYPO3\CMS\Scheduler\Task\AbstractTask->unmarkExecution()` +- :php:`\TYPO3\CMS\Setup\Event\AddJavaScriptModulesEvent->addModule()` +- :php:`\TYPO3\CMS\Setup\Event\AddJavaScriptModulesEvent->getModules()` + +The following PHP static class methods that have previously been marked as deprecated for v12 have been removed: + +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::ADMCMD_previewCmds()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::cshItem()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getClickMenuOnIconTagParameters()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getDropdownMenu()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getFuncCheck()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getFuncMenu()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getLinkToDataHandlerAction()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getPreviewUrl()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getRecordToolTip()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getThumbnailUrl()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::getUpdateSignalCode()` +- :php:`\TYPO3\CMS\Backend\Utility\BackendUtility::isModuleSetInTBE_MODULES()` +- :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::get()` +- :php:`\TYPO3\CMS\Core\FormProtection\FormProtectionFactory::purgeInstances()` +- :php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction::forRequireJS()` +- :php:`\TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::transform()` +- :php:`\TYPO3\CMS\Core\Utility\DebugUtility::debugInPopUpWindow()` +- :php:`\TYPO3\CMS\Core\Utility\DebugUtility::debugRows()` +- :php:`\TYPO3\CMS\Core\Utility\DebugUtility::printArray()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addCoreNavigationComponent()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addLLrefForTCAdescr()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addModule()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addNavigationComponent()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::allowTableOnStandardPages()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::getFileFieldTCAConfig()` +- :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::insertModuleFunction()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GET()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GP()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_GPmerged()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::_POST()` +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::linkThisScript()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule()` +- :php:`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerTypeConverter()` + +The following methods changed signature according to previous deprecations in v12 at the end of the argument list: + +- :php:`\TYPO3\CMS\Backend\Form\FormDataCompiler->compile()` (argument 2 is now required) +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage->setSeverity()` (argument 1 is now of type :php:`ContextualFeedbackSeverity`) +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue->clear()` (argument 1 is now of type :php:`ContextualFeedbackSeverity|null`) +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue->getAllMessagesAndFlush()` (argument 1 is now of type :php:`ContextualFeedbackSeverity|null`) +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue->getAllMessages()` (argument 1 is now of type :php:`ContextualFeedbackSeverity|null`) +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue->removeAllFlashMessagesFromSession()` (argument 1 is now of type :php:`ContextualFeedbackSeverity|null`) +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessages->__construct()` (argument 3 is now of type :php:`ContextualFeedbackSeverity`) +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->setLanguage()` (argument 1 is now of type :php:`Locale`) +- :php:`\TYPO3\CMS\Core\Utility\File\ExtendedFileUtility->addMessageToFlashMessageQueue()` (argument 2 is now of type :php:`ContextualFeedbackSeverity|null`) +- :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::intExplode()` (argument 4 :php:`$limit` has been removed) +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController->addFlashMessage()` (argument 2 is now of type :php:`ContextualFeedbackSeverity`) +- :php:`\TYPO3\CMS\Extbase\Utility\LocalizationUtility::translate()` (argument 4 has been removed) +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->start()` (argument 3 :php:`$request` has been removed) +- :php:`\TYPO3\CMS\Reports\Status->__construct()` (argument 4 is now of type :php:`ContextualFeedbackSeverity`) +- :php:`\TYPO3\CMS\Scheduler\AbstractAdditionalFieldProvider->addMessage()` (argument 2 is now of type :php:`ContextualFeedbackSeverity`) + +The following public class properties have been dropped: + +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->BE_USER` +- :php:`\TYPO3\CMS\Backend\Tree\View\AbstractTreeView->thisScript` +- :php:`\TYPO3\CMS\Core\Localization\LanguageService->debugKey` +- :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce->b64` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->lastTypoLinkLD` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->lastTypoLinkTarget` +- :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->lastTypoLinkUrl` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->baseUrl` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->extTarget` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->fileTarget` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->intTarget` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->spamProtectEmailAddresses` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->tmpl` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->xhtmlDoctype` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->xhtmlVersion` + +The following class method visibility has been changed to protected: + +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->getRecordOverlay()` + +The following class methods are now marked as internal: + +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->isSetSessionCookie()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->isRefreshTimeBasedCookie()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->removeCookie()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->isCookieSet()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->unpack_uc()` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->appendCookieToResponse()` + +The following class methods now have a native return type and removed the +:php:`#[\ReturnTypeWillChange]` attribute: + +- :php:`\TYPO3\CMS\Core\Collection\AbstractRecordCollection->current()` +- :php:`\TYPO3\CMS\Core\Collection\AbstractRecordCollection->key()` +- :php:`\TYPO3\CMS\Core\Log\LogRecord->offsetGet()` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessageQueue->dequeue()` +- :php:`\TYPO3\CMS\Core\Resource\Collection\AbstractFileCollection->key()` +- :php:`\TYPO3\CMS\Core\Resource\MetaDataAspect->offsetGet()` +- :php:`\TYPO3\CMS\Core\Resource\MetaDataAspect->current()` +- :php:`\TYPO3\CMS\Core\Resource\Search\Result\EmptyFileSearchResult->current()` +- :php:`\TYPO3\CMS\Core\Resource\Search\Result\EmptyFileSearchResult->key()` +- :php:`\TYPO3\CMS\Core\Routing\SiteRouteResult->offsetGet()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy->current()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy->key()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage->current()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage->offsetGet()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\QueryResult->offsetGet()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\QueryResult->current()` +- :php:`\TYPO3\CMS\Extbase\Persistence\Generic\QueryResult->key()` +- :php:`\TYPO3\CMS\Extbase\Persistence\ObjectStorage->current()` +- :php:`\TYPO3\CMS\Extbase\Persistence\ObjectStorage->offsetGet()` +- :php:`\TYPO3\CMS\Filelist\Dto\ResourceCollection->current()` +- :php:`\TYPO3\CMS\Filelist\Dto\ResourceCollection->key()` + +The following class properties visibility have been changed to protected: + +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->where_hid_del` +- :php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository->where_groupAccess` +- :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->type` + +The following class property visibility has been changed to private: + +- :php:`\TYPO3\CMS\Core\Type\DocType->getXhtmlVersion` + +The following class properties have been marked as internal: + +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->lastLogin_column` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->formfield_uname` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->formfield_uident` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->formfield_status` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->loginSessionStarted` +- :php:`\TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->dontSetCookie` +- :php:`\TYPO3\CMS\Core\Authentication\FrontendUserAuthentication->formfield_permanent` +- :php:`\TYPO3\CMS\Core\Authentication\FrontendUserAuthentication->is_permanent` + +The following class property has changed/enforced type: + +- :php:`\TYPO3\CMS\Core\Page\PageRenderer->endingSlash` (is now string) + +The following eID entry point has been removed: + +- :php:`requirejs` + +The following ViewHelpers have been changed or removed: + +- :html:`<f:be.buttons.csh>` removed +- :html:`<f:be.labels.csh>` removed +- :html:`<f:translate>` Argument "alternativeLanguageKeys" has been removed + +The following TypoScript options have been dropped or adapted: + +- :typoscript:`config.baseURL` +- :typoscript:`config.removePageCss` +- :typoscript:`config.spamProtectEmailAddresses` (only `ascii` value) +- :typoscript:`config.xhtmlDoctype` +- :typoscript:`plugin.[pluginName]._CSS_PAGE_STYLE` +- :typoscript:`[ip()]` condition function must be used in a context with request +- :typoscript:`[loginUser()]` condition function removed +- :typoscript:`[usergroup()]` condition function removed +- :typoscript:`constants` setup top-level-object and :typoscript:`constants` property of :typoscript:`parseFunc` +- :typoscript:`plugin.tx_felogin_login.settings.passwordValidators` has been removed + +The following constant has been dropped: + +- :php:`TYPO3_mainDir` + +The following class constants have been dropped: + +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage::ERROR` +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage::INFO` +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage::NOTICE` +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage::OK` +- :php:`\TYPO3\CMS\Core\Messaging\AbstractMessage::WARNING` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::ERROR` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::INFO` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::NOTICE` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::OK` +- :php:`\TYPO3\CMS\Core\Messaging\FlashMessage::WARNING` +- :php:`\TYPO3\CMS\Core\Page\JavaScriptModuleInstruction::FLAG_LOAD_REQUIRE_JS` +- :php:`\TYPO3\CMS\Reports\Status::ERROR` +- :php:`\TYPO3\CMS\Reports\Status::INFO` +- :php:`\TYPO3\CMS\Reports\Status::NOTICE` +- :php:`\TYPO3\CMS\Reports\Status::OK` +- :php:`\TYPO3\CMS\Reports\Status::WARNING` + +The following global option handling have been dropped and are ignored: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['defaultUserTSconfig']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['versionNumberInFilename']` only accepts a boolean value now + +The following global variables have been removed: + +- :php:`$GLOBALS['TBE_STYLES']` +- :php:`$GLOBALS['TBE_STYLES']['stylesheet']` +- :php:`$GLOBALS['TBE_STYLES']['stylesheet2']` +- :php:`$GLOBALS['TBE_STYLES']['skins']` +- :php:`$GLOBALS['TBE_STYLES']['admPanel']` +- :php:`$GLOBALS['TCA_DESCR']` + +The following hooks have been removed: + +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['RequireJS']['postInitializationModules']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/cache/frontend/class.t3lib_cache_frontend_abstractfrontend.php']['flushByTag']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['viewOnClickClass']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_post_processing']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['logoff_pre_processing']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['backendUserLogin']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['getDefaultUploadFolder']` +- :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Lowlevel\Controller\ConfigurationController']['modifyBlindedConfigurationOptions']` + +The following single field configuration has been removed from TCA: + +- :php:`MM_insert_fields` (for TCA fields with `MM` configuration) + +The following event has been removed: + +- :php:`\TYPO3\CMS\Core\Configuration\Event\ModifyLoadedPageTsConfigEvent` + +The following fallbacks have been removed: + +- Usage of the :file:`ext_icon.*` file locations for extension icons +- Usage of the result property :php:`additionalJavaScriptPost` of the form engine result array +- Using chart.js v3 compatible widgets in ext:dashboard +- Usage of :js:`.t3js-contextmenutrigger` to trigger and configure context menus +- Usage of the jsonArray property :php:`scriptCall` for AjaxController's +- Binding the selected menu items to callback actions in context menus +- Checking for :php:`\TYPO3\CMS\Core\Site\SiteLanguageAwareTrait` is removed in :php:`\TYPO3\CMS\Core\Routing\Aspect\AspectFactory` +- :html:`f:format.html` ViewHelper no longer works in BE context +- Usage of :php:`JScode` containing inline JavaScript for handing custom signals +- Usage property :php:`$resultArray['requireJsModules']` of the form engine result array +- Using backend FormEngine, the current ServerRequestInterface request must be provided in key "request" as + initialData to FormDataCompiler, the fallback to :php:`$GLOBALS['TYPO3_REQUEST']` has been removed. +- Compatibility layer for "TCEforms" key in FlexFormTools has been removed +- Compatibility layer for using array parameters for files in extbase (use `UploadedFile` instead) + +The following upgrade wizards have been removed: + +- Wizard for migrating backend user languages +- Wizard for installing the extension "legacy_collections" from TER +- Wizard for migrating the :php:`transOrigDiffSourceField` field to a json encoded string +- Wizard for cleaning up workspace `new` placeholders +- Wizard for cleaning up workspace `move` placeholders +- Wizard for migrating shortcut records +- Wizard for sanitizing existing SVG files in the `fileadmin` folder +- Wizard for populating a new channel column of the sys_log table + +The following features are now always enabled: + +- `security.backend.enforceContentSecurityPolicy` + +The following feature has been removed: + +- Regular expression based validators in ext:form backend UI + +The following database table fields have been removed: + +- :sql:`fe_users.TSconfig` +- :sql:`fe_groups.TSconfig` + +The following backend route identifier has been removed: + +- `ajax_core_requirejs` + +The following global JavaScript variable has been removed: + +- :js:`TYPO3.Tooltip` + +The following global JavaScript function has been removed: + +- :js:`Global_JavaScript_Function_Name` + +The following JavaScript module has been removed: + +- :js:`tooltip` + +The following JavaScript method behaviour has changed: + +- :js:`ColorPicker.initialize()` always requires an :js:`HTMLInputElement` to be passed as first argument + +The following JavaScript method has been removed: + +- :js:`getParameterFromUrl()` of :js:`@typo3/backend/utility` + +The following CKEditor plugin has been removed: + +- :js:`SoftHyphen` + +The following dependency injection service aliase has been removed: + +- :yaml:`@dashboard.views.widget` + +Impact +====== + +Using above removed functionality will most likely raise PHP fatal level errors, +may change website output or crashes browser JavaScript. + +.. index:: Backend, CLI, Database, FlexForm, Fluid, Frontend, JavaScript, LocalConfiguration, PHP-API, RTE, TCA, TSConfig, TypoScript, PartiallyScanned diff --git a/Documentation/Changelog/13.0/Breaking-100966-RemoveJquery-ui.rst b/Documentation/Changelog/13.0/Breaking-100966-RemoveJquery-ui.rst new file mode 100644 index 0000000..61dc7af --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-100966-RemoveJquery-ui.rst @@ -0,0 +1,50 @@ +.. include:: /Includes.rst.txt + +.. _breaking-100966-1686062649: + +==================================== +Breaking: #100966 - Remove jquery-ui +==================================== + +See :issue:`100966` + +Description +=========== + +The `NPM package jquery-ui <https://www.npmjs.com/package/jquery-ui>`__ has +been removed completely for TYPO3 v13 without any substitute. + +According to the `TYPO3 Deprecation Policy <https://typo3.org/article/typo3-deprecation-policy>`__, +JavaScript code and packages used only in the TYPO3 backend are not +considered to be part of that policy: + + The deprecation policy does not cover the deprecations of backend components + such as JavaScript code, CSS code, HTML code, and backend templates. + + +Impact +====== + +TYPO3 does not ship the NPM package `jquery-ui` any longer. Third-party +extensions that rely on this package will be broken and need to be adjusted. + +Since TYPO3 exposed only parts of `jquery-ui`, only the components `core`, +`draggable`, `droppable`, `mouse`, `resizable`, `selectable`, `sortable` and +`widget` are affected - other components simply did not exist. + + +Affected installations +====================== + +Those having custom or third-party extensions using `jquery-ui` from +:file:`typo3/sysext/core/Resources/Public/JavaScript/Contrib/jquery-ui/`. + + +Migration +========= + +TYPO3 does not provide any substitute. In TYPO3 the `draggable` and `resizable` +features of `jquery-ui` have been reimplemented in the new custom element +:html:`<typo3-backend-draggable-resizable>`. + +.. index:: Backend, JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101129-ConvertActionToNativeEnum.rst b/Documentation/Changelog/13.0/Breaking-101129-ConvertActionToNativeEnum.rst new file mode 100644 index 0000000..21479fd --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101129-ConvertActionToNativeEnum.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-Action-1687355374: + +======================================================== +Breaking: #101129 - Convert Action to native backed enum +======================================================== + +See :issue:`101129` + +Description +=========== + +The class :php:`\TYPO3\CMS\Scheduler\Task\Enumeration\Action` is now +converted to a native backed enum. In addition the class is moved to +the namespace :php:`\TYPO3\CMS\Scheduler` and renamed to +:php:`SchedulerManagementAction`. + +Impact +====== + +Since :php:`\TYPO3\CMS\Scheduler\Task\Enumeration\Action` is no longer +a class, the existing class constants are no longer available. +In addition it's not possible to instantiate it anymore. + +Affected installations +====================== + +Third-party extensions using the following class constants: + +- :php:`\TYPO3\CMS\Scheduler\Task\Enumeration\Action::ADD` +- :php:`\TYPO3\CMS\Scheduler\Task\Enumeration\Action::EDIT` +- :php:`\TYPO3\CMS\Scheduler\Task\Enumeration\Action::LIST` + +Class instantiation: + +- :php:`new Action('a-string')` + + +Migration +========= + +Include the enum :php:`SchedulerManagementAction` from namespace :php:`\TYPO3\CMS\Scheduler` +as a replacement for :php:`Action`. + +Use the new syntax + +- :php:`\TYPO3\CMS\Scheduler\SchedulerManagementAction::ADD` +- :php:`\TYPO3\CMS\Scheduler\SchedulerManagementAction::EDIT` +- :php:`\TYPO3\CMS\Scheduler\SchedulerManagementAction::LIST` + +as well as the :php:`tryFrom($aString)` static method of the backed enum. + + +.. index:: Backend, NotScanned, ext:linkvalidator, ext:recycler, ext:reports, ext:scheduler diff --git a/Documentation/Changelog/13.0/Breaking-101131-ConvertLoginTypeToNativeEnum.rst b/Documentation/Changelog/13.0/Breaking-101131-ConvertLoginTypeToNativeEnum.rst new file mode 100644 index 0000000..f8de863 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101131-ConvertLoginTypeToNativeEnum.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101131-1687289195: + +=========================================================== +Breaking: #101131 - Convert LoginType to native backed enum +=========================================================== + +See :issue:`101131` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Authentication\LoginType` is now +converted to a native backed enum. + +Impact +====== + +Since :php:`\TYPO3\CMS\Core\Authentication\LoginType` is no longer +a class, the existing class constants are no longer available. + +Affected installations +====================== + +Custom authenticators using the following class constants: + +- :php:`\TYPO3\CMS\Core\Authentication\LoginType::LOGIN` +- :php:`\TYPO3\CMS\Core\Authentication\LoginType::LOGOUT` + +Migration +========= + +Use the new syntax: +:php:`\TYPO3\CMS\Core\Authentication\LoginType::LOGIN->value` +:php:`\TYPO3\CMS\Core\Authentication\LoginType::LOGOUT->value` + +Alternatively, use the enum method :php:`tryFrom` to convert a +value to an enum. For direct comparison of two enums, the null-coalescing +operator shall be used to ensure that the parameter is a string: + +.. code-block:: php + + <?php + + use TYPO3\CMS\Core\Authentication\LoginType; + + if (LoginType::tryFrom($value ?? '') === LoginType::LOGIN) { + // Do login stuff + } + if (LoginType::tryFrom($value ?? '') === LoginType::LOGOUT) { + // Do logout stuff + } + +.. index:: Backend, Authentication, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101133-IconFactorySignatureChange.rst b/Documentation/Changelog/13.0/Breaking-101133-IconFactorySignatureChange.rst new file mode 100644 index 0000000..33c659f --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101133-IconFactorySignatureChange.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101133-1687875354: + +=========================================================== +Breaking: #101133 - IconFactory->getIcon() signature change +=========================================================== + +See :issue:`101133` + +Description +=========== + +The public method :php:`getIcon()` in :php:`\TYPO3\CMS\Core\Imaging\IconFactory` +has changed its 4th parameter, in order to prepare the removal +of class :php:`\TYPO3\CMS\Core\Type\Icon\IconState`. + +Impact +====== + +Custom extensions extending the :php:`getIcon()` method of class +:php:`\TYPO3\CMS\Core\Imaging\IconFactory` not having the same signature +will fail with a PHP fatal error. + +Affected installations +====================== + +Custom extensions extending the :php:`getIcon()` method from class +:php:`\TYPO3\CMS\Core\Imaging\IconFactory`. + +Migration +========= + +Adapt the 4th parameter of :php:`getIcon()` to be of type +:php:`\TYPO3\CMS\Core\Type\Icon\IconState|IconState $state = null` + +In addition, adapt the code in the body of the method. + +.. index:: Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101133-IconStateChangedType.rst b/Documentation/Changelog/13.0/Breaking-101133-IconStateChangedType.rst new file mode 100644 index 0000000..6f31684 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101133-IconStateChangedType.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101133-1687875355: + +============================================ +Breaking: #101133 - Icon->state changed type +============================================ + +See :issue:`101133` + +Description +=========== + +The protected property :php:`\TYPO3\CMS\Core\Imaging\Icon->state` holds now a native +enum :php:`\TYPO3\CMS\Core\Imaging\IconState` instead of an instance of +:php:`\TYPO3\CMS\Core\Type\Icon\IconState`. + +Impact +====== + +Custom extensions calling :php:`\TYPO3\CMS\Core\Imaging\Icon->getState()` will +receive an enum now, which will most probably lead to PHP errors in the runtime. + +Custom extensions calling :php:`\TYPO3\CMS\Core\Imaging\Icon->setState()` with an +instance of :php:`\TYPO3\CMS\Core\Type\Icon\IconState` will receive a PHP +TypeError. + +Affected installations +====================== + +Custom extensions calling :php:`\TYPO3\CMS\Core\Imaging\Icon->getState()` or +:php:`\TYPO3\CMS\Core\Imaging\Icon->setState()`. + +Migration +========= + +Adapt your code to handle the native enum :php:`\TYPO3\CMS\Core\Imaging\IconState`. + +.. code-block:: php + + use TYPO3\CMS\Core\Imaging\Icon; + use TYPO3\CMS\Core\Type\Icon\IconState; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // Before + $icon = GeneralUtility::makeInstance(Icon::class); + $icon->setState(IconState::cast(IconState::STATE_DEFAULT)); + $state = $icon->getState(); + $stateValue = (string)$state; + + // After + $icon = GeneralUtility::makeInstance(Icon::class); + $icon->setState(IconState::STATE_DEFAULT); + + $state = $icon->getState(); + $stateValue = $state->value; + +.. index:: Backend, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101137-PageDoktypeRecyclerRemoved.rst b/Documentation/Changelog/13.0/Breaking-101137-PageDoktypeRecyclerRemoved.rst new file mode 100644 index 0000000..0da7031 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101137-PageDoktypeRecyclerRemoved.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101137-1688397315: + +=================================================== +Breaking: #101137 - Page Doktype "Recycler" removed +=================================================== + +See :issue:`101137` + +Description +=========== + +TYPO3 had multiple concepts of a recycler / trash bin. One of the oldest +concepts was the ability to create a manual page of the type "Recycler" +(page records with doktype=255 set) where editors could manually move content +to such a page instead of deleting it. One other option is to use the +:guilabel:`Web > Recycler` backend module (available with the shipped recycler +system extension). This process is much more user-friendly: Any kind of record +which has been (soft-)deleted can be viewed and re-added via this module, no +manual process during the deletion process is needed. + +For reasons of consistency and de-cluttering the UI, the former functionality +has been removed from TYPO3 Core, along with the PHP class +constant :php:`\TYPO3\CMS\Domain\Repository\PageRepository::DOKTYPE_RECYCLER`. + + +Impact +====== + +The recycler doktype has been removed and cannot be selected or used anymore. Any +existing recycler pages are migrated to a page of type "Backend User Section" +which is also not accessible, if there is no valid backend user with permission +to see this page. + + +Affected installations +====================== + +TYPO3 installations using this special page doktype "Recycler". + + +Migration +========= + +A migration is in place, it is recommended to use the :guilabel:`Recycler` +module with soft-deleting records. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101143-StrictTypingLinktypeInterface.rst b/Documentation/Changelog/13.0/Breaking-101143-StrictTypingLinktypeInterface.rst new file mode 100644 index 0000000..7a47352 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101143-StrictTypingLinktypeInterface.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-LinktypeInterface-1687413563: + +====================================================== +Breaking: #101143 - Strict typing in LinktypeInterface +====================================================== + +See :issue:`101143` + +Description +=========== + +All methods in the interface :php:`\TYPO3\CMS\Linkvalidator\Linktype\LinktypeInterface` +are now strictly typed. + +Impact +====== + +Classes implementing the interface must now ensure all methods are strictly typed. + +Affected installations +====================== + +Custom classes implementing :php:`\TYPO3\CMS\Linkvalidator\Linktype\LinktypeInterface` + +Migration +========= + +Ensure that classes that implement :php:`\TYPO3\CMS\Linkvalidator\Linktype\LinktypeInterface` +have the following signatures: + +.. code-block:: php + + public function checkLink(string $url, array $softRefEntry, LinkAnalyzer $reference): bool; + public function fetchType(array $value, string $type, string $key): string; + public function getErrorParams(): array; + public function getBrokenUrl(array $row): string; + public function getErrorMessage(array $errorParams): string; + +.. index:: Backend, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/13.0/Breaking-101149-MarkPageTsBackendLayoutDataProviderAsFinal.rst b/Documentation/Changelog/13.0/Breaking-101149-MarkPageTsBackendLayoutDataProviderAsFinal.rst new file mode 100644 index 0000000..6c35a37 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101149-MarkPageTsBackendLayoutDataProviderAsFinal.rst @@ -0,0 +1,35 @@ +.. include:: /Includes.rst.txt + +.. _breaking-PageTsBackendLayoutDataProvider-1687440947: + +================================================================= +Breaking: #101149 - Mark PageTsBackendLayoutDataProvider as final +================================================================= + +See :issue:`101149` + +Description +=========== + +The class :php:`\TYPO3\CMS\Backend\View\BackendLayout\PageTsBackendLayoutDataProvider` +is marked as final. + + +Impact +====== + +It is no longer possible to extend the class +:php:`\TYPO3\CMS\Backend\View\BackendLayout\PageTsBackendLayoutDataProvider`. + +Affected installations +====================== + +Classes extending :php:`\TYPO3\CMS\Backend\View\BackendLayout\PageTsBackendLayoutDataProvider`. + +Migration +========= + +Instead of extending the data provider, it is recommended to register a custom +DataProvider for backend layouts, which can already be used since TYPO3 v7. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-101175-ConvertVersionStateToNativeEnum.rst b/Documentation/Changelog/13.0/Breaking-101175-ConvertVersionStateToNativeEnum.rst new file mode 100644 index 0000000..6f2390f --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101175-ConvertVersionStateToNativeEnum.rst @@ -0,0 +1,69 @@ +.. include:: /Includes.rst.txt + +.. _breaking-VersionState-1687856333: + +============================================================== +Breaking: #101175 - Convert VersionState to native backed enum +============================================================== + +See :issue:`101175` + +Description +=========== + +The class :php:`\TYPO3\CMS\Core\Versioning\VersionState` is now +converted to a native PHP backed enum. + +Impact +====== + +Since :php:`\TYPO3\CMS\Core\Versioning\VersionState` is no longer +a class, the existing class constants are no longer available, but are +enum instances instead. + +In addition it's not possible to instantiate it anymore or call +the :php:`equals()` method. + +Affected installations +====================== + +TYPO3 code using the following code: + +Using the following class constants: + +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::DEFAULT_STATE` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::NEW_PLACEHOLDER` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::DELETE_PLACEHOLDER` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::MOVE_POINTER` + +Class instantiation: + +- :php:`new \TYPO3\CMS\Core\Versioning\(VersionState::*->value)` + +where * denotes one of the enum values. + +Method invocation: + +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::cast()` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::cast()->equals()` + +Migration +========= + +Use the new syntax for getting the values: + +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::DEFAULT_STATE->value` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::NEW_PLACEHOLDER->value` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::DELETE_PLACEHOLDER->value` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::MOVE_POINTER->value` + +Class instantiation should be replaced by: + +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::tryFrom($row['t3ver_state'])` + +Method invocation of :php:`cast()`/:php:`equals()` should be replaced by: + +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::tryFrom(...)` +- :php:`\TYPO3\CMS\Core\Versioning\VersionState::tryFrom(...) === VersionState::MOVE_POINTER` + +.. index:: Backend, NotScanned, ext:backend, ext:core, ext:frontend, ext:workspaces diff --git a/Documentation/Changelog/13.0/Breaking-101186-StrictTypingUnableToLinkException.rst b/Documentation/Changelog/13.0/Breaking-101186-StrictTypingUnableToLinkException.rst new file mode 100644 index 0000000..3d6210a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101186-StrictTypingUnableToLinkException.rst @@ -0,0 +1,42 @@ +.. include:: /Includes.rst.txt + +.. _breaking-UnableToLinkException-1687953808: + +========================================================== +Breaking: #101186 - Strict typing in UnableToLinkException +========================================================== + +See :issue:`101186` + +Description +=========== + +The class constructor in :php:`\TYPO3\CMS\Frontend\Exception\UnableToLinkException` +is now strictly typed. In addition, the variable :php:`$linkText` has type :php:`string`. + +Impact +====== + +The class constructor is now strictly typed. + +Affected installations +====================== + +TYPO3 sites using the :php:`\TYPO3\CMS\Frontend\Exception\UnableToLinkException` exception. + +Migration +========= + +Ensure that the class constructor is called properly, according to the changed signature: + +.. code-block:: php + + public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + string $linkText = '' + ); + + +.. index:: Backend, NotScanned, ext:fluid, ext:frontend, ext:redirects diff --git a/Documentation/Changelog/13.0/Breaking-101192-RemoveFallbackRemovePlugins.rst b/Documentation/Changelog/13.0/Breaking-101192-RemoveFallbackRemovePlugins.rst new file mode 100644 index 0000000..9b170e0 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101192-RemoveFallbackRemovePlugins.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101192-1688017013: + +============================================================== +Breaking: #101192 - Remove fallback for CKEditor removePlugins +============================================================== + +See :issue:`101192` + +Description +=========== + +Remove fallback for CKEditor configuration `removePlugins` as a string. + +Impact +====== + +Runtime Javascript errors can occur if the CKEditor configuration +`removePlugins` isn't an array. + +Affected installations +====================== + +TYPO3 installation which have CKEditor configuration `removePlugins` +configured as a string. + +Migration +========= + +Adjust your CKEditor configuration and pass :yaml:`removePlugins` as array. + + +Before +------ + +.. code-block:: yaml + + editor: + config: + removePlugins: image + +After +----- + +.. code-block:: yaml + + editor: + config: + removePlugins: + - image + +.. index:: Backend, NotScanned, RTE, ext:rte_ckeditor diff --git a/Documentation/Changelog/13.0/Breaking-101266-RemoveRequireJS.rst b/Documentation/Changelog/13.0/Breaking-101266-RemoveRequireJS.rst new file mode 100644 index 0000000..fe8fbbe --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101266-RemoveRequireJS.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101266-1688654482: + +==================================== +Breaking: #101266 - Remove RequireJS +==================================== + +See :issue:`101266` + +Description +=========== + +The RequireJS project has been `discontinued`_ and was therefore +deprecated in TYPO3 v12 with :issue:`96510` in favor of native ECMAScript +v6/v11 modules (added in :issue:`96510`). + +The infrastructure for configuration and loading of RequireJS +modules is now removed. + + +Impact +====== + +Registering FormEngine JavaScript modules via :php:`'requireJsModules'` will +have no effect. The PageRenderer endpoints +:php:`\TYPO3\CMS\Core\Page\PageRenderer->loadRequireJs()` and +:php:`\TYPO3\CMS\Core\Page\PageRenderer->loadRequireJsModule()` +have been removed and must no longer be called. +The respective :html:`includeRequireJsModules` property of the +:html:`<f:be.pageRenderer>` ViewHelper has also been removed. + + +Affected installations +====================== + +TYPO3 installations using RequireJS modules to provide JavaScript in the TYPO3 +backend, or – less common – use PageRenderer RequireJS infrastructure for +frontend JavaScript module loading. + + +Migration +========= + +Migrate your JavaScript from the AMD module format to native ES6 modules and +register your configuration in :php:`Configuration/JavaScriptModules.php`, +also see :issue:`96510` and :ref:`t3coreapi:backend-javascript-es6` +for more information: + +.. code-block:: php + + # Configuration/JavaScriptModules.php + <?php + + return [ + 'dependencies' => ['core', 'backend'], + 'imports' => [ + '@vendor/my-extension/' => 'EXT:my_extension/Resources/Public/JavaScript/', + ], + ]; + +Then use :php:`\TYPO3\CMS\Core\Page\PageRenderer->loadJavaScriptModule()` instead +of :php:`\TYPO3\CMS\Core\Page\PageRenderer->loadRequireJsModule()` to load the ES6 module: + +.. code-block:: php + + // via PageRenderer + $this->pageRenderer->loadJavaScriptModule('@vendor/my-extension/example.js'); + + +In Fluid templates `includeJavaScriptModules` is to be used instead of +`includeRequireJsModules`: + +In Fluid template the `includeJavaScriptModules` property of the +:html:`<f:be.pageRenderer>` ViewHelper may be used: + +.. code-block:: xml + + <f:be.pageRenderer + includeJavaScriptModules="{ + 0: '@vendor/my-extension/example.js' + }" + /> + +.. seealso:: + :ref:`t3coreapi:backend-javascript-es6` for more info about JavaScript in TYPO3 Backend. + +.. _discontinued: https://github.com/requirejs/requirejs/issues/1816 + +.. index:: Backend, JavaScript, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101281-IntroduceTypeDeclarationsInResourceInterface.rst b/Documentation/Changelog/13.0/Breaking-101281-IntroduceTypeDeclarationsInResourceInterface.rst new file mode 100644 index 0000000..8815d04 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101281-IntroduceTypeDeclarationsInResourceInterface.rst @@ -0,0 +1,90 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101281-1688708590: + +==================================================================== +Breaking: #101281 - Introduce type declarations in ResourceInterface +==================================================================== + +See :issue:`101281` + +Description +=========== + +The following methods of interface +:php:`\TYPO3\CMS\Core\Resource\ResourceInterface` have been given return +type declarations: + +.. code-block:: php + + public function getIdentifier(): string; + public function getName(): string; + public function getStorage(): ResourceStorage; + public function getHashedIdentifier(): string; + public function getParentFolder(): FolderInterface; + + +Impact +====== + +This affects many classes due to the following implementation +rules: + +- :php:`\TYPO3\CMS\Core\Resource\Folder`, because it implements + :php:`\TYPO3\CMS\Core\Resource\FolderInterface` which extends + :php:`\TYPO3\CMS\Core\Resource\ResourceInterface` + +- :php:`\TYPO3\CMS\Core\Resource\FileReference`, and + :php:`\TYPO3\CMS\Core\Resource\AbstractFile` because both implement + :php:`\TYPO3\CMS\Core\Resource\FileInterface` which extends + :php:`\TYPO3\CMS\Core\Resource\ResourceInterface` + +- :php:`\TYPO3\CMS\Core\Resource\File` and + :php:`\TYPO3\CMS\Core\Resource\ProcessedFile` + because both extend :php:`\TYPO3\CMS\Core\Resource\AbstractFile` + +In consequence, the following methods are affected: + +- :php:`\TYPO3\CMS\Core\Resource\Folder::getIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\Folder::getName()` +- :php:`\TYPO3\CMS\Core\Resource\Folder::getStorage()` +- :php:`\TYPO3\CMS\Core\Resource\Folder::getHashedIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\Folder::getParentFolder()` +- :php:`\TYPO3\CMS\Core\Resource\FileReference::getIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\FileReference::getName()` +- :php:`\TYPO3\CMS\Core\Resource\FileReference::getStorage()` +- :php:`\TYPO3\CMS\Core\Resource\FileReference::getHashedIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\FileReference::getParentFolder()` +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getName()` +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getStorage()` +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getHashedIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getParentFolder()` +- :php:`\TYPO3\CMS\Core\Resource\File::getIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\File::getName()` +- :php:`\TYPO3\CMS\Core\Resource\File::getStorage()` +- :php:`\TYPO3\CMS\Core\Resource\File::getHashedIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\File::getParentFolder()` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile::getIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile::getName()` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile::getStorage()` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile::getHashedIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile::getParentFolder()` + + +Affected installations +====================== + +Affected installations are those which either implement the :php:`ResourceInterface` +directly (very unlikely) or those that extend any of mentioned implementations +(Core classes). + +The usage (the API) of those implementation itself has not changed! + + +Migration +========= + +Use the same return type declarations as :php:`ResourceInterface` does. + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101291-IntroduceCapabilitiesBitSet.rst b/Documentation/Changelog/13.0/Breaking-101291-IntroduceCapabilitiesBitSet.rst new file mode 100644 index 0000000..76a8a11 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101291-IntroduceCapabilitiesBitSet.rst @@ -0,0 +1,89 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101291-1688740732: + +================================================== +Breaking: #101291 - Introduce capabilities bit set +================================================== + +See :issue:`101291` + +Description +=========== + +The capabilities property of the :php:`ResourceStorage` and drivers +(:php:`LocalDriver`/:php:`AbstractDriver`) have been converted from an integer +(holding a bit value) to an instance of a new :php:`BitSet` class +:php:`\TYPO3\CMS\Core\Resource\Capabilities`. + +This affects the public API of the following interface methods: + +- :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface::getCapabilities()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface::mergeConfigurationCapabilities()` + +In consequence, all mentioned methods of implementations are affected as well, +those of: + +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::getCapabilities()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\LocalDriver::mergeConfigurationCapabilities()` + +Also the following constants have been removed: + +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_BROWSABLE` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_PUBLIC` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_WRITABLE` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_HIERARCHICAL_IDENTIFIERS` + + +Impact +====== + +The return type of the following methods, respective their implementations have +changed from :php:`int` to :php:`\TYPO3\CMS\Core\Resource\Capabilities`: + +- :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface::getCapabilities()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface::mergeConfigurationCapabilities()` + +The type of the parameter :php:`$capabilities` of the method +:php:`mergeConfigurationCapabilities()` has been changed from :php:`int` to +:php:`\TYPO3\CMS\Core\Resource\Capabilities`. + +The usage of the mentioned, removed constants of +:php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface` will lead to errors. + + +Affected installations +====================== + +Installations that implement custom drivers and therefore directly implement +:php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface` or extend +:php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver`. + +Also, installations that use the removed constants of +:php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface`. + + +Migration +========= + +When using mentioned methods that formerly returned the bit value as integer or +expected the bit value as integer parameter need to use the :php:`Capabilities` +class instead. It behaves exactly the same as the plain integer. If the plain +integer value needs to be retrieved, :php:`__toInt()` can be called on +:php:`Capabilities` instances. + +The following removed constants + +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_BROWSABLE` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_PUBLIC` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_WRITABLE` +- :php:`\TYPO3\CMS\Core\Resource\ResourceStorageInterface::CAPABILITY_HIERARCHICAL_IDENTIFIERS` + +can be replaced with public constants of the new :php:`Capabilities` class: + +- :php:`\TYPO3\CMS\Core\Resource\Capabilities::CAPABILITY_BROWSABLE` +- :php:`\TYPO3\CMS\Core\Resource\Capabilities::CAPABILITY_PUBLIC` +- :php:`\TYPO3\CMS\Core\Resource\Capabilities::CAPABILITY_WRITABLE` +- :php:`\TYPO3\CMS\Core\Resource\Capabilities::CAPABILITY_HIERARCHICAL_IDENTIFIERS` + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101294-IntroduceTypeDeclarationsInFileInterface.rst b/Documentation/Changelog/13.0/Breaking-101294-IntroduceTypeDeclarationsInFileInterface.rst new file mode 100644 index 0000000..1443cf4 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101294-IntroduceTypeDeclarationsInFileInterface.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101294-1688885539: + +================================================================ +Breaking: #101294 - Introduce type declarations in FileInterface +================================================================ + +See :issue:`101294` + +Description +=========== + +Return and param type declarations have been introduced for all methods stubs +of :php:`\TYPO3\CMS\Core\Resource\FileInterface`. + + +Impact +====== + +In consequence, all implementations of :php:`\TYPO3\CMS\Core\Resource\FileInterface` need +to reflect those changes and add the same return and param type declarations. + +In case, any of the Core implementations are extended, overridden methods might need +to be adjusted. The Core classes, implementing :php:`\TYPO3\CMS\Core\Resource\FileInterface`, are: + +- :php:`\TYPO3\CMS\Core\Resource\AbstractFile` +- :php:`\TYPO3\CMS\Core\Resource\File` +- :php:`\TYPO3\CMS\Core\Resource\FileReference` +- :php:`\TYPO3\CMS\Core\Resource\ProcessedFile` + + +Affected installations +====================== + +Only those installations that implement :php:`\TYPO3\CMS\Core\Resource\FileInterface` directly +or that extend any of those mentioned core implementations. + + +Migration +========= + +Return and param type declarations have to be synced with the ones of the interface. + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101305-IntroduceTypeDeclarationsInGeneralUtilityMethods.rst b/Documentation/Changelog/13.0/Breaking-101305-IntroduceTypeDeclarationsInGeneralUtilityMethods.rst new file mode 100644 index 0000000..142b704 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101305-IntroduceTypeDeclarationsInGeneralUtilityMethods.rst @@ -0,0 +1,104 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101305-1689059968: + +================================================================================== +Breaking: #101305 - Introduce type declarations for some methods in GeneralUtility +================================================================================== + +See :issue:`101305`, :issue:`101453` + +Description +=========== + +Native return and param type declarations have been introduced for the following +methods of :php:`\TYPO3\CMS\Core\Utility\GeneralUtility`: + +- :php:`addInstance()` +- :php:`array2xml()` +- :php:`callUserFunction()` +- :php:`cmpFQDN()` +- :php:`cmpIP()` +- :php:`cmpIPv4()` +- :php:`cmpIPv6()` +- :php:`copyDirectory()` +- :php:`createDirectoryPath()` +- :php:`createVersionNumberedFilename()` +- :php:`explodeUrl2Array()` +- :php:`fixPermissions()` +- :php:`flushInternalRuntimeCaches()` +- :php:`getAllFilesAndFoldersInPath()` +- :php:`getBytesFromSizeMeasurement()` +- :php:`getClassName()` +- :php:`getFileAbsFileName()` +- :php:`getFilesInDir()` +- :php:`getIndpEnv()` +- :php:`getInstances()` +- :php:`getLogger()` +- :php:`getSingletonInstances()` +- :php:`getUrl()` +- :php:`get_dirs()` +- :php:`get_tag_attributes()` +- :php:`implodeArrayForUrl()` +- :php:`implodeAttributes()` +- :php:`intExplode()` +- :php:`isAllowedAbsPath()` +- :php:`isOnCurrentHost()` +- :php:`isValidUrl()` +- :php:`jsonEncodeForHtmlAttribute()` +- :php:`jsonEncodeForJavaScript()` +- :php:`locationHeaderUrl()` +- :php:`makeInstanceForDi()` +- :php:`mkdir_deep()` +- :php:`mkdir()` +- :php:`normalizeIPv6()` +- :php:`purgeInstances()` +- :php:`quoteJSvalue()` +- :php:`removePrefixPathFromList()` +- :php:`removeSingletonInstance()` +- :php:`resetSingletonInstances()` +- :php:`resolveBackPath()` +- :php:`revExplode()` +- :php:`rmdir()` +- :php:`sanitizeLocalUrl()` +- :php:`setIndpEnv()` +- :php:`setSingletonInstance()` +- :php:`split_tag_attributes()` +- :php:`tempnam()` +- :php:`trimExplode()` +- :php:`unlink_tempfile()` +- :php:`upload_copy_move()` +- :php:`upload_to_tempfile()` +- :php:`validEmail()` +- :php:`validIP()` +- :php:`validIPv4()` +- :php:`validIPv6()` +- :php:`validPathStr()` +- :php:`webserverUsesHttps()` +- :php:`wrapJS()` +- :php:`writeFileToTypo3tempDir()` +- :php:`writeFile()` +- :php:`writeJavaScriptContentToTemporaryFile()` +- :php:`writeStyleSheetContentToTemporaryFile()` +- :php:`xml2arrayProcess()` +- :php:`xml2array()` +- :php:`xml2tree()` +- :php:`xmlRecompileFromStructValArray()` + +Impact +====== + +Calling any of the mentioned methods with invalid types will result in a +PHP error. + +Affected installations +====================== + +Only those installations that use the mentioned methods with invalid types. + +Migration +========= + +Make sure to pass parameters of the required types to the mentioned methods. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101309-IntroduceTypeDeclarationsInDriverInterface.rst b/Documentation/Changelog/13.0/Breaking-101309-IntroduceTypeDeclarationsInDriverInterface.rst new file mode 100644 index 0000000..af7fb1a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101309-IntroduceTypeDeclarationsInDriverInterface.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101309-1689061837: + +================================================================== +Breaking: #101309 - Introduce type declarations in DriverInterface +================================================================== + +See :issue:`101309` + +Description +=========== + +Return and param type declarations have been introduced for all methods stubs +of :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface`. + +Also, method :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::sanitizeFileName()` +has been removed. + + +Impact +====== + +In consequence, all implementations of :php:`\TYPO3\CMS\Core\Resource\Driver\DriverInterface` need +to reflect those changes and add the same return and param type declarations. + +In case, any of the Core implementations are extended, overridden methods might need to be adjusted. +The Core classes, implementing :php:`\TYPO3\CMS\Core\Resource\DriverInterface`, are: + +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver` +- :php:`\TYPO3\CMS\Core\Resource\Driver\LocalDriver` + +Concerning removed method :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::sanitizeFileName()`: + +Said method didn't sanitize at all, it didn't respect the given :php:`$charset` param and simply +returned the input string. Abstract classes MAY fulfill the interface contract but if they do so, +they MUST do it right. There is no benefit in fulfilling it just signature wise, it MUST fulfill +it functional wise and in this case it didn't. That's why :php:`LocalDriver` +reimplements :php:`sanitizeFileName()` completely. + +As a consequence of this removal, all classes that extend either +:php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver` or +:php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver`, need to +implement method :php:`sanitizeFileName()`. + + +Affected installations +====================== + +All installations that implement :php:`\TYPO3\CMS\Core\Resource\DriverInterface` or that +extend either :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver` or +:php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver`. + + +Migration +========= + +As for the type declarations: +Add the same param and return type declarations the interface does. + +As for the removed method :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::sanitizeFileName()`: + +Implement the method according to your driver capabilities. + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101311-MakeParameterForGeneralUtilitySanitizeLocalUrlRequired.rst b/Documentation/Changelog/13.0/Breaking-101311-MakeParameterForGeneralUtilitySanitizeLocalUrlRequired.rst new file mode 100644 index 0000000..564bdcb --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101311-MakeParameterForGeneralUtilitySanitizeLocalUrlRequired.rst @@ -0,0 +1,36 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101311-1689067519: + +==================================================================================== +Breaking: #101311 - Make the parameter for GeneralUtility::sanitizeLocalUrl required +==================================================================================== + +See :issue:`101311` + +Description +=========== + +The (only) parameter for :php:`\TYPO3\CMS\Core\Utility\GeneralUtility::sanitizeLocalUrl()` +is now required. + +Impact +====== + +Calling :php:`GeneralUtility::sanitizeLocalUrl()` without an argument will result +in a PHP error. + +Affected installations +====================== + +Only those installations that call :php:`GeneralUtility::sanitizeLocalUrl()` +without an argument. + +The extension scanner will detect affected usages as a strong match. + +Migration +========= + +Make sure to pass an argument to :php:`GeneralUtility::sanitizeLocalUrl()`. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101327-HardenFileInterfacegetSize.rst b/Documentation/Changelog/13.0/Breaking-101327-HardenFileInterfacegetSize.rst new file mode 100644 index 0000000..bd912fc --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101327-HardenFileInterfacegetSize.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101327-1689092559: + +=================================================== +Breaking: #101327 - Harden FileInterface::getSize() +=================================================== + +See :issue:`101327` + +Description +=========== + +A return type declaration has been added to the method stub :php:`\TYPO3\CMS\Core\Resource\FileInterface::getSize()`. +As a consequence, implementations of said method, :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getSize()` +and :php:`\TYPO3\CMS\Core\Resource\FileReference::getSize()` received return type declarations as well. + +Also, :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getSize()` has been adjusted to actually just +return an integer. Previously, it returned :php:`null`, if the actual size could not be gathered. It now returns +:php:`0` in that case. + + +Impact +====== + +Code, that calls :php:`\TYPO3\CMS\Core\Resource\AbstractFile::getSize()` through derivatives like +:php:`\TYPO3\CMS\Core\Resource\File::getSize()` might be adjusted to not respect :php:`null` any more. + +Implementations (classes) that implement :php:`\TYPO3\CMS\Core\Resource\FileInterface`, have to +adjust the return type of the method :php:`getSize()` to match the contract. + + +Affected installations +====================== + +Installations that implement :php:`\TYPO3\CMS\Core\Resource\FileInterface` or that call +:php:`\TYPO3\CMS\Core\Resource\FileInterface::getSize()` via derivatives. + + +Migration +========= + +Adjust the return type and possible :php:`null` checks. + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101398-FetchAllFieldsRelationHandler.rst b/Documentation/Changelog/13.0/Breaking-101398-FetchAllFieldsRelationHandler.rst new file mode 100644 index 0000000..b8aa606 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101398-FetchAllFieldsRelationHandler.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101398-1689861816: + +====================================================================== +Breaking: #101398 - Remove leftover $fetchAllFields in RelationHandler +====================================================================== + +See :issue:`101398` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Database\RelationHandler` had an unused property +:php:`$fetchAllFields` since TYPO3 v11.5.0. The related method +:php:`setFetchAllFields()` has been removed with it. + + +Impact +====== + +Custom extensions calling :php:`\TYPO3\CMS\Core\Database\RelationHandler->setFetchAllFields()` +will result in a PHP Fatal error. + + +Affected installations +====================== + +All installations with custom extensions calling +:php:`\TYPO3\CMS\Core\Database\RelationHandler->setFetchAllFields()`. + + +Migration +========= + +Remove the affected line of code. This method has had no effect since +TYPO3 v11.5.0. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101469-IntroduceTypeDeclarationsInFolderInterface.rst b/Documentation/Changelog/13.0/Breaking-101469-IntroduceTypeDeclarationsInFolderInterface.rst new file mode 100644 index 0000000..9c8dc1a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101469-IntroduceTypeDeclarationsInFolderInterface.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101469-1690528614: + +================================================================== +Breaking: #101469 - Introduce type declarations in FolderInterface +================================================================== + +See :issue:`101469` + +Description +=========== + +Return and param type declarations have been introduced for all methods stubs +of :php:`\TYPO3\CMS\Core\Resource\FolderInterface`. + + +Impact +====== + +In consequence, all implementations of :php:`\TYPO3\CMS\Core\Resource\FolderInterface` +need to reflect those changes and add the same return and param type declarations. + +In case, any of the Core implementations are extended, overridden methods might need to +be adjusted. The Core classes, implementing :php:`\TYPO3\CMS\Core\Resource\FolderInterface` +are: + +- :php:`\TYPO3\CMS\Core\Resource\Folder` +- :php:`\TYPO3\CMS\Core\Resource\InaccessibleFolder` + + +Affected installations +====================== + +All installations that implement :php:`\TYPO3\CMS\Core\Resource\FolderInterface` +or that extend either :php:`\TYPO3\CMS\Core\Resource\Folder` or +:php:`\TYPO3\CMS\Core\Resource\InaccessibleFolder`. + + +Migration +========= + +Add the same param and return type declarations the interface does. + + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101471-IntroduceTypeDeclarationsInAbstractDriver.rst b/Documentation/Changelog/13.0/Breaking-101471-IntroduceTypeDeclarationsInAbstractDriver.rst new file mode 100644 index 0000000..bd82765 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101471-IntroduceTypeDeclarationsInAbstractDriver.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101471-1690531810: + +================================================================= +Breaking: #101471 - Introduce type declarations in AbstractDriver +================================================================= + +See :issue:`101471` + +Description +=========== + +Return and param type declarations have been introduced for all methods and method +stubs of :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver` and +:php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver` + + +Impact +====== + +In consequence, all classes, extending any of those abstract classes and overriding +any of those affected methods need to reflect those changes and add the same return +and param type declarations. + +Affected methods are: + +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::isValidFilename()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::getTemporaryPathForFile()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::canonicalizeAndCheckFilePath()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::canonicalizeAndCheckFileIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractDriver::canonicalizeAndCheckFolderIdentifier()` + +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver::isPathValid()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver::canonicalizeAndCheckFilePath()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver::canonicalizeAndCheckFileIdentifier()` +- :php:`\TYPO3\CMS\Core\Resource\Driver\AbstractHierarchicalFilesystemDriver::canonicalizeAndCheckFolderIdentifier()` + + +Affected installations +====================== + +Installations that extend any of those abstract classes might be affected. + + +Migration +========= + +Add the same param and return type declarations the interface does. + + +.. index:: FAL, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101519-RemoveImmediateFlagInDebounceEvent.rst b/Documentation/Changelog/13.0/Breaking-101519-RemoveImmediateFlagInDebounceEvent.rst new file mode 100644 index 0000000..7c22d6c --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101519-RemoveImmediateFlagInDebounceEvent.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101519-1690884375: + +============================================================== +Breaking: #101519 - Remove `immediate` flag in `DebounceEvent` +============================================================== + +See :issue:`101519` + +Description +=========== + +With the introduction in TYPO3 v10, the :js:`DebounceEvent` module had the +possibility to shift the event handler execution to the beginning of the +debounce sequence, enabled via the optional :js:`immediate` parameter. + +The parameter is unused in TYPO3 and using this feature has a negative impact +on :abbr:`UX (User Experience)`. If used, the event handler is directly executed +and the user has to wait a specific time after the last event was triggered, +before any further execution is possible. + +The flag :js:`immediate` has been therefore removed. + + +Impact +====== + +The :js:`DebounceEvent` module now always waits until a certain time has passed +after the last trigger of the event happened before executing the event handler. +This is mostly used in potential heavy tasks, for example, an Ajax request that +is sent depending on the content of a search field. + + +Affected installations +====================== + +All extensions using the removed flag are affected. + + +Migration +========= + +There is no direct migration possible. An extension author either may +re-implement the removed behavior manually, or use the :js:`ThrottleEvent` +module, providing a similar behavior. + +.. index:: JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101603-RemovedHookForOverridingIconOverlayIdentifier.rst b/Documentation/Changelog/13.0/Breaking-101603-RemovedHookForOverridingIconOverlayIdentifier.rst new file mode 100644 index 0000000..4729c89 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101603-RemovedHookForOverridingIconOverlayIdentifier.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101603-1691322822: + +======================================================================= +Breaking: #101603 - Removed hook for overriding icon overlay identifier +======================================================================= + +See :issue:`101603` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Core\Imaging\IconFactory']['overrideIconOverlay']` +has been removed in favor of a new PSR-14 event :php:`\TYPO3\CMS\Core\Imaging\Event\ModifyRecordOverlayIconIdentifierEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in TYPO3 v13.0+. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook. The extension +scanner will report usages as strong match. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. + +Replace any hook usage with the new +:doc:`PSR-14 event <../13.0/Feature-101603-PSR-14EventForModifyingRecordOverlayIconIdentifier>`. + +.. index:: Backend, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101612-LinkParameterProviderInterfaceChanged.rst b/Documentation/Changelog/13.0/Breaking-101612-LinkParameterProviderInterfaceChanged.rst new file mode 100644 index 0000000..bd71cce --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101612-LinkParameterProviderInterfaceChanged.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101612-1691447955: + +========================================================== +Breaking: #101612 - LinkParameterProviderInterface changed +========================================================== + +See :issue:`101612` + +Description +=========== + +The PHP interface :php:`\TYPO3\CMS\Backend\Tree\View\LinkParameterProviderInterface` +has changed. The interface is used to generate URLs with query parameters for links +within element browsers or link browsers in the TYPO3 backend. + +The methods :php:`getScriptUrl()` and :php:`isCurrentlySelectedItem()` have been removed +from the interface, as the implementing link browsers do not need this information anymore +due to simplification in routing. + +The method :php:`getUrlParameters()` now has a native return type :php:`array`, whereas +previously this was only type-hinted. + + +Impact +====== + +When accessing implementing PHP objects, it should be noted that these methods do not +exist anymore. When called this might result in fatal PHP errors. + +When implementing the PHP interface, the implementing code will fail due to missing return +types. + + +Affected installations +====================== + +TYPO3 installations with custom implementations of this interface. + + +Migration +========= + +For extensions implementing the interface, the return type for :php:`getUrlParameters()` +can be added in order to be TYPO3 v12+ compatible. For v13-only compatibility, +it is recommended to remove the superfluous methods. + +.. index:: PHP-API, PartiallyScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-101647-UnusedGraphicalAssetsRemovedFromEXTbackend.rst b/Documentation/Changelog/13.0/Breaking-101647-UnusedGraphicalAssetsRemovedFromEXTbackend.rst new file mode 100644 index 0000000..ae5ce28 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101647-UnusedGraphicalAssetsRemovedFromEXTbackend.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101647-1691669710: + +==================================================================== +Breaking: #101647 - Unused graphical assets removed from EXT:backend +==================================================================== + +See :issue:`101647` + +Description +=========== + +The TYPO3 system extension "backend" accumulated many graphical assets over the +years that became unused piece by piece. + +The following icons have been removed from the Icon Registry: + +* `status-edit-read-only` +* `warning-in-use` +* `warning-lock` + +The following assets have been removed from the directory :file:`EXT:backend/Resources/Public/Images/`: + +* :file:`FormFieldWizard/wizard_forms.gif` +* :file:`clear.gif` +* :file:`filetree-folder-default.png` +* :file:`filetree-folder-opened.png` +* :file:`Logo.png` +* :file:`pages.gif` +* :file:`tt_content.gif` + + +Impact +====== + +Calling any of the removed icons from the Icon Registry will render the default +icon. Accessing any of the removed files directly will lead to a 404 error. + + +Affected installations +====================== + +All extensions using the removed icons and assets are affected. + + +Migration +========= + +No direct migration is available. + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-101671-DisableExternalLinktypesByDefaultInEXTlinkvalidator.rst b/Documentation/Changelog/13.0/Breaking-101671-DisableExternalLinktypesByDefaultInEXTlinkvalidator.rst new file mode 100644 index 0000000..f8d4a69 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101671-DisableExternalLinktypesByDefaultInEXTlinkvalidator.rst @@ -0,0 +1,88 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101671-1691924837: + +============================================================================== +Breaking: #101671 - Disable external linktypes by default in EXT:linkvalidator +============================================================================== + +See :issue:`101671` + +Description +=========== + +There are several known problems with external link checking in Linkvalidator, +such as: + +* "False positives": Some links are reported broken, but are not broken, see + :issue:`101670`. +* External sites are checked without rate limit which may cause sites which + perform link checking to be blocked, see :issue:`89287`. +* No caching of results (except for a runtime cache during link checking which + will be invalid on next run) + +These issues are currently not easily solvable and should also be addressed +specifically for the site concerned. + +We now deactivate checking external link types by default in the configuration: +:ref:`ext_linkvalidator:linktypes`. +The "external" link types checking still works but must be enabled explicitly. + +This will make administrators more aware of problems and the specific problems +can be addressed, for example, by providing a custom class to replace +:php:`\TYPO3\CMS\Linkvalidator\Linktype\ExternalLinktype`. Additionally, a page +:ref:`Known Problems <ext_linkvalidator:known-problems>` was +already added to the documentation in a previous `patch +<https://review.typo3.org/c/Packages/TYPO3.CMS/+/80421>`__. + + +Impact +====== + +External links will no longer be checked by default in EXT:linkvalidator +unless :typoscript:`mod.linkvalidator.linktypes` is specifically set via page +TSconfig. + + +Affected installations +====================== + +Installations using EXT:linkvalidator. + + +Migration +========= + +Either leave external link checking deactivated or find ways to mitigate the +problems with external link checking. + +Solutions: + +* do not use external link checking +* or, create a custom linktype class to replace :php:`ExternalLinktype` + + * the custom link type should rate limit when checking external links, + for example, by adding a crawl delay in the link targets with the same domain + * the custom link type should find a way to handle possible false positives + * alternatively the external link type should restrict link checking to known + domains without problems + * alternatively, there should be a method to exclude specific URLs or domains + from link checking + * excessive checking of external links should be avoided, for example, by + using a link target cache + +More information is available in the Linkvalidator documentation: + +* :ref:`ext_linkvalidator:known-problems` +* :ref:`ext_linkvalidator:linktype-implementation` + +Example for activating external linktype +---------------------------------------- + +.. code-block:: typoscript + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + + mod.linkvalidator.linktypes = db,file,external + + +.. index:: Backend, NotScanned, ext:linkvalidator diff --git a/Documentation/Changelog/13.0/Breaking-101820-RemoveBootstrapJQueryInterfaceAndWindowJQuery.rst b/Documentation/Changelog/13.0/Breaking-101820-RemoveBootstrapJQueryInterfaceAndWindowJQuery.rst new file mode 100644 index 0000000..e974b27 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101820-RemoveBootstrapJQueryInterfaceAndWindowJQuery.rst @@ -0,0 +1,58 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101820: + +======================================================================= +Breaking: #101820 - Remove bootstrap jQuery interface and window.jQuery +======================================================================= + +See :issue:`101820` + +Description +=========== + +The bootstrap jQuery interfaces required a global :js:`window.jQuery` variable +to be set. The jquery drop-in is dropped in order to remove this non-optional +jQuery dependency. + +As a side effect the :js:`window.jQuery` global is removed as well. +Note that global jQuery usage has already been deprecated in :issue:`86438` and +removed in :issue:`97243` with the suggestion to use JavaScript modules instead. +:js:`window.jQuery` was basically left in place for bootstrap to operate and +therefore only :js:`window.$` was removed back then. + +Impact +====== + +Loading the ES6 'bootstrap' module no longer has side effects, as the global +scope :js:`window` is no longer polluted by writing to the property :js:`jQuery`. +This also means jQuery will no longer be loaded when it is not actually needed. + +Affected Installations +====================== + +All installations that use bootstrap's jQuery interface or applications that +use `window.jQuery` to invoke jQuery. + +Following method calls are affected: + +- :js:`$(…).alert()` +- :js:`$(…).button()` +- :js:`$(…).carousel()` +- :js:`$(…).collapse()` +- :js:`$(…).dropdown()` +- :js:`$(…).tab()` +- :js:`$(…).modal()` +- :js:`$(…).offcanvas()` +- :js:`$(…).popover()` +- :js:`$(…).scrollspy()` +- :js:`$(…).toast()` +- :js:`$(…).tooltip()` + + +Migration +========= + +Use bootstrap's ES6 exports :js:`import { Carousel } from 'bootstrap';` instead. + +.. index:: Backend, JavaScript, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101822-ChangeCallbackInterruptionInTypo3backenddocument-save-actions.rst b/Documentation/Changelog/13.0/Breaking-101822-ChangeCallbackInterruptionInTypo3backenddocument-save-actions.rst new file mode 100644 index 0000000..3709b78 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101822-ChangeCallbackInterruptionInTypo3backenddocument-save-actions.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101822-1693575438: + +========================================================================================== +Breaking: #101822 - Change callback interruption in `@typo3/backend/document-save-actions` +========================================================================================== + +See :issue:`101822` + +Description +=========== + +The JavaScript module :js:`@typo3/backend/document-save-actions` is used in +FormEngine and Scheduler context mainly to disable the submit button in the +according forms, where also a spinner is rendered within the button to visualize +a running action. + +Over the time, the module took over some tasks that logically belong to +FormEngine, which lead to slimming down the module. In a further effort, jQuery +has been removed from said module, leading to a change in behavior how the +callback chain can be aborted. + +Native JavaScript events cannot get asked whether event propagation has been +stopped, making changes in the callbacks necessary. All callbacks registered via +:js:`DocumentSaveActions.getInstance().addPreSubmitCallback()` now need to +return a boolean value. + +Impact +====== + +Using :js:`stop[Immediate]Propagation()` on events passed into registered +callbacks is now unsupported and may lead to undefined behavior. + + +Affected installations +====================== + +All extensions using :js:`DocumentSaveActions.getInstance().addPreSubmitCallback()` +are affected. + + +Migration +========= + +Callbacks now need to return a boolean value, where returning :js:`false` will +abort the callback execution chain. + +.. index:: Backend, JavaScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-101933-DispatchAfterUserLoggedInEventForFrontendUserLogin.rst b/Documentation/Changelog/13.0/Breaking-101933-DispatchAfterUserLoggedInEventForFrontendUserLogin.rst new file mode 100644 index 0000000..b6e7753 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101933-DispatchAfterUserLoggedInEventForFrontendUserLogin.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101933-1695472624: + +=========================================================================== +Breaking: #101933 - Dispatch AfterUserLoggedInEvent for frontend user login +=========================================================================== + +See :issue:`101933` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent` PSR-14 +event is now also dispatched for a successful frontend user login. + + +Impact +====== + +Listeners to the :php:`AfterUserLoggedInEvent` event should evaluate the +implementation type of the :php:`$user` property, if custom functionality +after a user login should be executed for backend login only. + + +Affected installations +====================== + +Installations with an event listener to the php:`AfterUserLoggedInEvent` PSR-14 +event. + + +Migration +========= + +If custom functionality in a listener to the :php:`AfterUserLoggedInEvent` +event should be executed for the backend user login only, a type check for the +:php:`$user` property must be added. + +.. code-block:: php + + // Before + public function __invoke(AfterUserLoggedInEvent $afterUserLoggedInEvent): void + { + // custom logic after backend user login + } + + // After + public function __invoke(AfterUserLoggedInEvent $afterUserLoggedInEvent): void + { + if ($afterUserLoggedInEvent->getUser() instanceof BackendUserAuthentication) { + // custom logic after backend user login + } + } + +.. index:: Backend, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-101941-VariousGFX-relatedLegacyOptionsRemoved.rst b/Documentation/Changelog/13.0/Breaking-101941-VariousGFX-relatedLegacyOptionsRemoved.rst new file mode 100644 index 0000000..bbe7657 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101941-VariousGFX-relatedLegacyOptionsRemoved.rst @@ -0,0 +1,61 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101941-1695060791: + +============================================================== +Breaking: #101941 - Various GFX-related legacy options removed +============================================================== + +See :issue:`101941` + +Description +=========== + +TYPO3's powerful image manipulation suite has legacy options which were used +20 years ago where it was more important to deliver GIF files instead of PNG +files due to the size of the file. + +However, PNG supports transparency and 24 bit, and is supported widely nowadays +and the preferred option. + +For this reason, TYPO3's default behavior is now to generate PNG files instead +of GIF files when creating thumbnails. + +In addition, the GIFBUILDER option "reduceColors" has been removed, along with +the option to additionally compress GIF files via ImageMagick or GDLib. + +The following PHP code has been removed: + +* :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->dontCompress` +* :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->IMreduceColors()` +* :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions::gifCompress()` + +The following global settings have no effect anymore and are automatically removed +if still in use: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['gif_compress']` (removed) +* :php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['thumbnails_png']` (always active) + + +Impact +====== + +When generating thumbnail images or images via GIFBUILDER from various sources which +aren't supported by TYPO3's graphical processing, a PNG is now created instead of a GIF. + +This can happen, for instance, when previewing PDF files. JPG files are still kept the same. + + +Affected installations +====================== + +TYPO3 installations which used these settings or customized GifBuilder code. + + +Migration +========= + +For 99% of the installations, these options have been activated already, so there is no change +necessary when upgrading and also no visual change. + +.. index:: FAL, Frontend, LocalConfiguration, PHP-API, TypoScript, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101948-FileBasedAbstractRepositoryClassRemoved.rst b/Documentation/Changelog/13.0/Breaking-101948-FileBasedAbstractRepositoryClassRemoved.rst new file mode 100644 index 0000000..4231c19 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101948-FileBasedAbstractRepositoryClassRemoved.rst @@ -0,0 +1,102 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101948-1695118827: + +=============================================================== +Breaking: #101948 - File-based AbstractRepository class removed +=============================================================== + +See :issue:`101948` + +Description +=========== + +When the base architecture of File Abstraction Layer (FAL) was introduced in +TYPO3 v6.0, various functionality was based on concepts based on Extbase's +architecture. Some concepts never flourished. One of them being the +:php:`\TYPO3\CMS\Core\Resource\AbstractRepository` class from FAL. + +This PHP class served as a basis for 2 PHP classes, +:php:`\TYPO3\CMS\Core\Resource\FileRepository` and +:php:`\TYPO3\CMS\Core\Resource\ProcessedFileRepository`. + +Nowadays, it is obvious that some decisions in this area were not useful: + +1. The coupling to Extbase's repository architecture does not work out, as the +manual database queries that return objects should not be bound to Extbase's +QueryRestrictions. + +These never worked and were never implemented in the mentioned repository +classes from FAL. + +It becomes abundantly clear that the concepts do not match by looking at the +:php:`AbstractRepository` class which even had exceptions for methods that were +not compatible with Extbase. + +2. The concept of inheritance did not work out for Dependency Injection +introduced in TYPO3 v10, and with PHP 8.x which reveals various typing problems +that arose around :php:`AbstractRepository`. + +:php:`AbstractRepository` is thus removed, and the implementing classes do not +extend from this class anymore, as they only include the methods required for +their purpose, and are now completely strictly typed. + +Additionally, :php:`FileRepository` has been cleaned up by removing +:php:`findFileReferenceByUid()` as it is only a wrapper to +:php:`ResourceFactory::getFileReferenceObject()` + + +Impact +====== + +Code that uses the three classes in a third-party extension might fail as the +implementing PHP repositories :php:`FileRepository` and +:php:`ProcessedFileRepository` have only necessary methods available. + +PHP extensions that derive from the :php:`AbstractRepository` will stop working. + +Code that used :php:`FileRepository::findFileReferenceByUid()` will break. + + +Affected installations +====================== + +As all three PHP classes are low-level in the FAL API, the impact for regular +installations will be rather low. Third-party extensions that extend from the +:php:`AbstractRepository` of FAL, which is a wild use-case will stop working. It is +safe to say, that only edge-case extensions that worked with the FAL API might +be affected, but regular installations will see no difference. + + +Migration +========= + +Only extension authors working with the low-level API of File Abstraction Layer +would need to adapt their code to be type-safe. Extensions that extend from the +:php:`AbstractRepository` class of FAL should implement the necessary methods +themselves and remove the dependency from :php:`AbstractRepository`. + +It is highly recommended to not use any of these classes, but rather stick +to high-level API of FAL, such as :php:`ResourceFactory`, :php:`File` +or :php:`ResourceStorage`. + +Replace former calls to :php:`FileRepository::findFileReferenceByUid()` like: + +.. code-block:: php + + $fileRepository = GeneralUtility::makeInstance(FileRepository::class); + $reference = $fileRepository->findFileReferenceByUid($referenceUid); + +by using the :php:`ResourceFactory` with new code like: + +.. code-block:: php + + $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); + $reference = $resourceFactory->getFileReferenceObject($referenceUid); + +.. note:: + + Ideally use dependency injection instead of :php:`GeneralUtility::makeInstance()` to + retrieve the instance for :php:`ResourceFactory`. + +.. index:: FAL, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101950-RemovedLegacySettingGFXprocessor_allowTemporaryMasksAsPng.rst b/Documentation/Changelog/13.0/Breaking-101950-RemovedLegacySettingGFXprocessor_allowTemporaryMasksAsPng.rst new file mode 100644 index 0000000..1477570 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101950-RemovedLegacySettingGFXprocessor_allowTemporaryMasksAsPng.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101950-1695121128: + +=================================================================================== +Breaking: #101950 - Removed legacy setting 'GFX/processor_allowTemporaryMasksAsPng' +=================================================================================== + +See :issue:`101950` + +Description +=========== + +`GFX/processor_allowTemporaryMasksAsPng` is a setting that stems from an even older +setting called `im_mask_temp_ext_gif`. This setting was added because generally PNG +generation of Image/GraphicsMagick is always faster than generating GIF files, but +there were issues with PNG files in earlier versions of ImageMagick 5. + +TYPO3 requires newer versions of GraphicsMagick and at least ImageMagick version 6, +in which the above reported behaviours couldn't be replicated anymore, obsoleting +the need for a non-PNG setting entirely. + +The following global settings have no effect anymore and are automatically removed +if still in use: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_allowTemporaryMasksAsPng']` (removed) + + +Impact +====== + +Temporarily saved masking images are now saved as PNG files rather than GIF images. + +Testing has revealed no visual changes between this setting being turned on or off, +with both ImageMagick or GraphicsMagick. + + +Affected installations +====================== + +Every instance that already didn't set `processor_allowTemporaryMasksAsPng` to true. + + +Migration +========= + +The configuration value has been removed without replacement. No migration is necessary. + +.. index:: LocalConfiguration, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-101955-RemovedPublicMethodsRelatedToImageGeneration.rst b/Documentation/Changelog/13.0/Breaking-101955-RemovedPublicMethodsRelatedToImageGeneration.rst new file mode 100644 index 0000000..02343d4 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-101955-RemovedPublicMethodsRelatedToImageGeneration.rst @@ -0,0 +1,118 @@ +.. include:: /Includes.rst.txt + +.. _breaking-101955-1695195288: + +====================================================================== +Breaking: #101955 - Removed public methods related to Image Generation +====================================================================== + +See :issue:`101955` + +Description +=========== + +For historical reasons, there is a PHP API class +:php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions` which deals with general +imaging functionality such as converting, scaling or cropping images - mainly +with ImageMagick / GraphicsMagick as a basis. In addition, the PHP class +:php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder` which works with instructions +originally built for use with TypoScript and image manipulation such as masking, +combining text with images based mainly on the PHP extension GDLib. + +Even though TYPO3 works best having both GDLib and ImageMagick installed and +properly configured, the inter-dependency within the TYPO3 Core API when to +use what class has always been unclear - mostly because this +functionality has not been cleaned up in the past 20 years. + +For this reason, :php:`GifBuilder` now contains all functionality related to +GDLib, and all related methods from GraphicalFunctions have been removed. +:php:`GraphicalFunctions` thus is only contains ImageMagick/GraphicsMagick +functionality. + +In addition, :php:`GifBuilder` and :php:`GraphicalFunctions` are now two separate classes +without inheritance, but utilizes the Composition pattern. + +The following public methods from :php:`GraphicalFunctions` have been removed: + +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->adjust()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->applyImageMagickToPHPGif()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->applyOffset()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->autolevels()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->convertColor()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->copyImageOntoImage()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->crop()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->destroy()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->getTemporaryImageWithText()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->hexColor()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->imageCreateFromFile()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->ImageTTFBBoxWrapper()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->ImageTTFTextWrapper()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->ImageWrite()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->inputLevels()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeBox()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeEffect()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeEllipse()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeEmboss()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeOutline()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeShadow()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->makeText()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->maskImageOntoImage()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->output()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->outputLevels()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->scale()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->splitString()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->unifyColors()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions::readPngGif()` + +The following public properties from :php:`GraphicalFunctions` have been removed: + +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->colMap` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->h` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->map` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->saveAlphaLayer` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->setup` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->truecolorColors` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->w` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->workArea` + +The following public properties in :php:`GifBuilder` have been removed: + +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->charRangeMap` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->myClassName` + +The following public properties in :php:`GifBuilder` are now marked as protected: + +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->charRangeMap` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->combinedFileNames` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->combinedTextStrings` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->data` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->defaultWorkArea` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->objBB` +- :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->XY` + +Impact +====== + +When using the classes directly in PHP code of extensions, calling any of the +methods or accessing / setting the affected properties will result in a PHP +error. + + +Affected installations +====================== + +TYPO3 installations with custom extensions utilizing the PHP API of these two +classes directly. + +For any usages of these classes via TypoScript or the File Abstraction Layer API +will continue to work and are not affected by this breaking change. + + +Migration +========= + +Use static analysis tools such as PHPStan or Psalm to detect if PHP code of +custom extensions is affected, and make use of :php:`GifBuilder` class instead of +:php:`GraphicalFunctions` when needing GDLib functionality. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102009-ImagesizesCacheRemoved.rst b/Documentation/Changelog/13.0/Breaking-102009-ImagesizesCacheRemoved.rst new file mode 100644 index 0000000..33d8de6 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102009-ImagesizesCacheRemoved.rst @@ -0,0 +1,63 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102009-1695376655: + +============================================ +Breaking: #102009 - imagesizes cache removed +============================================ + +See :issue:`102009` + +Description +=========== + +A cache layer called "imagesizes" was added in 2004 (= TYPO3 3.x) to cache +width and height of any kind of images - mostly generated by GifBuilder or +ImageMagick. It was using the PHP function :php:`getimagesizes()` or, if this +failed, ImageMagick identify command, which is costly. + +In 2012, the new processing layer for File Abstraction Layer (FAL - via +:sql:`sys_file_processedfile`) was introduced with a more modern API, which persists +final information about processed images in a separate database table. + +Any kind of information processing then is first checked in FAL and then stored +again in `cache_imagesizes`. Some more files, which do not use FAL still utilize +this functionality, but the second level cache layer (by default in the database), +is unneeded nowadays. + +For this reason, the lowlevel cache "imagesizes" is removed along with some +methods which were not marked as internal, but marked public: + +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->cacheImageDimensions()` +- :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->getCachedImageDimensions()` + +The main entry point :php:`\TYPO3\CMS\Core\Imaging\GraphicalFunctions->getImageDimensions()` +is still available but does not use a cache layer anymore. + + +Impact +====== + +Calling the removed public methods will result in a fatal PHP error. In addition, +accessing the database table directly or via TYPO3's CacheManager is not possible +anymore, as the "imagesizes" cache is removed. + + +Affected installations +====================== + +TYPO3 installations which use the cache directly or access these methods +directly, which is very very unlikely. + + +Migration +========= + +The now unused database tables are automatically removed when using the database +compare update is called. When trying to retrieve image dimensions, +the :php:`\TYPO3\CMS\Core\Type\File\Imageinfo` PHP class should be used instead in +favor of the main :php:`GraphicalFunctions` method. + +The removed methods should be moved also towards the :php:`Imageinfo` PHP class. + +.. index:: Database, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102020-RemovedLegacySettingGFXgdlib_png.rst b/Documentation/Changelog/13.0/Breaking-102020-RemovedLegacySettingGFXgdlib_png.rst new file mode 100644 index 0000000..2520e81 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102020-RemovedLegacySettingGFXgdlib_png.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102020-1695429353: + +========================================================== +Breaking: #102020 - Removed legacy setting 'GFX/gdlib_png' +========================================================== + +See :issue:`102020` + +Description +=========== + +`GFX/gdlib_png` is a setting that adjusted rendering of temporary images +used by GDLib to be PNG files instead of GIF files. + +PNG files offer many benefits over GIF files, one of them being faster +processing times using Image/GraphicsMagick. + +In line with this change, the property :php:`GraphicalFunctions::$gifExtension` has +been removed, as it mainly was used by this class and :php:`GifBuilder` to determine +if a temporary PNG or GIF image should be rendered. + +`GFX/processor_colorspace` now defaults to an empty value and is migrated to one if +you use the recommended colorspace for the given processor (`sRGB` for ImageMagick, +`RGB` for GraphicsMagick). Image processing now will pick the recommended colorspace +unless you configure it to be another one. + +Additionally, all GIF assets that are now not shown anymore due to those changes have been +removed as well: + +* `EXT:core/Resources/Public/Images/NotFound.gif` +* `EXT:install/Resources/Public/Images/TestReference/Gdlib-*.gif` + +Impact +====== + +Temporary layers/masks are now saved as PNG files instead of GIF files. + + +Affected installations +====================== + +Every instance that already didn't set `gdlib_png` to true. Output differences may +only occur on instances that use :typoscript:`GIFBUILDER` functionality (see Migration +section for more information). + + +Migration +========= + +The configuration value has been removed without replacement. `GFX/processor_colorspace` is +automatically migrated to the recommended value for setups using the default configuration. + +:php:`GraphicalFunctions::$gifExtension` has been removed without replacement. If this has been +used to determine what type of file should be rendered using :php:`GraphicalFunctions::imageMagickConvert`, +please specify the filetype manually now. + + +.. index:: LocalConfiguration, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102023-RemoveSecurityusePasswordPolicyForFrontendUsers.rst b/Documentation/Changelog/13.0/Breaking-102023-RemoveSecurityusePasswordPolicyForFrontendUsers.rst new file mode 100644 index 0000000..cfe2381 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102023-RemoveSecurityusePasswordPolicyForFrontendUsers.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102023-1695557477: + +===================================================================== +Breaking: #102023 - Remove security.usePasswordPolicyForFrontendUsers +===================================================================== + +See :issue:`102023` + +Description +=========== + +The feature toggle :php:`security.usePasswordPolicyForFrontendUsers` has been +removed, because TypoScript-based password validation in ext:felogin has been +removed, too. + + +Impact +====== + +The password policy configured in +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy']` is now always active +for frontend user records in DataHandler and for the password recovery +functionality in ext:felogin. + + +Affected installations +====================== + +Installations, where :php:`security.usePasswordPolicyForFrontendUsers` is +deactivated. + + +Migration +========= + +To disable the password policy for frontend users, +:php:`$GLOBALS['TYPO3_CONF_VARS']['FE']['passwordPolicy']` must be set to an +empty string. Note, that it is not recommended to disable the password policy +on production websites. + +.. index:: Backend, Frontend, NotScanned, ext:felogin diff --git a/Documentation/Changelog/13.0/Breaking-102108-TCATypesbitmask_Settings.rst b/Documentation/Changelog/13.0/Breaking-102108-TCATypesbitmask_Settings.rst new file mode 100644 index 0000000..8ee61b2 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102108-TCATypesbitmask_Settings.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102108-1696618684: + +===================================================== +Breaking: #102108 - TCA `[types][bitmask_*]` settings +===================================================== + +See :issue:`102108` + +Description +=========== + +Handling of two settings has been removed from the +TYPO3 Core codebase: + +* :php:`$GLOBALS['TCA']['someTable']['types']['bitmask_excludelist_bits']` +* :php:`$GLOBALS['TCA']['someTable']['types']['bitmask_value_field']` + + +Impact +====== + +These two fields allowed to set record "sub types" based on a record +bitmask field, typically :php:`'type' => 'check'` or :php:`'type' => 'radio'`. + +This has been removed, the settings are not considered anymore when +rendering records in the backend record editing interface. + + +Affected installations +====================== + +Both settings have been used very rarely: Neither Core nor published TER extensions +revealed a single usage. The extension scanner will find affected extensions. + + +Migration +========= + +In case extensions still use these two rather obscure settings, they should +switch to casual :php:`$GLOBALS['TCA']['someTable']['ctrl']['type']` fields instead, +which can be powered by columns based on string values. + +Note the overall "subtype" record logic of TCA is within an ongoing process to +be removed in TYPO3 v13, so the basic thinking should be: There is a record, and its +details can be configured using :php:`$GLOBALS['TCA']['someTable']['ctrl']['type']`, +and that's it. Extensions using "sub types" or this bitmask detail need to simplify +and eventually deliver according upgrade wizards to adapt existing records. + + +.. index:: TCA, FullyScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-102113-RemovedLegacySettingGFXgdlib.rst b/Documentation/Changelog/13.0/Breaking-102113-RemovedLegacySettingGFXgdlib.rst new file mode 100644 index 0000000..afe1f3f --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102113-RemovedLegacySettingGFXgdlib.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102113-1696697947: + +====================================================== +Breaking: #102113 - Removed legacy setting 'GFX/gdlib' +====================================================== + +See :issue:`102113` + +Description +=========== + +'GFX/gdlib' is a setting that enables or disables image manipulation +using GDLib, functionality used in :typoscript:`GIFBUILDER`, depending if +the host system did not provide GDLib functionality. + +With this change, the configuration value 'GFX/gdlib' has been removed, and +TYPO3 will simply check for the :php:`GdImage` PHP class being available to +determine if it can be used. + +Impact +====== + +TYPO3 now always enables GDLib functionality as soon as relevant GDLib classes +are found. + + +Migration +========= + +The configuration value has been removed without replacement. + +Custom code that relied on :php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['gdlib']` +should instead adopt the simpler check +:php:`if (class_exists(\GdImage::class))`. + +.. index:: LocalConfiguration, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102146-RemovedLegacySettingBEflexformForceCDATA.rst b/Documentation/Changelog/13.0/Breaking-102146-RemovedLegacySettingBEflexformForceCDATA.rst new file mode 100644 index 0000000..8fbce27 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102146-RemovedLegacySettingBEflexformForceCDATA.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102146-1697045119: + +================================================================== +Breaking: #102146 - Removed legacy setting 'BE/flexformForceCDATA' +================================================================== + +See :issue:`102146` + +Description +=========== + +The TYPO3 configuration option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['flexformForceCDATA']` +has been removed without substitution. + +This setting was an ancient workaround for an issue in libxml in old PHP versions that has +been resolved long ago. + +This was the last usage of :php:`useCDATA` option in FlexForm-related XML methods in +the Core, so that option is removed along the way. Values of XML data should still be +encoded properly when dealing with related methods like :php:`GeneralUtility::array2xml()`. + + +Impact +====== + +There should be no impact on casual instances, except if single extensions tamper with +the :php:`useCDATA` options when dealing with XML data. + + +Affected installations +====================== + +Instances with extensions that explicitly call XML-related transformations methods +provided by the Core that tamper with :php:`useCDATA` may need a look. Chances are +everything is ok, though. + + +Migration +========= + +No direct migration possible. + +.. index:: LocalConfiguration, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102151-XMLPrologueAlwaysAddedInFlexArray2Xml.rst b/Documentation/Changelog/13.0/Breaking-102151-XMLPrologueAlwaysAddedInFlexArray2Xml.rst new file mode 100644 index 0000000..48e7c2a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102151-XMLPrologueAlwaysAddedInFlexArray2Xml.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102151-1697111006: + +================================================================ +Breaking: #102151 - XML prologue always added in flexArray2Xml() +================================================================ + +See :issue:`102151` + +Description +=========== + +The second argument :php:`$addPrologue = false` on +:php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools->flexArray2Xml()` +has been dropped: When imploding a FlexForm array to an XML string using +this method, the "XML prologue" is always added. + + +Impact +====== + +This should have no impact for consumers of this method. The counterpart method +:php:`\TYPO3\CMS\Core\Utility\GeneralUtility::xml2array()` happily deals with this. + + +Affected installations +====================== + +Instances with extensions using :php:`FlexFormTools->flexArray2Xml()` can drop +the second argument. The extension scanner will find usages with a weak match. + +Since this is a detail method of the TYPO3 Core FlexForm handling, not often +handled by extensions themselves, few instances will be affected in the first place. + + +Migration +========= + +No data migration needed, PHP consumers should drop the second argument +when calling the method. + +.. index:: FlexForm, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102165-FileAbstractionLayerProcessingAPIsAndInterfaceChanged.rst b/Documentation/Changelog/13.0/Breaking-102165-FileAbstractionLayerProcessingAPIsAndInterfaceChanged.rst new file mode 100644 index 0000000..b573056 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102165-FileAbstractionLayerProcessingAPIsAndInterfaceChanged.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102165-1698700428: + +================================================================================= +Breaking: #102165 - File Abstraction Layer: Processing APIs and interface changed +================================================================================= + +See :issue:`102165` + +Description +=========== + +The Task API for processing files (mainly images) in the File Abstraction Layer +(FAL) has been reworked. This mainly accommodates to the fact, that the API was +revisited, the functionality has been updated to be up-to-date with PHP +standards and further adaptions. + +The PHP interface :php:`\TYPO3\CMS\Core\Resource\Processing\TaskInterface` has +lost the :php:`__construct()` method as part of the interface, as the +constructor is an implementation detail and should not be part of an interface +definition. In addition, the method :php:`sanitizeConfiguration()` has been added +to clean and sort the properties required for a task. All other methods have +been fully typed. + +The PHP class :php:`\TYPO3\CMS\Core\Resource\Processing\AbstractGraphicalTask` +has been removed in order to reduce complexity, as all of the methods have +been moved into the respective subclasses. + +The PHP class :php:`\TYPO3\CMS\Core\Resource\Processing\Task` now has two +abstract methods :php:`getName()` and :php:`getType()` in favor of the protected +properties :php:`$name` and :php:`$type`. + +The PHP class :php:`\TYPO3\CMS\Core\Resource\ProcessedFile` is now fully typed. + + +Impact +====== + +Custom FAL processing tasks will result in a fatal error if not adapted to the +new interface. + +If an extension was depending on :php:`AbstractGraphicalTask`, calling this +code will now result in a PHP fatal error. + + +Affected installations +====================== + +TYPO3 installations working with the internals of the processing part of the +File Abstraction Layer, e.g. when extensions add custom FAL processors or +custom tasks. + + +Migration +========= + +Implementing a custom FAL processing task will require the extension author to +adapt to the new interface requirements. + +When a custom task was built on top of the :php:`AbstractGraphicalTask`, this +now needs to be removed and be compliant with the :php:`TaskInterface`, optionally +inheriting from the :php:`AbstractTask` class. This can already be achieved for +TYPO3 v12 to make an implementation compatible with TYPO3 v12 and TYPO3 v13. + +.. index:: FAL, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102181-RemovedCLIOptionsUsingBintypo3Cleanupflexforms.rst b/Documentation/Changelog/13.0/Breaking-102181-RemovedCLIOptionsUsingBintypo3Cleanupflexforms.rst new file mode 100644 index 0000000..37565e7 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102181-RemovedCLIOptionsUsingBintypo3Cleanupflexforms.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102181-1697467272: + +=========================================================================== +Breaking: #102181 - Removed CLI options using `bin/typo3 cleanup:flexforms` +=========================================================================== + +See :issue:`102181` + +Description +=========== + +The CLI command :bash:`bin/typo3 cleanup:flexforms` of extension :php:`lowlevel` +can be used to clean up database record :php:`type="flex"` fields that contain values +not reflected in the current FlexForm data structure anymore. + +The command has been changed slightly: The CLI options :bash:`-p` / :bash:`--pid` +and :bash:`-d` / :bash:`--depth` have been removed. + +The "dry run" CLI option :bash:`--dry-run` is kept. + +The command implementation has been rewritten in TYPO3 v13 and is some orders +of magnitudes quicker than before: While the command could easily run hours for +a seasoned instance, it is now usually a matter of seconds. The "pid" and +"depth" options were a hindrance to this drastic performance improvement and +have been removed. + + +Impact +====== + +The command exits with an error when called with one of :bash:`-p`, :bash:`--pid`, +:bash:`-d` or :bash:`--depth` option. It is no longer possible to restrict the +command to single page tree sections, the command always checks all (not soft-deleted) +records. + + +Affected installations +====================== + +The command is not very well known and - if ever - often only used when deploying +major upgrades of TYPO3 instances. Instances using one of the above options should +remove them from their deployment scripts, and enjoy the massive speed improvement. + + +Migration +========= + +No migration, remove the above mentioned options. + +.. index:: CLI, FlexForm, NotScanned, ext:lowlevel diff --git a/Documentation/Changelog/13.0/Breaking-102224-TemplaVoilaRelatedFlexFormDataStructureLookups.rst b/Documentation/Changelog/13.0/Breaking-102224-TemplaVoilaRelatedFlexFormDataStructureLookups.rst new file mode 100644 index 0000000..120ef6f --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102224-TemplaVoilaRelatedFlexFormDataStructureLookups.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102224-1697983588: + +====================================================================== +Breaking: #102224 - TemplaVoila related FlexForm dataStructure lookups +====================================================================== + +See :issue:`102224` + +Description +=========== + +The following TCA config options for :php:`'type' = 'flex'` column fields are +not handled anymore: + +* :php:`['config']['ds_pointerField_searchParent']` +* :php:`['config']['ds_pointerField_searchParent_subField']` +* :php:`['config']['ds_tableField']` + +The following related exception classes have been removed and are no longer thrown: + +* :php:`\TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowException` +* :php:`\TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowLoopException` +* :php:`\TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowRootException` +* :php:`\TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidPointerFieldValueException` + + +Impact +====== + +When dealing with TCA type :php:`flex` fields, there needs to be a "data structure" that +defines which fields are rendered when editing the record. The default is looking up +the data structure using the :php:`['ds']['default']` value. + +Multiple different data structures can be defined, so there is a strategy to find the +data structure relevant for current record. For table :sql:`tt_content`, this is +defined using :php:`ds_pointerField`, which determines the specific data structure based +on the combination of the fields :sql:`CType` and :sql:`list_type`. + +There have been more sophisticated lookup mechanisms based on the TCA config options +:php:`ds_pointerField_searchParent`, :php:`ds_pointerField_searchParent_subField` +and :php:`ds_tableField`. Those lookup mechanisms have been removed with TYPO3 v13. + + +Affected installations +====================== + +Instances with extensions having :php:`flex` fields using one of the TCA options +:php:`ds_pointerField_searchParent`, :php:`ds_pointerField_searchParent_subField` +or :php:`ds_tableField` will fail to retrieve their data structure. Most likely, +an exception will be thrown when editing such records. + +Those three fields have been implemented long ago for heavily flex form driven +instances based on "TemplaVoila" (TV). This detail never found broader acceptance in +not-TV driven instances. + +Instances not driven by one of the TemplaVoila forks are most likely not affected +by this change. Instances actively using TemplaVoila forks may be affected, but +those forks seem to implement the data structure lookup on their own already, +affected instances should wait for their templavoila maintainers to catch up. + + +Migration +========= + +There are appropriate events that allow manipulating the data structure +lookup logic in class :php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools`. +Those can be used to re-implement the logic that has been removed from TYPO3 +Core if needed. + + +.. index:: FlexForm, PHP-API, TCA, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102229-RemovedFlexFormTools-traverseFlexFormXMLData.rst b/Documentation/Changelog/13.0/Breaking-102229-RemovedFlexFormTools-traverseFlexFormXMLData.rst new file mode 100644 index 0000000..a3d8db6 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102229-RemovedFlexFormTools-traverseFlexFormXMLData.rst @@ -0,0 +1,80 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102229-1698053674: + +==================================================================== +Breaking: #102229 - Removed FlexFormTools->traverseFlexFormXMLData() +==================================================================== + +See :issue:`102229` + +Description +=========== + +Class :php:`\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools` got a series +of cleanup and removal patches. + +The following public class properties have been removed: + +* :php:`$reNumberIndexesOfSectionData` +* :php:`$flexArray2Xml_options` +* :php:`$callBackObj` +* :php:`$cleanFlexFormXML` + +The following public methods have been removed: + +* :php:`traverseFlexFormXMLData()` +* :php:`traverseFlexFormXMLData_recurse()` +* :php:`cleanFlexFormXML_callBackFunction()` + +The following public methods have been marked `@internal`: + +* :php:`cleanFlexFormXML()` +* :php:`flexArray2Xml()` +* :php:`migrateFlexFormTcaRecursive()` + +The class is now a stateless service and can be injected as shared service +without any risk of triggering side effects. + + +Impact +====== + +In general, these changes should have relatively low impact on extensions, if they +don't build additional low level functionality on top of the general TYPO3 Core +FlexForm related features. Extensions like the TemplaVoila forks may need to have +a look for required adaptions, though. + +Using the removed methods or properties in TYPO3 v13 will of course trigger PHP +fatal errors. + + +Affected installations +====================== + +Instances that extend functionality of FlexForm handling may be affected if they +use methods of class :php:`FlexFormTools`. This is a relatively rare case, most +instances will not be affected when they just provide and use casual FlexForm +definitions in extensions. + +The extension scanner will find possible extensions that consume the methods or +properties as a weak match. + + +Migration +========= + +If at all, method :php:`traverseFlexFormXMLData()` is probably the one used in +extensions. The easiest way is to copy the method and it's recursive worker method +to an own class. + +Extension developers are however encouraged to refactor their code since +:php:`traverseFlexFormXMLData()` with its callback logic was ugly, hard to follow +and maintain. The Core switched away from the method by implementing own traversers +that match the specific use cases. Method :php:`cleanFlexFormXML()` is an +example of such an implementation. Note FlexForms are *not* recursive since +section containers can not be nested since TYPO3 v8 anymore. The Core thus +uses some nested foreach loops instead of a recursive approach. + + +.. index:: FlexForm, PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102260-RemovedTCASoftrefNotify.rst b/Documentation/Changelog/13.0/Breaking-102260-RemovedTCASoftrefNotify.rst new file mode 100644 index 0000000..47f6791 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102260-RemovedTCASoftrefNotify.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102260-1698252706: + +====================================================== +Breaking: #102260 - Removed TCA ['softref'] = 'notify' +====================================================== + +See :issue:`102260` + +Description +=========== + +:php:`TCA` columns type fields like :php:`input` and :php:`text` obey the :php:`config` key +:php:`softref`. One of the allowed soft reference parsers is :php:`notify` implemented +by class :php:`\TYPO3\CMS\Core\DataHandling\SoftReference\NotifySoftReferenceParser`. + +This soft reference parser fits no apparent use case and has been removed. + + +Impact +====== + +Involving the :php:`notify` key in the comma-separated list of TCA columns config +:php:`softref` or a flex form data structure column definition does not trigger +any action anymore and may log a warning this parser hasn't been found. + + +Affected installations +====================== + +There was little reason to activate this soft reference parser in the first place +since it essentially did nothing. Instances with extensions having TCA column config +:php:`softref` set to a value including :php:`notify` will be affected. That's a very +rare use case. The extension scanner will not notify about this, but the +:php:`SoftReferenceParserFactory` will add a log entry this parser was not found upon +using an affected record. + + +Migration +========= + +Remove key :php:`notify` from TCA columns :php:`softref` list. + + +.. index:: TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102440-EXTt3editorMergedIntoEXTbackend.rst b/Documentation/Changelog/13.0/Breaking-102440-EXTt3editorMergedIntoEXTbackend.rst new file mode 100644 index 0000000..cddbe22 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102440-EXTt3editorMergedIntoEXTbackend.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102440-1700638677: + +======================================================== +Breaking: #102440 - EXT:t3editor merged into EXT:backend +======================================================== + +See :issue:`102440` + +Description +=========== + +TYPO3 comes with a code editor extension called "t3editor" for a long time. +Since then, the extension was always optional. When the extension is installed, +selected text areas are converted to code editors based on CodeMirror. + +The optional extension has been merged into `EXT:backend`, making the code +editor always available. + + +Impact +====== + +An integrator cannot optionally install the code editor anymore as it's part of +the mandatory "backend" extension now. + +By default, this affects the following occurrences: + +* TCA: `be_groups.TSconfig` +* TCA: `be_users.TSconfig` +* TCA: `pages.TSconfig` +* TCA: `sys_template.constants` +* TCA: `sys_template.config` +* TCA: `tt_content.bodytext`, if the content element is of type "HTML" +* EXT:filelist: edit file content +* Composer status view in Extension Manager + +Also, checks whether the extension is installed via +:php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded('t3editor')` +are now obsolete. + + +Affected installations +====================== + +All installations are affected. + + +Migration +========= + +Extension checks using :php:`\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::isLoaded('t3editor')` +don't have an effect anymore and must get removed. + +In addition, please see :ref:`deprecation-102440-1700638677`. + + +.. index:: Backend, JavaScript, PHP-API, NotScanned, ext:t3editor diff --git a/Documentation/Changelog/13.0/Breaking-102499-UserTSconfigSettingOverridePageModuleRemoved.rst b/Documentation/Changelog/13.0/Breaking-102499-UserTSconfigSettingOverridePageModuleRemoved.rst new file mode 100644 index 0000000..39f5e57 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102499-UserTSconfigSettingOverridePageModuleRemoved.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102499-1700813634: + +====================================================================== +Breaking: #102499 - User TSconfig setting "overridePageModule" removed +====================================================================== + +See :issue:`102499` + +Description +=========== + +The user TSconfig setting :typoscript:`options.overridePageModule` has been +removed. + +This option allowed to change links within some modules to be redirected to an +alternative page module, mainly introduced in TYPO3 4.x to allow to link to +the TemplaVoila page module. + +However, as this has never been applied consistently across all modules +provided by TYPO3 Core, it has been removed. The only few places within TYPO3 +Core where this option was still evaluated was within the Workspaces +Administration and the Info module. + +The alternative, using a different routing endpoint and support for module +aliases via the introduced Module API in TYPO3 v12, is much more robust and +consistent. + + +Impact +====== + +Setting the user TSconfig option :typoscript:`options.overridePageModule` has +no effect anymore. + + +Affected installations +====================== + +TYPO3 installations using this setting in user TSconfig, mainly when used in +conjunction with TemplaVoila and having mixed installations where both +TemplaVoila page module and the default Page module are used for different +editors. + + +Migration +========= + +In order to replace the Page module within a third-party extension such as +TemplaVoila, it is possible to create a custom module entry in an +extensions' :file:`Configuration/Backend/Modules.php` with the following entry: + +.. code-block:: php + + return [ + 'my_module' => [ + 'parent' => 'web', + 'position' => ['before' => '*'], + 'access' => 'user', + 'aliases' => ['web_layout'], + 'path' => '/module/my_module', + 'iconIdentifier' => 'module-page', + 'labels' => 'LLL:EXT:backend/Resources/Private/Language/locallang_mod.xlf', + 'routes' => [ + '_default' => [ + 'target' => \MyVendor\MyPackage\Controller\MyController::class . '::mainAction', + ], + ], + ], + ]; + +.. index:: Backend, TSConfig, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-102518-DatabaseEngineVersionRequirements.rst b/Documentation/Changelog/13.0/Breaking-102518-DatabaseEngineVersionRequirements.rst new file mode 100644 index 0000000..c59d5f2 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102518-DatabaseEngineVersionRequirements.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102518-1701035329: + +======================================================== +Breaking: #102518 - Database engine version requirements +======================================================== + +See :issue:`102518` and :issue:`102594` + +Description +=========== + +TYPO3 v13 supports these database products and versions: + +* MySQL 8.0.17 or higher +* MariaDB 10.4.3 or higher +* PostgresSQL 10.0 or higher +* SQLite 3.8.3 or higher + +Impact +====== + +Environments with older MariaDB or MySQL database engines will report an unsupported +database version and stop working properly with the upcoming Doctrine DBAL v4 upgrade. + +Affected installations +====================== + +Hosting a TYPO3 instance based on version 13 may require an update of the MariaDB or +MySQL database engine. + +Migration +========= + +TYPO3 v12 supports MariaDB 10.4.3 or MySQL 8.0.17 and higher database engines required by v13. +This allows upgrading the platform in a first step and upgrading to TYPO3 v13 in a second step. + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102581-RemovedHookForManipulatingContentObjectRenderer.rst b/Documentation/Changelog/13.0/Breaking-102581-RemovedHookForManipulatingContentObjectRenderer.rst new file mode 100644 index 0000000..ead2c0e --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102581-RemovedHookForManipulatingContentObjectRenderer.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102581-1701449553: + +======================================================================= +Breaking: #102581 - Removed hook for manipulating ContentObjectRenderer +======================================================================= + +See :issue:`102581` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['postInit']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Frontend\ContentObject\Event\AfterContentObjectRendererInitializedEvent`. + + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102581-PSR-14EventForModifyingContentObjectRenderer>` +to allow greater influence in the functionality. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102583-RemovedContextAspectTyposcript.rst b/Documentation/Changelog/13.0/Breaking-102583-RemovedContextAspectTyposcript.rst new file mode 100644 index 0000000..b821f2b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102583-RemovedContextAspectTyposcript.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102583-1701510037: + +===================================================== +Breaking: #102583 - Removed context aspect typoscript +===================================================== + +See :issue:`102583` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\Context\Context` aspect :php:`typoscript` has been +removed without direct substitution. This aspect was implemented by now removed +class :php:`\TYPO3\CMS\Core\Context\TypoScriptAspect`, handling the +EXT:adminpanel-related property :php:`forcedTemplateParsing`. + + +Impact +====== + +The following calls will throw PHP exceptions: + +.. code-block:: php + + /** @var \TYPO3\CMS\Core\Context\Context $context */ + $context->getPropertyFromAspect('typoscript', 'forcedTemplateParsing'); + $context->getAspect('typoscript'); + // Returns false + $context->hasAspect('typoscript'); + +Affected installations +====================== + +Extensions typically do not use this context aspect since it only carried an +EXT:adminpanel-related information. + + +Migration +========= + +No direct migration possible. There should be little reason for extensions to +work with this EXT:adminpanel related detail. + + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102590-TSFE-generatePage_preProcessingRemoved.rst b/Documentation/Changelog/13.0/Breaking-102590-TSFE-generatePage_preProcessingRemoved.rst new file mode 100644 index 0000000..cdcca28 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102590-TSFE-generatePage_preProcessingRemoved.rst @@ -0,0 +1,38 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102590-1701598566: + +============================================================== +Breaking: #102590 - TSFE->generatePage_preProcessing() removed +============================================================== + +See :issue:`102590` + +Description +=========== + +Frontend-related method :php:`TypoScriptFrontendController->generatePage_preProcessing()` +has been removed without substitution. + + +Impact +====== + +Calling the methods will raise a fatal PHP error. + + +Affected installations +====================== + +There is little to no need for extensions to call or override this method and it +should have been marked as :php:`@internal` already. It was part of a removed +"safety net" when extensions did set :php:`TypoScriptFrontendController->no_cache` +to :php:`false` after it has been set to :php:`true` already, which is not allowed. + + +Migration +========= + +No migration, do not call the method anymore. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102600-TSFE-applicationDataRemoved.rst b/Documentation/Changelog/13.0/Breaking-102600-TSFE-applicationDataRemoved.rst new file mode 100644 index 0000000..5b31915 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102600-TSFE-applicationDataRemoved.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102600-1701707508: + +================================================= +Breaking: #102600 - TSFE->applicationData removed +================================================= + +See :issue:`102600` + +Description +=========== + +Frontend-related property :php:`TypoScriptFrontendController->applicationData` +has been removed without substitution. + +This property has been used by a few rather old-school extensions to park and +communicate state using this global "extension-specific state array". + +When looking at the TYPO3 frontend rendering chain, class :php:`TypoScriptFrontendController` +is by far the biggest technical debt: It mixes a lot of concerns and carries tons of state +and functionality that should be modeled differently, which leads to easier to understand +and more flexible code. The class is shrinking since various major versions already and will +ultimately dissolve entirely at some point. Changes in this area are becoming more aggressive +with TYPO3 v13. Any code using the class will need adaptions at some point, single patches +will continue to communicate alternatives. + +In case of the :php:`applicationData` property, this is simply a misuse of the +class instance to park arbitrary state in a global object. This is why it needs to +fall and why there is no direct substitution. + + +Impact +====== + +Using :php:`TypoScriptFrontendController->applicationData` (or +:php:`$GLOBALS['TSFE']->applicationData`) will raise a PHP fatal error. + + +Affected installations +====================== + +Instances with extensions that use :php:`applicationData` to store and communicate +state. + + +Migration +========= + +There are various solutions to communicate state to avoid :php:`applicationData`: + +In some cases, an extension could establish a frontend middleware and attach a +request attribute that carries the state. + +In other cases an event could be fired to gather information from other extensions. +One example is the indexed_search extension which dispatches the new event +:php:`EnableIndexingEvent` to get know if indexing should be performed. The +third-party crawler extension should use this instead of setting that information +on :php:`$GLOBALS['TSFE']`. + + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102605-TSFE-fe_userRemoved.rst b/Documentation/Changelog/13.0/Breaking-102605-TSFE-fe_userRemoved.rst new file mode 100644 index 0000000..6219526 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102605-TSFE-fe_userRemoved.rst @@ -0,0 +1,85 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102605-1701772495: + +========================================= +Breaking: #102605 - TSFE->fe_user removed +========================================= + +See :issue:`102605` + +Description +=========== + +Frontend-related property :php:`TypoScriptFrontendController->fe_user` has been removed. + +When looking at the TYPO3 frontend rendering chain, class :php:`TypoScriptFrontendController` +is by far the biggest technical debt: It mixes a lot of concerns and carries tons of state +and functionality that should be modeled differently, which leads to easier to understand +and more flexible code. The class is shrinking since various major versions already and will +ultimately dissolve entirely at some point. Changes in this area are becoming more aggressive +with TYPO3 v13. Any code using the class will need adaptions at some point, single patches +will continue to communicate alternatives. + +In case of the :php:`fe_user` property, two alternatives exist: The frontend user +can be retrieved from the PSR-7 request attribute :php:`frontend.user`, and basic frontend user +information is available using the :php:`Context` aspect :php:`frontend.user`. + +Note accessing TypoScript :typoscript:`TSFE:fe_user` details continues to work for now, using +for example :typoscript:`lib.foo.data = TSFE:fe_user|user|username` to retrieve the username of a +logged in user is still ok. + + +Impact +====== + +Using :php:`TypoScriptFrontendController->fe_user` (or +:php:`$GLOBALS['TSFE']->fe_user`) will raise a PHP fatal error. + + +Affected installations +====================== + +Instances with extensions dealing with frontend user details may be affected, typically +custom login extensions or extensions consuming detail data of logged in users. + + +Migration +========= + +There are two possible migrations. + +First, a limited information list of frontend user details can be retrieved using the :php:`Context` +aspect :php:`frontend.user` in frontend calls. See class :php:`\TYPO3\CMS\Core\Context\UserAspect` for a +full list. The current context can retrieved using dependency injection. Example: + +.. code-block:: php + + use TYPO3\CMS\Core\Context\Context; + + final class MyExtensionController { + public function __construct( + private readonly Context $context, + ) {} + + public function myAction() { + $frontendUserUsername = $this->context->getPropertyFromAspect('frontend.user', 'username', '')); + } + } + +Additionally, the full :php:`\TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication` object is +available as request attribute :php:`frontend.user` in the frontend. Note some details of that object +are marked :php:`@internal`, using the context aspect is thus the preferred way. Example of an extension +using Extbase's :php:`ActionController`: + +.. code-block:: php + + final class MyExtensionController extends ActionController { + public function myAction() { + // Note the 'user' property is marked @internal. + $frontendUserUsername = $this->request->getAttribute('frontend.user')->user['username']; + } + } + + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102614-RemovedHookForManipulatingGetDataResult.rst b/Documentation/Changelog/13.0/Breaking-102614-RemovedHookForManipulatingGetDataResult.rst new file mode 100644 index 0000000..dae20dd --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102614-RemovedHookForManipulatingGetDataResult.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102614-1701869646: + +================================================================ +Breaking: #102614 - Removed Hook for manipulating GetData result +================================================================ + +See :issue:`102614` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['getData']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Frontend\ContentObject\Event\AfterGetDataResolvedEvent`. + + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102614-PSR-14EventForModifyingGetDataResult>` +to allow greater influence in the functionality. + +.. note:: + + The new event is no longer executed for every "section" of the provided + parameter string, but only once, before the final result of :php:`getData()` + is about to be returned. This therefore means, the former :php:`$secVal` + is no longer available in the new event. Please adjust your implementation + accordingly. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102621-MostTSFEMembersMarkedInternalOrRead-only.rst b/Documentation/Changelog/13.0/Breaking-102621-MostTSFEMembersMarkedInternalOrRead-only.rst new file mode 100644 index 0000000..c4efcaa --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102621-MostTSFEMembersMarkedInternalOrRead-only.rst @@ -0,0 +1,131 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102621-1701937690: + +================================================================== +Breaking: #102621 - Most TSFE members marked internal or read-only +================================================================== + +See :issue:`102621` + +Description +=========== + +Most properties and methods of class :php:`TypoScriptFrontendController` have +been marked :php:`@internal` or "read-only". + +:php:`TypoScriptFrontendController` ("TSFE") is a god object within the TYPO3 frontend +rendering chain: It is used by multiple middlewares that call TSFE methods to create +state in it, it is used within :php:`ContentObjectRenderer` and various other +classes to update and retrieve state. It is also registered as :php:`$GLOBALS['TSFE']` +at some point and thus available as global state object. + +This makes the class the biggest anti-pattern we have within the frontend - class +:php:`ContentObjectRenderer` is problematic as well, but that is a different story. +The current role of :php:`TypoScriptFrontendController` leads to very complex and +opaque state handling within the frontend rendering, is the true source of many +hard to fix issues and prevents Core development from implementing cool new features. + +The TYPO3 Core strives to resolve large parts of this with TYPO3 v13: State needed +by lower level code is being modeled as request attributes or handled locally in +middlewares, methods are moved out of the class into middlewares to improve +encapsulation and code flow. + +To do that within continued TYPO3 v13 development, the Core needs to mark various +methods and properties :php:`@internal`, and needs to mark more strict access patterns +on others. + +The solution is to look at public properties of :php:`TypoScriptFrontendController`, +and to declare those as :php:`@internal`, which extensions typically should not need to +deal with at all. Others (like for instance :php:`id`) are actively used by extensions and +will be substituted by something different later, and are thus marked as "allowed to read, +but never write" for extensions. This allows implementation of a deprecation layer for those +"read-only" properties later, while those marked :php:`@internal` can vanish without +further notice. A similar strategy is added for methods, leaving only a few not +marked :php:`@internal`, which the Core will deprecate with a compatibility layer later. + +The following public class properties have been marked "read-only", and have later been +deprecated with the full deprecation of :php:`TypoScriptFrontendController` in +TYPO3 v13.4, see :ref:`deprecation-105230-1728374467`. + +* :php:`TypoScriptFrontendController->id` - Use :php:`$request->getAttribute('frontend.page.information')->getId()` instead +* :php:`TypoScriptFrontendController->rootLine` - Use :php:`$request->getAttribute('frontend.page.information')->getRootLine()` instead +* :php:`TypoScriptFrontendController->page` - Use :php:`$request->getAttribute('frontend.page.information')->getPageRecord()` instead +* :php:`TypoScriptFrontendController->contentPid` - Avoid usages altogether, available as :php:`@internal` call using + :php:`$request->getAttribute('frontend.page.information')->getContentFromPid()` +* :php:`TypoScriptFrontendController->sys_page` - Avoid altogether, create own instance using :php:`GeneralUtility::makeInstance(PageRepository::class)` +* :php:`TypoScriptFrontendController->config['config']` - Use :php:`$request->getAttribute('frontend.typoscript')->getConfigArray()` instead` +* :php:`TypoScriptFrontendController->config['rootLine']` - Use :php:`$request->getAttribute('frontend.page.information')->getLocalRootLine()` instead +* :php:`TypoScriptFrontendController->cObj` - Create an own :php:`ContentObjectRenderer` instance, call :php:`setRequest($request)` + and :php:`start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages')` + +The following public class properties have been marked :php:`@internal` - in general +all properties not listed above. They contain information usually not relevant within +extensions. The TYPO3 core will model them differently. + +* :php:`TypoScriptFrontendController->absRefPrefix` +* :php:`TypoScriptFrontendController->no_cache` - Use request attribute :php:`frontend.cache.instruction` instead +* :php:`TypoScriptFrontendController->additionalHeaderData` +* :php:`TypoScriptFrontendController->additionalFooterData` +* :php:`TypoScriptFrontendController->register` +* :php:`TypoScriptFrontendController->registerStack` +* :php:`TypoScriptFrontendController->recordRegister` +* :php:`TypoScriptFrontendController->currentRecord` +* :php:`TypoScriptFrontendController->content` +* :php:`TypoScriptFrontendController->lastImgResourceInfo` + +The following methods have been marked :php:`@internal` and may vanish anytime: + +* :php:`TypoScriptFrontendController->__construct()` - extensions should not create own instances of TSFE +* :php:`TypoScriptFrontendController->determineId()` +* :php:`TypoScriptFrontendController->getPageAccessFailureReasons()` +* :php:`TypoScriptFrontendController->calculateLinkVars()` +* :php:`TypoScriptFrontendController->isGeneratePage()` +* :php:`TypoScriptFrontendController->preparePageContentGeneration()` +* :php:`TypoScriptFrontendController->generatePage_postProcessing()` +* :php:`TypoScriptFrontendController->generatePageTitle()` +* :php:`TypoScriptFrontendController->INTincScript()` +* :php:`TypoScriptFrontendController->INTincScript_loadJSCode()` +* :php:`TypoScriptFrontendController->isINTincScript()` +* :php:`TypoScriptFrontendController->applyHttpHeadersToResponse()` +* :php:`TypoScriptFrontendController->isStaticCacheble()` +* :php:`TypoScriptFrontendController->newCObj()` +* :php:`TypoScriptFrontendController->logDeprecatedTyposcript()` +* :php:`TypoScriptFrontendController->uniqueHash()` +* :php:`TypoScriptFrontendController->set_cache_timeout_default()` +* :php:`TypoScriptFrontendController->set_no_cache()` - Use :php:`$request->getAttribute('frontend.cache.instruction')->disableCache()` instead +* :php:`TypoScriptFrontendController->sL()` - Use :php:`GeneralUtility::makeInstance(LanguageServiceFactory::class)->createFromSiteLanguage($request->getAttribute('language'))->sL()` instead +* :php:`TypoScriptFrontendController->get_cache_timeout()` +* :php:`TypoScriptFrontendController->getRequestedId()` - Use :php:`$request->getAttribute('routing')->getPageId()` instead +* :php:`TypoScriptFrontendController->getLanguage()` - Use :php:`$request->getAttribute('language') ?? $request->getAttribute('site')->getDefaultLanguage()` instead +* :php:`TypoScriptFrontendController->getSite()` - Use :php:`$request->getAttribute('site')` instead +* :php:`TypoScriptFrontendController->getContext()` - Use dependency injection or :php:`GeneralUtility::makeInstance()` instead +* :php:`TypoScriptFrontendController->getPageArguments()` - Use :php:`$request->getAttribute('routing')` instead + +Impact +====== + +Writing to the listed read-only properties may break the frontend rendering, +using the properties or methods marked as :php:`@internal` may raise fatal PHP errors. + + +Affected installations +====================== + +The majority of extensions should already use the above properties that are marked read-only +for reading only: Updating their state can easily lead to unexpected behavior. Most +extensions also don't consume the properties or methods marked as :php:`@internal`. + +Extension developers should watch out for usages of :php:`TypoScriptFrontendController` in +general and reduce usages as much as possible. + + +Migration +========= + +The migration strategy depends on the specific use case. The frontend rendering chain +continues to add state that is needed by extensions as PSR-7 request attributes. Debugging +the incoming request within an extension often reveals a proper alternative. + + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102624-RemovedHookForManipulatingImageSourceCollection.rst b/Documentation/Changelog/13.0/Breaking-102624-RemovedHookForManipulatingImageSourceCollection.rst new file mode 100644 index 0000000..fc7e023 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102624-RemovedHookForManipulatingImageSourceCollection.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102624-1701943942: + +====================================================================== +Breaking: #102624 - PSR-14 Event for modifying image source collection +====================================================================== + +See :issue:`102624` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['getImageSourceCollection']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Frontend\ContentObject\Event\ModifyImageSourceCollectionEvent`. + + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102624-PSR-14EventForModifyingImageSourceCollection>` +to allow greater influence in the functionality. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102627-RemovedSpecialPropertiesOfPageArraysInPageRepository.rst b/Documentation/Changelog/13.0/Breaking-102627-RemovedSpecialPropertiesOfPageArraysInPageRepository.rst new file mode 100644 index 0000000..a0ae6a9 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102627-RemovedSpecialPropertiesOfPageArraysInPageRepository.rst @@ -0,0 +1,73 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102627-1704301828: + +=============================================================================== +Breaking: #102627 - Removed special properties of page arrays in PageRepository +=============================================================================== + +See :issue:`102627` + +Description +=========== + +When requesting a page with a translation, the following special properties of +an overlaid page have been removed: + +:php:`_PAGES_OVERLAY_UID`: The property denounced the UID of the overlaid +database record entry, keeping "uid" as the original uid field. + +:php:`_PAGES_OVERLAY`: A boolean flag being set to true if a page was actually +overlaid with a found record. + +:php:`_PAGES_OVERLAY_LANGUAGE`: The value of the database record's +"sys_language" field value (the "language ID" of the overlaid record) + +:php:`_PAGES_OVERLAY_REQUESTEDLANGUAGE`: A special property used to set the +actual requested language when having multi-level fallbacks while overlaying a +record. When a requested overlay of language=5 is not available, but its +fallback to language=2 is available, this property is set to "5" even though +the page records' "sys_language_uid" field is set to 2. + +These special properties have been relevant especially when generating menus, +or when fetching overlays for Extbase domain models, and have been used due +to historical reasons, because translations of pages have been set in +"pages_language_overlay" instead of the database table "pages" until TYPO3 v9.0. + +Any other record, where translations have been stored in the database, +received the special property "_LOCALIZED_UID". + + +Impact +====== + +When calling :php:`PageRepository->getPage()` or +:php:`PageRepository->getLanguageOverlay()` these special page-related +properties are not set anymore when overlaying a page. + + +Affected installations +====================== + +TYPO3 installations with custom extensions working on the low-level API +using these properties. + + +Migration +========= + +The value of the previous :php:`_PAGES_OVERLAY_UID` property is now available +in :php:`_LOCALIZED_UID` making it consistent with all database record overlays +across the system. + +The property :php:`_PAGES_OVERLAY` is removed in favor of a +:php:`isset($page['_LOCALIZED_UID')` check instead. + +The property :php:`_PAGES_OVERLAY_LANGUAGE` is removed in favor of the property +:php:`$page['sys_language_uid']` which holds the same value. + +The property :php:`_PAGES_OVERLAY_REQUESTEDLANGUAGE` is moved to a new property +called :php:`_REQUESTED_OVERLAY_LANGUAGE` which is available now for any kind +of overlaid record, and not just pages. + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102632-UseStrictTypesInExtbase.rst b/Documentation/Changelog/13.0/Breaking-102632-UseStrictTypesInExtbase.rst new file mode 100644 index 0000000..b05e377 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102632-UseStrictTypesInExtbase.rst @@ -0,0 +1,103 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102632-1702043797: + +=============================================== +Breaking: #102632 - Use strict types in Extbase +=============================================== + +See :issue:`102632`, :issue:`102878`, :issue:`102879`, :issue:`102885`, +:issue:`102954`, :issue:`102956`, :issue:`102966`, :issue:`102969` + +Description +=========== + +All properties, except the :php:`$view` property, in +:php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` are now strictly typed. +In addition, all function arguments and function return types are now strictly +typed. + +Also, the properties in the :php:`\TYPO3\CMS\Extbase\Annotation\Annotation` +namespace now have native PHP types for their properties. + +In summary, the following classes have received strict types: + +- :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Annotation\IgnoreValidation` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Annotation\ORM\Cascade` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Annotation\Required\Validate` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Domain\Model\AbstractFileFolder` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Domain\Model\Category` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Domain\Model\FileReference` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Domain\Model\File` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\ObjectStorage` +- :php:`\TYPO3\CMS\Extbase\TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface` + + +Impact +====== + +Classes extending the changed classes must now ensure that overwritten +properties and methods are all are strictly typed. + + +Affected installations +====================== + +Custom classes extending the changed classes. + + +Migration +========= + +Ensure classes that extend the changed classes use strict types for overwritten +properties, function arguments and return types. + +Extensions supporting multiple TYPO3 versions (for example, v12 and v13) must not +overwrite properties of the changed classes. +Instead, it is recommended to set values of overwritten properties in the +constructor of the extending class. + +Before +------ + +.. code-block:: php + + <?php + + namespace MyVendor\MyExtension\Controller; + + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class MyController extends ActionController + { + public string $errorMethodName = 'myAction'; + } + +After +----- + +.. code-block:: php + + <?php + + namespace MyVendor\MyExtension\Controller; + + use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; + + class MyController extends ActionController + { + public function __construct() + { + $this->errorMethodName = 'myAction'; + } + } + + +.. index:: Backend, Frontend, NotScanned, ext:extbase diff --git a/Documentation/Changelog/13.0/Breaking-102645-MoreStrictContextHandling.rst b/Documentation/Changelog/13.0/Breaking-102645-MoreStrictContextHandling.rst new file mode 100644 index 0000000..94bed1e --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102645-MoreStrictContextHandling.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102645-1702194379: + +================================================ +Breaking: #102645 - More strict Context handling +================================================ + +See :issue:`102645` + +Description +=========== + +Class :php:`\TYPO3\CMS\Core\Context\Context` is a stateful singleton class set up +pretty early by the frontend or backend application after the request object has been created. +Its state is then further changed by various frontend and backend middlewares. It can +be retrieved using dependency injection or :php:`GeneralUtility::makeInstance()` in consuming +classes. + +To clean up Context-related code a bit, the following changes have been made: + +* Method :php:`__construct()` removed from :php:`\TYPO3\CMS\Core\Context\Context` +* Class :php:`\TYPO3\CMS\Core\Context\ContextAwareInterface` removed +* Trait :php:`\TYPO3\CMS\Core\Context\ContextAwareTrait` removed + + +Impact +====== + +Handing over manual arguments to the constructor of :php:`__construct()` does not have +an effect anymore, and using the interface or the trait will raise a fatal PHP error. + + +Affected installations +====================== + +Most likely, not too many instances are affected: An instance of :php:`Context` is +typically created by Core bootstrap and retrieved using dependency injection, extensions +usually do not need to create own instances. + +There are also not many routing aspects with context dependencies that may use the +interface or the trait. If so, they can adapt easily and stay compatible with older +versions. + + +Migration +========= + +The constructor of the Context class was bogus. Since the class is an injectable singleton +that should be available through the container, it must not have manual constructor arguments +since this would shut down the container registration. Extensions typically did not create +own instances of :php:`Context`, using the constructor argument was - if at all - only done +in tests. Unit tests should typically create own instances using :php:`new` and hand them +over to classes that get the context injected. + +Adaption to the interface and trait removal is straight forward as well: Get the +context injected into the aspect, or retrieve the instance using :php:`GeneralUtility::makeInstance()`. + + +.. index:: PHP-API, PartiallyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102715-FrontendDetermineIdRelatedEventsChanged.rst b/Documentation/Changelog/13.0/Breaking-102715-FrontendDetermineIdRelatedEventsChanged.rst new file mode 100644 index 0000000..83c18ca --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102715-FrontendDetermineIdRelatedEventsChanged.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102715-1703254781: + +=================================================================== +Breaking: #102715 - Frontend "determineId()" related events changed +=================================================================== + +See :issue:`102715` + +Description +=========== + +With the continued refactoring of :php:`\TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController`, +the following events have been adapted: + +* :php:`\TYPO3\CMS\Frontend\Event\BeforePageIsResolvedEvent` +* :php:`\TYPO3\CMS\Frontend\Event\AfterPageWithRootLineIsResolvedEvent` +* :php:`\TYPO3\CMS\Frontend\Event\AfterPageAndLanguageIsResolvedEvent` + +The three events no longer retrieve an instance of :php:`TypoScriptFrontendController`, the +getter methods :php:`getController()` have been removed: The controller is instantiated +*after* the events have been dispatched, event listeners can no longer work with this +object. + +Instead, the events now contain an instance of the new :abbr:`DTO (Data Transfer Object)` +:php:`\TYPO3\CMS\Frontend\Page\PageInformation`, which can be retrieved and +manipulated by event listeners if necessary. + +Impact +====== + +Calling :php:`getController()` by consumers of above events will raise a fatal +PHP error. + +Also note the events may not be dispatched anymore when the middleware +:php:`\TYPO3\CMS\Frontend\Middleware\TypoScriptFrontendInitialization` creates +early responses. + + +Affected installations +====================== + +Those events are in place for a couple of special cases during early frontend rendering. +Most instances will not be affected, but some extensions may register event listeners. + + +Migration +========= + +Use method :php:`getPageInformation()` instead to retrieve calculated page state at +this point in the frontend rendering chain. Event listeners that manipulate that +object should set it again within the event using :php:`setPageInformation()`. + +In case middleware :php:`TypoScriptFrontendInitialization` no longer dispatches an event +when it created an early response on its own, an own middleware can be added around +that middleware to retrieve and further manipulate a response if needed. + + +.. index:: Frontend, PHP-API, PartiallyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102731-RemovedTypoScriptSettingShowForgotPasswordLinkInExtfelogin.rst b/Documentation/Changelog/13.0/Breaking-102731-RemovedTypoScriptSettingShowForgotPasswordLinkInExtfelogin.rst new file mode 100644 index 0000000..5e66779 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102731-RemovedTypoScriptSettingShowForgotPasswordLinkInExtfelogin.rst @@ -0,0 +1,39 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102731-1703944154: + +==================================================================================== +Breaking: #102731 - Removed TypoScript setting showForgotPasswordLink in ext:felogin +==================================================================================== + +See :issue:`102731` + +Description +=========== + +The :php:`showForgotPasswordLink` setting in ext:felogin has never been used in +default Fluid templates and was only been kept for backward compatibility +reasons. The setting has been deprecated with :issue:`98122`, but it has been forgotten +to be removed in TYPO3 v12. + + +Impact +====== + +The :php:`showForgotPasswordLink` setting has been removed from default +TypoScript. + + +Affected installations +====================== + +Instances using :php:`showForgotPasswordLink` setting in Fluid templates. + + +Migration +========= + +Use :php:`showForgotPassword` instead of :php:`showForgotPasswordLink`, which is +available since TYPO3 v11. + +.. index:: Frontend, TypoScript, NotScanned, ext:felogin diff --git a/Documentation/Changelog/13.0/Breaking-102745-RemovedContentObjectStdWrapHook.rst b/Documentation/Changelog/13.0/Breaking-102745-RemovedContentObjectStdWrapHook.rst new file mode 100644 index 0000000..c687ded --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102745-RemovedContentObjectStdWrapHook.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102745-1705054472: + +====================================================== +Breaking: #102745 - Removed ContentObject stdWrap hook +====================================================== + +See :issue:`102745` + +Description +=========== + +The ContentObject stdWrap hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['stdWrap']` +has been removed in favor of the more powerful PSR-14 events: + +* :php:`\TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapFunctionsInitializedEvent` +* :php:`\TYPO3\CMS\Frontend\ContentObject\Event\AfterStdWrapFunctionsInitializedEvent` +* :php:`\TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapFunctionsExecutedEvent` +* :php:`\TYPO3\CMS\Frontend\ContentObject\Event\AfterStdWrapFunctionsExecutedEvent` + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13.0+. The extension scanner will report usages. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using the hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new events) +when implementing the events as well without any further deprecations. +Use the :doc:`PSR-14 events <../13.0/Feature-102745-PSR-14EventsForModifyingContentObjectStdWrapFunctionality>` +to allow greater influence in the functionality. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102755-ImprovedGetImageResourceFunctionality.rst b/Documentation/Changelog/13.0/Breaking-102755-ImprovedGetImageResourceFunctionality.rst new file mode 100644 index 0000000..050e376 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102755-ImprovedGetImageResourceFunctionality.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102755-1704381963: + +=========================================================== +Breaking: #102755 - Improved getImageResource functionality +=========================================================== + +See :issue:`102755` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['getImgResource']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Frontend\ContentObject\Event\AfterImageResourceResolvedEvent`. + +The new event is using the new :php:`\TYPO3\CMS\Core\Imaging\ImageResource` :abbr:`DTO (Data Transfer Object)`, +which allows an improved API as developers do no longer have to deal with +unnamed array keys but benefit from the object-oriented approach, using +corresponding getter and setter. Therefore, the return types of the following +methods have been changed to :php:`?ImageResource`: + +* :php:`\TYPO3\CMS\Frontend\Imaging\GifBuilder->gifBuild()` +* :php:`\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer->getImgResource()` + +Impact +====== + +Any registered hook implementation is not executed anymore +in TYPO3 v13.0+. + +Calling the mentioned methods do now return either :php:`null` or an instance +of the :php:`ImageResource` DTO. + +The new Event is also using the new DTO instead of an array. + +Affected Installations +====================== + +TYPO3 installations with custom extensions using this hook or calling +mentioned methods directly. + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102755-PSR-14EventForModifyingGetImageResourceResult>` +to allow greater influence in the functionality. + +Additionally, adjust your code to handle the new return types appropriately. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102763-ExtbaseHashServiceUsageReplacedWithCoreHashService.rst b/Documentation/Changelog/13.0/Breaking-102763-ExtbaseHashServiceUsageReplacedWithCoreHashService.rst new file mode 100644 index 0000000..c4d4257 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102763-ExtbaseHashServiceUsageReplacedWithCoreHashService.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102763-1706362598: + +============================================================================ +Breaking: #102763 - Extbase HashService usage replaced with Core HashService +============================================================================ + +See :issue:`102763` + +Description +=========== + +All usages of the :php:`@internal` class +:php:`\TYPO3\CMS\Extbase\Security\Cryptography\HashService` in TYPO3 have been +removed and replaced by :php:`\TYPO3\CMS\Core\Crypto\HashService`. + + +Impact +====== + +Custom extensions expecting :php:`\TYPO3\CMS\Extbase\Security\Cryptography\HashService` +as type of the property :php:`'$hashService` of various :php:`@internal` classes +will result in a PHP Fatal error. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using one of the following: + +- Property :php:`$hashService` of :php:`\TYPO3\CMS\Fluid\ViewHelpers\FormViewHelper` +- :php:`@internal` property :php:`$hashService` of class :php:`\TYPO3\CMS\Extbase\Mvc\Controller\ActionController` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\FrontendLogin\Configuration\RecoveryConfiguration` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\FrontendLogin\Configuration\RecoveryConfiguration` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\FrontendLogin\Controller\PasswordRecoveryController` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\Form\Domain\Runtime\FormRuntime` +- Property :php:`$hashService` of :php:`internal` class :php:`\TYPO3\CMS\Form\Mvc\Property\TypeConverter\UploadedFileReferenceConverter` + + +Migration +========= + +Custom extensions must be adapted to use methods of class +:php:`\TYPO3\CMS\Core\Crypto\HashService`. + +.. index:: Fluid, Frontend, PHP-API, FullyScanned, ext:extbase diff --git a/Documentation/Changelog/13.0/Breaking-102763-FrontendUserPasswordRecoveryHashesInvalidated.rst b/Documentation/Changelog/13.0/Breaking-102763-FrontendUserPasswordRecoveryHashesInvalidated.rst new file mode 100644 index 0000000..5559fa6 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102763-FrontendUserPasswordRecoveryHashesInvalidated.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102763-1706375934: + +====================================================================== +Breaking: #102763 - Frontend user password recovery hashes invalidated +====================================================================== + +See :issue:`102763` + +Description +=========== + +The replacement of deprecated class +:php:`\TYPO3\CMS\Extbase\Security\Cryptography\HashService` results in existing +password recovery hashes of frontend users being invalid. + + +Impact +====== + +A frontend user with a valid and unexpired password recovery link created with +a TYPO3 version < 13 can not use the password recovery link to reset the +password. + +These hashes have a limited lifetime already (12 hours). On large installations +that require hashes to survive a major update, you could write a small CLI task +that re-adds missing hashes created in the maintenance time window of the upgrade. + + +Affected installations +====================== + +TYPO3 installations which use the "Display Password Recovery Link" option of +ext:fe_login. + + +Migration +========= + +Frontend users need to request a new password recovery link to reset the +password. + +.. index:: Frontend, NotScanned, ext:felogin diff --git a/Documentation/Changelog/13.0/Breaking-102775-PageRepositoryMethodsWithNativePHPTypes.rst b/Documentation/Changelog/13.0/Breaking-102775-PageRepositoryMethodsWithNativePHPTypes.rst new file mode 100644 index 0000000..f5b96e1 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102775-PageRepositoryMethodsWithNativePHPTypes.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102775-1704711591: + +================================================================ +Breaking: #102775 - PageRepository methods with native PHP types +================================================================ + +See :issue:`102775` + +Description +=========== + +Various methods in of the main TYPO3 Core classes +:php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository` +now have native PHP types in their method signature, requiring the caller code +to use exactly the required PHP types for the corresponding method arguments. + +The following methods are affected: + +- :php:`PageRepository->getPage()` +- :php:`PageRepository->getPage_noCheck()` +- :php:`PageRepository->getPageOverlay()` +- :php:`PageRepository->getPagesOverlay()` +- :php:`PageRepository->checkRecord()` +- :php:`PageRepository->getRawRecord()` +- :php:`PageRepository->enableFields()` +- :php:`PageRepository->getMultipleGroupsWhereClause()` +- :php:`PageRepository->versionOL()` + + +Impact +====== + +Calling the affected methods now requires the passed arguments to be +of the specified PHP type. Otherwise a PHP TypeError is triggered. + + +Affected installations +====================== + +TYPO3 installations with third-party extensions utilizing the +:php:`PageRepository` PHP class. + + +Migration +========= + +Extension authors need to adapt their PHP code to use ensure passed +arguments are of the required PHP type when calling corresponding methods +of the :php:`PageRepository` PHP class. Using proper type casts would is +a possible migration strategy. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102779-TYPO3V13SystemRequirements.rst b/Documentation/Changelog/13.0/Breaking-102779-TYPO3V13SystemRequirements.rst new file mode 100644 index 0000000..ef3c7d1 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102779-TYPO3V13SystemRequirements.rst @@ -0,0 +1,46 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102779-1704721008: + +================================================= +Breaking: #102779 - TYPO3 v13 System Requirements +================================================= + +See :issue:`102779` + +Description +=========== + +The minimum PHP version required to run TYPO3 version v13 has been defined as 8.2. + +TYPO3 v13 supports these database products and versions: + +* MySQL 8.0.17 or higher +* MariaDB 10.4.3 or higher +* PostgresSQL 10.0 or higher +* SQLite 3.8.3 or higher + + +Impact +====== + +The TYPO3 Core codebase and extensions tailored for v13 and above can use +features implemented with PHP up to and including 8.2. Running TYPO3 v13 with +older PHP versions or database engines will trigger fatal errors. + + +Affected installations +====================== + +Hosting a TYPO3 instance based on version 13 may require an update of the +PHP platform and the database engine. + + +Migration +========= + +TYPO3 v11 / v12 supports PHP 8.2 and database engines required by v13. This +allows upgrading the platform in a first step and upgrading to TYPO3 v13 in a +second step. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102793-PageRepository-enableFieldsHookRemoved.rst b/Documentation/Changelog/13.0/Breaking-102793-PageRepository-enableFieldsHookRemoved.rst new file mode 100644 index 0000000..eaf072f --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102793-PageRepository-enableFieldsHookRemoved.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102793-1704798752: + +============================================================= +Breaking: #102793 - PageRepository->enableFields hook removed +============================================================= + +See :issue:`102793` + +Description +=========== + +One of the common PHP APIs used in TYPO3 Core for fetching records is +:php:`\TYPO3\CMS\Core\Domain\Repository\PageRepository`. The method +:php:`enableFields()` is marked as deprecated, and the according hook +:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['addEnableColumns']` +has been removed. + + +Impact +====== + +Hook listeners will not be executed anymore. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using the mentioned hook. + + +Migration +========= + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['addEnableColumns']` +can be replaced by a listener to the newly introduced +:doc:`PSR-14 event <../13.0/Feature-102793-PSR-14EventForModifyingDefaultConstraintsInPageRepository>`. + +.. index:: Database, Frontend, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102806-HooksInPageRepositoryRemoved.rst b/Documentation/Changelog/13.0/Breaking-102806-HooksInPageRepositoryRemoved.rst new file mode 100644 index 0000000..8f5b62e --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102806-HooksInPageRepositoryRemoved.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102806-1704874383: + +=================================================== +Breaking: #102806 - Hooks in PageRepository removed +=================================================== + +See :issue:`102806` + +Description +=========== + +The following hooks in TYPO3's Core API class :php:`\TYPO3\CMS\Core\Domain\PageRepository` +have been removed: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Domain\PageRepository::class]['init']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getPage']` + +Later hook has been replaced by the new PSR-14 event +:php:`\TYPO3\CMS\Core\Domain\Event\BeforePageIsRetrievedEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore in TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using these hooks. + + +Migration +========= + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Domain\PageRepository::class]['init']` +is removed without substitution. Back in TYPO3 v4.x this hook was useful to modify +public properties after everything was initialized. Nowadays, this is not +necessary anymore, as the properties are not public anymore and calculated +based on the Context API when instantiated. + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_page.php']['getPage']` +is removed without deprecation in order to allow extensions to work with TYPO3 +v12 (using the hook) and v13+ (using the new Event) when implementing the event +as well without any further deprecations. Use the +:doc:`PSR-14 event <../13.0/Feature-102806-BeforePageIsRetrievedEventInPageRepository>` +to allow greater influence in the functionality. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102834-RemoveItemsFromNewContentElementWizard.rst b/Documentation/Changelog/13.0/Breaking-102834-RemoveItemsFromNewContentElementWizard.rst new file mode 100644 index 0000000..7eb0f4b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102834-RemoveItemsFromNewContentElementWizard.rst @@ -0,0 +1,48 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102834-1705491713: + +================================================================ +Breaking: #102834 - Remove items from New Content Element Wizard +================================================================ + +See :issue:`102834` + +Description +=========== + +The configuration of the New Content Element Wizard has been +:doc:`improved <../13.0/Feature-102834-Auto-registrationOfNewContentElementWizardViaTCA>` +by automatically registering the groups and elements from the TCA configuration. + +The previously used option to show / hide elements +:typoscript:`mod.wizards.newContentElement.wizardItems.<group>.show` is +therefore not evaluated anymore. + +All configured groups and elements are automatically shown. Removing these +groups and elements from the New Content Element Wizard has to be done via +the new :typoscript:`mod.wizards.newContentElement.wizardItems.removeItems` and +:typoscript:`mod.wizards.newContentElement.wizardItems.<group>.removeItems` +options. + +Impact +====== + +Using the page TSconfig option :typoscript:`mod.wizards.newContentElement.wizardItems.<group>.show` +to show / hide elements is not evaluated anymore. + +Affected installations +====================== + +TYPO3 installations with custom extensions using the page TSconfig +option :typoscript:`mod.wizards.newContentElement.wizardItems.<group>.show` to +show / hide elements in the New Content Element Wizard. + +Migration +========= + +To hide elements, migrate your page TSconfig from +:typoscript:`mod.wizards.newContentElement.wizardItems.<group>.show := removeFromList(html)` to +:typoscript:`mod.wizards.newContentElement.wizardItems.<group>.removeItems := addToList(html)`. + +.. index:: TCA, TypoScript, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-102835-StrictTypingInFinalTypoLinkCodecService.rst b/Documentation/Changelog/13.0/Breaking-102835-StrictTypingInFinalTypoLinkCodecService.rst new file mode 100644 index 0000000..6f32f63 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102835-StrictTypingInFinalTypoLinkCodecService.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102835-1705314374: + +=============================================================== +Breaking: #102835 - Strict typing in final TypoLinkCodecService +=============================================================== + +See :issue:`102835` + +Description +=========== + +The :php:`\TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService`, used to encode +and decode TypoLinks, has been declared `readonly` and set `final`. +Additionally, the class does now use strict typing and the :php:`decode()` +method's first parameter :php:`$typoLink` is now a type hinted :php:`string`. + +This has been done in combination with the introduction of the two new PSR-14 +events :php:`BeforeTypoLinkEncodedEvent` and :php:`AfterTypoLinkDecodedEvent`, +which allow to fully influence the encode and decode functionality, making +any cross classing superfluous. + +Impact +====== + +Extending / cross classing :php:`TypoLinkCodecService` does no longer work +and will lead to PHP errors. + +Calling :php:`decode()` with the first parameter :php:`$typolink` being not +a :php:`string` will lead to a PHP TypeError. + + +Affected installations +====================== + +All installations extending / cross classing :php:`TypoLinkCodecService` or +calling :php:`decode()` with the first parameter :php:`$typolink` not being +a :php:`string`. + + +Migration +========= + +Instead of extending / cross classing :php:`TypoLinkCodecService` use the +:doc:`new PSR-14 events <../13.0/Feature-102835-AddPSR-14EventsToManipulateTypoLinkCodecService>` +to modify the functionality. + +Ensure to always provide a :php:`string` as first parameter :php:`$typolink`, +when calling :php:`decode()` in your extension code. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102849-RemovedContentObjectStdWrapCacheStoreHook.rst b/Documentation/Changelog/13.0/Breaking-102849-RemovedContentObjectStdWrapCacheStoreHook.rst new file mode 100644 index 0000000..e7cdd8d --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102849-RemovedContentObjectStdWrapCacheStoreHook.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102849-1705514231: + +================================================================= +Breaking: #102849 - Removed ContentObject stdWrap cacheStore hook +================================================================= + +See :issue:`102849` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_content.php']['stdWrap_cacheStore']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapContentStoredInCacheEvent`. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102849-PSR-14EventForManipulatingStoreCacheFunctionalityOfStdWrap>` +to allow greater influence in the functionality. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102855-RemovedLinkServiceResolveByStringRepresentationHook.rst b/Documentation/Changelog/13.0/Breaking-102855-RemovedLinkServiceResolveByStringRepresentationHook.rst new file mode 100644 index 0000000..7f10eee --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102855-RemovedLinkServiceResolveByStringRepresentationHook.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102855-1705567984: + +========================================================================== +Breaking: #102855 - Removed LinkService resolveByStringRepresentation hook +========================================================================== + +See :issue:`102855` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Link']['resolveByStringRepresentation']` +has been removed in favor of the new PSR-14 event +:php:`\TYPO3\CMS\Core\LinkHandling\Event\AfterLinkResolvedByStringRepresentationEvent` +event. + +Impact +====== + +Any hook implementation registered is not executed anymore +in TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using this hook. + + +Migration +========= + +The hook is removed without deprecation in order to allow extensions +to work with TYPO3 v12 (using the hook) and v13+ (using the new event) +when implementing the event as well without any further deprecations. +Use the :doc:`PSR-14 event <../13.0/Feature-102855-PSR-14EventForModifyingResolvedLinkResultData>` +to allow greater influence in the functionality. + +.. index:: PHP-API, FullyScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102875-ChangedConnectionMethodSignaturesAndBehaviour.rst b/Documentation/Changelog/13.0/Breaking-102875-ChangedConnectionMethodSignaturesAndBehaviour.rst new file mode 100644 index 0000000..a8b600a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102875-ChangedConnectionMethodSignaturesAndBehaviour.rst @@ -0,0 +1,110 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102875-1705944556: + +====================================================================== +Breaking: #102875 - Changed Connection method signatures and behaviour +====================================================================== + +See :issue:`102875` + +Description +=========== + +Signature and behaviour of following methods has been changed: + +* :php:`lastInsertId()` no longer accepts the sequence and field name. +* :php:`quote()` no longer has a type argument and the value must be a string. + +Public :php:`Connection::PARAM_*` class constants has been replaced with the +Doctrine DBAL 4 :php:`ParameterType` and :php:`ArrayParameterType` enum definitions. + +.. note:: + Doctrine DBAL dropped the support for using the :php:`\PDO::PARAM_*` constants in + favour of the enum types on several methods. Be aware of this and use the + :php:`\TYPO3\CMS\Core\Database\Connection::PARAM_*` constants to reduce + required work on upgrading. + +Impact +====== + +Calling :php:`quote()` with a non-string as first argument will result in a +PHP error. Still providing the second argument will not emit an error, but +may be detected by static code analysers. + +Calling :php:`lastInsertId()` not directly after the record insert or inserting +records in another table in between will return the incorrect value. + +Affected installations +====================== + +Only installations calling :php:`quote()` with a non-string as first argument +or not using :php:`lastInsertId()` directly after the record insert. + +Migration +========= + +:php:`lastInsertId()` +--------------------- + +Returns the last inserted ID (auto-created) on the connection. + +.. note:: + That means, that the `last inserted id` needs to be retrieved directly before + inserting a record to another table. That should be the usual workflow used + in the wild - but be aware of this. + +**BEFORE** + +.. code-block:: php + :emphasize-lines: 20 + + use TYPO3\CMS\Core\Database\Connection as Typo3Connection; + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + /** @var Typo3Connection $connection */ + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('tx_myextension_mytable'); + + $connection->insert( + 'tx_myextension_mytable', + [ + 'pid' => $pid, + 'some_string' => $someString, + ], + [ + \PDO::PARAM_INT, + \PDO::PARAM_STR, + ] + ); + $uid = $connection->lastInsertId('tx_myextension_mytable'); + +**AFTER** + +.. code-block:: php + :emphasize-lines: 20 + + use TYPO3\CMS\Core\Database\Connection as Typo3Connection; + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + /** @var Typo3Connection $connection */ + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('tx_myextension_mytable'); + + $connection->insert( + 'tx_myextension_mytable', + [ + 'pid' => $pid, + 'some_string' => $someString, + ], + [ + Typo3Connection::PARAM_INT, + Typo3Connection::PARAM_STR, + ] + ); + $uid = $connection->lastInsertId(); + + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102875-ExpressionBuilderChanges.rst b/Documentation/Changelog/13.0/Breaking-102875-ExpressionBuilderChanges.rst new file mode 100644 index 0000000..72e7094 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102875-ExpressionBuilderChanges.rst @@ -0,0 +1,102 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102875-1706013339: + +============================================= +Breaking: #102875 - ExpressionBuilder changes +============================================= + +See :issue:`102875` + +Description +=========== + +Signature changes for following methods +--------------------------------------- + +* :php:`ExpressionBuilder::literal(string $value)`: Value must be a string now. +* :php:`ExpressionBuilder::trim()`: Only :php:`\Doctrine\DBAL\Platforms\TrimMode` + enum for :php:`$position` argument. + +Following class constants have been removed +------------------------------------------- + +* :php:`QUOTE_NOTHING`: Not used since already TYPO3 v12 and Doctrine DBAL 3.x. +* :php:`QUOTE_IDENTIFIER`: Not used since already TYPO3 v12 and Doctrine DBAL 3.x. +* :php:`QUOTE_PARAMETER`: Not used since already TYPO3 v12 and Doctrine DBAL 3.x. + +Impact +====== + +Calling any of the mentioned methods with invalid type will result in a PHP +error. + +Affected installations +====================== + +Only those installations that uses one of the mentioned methods with invalid type(s). + +Migration +========= + +:php:`ExpressionBuilder::literal()` +----------------------------------- + +Extension author need to ensure that a string is passed to :php:`literal()`. + +:php:`ExpressionBuilder::trim()` +-------------------------------- + +Extension author need to pass the Doctrine DBAL enum :php:`TrimMode` instead of +an integer. + +TRIM_LEADING + +.. csv-table:: Replacements + :header: "integer", "enum" + + 0, "TrimMode::UNSPECIFIED" + 1, "TrimMode::LEADING" + 2, "TrimMode::TRAILING" + 3, "TrimMode::BOTH" + + +.. code-block:: php + :caption: EXT:my_extension/Classes/Domain/Repository/MyTableRepository.php + + use Doctrine\DBAL\Platforms\TrimMode; + use TYPO3\CMS\Core\Database\Connection + use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; + + // before + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); + $queryBuilder->expr()->comparison( + $queryBuilder->expr()->trim($fieldName, 1), + ExpressionBuilder::EQ, + $queryBuilder->createNamedParameter('', Connection::PARAM_STR) + ); + + // after + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); + $queryBuilder->expr()->comparison( + $queryBuilder->expr()->trim($fieldName, TrimMode::LEADING), + ExpressionBuilder::EQ, + $queryBuilder->createNamedParameter('', Connection::PARAM_STR) + ); + + // example for dual version compatible code + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); + $queryBuilder->expr()->comparison( + $queryBuilder->expr()->trim($fieldName, TrimMode::LEADING), + ExpressionBuilder::EQ, + $queryBuilder->createNamedParameter('', Connection::PARAM_STR) + ); + +.. tip:: + + With Doctrine DBAL 3.x the :php:`TrimMode` was a class with class constants. Using + these no code changes are needed for TYPO3 v12 and v13 compatible code. Only + method call type hinting needs to be adjusted to use the enum instead of + int. + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102875-QueryBuilderChanges.rst b/Documentation/Changelog/13.0/Breaking-102875-QueryBuilderChanges.rst new file mode 100644 index 0000000..ab3cf79 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102875-QueryBuilderChanges.rst @@ -0,0 +1,264 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102875-1705944493: + +======================================== +Breaking: #102875 - QueryBuilder changes +======================================== + +See :issue:`102875` + +Description +=========== + +Doctrine DBAL 4 removed methods from the :php:`QueryBuilder` which has been +adopted to the extended :php:`\TYPO3\CMS\Core\Database\Query\QueryBuilder`. + +Removed methods: + +* :php:`QueryBuilder::add()`: Use new reset methods and normal set methods + instead. +* :php:`QueryBuilder::getQueryPart($partName)`: No replacement, internal state. +* :php:`QueryBuilder::getQueryParts()`: No replacement, internal state. +* :php:`QueryBuilder::resetQueryPart($partName)`: Replacement methods has been added, + see list. +* :php:`QueryBuilder::resetQueryParts()`: Replacement methods has been added, + see list. +* :php:`QueryBuilder::execute()`: Use :php:`QueryBuilder::executeQuery()` or + :php:`QueryBuilder::executeStatement()` directly. +* :php:`QueryBuilder::setMaxResults()`: Using `(int)0` as `max result` will + no longer work and retrieve no records. Use `NULL` instead to allow all + results. + +Signature changes: + +* :php:`QueryBuilder::quote(string $value)`: Second argument has been dropped + and the value must now be of type :php:`string`. + +Impact +====== + +Calling any of the mentioned removed methods will result in a PHP error. Also +signature changes introducing type hint will result in a PHP error if called +with an invalid type. + +Affected installations +====================== + +Only those installations that use the mentioned methods. + +Migration +========= + +Extension author need to replace the removed methods with the alternatives which + + +:php:`QueryBuilder::add('query-part-name')` +------------------------------------------- + +Use the direct set/select methods instead: + +.. csv-table:: Replacements + :header: "before", "after" + + ":php:`->add('select', $array)`", ":php:`->select(...$array)`" + ":php:`->add('where', $wheres)`", ":php:`->where(...$wheres)`" + ":php:`->add('having', $havings)`", ":php:`->having(...$havings)`" + ":php:`->add('orderBy', $orderBy)`", ":php:`->orderBy($orderByField, $orderByDirection)->addOrderBy($orderByField2)`" + ":php:`->add('groupBy', $groupBy)`", ":php:`->groupBy($groupField)->addGroupBy($groupField2)`" + +.. note:: + This can be done already in TYPO3 v12 with at least Doctrine DBAL 3.8. + +:php:`QueryBuilder::resetQueryParts()` and :php:`QueryBuilder::resetQueryPart()` +-------------------------------------------------------------------------------- + +However, several replacements have been put in place depending on the +:php:`$queryPartName` parameter: + +.. csv-table:: Replacements + :header: "before", "after" + + "'select'", "Call :php:`->select()` with a new set of columns" + "'distinct'", ":php:`->distinct(false)`" + "'where'", ":php:`->resetWhere()`" + "'having'", ":php:`->resetHaving()`" + "'groupBy'", ":php:`->resetGroupBy()`" + "'orderBy", ":php:`->resetOrderBy()`" + "'values'", "Call :php:`->values()` with a new set of values." + +.. note:: + This can be done already in TYPO3 v12 with at least Doctrine DBAL 3.8. + +:php:`QueryBuilder::execute()` +------------------------------ + +Doctrine DBAL 4 removed :php:`QueryBuilder::execute()` in favour of the two +methods :php:`QueryBuilder::executeQuery()` for select/count and :php:`QueryBuilder::executeStatement()` +for insert, delete and update queries. + +Before +~~~~~~ + +.. code-block:: php + :emphasize-lines: 9,20 + + use TYPO3\CMS\Core\Database\ConnectionPool; + + // select query + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->execute() + ->fetchAllAssociative(); + + // delete query + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $deletedRows = (int)$queryBuilder + ->delete('pages') + ->where( + $queryBuilder->expr()->eq('pid', $this->createNamedParameter(123), + ) + ->execute(); + +After +~~~~~ + +.. code-block:: php + :emphasize-lines: 9,20 + + use TYPO3\CMS\Core\Database\ConnectionPool; + + // select query + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->executeQuery() + ->fetchAllAssociative(); + + // delete query + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $deletedRows = (int)$queryBuilder + ->delete('pages') + ->where( + $queryBuilder->expr()->eq('pid', $this->createNamedParameter(123), + ) + ->executeStatement(); + +:php:`QueryBuilder::quote(string $value)` +----------------------------------------- + +:php:`quote()` uses :php:`Connection::quote()` and therefore adopts the changed +signature and behaviour. + +Before +~~~~~~ + +.. code-block:: php + :emphasize-lines: 15 + + use TYPO3\CMS\Core\Database\Connection as Typo3Connection; + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // select query + $pageId = 123; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->quote($pageId, Typo3Connection::PARAM_INT) + ), + ) + ->executeQuery() + ->fetchAllAssociative(); + +After +~~~~~ + +.. code-block:: php + :emphasize-lines: 14 + + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // select query + $pageId = 123; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->quote((string)$pageId) + ), + ) + ->executeQuery() + ->fetchAllAssociative(); + +.. tip:: + To provide TYPO3 v12 and v13 with one code base, :php:`->quote((string)$value)` + can be used to ensure dual Core compatibility. + +:php:`QueryBuilder::setMaxResults()` +------------------------------------ + +Using `(int)0` as `max result` will no longer work and retrieve no records. +Use `NULL` instead to allow all results. + +Before +~~~~~~ + +.. code-block:: php + :emphasize-lines: 12 + + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // select query + $pageId = 123; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->setFirstResult(0) + ->setMaxResults(0) + ->executeQuery() + ->fetchAllAssociative(); + +After +~~~~~ + +.. code-block:: php + :emphasize-lines: 12 + + use TYPO3\CMS\Core\Database\ConnectionPool; + use TYPO3\CMS\Core\Utility\GeneralUtility; + + // select query + $pageId = 123; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('*') + ->from('pages') + ->setFirstResult(0) + ->setMaxResults(null) + ->executeQuery() + ->fetchAllAssociative(); + + +.. index:: Database, PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102895-PackageInterfaceModified.rst b/Documentation/Changelog/13.0/Breaking-102895-PackageInterfaceModified.rst new file mode 100644 index 0000000..1c1b560 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102895-PackageInterfaceModified.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102895-1706002517: + +============================================= +Breaking: #102895 - PackageInterface modified +============================================= + +See :issue:`102895` + +Description +=========== + +The PHP interface :php:`\TYPO3\CMS\Core\Package\PackageInterface` has been +modified. + +All methods of the interface now have proper types in the method signature. + +In addition, the method :php:`getPackageIcon(): ?string` is added to define +whether the package has an icon which is shipped with the package. + + +Impact +====== + +Although the interface exists primarily because of the original implementation +from Flow Framework in TYPO3 v6.0 in order to differentiate between TYPO3 +extensions and Flow packages, the interface only has one implementation: +:php:`\TYPO3\CMS\Core\Package\Package`. + +Thus, it does not impact any extension or installation directly. + +However, projects might be affected if there is a custom implementation +of the :php:`PackageInterface`, which is highly unlikely. + + +Affected installations +====================== + +TYPO3 installations in very rare cases where there is a custom implementation +of the interface, which is unknown at the time of writing. + + +Migration +========= + +Extend the custom implementation to reflect the updated :php:`PackageInterface`. + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102900-MetaphoneSearchRemovedFromIndexed_search.rst b/Documentation/Changelog/13.0/Breaking-102900-MetaphoneSearchRemovedFromIndexed_search.rst new file mode 100644 index 0000000..6e2600b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102900-MetaphoneSearchRemovedFromIndexed_search.rst @@ -0,0 +1,70 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102900-1706016651: + +================================================================= +Breaking: #102900 - Metaphone search removed from indexed\_search +================================================================= + +See :issue:`102900` + +Description +=========== + +The indexed_search based frontend functionality had a feature called "metaphone" +to look for matches that "sound similar" to the given search word. This was available +using the "Advanced search" interface, if it has not been disabled by an +integrator. This feature has been removed. + +This feature had so many issues that it was deemed unfixable: + +* Most importantly, search results were bad. Even during dedicated testing, it + was hard to retrieve any "similar sounding" results. +* The implementation was tailored for English language only, lacking support for + any non-ASCII characters like umlauts. Sites with languages not based on + single byte characters got even worse results. +* The code has been not maintained for about 15 years. +* The feature seems to be used so seldom, there does not seem to be a single + extension that tries to fix at least the most important issues. +* There has been no issues reported about this broken feature over the years, + except when it triggered crashes. + +All in all it seems as if that feature was used extremely seldom, most likely +because the results are so bad. + +On a code level, the removal affects these areas: + +* Class :php:`\TYPO3\CMS\IndexedSearch\Utility\DoubleMetaPhoneUtility` has been + removed. +* The "hook" :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['metaphone']` + to register an own "metaphone" solution has been removed. +* The extension configuration option :php:`enableMetaphoneSearch` has been removed. +* The database columns :sql:`index_fulltext.metaphonedata` and :sql:`index_words.metaphone` + have been removed. +* A couple of methods and properties in the :php:`@internal` marked indexed_search + classes have been removed and simplified. + + +Impact +====== + +Frontend users can no longer select the "Sounds like" option when searching the +website. Backend users do not see statistics about this search variant in the +backend module. + + +Affected installations +====================== + +Websites with a search solution based on indexed_search with "metaphone" search +being active in the extension configuration, and with users actively using +the "metaphone" search feature. + + +Migration +========= + +No migration available. Sites that really need this feature should switch to +a more sophisticated search solution. + +.. index:: Backend, Database, Frontend, PHP-API, FullyScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102902-SearchRulesRemovedFromIndexedSearch.rst b/Documentation/Changelog/13.0/Breaking-102902-SearchRulesRemovedFromIndexedSearch.rst new file mode 100644 index 0000000..fd31611 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102902-SearchRulesRemovedFromIndexedSearch.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102902-1706022423: + +============================================================ +Breaking: #102902 - Search rules removed from Indexed Search +============================================================ + +See :issue:`102902` + +Description +=========== + +The "Rules" section in Indexed Search stems from a time when today's knowledge +how a search works was considered "advanced". By today's standards, it can be +considered common sense and therefore the rules and its related TypoScript +configuration have been removed. + + +Impact +====== + +The Indexed Search plugin doesn't show the rules anymore. The Fluid partial file +:file:`Resources/Private/Partials/Rules.html` and the related TypoScript +configuration :typoscript`:`plugin.tx_indexedsearch.settings.displayRules` +have been removed. + + +Affected installations +====================== + +All installations displaying the Indexed Search search rules are affected. + + +Migration +========= + +Fluid +----- + +Remove any overrides for the partial file :file:`Resources/Private/Partials/Rules.html`, +as well as the :html:`<f:render partial="Rules" />` invocation from a potentially +overridden :file:`Resources/Private/Partials/Form.html` partial file. + + +TypoScript +---------- + +If configured, remove the :typoscript`:`plugin.tx_indexedsearch.settings.displayRules` +configuration. + +.. index:: Frontend, TypoScript, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102907-IndexedSearchTypoScriptSettingsRemoved.rst b/Documentation/Changelog/13.0/Breaking-102907-IndexedSearchTypoScriptSettingsRemoved.rst new file mode 100644 index 0000000..eb6c746 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102907-IndexedSearchTypoScriptSettingsRemoved.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102907-1706043066: + +============================================================== +Breaking: #102907 - Indexed Search TypoScript settings removed +============================================================== + +See :issue:`102907` + +Description +=========== + +Indexed Search previously used a custom link building to generate links and their targets +for linking to search results that are of type "page", instead of the native "typolink" (:php:`LinkFactory`) +system, automatically detecting links to other sites of the same installation and using +the proper :typoscript:`extTarget` setting in TypoScript for creating the target attribute for the link. + +For this reason, the two TypoScript settings are removed: + +.. code-block:: typoscript + + plugin.tx_indexedsearch.settings.detectDomainRecords + plugin.tx_indexedsearch.settings.detectDomainRecords.target + + +Impact +====== + +Setting these options have no effect anymore. + + +Affected installations +====================== + +TYPO3 installations using indexed search using these options. + + +Migration +========= + +Remove the lines, and adapt config.extTarget accordingly if needed in such cases, as +the links are now generated through TYPO3's native link building APIs. + +.. index:: TypoScript, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102921-RemoveSeveralOutdatedIndexedSearchFeatures.rst b/Documentation/Changelog/13.0/Breaking-102921-RemoveSeveralOutdatedIndexedSearchFeatures.rst new file mode 100644 index 0000000..ff0c0d2 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102921-RemoveSeveralOutdatedIndexedSearchFeatures.rst @@ -0,0 +1,60 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102921-1706170368: + +=================================================================== +Breaking: #102921 - Remove several outdated indexed search features +=================================================================== + +See :issue:`102921` + +Description +=========== + +The internal search of TYPO3, Indexed Search exists since over 20 years. Some +functionality that is shipped with the search form is not considered up-to-date +anymore, in regard to templating, as Indexed Search has an Extbase and +Fluid-based plugin since TYPO3 v6.2 (10 years). + +Some functionality was never removed, which is now the case: + +* The ability to customize the styling of a specific page via + :typoscript:`plugin.tx_indexedsearch.settings.specialConfiguration` +* The ability to customize a result icon (used as Gif images) based on the type + via :typoscript:`plugin.tx_indexedsearch.settings.iconRendering` +* The ability to customize a result language symbol icon (used as Gif images) + based on the page language via :typoscript:`plugin.tx_indexedsearch.settings.flagRendering` + +In addition, the possibility for visitors to change only search for results in +a language other than the current language is removed. It proved little sense +to search for e.g. Japanese content on a French websites. + + +Impact +====== + +All of the TypoScript settings are not evaluated anymore. The Fluid variables +:html:`{allLanguageUids}`, :html:`{row.language}` and :html:`{row.icon}` are not +filled anymore. + +Search only shows results in the language of the currently active language +of the website. + + +Affected installations +====================== + +TYPO3 installations using these options or features with Indexed Search. + + +Migration +========= + +Adapt your TypoScript settings, and remove the TypoScript settings and Fluid +variables. + +If you still need specific rendering of icons for pages, or customized CSS for +result pages, it is recommended to use Fluid conditions adapted in your custom +template, which is usually not necessary. + +.. index:: Frontend, TypoScript, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102924-SingleTableInheritanceFromFeGroupsRemoved.rst b/Documentation/Changelog/13.0/Breaking-102924-SingleTableInheritanceFromFeGroupsRemoved.rst new file mode 100644 index 0000000..cd8f6bd --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102924-SingleTableInheritanceFromFeGroupsRemoved.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102924-1706178654: + +=================================================================== +Breaking: #102924 - Single Table Inheritance from fe_groups removed +=================================================================== + +See :issue:`102924` + +Description +=========== + +Extbase ships with a feature called "Single Table Inheritance", to allow +multiple Extbase domain models reflecting one database table depending on a +specific value of a database field. + +TYPO3 has the functionality enabled for the database tables :sql:`fe_users` and +:sql:`fe_groups`. + +The respective default models, which do not make a lot of sense as models +depend on a specific domain, have been removed in previous TYPO3 versions. + +For frontend user groups, the usage and the usefulness for TYPO3 to ship this out +of the box, has shown little impact. For this reason, the functionality has been +removed. Along with that, the database field :sql:`fe_groups.tx_extbase_type` and its +TCA definition as well as the Extbase configuration as a single table inheritance +option, has been removed. + +The functionality for Single Table Inheritance in Extbase and also for frontend +users is working as before without any changes. + +.. _Single Table Inheritance: https://en.wikipedia.org/wiki/Single_Table_Inheritance + +Impact +====== + +Using the database field in custom code, or using Single Table Inheritance in +Extbase for frontend user groups will result in SQL and PHP errors. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using Single Table Inheritance in +Extbase with frontend usergroups. + + +Migration +========= + +If necessary, extension authors can add Single Table Inheritance in their own +extension for `fe_groups` by themselves. + +* Add a database field :sql:`fe_groups.tx_extbase_type` in :file:`ext_tables.sql` +* Add TCA information in :file:`Configuration/TCA/Overrides/fe_groups.php` for the database field + + +.. index:: Database, TCA, NotScanned, ext:extbase diff --git a/Documentation/Changelog/13.0/Breaking-102925-TemplateChangesInIndexedSearch.rst b/Documentation/Changelog/13.0/Breaking-102925-TemplateChangesInIndexedSearch.rst new file mode 100644 index 0000000..3433c09 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102925-TemplateChangesInIndexedSearch.rst @@ -0,0 +1,97 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102925-1706182267: + +====================================================== +Breaking: #102925 - Template changes in Indexed Search +====================================================== + +See :issue:`102925` + +Description +=========== + +Due to some major refactorings within EXT:indexed_search, Fluid templates in the +frontend plugins were adapted. + + +Impact +====== + +In case Fluid templates of EXT:indexed_search are overridden, the rendered output +may look different and behave unpleasant. + + +Affected installations +====================== + +All installations overriding Fluid templates of EXT:indexed_search are affected. + + +Migration +========= + +Pagination +---------- + +The pagination ViewHelpers have been removed in favor of native pagination API +shipped with TYPO3. Usages of the ViewHelpers `is:pageBrowsingResults` and +`is:pageBrowsing` have been removed. + +The Fluid template file `Private/Templates/Search/Search.html` loads a new +JavaScript via :html:`<f:asset.script>`: + +.. code-block:: html + + <f:asset.script useNonce="true" identifier="indexed_search_pagination" src="EXT:indexed_search/Resources/Public/JavaScript/pagination.js" /> + + +`is:pageBrowsingResults` has been replaced with a short HTML snippet: + +.. code-block:: html + + <f:sanitize.html> + <f:translate key="displayResults" arguments="{0: result.pagination.startRecordNumber, 1: result.pagination.endRecordNumber, 2: result.count}" /> + </f:sanitize.html> + +`is:pageBrowsing` has been replaced with a new Fluid partial file: + +.. code-block:: html + + <f:render partial="Pagination" arguments="{pagination: result.pagination, searchParams: searchParams, freeIndexUid: freeIndexUid}" /> + +Search result items +------------------- + +The following options are now passed to the `Searchresult` partial: + +* `row: row` +* `searchParams: searchParams` +* `firstRow: firstRow` + +The `Searchresult` partial now registers the `is` namespace for Fluid ViewHelpers: + +.. code-block:: html + + <html + xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:is="http://typo3.org/ns/TYPO3/CMS/IndexedSearch/ViewHelpers" + data-namespace-typo3-fluid="true"> + + +Within the `Searchresult` partial, `{row.rating}` has been replaced with a +ViewHelper invocation: + +.. code-block:: html + + {is:searchResult.rating(firstRow: firstRow, sortOrder: searchParams.sortOrder, row: row)} + + +Rules +----- + +Remove any overrides for the partial file :file:`Resources/Private/Partials/Rules.html`, +as well as the :html:`<f:render partial="Rules" />` invocation from a potentially +overridden :file:`Resources/Private/Partials/Form.html` partial file. + +.. index:: Fluid, Frontend, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102931-GifBuilderHookRemoved.rst b/Documentation/Changelog/13.0/Breaking-102931-GifBuilderHookRemoved.rst new file mode 100644 index 0000000..0abbe4b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102931-GifBuilderHookRemoved.rst @@ -0,0 +1,43 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102931-1706198332: + +============================================== +Breaking: #102931 - Removed hook in GifBuilder +============================================== + +See :issue:`102931` + +Description +=========== + +The hook :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_gifbuilder.php']['gifbuilder-ConfPreProcess']` +has been removed. + +This hook was solely introduced in TYPO3 v3.8.0 for a specific use case which +isn't needed anymore, and thus removed. + +At the same time the whole :php:`GifBuilder` class is now strictly typed. + + +Impact +====== + +PHP code utilizing this hook will not be executed anymore. + + +Affected installations +====================== + +TYPO3 installations with extensions utilizing this hook, which is highly unlikely. + +Any usages can be found with the Extension Scanner in the Install Tool. + + +Migration +========= + +It is recommended to hand in custom configuration already into GifBuilder +directly, and remove any usages to the hook in custom extension code. + +.. index:: PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102932-RemovedTypoScriptFrontendControllerHooks.rst b/Documentation/Changelog/13.0/Breaking-102932-RemovedTypoScriptFrontendControllerHooks.rst new file mode 100644 index 0000000..5a1bc19 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102932-RemovedTypoScriptFrontendControllerHooks.rst @@ -0,0 +1,47 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102932-1706202449: + +============================================================== +Breaking: #102932 - Removed TypoScriptFrontendController hooks +============================================================== + +See :issue:`102932` + +Description +=========== + +The following frontend TypoScript and page rendering related hooks +have been removed: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['configArrayPostProc']`, + substituted by event :php:`ModifyTypoScriptConfigEvent`. +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['pageLoadedFromCache']`, + no direct substitution, use event :php:`AfterTypoScriptDeterminedEvent` or an own middleware + after :php:`typo3/cms-frontend/prepare-tsfe-rendering` instead. +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['createHashBase']', + substituted by event :php:`BeforePageCacheIdentifierIsHashedEvent`. + + +Impact +====== + +Any such hook implementation registered is not executed anymore +with TYPO3 v13.0+. + + +Affected installations +====================== + +TYPO3 installations with custom extensions using above listed hooks. + + +Migration +========= + +See :doc:`PSR-14 event <../13.0/Feature-102932-NewTypoScriptRelatedFrontendEvents>` +for substitutions. The new events are tailored for more restricted use cases and can +be used when existing hook usages have not been "side" usages. Any "off label" hook +usages should be converted to custom middlewares instead. + +.. index:: Frontend, PHP-API, FullyScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102935-OverhauledExtensionInstallationInExtensionManager.rst b/Documentation/Changelog/13.0/Breaking-102935-OverhauledExtensionInstallationInExtensionManager.rst new file mode 100644 index 0000000..a20c808 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102935-OverhauledExtensionInstallationInExtensionManager.rst @@ -0,0 +1,97 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102935-1706258423: + +========================================================================== +Breaking: #102935 - Overhauled extension installation in Extension Manager +========================================================================== + +See :issue:`102935` + +Description +=========== + +Installing extensions via the extension manager is only used for non-Composer-based +installations. However, there have been a couple of dependencies to +the `EXT:extensionmanager`, which required even Composer-based installations +to have this extension installed. This has now been resolved. The +`EXT:extensionmanager` extension is now optional. + +The public :php:`\TYPO3\CMS\Extensionmanager\Utility\InstallUtility->processExtensionSetup()` +method has therefore been removed. It has previously been used to execute a +couple of "import" tasks, such as import site configurations or media assets to +the :file:`fileadmin/`. However those tasks had dependencies to other optional core +extensions, such as `EXT:impexp`. Therefore the new PSR-14 event +:php:`PackageInitializationEvent` has been introduced and the functionality +has been split into corresponding event listeners, which are added to their +associated Core extensions. + +The PSR-14 events, dispatched by those "tasks" have been removed: + +* :php:`\TYPO3\CMS\Extensionmanager\Event\AfterExtensionDatabaseContentHasBeenImportedEvent` +* :php:`\TYPO3\CMS\Extensionmanager\Event\AfterExtensionFilesHaveBeenImportedEvent` +* :php:`\TYPO3\CMS\Extensionmanager\Event\AfterExtensionSiteFilesHaveBeenImportedEvent` +* :php:`\TYPO3\CMS\Extensionmanager\Event\AfterExtensionStaticDatabaseContentHasBeenImportedEvent` + +The information, provided by those events can now be accessed by fetching the +corresponding storage entry from the new +:php:`\TYPO3\CMS\Core\Package\Event\PackageInitializationEvent`. + +Using :php:`before` and :php:`after` keywords in the listener registration, +custom extensions can ensure to be executed, once the corresponding information +is available. + +It's even possible to manually execute those "tasks" by dispatching the +:php:`PackageInitializationEvent` in custom extension code. This can be +used as replacement for the :php:`InstallUtility->processExtensionSetup()` call. + +Impact +====== + +Using one of the removed PSR-14 events or calling the removed method will +lead to a PHP error. The extension scanner will report any usages. + + +Affected installations +====================== + +TYPO3 installations with extensions registering listeners to the removed events +or calling the removed method in their extension code. + +Migration +========= + +Instead of registering listeners for the removed events, developers can now +just register a listener to the new :php:`PackageInitializationEvent`, which +contains the listeners result as storage entry: + +.. code-block:: php + + // Before + + #[AsEventListener] + public function __invoke(AfterExtensionSiteFilesHaveBeenImportedEvent $event): void + { + foreach ($event->getSiteIdentifierList() as $siteIdentifier) { + $configuration = $this->siteConfiguration->load($siteIdentifier); + $configuration = $this->extendSiteConfiguration($configuration); + $this->siteConfiguration->write($siteIdentifier, $configuration); + } + } + + // After + + #[AsEventListener(after: ImportSiteConfigurationsOnPackageInitialization::class)] + public function __invoke(PackageInitializationEvent $event): void + { + foreach ($event->getStorageEntry(ImportSiteConfigurationsOnPackageInitialization::class)->getResult() as $siteIdentifier) { + $configuration = $this->siteConfiguration->load($siteIdentifier); + $configuration = $this->extendSiteConfiguration($configuration); + $this->siteConfiguration->write($siteIdentifier, $configuration); + } + } + +Instead of calling :php:`InstallUtility->processExtensionSetup()`, extensions +can just dispatch the :php:`PackageInitializationEvent` on their own. + +.. index:: Backend, PHP-API, PartiallyScanned, ext:extensionmanager diff --git a/Documentation/Changelog/13.0/Breaking-102937-Pi1_hooksHookRemovedFromIndexedSearch.rst b/Documentation/Changelog/13.0/Breaking-102937-Pi1_hooksHookRemovedFromIndexedSearch.rst new file mode 100644 index 0000000..0eeb33a --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102937-Pi1_hooksHookRemovedFromIndexedSearch.rst @@ -0,0 +1,40 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102937-1706261180: + +================================================================ +Breaking: #102937 - `pi1_hooks` hook removed from Indexed Search +================================================================ + +See :issue:`102937` + +Description +=========== + +Indexed Search provided the possibility to manipulate the search behavior via +hooks with :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks']`. + +There are no public extensions supporting TYPO3 v12 using this hooking mechanism, +therefore it has been removed without replacement. In case there are private consumers +of these hooks, we will allow to add a dedicated event at appropriate places later. + + +Impact +====== + +If implemented, hooks in :php:`$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks']` +are not called anymore. + + +Affected installations +====================== + +All extensions using this hook are affected. + + +Migration +========= + +No migration available. + +.. index:: PHP-API, FullyScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102945-PaginationOfIndexedSearchReplaced.rst b/Documentation/Changelog/13.0/Breaking-102945-PaginationOfIndexedSearchReplaced.rst new file mode 100644 index 0000000..7f66587 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102945-PaginationOfIndexedSearchReplaced.rst @@ -0,0 +1,51 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102945-1706274593: + +========================================================= +Breaking: #102945 - Pagination of Indexed Search replaced +========================================================= + +See :issue:`102945` + +Description +=========== + +Indexed Search used a custom crafted pagination, implemented with several +ViewHelpers known as `is:pageBrowsingResults` and `is:pageBrowsing`. +These ViewHelpers have been removed in favor of the existing Pagination API, +leading to template changes. + + +Impact +====== + +In case Fluid templates of EXT:indexed_search are overridden, the frontend will +render an exception due to the missing ViewHelpers. + + +Affected installations +====================== + +All installations overriding the Fluid template `Templates/Search/Search.html` +of EXT:indexed_search are affected. + + +Migration +========= + +`is:pageBrowsingResults` has been replaced with a short HTML snippet: + +.. code-block:: html + + <f:sanitize.html> + <f:translate key="displayResults" arguments="{0: result.pagination.startRecordNumber, 1: result.pagination.endRecordNumber, 2: result.count}" /> + </f:sanitize.html> + +`is:pageBrowsing` has been replaced with a new Fluid partial file: + +.. code-block:: html + + <f:render partial="Pagination" arguments="{pagination: result.pagination, searchParams: searchParams, freeIndexUid: freeIndexUid}" /> + +.. index:: Fluid, Frontend, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102968-FormEngineItemFormElIDRemoved.rst b/Documentation/Changelog/13.0/Breaking-102968-FormEngineItemFormElIDRemoved.rst new file mode 100644 index 0000000..1ac384b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102968-FormEngineItemFormElIDRemoved.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102968-1706440705: + +=================================================== +Breaking: #102968 - FormEngine itemFormElID removed +=================================================== + +See :issue:`102968` + +Description +=========== + +When dealing with custom FormEngine elements in the backend record editing +interface, the infrastructure prepares a huge data array and hands it over +to single element classes for rendering. + +The specific data key :php:`$this->data['parameterArray']['itemFormElID']` +has been removed. The intention of that key was to prepare some unique id +to be used as :html:`id` attribute. This never made a lot of sense, single +element classes can easily take care of this on their own if needed. + +Since the Core can't actively deprecate and log access to members of the main +data array as such, there is no point in declaring a deprecation for it, and +the array entry has been removed directly. + + +Impact +====== + +Extensions with custom backend FormEngine elements may raise an +"undefined array key" PHP warning, or may create empty id attributes in +their HTML output if accessing :php:`itemFormElID`. + + +Affected installations +====================== + +Instances with extensions that deliver custom FormEngine elements may +be affected. + + +Migration +========= + +A typical use case for a unique :html:`id` attribute on a form element is to +connect it with a :html:`label` element. Accessing :php:`itemFormElID` can +usually be easily avoided by creating a unique string using +:php:`StringUtility::getUniqueId()`, with a custom prefix: + +.. code-block:: php + + // Before + $attributeId = htmlspecialchars($this->data['parameterArray']['itemFormElID']); + $html[] = '<input id="' . $attributeId . '">'; + + // After + $attributeId = htmlspecialchars(StringUtility::getUniqueId('formengine-my-custom-element-')); + $html[] = '<input id="' . $attributeId . '">'; + + +.. index:: Backend, PHP-API, NotScanned, ext:backend diff --git a/Documentation/Changelog/13.0/Breaking-102970-NoDatabaseRelationsInFlexFormSectionContainers.rst b/Documentation/Changelog/13.0/Breaking-102970-NoDatabaseRelationsInFlexFormSectionContainers.rst new file mode 100644 index 0000000..8c50fb5 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102970-NoDatabaseRelationsInFlexFormSectionContainers.rst @@ -0,0 +1,205 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102970-1706447911: + +======================================================================== +Breaking: #102970 - No database relations in FlexForm container sections +======================================================================== + +See :issue:`102970` + +Description +=========== + +FlexForm handling details can be troublesome in certain scenarios. The Core +suffers from some nasty issues in this area, especially when relations +to other tables are used in FlexForms - the system for instance tends to mix +up things with language and workspace on this level. + +The Core strives to get these scenarios sorted out, and a couple of patches to +prepare towards better flex form handling have been done with v13.0 already. + +To unblock further development in this area, one detail is restricted a bit more +than with previous versions: FlexForm container section data structures must no +longer contain fields that configure relations to other database tables. + +This has already been restricted since TYPO3 v8 for TCA :php:`type="inline"` and +has been partially extended to :php:`type="category"` and others later, if they +configured :php:`MM` relations in FlexForm sections containers. Now, especially +:php:`type="select"` with :php:`foreign_table` will also throw an exception. + +In general, anonymous FlexForm container section data can and should not point to +database entities. Their use is tailored for "simple" types like :php:`input`, +:php:`email` and similar, support of those will not be restricted. + +Note this does *not* restrict using casual FlexForms without containers sections, +like FlexForm data structures that rely on casual fields in sheets: Those can +continue to work with TCA types like :php:`inline`, :php:`group` and :php:`select`, +and the Core development tries to actively fix existing problematic scenarios +in this area. + + +Impact +====== + +When editing records that configure FlexForms with container sections that use +database relation-aware :php:`TCA` types, an exception will be thrown by +FormEngine. The related code may later be relocated to a lower level place +that can be triggered by DataHandler as well. + + +Affected installations +====================== + +Instances with extensions that use FlexForm container sections configuring +database relations to tables. + +Since previous core versions restricted database relations within FlexForm +container sections already, and since container sections are a relatively rarely +used feature in the first place, we don't expect too many extensions to be +affected by this. + +You can easily spot custom usage of FlexForm sections by searching for a :xml:`<section>` +tag within your FlexForm :file:`.xml` files, or within a TCA definition +with :php:`type="flex"`. These will be the instances you need to migrate, +when those sections contain :php:`type="select"` fields (or others mentioned above). + +Affected FlexForm XML +--------------------- + +.. code-block:: xml + :caption: EXT:my_extension/Configuration/FlexForms/Example.xml + :emphasize-lines: 15-38 + + <?xml version="1.0" encoding="utf-8" standalone="yes" ?> + <T3DataStructure> + <sheets> + <sSection> + <ROOT> + <sheetTitle>section</sheetTitle> + <type>array</type> + <el> + <section_1> + <title>section_1 + array + +
1
+ + + array + container_1 + + + + field description + + select + selectTree + pages + ORDER BY pages.sorting + 20 + + pid + + true + true + + + + + + + + + + + + + + + +Affected FlexForm TCA +--------------------- + +.. code-block:: php + :caption: EXT:my_extension/Configuration/TCA/tx_myextension_flex.php + :emphasize-lines: 22-45 + + [ + 'columns' => [ + 'flex_2' => [ + 'label' => 'flex section container', + 'config' => [ + 'type' => 'flex', + 'ds' => [ + 'default' => ' + + + + + section + array + + + section_1 + array + +
1
+ + + array + container_1 + + + + field description + + select + selectTree + pages + ORDER BY pages.sorting + 20 + + pid + + true + true + + + + + + + +
+
+
+
+
+
+ ', + ], + ], + ], + ], + ] + +Migration +========= + +Some extensions tried to work around existing restrictions by switching from +:php:`type="inline"` to :php:`type="group"` or :php:`type="select"`, ending +up with the same problematic scenario. + +The basic issue is still, that binding database entities to anonymous data +structures is a problematic approach in the first place: A container section +that can be repeated often, combined with the additional built-in feature to +have multiple different sections at the same time, is close to impossible to +manage in a way that does not easily destroy data integrity. + +Extensions that rely on this feature need to get rid of this approach: It +typically means rewriting the extension to model relations using :php:`type="inline"` +bound to database columns directly. + + +.. index:: Backend, FlexForm, TCA, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102971-MostClassesOfEXTworkspacesDeclaredInternal.rst b/Documentation/Changelog/13.0/Breaking-102971-MostClassesOfEXTworkspacesDeclaredInternal.rst new file mode 100644 index 0000000..0753abe --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102971-MostClassesOfEXTworkspacesDeclaredInternal.rst @@ -0,0 +1,45 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102971-1706453204: + +==================================================================== +Breaking: #102971 - Most classes of EXT:workspaces declared internal +==================================================================== + +See :issue:`102971` + +Description +=========== + +A few additional classes of extension "workspaces" have been declared :php:`@internal`. +With this, most of the classes are now considered internal handling, except, of +course, dispatched events. + + +Impact +====== + +Extensions extending or using workspace classes as PHP API that are now marked +:php:`@internal` may break, when the Core changes such classes. This will not +be considered breaking. + + +Affected installations +====================== + +Few extensions extend workspaces as such, and the backend workspaces +application in particular. + + +Migration +========= + +Extension authors who need to extend from classes within EXT:workspaces should +reconsider on why this needs to be done. They should expect these may break +without further notice. + +Legit use cases can often be moved towards some additional event instead. +Extension authors are encouraged to come up with specific solutions in those cases. + + +.. index:: PHP-API, NotScanned, ext:workspaces diff --git a/Documentation/Changelog/13.0/Breaking-102975-UseFullMd5HashesInIndexed_search.rst b/Documentation/Changelog/13.0/Breaking-102975-UseFullMd5HashesInIndexed_search.rst new file mode 100644 index 0000000..4b7339b --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102975-UseFullMd5HashesInIndexed_search.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102975-1706525161: + +=========================================================== +Breaking: #102975 - Use full md5 hashes in `indexed_search` +=========================================================== + +See :issue:`102975` + +Description +=========== + +For historical reasons an integer representation for castrated md5 hashes has +been used in several places for the `ext:indexed_search` provided database schema +and functionality. This led to conflicts that manifested as "duplicate key" errors. + +Therefore, the database fields are transformed to varchar fields and the whole +indexed search codebase changed to work with full md5 hashes now. + +Due to the database changes it is necessary to truncate the indexed search tables, +which is done within the database analyzer. Reindexing the data is therefore +required. + +Field types of following table fields are changed now: + +* `index_phash`: `phash`, `phash_grouping`, `contentHash` +* `index_fulltext`: `phash` +* `index_rel`: `phash`, `wid` +* `index_words`: `wid` +* `index_section`: `phash`, `phash_t3` +* `index_grlist`: `phash`, `phash_x`, `hash_gr_list` +* `index_debug`: `phash` + +.. note:: + + Remember to reindex your installation to fill the index again. + +Impact +====== + +Installations using the `ext:indexed_search` need to apply a database schema +change which involves the truncation of the corresponding tables and reindex +the installation. + +Affected installations +====================== + +All installations using `EXT:indexed_search` are affected. + + +Migration +========= + +The database analyzer takes care of updating affected columns and truncates +index related tables to be ready for reindexing. + + +.. index:: Database, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-102976-TimeTrackerReadAPIInternal.rst b/Documentation/Changelog/13.0/Breaking-102976-TimeTrackerReadAPIInternal.rst new file mode 100644 index 0000000..3c2d6e6 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102976-TimeTrackerReadAPIInternal.rst @@ -0,0 +1,53 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102976-1706528522: + +================================================= +Breaking: #102976 - TimeTracker read API internal +================================================= + +See :issue:`102976` + +Description +=========== + +Class :php:`\TYPO3\CMS\Core\TimeTracker` is used in the TYPO3 frontend rendering. +It allows tracking time consumed by single code sections. The admin panel uses +gathered data and renders a "time elapsed" overview from it. + +All methods and properties that enable or disable tracking details and return +the gathered data have been marked :php:`@internal` and partially moved to +EXT:adminpanel. + +Extensions should only write data to :php:`TimeTracker`, methods that are +considered API are these: + +* :php:`TimeTracker->push()` (second argument may vanish) +* :php:`TimeTracker->pull()` +* :php:`TimeTracker->setTSlogMessage()` + + +Impact +====== + +Extensions using methods other than the ones listed above may raise PHP fatal +errors or different result structures when the underlying code is further +refactored. + + +Affected installations +====================== + +Most extensions in the wild use only the above listed methods. There is little +reason to use other methods, except for extension that mimic or extend +functionality of EXT:adminpanel. Instances with such extensions need to follow +changes of class :php:`TimeTracker`. + + +Migration +========= + +No direct migration possible. + + +.. index:: Frontend, PHP-API, NotScanned, ext:frontend diff --git a/Documentation/Changelog/13.0/Breaking-102980-GetAllPageNumbersInPaginationInterface.rst b/Documentation/Changelog/13.0/Breaking-102980-GetAllPageNumbersInPaginationInterface.rst new file mode 100644 index 0000000..f63e860 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102980-GetAllPageNumbersInPaginationInterface.rst @@ -0,0 +1,44 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102980-1706534274: + +============================================================== +Breaking: #102980 - getAllPageNumbers() in PaginationInterface +============================================================== + +See :issue:`102980` + +Description +=========== + +A method has been added to :php:`\TYPO3\CMS\Core\Pagination\PaginationInterface` +with this signature: :php:`public function getAllPageNumbers(): array;`. It should +return a list of all available page numbers. + +The method has already been implemented in +:php:`\TYPO3\CMS\Core\Pagination\SimplePagination` and +:php:`\TYPO3\CMS\Core\Pagination\SlidingWindowPagination`. + + +Impact +====== + +Custom implementations of :php:`PaginationInterface` must implement the method. + + +Affected installations +====================== + +Instances with extensions that provide own pagination classes that implement +:php:`PaginationInterface` may be affected. + + + +Migration +========= + +See the two Core classes :php:`SimplePagination` and :php:`SlidingWindowPagination` +for examples on how the method is implemented. + + +.. index:: PHP-API, NotScanned, ext:core diff --git a/Documentation/Changelog/13.0/Breaking-102985-DeclareIndexedSearchAsContentType.rst b/Documentation/Changelog/13.0/Breaking-102985-DeclareIndexedSearchAsContentType.rst new file mode 100644 index 0000000..b2af709 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-102985-DeclareIndexedSearchAsContentType.rst @@ -0,0 +1,68 @@ +.. include:: /Includes.rst.txt + +.. _breaking-102985-1706549304: + +========================================================== +Breaking: #102985 - Declare Indexed Search as Content Type +========================================================== + +See :issue:`102985` + +Description +=========== + +The plugin configuration of the "Indexed Search" plugin has been changed. The +plugin is now configured as a proper "content element" using the `CType` plugin +type. This allows to further shrink down the `CType=list` and +`list_type=` combination, like it has already been done with other +plugins, e.g. the "Frontend Login" plugin. + +Impact +====== + +The "Indexed Search" plugin is now configured as a content element, using +`CType=indexedsearch_pi2` instead of the `CType=list` and +`list_type=indexedsearch_pi2` combination. + +An upgrade wizard is in place, migrating existing content elements as well +as corresponding backend user group permissions. + +Affected installations +====================== + +All installations with extensions, relying on the "Indexed Search" plugin +using the `CType=list` and `list_type=indexedsearch_pi2` combination. This +might be done in custom database queries, frontend data providers or in +TSconfig. Also in cases where the corresponding backend user group permissions +(:sql:`be_groups.explicit_allowdeny`) are manually evaluated. + +Migration +========= + +Execute the `Migrate "Indexed Search" plugins to content elements.` upgrade +wizard to automatically migrate existing records. Make sure to have the +`Migrate backend groups "explicit_allowdeny" field to simplified format.` +upgrade wizard executed beforehand. + +Additionally, adjust any place relying on the plugin using the +`CType=list` and `list_type=indexedsearch_pi2` combination. + +Example SQL migrations: + +.. code-block:: sql + + -- Before + SELECT * FROM tt_content WHERE CType = 'list' AND list_type = 'indexedsearch_pi2'; + + -- After + SELECT * FROM tt_content WHERE CType = 'indexedsearch_pi2'; + +.. code-block:: sql + + -- Before + SELECT * FROM be_groups WHERE explicit_allowdeny LIKE '%tt_content:list_type:indexedsearch_pi2%'; + + -- After + SELECT * FROM be_groups WHERE explicit_allowdeny LIKE '%tt_content:CType:indexedsearch_pi2%'; + +.. index:: TCA, NotScanned, ext:indexed_search diff --git a/Documentation/Changelog/13.0/Breaking-97330-FormEngineElementClassesMustCreateLabelOrLegend.rst b/Documentation/Changelog/13.0/Breaking-97330-FormEngineElementClassesMustCreateLabelOrLegend.rst new file mode 100644 index 0000000..71fc298 --- /dev/null +++ b/Documentation/Changelog/13.0/Breaking-97330-FormEngineElementClassesMustCreateLabelOrLegend.rst @@ -0,0 +1,104 @@ +.. include:: /Includes.rst.txt + +.. _breaking-97330-1687870738: + +========================================================================= +Breaking: #97330 - FormEngine element classes must create label or legend +========================================================================= + +See :issue:`97330` + +Description +=========== + +When editing records in the backend, the :php:`FormEngine` class structure located +within :file:`EXT:backend/Classes/Form/` handles the generation of the editing view. + +A change has been applied related to the rendering of single field labels, which +is no longer done automatically by "container" classes: Single elements have to +create the label themselves. + +Extension that add own elements to FormEngine must be adapted, otherwise the +element label is no longer rendered. + + +Impact +====== + +When the required changes are not applied to custom FormEngine element classes, +the value of the TCA "label" property is not rendered. + + +Affected installations +====================== + +Instances with custom FormEngine elements are affected. Custom elements need to be +registered to the FormEngine's :php:`NodeFactory`, candidates are found by looking at +the :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']` array (for instance +using the :guilabel:`System > Configuration` backend module provided by +EXT:lowlevel). Classes registered using the sub keys :php:`nodeRegistry` and +:php:`nodeResolver` may be affected. The extension scanner does not find +affected classes. + + +Migration +========= + +Custom elements must take care of creating a :html:`
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + +
+ + + Logo + + + TYPO3 Logo + + + TYPO3 Logo + + +
+ +
+ + +
+
+ + +
+ + +
+
+ + + + + + + + +
+ + + +
+ + +
+
+ + +
+ + +
+
+ + +
+ +

+ This email was sent by {typo3.sitename} from URL: {normalizedParams.siteUrl} - Please contact your site administrator if you feel you received this email by accident. +

+

+ {typo3.information.copyrightNotice -> f:format.raw()} +

+ +
+ + +
+
+ + + +
+
+ + + + diff --git a/Resources/Private/Layouts/SystemEmail.fluid.txt b/Resources/Private/Layouts/SystemEmail.fluid.txt new file mode 100644 index 0000000..f5bc9db --- /dev/null +++ b/Resources/Private/Layouts/SystemEmail.fluid.txt @@ -0,0 +1,8 @@ + + + +This email was sent by {typo3.sitename} from URL: {normalizedParams.siteUrl} + +Please contact your site administrator if you feel you received this email by accident. + +{typo3.information.copyrightNotice -> f:format.stripTags() -> f:format.htmlentitiesDecode()} diff --git a/Resources/Private/Php/cli.php b/Resources/Private/Php/cli.php new file mode 100644 index 0000000..e11fc9a --- /dev/null +++ b/Resources/Private/Php/cli.php @@ -0,0 +1,25 @@ +#!/usr/bin/env php +get(\TYPO3\CMS\Core\Console\CommandApplication::class)->run(); +}); diff --git a/Resources/Private/Php/framework-packages.php b/Resources/Private/Php/framework-packages.php new file mode 100644 index 0000000..a94b37e --- /dev/null +++ b/Resources/Private/Php/framework-packages.php @@ -0,0 +1,41 @@ +has(\TYPO3\CMS\Core\Http\Application::class)) { + $container->get(\TYPO3\CMS\Core\Http\Application::class)->run(); + return; + } + + $container->get(\TYPO3\CMS\Install\Http\Application::class)->run(); +}); diff --git a/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendCache.sql b/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendCache.sql new file mode 100644 index 0000000..c8b98d3 --- /dev/null +++ b/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendCache.sql @@ -0,0 +1,8 @@ +CREATE TABLE ###CACHE_TABLE### ( + id int(11) unsigned NOT NULL auto_increment, + identifier varchar(250) DEFAULT '' NOT NULL, + expires int(11) unsigned DEFAULT '0' NOT NULL, + content longblob, + PRIMARY KEY (id), + KEY cache_id (identifier(180),expires) +); diff --git a/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendTags.sql b/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendTags.sql new file mode 100644 index 0000000..eb6a1fe --- /dev/null +++ b/Resources/Private/Sql/Cache/Backend/Typo3DatabaseBackendTags.sql @@ -0,0 +1,8 @@ +CREATE TABLE ###TAGS_TABLE### ( + id int(11) unsigned NOT NULL auto_increment, + identifier varchar(250) DEFAULT '' NOT NULL, + tag varchar(250) DEFAULT '' NOT NULL, + PRIMARY KEY (id), + KEY cache_id (identifier(191)), + KEY cache_tag (tag(191)) +); diff --git a/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.fluid.html new file mode 100644 index 0000000..475f651 --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Auth.fluid.html @@ -0,0 +1,26 @@ + + + + +
+
+
+
+ +
+
+
+
+
+ +

+ + +

+
+
+ + diff --git a/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.fluid.html new file mode 100644 index 0000000..fbdbd70 --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Edit.fluid.html @@ -0,0 +1,80 @@ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + {name} + + + + + +
+ + + {amountOfCodesLeft} +
+ + + + {lastUsed} + + +
+ + + {updated} +
+
+
+
+ +
+
+

+

+ + TODO: The following should be a button which directly triggers a form submit + with the information to regenerate the recovery codes saved in some hidden field. + +
+
+ + +
+
+
+
diff --git a/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.fluid.html new file mode 100644 index 0000000..4e3f2fc --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes/Setup.fluid.html @@ -0,0 +1,48 @@ +
+
+
+ +
+ + + +
+ + +
+
+
{recoveryCodes}
+
+
+
+
+ + + + +
+
+
+
+ +
+ + + +
+ +
+
+
+
+ +
diff --git a/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.fluid.html new file mode 100644 index 0000000..f7c9283 --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Auth.fluid.html @@ -0,0 +1,27 @@ + + + + +
+
+
+
+ + +
+
+
+
+
+ +

+ + +

+
+
+ + diff --git a/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.fluid.html new file mode 100644 index 0000000..ff02ad0 --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Edit.fluid.html @@ -0,0 +1,53 @@ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + {name} + + + + + +
+ + + + {lastUsed} + + +
+ + + {updated} +
+
+
+
diff --git a/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.fluid.html b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.fluid.html new file mode 100644 index 0000000..7c18655 --- /dev/null +++ b/Resources/Private/Templates/Authentication/MfaProvider/Totp/Setup.fluid.html @@ -0,0 +1,79 @@ + + + + +
+
+
+ +
+ + + +
+
{qrCode -> f:format.raw()}
+
+
+
+
+
+
+ +
+ + + +
+
+ + + + +
+
+
+
+
+ +
+ + + +
+ +
+
+
+
+ +
+ + + +
+ +
+
+
+ +
+
+ + diff --git a/Resources/Private/Templates/Email/Default.fluid.html b/Resources/Private/Templates/Email/Default.fluid.html new file mode 100644 index 0000000..4b82b35 --- /dev/null +++ b/Resources/Private/Templates/Email/Default.fluid.html @@ -0,0 +1,8 @@ + +{headline} + + +

{introduction}

+
+

{content}

+
diff --git a/Resources/Private/Templates/Email/Default.fluid.txt b/Resources/Private/Templates/Email/Default.fluid.txt new file mode 100644 index 0000000..0c43f3a --- /dev/null +++ b/Resources/Private/Templates/Email/Default.fluid.txt @@ -0,0 +1,5 @@ + +{headline} +{introduction} + +{content} diff --git a/Resources/Private/Templates/ErrorPage/Error.fluid.html b/Resources/Private/Templates/ErrorPage/Error.fluid.html new file mode 100644 index 0000000..6fcbaca --- /dev/null +++ b/Resources/Private/Templates/ErrorPage/Error.fluid.html @@ -0,0 +1,98 @@ + + + + + + + {title} + + + + +
+ + + diff --git a/Resources/Private/Templates/PageRenderer.html b/Resources/Private/Templates/PageRenderer.html new file mode 100644 index 0000000..5923f32 --- /dev/null +++ b/Resources/Private/Templates/PageRenderer.html @@ -0,0 +1,29 @@ +###XMLPROLOG_DOCTYPE### +###HTMLTAG### +###HEADTAG### + + +###INLINECOMMENT### + +###SHORTCUT### +###TITLE### +###META### + +###CSS_LIBS### +###CSS_INCLUDE### +###CSS_INLINE### + +###JS_LIBS### +###JS_INCLUDE### +###JS_INLINE### + +###HEADERDATA### + + +###BODY### +###JS_LIBS_FOOTER### +###JS_INCLUDE_FOOTER### +###JS_INLINE_FOOTER### +###FOOTERDATA### + + diff --git a/Resources/Public/Icons/Extension.svg b/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000..cdd2e11 --- /dev/null +++ b/Resources/Public/Icons/Extension.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/Public/Icons/Flags/ad.webp b/Resources/Public/Icons/Flags/ad.webp new file mode 100644 index 0000000..9be8027 Binary files /dev/null and b/Resources/Public/Icons/Flags/ad.webp differ diff --git a/Resources/Public/Icons/Flags/ae.webp b/Resources/Public/Icons/Flags/ae.webp new file mode 100644 index 0000000..632e634 Binary files /dev/null and b/Resources/Public/Icons/Flags/ae.webp differ diff --git a/Resources/Public/Icons/Flags/af.webp b/Resources/Public/Icons/Flags/af.webp new file mode 100644 index 0000000..8fd769c Binary files /dev/null and b/Resources/Public/Icons/Flags/af.webp differ diff --git a/Resources/Public/Icons/Flags/ag.webp b/Resources/Public/Icons/Flags/ag.webp new file mode 100644 index 0000000..d98ebed Binary files /dev/null and b/Resources/Public/Icons/Flags/ag.webp differ diff --git a/Resources/Public/Icons/Flags/ai.webp b/Resources/Public/Icons/Flags/ai.webp new file mode 100644 index 0000000..2433a5e Binary files /dev/null and b/Resources/Public/Icons/Flags/ai.webp differ diff --git a/Resources/Public/Icons/Flags/al.webp b/Resources/Public/Icons/Flags/al.webp new file mode 100644 index 0000000..4c46d4c Binary files /dev/null and b/Resources/Public/Icons/Flags/al.webp differ diff --git a/Resources/Public/Icons/Flags/am.webp b/Resources/Public/Icons/Flags/am.webp new file mode 100644 index 0000000..441fd9b Binary files /dev/null and b/Resources/Public/Icons/Flags/am.webp differ diff --git a/Resources/Public/Icons/Flags/ao.webp b/Resources/Public/Icons/Flags/ao.webp new file mode 100644 index 0000000..45396e9 Binary files /dev/null and b/Resources/Public/Icons/Flags/ao.webp differ diff --git a/Resources/Public/Icons/Flags/aq.webp b/Resources/Public/Icons/Flags/aq.webp new file mode 100644 index 0000000..41c1e6a Binary files /dev/null and b/Resources/Public/Icons/Flags/aq.webp differ diff --git a/Resources/Public/Icons/Flags/ar.webp b/Resources/Public/Icons/Flags/ar.webp new file mode 100644 index 0000000..df50fdf Binary files /dev/null and b/Resources/Public/Icons/Flags/ar.webp differ diff --git a/Resources/Public/Icons/Flags/arab.webp b/Resources/Public/Icons/Flags/arab.webp new file mode 100644 index 0000000..3909258 Binary files /dev/null and b/Resources/Public/Icons/Flags/arab.webp differ diff --git a/Resources/Public/Icons/Flags/as.webp b/Resources/Public/Icons/Flags/as.webp new file mode 100644 index 0000000..3e52c68 Binary files /dev/null and b/Resources/Public/Icons/Flags/as.webp differ diff --git a/Resources/Public/Icons/Flags/at.webp b/Resources/Public/Icons/Flags/at.webp new file mode 100644 index 0000000..715d235 Binary files /dev/null and b/Resources/Public/Icons/Flags/at.webp differ diff --git a/Resources/Public/Icons/Flags/au.webp b/Resources/Public/Icons/Flags/au.webp new file mode 100644 index 0000000..918b1b3 Binary files /dev/null and b/Resources/Public/Icons/Flags/au.webp differ diff --git a/Resources/Public/Icons/Flags/aw.webp b/Resources/Public/Icons/Flags/aw.webp new file mode 100644 index 0000000..e3c7112 Binary files /dev/null and b/Resources/Public/Icons/Flags/aw.webp differ diff --git a/Resources/Public/Icons/Flags/ax.webp b/Resources/Public/Icons/Flags/ax.webp new file mode 100644 index 0000000..28913a6 Binary files /dev/null and b/Resources/Public/Icons/Flags/ax.webp differ diff --git a/Resources/Public/Icons/Flags/az.webp b/Resources/Public/Icons/Flags/az.webp new file mode 100644 index 0000000..5261d3a Binary files /dev/null and b/Resources/Public/Icons/Flags/az.webp differ diff --git a/Resources/Public/Icons/Flags/ba.webp b/Resources/Public/Icons/Flags/ba.webp new file mode 100644 index 0000000..62a7675 Binary files /dev/null and b/Resources/Public/Icons/Flags/ba.webp differ diff --git a/Resources/Public/Icons/Flags/bb.webp b/Resources/Public/Icons/Flags/bb.webp new file mode 100644 index 0000000..225f862 Binary files /dev/null and b/Resources/Public/Icons/Flags/bb.webp differ diff --git a/Resources/Public/Icons/Flags/bd.webp b/Resources/Public/Icons/Flags/bd.webp new file mode 100644 index 0000000..ebcb9f1 Binary files /dev/null and b/Resources/Public/Icons/Flags/bd.webp differ diff --git a/Resources/Public/Icons/Flags/be.webp b/Resources/Public/Icons/Flags/be.webp new file mode 100644 index 0000000..ec3b569 Binary files /dev/null and b/Resources/Public/Icons/Flags/be.webp differ diff --git a/Resources/Public/Icons/Flags/bf.webp b/Resources/Public/Icons/Flags/bf.webp new file mode 100644 index 0000000..452eced Binary files /dev/null and b/Resources/Public/Icons/Flags/bf.webp differ diff --git a/Resources/Public/Icons/Flags/bg.webp b/Resources/Public/Icons/Flags/bg.webp new file mode 100644 index 0000000..bc431a6 Binary files /dev/null and b/Resources/Public/Icons/Flags/bg.webp differ diff --git a/Resources/Public/Icons/Flags/bh.webp b/Resources/Public/Icons/Flags/bh.webp new file mode 100644 index 0000000..3d9641a Binary files /dev/null and b/Resources/Public/Icons/Flags/bh.webp differ diff --git a/Resources/Public/Icons/Flags/bi.webp b/Resources/Public/Icons/Flags/bi.webp new file mode 100644 index 0000000..39a5b5b Binary files /dev/null and b/Resources/Public/Icons/Flags/bi.webp differ diff --git a/Resources/Public/Icons/Flags/bj.webp b/Resources/Public/Icons/Flags/bj.webp new file mode 100644 index 0000000..5350550 Binary files /dev/null and b/Resources/Public/Icons/Flags/bj.webp differ diff --git a/Resources/Public/Icons/Flags/bl.webp b/Resources/Public/Icons/Flags/bl.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/bl.webp differ diff --git a/Resources/Public/Icons/Flags/black.webp b/Resources/Public/Icons/Flags/black.webp new file mode 100644 index 0000000..04c4a3f Binary files /dev/null and b/Resources/Public/Icons/Flags/black.webp differ diff --git a/Resources/Public/Icons/Flags/blue.webp b/Resources/Public/Icons/Flags/blue.webp new file mode 100644 index 0000000..befb3cd Binary files /dev/null and b/Resources/Public/Icons/Flags/blue.webp differ diff --git a/Resources/Public/Icons/Flags/bm.webp b/Resources/Public/Icons/Flags/bm.webp new file mode 100644 index 0000000..3614f5b Binary files /dev/null and b/Resources/Public/Icons/Flags/bm.webp differ diff --git a/Resources/Public/Icons/Flags/bn.webp b/Resources/Public/Icons/Flags/bn.webp new file mode 100644 index 0000000..95632f2 Binary files /dev/null and b/Resources/Public/Icons/Flags/bn.webp differ diff --git a/Resources/Public/Icons/Flags/bo.webp b/Resources/Public/Icons/Flags/bo.webp new file mode 100644 index 0000000..88fb528 Binary files /dev/null and b/Resources/Public/Icons/Flags/bo.webp differ diff --git a/Resources/Public/Icons/Flags/bq.webp b/Resources/Public/Icons/Flags/bq.webp new file mode 100644 index 0000000..34f1560 Binary files /dev/null and b/Resources/Public/Icons/Flags/bq.webp differ diff --git a/Resources/Public/Icons/Flags/br.webp b/Resources/Public/Icons/Flags/br.webp new file mode 100644 index 0000000..18711cd Binary files /dev/null and b/Resources/Public/Icons/Flags/br.webp differ diff --git a/Resources/Public/Icons/Flags/bs.webp b/Resources/Public/Icons/Flags/bs.webp new file mode 100644 index 0000000..62b25a2 Binary files /dev/null and b/Resources/Public/Icons/Flags/bs.webp differ diff --git a/Resources/Public/Icons/Flags/bt.webp b/Resources/Public/Icons/Flags/bt.webp new file mode 100644 index 0000000..8558631 Binary files /dev/null and b/Resources/Public/Icons/Flags/bt.webp differ diff --git a/Resources/Public/Icons/Flags/bv.webp b/Resources/Public/Icons/Flags/bv.webp new file mode 100644 index 0000000..61c0343 Binary files /dev/null and b/Resources/Public/Icons/Flags/bv.webp differ diff --git a/Resources/Public/Icons/Flags/bw.webp b/Resources/Public/Icons/Flags/bw.webp new file mode 100644 index 0000000..6f43b53 Binary files /dev/null and b/Resources/Public/Icons/Flags/bw.webp differ diff --git a/Resources/Public/Icons/Flags/by.webp b/Resources/Public/Icons/Flags/by.webp new file mode 100644 index 0000000..e0520ed Binary files /dev/null and b/Resources/Public/Icons/Flags/by.webp differ diff --git a/Resources/Public/Icons/Flags/bz.webp b/Resources/Public/Icons/Flags/bz.webp new file mode 100644 index 0000000..ff181dc Binary files /dev/null and b/Resources/Public/Icons/Flags/bz.webp differ diff --git a/Resources/Public/Icons/Flags/ca-qc.webp b/Resources/Public/Icons/Flags/ca-qc.webp new file mode 100644 index 0000000..7f049c1 Binary files /dev/null and b/Resources/Public/Icons/Flags/ca-qc.webp differ diff --git a/Resources/Public/Icons/Flags/ca.webp b/Resources/Public/Icons/Flags/ca.webp new file mode 100644 index 0000000..2584c5a Binary files /dev/null and b/Resources/Public/Icons/Flags/ca.webp differ diff --git a/Resources/Public/Icons/Flags/cc.webp b/Resources/Public/Icons/Flags/cc.webp new file mode 100644 index 0000000..0804d55 Binary files /dev/null and b/Resources/Public/Icons/Flags/cc.webp differ diff --git a/Resources/Public/Icons/Flags/cd.webp b/Resources/Public/Icons/Flags/cd.webp new file mode 100644 index 0000000..77ba495 Binary files /dev/null and b/Resources/Public/Icons/Flags/cd.webp differ diff --git a/Resources/Public/Icons/Flags/cf.webp b/Resources/Public/Icons/Flags/cf.webp new file mode 100644 index 0000000..cd8ec50 Binary files /dev/null and b/Resources/Public/Icons/Flags/cf.webp differ diff --git a/Resources/Public/Icons/Flags/cg.webp b/Resources/Public/Icons/Flags/cg.webp new file mode 100644 index 0000000..34e2e5e Binary files /dev/null and b/Resources/Public/Icons/Flags/cg.webp differ diff --git a/Resources/Public/Icons/Flags/ch.webp b/Resources/Public/Icons/Flags/ch.webp new file mode 100644 index 0000000..bd8f7d1 Binary files /dev/null and b/Resources/Public/Icons/Flags/ch.webp differ diff --git a/Resources/Public/Icons/Flags/ci.webp b/Resources/Public/Icons/Flags/ci.webp new file mode 100644 index 0000000..ef4ce9a Binary files /dev/null and b/Resources/Public/Icons/Flags/ci.webp differ diff --git a/Resources/Public/Icons/Flags/ck.webp b/Resources/Public/Icons/Flags/ck.webp new file mode 100644 index 0000000..ca5965b Binary files /dev/null and b/Resources/Public/Icons/Flags/ck.webp differ diff --git a/Resources/Public/Icons/Flags/cl.webp b/Resources/Public/Icons/Flags/cl.webp new file mode 100644 index 0000000..f0ef9ed Binary files /dev/null and b/Resources/Public/Icons/Flags/cl.webp differ diff --git a/Resources/Public/Icons/Flags/cm.webp b/Resources/Public/Icons/Flags/cm.webp new file mode 100644 index 0000000..12fbad5 Binary files /dev/null and b/Resources/Public/Icons/Flags/cm.webp differ diff --git a/Resources/Public/Icons/Flags/cn.webp b/Resources/Public/Icons/Flags/cn.webp new file mode 100644 index 0000000..6df4ec5 Binary files /dev/null and b/Resources/Public/Icons/Flags/cn.webp differ diff --git a/Resources/Public/Icons/Flags/co.webp b/Resources/Public/Icons/Flags/co.webp new file mode 100644 index 0000000..634607e Binary files /dev/null and b/Resources/Public/Icons/Flags/co.webp differ diff --git a/Resources/Public/Icons/Flags/cr.webp b/Resources/Public/Icons/Flags/cr.webp new file mode 100644 index 0000000..fba7788 Binary files /dev/null and b/Resources/Public/Icons/Flags/cr.webp differ diff --git a/Resources/Public/Icons/Flags/cu.webp b/Resources/Public/Icons/Flags/cu.webp new file mode 100644 index 0000000..b7db849 Binary files /dev/null and b/Resources/Public/Icons/Flags/cu.webp differ diff --git a/Resources/Public/Icons/Flags/cv.webp b/Resources/Public/Icons/Flags/cv.webp new file mode 100644 index 0000000..e961601 Binary files /dev/null and b/Resources/Public/Icons/Flags/cv.webp differ diff --git a/Resources/Public/Icons/Flags/cw.webp b/Resources/Public/Icons/Flags/cw.webp new file mode 100644 index 0000000..753464b Binary files /dev/null and b/Resources/Public/Icons/Flags/cw.webp differ diff --git a/Resources/Public/Icons/Flags/cx.webp b/Resources/Public/Icons/Flags/cx.webp new file mode 100644 index 0000000..06f80bc Binary files /dev/null and b/Resources/Public/Icons/Flags/cx.webp differ diff --git a/Resources/Public/Icons/Flags/cy.webp b/Resources/Public/Icons/Flags/cy.webp new file mode 100644 index 0000000..82bb93c Binary files /dev/null and b/Resources/Public/Icons/Flags/cy.webp differ diff --git a/Resources/Public/Icons/Flags/cyan.webp b/Resources/Public/Icons/Flags/cyan.webp new file mode 100644 index 0000000..d996770 Binary files /dev/null and b/Resources/Public/Icons/Flags/cyan.webp differ diff --git a/Resources/Public/Icons/Flags/cz.webp b/Resources/Public/Icons/Flags/cz.webp new file mode 100644 index 0000000..c7ab689 Binary files /dev/null and b/Resources/Public/Icons/Flags/cz.webp differ diff --git a/Resources/Public/Icons/Flags/de.webp b/Resources/Public/Icons/Flags/de.webp new file mode 100644 index 0000000..0941e8a Binary files /dev/null and b/Resources/Public/Icons/Flags/de.webp differ diff --git a/Resources/Public/Icons/Flags/dj.webp b/Resources/Public/Icons/Flags/dj.webp new file mode 100644 index 0000000..bbe19c1 Binary files /dev/null and b/Resources/Public/Icons/Flags/dj.webp differ diff --git a/Resources/Public/Icons/Flags/dk.webp b/Resources/Public/Icons/Flags/dk.webp new file mode 100644 index 0000000..b84926d Binary files /dev/null and b/Resources/Public/Icons/Flags/dk.webp differ diff --git a/Resources/Public/Icons/Flags/dm.webp b/Resources/Public/Icons/Flags/dm.webp new file mode 100644 index 0000000..b9dda76 Binary files /dev/null and b/Resources/Public/Icons/Flags/dm.webp differ diff --git a/Resources/Public/Icons/Flags/do.webp b/Resources/Public/Icons/Flags/do.webp new file mode 100644 index 0000000..4aafedc Binary files /dev/null and b/Resources/Public/Icons/Flags/do.webp differ diff --git a/Resources/Public/Icons/Flags/dz.webp b/Resources/Public/Icons/Flags/dz.webp new file mode 100644 index 0000000..d2ae231 Binary files /dev/null and b/Resources/Public/Icons/Flags/dz.webp differ diff --git a/Resources/Public/Icons/Flags/eac.webp b/Resources/Public/Icons/Flags/eac.webp new file mode 100644 index 0000000..e598de9 Binary files /dev/null and b/Resources/Public/Icons/Flags/eac.webp differ diff --git a/Resources/Public/Icons/Flags/ec.webp b/Resources/Public/Icons/Flags/ec.webp new file mode 100644 index 0000000..13a0f60 Binary files /dev/null and b/Resources/Public/Icons/Flags/ec.webp differ diff --git a/Resources/Public/Icons/Flags/ee.webp b/Resources/Public/Icons/Flags/ee.webp new file mode 100644 index 0000000..d7e960d Binary files /dev/null and b/Resources/Public/Icons/Flags/ee.webp differ diff --git a/Resources/Public/Icons/Flags/eg.webp b/Resources/Public/Icons/Flags/eg.webp new file mode 100644 index 0000000..fb8144b Binary files /dev/null and b/Resources/Public/Icons/Flags/eg.webp differ diff --git a/Resources/Public/Icons/Flags/eh.webp b/Resources/Public/Icons/Flags/eh.webp new file mode 100644 index 0000000..8cc0b45 Binary files /dev/null and b/Resources/Public/Icons/Flags/eh.webp differ diff --git a/Resources/Public/Icons/Flags/en-us-gb.webp b/Resources/Public/Icons/Flags/en-us-gb.webp new file mode 100644 index 0000000..2933415 Binary files /dev/null and b/Resources/Public/Icons/Flags/en-us-gb.webp differ diff --git a/Resources/Public/Icons/Flags/er.webp b/Resources/Public/Icons/Flags/er.webp new file mode 100644 index 0000000..cdcb1ca Binary files /dev/null and b/Resources/Public/Icons/Flags/er.webp differ diff --git a/Resources/Public/Icons/Flags/es-ct.webp b/Resources/Public/Icons/Flags/es-ct.webp new file mode 100644 index 0000000..1b3c04b Binary files /dev/null and b/Resources/Public/Icons/Flags/es-ct.webp differ diff --git a/Resources/Public/Icons/Flags/es-ga.webp b/Resources/Public/Icons/Flags/es-ga.webp new file mode 100644 index 0000000..fa2a541 Binary files /dev/null and b/Resources/Public/Icons/Flags/es-ga.webp differ diff --git a/Resources/Public/Icons/Flags/es-pv.webp b/Resources/Public/Icons/Flags/es-pv.webp new file mode 100644 index 0000000..3bcc2e5 Binary files /dev/null and b/Resources/Public/Icons/Flags/es-pv.webp differ diff --git a/Resources/Public/Icons/Flags/es.webp b/Resources/Public/Icons/Flags/es.webp new file mode 100644 index 0000000..0c9a8c6 Binary files /dev/null and b/Resources/Public/Icons/Flags/es.webp differ diff --git a/Resources/Public/Icons/Flags/et.webp b/Resources/Public/Icons/Flags/et.webp new file mode 100644 index 0000000..668320a Binary files /dev/null and b/Resources/Public/Icons/Flags/et.webp differ diff --git a/Resources/Public/Icons/Flags/eu.webp b/Resources/Public/Icons/Flags/eu.webp new file mode 100644 index 0000000..01ad949 Binary files /dev/null and b/Resources/Public/Icons/Flags/eu.webp differ diff --git a/Resources/Public/Icons/Flags/fi.webp b/Resources/Public/Icons/Flags/fi.webp new file mode 100644 index 0000000..2fe9af5 Binary files /dev/null and b/Resources/Public/Icons/Flags/fi.webp differ diff --git a/Resources/Public/Icons/Flags/fj.webp b/Resources/Public/Icons/Flags/fj.webp new file mode 100644 index 0000000..6345844 Binary files /dev/null and b/Resources/Public/Icons/Flags/fj.webp differ diff --git a/Resources/Public/Icons/Flags/fk.webp b/Resources/Public/Icons/Flags/fk.webp new file mode 100644 index 0000000..0d976e7 Binary files /dev/null and b/Resources/Public/Icons/Flags/fk.webp differ diff --git a/Resources/Public/Icons/Flags/fm.webp b/Resources/Public/Icons/Flags/fm.webp new file mode 100644 index 0000000..f2d9f46 Binary files /dev/null and b/Resources/Public/Icons/Flags/fm.webp differ diff --git a/Resources/Public/Icons/Flags/fo.webp b/Resources/Public/Icons/Flags/fo.webp new file mode 100644 index 0000000..7d69b61 Binary files /dev/null and b/Resources/Public/Icons/Flags/fo.webp differ diff --git a/Resources/Public/Icons/Flags/fr.webp b/Resources/Public/Icons/Flags/fr.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/fr.webp differ diff --git a/Resources/Public/Icons/Flags/ga.webp b/Resources/Public/Icons/Flags/ga.webp new file mode 100644 index 0000000..e030456 Binary files /dev/null and b/Resources/Public/Icons/Flags/ga.webp differ diff --git a/Resources/Public/Icons/Flags/gb-eng.webp b/Resources/Public/Icons/Flags/gb-eng.webp new file mode 100644 index 0000000..05b48c8 Binary files /dev/null and b/Resources/Public/Icons/Flags/gb-eng.webp differ diff --git a/Resources/Public/Icons/Flags/gb-nir.webp b/Resources/Public/Icons/Flags/gb-nir.webp new file mode 100644 index 0000000..be879ae Binary files /dev/null and b/Resources/Public/Icons/Flags/gb-nir.webp differ diff --git a/Resources/Public/Icons/Flags/gb-sct.webp b/Resources/Public/Icons/Flags/gb-sct.webp new file mode 100644 index 0000000..ba58e46 Binary files /dev/null and b/Resources/Public/Icons/Flags/gb-sct.webp differ diff --git a/Resources/Public/Icons/Flags/gb-wls.webp b/Resources/Public/Icons/Flags/gb-wls.webp new file mode 100644 index 0000000..7503d9f Binary files /dev/null and b/Resources/Public/Icons/Flags/gb-wls.webp differ diff --git a/Resources/Public/Icons/Flags/gb.webp b/Resources/Public/Icons/Flags/gb.webp new file mode 100644 index 0000000..dbff4ba Binary files /dev/null and b/Resources/Public/Icons/Flags/gb.webp differ diff --git a/Resources/Public/Icons/Flags/gd.webp b/Resources/Public/Icons/Flags/gd.webp new file mode 100644 index 0000000..fd58ef1 Binary files /dev/null and b/Resources/Public/Icons/Flags/gd.webp differ diff --git a/Resources/Public/Icons/Flags/ge.webp b/Resources/Public/Icons/Flags/ge.webp new file mode 100644 index 0000000..11a43cb Binary files /dev/null and b/Resources/Public/Icons/Flags/ge.webp differ diff --git a/Resources/Public/Icons/Flags/gf.webp b/Resources/Public/Icons/Flags/gf.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/gf.webp differ diff --git a/Resources/Public/Icons/Flags/gg.webp b/Resources/Public/Icons/Flags/gg.webp new file mode 100644 index 0000000..77d3ae2 Binary files /dev/null and b/Resources/Public/Icons/Flags/gg.webp differ diff --git a/Resources/Public/Icons/Flags/gh.webp b/Resources/Public/Icons/Flags/gh.webp new file mode 100644 index 0000000..3b7c614 Binary files /dev/null and b/Resources/Public/Icons/Flags/gh.webp differ diff --git a/Resources/Public/Icons/Flags/gi.webp b/Resources/Public/Icons/Flags/gi.webp new file mode 100644 index 0000000..a231525 Binary files /dev/null and b/Resources/Public/Icons/Flags/gi.webp differ diff --git a/Resources/Public/Icons/Flags/gl.webp b/Resources/Public/Icons/Flags/gl.webp new file mode 100644 index 0000000..9c6a705 Binary files /dev/null and b/Resources/Public/Icons/Flags/gl.webp differ diff --git a/Resources/Public/Icons/Flags/gm.webp b/Resources/Public/Icons/Flags/gm.webp new file mode 100644 index 0000000..6b5c02c Binary files /dev/null and b/Resources/Public/Icons/Flags/gm.webp differ diff --git a/Resources/Public/Icons/Flags/gn.webp b/Resources/Public/Icons/Flags/gn.webp new file mode 100644 index 0000000..aa4ef13 Binary files /dev/null and b/Resources/Public/Icons/Flags/gn.webp differ diff --git a/Resources/Public/Icons/Flags/gp.webp b/Resources/Public/Icons/Flags/gp.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/gp.webp differ diff --git a/Resources/Public/Icons/Flags/gq.webp b/Resources/Public/Icons/Flags/gq.webp new file mode 100644 index 0000000..b8c9628 Binary files /dev/null and b/Resources/Public/Icons/Flags/gq.webp differ diff --git a/Resources/Public/Icons/Flags/gr.webp b/Resources/Public/Icons/Flags/gr.webp new file mode 100644 index 0000000..39d0a84 Binary files /dev/null and b/Resources/Public/Icons/Flags/gr.webp differ diff --git a/Resources/Public/Icons/Flags/green.webp b/Resources/Public/Icons/Flags/green.webp new file mode 100644 index 0000000..b6839fa Binary files /dev/null and b/Resources/Public/Icons/Flags/green.webp differ diff --git a/Resources/Public/Icons/Flags/gs.webp b/Resources/Public/Icons/Flags/gs.webp new file mode 100644 index 0000000..c34a748 Binary files /dev/null and b/Resources/Public/Icons/Flags/gs.webp differ diff --git a/Resources/Public/Icons/Flags/gt.webp b/Resources/Public/Icons/Flags/gt.webp new file mode 100644 index 0000000..483ba24 Binary files /dev/null and b/Resources/Public/Icons/Flags/gt.webp differ diff --git a/Resources/Public/Icons/Flags/gu.webp b/Resources/Public/Icons/Flags/gu.webp new file mode 100644 index 0000000..6fdf125 Binary files /dev/null and b/Resources/Public/Icons/Flags/gu.webp differ diff --git a/Resources/Public/Icons/Flags/gw.webp b/Resources/Public/Icons/Flags/gw.webp new file mode 100644 index 0000000..40e342a Binary files /dev/null and b/Resources/Public/Icons/Flags/gw.webp differ diff --git a/Resources/Public/Icons/Flags/gy.webp b/Resources/Public/Icons/Flags/gy.webp new file mode 100644 index 0000000..3602361 Binary files /dev/null and b/Resources/Public/Icons/Flags/gy.webp differ diff --git a/Resources/Public/Icons/Flags/hk.webp b/Resources/Public/Icons/Flags/hk.webp new file mode 100644 index 0000000..83a1eb5 Binary files /dev/null and b/Resources/Public/Icons/Flags/hk.webp differ diff --git a/Resources/Public/Icons/Flags/hm.webp b/Resources/Public/Icons/Flags/hm.webp new file mode 100644 index 0000000..918b1b3 Binary files /dev/null and b/Resources/Public/Icons/Flags/hm.webp differ diff --git a/Resources/Public/Icons/Flags/hn.webp b/Resources/Public/Icons/Flags/hn.webp new file mode 100644 index 0000000..ba2b029 Binary files /dev/null and b/Resources/Public/Icons/Flags/hn.webp differ diff --git a/Resources/Public/Icons/Flags/hr.webp b/Resources/Public/Icons/Flags/hr.webp new file mode 100644 index 0000000..76a6871 Binary files /dev/null and b/Resources/Public/Icons/Flags/hr.webp differ diff --git a/Resources/Public/Icons/Flags/ht.webp b/Resources/Public/Icons/Flags/ht.webp new file mode 100644 index 0000000..7031891 Binary files /dev/null and b/Resources/Public/Icons/Flags/ht.webp differ diff --git a/Resources/Public/Icons/Flags/hu.webp b/Resources/Public/Icons/Flags/hu.webp new file mode 100644 index 0000000..67d32e6 Binary files /dev/null and b/Resources/Public/Icons/Flags/hu.webp differ diff --git a/Resources/Public/Icons/Flags/ic.webp b/Resources/Public/Icons/Flags/ic.webp new file mode 100644 index 0000000..c066c58 Binary files /dev/null and b/Resources/Public/Icons/Flags/ic.webp differ diff --git a/Resources/Public/Icons/Flags/id.webp b/Resources/Public/Icons/Flags/id.webp new file mode 100644 index 0000000..535e780 Binary files /dev/null and b/Resources/Public/Icons/Flags/id.webp differ diff --git a/Resources/Public/Icons/Flags/ie.webp b/Resources/Public/Icons/Flags/ie.webp new file mode 100644 index 0000000..4d5aac3 Binary files /dev/null and b/Resources/Public/Icons/Flags/ie.webp differ diff --git a/Resources/Public/Icons/Flags/il.webp b/Resources/Public/Icons/Flags/il.webp new file mode 100644 index 0000000..a45b600 Binary files /dev/null and b/Resources/Public/Icons/Flags/il.webp differ diff --git a/Resources/Public/Icons/Flags/im.webp b/Resources/Public/Icons/Flags/im.webp new file mode 100644 index 0000000..3e44448 Binary files /dev/null and b/Resources/Public/Icons/Flags/im.webp differ diff --git a/Resources/Public/Icons/Flags/in.webp b/Resources/Public/Icons/Flags/in.webp new file mode 100644 index 0000000..3384a7a Binary files /dev/null and b/Resources/Public/Icons/Flags/in.webp differ diff --git a/Resources/Public/Icons/Flags/indigo.webp b/Resources/Public/Icons/Flags/indigo.webp new file mode 100644 index 0000000..88e9796 Binary files /dev/null and b/Resources/Public/Icons/Flags/indigo.webp differ diff --git a/Resources/Public/Icons/Flags/io.webp b/Resources/Public/Icons/Flags/io.webp new file mode 100644 index 0000000..e9dfa45 Binary files /dev/null and b/Resources/Public/Icons/Flags/io.webp differ diff --git a/Resources/Public/Icons/Flags/iq.webp b/Resources/Public/Icons/Flags/iq.webp new file mode 100644 index 0000000..bc47ae6 Binary files /dev/null and b/Resources/Public/Icons/Flags/iq.webp differ diff --git a/Resources/Public/Icons/Flags/ir.webp b/Resources/Public/Icons/Flags/ir.webp new file mode 100644 index 0000000..dd4d6ea Binary files /dev/null and b/Resources/Public/Icons/Flags/ir.webp differ diff --git a/Resources/Public/Icons/Flags/is.webp b/Resources/Public/Icons/Flags/is.webp new file mode 100644 index 0000000..e1210c7 Binary files /dev/null and b/Resources/Public/Icons/Flags/is.webp differ diff --git a/Resources/Public/Icons/Flags/it.webp b/Resources/Public/Icons/Flags/it.webp new file mode 100644 index 0000000..bfa90e9 Binary files /dev/null and b/Resources/Public/Icons/Flags/it.webp differ diff --git a/Resources/Public/Icons/Flags/je.webp b/Resources/Public/Icons/Flags/je.webp new file mode 100644 index 0000000..ae63451 Binary files /dev/null and b/Resources/Public/Icons/Flags/je.webp differ diff --git a/Resources/Public/Icons/Flags/jm.webp b/Resources/Public/Icons/Flags/jm.webp new file mode 100644 index 0000000..d3be005 Binary files /dev/null and b/Resources/Public/Icons/Flags/jm.webp differ diff --git a/Resources/Public/Icons/Flags/jo.webp b/Resources/Public/Icons/Flags/jo.webp new file mode 100644 index 0000000..4f98b04 Binary files /dev/null and b/Resources/Public/Icons/Flags/jo.webp differ diff --git a/Resources/Public/Icons/Flags/jp.webp b/Resources/Public/Icons/Flags/jp.webp new file mode 100644 index 0000000..bc7cee5 Binary files /dev/null and b/Resources/Public/Icons/Flags/jp.webp differ diff --git a/Resources/Public/Icons/Flags/ke.webp b/Resources/Public/Icons/Flags/ke.webp new file mode 100644 index 0000000..b83edc0 Binary files /dev/null and b/Resources/Public/Icons/Flags/ke.webp differ diff --git a/Resources/Public/Icons/Flags/kg.webp b/Resources/Public/Icons/Flags/kg.webp new file mode 100644 index 0000000..9ae8189 Binary files /dev/null and b/Resources/Public/Icons/Flags/kg.webp differ diff --git a/Resources/Public/Icons/Flags/kh.webp b/Resources/Public/Icons/Flags/kh.webp new file mode 100644 index 0000000..b07e29b Binary files /dev/null and b/Resources/Public/Icons/Flags/kh.webp differ diff --git a/Resources/Public/Icons/Flags/ki.webp b/Resources/Public/Icons/Flags/ki.webp new file mode 100644 index 0000000..c8ad66e Binary files /dev/null and b/Resources/Public/Icons/Flags/ki.webp differ diff --git a/Resources/Public/Icons/Flags/km.webp b/Resources/Public/Icons/Flags/km.webp new file mode 100644 index 0000000..6c34cf6 Binary files /dev/null and b/Resources/Public/Icons/Flags/km.webp differ diff --git a/Resources/Public/Icons/Flags/kn.webp b/Resources/Public/Icons/Flags/kn.webp new file mode 100644 index 0000000..d60ed71 Binary files /dev/null and b/Resources/Public/Icons/Flags/kn.webp differ diff --git a/Resources/Public/Icons/Flags/kp.webp b/Resources/Public/Icons/Flags/kp.webp new file mode 100644 index 0000000..498c0d1 Binary files /dev/null and b/Resources/Public/Icons/Flags/kp.webp differ diff --git a/Resources/Public/Icons/Flags/kr.webp b/Resources/Public/Icons/Flags/kr.webp new file mode 100644 index 0000000..a78a451 Binary files /dev/null and b/Resources/Public/Icons/Flags/kr.webp differ diff --git a/Resources/Public/Icons/Flags/kw.webp b/Resources/Public/Icons/Flags/kw.webp new file mode 100644 index 0000000..2053d56 Binary files /dev/null and b/Resources/Public/Icons/Flags/kw.webp differ diff --git a/Resources/Public/Icons/Flags/ky.webp b/Resources/Public/Icons/Flags/ky.webp new file mode 100644 index 0000000..f417f95 Binary files /dev/null and b/Resources/Public/Icons/Flags/ky.webp differ diff --git a/Resources/Public/Icons/Flags/kz.webp b/Resources/Public/Icons/Flags/kz.webp new file mode 100644 index 0000000..9087c9d Binary files /dev/null and b/Resources/Public/Icons/Flags/kz.webp differ diff --git a/Resources/Public/Icons/Flags/la.webp b/Resources/Public/Icons/Flags/la.webp new file mode 100644 index 0000000..365f0cd Binary files /dev/null and b/Resources/Public/Icons/Flags/la.webp differ diff --git a/Resources/Public/Icons/Flags/lb.webp b/Resources/Public/Icons/Flags/lb.webp new file mode 100644 index 0000000..2d0bef3 Binary files /dev/null and b/Resources/Public/Icons/Flags/lb.webp differ diff --git a/Resources/Public/Icons/Flags/lc.webp b/Resources/Public/Icons/Flags/lc.webp new file mode 100644 index 0000000..07fbd11 Binary files /dev/null and b/Resources/Public/Icons/Flags/lc.webp differ diff --git a/Resources/Public/Icons/Flags/li.webp b/Resources/Public/Icons/Flags/li.webp new file mode 100644 index 0000000..dfc42a8 Binary files /dev/null and b/Resources/Public/Icons/Flags/li.webp differ diff --git a/Resources/Public/Icons/Flags/lk.webp b/Resources/Public/Icons/Flags/lk.webp new file mode 100644 index 0000000..5fa3046 Binary files /dev/null and b/Resources/Public/Icons/Flags/lk.webp differ diff --git a/Resources/Public/Icons/Flags/lr.webp b/Resources/Public/Icons/Flags/lr.webp new file mode 100644 index 0000000..e91fdf4 Binary files /dev/null and b/Resources/Public/Icons/Flags/lr.webp differ diff --git a/Resources/Public/Icons/Flags/ls.webp b/Resources/Public/Icons/Flags/ls.webp new file mode 100644 index 0000000..dd13b3d Binary files /dev/null and b/Resources/Public/Icons/Flags/ls.webp differ diff --git a/Resources/Public/Icons/Flags/lt.webp b/Resources/Public/Icons/Flags/lt.webp new file mode 100644 index 0000000..a4a03ab Binary files /dev/null and b/Resources/Public/Icons/Flags/lt.webp differ diff --git a/Resources/Public/Icons/Flags/lu.webp b/Resources/Public/Icons/Flags/lu.webp new file mode 100644 index 0000000..61876aa Binary files /dev/null and b/Resources/Public/Icons/Flags/lu.webp differ diff --git a/Resources/Public/Icons/Flags/lv.webp b/Resources/Public/Icons/Flags/lv.webp new file mode 100644 index 0000000..cf4e493 Binary files /dev/null and b/Resources/Public/Icons/Flags/lv.webp differ diff --git a/Resources/Public/Icons/Flags/ly.webp b/Resources/Public/Icons/Flags/ly.webp new file mode 100644 index 0000000..1080070 Binary files /dev/null and b/Resources/Public/Icons/Flags/ly.webp differ diff --git a/Resources/Public/Icons/Flags/ma.webp b/Resources/Public/Icons/Flags/ma.webp new file mode 100644 index 0000000..a4b3942 Binary files /dev/null and b/Resources/Public/Icons/Flags/ma.webp differ diff --git a/Resources/Public/Icons/Flags/mc.webp b/Resources/Public/Icons/Flags/mc.webp new file mode 100644 index 0000000..8e41600 Binary files /dev/null and b/Resources/Public/Icons/Flags/mc.webp differ diff --git a/Resources/Public/Icons/Flags/md.webp b/Resources/Public/Icons/Flags/md.webp new file mode 100644 index 0000000..9a326c7 Binary files /dev/null and b/Resources/Public/Icons/Flags/md.webp differ diff --git a/Resources/Public/Icons/Flags/me.webp b/Resources/Public/Icons/Flags/me.webp new file mode 100644 index 0000000..edc70b2 Binary files /dev/null and b/Resources/Public/Icons/Flags/me.webp differ diff --git a/Resources/Public/Icons/Flags/mf.webp b/Resources/Public/Icons/Flags/mf.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/mf.webp differ diff --git a/Resources/Public/Icons/Flags/mg.webp b/Resources/Public/Icons/Flags/mg.webp new file mode 100644 index 0000000..8e68132 Binary files /dev/null and b/Resources/Public/Icons/Flags/mg.webp differ diff --git a/Resources/Public/Icons/Flags/mh.webp b/Resources/Public/Icons/Flags/mh.webp new file mode 100644 index 0000000..d8646ad Binary files /dev/null and b/Resources/Public/Icons/Flags/mh.webp differ diff --git a/Resources/Public/Icons/Flags/mk.webp b/Resources/Public/Icons/Flags/mk.webp new file mode 100644 index 0000000..f1d808f Binary files /dev/null and b/Resources/Public/Icons/Flags/mk.webp differ diff --git a/Resources/Public/Icons/Flags/ml.webp b/Resources/Public/Icons/Flags/ml.webp new file mode 100644 index 0000000..0bc6eae Binary files /dev/null and b/Resources/Public/Icons/Flags/ml.webp differ diff --git a/Resources/Public/Icons/Flags/mm.webp b/Resources/Public/Icons/Flags/mm.webp new file mode 100644 index 0000000..72a2122 Binary files /dev/null and b/Resources/Public/Icons/Flags/mm.webp differ diff --git a/Resources/Public/Icons/Flags/mn.webp b/Resources/Public/Icons/Flags/mn.webp new file mode 100644 index 0000000..3a8720f Binary files /dev/null and b/Resources/Public/Icons/Flags/mn.webp differ diff --git a/Resources/Public/Icons/Flags/mo.webp b/Resources/Public/Icons/Flags/mo.webp new file mode 100644 index 0000000..2111c78 Binary files /dev/null and b/Resources/Public/Icons/Flags/mo.webp differ diff --git a/Resources/Public/Icons/Flags/mp.webp b/Resources/Public/Icons/Flags/mp.webp new file mode 100644 index 0000000..f28651f Binary files /dev/null and b/Resources/Public/Icons/Flags/mp.webp differ diff --git a/Resources/Public/Icons/Flags/mq.webp b/Resources/Public/Icons/Flags/mq.webp new file mode 100644 index 0000000..9ef9514 Binary files /dev/null and b/Resources/Public/Icons/Flags/mq.webp differ diff --git a/Resources/Public/Icons/Flags/mr.webp b/Resources/Public/Icons/Flags/mr.webp new file mode 100644 index 0000000..03f5f99 Binary files /dev/null and b/Resources/Public/Icons/Flags/mr.webp differ diff --git a/Resources/Public/Icons/Flags/ms.webp b/Resources/Public/Icons/Flags/ms.webp new file mode 100644 index 0000000..963c2ed Binary files /dev/null and b/Resources/Public/Icons/Flags/ms.webp differ diff --git a/Resources/Public/Icons/Flags/mt.webp b/Resources/Public/Icons/Flags/mt.webp new file mode 100644 index 0000000..03ed5e1 Binary files /dev/null and b/Resources/Public/Icons/Flags/mt.webp differ diff --git a/Resources/Public/Icons/Flags/mu.webp b/Resources/Public/Icons/Flags/mu.webp new file mode 100644 index 0000000..8d1595a Binary files /dev/null and b/Resources/Public/Icons/Flags/mu.webp differ diff --git a/Resources/Public/Icons/Flags/multiple.webp b/Resources/Public/Icons/Flags/multiple.webp new file mode 100644 index 0000000..99d1100 Binary files /dev/null and b/Resources/Public/Icons/Flags/multiple.webp differ diff --git a/Resources/Public/Icons/Flags/mv.webp b/Resources/Public/Icons/Flags/mv.webp new file mode 100644 index 0000000..874bab0 Binary files /dev/null and b/Resources/Public/Icons/Flags/mv.webp differ diff --git a/Resources/Public/Icons/Flags/mw.webp b/Resources/Public/Icons/Flags/mw.webp new file mode 100644 index 0000000..6226bf6 Binary files /dev/null and b/Resources/Public/Icons/Flags/mw.webp differ diff --git a/Resources/Public/Icons/Flags/mx.webp b/Resources/Public/Icons/Flags/mx.webp new file mode 100644 index 0000000..0beac34 Binary files /dev/null and b/Resources/Public/Icons/Flags/mx.webp differ diff --git a/Resources/Public/Icons/Flags/my.webp b/Resources/Public/Icons/Flags/my.webp new file mode 100644 index 0000000..b430b0b Binary files /dev/null and b/Resources/Public/Icons/Flags/my.webp differ diff --git a/Resources/Public/Icons/Flags/mz.webp b/Resources/Public/Icons/Flags/mz.webp new file mode 100644 index 0000000..0d42eee Binary files /dev/null and b/Resources/Public/Icons/Flags/mz.webp differ diff --git a/Resources/Public/Icons/Flags/na.webp b/Resources/Public/Icons/Flags/na.webp new file mode 100644 index 0000000..2b3f305 Binary files /dev/null and b/Resources/Public/Icons/Flags/na.webp differ diff --git a/Resources/Public/Icons/Flags/nc.webp b/Resources/Public/Icons/Flags/nc.webp new file mode 100644 index 0000000..e2b3744 Binary files /dev/null and b/Resources/Public/Icons/Flags/nc.webp differ diff --git a/Resources/Public/Icons/Flags/ne.webp b/Resources/Public/Icons/Flags/ne.webp new file mode 100644 index 0000000..b1d40e8 Binary files /dev/null and b/Resources/Public/Icons/Flags/ne.webp differ diff --git a/Resources/Public/Icons/Flags/nf.webp b/Resources/Public/Icons/Flags/nf.webp new file mode 100644 index 0000000..d2a6799 Binary files /dev/null and b/Resources/Public/Icons/Flags/nf.webp differ diff --git a/Resources/Public/Icons/Flags/ng.webp b/Resources/Public/Icons/Flags/ng.webp new file mode 100644 index 0000000..ab5e146 Binary files /dev/null and b/Resources/Public/Icons/Flags/ng.webp differ diff --git a/Resources/Public/Icons/Flags/ni.webp b/Resources/Public/Icons/Flags/ni.webp new file mode 100644 index 0000000..b7301bd Binary files /dev/null and b/Resources/Public/Icons/Flags/ni.webp differ diff --git a/Resources/Public/Icons/Flags/nl.webp b/Resources/Public/Icons/Flags/nl.webp new file mode 100644 index 0000000..34f1560 Binary files /dev/null and b/Resources/Public/Icons/Flags/nl.webp differ diff --git a/Resources/Public/Icons/Flags/no.webp b/Resources/Public/Icons/Flags/no.webp new file mode 100644 index 0000000..99b71af Binary files /dev/null and b/Resources/Public/Icons/Flags/no.webp differ diff --git a/Resources/Public/Icons/Flags/np.webp b/Resources/Public/Icons/Flags/np.webp new file mode 100644 index 0000000..e79ca58 Binary files /dev/null and b/Resources/Public/Icons/Flags/np.webp differ diff --git a/Resources/Public/Icons/Flags/nr.webp b/Resources/Public/Icons/Flags/nr.webp new file mode 100644 index 0000000..8cda530 Binary files /dev/null and b/Resources/Public/Icons/Flags/nr.webp differ diff --git a/Resources/Public/Icons/Flags/nu.webp b/Resources/Public/Icons/Flags/nu.webp new file mode 100644 index 0000000..6e6e892 Binary files /dev/null and b/Resources/Public/Icons/Flags/nu.webp differ diff --git a/Resources/Public/Icons/Flags/nz.webp b/Resources/Public/Icons/Flags/nz.webp new file mode 100644 index 0000000..d2e20b2 Binary files /dev/null and b/Resources/Public/Icons/Flags/nz.webp differ diff --git a/Resources/Public/Icons/Flags/om.webp b/Resources/Public/Icons/Flags/om.webp new file mode 100644 index 0000000..7ca34e5 Binary files /dev/null and b/Resources/Public/Icons/Flags/om.webp differ diff --git a/Resources/Public/Icons/Flags/orange.webp b/Resources/Public/Icons/Flags/orange.webp new file mode 100644 index 0000000..034bd22 Binary files /dev/null and b/Resources/Public/Icons/Flags/orange.webp differ diff --git a/Resources/Public/Icons/Flags/pa.webp b/Resources/Public/Icons/Flags/pa.webp new file mode 100644 index 0000000..238e341 Binary files /dev/null and b/Resources/Public/Icons/Flags/pa.webp differ diff --git a/Resources/Public/Icons/Flags/pc.webp b/Resources/Public/Icons/Flags/pc.webp new file mode 100644 index 0000000..1a2a36c Binary files /dev/null and b/Resources/Public/Icons/Flags/pc.webp differ diff --git a/Resources/Public/Icons/Flags/pe.webp b/Resources/Public/Icons/Flags/pe.webp new file mode 100644 index 0000000..8c7963a Binary files /dev/null and b/Resources/Public/Icons/Flags/pe.webp differ diff --git a/Resources/Public/Icons/Flags/pf.webp b/Resources/Public/Icons/Flags/pf.webp new file mode 100644 index 0000000..5088eb8 Binary files /dev/null and b/Resources/Public/Icons/Flags/pf.webp differ diff --git a/Resources/Public/Icons/Flags/pg.webp b/Resources/Public/Icons/Flags/pg.webp new file mode 100644 index 0000000..2d17192 Binary files /dev/null and b/Resources/Public/Icons/Flags/pg.webp differ diff --git a/Resources/Public/Icons/Flags/ph.webp b/Resources/Public/Icons/Flags/ph.webp new file mode 100644 index 0000000..990df2e Binary files /dev/null and b/Resources/Public/Icons/Flags/ph.webp differ diff --git a/Resources/Public/Icons/Flags/pink.webp b/Resources/Public/Icons/Flags/pink.webp new file mode 100644 index 0000000..c2b3c68 Binary files /dev/null and b/Resources/Public/Icons/Flags/pink.webp differ diff --git a/Resources/Public/Icons/Flags/pk.webp b/Resources/Public/Icons/Flags/pk.webp new file mode 100644 index 0000000..d45a800 Binary files /dev/null and b/Resources/Public/Icons/Flags/pk.webp differ diff --git a/Resources/Public/Icons/Flags/pl.webp b/Resources/Public/Icons/Flags/pl.webp new file mode 100644 index 0000000..57cb99f Binary files /dev/null and b/Resources/Public/Icons/Flags/pl.webp differ diff --git a/Resources/Public/Icons/Flags/pm.webp b/Resources/Public/Icons/Flags/pm.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/pm.webp differ diff --git a/Resources/Public/Icons/Flags/pn.webp b/Resources/Public/Icons/Flags/pn.webp new file mode 100644 index 0000000..bf11c74 Binary files /dev/null and b/Resources/Public/Icons/Flags/pn.webp differ diff --git a/Resources/Public/Icons/Flags/pr.webp b/Resources/Public/Icons/Flags/pr.webp new file mode 100644 index 0000000..8e497b9 Binary files /dev/null and b/Resources/Public/Icons/Flags/pr.webp differ diff --git a/Resources/Public/Icons/Flags/ps.webp b/Resources/Public/Icons/Flags/ps.webp new file mode 100644 index 0000000..4ef4935 Binary files /dev/null and b/Resources/Public/Icons/Flags/ps.webp differ diff --git a/Resources/Public/Icons/Flags/pt.webp b/Resources/Public/Icons/Flags/pt.webp new file mode 100644 index 0000000..9f1c239 Binary files /dev/null and b/Resources/Public/Icons/Flags/pt.webp differ diff --git a/Resources/Public/Icons/Flags/purple.webp b/Resources/Public/Icons/Flags/purple.webp new file mode 100644 index 0000000..11009b2 Binary files /dev/null and b/Resources/Public/Icons/Flags/purple.webp differ diff --git a/Resources/Public/Icons/Flags/pw.webp b/Resources/Public/Icons/Flags/pw.webp new file mode 100644 index 0000000..2057c26 Binary files /dev/null and b/Resources/Public/Icons/Flags/pw.webp differ diff --git a/Resources/Public/Icons/Flags/py.webp b/Resources/Public/Icons/Flags/py.webp new file mode 100644 index 0000000..055b3bd Binary files /dev/null and b/Resources/Public/Icons/Flags/py.webp differ diff --git a/Resources/Public/Icons/Flags/qa.webp b/Resources/Public/Icons/Flags/qa.webp new file mode 100644 index 0000000..8d4f47f Binary files /dev/null and b/Resources/Public/Icons/Flags/qa.webp differ diff --git a/Resources/Public/Icons/Flags/rainbow.webp b/Resources/Public/Icons/Flags/rainbow.webp new file mode 100644 index 0000000..7cfc593 Binary files /dev/null and b/Resources/Public/Icons/Flags/rainbow.webp differ diff --git a/Resources/Public/Icons/Flags/re.webp b/Resources/Public/Icons/Flags/re.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/re.webp differ diff --git a/Resources/Public/Icons/Flags/red.webp b/Resources/Public/Icons/Flags/red.webp new file mode 100644 index 0000000..7753dfd Binary files /dev/null and b/Resources/Public/Icons/Flags/red.webp differ diff --git a/Resources/Public/Icons/Flags/ro.webp b/Resources/Public/Icons/Flags/ro.webp new file mode 100644 index 0000000..29d39aa Binary files /dev/null and b/Resources/Public/Icons/Flags/ro.webp differ diff --git a/Resources/Public/Icons/Flags/rs.webp b/Resources/Public/Icons/Flags/rs.webp new file mode 100644 index 0000000..124ec85 Binary files /dev/null and b/Resources/Public/Icons/Flags/rs.webp differ diff --git a/Resources/Public/Icons/Flags/ru.webp b/Resources/Public/Icons/Flags/ru.webp new file mode 100644 index 0000000..206a25a Binary files /dev/null and b/Resources/Public/Icons/Flags/ru.webp differ diff --git a/Resources/Public/Icons/Flags/rw.webp b/Resources/Public/Icons/Flags/rw.webp new file mode 100644 index 0000000..3154dce Binary files /dev/null and b/Resources/Public/Icons/Flags/rw.webp differ diff --git a/Resources/Public/Icons/Flags/sa.webp b/Resources/Public/Icons/Flags/sa.webp new file mode 100644 index 0000000..0fc8148 Binary files /dev/null and b/Resources/Public/Icons/Flags/sa.webp differ diff --git a/Resources/Public/Icons/Flags/sb.webp b/Resources/Public/Icons/Flags/sb.webp new file mode 100644 index 0000000..66c7ae0 Binary files /dev/null and b/Resources/Public/Icons/Flags/sb.webp differ diff --git a/Resources/Public/Icons/Flags/sc.webp b/Resources/Public/Icons/Flags/sc.webp new file mode 100644 index 0000000..5648547 Binary files /dev/null and b/Resources/Public/Icons/Flags/sc.webp differ diff --git a/Resources/Public/Icons/Flags/sd.webp b/Resources/Public/Icons/Flags/sd.webp new file mode 100644 index 0000000..e73355b Binary files /dev/null and b/Resources/Public/Icons/Flags/sd.webp differ diff --git a/Resources/Public/Icons/Flags/se.webp b/Resources/Public/Icons/Flags/se.webp new file mode 100644 index 0000000..7bc80de Binary files /dev/null and b/Resources/Public/Icons/Flags/se.webp differ diff --git a/Resources/Public/Icons/Flags/sg.webp b/Resources/Public/Icons/Flags/sg.webp new file mode 100644 index 0000000..9853f28 Binary files /dev/null and b/Resources/Public/Icons/Flags/sg.webp differ diff --git a/Resources/Public/Icons/Flags/sh-ac.webp b/Resources/Public/Icons/Flags/sh-ac.webp new file mode 100644 index 0000000..bf89ce3 Binary files /dev/null and b/Resources/Public/Icons/Flags/sh-ac.webp differ diff --git a/Resources/Public/Icons/Flags/sh-hl.webp b/Resources/Public/Icons/Flags/sh-hl.webp new file mode 100644 index 0000000..4311374 Binary files /dev/null and b/Resources/Public/Icons/Flags/sh-hl.webp differ diff --git a/Resources/Public/Icons/Flags/sh-ta.webp b/Resources/Public/Icons/Flags/sh-ta.webp new file mode 100644 index 0000000..840ed7c Binary files /dev/null and b/Resources/Public/Icons/Flags/sh-ta.webp differ diff --git a/Resources/Public/Icons/Flags/sh.webp b/Resources/Public/Icons/Flags/sh.webp new file mode 100644 index 0000000..dbff4ba Binary files /dev/null and b/Resources/Public/Icons/Flags/sh.webp differ diff --git a/Resources/Public/Icons/Flags/si.webp b/Resources/Public/Icons/Flags/si.webp new file mode 100644 index 0000000..cad661a Binary files /dev/null and b/Resources/Public/Icons/Flags/si.webp differ diff --git a/Resources/Public/Icons/Flags/sj.webp b/Resources/Public/Icons/Flags/sj.webp new file mode 100644 index 0000000..dee6c6b Binary files /dev/null and b/Resources/Public/Icons/Flags/sj.webp differ diff --git a/Resources/Public/Icons/Flags/sk.webp b/Resources/Public/Icons/Flags/sk.webp new file mode 100644 index 0000000..ff7ed27 Binary files /dev/null and b/Resources/Public/Icons/Flags/sk.webp differ diff --git a/Resources/Public/Icons/Flags/sl.webp b/Resources/Public/Icons/Flags/sl.webp new file mode 100644 index 0000000..1cfa7ab Binary files /dev/null and b/Resources/Public/Icons/Flags/sl.webp differ diff --git a/Resources/Public/Icons/Flags/sm.webp b/Resources/Public/Icons/Flags/sm.webp new file mode 100644 index 0000000..23a88d7 Binary files /dev/null and b/Resources/Public/Icons/Flags/sm.webp differ diff --git a/Resources/Public/Icons/Flags/sn.webp b/Resources/Public/Icons/Flags/sn.webp new file mode 100644 index 0000000..d627a32 Binary files /dev/null and b/Resources/Public/Icons/Flags/sn.webp differ diff --git a/Resources/Public/Icons/Flags/so.webp b/Resources/Public/Icons/Flags/so.webp new file mode 100644 index 0000000..e4a6a85 Binary files /dev/null and b/Resources/Public/Icons/Flags/so.webp differ diff --git a/Resources/Public/Icons/Flags/sr.webp b/Resources/Public/Icons/Flags/sr.webp new file mode 100644 index 0000000..2356b2b Binary files /dev/null and b/Resources/Public/Icons/Flags/sr.webp differ diff --git a/Resources/Public/Icons/Flags/ss.webp b/Resources/Public/Icons/Flags/ss.webp new file mode 100644 index 0000000..07acb86 Binary files /dev/null and b/Resources/Public/Icons/Flags/ss.webp differ diff --git a/Resources/Public/Icons/Flags/st.webp b/Resources/Public/Icons/Flags/st.webp new file mode 100644 index 0000000..d1f5f8a Binary files /dev/null and b/Resources/Public/Icons/Flags/st.webp differ diff --git a/Resources/Public/Icons/Flags/sv.webp b/Resources/Public/Icons/Flags/sv.webp new file mode 100644 index 0000000..287d644 Binary files /dev/null and b/Resources/Public/Icons/Flags/sv.webp differ diff --git a/Resources/Public/Icons/Flags/sx.webp b/Resources/Public/Icons/Flags/sx.webp new file mode 100644 index 0000000..141e4a4 Binary files /dev/null and b/Resources/Public/Icons/Flags/sx.webp differ diff --git a/Resources/Public/Icons/Flags/sy.webp b/Resources/Public/Icons/Flags/sy.webp new file mode 100644 index 0000000..1a51c0e Binary files /dev/null and b/Resources/Public/Icons/Flags/sy.webp differ diff --git a/Resources/Public/Icons/Flags/sz.webp b/Resources/Public/Icons/Flags/sz.webp new file mode 100644 index 0000000..b924ed5 Binary files /dev/null and b/Resources/Public/Icons/Flags/sz.webp differ diff --git a/Resources/Public/Icons/Flags/tc.webp b/Resources/Public/Icons/Flags/tc.webp new file mode 100644 index 0000000..02f0cc3 Binary files /dev/null and b/Resources/Public/Icons/Flags/tc.webp differ diff --git a/Resources/Public/Icons/Flags/td.webp b/Resources/Public/Icons/Flags/td.webp new file mode 100644 index 0000000..430adee Binary files /dev/null and b/Resources/Public/Icons/Flags/td.webp differ diff --git a/Resources/Public/Icons/Flags/teal.webp b/Resources/Public/Icons/Flags/teal.webp new file mode 100644 index 0000000..a5985da Binary files /dev/null and b/Resources/Public/Icons/Flags/teal.webp differ diff --git a/Resources/Public/Icons/Flags/tf.webp b/Resources/Public/Icons/Flags/tf.webp new file mode 100644 index 0000000..fea7c01 Binary files /dev/null and b/Resources/Public/Icons/Flags/tf.webp differ diff --git a/Resources/Public/Icons/Flags/tg.webp b/Resources/Public/Icons/Flags/tg.webp new file mode 100644 index 0000000..88fa77d Binary files /dev/null and b/Resources/Public/Icons/Flags/tg.webp differ diff --git a/Resources/Public/Icons/Flags/th.webp b/Resources/Public/Icons/Flags/th.webp new file mode 100644 index 0000000..232bfbc Binary files /dev/null and b/Resources/Public/Icons/Flags/th.webp differ diff --git a/Resources/Public/Icons/Flags/tj.webp b/Resources/Public/Icons/Flags/tj.webp new file mode 100644 index 0000000..b8a01ee Binary files /dev/null and b/Resources/Public/Icons/Flags/tj.webp differ diff --git a/Resources/Public/Icons/Flags/tk.webp b/Resources/Public/Icons/Flags/tk.webp new file mode 100644 index 0000000..3294a0f Binary files /dev/null and b/Resources/Public/Icons/Flags/tk.webp differ diff --git a/Resources/Public/Icons/Flags/tl.webp b/Resources/Public/Icons/Flags/tl.webp new file mode 100644 index 0000000..4d0c01a Binary files /dev/null and b/Resources/Public/Icons/Flags/tl.webp differ diff --git a/Resources/Public/Icons/Flags/tm.webp b/Resources/Public/Icons/Flags/tm.webp new file mode 100644 index 0000000..3aeeafd Binary files /dev/null and b/Resources/Public/Icons/Flags/tm.webp differ diff --git a/Resources/Public/Icons/Flags/tn.webp b/Resources/Public/Icons/Flags/tn.webp new file mode 100644 index 0000000..097e6fc Binary files /dev/null and b/Resources/Public/Icons/Flags/tn.webp differ diff --git a/Resources/Public/Icons/Flags/to.webp b/Resources/Public/Icons/Flags/to.webp new file mode 100644 index 0000000..7864ac1 Binary files /dev/null and b/Resources/Public/Icons/Flags/to.webp differ diff --git a/Resources/Public/Icons/Flags/tr.webp b/Resources/Public/Icons/Flags/tr.webp new file mode 100644 index 0000000..d4e08e0 Binary files /dev/null and b/Resources/Public/Icons/Flags/tr.webp differ diff --git a/Resources/Public/Icons/Flags/tt.webp b/Resources/Public/Icons/Flags/tt.webp new file mode 100644 index 0000000..a90aee4 Binary files /dev/null and b/Resources/Public/Icons/Flags/tt.webp differ diff --git a/Resources/Public/Icons/Flags/tv.webp b/Resources/Public/Icons/Flags/tv.webp new file mode 100644 index 0000000..612c9b8 Binary files /dev/null and b/Resources/Public/Icons/Flags/tv.webp differ diff --git a/Resources/Public/Icons/Flags/tw.webp b/Resources/Public/Icons/Flags/tw.webp new file mode 100644 index 0000000..5804dbd Binary files /dev/null and b/Resources/Public/Icons/Flags/tw.webp differ diff --git a/Resources/Public/Icons/Flags/tz.webp b/Resources/Public/Icons/Flags/tz.webp new file mode 100644 index 0000000..9a3c0d3 Binary files /dev/null and b/Resources/Public/Icons/Flags/tz.webp differ diff --git a/Resources/Public/Icons/Flags/ua.webp b/Resources/Public/Icons/Flags/ua.webp new file mode 100644 index 0000000..a4fd8c2 Binary files /dev/null and b/Resources/Public/Icons/Flags/ua.webp differ diff --git a/Resources/Public/Icons/Flags/ug.webp b/Resources/Public/Icons/Flags/ug.webp new file mode 100644 index 0000000..c71b306 Binary files /dev/null and b/Resources/Public/Icons/Flags/ug.webp differ diff --git a/Resources/Public/Icons/Flags/us.webp b/Resources/Public/Icons/Flags/us.webp new file mode 100644 index 0000000..f4a67c3 Binary files /dev/null and b/Resources/Public/Icons/Flags/us.webp differ diff --git a/Resources/Public/Icons/Flags/uy.webp b/Resources/Public/Icons/Flags/uy.webp new file mode 100644 index 0000000..3aed10a Binary files /dev/null and b/Resources/Public/Icons/Flags/uy.webp differ diff --git a/Resources/Public/Icons/Flags/uz.webp b/Resources/Public/Icons/Flags/uz.webp new file mode 100644 index 0000000..e46a59c Binary files /dev/null and b/Resources/Public/Icons/Flags/uz.webp differ diff --git a/Resources/Public/Icons/Flags/va.webp b/Resources/Public/Icons/Flags/va.webp new file mode 100644 index 0000000..897c499 Binary files /dev/null and b/Resources/Public/Icons/Flags/va.webp differ diff --git a/Resources/Public/Icons/Flags/vc.webp b/Resources/Public/Icons/Flags/vc.webp new file mode 100644 index 0000000..923eab9 Binary files /dev/null and b/Resources/Public/Icons/Flags/vc.webp differ diff --git a/Resources/Public/Icons/Flags/ve.webp b/Resources/Public/Icons/Flags/ve.webp new file mode 100644 index 0000000..df674f5 Binary files /dev/null and b/Resources/Public/Icons/Flags/ve.webp differ diff --git a/Resources/Public/Icons/Flags/vg.webp b/Resources/Public/Icons/Flags/vg.webp new file mode 100644 index 0000000..35d9a78 Binary files /dev/null and b/Resources/Public/Icons/Flags/vg.webp differ diff --git a/Resources/Public/Icons/Flags/vi.webp b/Resources/Public/Icons/Flags/vi.webp new file mode 100644 index 0000000..b051295 Binary files /dev/null and b/Resources/Public/Icons/Flags/vi.webp differ diff --git a/Resources/Public/Icons/Flags/vn.webp b/Resources/Public/Icons/Flags/vn.webp new file mode 100644 index 0000000..fe90974 Binary files /dev/null and b/Resources/Public/Icons/Flags/vn.webp differ diff --git a/Resources/Public/Icons/Flags/vu.webp b/Resources/Public/Icons/Flags/vu.webp new file mode 100644 index 0000000..4dea8fa Binary files /dev/null and b/Resources/Public/Icons/Flags/vu.webp differ diff --git a/Resources/Public/Icons/Flags/wf.webp b/Resources/Public/Icons/Flags/wf.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/wf.webp differ diff --git a/Resources/Public/Icons/Flags/white.webp b/Resources/Public/Icons/Flags/white.webp new file mode 100644 index 0000000..623af6a Binary files /dev/null and b/Resources/Public/Icons/Flags/white.webp differ diff --git a/Resources/Public/Icons/Flags/ws.webp b/Resources/Public/Icons/Flags/ws.webp new file mode 100644 index 0000000..7c01e5d Binary files /dev/null and b/Resources/Public/Icons/Flags/ws.webp differ diff --git a/Resources/Public/Icons/Flags/xk.webp b/Resources/Public/Icons/Flags/xk.webp new file mode 100644 index 0000000..99092bd Binary files /dev/null and b/Resources/Public/Icons/Flags/xk.webp differ diff --git a/Resources/Public/Icons/Flags/ye.webp b/Resources/Public/Icons/Flags/ye.webp new file mode 100644 index 0000000..4c0c701 Binary files /dev/null and b/Resources/Public/Icons/Flags/ye.webp differ diff --git a/Resources/Public/Icons/Flags/yellow.webp b/Resources/Public/Icons/Flags/yellow.webp new file mode 100644 index 0000000..99a0d95 Binary files /dev/null and b/Resources/Public/Icons/Flags/yellow.webp differ diff --git a/Resources/Public/Icons/Flags/yt.webp b/Resources/Public/Icons/Flags/yt.webp new file mode 100644 index 0000000..0ca3e63 Binary files /dev/null and b/Resources/Public/Icons/Flags/yt.webp differ diff --git a/Resources/Public/Icons/Flags/za.webp b/Resources/Public/Icons/Flags/za.webp new file mode 100644 index 0000000..349f560 Binary files /dev/null and b/Resources/Public/Icons/Flags/za.webp differ diff --git a/Resources/Public/Icons/Flags/zm.webp b/Resources/Public/Icons/Flags/zm.webp new file mode 100644 index 0000000..1b0d2c2 Binary files /dev/null and b/Resources/Public/Icons/Flags/zm.webp differ diff --git a/Resources/Public/Icons/Flags/zw.webp b/Resources/Public/Icons/Flags/zw.webp new file mode 100644 index 0000000..7e6328f Binary files /dev/null and b/Resources/Public/Icons/Flags/zw.webp differ diff --git a/Resources/Public/Icons/T3Icons/icons.json b/Resources/Public/Icons/T3Icons/icons.json new file mode 100644 index 0000000..34808da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/icons.json @@ -0,0 +1,5789 @@ +{ + "icons": { + "actions-accessibility": { + "identifier": "actions-accessibility", + "category": "actions", + "svg": "svgs/actions/actions-accessibility.svg", + "sprite": "sprites/actions.svg#actions-accessibility", + "bidi": false + }, + "actions-approve": { + "identifier": "actions-approve", + "category": "actions", + "svg": "svgs/actions/actions-approve.svg", + "sprite": "sprites/actions.svg#actions-approve", + "bidi": false + }, + "actions-archive": { + "identifier": "actions-archive", + "category": "actions", + "svg": "svgs/actions/actions-archive.svg", + "sprite": "sprites/actions.svg#actions-archive", + "bidi": false + }, + "actions-arrow-down-alt": { + "identifier": "actions-arrow-down-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-alt", + "bidi": false + }, + "actions-arrow-down-end-alt": { + "identifier": "actions-arrow-down-end-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-end-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-end-alt", + "bidi": true + }, + "actions-arrow-down-end": { + "identifier": "actions-arrow-down-end", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-end.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-end", + "bidi": true + }, + "actions-arrow-down-left-alt": { + "identifier": "actions-arrow-down-left-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-left-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-left-alt", + "bidi": false + }, + "actions-arrow-down-left": { + "identifier": "actions-arrow-down-left", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-left.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-left", + "bidi": false + }, + "actions-arrow-down-right-alt": { + "identifier": "actions-arrow-down-right-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-right-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-right-alt", + "bidi": false + }, + "actions-arrow-down-right": { + "identifier": "actions-arrow-down-right", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-right.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-right", + "bidi": false + }, + "actions-arrow-down-start-alt": { + "identifier": "actions-arrow-down-start-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-start-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-start-alt", + "bidi": true + }, + "actions-arrow-down-start": { + "identifier": "actions-arrow-down-start", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down-start.svg", + "sprite": "sprites/actions.svg#actions-arrow-down-start", + "bidi": true + }, + "actions-arrow-down": { + "identifier": "actions-arrow-down", + "category": "actions", + "svg": "svgs/actions/actions-arrow-down.svg", + "sprite": "sprites/actions.svg#actions-arrow-down", + "bidi": false + }, + "actions-arrow-end-alt": { + "identifier": "actions-arrow-end-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-end-alt", + "bidi": true + }, + "actions-arrow-end-down-alt": { + "identifier": "actions-arrow-end-down-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end-down-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-end-down-alt", + "bidi": true + }, + "actions-arrow-end-down": { + "identifier": "actions-arrow-end-down", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end-down.svg", + "sprite": "sprites/actions.svg#actions-arrow-end-down", + "bidi": true + }, + "actions-arrow-end-up-alt": { + "identifier": "actions-arrow-end-up-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end-up-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-end-up-alt", + "bidi": true + }, + "actions-arrow-end-up": { + "identifier": "actions-arrow-end-up", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end-up.svg", + "sprite": "sprites/actions.svg#actions-arrow-end-up", + "bidi": true + }, + "actions-arrow-end": { + "identifier": "actions-arrow-end", + "category": "actions", + "svg": "svgs/actions/actions-arrow-end.svg", + "sprite": "sprites/actions.svg#actions-arrow-end", + "bidi": true + }, + "actions-arrow-left-alt": { + "identifier": "actions-arrow-left-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-left-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-left-alt", + "bidi": false + }, + "actions-arrow-left": { + "identifier": "actions-arrow-left", + "category": "actions", + "svg": "svgs/actions/actions-arrow-left.svg", + "sprite": "sprites/actions.svg#actions-arrow-left", + "bidi": false + }, + "actions-arrow-right-alt": { + "identifier": "actions-arrow-right-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-right-alt", + "bidi": false + }, + "actions-arrow-right-down-alt": { + "identifier": "actions-arrow-right-down-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right-down-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-right-down-alt", + "bidi": false + }, + "actions-arrow-right-down": { + "identifier": "actions-arrow-right-down", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right-down.svg", + "sprite": "sprites/actions.svg#actions-arrow-right-down", + "bidi": false + }, + "actions-arrow-right-up-alt": { + "identifier": "actions-arrow-right-up-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right-up-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-right-up-alt", + "bidi": false + }, + "actions-arrow-right-up": { + "identifier": "actions-arrow-right-up", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right-up.svg", + "sprite": "sprites/actions.svg#actions-arrow-right-up", + "bidi": false + }, + "actions-arrow-right": { + "identifier": "actions-arrow-right", + "category": "actions", + "svg": "svgs/actions/actions-arrow-right.svg", + "sprite": "sprites/actions.svg#actions-arrow-right", + "bidi": false + }, + "actions-arrow-start-alt": { + "identifier": "actions-arrow-start-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-start-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-start-alt", + "bidi": true + }, + "actions-arrow-start": { + "identifier": "actions-arrow-start", + "category": "actions", + "svg": "svgs/actions/actions-arrow-start.svg", + "sprite": "sprites/actions.svg#actions-arrow-start", + "bidi": true + }, + "actions-arrow-up-alt": { + "identifier": "actions-arrow-up-alt", + "category": "actions", + "svg": "svgs/actions/actions-arrow-up-alt.svg", + "sprite": "sprites/actions.svg#actions-arrow-up-alt", + "bidi": false + }, + "actions-arrow-up": { + "identifier": "actions-arrow-up", + "category": "actions", + "svg": "svgs/actions/actions-arrow-up.svg", + "sprite": "sprites/actions.svg#actions-arrow-up", + "bidi": false + }, + "actions-aspect-ratio": { + "identifier": "actions-aspect-ratio", + "category": "actions", + "svg": "svgs/actions/actions-aspect-ratio.svg", + "sprite": "sprites/actions.svg#actions-aspect-ratio", + "bidi": false + }, + "actions-badge": { + "identifier": "actions-badge", + "category": "actions", + "svg": "svgs/actions/actions-badge.svg", + "sprite": "sprites/actions.svg#actions-badge", + "bidi": false + }, + "actions-ban": { + "identifier": "actions-ban", + "category": "actions", + "svg": "svgs/actions/actions-ban.svg", + "sprite": "sprites/actions.svg#actions-ban", + "bidi": false + }, + "actions-barcode-read": { + "identifier": "actions-barcode-read", + "category": "actions", + "svg": "svgs/actions/actions-barcode-read.svg", + "sprite": "sprites/actions.svg#actions-barcode-read", + "bidi": false + }, + "actions-barcode-scan": { + "identifier": "actions-barcode-scan", + "category": "actions", + "svg": "svgs/actions/actions-barcode-scan.svg", + "sprite": "sprites/actions.svg#actions-barcode-scan", + "bidi": false + }, + "actions-barcode": { + "identifier": "actions-barcode", + "category": "actions", + "svg": "svgs/actions/actions-barcode.svg", + "sprite": "sprites/actions.svg#actions-barcode", + "bidi": false + }, + "actions-bell-ring": { + "identifier": "actions-bell-ring", + "category": "actions", + "svg": "svgs/actions/actions-bell-ring.svg", + "sprite": "sprites/actions.svg#actions-bell-ring", + "bidi": false + }, + "actions-bell-slash": { + "identifier": "actions-bell-slash", + "category": "actions", + "svg": "svgs/actions/actions-bell-slash.svg", + "sprite": "sprites/actions.svg#actions-bell-slash", + "bidi": false + }, + "actions-bell": { + "identifier": "actions-bell", + "category": "actions", + "svg": "svgs/actions/actions-bell.svg", + "sprite": "sprites/actions.svg#actions-bell", + "bidi": false + }, + "actions-bolt-alt": { + "identifier": "actions-bolt-alt", + "category": "actions", + "svg": "svgs/actions/actions-bolt-alt.svg", + "sprite": "sprites/actions.svg#actions-bolt-alt", + "bidi": false + }, + "actions-bolt": { + "identifier": "actions-bolt", + "category": "actions", + "svg": "svgs/actions/actions-bolt.svg", + "sprite": "sprites/actions.svg#actions-bolt", + "bidi": false + }, + "actions-book": { + "identifier": "actions-book", + "category": "actions", + "svg": "svgs/actions/actions-book.svg", + "sprite": "sprites/actions.svg#actions-book", + "bidi": false + }, + "actions-bookmark-add": { + "identifier": "actions-bookmark-add", + "category": "actions", + "svg": "svgs/actions/actions-bookmark-add.svg", + "sprite": "sprites/actions.svg#actions-bookmark-add", + "bidi": false + }, + "actions-bookmark-remove": { + "identifier": "actions-bookmark-remove", + "category": "actions", + "svg": "svgs/actions/actions-bookmark-remove.svg", + "sprite": "sprites/actions.svg#actions-bookmark-remove", + "bidi": false + }, + "actions-bookmark": { + "identifier": "actions-bookmark", + "category": "actions", + "svg": "svgs/actions/actions-bookmark.svg", + "sprite": "sprites/actions.svg#actions-bookmark", + "bidi": false + }, + "actions-bookmarks": { + "identifier": "actions-bookmarks", + "category": "actions", + "svg": "svgs/actions/actions-bookmarks.svg", + "sprite": "sprites/actions.svg#actions-bookmarks", + "bidi": false + }, + "actions-brand-apple": { + "identifier": "actions-brand-apple", + "category": "actions", + "svg": "svgs/actions/actions-brand-apple.svg", + "sprite": "sprites/actions.svg#actions-brand-apple", + "bidi": false + }, + "actions-brand-bluesky": { + "identifier": "actions-brand-bluesky", + "category": "actions", + "svg": "svgs/actions/actions-brand-bluesky.svg", + "sprite": "sprites/actions.svg#actions-brand-bluesky", + "bidi": false + }, + "actions-brand-discord": { + "identifier": "actions-brand-discord", + "category": "actions", + "svg": "svgs/actions/actions-brand-discord.svg", + "sprite": "sprites/actions.svg#actions-brand-discord", + "bidi": false + }, + "actions-brand-facebook": { + "identifier": "actions-brand-facebook", + "category": "actions", + "svg": "svgs/actions/actions-brand-facebook.svg", + "sprite": "sprites/actions.svg#actions-brand-facebook", + "bidi": false + }, + "actions-brand-git": { + "identifier": "actions-brand-git", + "category": "actions", + "svg": "svgs/actions/actions-brand-git.svg", + "sprite": "sprites/actions.svg#actions-brand-git", + "bidi": false + }, + "actions-brand-github": { + "identifier": "actions-brand-github", + "category": "actions", + "svg": "svgs/actions/actions-brand-github.svg", + "sprite": "sprites/actions.svg#actions-brand-github", + "bidi": false + }, + "actions-brand-gitlab": { + "identifier": "actions-brand-gitlab", + "category": "actions", + "svg": "svgs/actions/actions-brand-gitlab.svg", + "sprite": "sprites/actions.svg#actions-brand-gitlab", + "bidi": false + }, + "actions-brand-google": { + "identifier": "actions-brand-google", + "category": "actions", + "svg": "svgs/actions/actions-brand-google.svg", + "sprite": "sprites/actions.svg#actions-brand-google", + "bidi": false + }, + "actions-brand-instagram": { + "identifier": "actions-brand-instagram", + "category": "actions", + "svg": "svgs/actions/actions-brand-instagram.svg", + "sprite": "sprites/actions.svg#actions-brand-instagram", + "bidi": false + }, + "actions-brand-linkedin": { + "identifier": "actions-brand-linkedin", + "category": "actions", + "svg": "svgs/actions/actions-brand-linkedin.svg", + "sprite": "sprites/actions.svg#actions-brand-linkedin", + "bidi": false + }, + "actions-brand-linux": { + "identifier": "actions-brand-linux", + "category": "actions", + "svg": "svgs/actions/actions-brand-linux.svg", + "sprite": "sprites/actions.svg#actions-brand-linux", + "bidi": false + }, + "actions-brand-mastodon": { + "identifier": "actions-brand-mastodon", + "category": "actions", + "svg": "svgs/actions/actions-brand-mastodon.svg", + "sprite": "sprites/actions.svg#actions-brand-mastodon", + "bidi": false + }, + "actions-brand-php": { + "identifier": "actions-brand-php", + "category": "actions", + "svg": "svgs/actions/actions-brand-php.svg", + "sprite": "sprites/actions.svg#actions-brand-php", + "bidi": false + }, + "actions-brand-slack": { + "identifier": "actions-brand-slack", + "category": "actions", + "svg": "svgs/actions/actions-brand-slack.svg", + "sprite": "sprites/actions.svg#actions-brand-slack", + "bidi": false + }, + "actions-brand-threads": { + "identifier": "actions-brand-threads", + "category": "actions", + "svg": "svgs/actions/actions-brand-threads.svg", + "sprite": "sprites/actions.svg#actions-brand-threads", + "bidi": false + }, + "actions-brand-typo3": { + "identifier": "actions-brand-typo3", + "category": "actions", + "svg": "svgs/actions/actions-brand-typo3.svg", + "sprite": "sprites/actions.svg#actions-brand-typo3", + "bidi": false + }, + "actions-brand-windows": { + "identifier": "actions-brand-windows", + "category": "actions", + "svg": "svgs/actions/actions-brand-windows.svg", + "sprite": "sprites/actions.svg#actions-brand-windows", + "bidi": false + }, + "actions-brand-x": { + "identifier": "actions-brand-x", + "category": "actions", + "svg": "svgs/actions/actions-brand-x.svg", + "sprite": "sprites/actions.svg#actions-brand-x", + "bidi": false + }, + "actions-brand-xing": { + "identifier": "actions-brand-xing", + "category": "actions", + "svg": "svgs/actions/actions-brand-xing.svg", + "sprite": "sprites/actions.svg#actions-brand-xing", + "bidi": false + }, + "actions-brand-youtube": { + "identifier": "actions-brand-youtube", + "category": "actions", + "svg": "svgs/actions/actions-brand-youtube.svg", + "sprite": "sprites/actions.svg#actions-brand-youtube", + "bidi": false + }, + "actions-briefcase": { + "identifier": "actions-briefcase", + "category": "actions", + "svg": "svgs/actions/actions-briefcase.svg", + "sprite": "sprites/actions.svg#actions-briefcase", + "bidi": false + }, + "actions-brightness-high": { + "identifier": "actions-brightness-high", + "category": "actions", + "svg": "svgs/actions/actions-brightness-high.svg", + "sprite": "sprites/actions.svg#actions-brightness-high", + "bidi": false + }, + "actions-brightness-low": { + "identifier": "actions-brightness-low", + "category": "actions", + "svg": "svgs/actions/actions-brightness-low.svg", + "sprite": "sprites/actions.svg#actions-brightness-low", + "bidi": false + }, + "actions-browser": { + "identifier": "actions-browser", + "category": "actions", + "svg": "svgs/actions/actions-browser.svg", + "sprite": "sprites/actions.svg#actions-browser", + "bidi": false + }, + "actions-brush": { + "identifier": "actions-brush", + "category": "actions", + "svg": "svgs/actions/actions-brush.svg", + "sprite": "sprites/actions.svg#actions-brush", + "bidi": false + }, + "actions-building": { + "identifier": "actions-building", + "category": "actions", + "svg": "svgs/actions/actions-building.svg", + "sprite": "sprites/actions.svg#actions-building", + "bidi": false + }, + "actions-bullhorn-slash": { + "identifier": "actions-bullhorn-slash", + "category": "actions", + "svg": "svgs/actions/actions-bullhorn-slash.svg", + "sprite": "sprites/actions.svg#actions-bullhorn-slash", + "bidi": false + }, + "actions-bullhorn": { + "identifier": "actions-bullhorn", + "category": "actions", + "svg": "svgs/actions/actions-bullhorn.svg", + "sprite": "sprites/actions.svg#actions-bullhorn", + "bidi": false + }, + "actions-calendar-alternative": { + "identifier": "actions-calendar-alternative", + "category": "actions", + "svg": "svgs/actions/actions-calendar-alternative.svg", + "sprite": "sprites/actions.svg#actions-calendar-alternative", + "bidi": false + }, + "actions-calendar": { + "identifier": "actions-calendar", + "category": "actions", + "svg": "svgs/actions/actions-calendar.svg", + "sprite": "sprites/actions.svg#actions-calendar", + "bidi": false + }, + "actions-canvas": { + "identifier": "actions-canvas", + "category": "actions", + "svg": "svgs/actions/actions-canvas.svg", + "sprite": "sprites/actions.svg#actions-canvas", + "bidi": false + }, + "actions-capslock": { + "identifier": "actions-capslock", + "category": "actions", + "svg": "svgs/actions/actions-capslock.svg", + "sprite": "sprites/actions.svg#actions-capslock", + "bidi": false + }, + "actions-caret-bar-bottom": { + "identifier": "actions-caret-bar-bottom", + "category": "actions", + "svg": "svgs/actions/actions-caret-bar-bottom.svg", + "sprite": "sprites/actions.svg#actions-caret-bar-bottom", + "bidi": false + }, + "actions-caret-bar-end": { + "identifier": "actions-caret-bar-end", + "category": "actions", + "svg": "svgs/actions/actions-caret-bar-end.svg", + "sprite": "sprites/actions.svg#actions-caret-bar-end", + "bidi": true + }, + "actions-caret-bar-start": { + "identifier": "actions-caret-bar-start", + "category": "actions", + "svg": "svgs/actions/actions-caret-bar-start.svg", + "sprite": "sprites/actions.svg#actions-caret-bar-start", + "bidi": true + }, + "actions-caret-bar-top": { + "identifier": "actions-caret-bar-top", + "category": "actions", + "svg": "svgs/actions/actions-caret-bar-top.svg", + "sprite": "sprites/actions.svg#actions-caret-bar-top", + "bidi": false + }, + "actions-caret-down": { + "identifier": "actions-caret-down", + "category": "actions", + "svg": "svgs/actions/actions-caret-down.svg", + "sprite": "sprites/actions.svg#actions-caret-down", + "bidi": false + }, + "actions-caret-end": { + "identifier": "actions-caret-end", + "category": "actions", + "svg": "svgs/actions/actions-caret-end.svg", + "sprite": "sprites/actions.svg#actions-caret-end", + "bidi": true + }, + "actions-caret-start": { + "identifier": "actions-caret-start", + "category": "actions", + "svg": "svgs/actions/actions-caret-start.svg", + "sprite": "sprites/actions.svg#actions-caret-start", + "bidi": true + }, + "actions-caret-up": { + "identifier": "actions-caret-up", + "category": "actions", + "svg": "svgs/actions/actions-caret-up.svg", + "sprite": "sprites/actions.svg#actions-caret-up", + "bidi": false + }, + "actions-cart": { + "identifier": "actions-cart", + "category": "actions", + "svg": "svgs/actions/actions-cart.svg", + "sprite": "sprites/actions.svg#actions-cart", + "bidi": false + }, + "actions-category": { + "identifier": "actions-category", + "category": "actions", + "svg": "svgs/actions/actions-category.svg", + "sprite": "sprites/actions.svg#actions-category", + "bidi": false + }, + "actions-certificate-alternative": { + "identifier": "actions-certificate-alternative", + "category": "actions", + "svg": "svgs/actions/actions-certificate-alternative.svg", + "sprite": "sprites/actions.svg#actions-certificate-alternative", + "bidi": false + }, + "actions-certificate": { + "identifier": "actions-certificate", + "category": "actions", + "svg": "svgs/actions/actions-certificate.svg", + "sprite": "sprites/actions.svg#actions-certificate", + "bidi": false + }, + "actions-chat": { + "identifier": "actions-chat", + "category": "actions", + "svg": "svgs/actions/actions-chat.svg", + "sprite": "sprites/actions.svg#actions-chat", + "bidi": false + }, + "actions-check-badge-alt": { + "identifier": "actions-check-badge-alt", + "category": "actions", + "svg": "svgs/actions/actions-check-badge-alt.svg", + "sprite": "sprites/actions.svg#actions-check-badge-alt", + "bidi": false + }, + "actions-check-badge": { + "identifier": "actions-check-badge", + "category": "actions", + "svg": "svgs/actions/actions-check-badge.svg", + "sprite": "sprites/actions.svg#actions-check-badge", + "bidi": false + }, + "actions-check-circle-alt": { + "identifier": "actions-check-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-check-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-check-circle-alt", + "bidi": false + }, + "actions-check-circle": { + "identifier": "actions-check-circle", + "category": "actions", + "svg": "svgs/actions/actions-check-circle.svg", + "sprite": "sprites/actions.svg#actions-check-circle", + "bidi": false + }, + "actions-check-square-alt": { + "identifier": "actions-check-square-alt", + "category": "actions", + "svg": "svgs/actions/actions-check-square-alt.svg", + "sprite": "sprites/actions.svg#actions-check-square-alt", + "bidi": false + }, + "actions-check-square": { + "identifier": "actions-check-square", + "category": "actions", + "svg": "svgs/actions/actions-check-square.svg", + "sprite": "sprites/actions.svg#actions-check-square", + "bidi": false + }, + "actions-check": { + "identifier": "actions-check", + "category": "actions", + "svg": "svgs/actions/actions-check.svg", + "sprite": "sprites/actions.svg#actions-check", + "bidi": false + }, + "actions-chevron-bar-down": { + "identifier": "actions-chevron-bar-down", + "category": "actions", + "svg": "svgs/actions/actions-chevron-bar-down.svg", + "sprite": "sprites/actions.svg#actions-chevron-bar-down", + "bidi": false + }, + "actions-chevron-bar-end": { + "identifier": "actions-chevron-bar-end", + "category": "actions", + "svg": "svgs/actions/actions-chevron-bar-end.svg", + "sprite": "sprites/actions.svg#actions-chevron-bar-end", + "bidi": true + }, + "actions-chevron-bar-start": { + "identifier": "actions-chevron-bar-start", + "category": "actions", + "svg": "svgs/actions/actions-chevron-bar-start.svg", + "sprite": "sprites/actions.svg#actions-chevron-bar-start", + "bidi": true + }, + "actions-chevron-bar-up": { + "identifier": "actions-chevron-bar-up", + "category": "actions", + "svg": "svgs/actions/actions-chevron-bar-up.svg", + "sprite": "sprites/actions.svg#actions-chevron-bar-up", + "bidi": false + }, + "actions-chevron-contract": { + "identifier": "actions-chevron-contract", + "category": "actions", + "svg": "svgs/actions/actions-chevron-contract.svg", + "sprite": "sprites/actions.svg#actions-chevron-contract", + "bidi": false + }, + "actions-chevron-double-end": { + "identifier": "actions-chevron-double-end", + "category": "actions", + "svg": "svgs/actions/actions-chevron-double-end.svg", + "sprite": "sprites/actions.svg#actions-chevron-double-end", + "bidi": true + }, + "actions-chevron-double-start": { + "identifier": "actions-chevron-double-start", + "category": "actions", + "svg": "svgs/actions/actions-chevron-double-start.svg", + "sprite": "sprites/actions.svg#actions-chevron-double-start", + "bidi": true + }, + "actions-chevron-down": { + "identifier": "actions-chevron-down", + "category": "actions", + "svg": "svgs/actions/actions-chevron-down.svg", + "sprite": "sprites/actions.svg#actions-chevron-down", + "bidi": false + }, + "actions-chevron-end": { + "identifier": "actions-chevron-end", + "category": "actions", + "svg": "svgs/actions/actions-chevron-end.svg", + "sprite": "sprites/actions.svg#actions-chevron-end", + "bidi": true + }, + "actions-chevron-expand": { + "identifier": "actions-chevron-expand", + "category": "actions", + "svg": "svgs/actions/actions-chevron-expand.svg", + "sprite": "sprites/actions.svg#actions-chevron-expand", + "bidi": false + }, + "actions-chevron-start": { + "identifier": "actions-chevron-start", + "category": "actions", + "svg": "svgs/actions/actions-chevron-start.svg", + "sprite": "sprites/actions.svg#actions-chevron-start", + "bidi": true + }, + "actions-chevron-up": { + "identifier": "actions-chevron-up", + "category": "actions", + "svg": "svgs/actions/actions-chevron-up.svg", + "sprite": "sprites/actions.svg#actions-chevron-up", + "bidi": false + }, + "actions-circle-full": { + "identifier": "actions-circle-full", + "category": "actions", + "svg": "svgs/actions/actions-circle-full.svg", + "sprite": "sprites/actions.svg#actions-circle-full", + "bidi": false + }, + "actions-circle-half": { + "identifier": "actions-circle-half", + "category": "actions", + "svg": "svgs/actions/actions-circle-half.svg", + "sprite": "sprites/actions.svg#actions-circle-half", + "bidi": false + }, + "actions-circle": { + "identifier": "actions-circle", + "category": "actions", + "svg": "svgs/actions/actions-circle.svg", + "sprite": "sprites/actions.svg#actions-circle", + "bidi": false + }, + "actions-clipboard-close": { + "identifier": "actions-clipboard-close", + "category": "actions", + "svg": "svgs/actions/actions-clipboard-close.svg", + "sprite": "sprites/actions.svg#actions-clipboard-close", + "bidi": false + }, + "actions-clipboard-paste": { + "identifier": "actions-clipboard-paste", + "category": "actions", + "svg": "svgs/actions/actions-clipboard-paste.svg", + "sprite": "sprites/actions.svg#actions-clipboard-paste", + "bidi": false + }, + "actions-clipboard": { + "identifier": "actions-clipboard", + "category": "actions", + "svg": "svgs/actions/actions-clipboard.svg", + "sprite": "sprites/actions.svg#actions-clipboard", + "bidi": false + }, + "actions-clock": { + "identifier": "actions-clock", + "category": "actions", + "svg": "svgs/actions/actions-clock.svg", + "sprite": "sprites/actions.svg#actions-clock", + "bidi": false + }, + "actions-close": { + "identifier": "actions-close", + "category": "actions", + "svg": "svgs/actions/actions-close.svg", + "sprite": "sprites/actions.svg#actions-close", + "bidi": false + }, + "actions-cloud-download": { + "identifier": "actions-cloud-download", + "category": "actions", + "svg": "svgs/actions/actions-cloud-download.svg", + "sprite": "sprites/actions.svg#actions-cloud-download", + "bidi": false + }, + "actions-cloud-slash": { + "identifier": "actions-cloud-slash", + "category": "actions", + "svg": "svgs/actions/actions-cloud-slash.svg", + "sprite": "sprites/actions.svg#actions-cloud-slash", + "bidi": false + }, + "actions-cloud-upload": { + "identifier": "actions-cloud-upload", + "category": "actions", + "svg": "svgs/actions/actions-cloud-upload.svg", + "sprite": "sprites/actions.svg#actions-cloud-upload", + "bidi": false + }, + "actions-cloud": { + "identifier": "actions-cloud", + "category": "actions", + "svg": "svgs/actions/actions-cloud.svg", + "sprite": "sprites/actions.svg#actions-cloud", + "bidi": false + }, + "actions-code-commit": { + "identifier": "actions-code-commit", + "category": "actions", + "svg": "svgs/actions/actions-code-commit.svg", + "sprite": "sprites/actions.svg#actions-code-commit", + "bidi": false + }, + "actions-code-compare": { + "identifier": "actions-code-compare", + "category": "actions", + "svg": "svgs/actions/actions-code-compare.svg", + "sprite": "sprites/actions.svg#actions-code-compare", + "bidi": false + }, + "actions-code-fork": { + "identifier": "actions-code-fork", + "category": "actions", + "svg": "svgs/actions/actions-code-fork.svg", + "sprite": "sprites/actions.svg#actions-code-fork", + "bidi": false + }, + "actions-code-merge-localization": { + "identifier": "actions-code-merge-localization", + "category": "actions", + "svg": "svgs/actions/actions-code-merge-localization.svg", + "sprite": "sprites/actions.svg#actions-code-merge-localization", + "bidi": false + }, + "actions-code-merge": { + "identifier": "actions-code-merge", + "category": "actions", + "svg": "svgs/actions/actions-code-merge.svg", + "sprite": "sprites/actions.svg#actions-code-merge", + "bidi": false + }, + "actions-code-pull-request-close": { + "identifier": "actions-code-pull-request-close", + "category": "actions", + "svg": "svgs/actions/actions-code-pull-request-close.svg", + "sprite": "sprites/actions.svg#actions-code-pull-request-close", + "bidi": false + }, + "actions-code-pull-request-draft": { + "identifier": "actions-code-pull-request-draft", + "category": "actions", + "svg": "svgs/actions/actions-code-pull-request-draft.svg", + "sprite": "sprites/actions.svg#actions-code-pull-request-draft", + "bidi": false + }, + "actions-code-pull-request": { + "identifier": "actions-code-pull-request", + "category": "actions", + "svg": "svgs/actions/actions-code-pull-request.svg", + "sprite": "sprites/actions.svg#actions-code-pull-request", + "bidi": false + }, + "actions-code": { + "identifier": "actions-code", + "category": "actions", + "svg": "svgs/actions/actions-code.svg", + "sprite": "sprites/actions.svg#actions-code", + "bidi": false + }, + "actions-coffee": { + "identifier": "actions-coffee", + "category": "actions", + "svg": "svgs/actions/actions-coffee.svg", + "sprite": "sprites/actions.svg#actions-coffee", + "bidi": false + }, + "actions-cog-alt": { + "identifier": "actions-cog-alt", + "category": "actions", + "svg": "svgs/actions/actions-cog-alt.svg", + "sprite": "sprites/actions.svg#actions-cog-alt", + "bidi": false + }, + "actions-cog": { + "identifier": "actions-cog", + "category": "actions", + "svg": "svgs/actions/actions-cog.svg", + "sprite": "sprites/actions.svg#actions-cog", + "bidi": false + }, + "actions-comment": { + "identifier": "actions-comment", + "category": "actions", + "svg": "svgs/actions/actions-comment.svg", + "sprite": "sprites/actions.svg#actions-comment", + "bidi": false + }, + "actions-container": { + "identifier": "actions-container", + "category": "actions", + "svg": "svgs/actions/actions-container.svg", + "sprite": "sprites/actions.svg#actions-container", + "bidi": false + }, + "actions-cookie-bite": { + "identifier": "actions-cookie-bite", + "category": "actions", + "svg": "svgs/actions/actions-cookie-bite.svg", + "sprite": "sprites/actions.svg#actions-cookie-bite", + "bidi": false + }, + "actions-cookie": { + "identifier": "actions-cookie", + "category": "actions", + "svg": "svgs/actions/actions-cookie.svg", + "sprite": "sprites/actions.svg#actions-cookie", + "bidi": false + }, + "actions-copyright": { + "identifier": "actions-copyright", + "category": "actions", + "svg": "svgs/actions/actions-copyright.svg", + "sprite": "sprites/actions.svg#actions-copyright", + "bidi": false + }, + "actions-cpu": { + "identifier": "actions-cpu", + "category": "actions", + "svg": "svgs/actions/actions-cpu.svg", + "sprite": "sprites/actions.svg#actions-cpu", + "bidi": false + }, + "actions-credit-card": { + "identifier": "actions-credit-card", + "category": "actions", + "svg": "svgs/actions/actions-credit-card.svg", + "sprite": "sprites/actions.svg#actions-credit-card", + "bidi": false + }, + "actions-crop": { + "identifier": "actions-crop", + "category": "actions", + "svg": "svgs/actions/actions-crop.svg", + "sprite": "sprites/actions.svg#actions-crop", + "bidi": false + }, + "actions-cut-release": { + "identifier": "actions-cut-release", + "category": "actions", + "svg": "svgs/actions/actions-cut-release.svg", + "sprite": "sprites/actions.svg#actions-cut-release", + "bidi": false + }, + "actions-cut": { + "identifier": "actions-cut", + "category": "actions", + "svg": "svgs/actions/actions-cut.svg", + "sprite": "sprites/actions.svg#actions-cut", + "bidi": false + }, + "actions-database-export": { + "identifier": "actions-database-export", + "category": "actions", + "svg": "svgs/actions/actions-database-export.svg", + "sprite": "sprites/actions.svg#actions-database-export", + "bidi": false + }, + "actions-database-import": { + "identifier": "actions-database-import", + "category": "actions", + "svg": "svgs/actions/actions-database-import.svg", + "sprite": "sprites/actions.svg#actions-database-import", + "bidi": false + }, + "actions-database-reload": { + "identifier": "actions-database-reload", + "category": "actions", + "svg": "svgs/actions/actions-database-reload.svg", + "sprite": "sprites/actions.svg#actions-database-reload", + "bidi": false + }, + "actions-database": { + "identifier": "actions-database", + "category": "actions", + "svg": "svgs/actions/actions-database.svg", + "sprite": "sprites/actions.svg#actions-database", + "bidi": false + }, + "actions-debug": { + "identifier": "actions-debug", + "category": "actions", + "svg": "svgs/actions/actions-debug.svg", + "sprite": "sprites/actions.svg#actions-debug", + "bidi": false + }, + "actions-delete-edit": { + "identifier": "actions-delete-edit", + "category": "actions", + "svg": "svgs/actions/actions-delete-edit.svg", + "sprite": "sprites/actions.svg#actions-delete-edit", + "bidi": false + }, + "actions-delete-restore": { + "identifier": "actions-delete-restore", + "category": "actions", + "svg": "svgs/actions/actions-delete-restore.svg", + "sprite": "sprites/actions.svg#actions-delete-restore", + "bidi": false + }, + "actions-delete": { + "identifier": "actions-delete", + "category": "actions", + "svg": "svgs/actions/actions-delete.svg", + "sprite": "sprites/actions.svg#actions-delete", + "bidi": false + }, + "actions-device-desktop-star": { + "identifier": "actions-device-desktop-star", + "category": "actions", + "svg": "svgs/actions/actions-device-desktop-star.svg", + "sprite": "sprites/actions.svg#actions-device-desktop-star", + "bidi": false + }, + "actions-device-desktop-user": { + "identifier": "actions-device-desktop-user", + "category": "actions", + "svg": "svgs/actions/actions-device-desktop-user.svg", + "sprite": "sprites/actions.svg#actions-device-desktop-user", + "bidi": false + }, + "actions-device-desktop": { + "identifier": "actions-device-desktop", + "category": "actions", + "svg": "svgs/actions/actions-device-desktop.svg", + "sprite": "sprites/actions.svg#actions-device-desktop", + "bidi": false + }, + "actions-device-mobile": { + "identifier": "actions-device-mobile", + "category": "actions", + "svg": "svgs/actions/actions-device-mobile.svg", + "sprite": "sprites/actions.svg#actions-device-mobile", + "bidi": false + }, + "actions-device-orientation-change": { + "identifier": "actions-device-orientation-change", + "category": "actions", + "svg": "svgs/actions/actions-device-orientation-change.svg", + "sprite": "sprites/actions.svg#actions-device-orientation-change", + "bidi": false + }, + "actions-device-tablet": { + "identifier": "actions-device-tablet", + "category": "actions", + "svg": "svgs/actions/actions-device-tablet.svg", + "sprite": "sprites/actions.svg#actions-device-tablet", + "bidi": false + }, + "actions-device-unidentified": { + "identifier": "actions-device-unidentified", + "category": "actions", + "svg": "svgs/actions/actions-device-unidentified.svg", + "sprite": "sprites/actions.svg#actions-device-unidentified", + "bidi": false + }, + "actions-dice-1": { + "identifier": "actions-dice-1", + "category": "actions", + "svg": "svgs/actions/actions-dice-1.svg", + "sprite": "sprites/actions.svg#actions-dice-1", + "bidi": false + }, + "actions-dice-2": { + "identifier": "actions-dice-2", + "category": "actions", + "svg": "svgs/actions/actions-dice-2.svg", + "sprite": "sprites/actions.svg#actions-dice-2", + "bidi": false + }, + "actions-dice-3": { + "identifier": "actions-dice-3", + "category": "actions", + "svg": "svgs/actions/actions-dice-3.svg", + "sprite": "sprites/actions.svg#actions-dice-3", + "bidi": false + }, + "actions-dice-4": { + "identifier": "actions-dice-4", + "category": "actions", + "svg": "svgs/actions/actions-dice-4.svg", + "sprite": "sprites/actions.svg#actions-dice-4", + "bidi": false + }, + "actions-dice-5": { + "identifier": "actions-dice-5", + "category": "actions", + "svg": "svgs/actions/actions-dice-5.svg", + "sprite": "sprites/actions.svg#actions-dice-5", + "bidi": false + }, + "actions-dice-6": { + "identifier": "actions-dice-6", + "category": "actions", + "svg": "svgs/actions/actions-dice-6.svg", + "sprite": "sprites/actions.svg#actions-dice-6", + "bidi": false + }, + "actions-dice": { + "identifier": "actions-dice", + "category": "actions", + "svg": "svgs/actions/actions-dice.svg", + "sprite": "sprites/actions.svg#actions-dice", + "bidi": false + }, + "actions-document-add": { + "identifier": "actions-document-add", + "category": "actions", + "svg": "svgs/actions/actions-document-add.svg", + "sprite": "sprites/actions.svg#actions-document-add", + "bidi": false + }, + "actions-document-edit-access": { + "identifier": "actions-document-edit-access", + "category": "actions", + "svg": "svgs/actions/actions-document-edit-access.svg", + "sprite": "sprites/actions.svg#actions-document-edit-access", + "bidi": false + }, + "actions-document-edit": { + "identifier": "actions-document-edit", + "category": "actions", + "svg": "svgs/actions/actions-document-edit.svg", + "sprite": "sprites/actions.svg#actions-document-edit", + "bidi": false + }, + "actions-document-localize": { + "identifier": "actions-document-localize", + "category": "actions", + "svg": "svgs/actions/actions-document-localize.svg", + "sprite": "sprites/actions.svg#actions-document-localize", + "bidi": false + }, + "actions-document-move": { + "identifier": "actions-document-move", + "category": "actions", + "svg": "svgs/actions/actions-document-move.svg", + "sprite": "sprites/actions.svg#actions-document-move", + "bidi": false + }, + "actions-document-readonly": { + "identifier": "actions-document-readonly", + "category": "actions", + "svg": "svgs/actions/actions-document-readonly.svg", + "sprite": "sprites/actions.svg#actions-document-readonly", + "bidi": false + }, + "actions-document-select": { + "identifier": "actions-document-select", + "category": "actions", + "svg": "svgs/actions/actions-document-select.svg", + "sprite": "sprites/actions.svg#actions-document-select", + "bidi": false + }, + "actions-document-share": { + "identifier": "actions-document-share", + "category": "actions", + "svg": "svgs/actions/actions-document-share.svg", + "sprite": "sprites/actions.svg#actions-document-share", + "bidi": false + }, + "actions-document-synchronize": { + "identifier": "actions-document-synchronize", + "category": "actions", + "svg": "svgs/actions/actions-document-synchronize.svg", + "sprite": "sprites/actions.svg#actions-document-synchronize", + "bidi": false + }, + "actions-document-view": { + "identifier": "actions-document-view", + "category": "actions", + "svg": "svgs/actions/actions-document-view.svg", + "sprite": "sprites/actions.svg#actions-document-view", + "bidi": false + }, + "actions-document": { + "identifier": "actions-document", + "category": "actions", + "svg": "svgs/actions/actions-document.svg", + "sprite": "sprites/actions.svg#actions-document", + "bidi": false + }, + "actions-dot": { + "identifier": "actions-dot", + "category": "actions", + "svg": "svgs/actions/actions-dot.svg", + "sprite": "sprites/actions.svg#actions-dot", + "bidi": false + }, + "actions-download": { + "identifier": "actions-download", + "category": "actions", + "svg": "svgs/actions/actions-download.svg", + "sprite": "sprites/actions.svg#actions-download", + "bidi": false + }, + "actions-drag": { + "identifier": "actions-drag", + "category": "actions", + "svg": "svgs/actions/actions-drag.svg", + "sprite": "sprites/actions.svg#actions-drag", + "bidi": false + }, + "actions-duplicate": { + "identifier": "actions-duplicate", + "category": "actions", + "svg": "svgs/actions/actions-duplicate.svg", + "sprite": "sprites/actions.svg#actions-duplicate", + "bidi": false + }, + "actions-duplicates": { + "identifier": "actions-duplicates", + "category": "actions", + "svg": "svgs/actions/actions-duplicates.svg", + "sprite": "sprites/actions.svg#actions-duplicates", + "bidi": false + }, + "actions-envelope-open-text": { + "identifier": "actions-envelope-open-text", + "category": "actions", + "svg": "svgs/actions/actions-envelope-open-text.svg", + "sprite": "sprites/actions.svg#actions-envelope-open-text", + "bidi": false + }, + "actions-envelope-open": { + "identifier": "actions-envelope-open", + "category": "actions", + "svg": "svgs/actions/actions-envelope-open.svg", + "sprite": "sprites/actions.svg#actions-envelope-open", + "bidi": false + }, + "actions-envelope": { + "identifier": "actions-envelope", + "category": "actions", + "svg": "svgs/actions/actions-envelope.svg", + "sprite": "sprites/actions.svg#actions-envelope", + "bidi": false + }, + "actions-exchange": { + "identifier": "actions-exchange", + "category": "actions", + "svg": "svgs/actions/actions-exchange.svg", + "sprite": "sprites/actions.svg#actions-exchange", + "bidi": true + }, + "actions-exclamation-circle-alt": { + "identifier": "actions-exclamation-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-exclamation-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-exclamation-circle-alt", + "bidi": false + }, + "actions-exclamation-circle": { + "identifier": "actions-exclamation-circle", + "category": "actions", + "svg": "svgs/actions/actions-exclamation-circle.svg", + "sprite": "sprites/actions.svg#actions-exclamation-circle", + "bidi": false + }, + "actions-exclamation-triangle-alt": { + "identifier": "actions-exclamation-triangle-alt", + "category": "actions", + "svg": "svgs/actions/actions-exclamation-triangle-alt.svg", + "sprite": "sprites/actions.svg#actions-exclamation-triangle-alt", + "bidi": false + }, + "actions-exclamation-triangle": { + "identifier": "actions-exclamation-triangle", + "category": "actions", + "svg": "svgs/actions/actions-exclamation-triangle.svg", + "sprite": "sprites/actions.svg#actions-exclamation-triangle", + "bidi": false + }, + "actions-exclamation": { + "identifier": "actions-exclamation", + "category": "actions", + "svg": "svgs/actions/actions-exclamation.svg", + "sprite": "sprites/actions.svg#actions-exclamation", + "bidi": false + }, + "actions-expand": { + "identifier": "actions-expand", + "category": "actions", + "svg": "svgs/actions/actions-expand.svg", + "sprite": "sprites/actions.svg#actions-expand", + "bidi": false + }, + "actions-extension-add": { + "identifier": "actions-extension-add", + "category": "actions", + "svg": "svgs/actions/actions-extension-add.svg", + "sprite": "sprites/actions.svg#actions-extension-add", + "bidi": false + }, + "actions-extension-import": { + "identifier": "actions-extension-import", + "category": "actions", + "svg": "svgs/actions/actions-extension-import.svg", + "sprite": "sprites/actions.svg#actions-extension-import", + "bidi": false + }, + "actions-extension-refresh": { + "identifier": "actions-extension-refresh", + "category": "actions", + "svg": "svgs/actions/actions-extension-refresh.svg", + "sprite": "sprites/actions.svg#actions-extension-refresh", + "bidi": false + }, + "actions-extension-remove": { + "identifier": "actions-extension-remove", + "category": "actions", + "svg": "svgs/actions/actions-extension-remove.svg", + "sprite": "sprites/actions.svg#actions-extension-remove", + "bidi": false + }, + "actions-extension": { + "identifier": "actions-extension", + "category": "actions", + "svg": "svgs/actions/actions-extension.svg", + "sprite": "sprites/actions.svg#actions-extension", + "bidi": false + }, + "actions-eye-link": { + "identifier": "actions-eye-link", + "category": "actions", + "svg": "svgs/actions/actions-eye-link.svg", + "sprite": "sprites/actions.svg#actions-eye-link", + "bidi": false + }, + "actions-eye": { + "identifier": "actions-eye", + "category": "actions", + "svg": "svgs/actions/actions-eye.svg", + "sprite": "sprites/actions.svg#actions-eye", + "bidi": false + }, + "actions-file-add": { + "identifier": "actions-file-add", + "category": "actions", + "svg": "svgs/actions/actions-file-add.svg", + "sprite": "sprites/actions.svg#actions-file-add", + "bidi": false + }, + "actions-file-audio": { + "identifier": "actions-file-audio", + "category": "actions", + "svg": "svgs/actions/actions-file-audio.svg", + "sprite": "sprites/actions.svg#actions-file-audio", + "bidi": false + }, + "actions-file-certificate": { + "identifier": "actions-file-certificate", + "category": "actions", + "svg": "svgs/actions/actions-file-certificate.svg", + "sprite": "sprites/actions.svg#actions-file-certificate", + "bidi": false + }, + "actions-file-csv-download": { + "identifier": "actions-file-csv-download", + "category": "actions", + "svg": "svgs/actions/actions-file-csv-download.svg", + "sprite": "sprites/actions.svg#actions-file-csv-download", + "bidi": false + }, + "actions-file-csv": { + "identifier": "actions-file-csv", + "category": "actions", + "svg": "svgs/actions/actions-file-csv.svg", + "sprite": "sprites/actions.svg#actions-file-csv", + "bidi": false + }, + "actions-file-edit": { + "identifier": "actions-file-edit", + "category": "actions", + "svg": "svgs/actions/actions-file-edit.svg", + "sprite": "sprites/actions.svg#actions-file-edit", + "bidi": false + }, + "actions-file-html": { + "identifier": "actions-file-html", + "category": "actions", + "svg": "svgs/actions/actions-file-html.svg", + "sprite": "sprites/actions.svg#actions-file-html", + "bidi": false + }, + "actions-file-image": { + "identifier": "actions-file-image", + "category": "actions", + "svg": "svgs/actions/actions-file-image.svg", + "sprite": "sprites/actions.svg#actions-file-image", + "bidi": false + }, + "actions-file-move": { + "identifier": "actions-file-move", + "category": "actions", + "svg": "svgs/actions/actions-file-move.svg", + "sprite": "sprites/actions.svg#actions-file-move", + "bidi": false + }, + "actions-file-openoffice": { + "identifier": "actions-file-openoffice", + "category": "actions", + "svg": "svgs/actions/actions-file-openoffice.svg", + "sprite": "sprites/actions.svg#actions-file-openoffice", + "bidi": false + }, + "actions-file-pdf": { + "identifier": "actions-file-pdf", + "category": "actions", + "svg": "svgs/actions/actions-file-pdf.svg", + "sprite": "sprites/actions.svg#actions-file-pdf", + "bidi": false + }, + "actions-file-search": { + "identifier": "actions-file-search", + "category": "actions", + "svg": "svgs/actions/actions-file-search.svg", + "sprite": "sprites/actions.svg#actions-file-search", + "bidi": false + }, + "actions-file-shield": { + "identifier": "actions-file-shield", + "category": "actions", + "svg": "svgs/actions/actions-file-shield.svg", + "sprite": "sprites/actions.svg#actions-file-shield", + "bidi": false + }, + "actions-file-t3d-download": { + "identifier": "actions-file-t3d-download", + "category": "actions", + "svg": "svgs/actions/actions-file-t3d-download.svg", + "sprite": "sprites/actions.svg#actions-file-t3d-download", + "bidi": false + }, + "actions-file-t3d-upload": { + "identifier": "actions-file-t3d-upload", + "category": "actions", + "svg": "svgs/actions/actions-file-t3d-upload.svg", + "sprite": "sprites/actions.svg#actions-file-t3d-upload", + "bidi": false + }, + "actions-file-t3d": { + "identifier": "actions-file-t3d", + "category": "actions", + "svg": "svgs/actions/actions-file-t3d.svg", + "sprite": "sprites/actions.svg#actions-file-t3d", + "bidi": false + }, + "actions-file-text": { + "identifier": "actions-file-text", + "category": "actions", + "svg": "svgs/actions/actions-file-text.svg", + "sprite": "sprites/actions.svg#actions-file-text", + "bidi": false + }, + "actions-file-video": { + "identifier": "actions-file-video", + "category": "actions", + "svg": "svgs/actions/actions-file-video.svg", + "sprite": "sprites/actions.svg#actions-file-video", + "bidi": false + }, + "actions-file-view": { + "identifier": "actions-file-view", + "category": "actions", + "svg": "svgs/actions/actions-file-view.svg", + "sprite": "sprites/actions.svg#actions-file-view", + "bidi": false + }, + "actions-file": { + "identifier": "actions-file", + "category": "actions", + "svg": "svgs/actions/actions-file.svg", + "sprite": "sprites/actions.svg#actions-file", + "bidi": false + }, + "actions-filter": { + "identifier": "actions-filter", + "category": "actions", + "svg": "svgs/actions/actions-filter.svg", + "sprite": "sprites/actions.svg#actions-filter", + "bidi": false + }, + "actions-folder-add": { + "identifier": "actions-folder-add", + "category": "actions", + "svg": "svgs/actions/actions-folder-add.svg", + "sprite": "sprites/actions.svg#actions-folder-add", + "bidi": false + }, + "actions-folder": { + "identifier": "actions-folder", + "category": "actions", + "svg": "svgs/actions/actions-folder.svg", + "sprite": "sprites/actions.svg#actions-folder", + "bidi": false + }, + "actions-form-insert-after": { + "identifier": "actions-form-insert-after", + "category": "actions", + "svg": "svgs/actions/actions-form-insert-after.svg", + "sprite": "sprites/actions.svg#actions-form-insert-after", + "bidi": false + }, + "actions-form-insert-before": { + "identifier": "actions-form-insert-before", + "category": "actions", + "svg": "svgs/actions/actions-form-insert-before.svg", + "sprite": "sprites/actions.svg#actions-form-insert-before", + "bidi": false + }, + "actions-form-insert-in": { + "identifier": "actions-form-insert-in", + "category": "actions", + "svg": "svgs/actions/actions-form-insert-in.svg", + "sprite": "sprites/actions.svg#actions-form-insert-in", + "bidi": false + }, + "actions-fullscreen": { + "identifier": "actions-fullscreen", + "category": "actions", + "svg": "svgs/actions/actions-fullscreen.svg", + "sprite": "sprites/actions.svg#actions-fullscreen", + "bidi": false + }, + "actions-gift-card": { + "identifier": "actions-gift-card", + "category": "actions", + "svg": "svgs/actions/actions-gift-card.svg", + "sprite": "sprites/actions.svg#actions-gift-card", + "bidi": false + }, + "actions-gift": { + "identifier": "actions-gift", + "category": "actions", + "svg": "svgs/actions/actions-gift.svg", + "sprite": "sprites/actions.svg#actions-gift", + "bidi": false + }, + "actions-git": { + "identifier": "actions-git", + "category": "actions", + "svg": "svgs/actions/actions-git.svg", + "sprite": "sprites/actions.svg#actions-git", + "bidi": false + }, + "actions-globe-alt": { + "identifier": "actions-globe-alt", + "category": "actions", + "svg": "svgs/actions/actions-globe-alt.svg", + "sprite": "sprites/actions.svg#actions-globe-alt", + "bidi": false + }, + "actions-globe": { + "identifier": "actions-globe", + "category": "actions", + "svg": "svgs/actions/actions-globe.svg", + "sprite": "sprites/actions.svg#actions-globe", + "bidi": false + }, + "actions-graduation-cap": { + "identifier": "actions-graduation-cap", + "category": "actions", + "svg": "svgs/actions/actions-graduation-cap.svg", + "sprite": "sprites/actions.svg#actions-graduation-cap", + "bidi": false + }, + "actions-hand-pointer": { + "identifier": "actions-hand-pointer", + "category": "actions", + "svg": "svgs/actions/actions-hand-pointer.svg", + "sprite": "sprites/actions.svg#actions-hand-pointer", + "bidi": false + }, + "actions-heart-alt": { + "identifier": "actions-heart-alt", + "category": "actions", + "svg": "svgs/actions/actions-heart-alt.svg", + "sprite": "sprites/actions.svg#actions-heart-alt", + "bidi": false + }, + "actions-heart": { + "identifier": "actions-heart", + "category": "actions", + "svg": "svgs/actions/actions-heart.svg", + "sprite": "sprites/actions.svg#actions-heart", + "bidi": false + }, + "actions-history": { + "identifier": "actions-history", + "category": "actions", + "svg": "svgs/actions/actions-history.svg", + "sprite": "sprites/actions.svg#actions-history", + "bidi": false + }, + "actions-house": { + "identifier": "actions-house", + "category": "actions", + "svg": "svgs/actions/actions-house.svg", + "sprite": "sprites/actions.svg#actions-house", + "bidi": false + }, + "actions-hyphen": { + "identifier": "actions-hyphen", + "category": "actions", + "svg": "svgs/actions/actions-hyphen.svg", + "sprite": "sprites/actions.svg#actions-hyphen", + "bidi": false + }, + "actions-id-badge": { + "identifier": "actions-id-badge", + "category": "actions", + "svg": "svgs/actions/actions-id-badge.svg", + "sprite": "sprites/actions.svg#actions-id-badge", + "bidi": false + }, + "actions-image": { + "identifier": "actions-image", + "category": "actions", + "svg": "svgs/actions/actions-image.svg", + "sprite": "sprites/actions.svg#actions-image", + "bidi": false + }, + "actions-infinity": { + "identifier": "actions-infinity", + "category": "actions", + "svg": "svgs/actions/actions-infinity.svg", + "sprite": "sprites/actions.svg#actions-infinity", + "bidi": false + }, + "actions-info-circle-alt": { + "identifier": "actions-info-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-info-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-info-circle-alt", + "bidi": false + }, + "actions-info-circle": { + "identifier": "actions-info-circle", + "category": "actions", + "svg": "svgs/actions/actions-info-circle.svg", + "sprite": "sprites/actions.svg#actions-info-circle", + "bidi": false + }, + "actions-info": { + "identifier": "actions-info", + "category": "actions", + "svg": "svgs/actions/actions-info.svg", + "sprite": "sprites/actions.svg#actions-info", + "bidi": false + }, + "actions-insert": { + "identifier": "actions-insert", + "category": "actions", + "svg": "svgs/actions/actions-insert.svg", + "sprite": "sprites/actions.svg#actions-insert", + "bidi": false + }, + "actions-key": { + "identifier": "actions-key", + "category": "actions", + "svg": "svgs/actions/actions-key.svg", + "sprite": "sprites/actions.svg#actions-key", + "bidi": false + }, + "actions-lightbulb-on": { + "identifier": "actions-lightbulb-on", + "category": "actions", + "svg": "svgs/actions/actions-lightbulb-on.svg", + "sprite": "sprites/actions.svg#actions-lightbulb-on", + "bidi": false + }, + "actions-lightbulb": { + "identifier": "actions-lightbulb", + "category": "actions", + "svg": "svgs/actions/actions-lightbulb.svg", + "sprite": "sprites/actions.svg#actions-lightbulb", + "bidi": false + }, + "actions-line-columns": { + "identifier": "actions-line-columns", + "category": "actions", + "svg": "svgs/actions/actions-line-columns.svg", + "sprite": "sprites/actions.svg#actions-line-columns", + "bidi": false + }, + "actions-link": { + "identifier": "actions-link", + "category": "actions", + "svg": "svgs/actions/actions-link.svg", + "sprite": "sprites/actions.svg#actions-link", + "bidi": false + }, + "actions-list-alternative": { + "identifier": "actions-list-alternative", + "category": "actions", + "svg": "svgs/actions/actions-list-alternative.svg", + "sprite": "sprites/actions.svg#actions-list-alternative", + "bidi": false + }, + "actions-list": { + "identifier": "actions-list", + "category": "actions", + "svg": "svgs/actions/actions-list.svg", + "sprite": "sprites/actions.svg#actions-list", + "bidi": false + }, + "actions-lock": { + "identifier": "actions-lock", + "category": "actions", + "svg": "svgs/actions/actions-lock.svg", + "sprite": "sprites/actions.svg#actions-lock", + "bidi": false + }, + "actions-login": { + "identifier": "actions-login", + "category": "actions", + "svg": "svgs/actions/actions-login.svg", + "sprite": "sprites/actions.svg#actions-login", + "bidi": false + }, + "actions-logout": { + "identifier": "actions-logout", + "category": "actions", + "svg": "svgs/actions/actions-logout.svg", + "sprite": "sprites/actions.svg#actions-logout", + "bidi": false + }, + "actions-magnet": { + "identifier": "actions-magnet", + "category": "actions", + "svg": "svgs/actions/actions-magnet.svg", + "sprite": "sprites/actions.svg#actions-magnet", + "bidi": false + }, + "actions-map": { + "identifier": "actions-map", + "category": "actions", + "svg": "svgs/actions/actions-map.svg", + "sprite": "sprites/actions.svg#actions-map", + "bidi": false + }, + "actions-marker": { + "identifier": "actions-marker", + "category": "actions", + "svg": "svgs/actions/actions-marker.svg", + "sprite": "sprites/actions.svg#actions-marker", + "bidi": false + }, + "actions-menu-alternative": { + "identifier": "actions-menu-alternative", + "category": "actions", + "svg": "svgs/actions/actions-menu-alternative.svg", + "sprite": "sprites/actions.svg#actions-menu-alternative", + "bidi": false + }, + "actions-menu-sidebar-collapsed": { + "identifier": "actions-menu-sidebar-collapsed", + "category": "actions", + "svg": "svgs/actions/actions-menu-sidebar-collapsed.svg", + "sprite": "sprites/actions.svg#actions-menu-sidebar-collapsed", + "bidi": false + }, + "actions-menu-sidebar-expanded": { + "identifier": "actions-menu-sidebar-expanded", + "category": "actions", + "svg": "svgs/actions/actions-menu-sidebar-expanded.svg", + "sprite": "sprites/actions.svg#actions-menu-sidebar-expanded", + "bidi": false + }, + "actions-menu": { + "identifier": "actions-menu", + "category": "actions", + "svg": "svgs/actions/actions-menu.svg", + "sprite": "sprites/actions.svg#actions-menu", + "bidi": false + }, + "actions-message-add": { + "identifier": "actions-message-add", + "category": "actions", + "svg": "svgs/actions/actions-message-add.svg", + "sprite": "sprites/actions.svg#actions-message-add", + "bidi": false + }, + "actions-message-dots": { + "identifier": "actions-message-dots", + "category": "actions", + "svg": "svgs/actions/actions-message-dots.svg", + "sprite": "sprites/actions.svg#actions-message-dots", + "bidi": false + }, + "actions-message-localize": { + "identifier": "actions-message-localize", + "category": "actions", + "svg": "svgs/actions/actions-message-localize.svg", + "sprite": "sprites/actions.svg#actions-message-localize", + "bidi": false + }, + "actions-message-remove": { + "identifier": "actions-message-remove", + "category": "actions", + "svg": "svgs/actions/actions-message-remove.svg", + "sprite": "sprites/actions.svg#actions-message-remove", + "bidi": false + }, + "actions-message": { + "identifier": "actions-message", + "category": "actions", + "svg": "svgs/actions/actions-message.svg", + "sprite": "sprites/actions.svg#actions-message", + "bidi": false + }, + "actions-microchip": { + "identifier": "actions-microchip", + "category": "actions", + "svg": "svgs/actions/actions-microchip.svg", + "sprite": "sprites/actions.svg#actions-microchip", + "bidi": false + }, + "actions-minus-badge-alt": { + "identifier": "actions-minus-badge-alt", + "category": "actions", + "svg": "svgs/actions/actions-minus-badge-alt.svg", + "sprite": "sprites/actions.svg#actions-minus-badge-alt", + "bidi": false + }, + "actions-minus-badge": { + "identifier": "actions-minus-badge", + "category": "actions", + "svg": "svgs/actions/actions-minus-badge.svg", + "sprite": "sprites/actions.svg#actions-minus-badge", + "bidi": false + }, + "actions-minus-circle-alt": { + "identifier": "actions-minus-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-minus-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-minus-circle-alt", + "bidi": false + }, + "actions-minus-circle": { + "identifier": "actions-minus-circle", + "category": "actions", + "svg": "svgs/actions/actions-minus-circle.svg", + "sprite": "sprites/actions.svg#actions-minus-circle", + "bidi": false + }, + "actions-minus-square-alt": { + "identifier": "actions-minus-square-alt", + "category": "actions", + "svg": "svgs/actions/actions-minus-square-alt.svg", + "sprite": "sprites/actions.svg#actions-minus-square-alt", + "bidi": false + }, + "actions-minus-square": { + "identifier": "actions-minus-square", + "category": "actions", + "svg": "svgs/actions/actions-minus-square.svg", + "sprite": "sprites/actions.svg#actions-minus-square", + "bidi": false + }, + "actions-minus": { + "identifier": "actions-minus", + "category": "actions", + "svg": "svgs/actions/actions-minus.svg", + "sprite": "sprites/actions.svg#actions-minus", + "bidi": false + }, + "actions-moon": { + "identifier": "actions-moon", + "category": "actions", + "svg": "svgs/actions/actions-moon.svg", + "sprite": "sprites/actions.svg#actions-moon", + "bidi": false + }, + "actions-move": { + "identifier": "actions-move", + "category": "actions", + "svg": "svgs/actions/actions-move.svg", + "sprite": "sprites/actions.svg#actions-move", + "bidi": false + }, + "actions-music-alt": { + "identifier": "actions-music-alt", + "category": "actions", + "svg": "svgs/actions/actions-music-alt.svg", + "sprite": "sprites/actions.svg#actions-music-alt", + "bidi": false + }, + "actions-music": { + "identifier": "actions-music", + "category": "actions", + "svg": "svgs/actions/actions-music.svg", + "sprite": "sprites/actions.svg#actions-music", + "bidi": false + }, + "actions-newspaper": { + "identifier": "actions-newspaper", + "category": "actions", + "svg": "svgs/actions/actions-newspaper.svg", + "sprite": "sprites/actions.svg#actions-newspaper", + "bidi": false + }, + "actions-note": { + "identifier": "actions-note", + "category": "actions", + "svg": "svgs/actions/actions-note.svg", + "sprite": "sprites/actions.svg#actions-note", + "bidi": false + }, + "actions-notebook-typoscript": { + "identifier": "actions-notebook-typoscript", + "category": "actions", + "svg": "svgs/actions/actions-notebook-typoscript.svg", + "sprite": "sprites/actions.svg#actions-notebook-typoscript", + "bidi": false + }, + "actions-notebook": { + "identifier": "actions-notebook", + "category": "actions", + "svg": "svgs/actions/actions-notebook.svg", + "sprite": "sprites/actions.svg#actions-notebook", + "bidi": false + }, + "actions-open": { + "identifier": "actions-open", + "category": "actions", + "svg": "svgs/actions/actions-open.svg", + "sprite": "sprites/actions.svg#actions-open", + "bidi": false + }, + "actions-options": { + "identifier": "actions-options", + "category": "actions", + "svg": "svgs/actions/actions-options.svg", + "sprite": "sprites/actions.svg#actions-options", + "bidi": false + }, + "actions-package": { + "identifier": "actions-package", + "category": "actions", + "svg": "svgs/actions/actions-package.svg", + "sprite": "sprites/actions.svg#actions-package", + "bidi": false + }, + "actions-pagetree-mount": { + "identifier": "actions-pagetree-mount", + "category": "actions", + "svg": "svgs/actions/actions-pagetree-mount.svg", + "sprite": "sprites/actions.svg#actions-pagetree-mount", + "bidi": false + }, + "actions-pagetree": { + "identifier": "actions-pagetree", + "category": "actions", + "svg": "svgs/actions/actions-pagetree.svg", + "sprite": "sprites/actions.svg#actions-pagetree", + "bidi": false + }, + "actions-panel-collapse-end": { + "identifier": "actions-panel-collapse-end", + "category": "actions", + "svg": "svgs/actions/actions-panel-collapse-end.svg", + "sprite": "sprites/actions.svg#actions-panel-collapse-end", + "bidi": true + }, + "actions-panel-collapse-start": { + "identifier": "actions-panel-collapse-start", + "category": "actions", + "svg": "svgs/actions/actions-panel-collapse-start.svg", + "sprite": "sprites/actions.svg#actions-panel-collapse-start", + "bidi": true + }, + "actions-panel-expand-end": { + "identifier": "actions-panel-expand-end", + "category": "actions", + "svg": "svgs/actions/actions-panel-expand-end.svg", + "sprite": "sprites/actions.svg#actions-panel-expand-end", + "bidi": true + }, + "actions-panel-expand-start": { + "identifier": "actions-panel-expand-start", + "category": "actions", + "svg": "svgs/actions/actions-panel-expand-start.svg", + "sprite": "sprites/actions.svg#actions-panel-expand-start", + "bidi": true + }, + "actions-paperplane": { + "identifier": "actions-paperplane", + "category": "actions", + "svg": "svgs/actions/actions-paperplane.svg", + "sprite": "sprites/actions.svg#actions-paperplane", + "bidi": false + }, + "actions-paste-after": { + "identifier": "actions-paste-after", + "category": "actions", + "svg": "svgs/actions/actions-paste-after.svg", + "sprite": "sprites/actions.svg#actions-paste-after", + "bidi": false + }, + "actions-paste-before": { + "identifier": "actions-paste-before", + "category": "actions", + "svg": "svgs/actions/actions-paste-before.svg", + "sprite": "sprites/actions.svg#actions-paste-before", + "bidi": false + }, + "actions-pause": { + "identifier": "actions-pause", + "category": "actions", + "svg": "svgs/actions/actions-pause.svg", + "sprite": "sprites/actions.svg#actions-pause", + "bidi": false + }, + "actions-percent-badge": { + "identifier": "actions-percent-badge", + "category": "actions", + "svg": "svgs/actions/actions-percent-badge.svg", + "sprite": "sprites/actions.svg#actions-percent-badge", + "bidi": false + }, + "actions-percent": { + "identifier": "actions-percent", + "category": "actions", + "svg": "svgs/actions/actions-percent.svg", + "sprite": "sprites/actions.svg#actions-percent", + "bidi": false + }, + "actions-phone": { + "identifier": "actions-phone", + "category": "actions", + "svg": "svgs/actions/actions-phone.svg", + "sprite": "sprites/actions.svg#actions-phone", + "bidi": false + }, + "actions-placeholder-add": { + "identifier": "actions-placeholder-add", + "category": "actions", + "svg": "svgs/actions/actions-placeholder-add.svg", + "sprite": "sprites/actions.svg#actions-placeholder-add", + "bidi": false + }, + "actions-placeholder": { + "identifier": "actions-placeholder", + "category": "actions", + "svg": "svgs/actions/actions-placeholder.svg", + "sprite": "sprites/actions.svg#actions-placeholder", + "bidi": false + }, + "actions-play": { + "identifier": "actions-play", + "category": "actions", + "svg": "svgs/actions/actions-play.svg", + "sprite": "sprites/actions.svg#actions-play", + "bidi": false + }, + "actions-plus-badge-alt": { + "identifier": "actions-plus-badge-alt", + "category": "actions", + "svg": "svgs/actions/actions-plus-badge-alt.svg", + "sprite": "sprites/actions.svg#actions-plus-badge-alt", + "bidi": false + }, + "actions-plus-badge": { + "identifier": "actions-plus-badge", + "category": "actions", + "svg": "svgs/actions/actions-plus-badge.svg", + "sprite": "sprites/actions.svg#actions-plus-badge", + "bidi": false + }, + "actions-plus-circle-alt": { + "identifier": "actions-plus-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-plus-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-plus-circle-alt", + "bidi": false + }, + "actions-plus-circle": { + "identifier": "actions-plus-circle", + "category": "actions", + "svg": "svgs/actions/actions-plus-circle.svg", + "sprite": "sprites/actions.svg#actions-plus-circle", + "bidi": false + }, + "actions-plus-square-alt": { + "identifier": "actions-plus-square-alt", + "category": "actions", + "svg": "svgs/actions/actions-plus-square-alt.svg", + "sprite": "sprites/actions.svg#actions-plus-square-alt", + "bidi": false + }, + "actions-plus-square": { + "identifier": "actions-plus-square", + "category": "actions", + "svg": "svgs/actions/actions-plus-square.svg", + "sprite": "sprites/actions.svg#actions-plus-square", + "bidi": false + }, + "actions-plus": { + "identifier": "actions-plus", + "category": "actions", + "svg": "svgs/actions/actions-plus.svg", + "sprite": "sprites/actions.svg#actions-plus", + "bidi": false + }, + "actions-preview": { + "identifier": "actions-preview", + "category": "actions", + "svg": "svgs/actions/actions-preview.svg", + "sprite": "sprites/actions.svg#actions-preview", + "bidi": false + }, + "actions-qrcode": { + "identifier": "actions-qrcode", + "category": "actions", + "svg": "svgs/actions/actions-qrcode.svg", + "sprite": "sprites/actions.svg#actions-qrcode", + "bidi": false + }, + "actions-question-circle-alt": { + "identifier": "actions-question-circle-alt", + "category": "actions", + "svg": "svgs/actions/actions-question-circle-alt.svg", + "sprite": "sprites/actions.svg#actions-question-circle-alt", + "bidi": false + }, + "actions-question-circle": { + "identifier": "actions-question-circle", + "category": "actions", + "svg": "svgs/actions/actions-question-circle.svg", + "sprite": "sprites/actions.svg#actions-question-circle", + "bidi": false + }, + "actions-question": { + "identifier": "actions-question", + "category": "actions", + "svg": "svgs/actions/actions-question.svg", + "sprite": "sprites/actions.svg#actions-question", + "bidi": false + }, + "actions-random": { + "identifier": "actions-random", + "category": "actions", + "svg": "svgs/actions/actions-random.svg", + "sprite": "sprites/actions.svg#actions-random", + "bidi": true + }, + "actions-receipt": { + "identifier": "actions-receipt", + "category": "actions", + "svg": "svgs/actions/actions-receipt.svg", + "sprite": "sprites/actions.svg#actions-receipt", + "bidi": false + }, + "actions-redo": { + "identifier": "actions-redo", + "category": "actions", + "svg": "svgs/actions/actions-redo.svg", + "sprite": "sprites/actions.svg#actions-redo", + "bidi": true + }, + "actions-refresh": { + "identifier": "actions-refresh", + "category": "actions", + "svg": "svgs/actions/actions-refresh.svg", + "sprite": "sprites/actions.svg#actions-refresh", + "bidi": false + }, + "actions-rename": { + "identifier": "actions-rename", + "category": "actions", + "svg": "svgs/actions/actions-rename.svg", + "sprite": "sprites/actions.svg#actions-rename", + "bidi": false + }, + "actions-replace": { + "identifier": "actions-replace", + "category": "actions", + "svg": "svgs/actions/actions-replace.svg", + "sprite": "sprites/actions.svg#actions-replace", + "bidi": false + }, + "actions-rocket": { + "identifier": "actions-rocket", + "category": "actions", + "svg": "svgs/actions/actions-rocket.svg", + "sprite": "sprites/actions.svg#actions-rocket", + "bidi": false + }, + "actions-rss": { + "identifier": "actions-rss", + "category": "actions", + "svg": "svgs/actions/actions-rss.svg", + "sprite": "sprites/actions.svg#actions-rss", + "bidi": false + }, + "actions-save-add": { + "identifier": "actions-save-add", + "category": "actions", + "svg": "svgs/actions/actions-save-add.svg", + "sprite": "sprites/actions.svg#actions-save-add", + "bidi": false + }, + "actions-save-close": { + "identifier": "actions-save-close", + "category": "actions", + "svg": "svgs/actions/actions-save-close.svg", + "sprite": "sprites/actions.svg#actions-save-close", + "bidi": false + }, + "actions-save-translation-clearcache": { + "identifier": "actions-save-translation-clearcache", + "category": "actions", + "svg": "svgs/actions/actions-save-translation-clearcache.svg", + "sprite": "sprites/actions.svg#actions-save-translation-clearcache", + "bidi": false + }, + "actions-save-translation": { + "identifier": "actions-save-translation", + "category": "actions", + "svg": "svgs/actions/actions-save-translation.svg", + "sprite": "sprites/actions.svg#actions-save-translation", + "bidi": false + }, + "actions-save-view": { + "identifier": "actions-save-view", + "category": "actions", + "svg": "svgs/actions/actions-save-view.svg", + "sprite": "sprites/actions.svg#actions-save-view", + "bidi": false + }, + "actions-save": { + "identifier": "actions-save", + "category": "actions", + "svg": "svgs/actions/actions-save.svg", + "sprite": "sprites/actions.svg#actions-save", + "bidi": false + }, + "actions-search": { + "identifier": "actions-search", + "category": "actions", + "svg": "svgs/actions/actions-search.svg", + "sprite": "sprites/actions.svg#actions-search", + "bidi": false + }, + "actions-selection-elements-all": { + "identifier": "actions-selection-elements-all", + "category": "actions", + "svg": "svgs/actions/actions-selection-elements-all.svg", + "sprite": "sprites/actions.svg#actions-selection-elements-all", + "bidi": false + }, + "actions-selection-elements-invert": { + "identifier": "actions-selection-elements-invert", + "category": "actions", + "svg": "svgs/actions/actions-selection-elements-invert.svg", + "sprite": "sprites/actions.svg#actions-selection-elements-invert", + "bidi": false + }, + "actions-selection-elements-none": { + "identifier": "actions-selection-elements-none", + "category": "actions", + "svg": "svgs/actions/actions-selection-elements-none.svg", + "sprite": "sprites/actions.svg#actions-selection-elements-none", + "bidi": false + }, + "actions-selection": { + "identifier": "actions-selection", + "category": "actions", + "svg": "svgs/actions/actions-selection.svg", + "sprite": "sprites/actions.svg#actions-selection", + "bidi": false + }, + "actions-server": { + "identifier": "actions-server", + "category": "actions", + "svg": "svgs/actions/actions-server.svg", + "sprite": "sprites/actions.svg#actions-server", + "bidi": false + }, + "actions-share-alt": { + "identifier": "actions-share-alt", + "category": "actions", + "svg": "svgs/actions/actions-share-alt.svg", + "sprite": "sprites/actions.svg#actions-share-alt", + "bidi": false + }, + "actions-share": { + "identifier": "actions-share", + "category": "actions", + "svg": "svgs/actions/actions-share.svg", + "sprite": "sprites/actions.svg#actions-share", + "bidi": false + }, + "actions-shield-star": { + "identifier": "actions-shield-star", + "category": "actions", + "svg": "svgs/actions/actions-shield-star.svg", + "sprite": "sprites/actions.svg#actions-shield-star", + "bidi": false + }, + "actions-shield-typo3": { + "identifier": "actions-shield-typo3", + "category": "actions", + "svg": "svgs/actions/actions-shield-typo3.svg", + "sprite": "sprites/actions.svg#actions-shield-typo3", + "bidi": false + }, + "actions-shield": { + "identifier": "actions-shield", + "category": "actions", + "svg": "svgs/actions/actions-shield.svg", + "sprite": "sprites/actions.svg#actions-shield", + "bidi": false + }, + "actions-soft-hyphen": { + "identifier": "actions-soft-hyphen", + "category": "actions", + "svg": "svgs/actions/actions-soft-hyphen.svg", + "sprite": "sprites/actions.svg#actions-soft-hyphen", + "bidi": false + }, + "actions-sort-amount-down": { + "identifier": "actions-sort-amount-down", + "category": "actions", + "svg": "svgs/actions/actions-sort-amount-down.svg", + "sprite": "sprites/actions.svg#actions-sort-amount-down", + "bidi": false + }, + "actions-sort-amount-up": { + "identifier": "actions-sort-amount-up", + "category": "actions", + "svg": "svgs/actions/actions-sort-amount-up.svg", + "sprite": "sprites/actions.svg#actions-sort-amount-up", + "bidi": false + }, + "actions-sort-amount": { + "identifier": "actions-sort-amount", + "category": "actions", + "svg": "svgs/actions/actions-sort-amount.svg", + "sprite": "sprites/actions.svg#actions-sort-amount", + "bidi": false + }, + "actions-square": { + "identifier": "actions-square", + "category": "actions", + "svg": "svgs/actions/actions-square.svg", + "sprite": "sprites/actions.svg#actions-square", + "bidi": false + }, + "actions-star-alt": { + "identifier": "actions-star-alt", + "category": "actions", + "svg": "svgs/actions/actions-star-alt.svg", + "sprite": "sprites/actions.svg#actions-star-alt", + "bidi": false + }, + "actions-star": { + "identifier": "actions-star", + "category": "actions", + "svg": "svgs/actions/actions-star.svg", + "sprite": "sprites/actions.svg#actions-star", + "bidi": false + }, + "actions-store": { + "identifier": "actions-store", + "category": "actions", + "svg": "svgs/actions/actions-store.svg", + "sprite": "sprites/actions.svg#actions-store", + "bidi": false + }, + "actions-surfboard": { + "identifier": "actions-surfboard", + "category": "actions", + "svg": "svgs/actions/actions-surfboard.svg", + "sprite": "sprites/actions.svg#actions-surfboard", + "bidi": false + }, + "actions-swap": { + "identifier": "actions-swap", + "category": "actions", + "svg": "svgs/actions/actions-swap.svg", + "sprite": "sprites/actions.svg#actions-swap", + "bidi": true + }, + "actions-synchronize": { + "identifier": "actions-synchronize", + "category": "actions", + "svg": "svgs/actions/actions-synchronize.svg", + "sprite": "sprites/actions.svg#actions-synchronize", + "bidi": false + }, + "actions-table": { + "identifier": "actions-table", + "category": "actions", + "svg": "svgs/actions/actions-table.svg", + "sprite": "sprites/actions.svg#actions-table", + "bidi": false + }, + "actions-tag": { + "identifier": "actions-tag", + "category": "actions", + "svg": "svgs/actions/actions-tag.svg", + "sprite": "sprites/actions.svg#actions-tag", + "bidi": false + }, + "actions-template-new": { + "identifier": "actions-template-new", + "category": "actions", + "svg": "svgs/actions/actions-template-new.svg", + "sprite": "sprites/actions.svg#actions-template-new", + "bidi": false + }, + "actions-template": { + "identifier": "actions-template", + "category": "actions", + "svg": "svgs/actions/actions-template.svg", + "sprite": "sprites/actions.svg#actions-template", + "bidi": false + }, + "actions-terminal": { + "identifier": "actions-terminal", + "category": "actions", + "svg": "svgs/actions/actions-terminal.svg", + "sprite": "sprites/actions.svg#actions-terminal", + "bidi": false + }, + "actions-text-indent": { + "identifier": "actions-text-indent", + "category": "actions", + "svg": "svgs/actions/actions-text-indent.svg", + "sprite": "sprites/actions.svg#actions-text-indent", + "bidi": false + }, + "actions-thumbtack": { + "identifier": "actions-thumbtack", + "category": "actions", + "svg": "svgs/actions/actions-thumbtack.svg", + "sprite": "sprites/actions.svg#actions-thumbtack", + "bidi": false + }, + "actions-ticket": { + "identifier": "actions-ticket", + "category": "actions", + "svg": "svgs/actions/actions-ticket.svg", + "sprite": "sprites/actions.svg#actions-ticket", + "bidi": false + }, + "actions-toggle-off": { + "identifier": "actions-toggle-off", + "category": "actions", + "svg": "svgs/actions/actions-toggle-off.svg", + "sprite": "sprites/actions.svg#actions-toggle-off", + "bidi": false + }, + "actions-toggle-on": { + "identifier": "actions-toggle-on", + "category": "actions", + "svg": "svgs/actions/actions-toggle-on.svg", + "sprite": "sprites/actions.svg#actions-toggle-on", + "bidi": false + }, + "actions-translate": { + "identifier": "actions-translate", + "category": "actions", + "svg": "svgs/actions/actions-translate.svg", + "sprite": "sprites/actions.svg#actions-translate", + "bidi": false + }, + "actions-triangle": { + "identifier": "actions-triangle", + "category": "actions", + "svg": "svgs/actions/actions-triangle.svg", + "sprite": "sprites/actions.svg#actions-triangle", + "bidi": false + }, + "actions-trophy": { + "identifier": "actions-trophy", + "category": "actions", + "svg": "svgs/actions/actions-trophy.svg", + "sprite": "sprites/actions.svg#actions-trophy", + "bidi": false + }, + "actions-undo": { + "identifier": "actions-undo", + "category": "actions", + "svg": "svgs/actions/actions-undo.svg", + "sprite": "sprites/actions.svg#actions-undo", + "bidi": true + }, + "actions-university": { + "identifier": "actions-university", + "category": "actions", + "svg": "svgs/actions/actions-university.svg", + "sprite": "sprites/actions.svg#actions-university", + "bidi": false + }, + "actions-unlink": { + "identifier": "actions-unlink", + "category": "actions", + "svg": "svgs/actions/actions-unlink.svg", + "sprite": "sprites/actions.svg#actions-unlink", + "bidi": false + }, + "actions-unlock": { + "identifier": "actions-unlock", + "category": "actions", + "svg": "svgs/actions/actions-unlock.svg", + "sprite": "sprites/actions.svg#actions-unlock", + "bidi": false + }, + "actions-upload": { + "identifier": "actions-upload", + "category": "actions", + "svg": "svgs/actions/actions-upload.svg", + "sprite": "sprites/actions.svg#actions-upload", + "bidi": false + }, + "actions-user-emulate": { + "identifier": "actions-user-emulate", + "category": "actions", + "svg": "svgs/actions/actions-user-emulate.svg", + "sprite": "sprites/actions.svg#actions-user-emulate", + "bidi": false + }, + "actions-user-switch": { + "identifier": "actions-user-switch", + "category": "actions", + "svg": "svgs/actions/actions-user-switch.svg", + "sprite": "sprites/actions.svg#actions-user-switch", + "bidi": false + }, + "actions-user": { + "identifier": "actions-user", + "category": "actions", + "svg": "svgs/actions/actions-user.svg", + "sprite": "sprites/actions.svg#actions-user", + "bidi": false + }, + "actions-users": { + "identifier": "actions-users", + "category": "actions", + "svg": "svgs/actions/actions-users.svg", + "sprite": "sprites/actions.svg#actions-users", + "bidi": false + }, + "actions-variable-add": { + "identifier": "actions-variable-add", + "category": "actions", + "svg": "svgs/actions/actions-variable-add.svg", + "sprite": "sprites/actions.svg#actions-variable-add", + "bidi": false + }, + "actions-variable-remove": { + "identifier": "actions-variable-remove", + "category": "actions", + "svg": "svgs/actions/actions-variable-remove.svg", + "sprite": "sprites/actions.svg#actions-variable-remove", + "bidi": false + }, + "actions-variable": { + "identifier": "actions-variable", + "category": "actions", + "svg": "svgs/actions/actions-variable.svg", + "sprite": "sprites/actions.svg#actions-variable", + "bidi": false + }, + "actions-video": { + "identifier": "actions-video", + "category": "actions", + "svg": "svgs/actions/actions-video.svg", + "sprite": "sprites/actions.svg#actions-video", + "bidi": false + }, + "actions-viewmode-compare": { + "identifier": "actions-viewmode-compare", + "category": "actions", + "svg": "svgs/actions/actions-viewmode-compare.svg", + "sprite": "sprites/actions.svg#actions-viewmode-compare", + "bidi": false + }, + "actions-viewmode-layout": { + "identifier": "actions-viewmode-layout", + "category": "actions", + "svg": "svgs/actions/actions-viewmode-layout.svg", + "sprite": "sprites/actions.svg#actions-viewmode-layout", + "bidi": false + }, + "actions-viewmode-list": { + "identifier": "actions-viewmode-list", + "category": "actions", + "svg": "svgs/actions/actions-viewmode-list.svg", + "sprite": "sprites/actions.svg#actions-viewmode-list", + "bidi": false + }, + "actions-viewmode-photos": { + "identifier": "actions-viewmode-photos", + "category": "actions", + "svg": "svgs/actions/actions-viewmode-photos.svg", + "sprite": "sprites/actions.svg#actions-viewmode-photos", + "bidi": false + }, + "actions-viewmode-tiles": { + "identifier": "actions-viewmode-tiles", + "category": "actions", + "svg": "svgs/actions/actions-viewmode-tiles.svg", + "sprite": "sprites/actions.svg#actions-viewmode-tiles", + "bidi": false + }, + "actions-wallet": { + "identifier": "actions-wallet", + "category": "actions", + "svg": "svgs/actions/actions-wallet.svg", + "sprite": "sprites/actions.svg#actions-wallet", + "bidi": false + }, + "actions-wand-sparkles": { + "identifier": "actions-wand-sparkles", + "category": "actions", + "svg": "svgs/actions/actions-wand-sparkles.svg", + "sprite": "sprites/actions.svg#actions-wand-sparkles", + "bidi": false + }, + "actions-wand": { + "identifier": "actions-wand", + "category": "actions", + "svg": "svgs/actions/actions-wand.svg", + "sprite": "sprites/actions.svg#actions-wand", + "bidi": false + }, + "actions-wave": { + "identifier": "actions-wave", + "category": "actions", + "svg": "svgs/actions/actions-wave.svg", + "sprite": "sprites/actions.svg#actions-wave", + "bidi": false + }, + "actions-webhook": { + "identifier": "actions-webhook", + "category": "actions", + "svg": "svgs/actions/actions-webhook.svg", + "sprite": "sprites/actions.svg#actions-webhook", + "bidi": false + }, + "actions-window-cog": { + "identifier": "actions-window-cog", + "category": "actions", + "svg": "svgs/actions/actions-window-cog.svg", + "sprite": "sprites/actions.svg#actions-window-cog", + "bidi": false + }, + "actions-window-open": { + "identifier": "actions-window-open", + "category": "actions", + "svg": "svgs/actions/actions-window-open.svg", + "sprite": "sprites/actions.svg#actions-window-open", + "bidi": false + }, + "actions-window-restore": { + "identifier": "actions-window-restore", + "category": "actions", + "svg": "svgs/actions/actions-window-restore.svg", + "sprite": "sprites/actions.svg#actions-window-restore", + "bidi": false + }, + "actions-window": { + "identifier": "actions-window", + "category": "actions", + "svg": "svgs/actions/actions-window.svg", + "sprite": "sprites/actions.svg#actions-window", + "bidi": false + }, + "actions-workspace": { + "identifier": "actions-workspace", + "category": "actions", + "svg": "svgs/actions/actions-workspace.svg", + "sprite": "sprites/actions.svg#actions-workspace", + "bidi": false + }, + "apps-clipboard-images": { + "identifier": "apps-clipboard-images", + "category": "apps", + "svg": "svgs/apps/apps-clipboard-images.svg", + "sprite": "sprites/apps.svg#apps-clipboard-images", + "bidi": false + }, + "apps-clipboard-list": { + "identifier": "apps-clipboard-list", + "category": "apps", + "svg": "svgs/apps/apps-clipboard-list.svg", + "sprite": "sprites/apps.svg#apps-clipboard-list", + "bidi": false + }, + "apps-filetree-folder-add": { + "identifier": "apps-filetree-folder-add", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-add.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-add", + "bidi": false + }, + "apps-filetree-folder-default": { + "identifier": "apps-filetree-folder-default", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-default.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-default", + "bidi": false + }, + "apps-filetree-folder-list": { + "identifier": "apps-filetree-folder-list", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-list.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-list", + "bidi": false + }, + "apps-filetree-folder-locked": { + "identifier": "apps-filetree-folder-locked", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-locked.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-locked", + "bidi": false + }, + "apps-filetree-folder-media": { + "identifier": "apps-filetree-folder-media", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-media.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-media", + "bidi": false + }, + "apps-filetree-folder-news": { + "identifier": "apps-filetree-folder-news", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-news.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-news", + "bidi": false + }, + "apps-filetree-folder-opened": { + "identifier": "apps-filetree-folder-opened", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-opened.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-opened", + "bidi": false + }, + "apps-filetree-folder-recycler": { + "identifier": "apps-filetree-folder-recycler", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-recycler.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-recycler", + "bidi": false + }, + "apps-filetree-folder-temp": { + "identifier": "apps-filetree-folder-temp", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-temp.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-temp", + "bidi": false + }, + "apps-filetree-folder-user": { + "identifier": "apps-filetree-folder-user", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder-user.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder-user", + "bidi": false + }, + "apps-filetree-folder": { + "identifier": "apps-filetree-folder", + "category": "apps", + "svg": "svgs/apps/apps-filetree-folder.svg", + "sprite": "sprites/apps.svg#apps-filetree-folder", + "bidi": false + }, + "apps-filetree-mount": { + "identifier": "apps-filetree-mount", + "category": "apps", + "svg": "svgs/apps/apps-filetree-mount.svg", + "sprite": "sprites/apps.svg#apps-filetree-mount", + "bidi": false + }, + "apps-filetree-root": { + "identifier": "apps-filetree-root", + "category": "apps", + "svg": "svgs/apps/apps-filetree-root.svg", + "sprite": "sprites/apps.svg#apps-filetree-root", + "bidi": false + }, + "apps-pagetree-backend-user-hideinmenu": { + "identifier": "apps-pagetree-backend-user-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-backend-user-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-backend-user-hideinmenu", + "bidi": false + }, + "apps-pagetree-backend-user": { + "identifier": "apps-pagetree-backend-user", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-backend-user.svg", + "sprite": "sprites/apps.svg#apps-pagetree-backend-user", + "bidi": false + }, + "apps-pagetree-category-collapse-all": { + "identifier": "apps-pagetree-category-collapse-all", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-category-collapse-all.svg", + "sprite": "sprites/apps.svg#apps-pagetree-category-collapse-all", + "bidi": false + }, + "apps-pagetree-category-expand-all": { + "identifier": "apps-pagetree-category-expand-all", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-category-expand-all.svg", + "sprite": "sprites/apps.svg#apps-pagetree-category-expand-all", + "bidi": false + }, + "apps-pagetree-drag-copy-above": { + "identifier": "apps-pagetree-drag-copy-above", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-copy-above.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-copy-above", + "bidi": false + }, + "apps-pagetree-drag-copy-below": { + "identifier": "apps-pagetree-drag-copy-below", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-copy-below.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-copy-below", + "bidi": false + }, + "apps-pagetree-drag-move-above": { + "identifier": "apps-pagetree-drag-move-above", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-move-above.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-move-above", + "bidi": false + }, + "apps-pagetree-drag-move-below": { + "identifier": "apps-pagetree-drag-move-below", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-move-below.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-move-below", + "bidi": false + }, + "apps-pagetree-drag-move-between": { + "identifier": "apps-pagetree-drag-move-between", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-move-between.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-move-between", + "bidi": false + }, + "apps-pagetree-drag-move-into": { + "identifier": "apps-pagetree-drag-move-into", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-move-into.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-move-into", + "bidi": false + }, + "apps-pagetree-drag-new-between": { + "identifier": "apps-pagetree-drag-new-between", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-new-between.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-new-between", + "bidi": false + }, + "apps-pagetree-drag-new-inside": { + "identifier": "apps-pagetree-drag-new-inside", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-new-inside.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-new-inside", + "bidi": false + }, + "apps-pagetree-drag-place-denied": { + "identifier": "apps-pagetree-drag-place-denied", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-drag-place-denied.svg", + "sprite": "sprites/apps.svg#apps-pagetree-drag-place-denied", + "bidi": false + }, + "apps-pagetree-folder-contains-approve": { + "identifier": "apps-pagetree-folder-contains-approve", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains-approve.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains-approve", + "bidi": false + }, + "apps-pagetree-folder-contains-board": { + "identifier": "apps-pagetree-folder-contains-board", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains-board.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains-board", + "bidi": false + }, + "apps-pagetree-folder-contains-fe_users": { + "identifier": "apps-pagetree-folder-contains-fe_users", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains-fe_users.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains-fe_users", + "bidi": false + }, + "apps-pagetree-folder-contains-news": { + "identifier": "apps-pagetree-folder-contains-news", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains-news.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains-news", + "bidi": false + }, + "apps-pagetree-folder-contains-shop": { + "identifier": "apps-pagetree-folder-contains-shop", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains-shop.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains-shop", + "bidi": false + }, + "apps-pagetree-folder-contains": { + "identifier": "apps-pagetree-folder-contains", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-contains.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-contains", + "bidi": false + }, + "apps-pagetree-folder-default": { + "identifier": "apps-pagetree-folder-default", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-default.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-default", + "bidi": false + }, + "apps-pagetree-folder-hideinmenu": { + "identifier": "apps-pagetree-folder-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-hideinmenu", + "bidi": false + }, + "apps-pagetree-folder-root": { + "identifier": "apps-pagetree-folder-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-folder-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-folder-root", + "bidi": false + }, + "apps-pagetree-page-advanced-hideinmenu": { + "identifier": "apps-pagetree-page-advanced-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-advanced-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-advanced-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-advanced-root": { + "identifier": "apps-pagetree-page-advanced-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-advanced-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-advanced-root", + "bidi": false + }, + "apps-pagetree-page-advanced": { + "identifier": "apps-pagetree-page-advanced", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-advanced.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-advanced", + "bidi": false + }, + "apps-pagetree-page-backend-user-hideinmenu": { + "identifier": "apps-pagetree-page-backend-user-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-user-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-user-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-backend-user-root": { + "identifier": "apps-pagetree-page-backend-user-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-user-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-user-root", + "bidi": false + }, + "apps-pagetree-page-backend-user": { + "identifier": "apps-pagetree-page-backend-user", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-user.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-user", + "bidi": false + }, + "apps-pagetree-page-backend-users-hideinmenu": { + "identifier": "apps-pagetree-page-backend-users-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-users-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-users-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-backend-users-root": { + "identifier": "apps-pagetree-page-backend-users-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-users-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-users-root", + "bidi": false + }, + "apps-pagetree-page-backend-users": { + "identifier": "apps-pagetree-page-backend-users", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-backend-users.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-backend-users", + "bidi": false + }, + "apps-pagetree-page-content-from-page-hideinmenu": { + "identifier": "apps-pagetree-page-content-from-page-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-content-from-page-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-content-from-page-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-content-from-page-root": { + "identifier": "apps-pagetree-page-content-from-page-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-content-from-page-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-content-from-page-root", + "bidi": false + }, + "apps-pagetree-page-content-from-page": { + "identifier": "apps-pagetree-page-content-from-page", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-content-from-page.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-content-from-page", + "bidi": false + }, + "apps-pagetree-page-default": { + "identifier": "apps-pagetree-page-default", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-default.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-default", + "bidi": false + }, + "apps-pagetree-page-domain": { + "identifier": "apps-pagetree-page-domain", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-domain.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-domain", + "bidi": false + }, + "apps-pagetree-page-frontend-user-hideinmenu": { + "identifier": "apps-pagetree-page-frontend-user-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-user-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-user-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-frontend-user-root": { + "identifier": "apps-pagetree-page-frontend-user-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-user-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-user-root", + "bidi": false + }, + "apps-pagetree-page-frontend-user": { + "identifier": "apps-pagetree-page-frontend-user", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-user.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-user", + "bidi": false + }, + "apps-pagetree-page-frontend-users-hideinmenu": { + "identifier": "apps-pagetree-page-frontend-users-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-users-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-users-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-frontend-users-root": { + "identifier": "apps-pagetree-page-frontend-users-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-users-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-users-root", + "bidi": false + }, + "apps-pagetree-page-frontend-users": { + "identifier": "apps-pagetree-page-frontend-users", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-frontend-users.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-frontend-users", + "bidi": false + }, + "apps-pagetree-page-hideinmenu": { + "identifier": "apps-pagetree-page-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-mountpoint-hideinmenu": { + "identifier": "apps-pagetree-page-mountpoint-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-mountpoint-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-mountpoint-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-mountpoint-root": { + "identifier": "apps-pagetree-page-mountpoint-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-mountpoint-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-mountpoint-root", + "bidi": false + }, + "apps-pagetree-page-mountpoint": { + "identifier": "apps-pagetree-page-mountpoint", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-mountpoint.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-mountpoint", + "bidi": false + }, + "apps-pagetree-page-recycler-hideinmenu": { + "identifier": "apps-pagetree-page-recycler-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-recycler-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-recycler-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-recycler": { + "identifier": "apps-pagetree-page-recycler", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-recycler.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-recycler", + "bidi": false + }, + "apps-pagetree-page-shortcut-external-hideinmenu": { + "identifier": "apps-pagetree-page-shortcut-external-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut-external-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut-external-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-shortcut-external-root": { + "identifier": "apps-pagetree-page-shortcut-external-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut-external-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut-external-root", + "bidi": false + }, + "apps-pagetree-page-shortcut-external": { + "identifier": "apps-pagetree-page-shortcut-external", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut-external.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut-external", + "bidi": false + }, + "apps-pagetree-page-shortcut-hideinmenu": { + "identifier": "apps-pagetree-page-shortcut-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut-hideinmenu", + "bidi": false + }, + "apps-pagetree-page-shortcut-root": { + "identifier": "apps-pagetree-page-shortcut-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut-root", + "bidi": false + }, + "apps-pagetree-page-shortcut": { + "identifier": "apps-pagetree-page-shortcut", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page-shortcut.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page-shortcut", + "bidi": false + }, + "apps-pagetree-page": { + "identifier": "apps-pagetree-page", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-page.svg", + "sprite": "sprites/apps.svg#apps-pagetree-page", + "bidi": false + }, + "apps-pagetree-root": { + "identifier": "apps-pagetree-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-root", + "bidi": false + }, + "apps-pagetree-spacer-hideinmenu": { + "identifier": "apps-pagetree-spacer-hideinmenu", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-spacer-hideinmenu.svg", + "sprite": "sprites/apps.svg#apps-pagetree-spacer-hideinmenu", + "bidi": false + }, + "apps-pagetree-spacer-root": { + "identifier": "apps-pagetree-spacer-root", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-spacer-root.svg", + "sprite": "sprites/apps.svg#apps-pagetree-spacer-root", + "bidi": false + }, + "apps-pagetree-spacer": { + "identifier": "apps-pagetree-spacer", + "category": "apps", + "svg": "svgs/apps/apps-pagetree-spacer.svg", + "sprite": "sprites/apps.svg#apps-pagetree-spacer", + "bidi": false + }, + "avatar-default": { + "identifier": "avatar-default", + "category": "avatar", + "svg": "svgs/avatar/avatar-default.svg", + "sprite": "sprites/avatar.svg#avatar-default", + "bidi": false + }, + "content-accordion": { + "identifier": "content-accordion", + "category": "content", + "svg": "svgs/content/content-accordion.svg", + "sprite": "sprites/content.svg#content-accordion", + "bidi": false + }, + "content-audio": { + "identifier": "content-audio", + "category": "content", + "svg": "svgs/content/content-audio.svg", + "sprite": "sprites/content.svg#content-audio", + "bidi": false + }, + "content-beside-text-img-above-center": { + "identifier": "content-beside-text-img-above-center", + "category": "content", + "svg": "svgs/content/content-beside-text-img-above-center.svg", + "sprite": "sprites/content.svg#content-beside-text-img-above-center", + "bidi": false + }, + "content-beside-text-img-above-left": { + "identifier": "content-beside-text-img-above-left", + "category": "content", + "svg": "svgs/content/content-beside-text-img-above-left.svg", + "sprite": "sprites/content.svg#content-beside-text-img-above-left", + "bidi": false + }, + "content-beside-text-img-above-right": { + "identifier": "content-beside-text-img-above-right", + "category": "content", + "svg": "svgs/content/content-beside-text-img-above-right.svg", + "sprite": "sprites/content.svg#content-beside-text-img-above-right", + "bidi": false + }, + "content-beside-text-img-below-center": { + "identifier": "content-beside-text-img-below-center", + "category": "content", + "svg": "svgs/content/content-beside-text-img-below-center.svg", + "sprite": "sprites/content.svg#content-beside-text-img-below-center", + "bidi": false + }, + "content-beside-text-img-below-left": { + "identifier": "content-beside-text-img-below-left", + "category": "content", + "svg": "svgs/content/content-beside-text-img-below-left.svg", + "sprite": "sprites/content.svg#content-beside-text-img-below-left", + "bidi": false + }, + "content-beside-text-img-below-right": { + "identifier": "content-beside-text-img-below-right", + "category": "content", + "svg": "svgs/content/content-beside-text-img-below-right.svg", + "sprite": "sprites/content.svg#content-beside-text-img-below-right", + "bidi": false + }, + "content-beside-text-img-centered-left": { + "identifier": "content-beside-text-img-centered-left", + "category": "content", + "svg": "svgs/content/content-beside-text-img-centered-left.svg", + "sprite": "sprites/content.svg#content-beside-text-img-centered-left", + "bidi": false + }, + "content-beside-text-img-centered-right": { + "identifier": "content-beside-text-img-centered-right", + "category": "content", + "svg": "svgs/content/content-beside-text-img-centered-right.svg", + "sprite": "sprites/content.svg#content-beside-text-img-centered-right", + "bidi": false + }, + "content-beside-text-img-left": { + "identifier": "content-beside-text-img-left", + "category": "content", + "svg": "svgs/content/content-beside-text-img-left.svg", + "sprite": "sprites/content.svg#content-beside-text-img-left", + "bidi": false + }, + "content-beside-text-img-right": { + "identifier": "content-beside-text-img-right", + "category": "content", + "svg": "svgs/content/content-beside-text-img-right.svg", + "sprite": "sprites/content.svg#content-beside-text-img-right", + "bidi": false + }, + "content-book": { + "identifier": "content-book", + "category": "content", + "svg": "svgs/content/content-book.svg", + "sprite": "sprites/content.svg#content-book", + "bidi": false + }, + "content-bookmark": { + "identifier": "content-bookmark", + "category": "content", + "svg": "svgs/content/content-bookmark.svg", + "sprite": "sprites/content.svg#content-bookmark", + "bidi": false + }, + "content-briefcase": { + "identifier": "content-briefcase", + "category": "content", + "svg": "svgs/content/content-briefcase.svg", + "sprite": "sprites/content.svg#content-briefcase", + "bidi": false + }, + "content-building": { + "identifier": "content-building", + "category": "content", + "svg": "svgs/content/content-building.svg", + "sprite": "sprites/content.svg#content-building", + "bidi": false + }, + "content-bullets": { + "identifier": "content-bullets", + "category": "content", + "svg": "svgs/content/content-bullets.svg", + "sprite": "sprites/content.svg#content-bullets", + "bidi": false + }, + "content-card-group": { + "identifier": "content-card-group", + "category": "content", + "svg": "svgs/content/content-card-group.svg", + "sprite": "sprites/content.svg#content-card-group", + "bidi": false + }, + "content-card": { + "identifier": "content-card", + "category": "content", + "svg": "svgs/content/content-card.svg", + "sprite": "sprites/content.svg#content-card", + "bidi": false + }, + "content-carousel-header": { + "identifier": "content-carousel-header", + "category": "content", + "svg": "svgs/content/content-carousel-header.svg", + "sprite": "sprites/content.svg#content-carousel-header", + "bidi": false + }, + "content-carousel-html": { + "identifier": "content-carousel-html", + "category": "content", + "svg": "svgs/content/content-carousel-html.svg", + "sprite": "sprites/content.svg#content-carousel-html", + "bidi": false + }, + "content-carousel-image": { + "identifier": "content-carousel-image", + "category": "content", + "svg": "svgs/content/content-carousel-image.svg", + "sprite": "sprites/content.svg#content-carousel-image", + "bidi": false + }, + "content-carousel-item-calltoaction": { + "identifier": "content-carousel-item-calltoaction", + "category": "content", + "svg": "svgs/content/content-carousel-item-calltoaction.svg", + "sprite": "sprites/content.svg#content-carousel-item-calltoaction", + "bidi": false + }, + "content-carousel-item-textandimage": { + "identifier": "content-carousel-item-textandimage", + "category": "content", + "svg": "svgs/content/content-carousel-item-textandimage.svg", + "sprite": "sprites/content.svg#content-carousel-item-textandimage", + "bidi": false + }, + "content-carousel": { + "identifier": "content-carousel", + "category": "content", + "svg": "svgs/content/content-carousel.svg", + "sprite": "sprites/content.svg#content-carousel", + "bidi": false + }, + "content-certificate-alternative": { + "identifier": "content-certificate-alternative", + "category": "content", + "svg": "svgs/content/content-certificate-alternative.svg", + "sprite": "sprites/content.svg#content-certificate-alternative", + "bidi": false + }, + "content-certificate": { + "identifier": "content-certificate", + "category": "content", + "svg": "svgs/content/content-certificate.svg", + "sprite": "sprites/content.svg#content-certificate", + "bidi": false + }, + "content-clock": { + "identifier": "content-clock", + "category": "content", + "svg": "svgs/content/content-clock.svg", + "sprite": "sprites/content.svg#content-clock", + "bidi": false + }, + "content-coffee": { + "identifier": "content-coffee", + "category": "content", + "svg": "svgs/content/content-coffee.svg", + "sprite": "sprites/content.svg#content-coffee", + "bidi": false + }, + "content-container-columns-1": { + "identifier": "content-container-columns-1", + "category": "content", + "svg": "svgs/content/content-container-columns-1.svg", + "sprite": "sprites/content.svg#content-container-columns-1", + "bidi": false + }, + "content-container-columns-2-left": { + "identifier": "content-container-columns-2-left", + "category": "content", + "svg": "svgs/content/content-container-columns-2-left.svg", + "sprite": "sprites/content.svg#content-container-columns-2-left", + "bidi": false + }, + "content-container-columns-2-right": { + "identifier": "content-container-columns-2-right", + "category": "content", + "svg": "svgs/content/content-container-columns-2-right.svg", + "sprite": "sprites/content.svg#content-container-columns-2-right", + "bidi": false + }, + "content-container-columns-2": { + "identifier": "content-container-columns-2", + "category": "content", + "svg": "svgs/content/content-container-columns-2.svg", + "sprite": "sprites/content.svg#content-container-columns-2", + "bidi": false + }, + "content-container-columns-3": { + "identifier": "content-container-columns-3", + "category": "content", + "svg": "svgs/content/content-container-columns-3.svg", + "sprite": "sprites/content.svg#content-container-columns-3", + "bidi": false + }, + "content-container-columns-4": { + "identifier": "content-container-columns-4", + "category": "content", + "svg": "svgs/content/content-container-columns-4.svg", + "sprite": "sprites/content.svg#content-container-columns-4", + "bidi": false + }, + "content-container": { + "identifier": "content-container", + "category": "content", + "svg": "svgs/content/content-container.svg", + "sprite": "sprites/content.svg#content-container", + "bidi": false + }, + "content-cpu": { + "identifier": "content-cpu", + "category": "content", + "svg": "svgs/content/content-cpu.svg", + "sprite": "sprites/content.svg#content-cpu", + "bidi": false + }, + "content-csv": { + "identifier": "content-csv", + "category": "content", + "svg": "svgs/content/content-csv.svg", + "sprite": "sprites/content.svg#content-csv", + "bidi": false + }, + "content-dashboard-empty": { + "identifier": "content-dashboard-empty", + "category": "content", + "svg": "svgs/content/content-dashboard-empty.svg", + "sprite": "sprites/content.svg#content-dashboard-empty", + "bidi": false + }, + "content-dashboard": { + "identifier": "content-dashboard", + "category": "content", + "svg": "svgs/content/content-dashboard.svg", + "sprite": "sprites/content.svg#content-dashboard", + "bidi": false + }, + "content-database": { + "identifier": "content-database", + "category": "content", + "svg": "svgs/content/content-database.svg", + "sprite": "sprites/content.svg#content-database", + "bidi": false + }, + "content-device-desktop": { + "identifier": "content-device-desktop", + "category": "content", + "svg": "svgs/content/content-device-desktop.svg", + "sprite": "sprites/content.svg#content-device-desktop", + "bidi": false + }, + "content-device-mobile": { + "identifier": "content-device-mobile", + "category": "content", + "svg": "svgs/content/content-device-mobile.svg", + "sprite": "sprites/content.svg#content-device-mobile", + "bidi": false + }, + "content-device-tablet": { + "identifier": "content-device-tablet", + "category": "content", + "svg": "svgs/content/content-device-tablet.svg", + "sprite": "sprites/content.svg#content-device-tablet", + "bidi": false + }, + "content-elements-login": { + "identifier": "content-elements-login", + "category": "content", + "svg": "svgs/content/content-elements-login.svg", + "sprite": "sprites/content.svg#content-elements-login", + "bidi": false + }, + "content-elements-mailform": { + "identifier": "content-elements-mailform", + "category": "content", + "svg": "svgs/content/content-elements-mailform.svg", + "sprite": "sprites/content.svg#content-elements-mailform", + "bidi": false + }, + "content-elements-searchform": { + "identifier": "content-elements-searchform", + "category": "content", + "svg": "svgs/content/content-elements-searchform.svg", + "sprite": "sprites/content.svg#content-elements-searchform", + "bidi": false + }, + "content-extension": { + "identifier": "content-extension", + "category": "content", + "svg": "svgs/content/content-extension.svg", + "sprite": "sprites/content.svg#content-extension", + "bidi": false + }, + "content-form": { + "identifier": "content-form", + "category": "content", + "svg": "svgs/content/content-form.svg", + "sprite": "sprites/content.svg#content-form", + "bidi": false + }, + "content-gallery": { + "identifier": "content-gallery", + "category": "content", + "svg": "svgs/content/content-gallery.svg", + "sprite": "sprites/content.svg#content-gallery", + "bidi": false + }, + "content-header": { + "identifier": "content-header", + "category": "content", + "svg": "svgs/content/content-header.svg", + "sprite": "sprites/content.svg#content-header", + "bidi": false + }, + "content-heart": { + "identifier": "content-heart", + "category": "content", + "svg": "svgs/content/content-heart.svg", + "sprite": "sprites/content.svg#content-heart", + "bidi": false + }, + "content-idea": { + "identifier": "content-idea", + "category": "content", + "svg": "svgs/content/content-idea.svg", + "sprite": "sprites/content.svg#content-idea", + "bidi": false + }, + "content-image": { + "identifier": "content-image", + "category": "content", + "svg": "svgs/content/content-image.svg", + "sprite": "sprites/content.svg#content-image", + "bidi": false + }, + "content-info": { + "identifier": "content-info", + "category": "content", + "svg": "svgs/content/content-info.svg", + "sprite": "sprites/content.svg#content-info", + "bidi": false + }, + "content-inside-text-img-left": { + "identifier": "content-inside-text-img-left", + "category": "content", + "svg": "svgs/content/content-inside-text-img-left.svg", + "sprite": "sprites/content.svg#content-inside-text-img-left", + "bidi": false + }, + "content-inside-text-img-right": { + "identifier": "content-inside-text-img-right", + "category": "content", + "svg": "svgs/content/content-inside-text-img-right.svg", + "sprite": "sprites/content.svg#content-inside-text-img-right", + "bidi": false + }, + "content-listgroup": { + "identifier": "content-listgroup", + "category": "content", + "svg": "svgs/content/content-listgroup.svg", + "sprite": "sprites/content.svg#content-listgroup", + "bidi": false + }, + "content-magnet": { + "identifier": "content-magnet", + "category": "content", + "svg": "svgs/content/content-magnet.svg", + "sprite": "sprites/content.svg#content-magnet", + "bidi": false + }, + "content-map": { + "identifier": "content-map", + "category": "content", + "svg": "svgs/content/content-map.svg", + "sprite": "sprites/content.svg#content-map", + "bidi": false + }, + "content-marker": { + "identifier": "content-marker", + "category": "content", + "svg": "svgs/content/content-marker.svg", + "sprite": "sprites/content.svg#content-marker", + "bidi": false + }, + "content-media": { + "identifier": "content-media", + "category": "content", + "svg": "svgs/content/content-media.svg", + "sprite": "sprites/content.svg#content-media", + "bidi": false + }, + "content-menu-abstract": { + "identifier": "content-menu-abstract", + "category": "content", + "svg": "svgs/content/content-menu-abstract.svg", + "sprite": "sprites/content.svg#content-menu-abstract", + "bidi": false + }, + "content-menu-card": { + "identifier": "content-menu-card", + "category": "content", + "svg": "svgs/content/content-menu-card.svg", + "sprite": "sprites/content.svg#content-menu-card", + "bidi": false + }, + "content-menu-categorized": { + "identifier": "content-menu-categorized", + "category": "content", + "svg": "svgs/content/content-menu-categorized.svg", + "sprite": "sprites/content.svg#content-menu-categorized", + "bidi": false + }, + "content-menu-pages": { + "identifier": "content-menu-pages", + "category": "content", + "svg": "svgs/content/content-menu-pages.svg", + "sprite": "sprites/content.svg#content-menu-pages", + "bidi": false + }, + "content-menu-recently-updated": { + "identifier": "content-menu-recently-updated", + "category": "content", + "svg": "svgs/content/content-menu-recently-updated.svg", + "sprite": "sprites/content.svg#content-menu-recently-updated", + "bidi": false + }, + "content-menu-related": { + "identifier": "content-menu-related", + "category": "content", + "svg": "svgs/content/content-menu-related.svg", + "sprite": "sprites/content.svg#content-menu-related", + "bidi": false + }, + "content-menu-section": { + "identifier": "content-menu-section", + "category": "content", + "svg": "svgs/content/content-menu-section.svg", + "sprite": "sprites/content.svg#content-menu-section", + "bidi": false + }, + "content-menu-sitemap-pages": { + "identifier": "content-menu-sitemap-pages", + "category": "content", + "svg": "svgs/content/content-menu-sitemap-pages.svg", + "sprite": "sprites/content.svg#content-menu-sitemap-pages", + "bidi": false + }, + "content-menu-sitemap": { + "identifier": "content-menu-sitemap", + "category": "content", + "svg": "svgs/content/content-menu-sitemap.svg", + "sprite": "sprites/content.svg#content-menu-sitemap", + "bidi": false + }, + "content-menu-thumbnail": { + "identifier": "content-menu-thumbnail", + "category": "content", + "svg": "svgs/content/content-menu-thumbnail.svg", + "sprite": "sprites/content.svg#content-menu-thumbnail", + "bidi": false + }, + "content-message-dots": { + "identifier": "content-message-dots", + "category": "content", + "svg": "svgs/content/content-message-dots.svg", + "sprite": "sprites/content.svg#content-message-dots", + "bidi": false + }, + "content-message": { + "identifier": "content-message", + "category": "content", + "svg": "svgs/content/content-message.svg", + "sprite": "sprites/content.svg#content-message", + "bidi": false + }, + "content-messages": { + "identifier": "content-messages", + "category": "content", + "svg": "svgs/content/content-messages.svg", + "sprite": "sprites/content.svg#content-messages", + "bidi": false + }, + "content-microchip": { + "identifier": "content-microchip", + "category": "content", + "svg": "svgs/content/content-microchip.svg", + "sprite": "sprites/content.svg#content-microchip", + "bidi": false + }, + "content-news": { + "identifier": "content-news", + "category": "content", + "svg": "svgs/content/content-news.svg", + "sprite": "sprites/content.svg#content-news", + "bidi": false + }, + "content-note": { + "identifier": "content-note", + "category": "content", + "svg": "svgs/content/content-note.svg", + "sprite": "sprites/content.svg#content-note", + "bidi": false + }, + "content-package": { + "identifier": "content-package", + "category": "content", + "svg": "svgs/content/content-package.svg", + "sprite": "sprites/content.svg#content-package", + "bidi": false + }, + "content-panel": { + "identifier": "content-panel", + "category": "content", + "svg": "svgs/content/content-panel.svg", + "sprite": "sprites/content.svg#content-panel", + "bidi": false + }, + "content-plugin": { + "identifier": "content-plugin", + "category": "content", + "svg": "svgs/content/content-plugin.svg", + "sprite": "sprites/content.svg#content-plugin", + "bidi": false + }, + "content-quote": { + "identifier": "content-quote", + "category": "content", + "svg": "svgs/content/content-quote.svg", + "sprite": "sprites/content.svg#content-quote", + "bidi": false + }, + "content-special-div": { + "identifier": "content-special-div", + "category": "content", + "svg": "svgs/content/content-special-div.svg", + "sprite": "sprites/content.svg#content-special-div", + "bidi": false + }, + "content-special-html": { + "identifier": "content-special-html", + "category": "content", + "svg": "svgs/content/content-special-html.svg", + "sprite": "sprites/content.svg#content-special-html", + "bidi": false + }, + "content-special-indexed_search": { + "identifier": "content-special-indexed_search", + "category": "content", + "svg": "svgs/content/content-special-indexed_search.svg", + "sprite": "sprites/content.svg#content-special-indexed_search", + "bidi": false + }, + "content-special-menu": { + "identifier": "content-special-menu", + "category": "content", + "svg": "svgs/content/content-special-menu.svg", + "sprite": "sprites/content.svg#content-special-menu", + "bidi": false + }, + "content-special-shortcut": { + "identifier": "content-special-shortcut", + "category": "content", + "svg": "svgs/content/content-special-shortcut.svg", + "sprite": "sprites/content.svg#content-special-shortcut", + "bidi": false + }, + "content-special-uploads": { + "identifier": "content-special-uploads", + "category": "content", + "svg": "svgs/content/content-special-uploads.svg", + "sprite": "sprites/content.svg#content-special-uploads", + "bidi": false + }, + "content-store": { + "identifier": "content-store", + "category": "content", + "svg": "svgs/content/content-store.svg", + "sprite": "sprites/content.svg#content-store", + "bidi": false + }, + "content-tab-item": { + "identifier": "content-tab-item", + "category": "content", + "svg": "svgs/content/content-tab-item.svg", + "sprite": "sprites/content.svg#content-tab-item", + "bidi": false + }, + "content-tab": { + "identifier": "content-tab", + "category": "content", + "svg": "svgs/content/content-tab.svg", + "sprite": "sprites/content.svg#content-tab", + "bidi": false + }, + "content-table": { + "identifier": "content-table", + "category": "content", + "svg": "svgs/content/content-table.svg", + "sprite": "sprites/content.svg#content-table", + "bidi": false + }, + "content-target": { + "identifier": "content-target", + "category": "content", + "svg": "svgs/content/content-target.svg", + "sprite": "sprites/content.svg#content-target", + "bidi": false + }, + "content-text-columns": { + "identifier": "content-text-columns", + "category": "content", + "svg": "svgs/content/content-text-columns.svg", + "sprite": "sprites/content.svg#content-text-columns", + "bidi": false + }, + "content-text-teaser": { + "identifier": "content-text-teaser", + "category": "content", + "svg": "svgs/content/content-text-teaser.svg", + "sprite": "sprites/content.svg#content-text-teaser", + "bidi": false + }, + "content-text": { + "identifier": "content-text", + "category": "content", + "svg": "svgs/content/content-text.svg", + "sprite": "sprites/content.svg#content-text", + "bidi": false + }, + "content-textmedia": { + "identifier": "content-textmedia", + "category": "content", + "svg": "svgs/content/content-textmedia.svg", + "sprite": "sprites/content.svg#content-textmedia", + "bidi": false + }, + "content-textpic": { + "identifier": "content-textpic", + "category": "content", + "svg": "svgs/content/content-textpic.svg", + "sprite": "sprites/content.svg#content-textpic", + "bidi": false + }, + "content-thumbtack": { + "identifier": "content-thumbtack", + "category": "content", + "svg": "svgs/content/content-thumbtack.svg", + "sprite": "sprites/content.svg#content-thumbtack", + "bidi": false + }, + "content-timeline-item": { + "identifier": "content-timeline-item", + "category": "content", + "svg": "svgs/content/content-timeline-item.svg", + "sprite": "sprites/content.svg#content-timeline-item", + "bidi": false + }, + "content-timeline": { + "identifier": "content-timeline", + "category": "content", + "svg": "svgs/content/content-timeline.svg", + "sprite": "sprites/content.svg#content-timeline", + "bidi": false + }, + "content-trophy": { + "identifier": "content-trophy", + "category": "content", + "svg": "svgs/content/content-trophy.svg", + "sprite": "sprites/content.svg#content-trophy", + "bidi": false + }, + "content-user": { + "identifier": "content-user", + "category": "content", + "svg": "svgs/content/content-user.svg", + "sprite": "sprites/content.svg#content-user", + "bidi": false + }, + "content-webhook": { + "identifier": "content-webhook", + "category": "content", + "svg": "svgs/content/content-webhook.svg", + "sprite": "sprites/content.svg#content-webhook", + "bidi": false + }, + "content-widget-calltoaction": { + "identifier": "content-widget-calltoaction", + "category": "content", + "svg": "svgs/content/content-widget-calltoaction.svg", + "sprite": "sprites/content.svg#content-widget-calltoaction", + "bidi": false + }, + "content-widget-chart-bar": { + "identifier": "content-widget-chart-bar", + "category": "content", + "svg": "svgs/content/content-widget-chart-bar.svg", + "sprite": "sprites/content.svg#content-widget-chart-bar", + "bidi": false + }, + "content-widget-chart-pie": { + "identifier": "content-widget-chart-pie", + "category": "content", + "svg": "svgs/content/content-widget-chart-pie.svg", + "sprite": "sprites/content.svg#content-widget-chart-pie", + "bidi": false + }, + "content-widget-chart": { + "identifier": "content-widget-chart", + "category": "content", + "svg": "svgs/content/content-widget-chart.svg", + "sprite": "sprites/content.svg#content-widget-chart", + "bidi": false + }, + "content-widget-image": { + "identifier": "content-widget-image", + "category": "content", + "svg": "svgs/content/content-widget-image.svg", + "sprite": "sprites/content.svg#content-widget-image", + "bidi": false + }, + "content-widget-list": { + "identifier": "content-widget-list", + "category": "content", + "svg": "svgs/content/content-widget-list.svg", + "sprite": "sprites/content.svg#content-widget-list", + "bidi": false + }, + "content-widget-number": { + "identifier": "content-widget-number", + "category": "content", + "svg": "svgs/content/content-widget-number.svg", + "sprite": "sprites/content.svg#content-widget-number", + "bidi": false + }, + "content-widget-rss": { + "identifier": "content-widget-rss", + "category": "content", + "svg": "svgs/content/content-widget-rss.svg", + "sprite": "sprites/content.svg#content-widget-rss", + "bidi": false + }, + "content-widget-table": { + "identifier": "content-widget-table", + "category": "content", + "svg": "svgs/content/content-widget-table.svg", + "sprite": "sprites/content.svg#content-widget-table", + "bidi": false + }, + "content-widget-text": { + "identifier": "content-widget-text", + "category": "content", + "svg": "svgs/content/content-widget-text.svg", + "sprite": "sprites/content.svg#content-widget-text", + "bidi": false + }, + "content-widget": { + "identifier": "content-widget", + "category": "content", + "svg": "svgs/content/content-widget.svg", + "sprite": "sprites/content.svg#content-widget", + "bidi": false + }, + "default-not-found": { + "identifier": "default-not-found", + "category": "default", + "svg": "svgs/default/default-not-found.svg", + "sprite": "sprites/default.svg#default-not-found", + "bidi": false + }, + "files-folder-content": { + "identifier": "files-folder-content", + "category": "files", + "svg": "svgs/files/files-folder-content.svg", + "sprite": "sprites/files.svg#files-folder-content", + "bidi": false + }, + "files-folder-images": { + "identifier": "files-folder-images", + "category": "files", + "svg": "svgs/files/files-folder-images.svg", + "sprite": "sprites/files.svg#files-folder-images", + "bidi": false + }, + "files-folder": { + "identifier": "files-folder", + "category": "files", + "svg": "svgs/files/files-folder.svg", + "sprite": "sprites/files.svg#files-folder", + "bidi": false + }, + "form-advanced-password": { + "identifier": "form-advanced-password", + "category": "form", + "svg": "svgs/form/form-advanced-password.svg", + "sprite": "sprites/form.svg#form-advanced-password", + "bidi": false + }, + "form-checkbox": { + "identifier": "form-checkbox", + "category": "form", + "svg": "svgs/form/form-checkbox.svg", + "sprite": "sprites/form.svg#form-checkbox", + "bidi": false + }, + "form-content-element": { + "identifier": "form-content-element", + "category": "form", + "svg": "svgs/form/form-content-element.svg", + "sprite": "sprites/form.svg#form-content-element", + "bidi": false + }, + "form-date-picker": { + "identifier": "form-date-picker", + "category": "form", + "svg": "svgs/form/form-date-picker.svg", + "sprite": "sprites/form.svg#form-date-picker", + "bidi": false + }, + "form-email": { + "identifier": "form-email", + "category": "form", + "svg": "svgs/form/form-email.svg", + "sprite": "sprites/form.svg#form-email", + "bidi": false + }, + "form-fieldset": { + "identifier": "form-fieldset", + "category": "form", + "svg": "svgs/form/form-fieldset.svg", + "sprite": "sprites/form.svg#form-fieldset", + "bidi": false + }, + "form-file-upload": { + "identifier": "form-file-upload", + "category": "form", + "svg": "svgs/form/form-file-upload.svg", + "sprite": "sprites/form.svg#form-file-upload", + "bidi": false + }, + "form-finisher": { + "identifier": "form-finisher", + "category": "form", + "svg": "svgs/form/form-finisher.svg", + "sprite": "sprites/form.svg#form-finisher", + "bidi": false + }, + "form-gridcolumn": { + "identifier": "form-gridcolumn", + "category": "form", + "svg": "svgs/form/form-gridcolumn.svg", + "sprite": "sprites/form.svg#form-gridcolumn", + "bidi": false + }, + "form-gridcontainer": { + "identifier": "form-gridcontainer", + "category": "form", + "svg": "svgs/form/form-gridcontainer.svg", + "sprite": "sprites/form.svg#form-gridcontainer", + "bidi": false + }, + "form-gridrow": { + "identifier": "form-gridrow", + "category": "form", + "svg": "svgs/form/form-gridrow.svg", + "sprite": "sprites/form.svg#form-gridrow", + "bidi": false + }, + "form-hidden": { + "identifier": "form-hidden", + "category": "form", + "svg": "svgs/form/form-hidden.svg", + "sprite": "sprites/form.svg#form-hidden", + "bidi": false + }, + "form-image-upload": { + "identifier": "form-image-upload", + "category": "form", + "svg": "svgs/form/form-image-upload.svg", + "sprite": "sprites/form.svg#form-image-upload", + "bidi": false + }, + "form-multi-checkbox": { + "identifier": "form-multi-checkbox", + "category": "form", + "svg": "svgs/form/form-multi-checkbox.svg", + "sprite": "sprites/form.svg#form-multi-checkbox", + "bidi": false + }, + "form-multi-select": { + "identifier": "form-multi-select", + "category": "form", + "svg": "svgs/form/form-multi-select.svg", + "sprite": "sprites/form.svg#form-multi-select", + "bidi": false + }, + "form-number": { + "identifier": "form-number", + "category": "form", + "svg": "svgs/form/form-number.svg", + "sprite": "sprites/form.svg#form-number", + "bidi": false + }, + "form-page": { + "identifier": "form-page", + "category": "form", + "svg": "svgs/form/form-page.svg", + "sprite": "sprites/form.svg#form-page", + "bidi": false + }, + "form-password": { + "identifier": "form-password", + "category": "form", + "svg": "svgs/form/form-password.svg", + "sprite": "sprites/form.svg#form-password", + "bidi": false + }, + "form-radio-button": { + "identifier": "form-radio-button", + "category": "form", + "svg": "svgs/form/form-radio-button.svg", + "sprite": "sprites/form.svg#form-radio-button", + "bidi": false + }, + "form-single-select": { + "identifier": "form-single-select", + "category": "form", + "svg": "svgs/form/form-single-select.svg", + "sprite": "sprites/form.svg#form-single-select", + "bidi": false + }, + "form-static-text": { + "identifier": "form-static-text", + "category": "form", + "svg": "svgs/form/form-static-text.svg", + "sprite": "sprites/form.svg#form-static-text", + "bidi": false + }, + "form-summary-page": { + "identifier": "form-summary-page", + "category": "form", + "svg": "svgs/form/form-summary-page.svg", + "sprite": "sprites/form.svg#form-summary-page", + "bidi": false + }, + "form-telephone": { + "identifier": "form-telephone", + "category": "form", + "svg": "svgs/form/form-telephone.svg", + "sprite": "sprites/form.svg#form-telephone", + "bidi": false + }, + "form-text": { + "identifier": "form-text", + "category": "form", + "svg": "svgs/form/form-text.svg", + "sprite": "sprites/form.svg#form-text", + "bidi": false + }, + "form-textarea": { + "identifier": "form-textarea", + "category": "form", + "svg": "svgs/form/form-textarea.svg", + "sprite": "sprites/form.svg#form-textarea", + "bidi": false + }, + "form-url": { + "identifier": "form-url", + "category": "form", + "svg": "svgs/form/form-url.svg", + "sprite": "sprites/form.svg#form-url", + "bidi": false + }, + "form-validator": { + "identifier": "form-validator", + "category": "form", + "svg": "svgs/form/form-validator.svg", + "sprite": "sprites/form.svg#form-validator", + "bidi": false + }, + "information-os-unknown": { + "identifier": "information-os-unknown", + "category": "information", + "svg": "svgs/information/information-os-unknown.svg", + "sprite": "sprites/information.svg#information-os-unknown", + "bidi": false + }, + "information-typo3-version": { + "identifier": "information-typo3-version", + "category": "information", + "svg": "svgs/information/information-typo3-version.svg", + "sprite": "sprites/information.svg#information-typo3-version", + "bidi": false + }, + "install-check-brokenextension": { + "identifier": "install-check-brokenextension", + "category": "install", + "svg": "svgs/install/install-check-brokenextension.svg", + "sprite": "sprites/install.svg#install-check-brokenextension", + "bidi": false + }, + "install-check-directory": { + "identifier": "install-check-directory", + "category": "install", + "svg": "svgs/install/install-check-directory.svg", + "sprite": "sprites/install.svg#install-check-directory", + "bidi": false + }, + "install-check-extables": { + "identifier": "install-check-extables", + "category": "install", + "svg": "svgs/install/install-check-extables.svg", + "sprite": "sprites/install.svg#install-check-extables", + "bidi": false + }, + "install-check-tca": { + "identifier": "install-check-tca", + "category": "install", + "svg": "svgs/install/install-check-tca.svg", + "sprite": "sprites/install.svg#install-check-tca", + "bidi": false + }, + "install-clear-autoload": { + "identifier": "install-clear-autoload", + "category": "install", + "svg": "svgs/install/install-clear-autoload.svg", + "sprite": "sprites/install.svg#install-clear-autoload", + "bidi": false + }, + "install-clear-cache": { + "identifier": "install-clear-cache", + "category": "install", + "svg": "svgs/install/install-clear-cache.svg", + "sprite": "sprites/install.svg#install-clear-cache", + "bidi": false + }, + "install-clear-database": { + "identifier": "install-clear-database", + "category": "install", + "svg": "svgs/install/install-clear-database.svg", + "sprite": "sprites/install.svg#install-clear-database", + "bidi": false + }, + "install-clear-files": { + "identifier": "install-clear-files", + "category": "install", + "svg": "svgs/install/install-clear-files.svg", + "sprite": "sprites/install.svg#install-clear-files", + "bidi": false + }, + "install-create-admin": { + "identifier": "install-create-admin", + "category": "install", + "svg": "svgs/install/install-create-admin.svg", + "sprite": "sprites/install.svg#install-create-admin", + "bidi": false + }, + "install-database-analyze": { + "identifier": "install-database-analyze", + "category": "install", + "svg": "svgs/install/install-database-analyze.svg", + "sprite": "sprites/install.svg#install-database-analyze", + "bidi": false + }, + "install-documentation": { + "identifier": "install-documentation", + "category": "install", + "svg": "svgs/install/install-documentation.svg", + "sprite": "sprites/install.svg#install-documentation", + "bidi": false + }, + "install-extension-settings": { + "identifier": "install-extension-settings", + "category": "install", + "svg": "svgs/install/install-extension-settings.svg", + "sprite": "sprites/install.svg#install-extension-settings", + "bidi": false + }, + "install-manage-features": { + "identifier": "install-manage-features", + "category": "install", + "svg": "svgs/install/install-manage-features.svg", + "sprite": "sprites/install.svg#install-manage-features", + "bidi": false + }, + "install-manage-language": { + "identifier": "install-manage-language", + "category": "install", + "svg": "svgs/install/install-manage-language.svg", + "sprite": "sprites/install.svg#install-manage-language", + "bidi": false + }, + "install-manage-maintainer": { + "identifier": "install-manage-maintainer", + "category": "install", + "svg": "svgs/install/install-manage-maintainer.svg", + "sprite": "sprites/install.svg#install-manage-maintainer", + "bidi": false + }, + "install-manage-presets": { + "identifier": "install-manage-presets", + "category": "install", + "svg": "svgs/install/install-manage-presets.svg", + "sprite": "sprites/install.svg#install-manage-presets", + "bidi": false + }, + "install-manage-settings": { + "identifier": "install-manage-settings", + "category": "install", + "svg": "svgs/install/install-manage-settings.svg", + "sprite": "sprites/install.svg#install-manage-settings", + "bidi": false + }, + "install-password": { + "identifier": "install-password", + "category": "install", + "svg": "svgs/install/install-password.svg", + "sprite": "sprites/install.svg#install-password", + "bidi": false + }, + "install-php-info": { + "identifier": "install-php-info", + "category": "install", + "svg": "svgs/install/install-php-info.svg", + "sprite": "sprites/install.svg#install-php-info", + "bidi": false + }, + "install-reset-user": { + "identifier": "install-reset-user", + "category": "install", + "svg": "svgs/install/install-reset-user.svg", + "sprite": "sprites/install.svg#install-reset-user", + "bidi": false + }, + "install-scan-extensions": { + "identifier": "install-scan-extensions", + "category": "install", + "svg": "svgs/install/install-scan-extensions.svg", + "sprite": "sprites/install.svg#install-scan-extensions", + "bidi": false + }, + "install-show-environment": { + "identifier": "install-show-environment", + "category": "install", + "svg": "svgs/install/install-show-environment.svg", + "sprite": "sprites/install.svg#install-show-environment", + "bidi": false + }, + "install-test-environment": { + "identifier": "install-test-environment", + "category": "install", + "svg": "svgs/install/install-test-environment.svg", + "sprite": "sprites/install.svg#install-test-environment", + "bidi": false + }, + "install-test-image": { + "identifier": "install-test-image", + "category": "install", + "svg": "svgs/install/install-test-image.svg", + "sprite": "sprites/install.svg#install-test-image", + "bidi": false + }, + "install-test-mail": { + "identifier": "install-test-mail", + "category": "install", + "svg": "svgs/install/install-test-mail.svg", + "sprite": "sprites/install.svg#install-test-mail", + "bidi": false + }, + "install-update": { + "identifier": "install-update", + "category": "install", + "svg": "svgs/install/install-update.svg", + "sprite": "sprites/install.svg#install-update", + "bidi": false + }, + "install-wizards": { + "identifier": "install-wizards", + "category": "install", + "svg": "svgs/install/install-wizards.svg", + "sprite": "sprites/install.svg#install-wizards", + "bidi": false + }, + "mimetypes-application": { + "identifier": "mimetypes-application", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-application.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-application", + "bidi": false + }, + "mimetypes-compressed": { + "identifier": "mimetypes-compressed", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-compressed.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-compressed", + "bidi": false + }, + "mimetypes-excel": { + "identifier": "mimetypes-excel", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-excel.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-excel", + "bidi": false + }, + "mimetypes-media-audio": { + "identifier": "mimetypes-media-audio", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-audio.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-audio", + "bidi": false + }, + "mimetypes-media-flash": { + "identifier": "mimetypes-media-flash", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-flash.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-flash", + "bidi": false + }, + "mimetypes-media-image": { + "identifier": "mimetypes-media-image", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-image.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-image", + "bidi": false + }, + "mimetypes-media-video-vimeo": { + "identifier": "mimetypes-media-video-vimeo", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-video-vimeo.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-video-vimeo", + "bidi": false + }, + "mimetypes-media-video-youtube": { + "identifier": "mimetypes-media-video-youtube", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-video-youtube.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-video-youtube", + "bidi": false + }, + "mimetypes-media-video": { + "identifier": "mimetypes-media-video", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-media-video.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-media-video", + "bidi": false + }, + "mimetypes-open-document-database": { + "identifier": "mimetypes-open-document-database", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-database.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-database", + "bidi": false + }, + "mimetypes-open-document-drawing": { + "identifier": "mimetypes-open-document-drawing", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-drawing.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-drawing", + "bidi": false + }, + "mimetypes-open-document-formula": { + "identifier": "mimetypes-open-document-formula", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-formula.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-formula", + "bidi": false + }, + "mimetypes-open-document-presentation": { + "identifier": "mimetypes-open-document-presentation", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-presentation.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-presentation", + "bidi": false + }, + "mimetypes-open-document-spreadsheet": { + "identifier": "mimetypes-open-document-spreadsheet", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-spreadsheet.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-spreadsheet", + "bidi": false + }, + "mimetypes-open-document-text": { + "identifier": "mimetypes-open-document-text", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-open-document-text.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-open-document-text", + "bidi": false + }, + "mimetypes-other-other": { + "identifier": "mimetypes-other-other", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-other-other.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-other-other", + "bidi": false + }, + "mimetypes-pdf": { + "identifier": "mimetypes-pdf", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-pdf.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-pdf", + "bidi": false + }, + "mimetypes-powerpoint": { + "identifier": "mimetypes-powerpoint", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-powerpoint.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-powerpoint", + "bidi": false + }, + "mimetypes-text-css": { + "identifier": "mimetypes-text-css", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-css.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-css", + "bidi": false + }, + "mimetypes-text-csv": { + "identifier": "mimetypes-text-csv", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-csv.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-csv", + "bidi": false + }, + "mimetypes-text-html": { + "identifier": "mimetypes-text-html", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-html.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-html", + "bidi": false + }, + "mimetypes-text-js": { + "identifier": "mimetypes-text-js", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-js.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-js", + "bidi": false + }, + "mimetypes-text-php": { + "identifier": "mimetypes-text-php", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-php.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-php", + "bidi": false + }, + "mimetypes-text-text": { + "identifier": "mimetypes-text-text", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-text.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-text", + "bidi": false + }, + "mimetypes-text-ts": { + "identifier": "mimetypes-text-ts", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-ts.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-ts", + "bidi": false + }, + "mimetypes-text-typoscript": { + "identifier": "mimetypes-text-typoscript", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-text-typoscript.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-text-typoscript", + "bidi": false + }, + "mimetypes-word": { + "identifier": "mimetypes-word", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-word.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-word", + "bidi": false + }, + "mimetypes-x-backend_layout": { + "identifier": "mimetypes-x-backend_layout", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-backend_layout.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-backend_layout", + "bidi": false + }, + "mimetypes-x-content-divider": { + "identifier": "mimetypes-x-content-divider", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-divider.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-divider", + "bidi": false + }, + "mimetypes-x-content-domain": { + "identifier": "mimetypes-x-content-domain", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-domain.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-domain", + "bidi": false + }, + "mimetypes-x-content-form-search": { + "identifier": "mimetypes-x-content-form-search", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-form-search.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-form-search", + "bidi": false + }, + "mimetypes-x-content-form": { + "identifier": "mimetypes-x-content-form", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-form.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-form", + "bidi": false + }, + "mimetypes-x-content-header": { + "identifier": "mimetypes-x-content-header", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-header.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-header", + "bidi": false + }, + "mimetypes-x-content-html": { + "identifier": "mimetypes-x-content-html", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-html.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-html", + "bidi": false + }, + "mimetypes-x-content-image": { + "identifier": "mimetypes-x-content-image", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-image.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-image", + "bidi": false + }, + "mimetypes-x-content-link": { + "identifier": "mimetypes-x-content-link", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-link.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-link", + "bidi": false + }, + "mimetypes-x-content-list-bullets": { + "identifier": "mimetypes-x-content-list-bullets", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-list-bullets.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-list-bullets", + "bidi": false + }, + "mimetypes-x-content-list-files": { + "identifier": "mimetypes-x-content-list-files", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-list-files.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-list-files", + "bidi": false + }, + "mimetypes-x-content-login": { + "identifier": "mimetypes-x-content-login", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-login.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-login", + "bidi": false + }, + "mimetypes-x-content-menu": { + "identifier": "mimetypes-x-content-menu", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-menu.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-menu", + "bidi": false + }, + "mimetypes-x-content-multimedia": { + "identifier": "mimetypes-x-content-multimedia", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-multimedia.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-multimedia", + "bidi": false + }, + "mimetypes-x-content-page-language-overlay": { + "identifier": "mimetypes-x-content-page-language-overlay", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-page-language-overlay.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-page-language-overlay", + "bidi": false + }, + "mimetypes-x-content-plugin": { + "identifier": "mimetypes-x-content-plugin", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-plugin.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-plugin", + "bidi": false + }, + "mimetypes-x-content-script": { + "identifier": "mimetypes-x-content-script", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-script.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-script", + "bidi": false + }, + "mimetypes-x-content-table": { + "identifier": "mimetypes-x-content-table", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-table.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-table", + "bidi": false + }, + "mimetypes-x-content-template-extension": { + "identifier": "mimetypes-x-content-template-extension", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-template-extension.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-template-extension", + "bidi": false + }, + "mimetypes-x-content-template-static": { + "identifier": "mimetypes-x-content-template-static", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-template-static.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-template-static", + "bidi": false + }, + "mimetypes-x-content-template": { + "identifier": "mimetypes-x-content-template", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-template.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-template", + "bidi": false + }, + "mimetypes-x-content-text-media": { + "identifier": "mimetypes-x-content-text-media", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-text-media.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-text-media", + "bidi": false + }, + "mimetypes-x-content-text-picture": { + "identifier": "mimetypes-x-content-text-picture", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-text-picture.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-text-picture", + "bidi": false + }, + "mimetypes-x-content-text": { + "identifier": "mimetypes-x-content-text", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-content-text.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-content-text", + "bidi": false + }, + "mimetypes-x-index_config": { + "identifier": "mimetypes-x-index_config", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-index_config.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-index_config", + "bidi": false + }, + "mimetypes-x-sys_action": { + "identifier": "mimetypes-x-sys_action", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_action.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_action", + "bidi": false + }, + "mimetypes-x-sys_category": { + "identifier": "mimetypes-x-sys_category", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_category.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_category", + "bidi": false + }, + "mimetypes-x-sys_file_storage": { + "identifier": "mimetypes-x-sys_file_storage", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_file_storage.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_file_storage", + "bidi": false + }, + "mimetypes-x-sys_filemounts": { + "identifier": "mimetypes-x-sys_filemounts", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_filemounts.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_filemounts", + "bidi": false + }, + "mimetypes-x-sys_language": { + "identifier": "mimetypes-x-sys_language", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_language.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_language", + "bidi": false + }, + "mimetypes-x-sys_news": { + "identifier": "mimetypes-x-sys_news", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_news.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_news", + "bidi": false + }, + "mimetypes-x-sys_note": { + "identifier": "mimetypes-x-sys_note", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_note.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_note", + "bidi": false + }, + "mimetypes-x-sys_redirect": { + "identifier": "mimetypes-x-sys_redirect", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_redirect.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_redirect", + "bidi": false + }, + "mimetypes-x-sys_workspace": { + "identifier": "mimetypes-x-sys_workspace", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-sys_workspace.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-sys_workspace", + "bidi": false + }, + "mimetypes-x-tx_rtehtmlarea_acronym": { + "identifier": "mimetypes-x-tx_rtehtmlarea_acronym", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-tx_rtehtmlarea_acronym.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-tx_rtehtmlarea_acronym", + "bidi": false + }, + "mimetypes-x-tx_scheduler_task_group": { + "identifier": "mimetypes-x-tx_scheduler_task_group", + "category": "mimetypes", + "svg": "svgs/mimetypes/mimetypes-x-tx_scheduler_task_group.svg", + "sprite": "sprites/mimetypes.svg#mimetypes-x-tx_scheduler_task_group", + "bidi": false + }, + "miscellaneous-placeholder": { + "identifier": "miscellaneous-placeholder", + "category": "miscellaneous", + "svg": "svgs/miscellaneous/miscellaneous-placeholder.svg", + "sprite": "sprites/miscellaneous.svg#miscellaneous-placeholder", + "bidi": false + }, + "module-about": { + "identifier": "module-about", + "category": "module", + "svg": "svgs/module/module-about.svg", + "sprite": "sprites/module.svg#module-about", + "bidi": false + }, + "module-aboutmodules": { + "identifier": "module-aboutmodules", + "category": "module", + "svg": "svgs/module/module-aboutmodules.svg", + "sprite": "sprites/module.svg#module-aboutmodules", + "bidi": false + }, + "module-adminpanel": { + "identifier": "module-adminpanel", + "category": "module", + "svg": "svgs/module/module-adminpanel.svg", + "sprite": "sprites/module.svg#module-adminpanel", + "bidi": false + }, + "module-assist": { + "identifier": "module-assist", + "category": "module", + "svg": "svgs/module/module-assist.svg", + "sprite": "sprites/module.svg#module-assist", + "bidi": false + }, + "module-belog": { + "identifier": "module-belog", + "category": "module", + "svg": "svgs/module/module-belog.svg", + "sprite": "sprites/module.svg#module-belog", + "bidi": false + }, + "module-config": { + "identifier": "module-config", + "category": "module", + "svg": "svgs/module/module-config.svg", + "sprite": "sprites/module.svg#module-config", + "bidi": false + }, + "module-contentelements": { + "identifier": "module-contentelements", + "category": "module", + "svg": "svgs/module/module-contentelements.svg", + "sprite": "sprites/module.svg#module-contentelements", + "bidi": false + }, + "module-dashboard": { + "identifier": "module-dashboard", + "category": "module", + "svg": "svgs/module/module-dashboard.svg", + "sprite": "sprites/module.svg#module-dashboard", + "bidi": false + }, + "module-dbal": { + "identifier": "module-dbal", + "category": "module", + "svg": "svgs/module/module-dbal.svg", + "sprite": "sprites/module.svg#module-dbal", + "bidi": false + }, + "module-dbint": { + "identifier": "module-dbint", + "category": "module", + "svg": "svgs/module/module-dbint.svg", + "sprite": "sprites/module.svg#module-dbint", + "bidi": false + }, + "module-debug": { + "identifier": "module-debug", + "category": "module", + "svg": "svgs/module/module-debug.svg", + "sprite": "sprites/module.svg#module-debug", + "bidi": false + }, + "module-documentation": { + "identifier": "module-documentation", + "category": "module", + "svg": "svgs/module/module-documentation.svg", + "sprite": "sprites/module.svg#module-documentation", + "bidi": false + }, + "module-extensionmanager": { + "identifier": "module-extensionmanager", + "category": "module", + "svg": "svgs/module/module-extensionmanager.svg", + "sprite": "sprites/module.svg#module-extensionmanager", + "bidi": false + }, + "module-file": { + "identifier": "module-file", + "category": "module", + "svg": "svgs/module/module-file.svg", + "sprite": "sprites/module.svg#module-file", + "bidi": false + }, + "module-form": { + "identifier": "module-form", + "category": "module", + "svg": "svgs/module/module-form.svg", + "sprite": "sprites/module.svg#module-form", + "bidi": false + }, + "module-func": { + "identifier": "module-func", + "category": "module", + "svg": "svgs/module/module-func.svg", + "sprite": "sprites/module.svg#module-func", + "bidi": false + }, + "module-generic": { + "identifier": "module-generic", + "category": "module", + "svg": "svgs/module/module-generic.svg", + "sprite": "sprites/module.svg#module-generic", + "bidi": false + }, + "module-help": { + "identifier": "module-help", + "category": "module", + "svg": "svgs/module/module-help.svg", + "sprite": "sprites/module.svg#module-help", + "bidi": false + }, + "module-indexed_search": { + "identifier": "module-indexed_search", + "category": "module", + "svg": "svgs/module/module-indexed_search.svg", + "sprite": "sprites/module.svg#module-indexed_search", + "bidi": false + }, + "module-info": { + "identifier": "module-info", + "category": "module", + "svg": "svgs/module/module-info.svg", + "sprite": "sprites/module.svg#module-info", + "bidi": false + }, + "module-install-environment": { + "identifier": "module-install-environment", + "category": "module", + "svg": "svgs/module/module-install-environment.svg", + "sprite": "sprites/module.svg#module-install-environment", + "bidi": false + }, + "module-install-maintenance": { + "identifier": "module-install-maintenance", + "category": "module", + "svg": "svgs/module/module-install-maintenance.svg", + "sprite": "sprites/module.svg#module-install-maintenance", + "bidi": false + }, + "module-install-settings": { + "identifier": "module-install-settings", + "category": "module", + "svg": "svgs/module/module-install-settings.svg", + "sprite": "sprites/module.svg#module-install-settings", + "bidi": false + }, + "module-install-upgrade": { + "identifier": "module-install-upgrade", + "category": "module", + "svg": "svgs/module/module-install-upgrade.svg", + "sprite": "sprites/module.svg#module-install-upgrade", + "bidi": false + }, + "module-install": { + "identifier": "module-install", + "category": "module", + "svg": "svgs/module/module-install.svg", + "sprite": "sprites/module.svg#module-install", + "bidi": false + }, + "module-integrations": { + "identifier": "module-integrations", + "category": "module", + "svg": "svgs/module/module-integrations.svg", + "sprite": "sprites/module.svg#module-integrations", + "bidi": false + }, + "module-lang": { + "identifier": "module-lang", + "category": "module", + "svg": "svgs/module/module-lang.svg", + "sprite": "sprites/module.svg#module-lang", + "bidi": false + }, + "module-linkvalidator": { + "identifier": "module-linkvalidator", + "category": "module", + "svg": "svgs/module/module-linkvalidator.svg", + "sprite": "sprites/module.svg#module-linkvalidator", + "bidi": false + }, + "module-list": { + "identifier": "module-list", + "category": "module", + "svg": "svgs/module/module-list.svg", + "sprite": "sprites/module.svg#module-list", + "bidi": false + }, + "module-page": { + "identifier": "module-page", + "category": "module", + "svg": "svgs/module/module-page.svg", + "sprite": "sprites/module.svg#module-page", + "bidi": false + }, + "module-permission": { + "identifier": "module-permission", + "category": "module", + "svg": "svgs/module/module-permission.svg", + "sprite": "sprites/module.svg#module-permission", + "bidi": false + }, + "module-qrcode": { + "identifier": "module-qrcode", + "category": "module", + "svg": "svgs/module/module-qrcode.svg", + "sprite": "sprites/module.svg#module-qrcode", + "bidi": false + }, + "module-reactions": { + "identifier": "module-reactions", + "category": "module", + "svg": "svgs/module/module-reactions.svg", + "sprite": "sprites/module.svg#module-reactions", + "bidi": false + }, + "module-recycler": { + "identifier": "module-recycler", + "category": "module", + "svg": "svgs/module/module-recycler.svg", + "sprite": "sprites/module.svg#module-recycler", + "bidi": false + }, + "module-redirects": { + "identifier": "module-redirects", + "category": "module", + "svg": "svgs/module/module-redirects.svg", + "sprite": "sprites/module.svg#module-redirects", + "bidi": false + }, + "module-reports": { + "identifier": "module-reports", + "category": "module", + "svg": "svgs/module/module-reports.svg", + "sprite": "sprites/module.svg#module-reports", + "bidi": false + }, + "module-rte-ckeditor": { + "identifier": "module-rte-ckeditor", + "category": "module", + "svg": "svgs/module/module-rte-ckeditor.svg", + "sprite": "sprites/module.svg#module-rte-ckeditor", + "bidi": false + }, + "module-scheduler": { + "identifier": "module-scheduler", + "category": "module", + "svg": "svgs/module/module-scheduler.svg", + "sprite": "sprites/module.svg#module-scheduler", + "bidi": false + }, + "module-security": { + "identifier": "module-security", + "category": "module", + "svg": "svgs/module/module-security.svg", + "sprite": "sprites/module.svg#module-security", + "bidi": false + }, + "module-seo": { + "identifier": "module-seo", + "category": "module", + "svg": "svgs/module/module-seo.svg", + "sprite": "sprites/module.svg#module-seo", + "bidi": false + }, + "module-setup": { + "identifier": "module-setup", + "category": "module", + "svg": "svgs/module/module-setup.svg", + "sprite": "sprites/module.svg#module-setup", + "bidi": false + }, + "module-site-settings": { + "identifier": "module-site-settings", + "category": "module", + "svg": "svgs/module/module-site-settings.svg", + "sprite": "sprites/module.svg#module-site-settings", + "bidi": false + }, + "module-site": { + "identifier": "module-site", + "category": "module", + "svg": "svgs/module/module-site.svg", + "sprite": "sprites/module.svg#module-site", + "bidi": false + }, + "module-sites": { + "identifier": "module-sites", + "category": "module", + "svg": "svgs/module/module-sites.svg", + "sprite": "sprites/module.svg#module-sites", + "bidi": false + }, + "module-styleguide": { + "identifier": "module-styleguide", + "category": "module", + "svg": "svgs/module/module-styleguide.svg", + "sprite": "sprites/module.svg#module-styleguide", + "bidi": false + }, + "module-system": { + "identifier": "module-system", + "category": "module", + "svg": "svgs/module/module-system.svg", + "sprite": "sprites/module.svg#module-system", + "bidi": false + }, + "module-taskcenter": { + "identifier": "module-taskcenter", + "category": "module", + "svg": "svgs/module/module-taskcenter.svg", + "sprite": "sprites/module.svg#module-taskcenter", + "bidi": false + }, + "module-template": { + "identifier": "module-template", + "category": "module", + "svg": "svgs/module/module-template.svg", + "sprite": "sprites/module.svg#module-template", + "bidi": false + }, + "module-tools": { + "identifier": "module-tools", + "category": "module", + "svg": "svgs/module/module-tools.svg", + "sprite": "sprites/module.svg#module-tools", + "bidi": false + }, + "module-tsconfig": { + "identifier": "module-tsconfig", + "category": "module", + "svg": "svgs/module/module-tsconfig.svg", + "sprite": "sprites/module.svg#module-tsconfig", + "bidi": false + }, + "module-upgrade": { + "identifier": "module-upgrade", + "category": "module", + "svg": "svgs/module/module-upgrade.svg", + "sprite": "sprites/module.svg#module-upgrade", + "bidi": false + }, + "module-urls": { + "identifier": "module-urls", + "category": "module", + "svg": "svgs/module/module-urls.svg", + "sprite": "sprites/module.svg#module-urls", + "bidi": false + }, + "module-user": { + "identifier": "module-user", + "category": "module", + "svg": "svgs/module/module-user.svg", + "sprite": "sprites/module.svg#module-user", + "bidi": false + }, + "module-viewpage": { + "identifier": "module-viewpage", + "category": "module", + "svg": "svgs/module/module-viewpage.svg", + "sprite": "sprites/module.svg#module-viewpage", + "bidi": false + }, + "module-web": { + "identifier": "module-web", + "category": "module", + "svg": "svgs/module/module-web.svg", + "sprite": "sprites/module.svg#module-web", + "bidi": false + }, + "module-webhooks": { + "identifier": "module-webhooks", + "category": "module", + "svg": "svgs/module/module-webhooks.svg", + "sprite": "sprites/module.svg#module-webhooks", + "bidi": false + }, + "module-workspaces": { + "identifier": "module-workspaces", + "category": "module", + "svg": "svgs/module/module-workspaces.svg", + "sprite": "sprites/module.svg#module-workspaces", + "bidi": false + }, + "overlay-advanced": { + "identifier": "overlay-advanced", + "category": "overlay", + "svg": "svgs/overlay/overlay-advanced.svg", + "sprite": "sprites/overlay.svg#overlay-advanced", + "bidi": false + }, + "overlay-approved": { + "identifier": "overlay-approved", + "category": "overlay", + "svg": "svgs/overlay/overlay-approved.svg", + "sprite": "sprites/overlay.svg#overlay-approved", + "bidi": false + }, + "overlay-backenduser": { + "identifier": "overlay-backenduser", + "category": "overlay", + "svg": "svgs/overlay/overlay-backenduser.svg", + "sprite": "sprites/overlay.svg#overlay-backenduser", + "bidi": false + }, + "overlay-backendusers": { + "identifier": "overlay-backendusers", + "category": "overlay", + "svg": "svgs/overlay/overlay-backendusers.svg", + "sprite": "sprites/overlay.svg#overlay-backendusers", + "bidi": false + }, + "overlay-deleted": { + "identifier": "overlay-deleted", + "category": "overlay", + "svg": "svgs/overlay/overlay-deleted.svg", + "sprite": "sprites/overlay.svg#overlay-deleted", + "bidi": false + }, + "overlay-edit": { + "identifier": "overlay-edit", + "category": "overlay", + "svg": "svgs/overlay/overlay-edit.svg", + "sprite": "sprites/overlay.svg#overlay-edit", + "bidi": false + }, + "overlay-endtime": { + "identifier": "overlay-endtime", + "category": "overlay", + "svg": "svgs/overlay/overlay-endtime.svg", + "sprite": "sprites/overlay.svg#overlay-endtime", + "bidi": false + }, + "overlay-external-link": { + "identifier": "overlay-external-link", + "category": "overlay", + "svg": "svgs/overlay/overlay-external-link.svg", + "sprite": "sprites/overlay.svg#overlay-external-link", + "bidi": false + }, + "overlay-frontenduser": { + "identifier": "overlay-frontenduser", + "category": "overlay", + "svg": "svgs/overlay/overlay-frontenduser.svg", + "sprite": "sprites/overlay.svg#overlay-frontenduser", + "bidi": false + }, + "overlay-frontendusers": { + "identifier": "overlay-frontendusers", + "category": "overlay", + "svg": "svgs/overlay/overlay-frontendusers.svg", + "sprite": "sprites/overlay.svg#overlay-frontendusers", + "bidi": false + }, + "overlay-hidden": { + "identifier": "overlay-hidden", + "category": "overlay", + "svg": "svgs/overlay/overlay-hidden.svg", + "sprite": "sprites/overlay.svg#overlay-hidden", + "bidi": false + }, + "overlay-includes-subpages": { + "identifier": "overlay-includes-subpages", + "category": "overlay", + "svg": "svgs/overlay/overlay-includes-subpages.svg", + "sprite": "sprites/overlay.svg#overlay-includes-subpages", + "bidi": false + }, + "overlay-info": { + "identifier": "overlay-info", + "category": "overlay", + "svg": "svgs/overlay/overlay-info.svg", + "sprite": "sprites/overlay.svg#overlay-info", + "bidi": false + }, + "overlay-list": { + "identifier": "overlay-list", + "category": "overlay", + "svg": "svgs/overlay/overlay-list.svg", + "sprite": "sprites/overlay.svg#overlay-list", + "bidi": false + }, + "overlay-locked": { + "identifier": "overlay-locked", + "category": "overlay", + "svg": "svgs/overlay/overlay-locked.svg", + "sprite": "sprites/overlay.svg#overlay-locked", + "bidi": false + }, + "overlay-media": { + "identifier": "overlay-media", + "category": "overlay", + "svg": "svgs/overlay/overlay-media.svg", + "sprite": "sprites/overlay.svg#overlay-media", + "bidi": false + }, + "overlay-missing": { + "identifier": "overlay-missing", + "category": "overlay", + "svg": "svgs/overlay/overlay-missing.svg", + "sprite": "sprites/overlay.svg#overlay-missing", + "bidi": false + }, + "overlay-mountpoint": { + "identifier": "overlay-mountpoint", + "category": "overlay", + "svg": "svgs/overlay/overlay-mountpoint.svg", + "sprite": "sprites/overlay.svg#overlay-mountpoint", + "bidi": false + }, + "overlay-new": { + "identifier": "overlay-new", + "category": "overlay", + "svg": "svgs/overlay/overlay-new.svg", + "sprite": "sprites/overlay.svg#overlay-new", + "bidi": false + }, + "overlay-news": { + "identifier": "overlay-news", + "category": "overlay", + "svg": "svgs/overlay/overlay-news.svg", + "sprite": "sprites/overlay.svg#overlay-news", + "bidi": false + }, + "overlay-readonly": { + "identifier": "overlay-readonly", + "category": "overlay", + "svg": "svgs/overlay/overlay-readonly.svg", + "sprite": "sprites/overlay.svg#overlay-readonly", + "bidi": false + }, + "overlay-restricted": { + "identifier": "overlay-restricted", + "category": "overlay", + "svg": "svgs/overlay/overlay-restricted.svg", + "sprite": "sprites/overlay.svg#overlay-restricted", + "bidi": false + }, + "overlay-scheduled": { + "identifier": "overlay-scheduled", + "category": "overlay", + "svg": "svgs/overlay/overlay-scheduled.svg", + "sprite": "sprites/overlay.svg#overlay-scheduled", + "bidi": false + }, + "overlay-shop": { + "identifier": "overlay-shop", + "category": "overlay", + "svg": "svgs/overlay/overlay-shop.svg", + "sprite": "sprites/overlay.svg#overlay-shop", + "bidi": false + }, + "overlay-shortcut": { + "identifier": "overlay-shortcut", + "category": "overlay", + "svg": "svgs/overlay/overlay-shortcut.svg", + "sprite": "sprites/overlay.svg#overlay-shortcut", + "bidi": false + }, + "overlay-translated": { + "identifier": "overlay-translated", + "category": "overlay", + "svg": "svgs/overlay/overlay-translated.svg", + "sprite": "sprites/overlay.svg#overlay-translated", + "bidi": false + }, + "overlay-warning": { + "identifier": "overlay-warning", + "category": "overlay", + "svg": "svgs/overlay/overlay-warning.svg", + "sprite": "sprites/overlay.svg#overlay-warning", + "bidi": false + }, + "spinner-circle": { + "identifier": "spinner-circle", + "category": "spinner", + "svg": "svgs/spinner/spinner-circle.svg", + "sprite": "sprites/spinner.svg#spinner-circle", + "bidi": false + }, + "status-user-admin": { + "identifier": "status-user-admin", + "category": "status", + "svg": "svgs/status/status-user-admin.svg", + "sprite": "sprites/status.svg#status-user-admin", + "bidi": false + }, + "status-user-backend": { + "identifier": "status-user-backend", + "category": "status", + "svg": "svgs/status/status-user-backend.svg", + "sprite": "sprites/status.svg#status-user-backend", + "bidi": false + }, + "status-user-frontend": { + "identifier": "status-user-frontend", + "category": "status", + "svg": "svgs/status/status-user-frontend.svg", + "sprite": "sprites/status.svg#status-user-frontend", + "bidi": false + }, + "status-user-group-backend": { + "identifier": "status-user-group-backend", + "category": "status", + "svg": "svgs/status/status-user-group-backend.svg", + "sprite": "sprites/status.svg#status-user-group-backend", + "bidi": false + }, + "status-user-group-frontend": { + "identifier": "status-user-group-frontend", + "category": "status", + "svg": "svgs/status/status-user-group-frontend.svg", + "sprite": "sprites/status.svg#status-user-group-frontend", + "bidi": false + } + }, + "aliases": { + "actions-view-go-back": "actions-arrow-down-left", + "actions-view-go-forward": "actions-arrow-down-right", + "actions-arrow-forward": "actions-arrow-end", + "actions-version-workspace-sendtoprevstage": "actions-arrow-left", + "actions-view-go-down": "actions-arrow-right-down", + "actions-view-go-up": "actions-arrow-right-up", + "actions-version-workspace-sendtostage": "actions-arrow-right", + "actions-arrow-backward": "actions-arrow-start", + "actions-system-cache-clear-impact-high": "actions-bolt-alt", + "actions-system-cache-clear-impact-medium": "actions-bolt-alt", + "actions-system-cache-clear-impact-low": "actions-bolt-alt", + "actions-system-cache-clear-rte": "actions-bolt-alt", + "apps-toolbar-menu-cache": "actions-bolt", + "actions-system-cache-clear": "actions-bolt", + "actions-system-shortcut-active": "actions-bookmark", + "actions-system-shortcut-new": "actions-bookmark", + "apps-toolbar-menu-shortcut": "actions-bookmark", + "information-os-apple": "actions-brand-apple", + "information-git": "actions-brand-git", + "information-os-linux": "actions-brand-linux", + "information-php-version": "actions-brand-php", + "actions-slack": "actions-brand-slack", + "actions-typo3": "actions-brand-typo3", + "information-os-windows": "actions-brand-windows", + "actions-brand-twitter": "actions-brand-x", + "actions-edit-pick-date": "actions-calendar-alternative", + "actions-move-to-bottom": "actions-caret-bar-bottom", + "actions-caret-bar-right": "actions-caret-bar-end", + "actions-caret-bar-left": "actions-caret-bar-start", + "actions-move-to-top": "actions-caret-bar-top", + "actions-move-down": "actions-caret-down", + "actions-pagetree-expand": "actions-caret-down", + "status-status-sorting-desc": "actions-caret-down", + "status-status-sorting-light-desc": "actions-caret-down", + "apps-irre-expanded": "actions-caret-down", + "apps-pagetree-expand": "actions-caret-down", + "actions-caret-right": "actions-caret-end", + "actions-move-right": "actions-caret-end", + "actions-pagetree-collapse": "actions-caret-end", + "status-status-current": "actions-caret-end", + "apps-irre-collapsed": "actions-caret-end", + "apps-pagetree-collapse": "actions-caret-end", + "actions-caret-left": "actions-caret-start", + "actions-move-left": "actions-caret-start", + "actions-move-up": "actions-caret-up", + "status-status-sorting-asc": "actions-caret-up", + "status-status-sorting-light-asc": "actions-caret-up", + "status-dialog-ok": "actions-check-circle", + "actions-check-unmarkstate": "actions-check-square", + "apps-pagetree-category-toggle-hide-checked": "actions-check-square", + "sysnote-type-4": "actions-check-square", + "status-status-checked": "actions-check", + "status-status-permission-granted": "actions-check", + "actions-chevron-bar-right": "actions-chevron-bar-end", + "actions-view-paging-last": "actions-chevron-bar-end", + "actions-view-paging-last-disabled": "actions-chevron-bar-end", + "actions-chevron-bar-left": "actions-chevron-bar-start", + "actions-view-paging-first": "actions-chevron-bar-start", + "actions-view-paging-first-disabled": "actions-chevron-bar-start", + "actions-navigate-last": "actions-chevron-double-end", + "actions-chevron-double-right": "actions-chevron-double-end", + "actions-view-paging-next": "actions-chevron-double-end", + "actions-view-paging-next-disabled": "actions-chevron-double-end", + "actions-navigate-first": "actions-chevron-double-start", + "actions-chevron-double-left": "actions-chevron-double-start", + "actions-view-paging-previous": "actions-chevron-double-start", + "actions-view-paging-previous-disabled": "actions-chevron-double-start", + "actions-view-list-expand": "actions-chevron-down", + "actions-navigate-next": "actions-chevron-end", + "actions-navigate-forward": "actions-chevron-end", + "actions-chevron-right": "actions-chevron-end", + "actions-view-table-expand": "actions-chevron-end", + "actions-navigate-previous": "actions-chevron-start", + "actions-navigate-back": "actions-chevron-start", + "actions-chevron-left": "actions-chevron-start", + "actions-view-table-collapse": "actions-chevron-start", + "actions-view-list-collapse": "actions-chevron-up", + "actions-edit-copy-release": "actions-clipboard-close", + "actions-document-paste-into": "actions-clipboard-paste", + "actions-document-paste": "actions-clipboard-paste", + "actions-edit-copy": "actions-clipboard", + "actions-message-error-close": "actions-close", + "actions-message-information-close": "actions-close", + "actions-message-notice-close": "actions-close", + "actions-message-ok-close": "actions-close", + "actions-message-warning-close": "actions-close", + "actions-input-clear": "actions-close", + "status-status-permission-denied": "actions-close", + "actions-online-media-add": "actions-cloud", + "actions-edit-merge-localization": "actions-code-merge-localization", + "actions-merge": "actions-code-merge", + "actions-version-document-remove": "actions-code-pull-request-close", + "sysnote-type-2": "actions-code", + "sysnote-type-1": "actions-cog", + "actions-system-extension-configure": "actions-cog", + "actions-edit-cut-release": "actions-cut-release", + "actions-edit-cut": "actions-cut", + "actions-system-extension-sqldump": "actions-database-export", + "information-database": "actions-database", + "information-debugger": "actions-debug", + "actions-edit-undelete-edit": "actions-delete-edit", + "actions-edit-restore": "actions-delete-restore", + "actions-edit-delete": "actions-delete", + "actions-selection-delete": "actions-delete", + "actions-dice-one": "actions-dice-1", + "actions-dice-two": "actions-dice-2", + "actions-dice-three": "actions-dice-3", + "actions-dice-four": "actions-dice-4", + "actions-dice-five": "actions-dice-5", + "actions-dice-six": "actions-dice-6", + "actions-document-new": "actions-document-add", + "actions-document-open": "actions-document-edit", + "actions-document-open-read-only": "actions-document-readonly", + "actions-insert-reference": "actions-document-share", + "actions-system-extension-download": "actions-download", + "actions-edit-download": "actions-download", + "actions-move-move": "actions-drag", + "actions-document-duplicates-select": "actions-duplicates", + "status-dialog-information": "actions-exclamation-circle", + "status-dialog-notification": "actions-exclamation-circle", + "status-dialog-warning": "actions-exclamation-triangle", + "status-dialog-error": "actions-exclamation-triangle", + "actions-system-extension-install": "actions-extension-add", + "actions-system-extension-import": "actions-extension-import", + "actions-system-extension-update": "actions-extension-refresh", + "actions-system-extension-update-disable": "actions-extension-refresh", + "actions-system-extension-uninstall": "actions-extension-remove", + "actions-version-workspaces-preview-link": "actions-eye-link", + "actions-version-workspace-preview": "actions-eye", + "actions-view": "actions-eye", + "actions-page-new": "actions-file-add", + "actions-document-export-csv": "actions-file-csv-download", + "actions-version-page-open": "actions-file-edit", + "actions-page-open": "actions-file-edit", + "actions-page-move": "actions-file-move", + "actions-system-pagemodule-open": "actions-file-search", + "actions-document-export-t3d": "actions-file-t3d-download", + "actions-document-import-t3d": "actions-file-t3d-upload", + "actions-view-page": "actions-file-view", + "apps-toolbar-menu-opendocs": "actions-file", + "actions-system-tree-search-open": "actions-filter", + "actions-insert-record": "actions-folder", + "actions-document-history-open": "actions-history", + "actions-document-info": "actions-info", + "actions-edit-insert-default": "actions-insert", + "actions-wizard-rte": "actions-link", + "actions-wizard-link": "actions-link", + "actions-system-list-open": "actions-list-alternative", + "apps-toolbar-menu-systeminformation": "actions-list-alternative", + "actions-sign-in": "actions-login", + "actions-sign-out": "actions-logout", + "actions-edit-localize-status-high": "actions-message-add", + "actions-localize": "actions-message-localize", + "actions-edit-localize-status-low": "actions-message-remove", + "actions-remove": "actions-minus", + "information-composer-mode": "actions-music", + "sysnote-type-0": "actions-note", + "actions-system-typoscript-documentation": "actions-notebook-typoscript", + "actions-system-typoscript-documentation-open": "actions-notebook-typoscript", + "actions-system-extension-documentation": "actions-notebook", + "actions-pencil": "actions-open", + "actions-system-options-view": "actions-options", + "apps-toolbar-menu-actions": "actions-options", + "actions-pagetree-mountroot": "actions-pagetree-mount", + "actions-document-paste-after": "actions-paste-after", + "actions-add-placeholder": "actions-placeholder-add", + "actions-add": "actions-plus", + "apps-toolbar-menu-help": "actions-question-circle", + "actions-system-help-open": "actions-question", + "actions-edit-redo": "actions-redo", + "actions-system-refresh": "actions-refresh", + "actions-edit-rename": "actions-rename", + "actions-edit-replace": "actions-replace", + "actions-document-save-new": "actions-save-add", + "actions-document-save-close": "actions-save-close", + "actions-document-save-cleartranslationcache": "actions-save-translation-clearcache", + "actions-document-save-translation": "actions-save-translation", + "actions-document-save-view": "actions-save-view", + "actions-document-save": "actions-save", + "apps-toolbar-menu-search": "actions-search", + "information-webserver": "actions-server", + "share-alt": "actions-share-alt", + "actions-check-markstate": "actions-square", + "actions-version-swap-version": "actions-swap", + "actions-version-swap-workspace": "actions-swap", + "sysnote-type-3": "actions-thumbtack", + "actions-edit-unhide": "actions-toggle-off", + "actions-edit-hide": "actions-toggle-on", + "actions-edit-undo": "actions-undo", + "actions-edit-upload": "actions-upload", + "actions-system-backend-user-emulate": "actions-user-emulate", + "actions-system-backend-user-switch": "actions-user-switch", + "actions-variable-select": "actions-variable-add", + "information-application-context": "actions-window-cog", + "apps-toolbar-menu-workspace": "actions-workspace", + "empty-empty": "miscellaneous-placeholder", + "module-cshmanual": "module-documentation", + "module-filelist": "module-file", + "modulegroup-file": "module-file", + "module-version": "module-generic", + "modulegroup-help": "module-help", + "modulegroup-site": "module-site", + "modulegroup-system": "module-system", + "module-templates": "module-template", + "modulegroup-tools": "module-tools", + "module-tstemplate": "module-tsconfig", + "module-beuser": "module-user", + "modulegroup-user": "module-user", + "modulegroup-web": "module-web", + "spinner-circle-light": "spinner-circle", + "spinner-circle-dark": "spinner-circle" + } +} \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/actions.svg b/Resources/Public/Icons/T3Icons/sprites/actions.svg new file mode 100644 index 0000000..6406949 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/actions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/apps.svg b/Resources/Public/Icons/T3Icons/sprites/apps.svg new file mode 100644 index 0000000..458e68f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/apps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/avatar.svg b/Resources/Public/Icons/T3Icons/sprites/avatar.svg new file mode 100644 index 0000000..579f9b7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/content.svg b/Resources/Public/Icons/T3Icons/sprites/content.svg new file mode 100644 index 0000000..0fd3ff3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/content.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/default.svg b/Resources/Public/Icons/T3Icons/sprites/default.svg new file mode 100644 index 0000000..169861f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/files.svg b/Resources/Public/Icons/T3Icons/sprites/files.svg new file mode 100644 index 0000000..d958e6c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/form.svg b/Resources/Public/Icons/T3Icons/sprites/form.svg new file mode 100644 index 0000000..4c73621 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/information.svg b/Resources/Public/Icons/T3Icons/sprites/information.svg new file mode 100644 index 0000000..e4d4b18 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/information.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/install.svg b/Resources/Public/Icons/T3Icons/sprites/install.svg new file mode 100644 index 0000000..23b5703 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/install.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/mimetypes.svg b/Resources/Public/Icons/T3Icons/sprites/mimetypes.svg new file mode 100644 index 0000000..742602b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/mimetypes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/miscellaneous.svg b/Resources/Public/Icons/T3Icons/sprites/miscellaneous.svg new file mode 100644 index 0000000..49f574d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/miscellaneous.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/module.svg b/Resources/Public/Icons/T3Icons/sprites/module.svg new file mode 100644 index 0000000..c2053e9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/module.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/overlay.svg b/Resources/Public/Icons/T3Icons/sprites/overlay.svg new file mode 100644 index 0000000..c2a2659 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/overlay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/spinner.svg b/Resources/Public/Icons/T3Icons/sprites/spinner.svg new file mode 100644 index 0000000..a418397 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/spinner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/sprites/status.svg b/Resources/Public/Icons/T3Icons/sprites/status.svg new file mode 100644 index 0000000..586e499 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/sprites/status.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-accessibility.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-accessibility.svg new file mode 100644 index 0000000..e1403d6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-accessibility.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-approve.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-approve.svg new file mode 100644 index 0000000..ccde556 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-approve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-archive.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-archive.svg new file mode 100644 index 0000000..bdc223d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-alt.svg new file mode 100644 index 0000000..49100a3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end-alt.svg new file mode 100644 index 0000000..812075c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end.svg new file mode 100644 index 0000000..5f3221a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left-alt.svg new file mode 100644 index 0000000..a609939 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left.svg new file mode 100644 index 0000000..cb8d5fd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right-alt.svg new file mode 100644 index 0000000..812075c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right.svg new file mode 100644 index 0000000..5f3221a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start-alt.svg new file mode 100644 index 0000000..a609939 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start.svg new file mode 100644 index 0000000..cb8d5fd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down.svg new file mode 100644 index 0000000..2438da8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-alt.svg new file mode 100644 index 0000000..5f5ca87 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down-alt.svg new file mode 100644 index 0000000..a663839 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down.svg new file mode 100644 index 0000000..1a33472 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up-alt.svg new file mode 100644 index 0000000..8830c49 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up.svg new file mode 100644 index 0000000..9d2324d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end.svg new file mode 100644 index 0000000..97d09cc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left-alt.svg new file mode 100644 index 0000000..55e3d67 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left.svg new file mode 100644 index 0000000..9993548 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-alt.svg new file mode 100644 index 0000000..5f5ca87 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down-alt.svg new file mode 100644 index 0000000..a663839 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down.svg new file mode 100644 index 0000000..1a33472 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up-alt.svg new file mode 100644 index 0000000..8830c49 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up.svg new file mode 100644 index 0000000..9d2324d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right.svg new file mode 100644 index 0000000..97d09cc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start-alt.svg new file mode 100644 index 0000000..55e3d67 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start.svg new file mode 100644 index 0000000..9993548 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up-alt.svg new file mode 100644 index 0000000..55893ae --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up.svg new file mode 100644 index 0000000..f58d4c5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-arrow-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-aspect-ratio.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-aspect-ratio.svg new file mode 100644 index 0000000..1301188 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-aspect-ratio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-badge.svg new file mode 100644 index 0000000..c1a4188 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-ban.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-ban.svg new file mode 100644 index 0000000..0d42017 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-ban.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-read.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-read.svg new file mode 100644 index 0000000..8f2e496 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-read.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-scan.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-scan.svg new file mode 100644 index 0000000..9744ff5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode-scan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode.svg new file mode 100644 index 0000000..604c983 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-barcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-ring.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-ring.svg new file mode 100644 index 0000000..8025e9c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-ring.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-slash.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-slash.svg new file mode 100644 index 0000000..0c20364 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell.svg new file mode 100644 index 0000000..38bb167 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt-alt.svg new file mode 100644 index 0000000..266c19f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt.svg new file mode 100644 index 0000000..da453b9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-book.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-book.svg new file mode 100644 index 0000000..fb2c763 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-book.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-add.svg new file mode 100644 index 0000000..993fe85 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-remove.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-remove.svg new file mode 100644 index 0000000..7fc30d3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark.svg new file mode 100644 index 0000000..c552a2a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmarks.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmarks.svg new file mode 100644 index 0000000..520912e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bookmarks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-apple.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-apple.svg new file mode 100644 index 0000000..d0a8099 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-bluesky.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-bluesky.svg new file mode 100644 index 0000000..03be796 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-bluesky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-discord.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-discord.svg new file mode 100644 index 0000000..5efa77f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-facebook.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-facebook.svg new file mode 100644 index 0000000..e8b72ef --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-git.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-git.svg new file mode 100644 index 0000000..fdc23bd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-git.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-github.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-github.svg new file mode 100644 index 0000000..08a378e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-gitlab.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-gitlab.svg new file mode 100644 index 0000000..8fc1096 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-google.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-google.svg new file mode 100644 index 0000000..940a80d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-instagram.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-instagram.svg new file mode 100644 index 0000000..bbf1b76 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linkedin.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linkedin.svg new file mode 100644 index 0000000..2e5041e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linkedin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linux.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linux.svg new file mode 100644 index 0000000..5f7e2bf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-mastodon.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-mastodon.svg new file mode 100644 index 0000000..7ff0f9b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-mastodon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-php.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-php.svg new file mode 100644 index 0000000..438e6b5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-php.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-slack.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-slack.svg new file mode 100644 index 0000000..04320e5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-threads.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-threads.svg new file mode 100644 index 0000000..86d1b3d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-threads.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-typo3.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-typo3.svg new file mode 100644 index 0000000..55b0a5c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-typo3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-windows.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-windows.svg new file mode 100644 index 0000000..40b37ab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-x.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-x.svg new file mode 100644 index 0000000..89db5ee --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-xing.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-xing.svg new file mode 100644 index 0000000..8f43c53 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-xing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-youtube.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-youtube.svg new file mode 100644 index 0000000..25202a1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brand-youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-briefcase.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-briefcase.svg new file mode 100644 index 0000000..18632c4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-briefcase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-high.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-high.svg new file mode 100644 index 0000000..9554a9c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-high.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-low.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-low.svg new file mode 100644 index 0000000..1f5bd44 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brightness-low.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-browser.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-browser.svg new file mode 100644 index 0000000..5cad6e0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-browser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-brush.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brush.svg new file mode 100644 index 0000000..c71ef00 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-brush.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-building.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-building.svg new file mode 100644 index 0000000..c5d5da8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-building.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn-slash.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn-slash.svg new file mode 100644 index 0000000..d2b7442 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn.svg new file mode 100644 index 0000000..fc1a45d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-bullhorn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar-alternative.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar-alternative.svg new file mode 100644 index 0000000..fdd8c52 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar-alternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar.svg new file mode 100644 index 0000000..ce0f03a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-canvas.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-canvas.svg new file mode 100644 index 0000000..75b9535 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-canvas.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-capslock.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-capslock.svg new file mode 100644 index 0000000..154d76d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-capslock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-bottom.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-bottom.svg new file mode 100644 index 0000000..7a24d11 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-bottom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-end.svg new file mode 100644 index 0000000..82bfd76 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-start.svg new file mode 100644 index 0000000..bd88189 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-top.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-top.svg new file mode 100644 index 0000000..f522fdb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-bar-top.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-down.svg new file mode 100644 index 0000000..810decf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-end.svg new file mode 100644 index 0000000..b72b9c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-start.svg new file mode 100644 index 0000000..189f397 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-up.svg new file mode 100644 index 0000000..7670d3b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-caret-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cart.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cart.svg new file mode 100644 index 0000000..cd6ac60 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-category.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-category.svg new file mode 100644 index 0000000..6bf27a4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-category.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate-alternative.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate-alternative.svg new file mode 100644 index 0000000..3d4d45d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate-alternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate.svg new file mode 100644 index 0000000..5575c98 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-certificate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chat.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chat.svg new file mode 100644 index 0000000..7acf91e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge-alt.svg new file mode 100644 index 0000000..6e22a97 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge.svg new file mode 100644 index 0000000..b2128c9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle-alt.svg new file mode 100644 index 0000000..2a85d16 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle.svg new file mode 100644 index 0000000..7c630ec --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square-alt.svg new file mode 100644 index 0000000..7ea4397 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square.svg new file mode 100644 index 0000000..8b3836e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-check.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check.svg new file mode 100644 index 0000000..8a76e9b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-down.svg new file mode 100644 index 0000000..34f8306 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-end.svg new file mode 100644 index 0000000..f25b3c5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-start.svg new file mode 100644 index 0000000..5fd2099 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-up.svg new file mode 100644 index 0000000..3cbc9d2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-bar-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-contract.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-contract.svg new file mode 100644 index 0000000..902f573 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-contract.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-end.svg new file mode 100644 index 0000000..876bc79 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-start.svg new file mode 100644 index 0000000..e98a9a8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-double-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-down.svg new file mode 100644 index 0000000..666fd1e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-end.svg new file mode 100644 index 0000000..fefd7d6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-expand.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-expand.svg new file mode 100644 index 0000000..3dae785 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-start.svg new file mode 100644 index 0000000..aa07084 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-up.svg new file mode 100644 index 0000000..eadab94 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-full.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-full.svg new file mode 100644 index 0000000..2385cb5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-half.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-half.svg new file mode 100644 index 0000000..06dd6da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle-half.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle.svg new file mode 100644 index 0000000..8631768 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-close.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-close.svg new file mode 100644 index 0000000..3dce1d3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-paste.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-paste.svg new file mode 100644 index 0000000..915a57e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard-paste.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard.svg new file mode 100644 index 0000000..cb99d5e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-clock.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clock.svg new file mode 100644 index 0000000..2451993 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-close.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-close.svg new file mode 100644 index 0000000..90b5f53 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-download.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-download.svg new file mode 100644 index 0000000..f74c3ea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-slash.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-slash.svg new file mode 100644 index 0000000..e84f54b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-upload.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-upload.svg new file mode 100644 index 0000000..5693ad2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud.svg new file mode 100644 index 0000000..cc0b61a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-commit.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-commit.svg new file mode 100644 index 0000000..d05a839 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-commit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-compare.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-compare.svg new file mode 100644 index 0000000..0e58a8e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-fork.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-fork.svg new file mode 100644 index 0000000..e91d969 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-fork.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge-localization.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge-localization.svg new file mode 100644 index 0000000..7b66d38 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge-localization.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge.svg new file mode 100644 index 0000000..9c1d10c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-merge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-close.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-close.svg new file mode 100644 index 0000000..b1d22e8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-draft.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-draft.svg new file mode 100644 index 0000000..7869c01 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request-draft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request.svg new file mode 100644 index 0000000..553a574 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code-pull-request.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-code.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code.svg new file mode 100644 index 0000000..8e7291c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-coffee.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-coffee.svg new file mode 100644 index 0000000..45dd2a9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-coffee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog-alt.svg new file mode 100644 index 0000000..af0cfde --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog.svg new file mode 100644 index 0000000..5284fc6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-comment.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-comment.svg new file mode 100644 index 0000000..7c0c58c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-comment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-container.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-container.svg new file mode 100644 index 0000000..f2ee378 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-container.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie-bite.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie-bite.svg new file mode 100644 index 0000000..84615d9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie-bite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie.svg new file mode 100644 index 0000000..b5c4aa1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cookie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-copyright.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-copyright.svg new file mode 100644 index 0000000..aef705b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-copyright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cpu.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cpu.svg new file mode 100644 index 0000000..00bc15f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cpu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-credit-card.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-credit-card.svg new file mode 100644 index 0000000..366d551 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-credit-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-crop.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-crop.svg new file mode 100644 index 0000000..df3aa00 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-crop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut-release.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut-release.svg new file mode 100644 index 0000000..8159df5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut-release.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut.svg new file mode 100644 index 0000000..4056eae --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-cut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-export.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-export.svg new file mode 100644 index 0000000..91f9dc0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-export.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-import.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-import.svg new file mode 100644 index 0000000..f4dfc02 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-import.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-reload.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-reload.svg new file mode 100644 index 0000000..0b6a907 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database-reload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-database.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database.svg new file mode 100644 index 0000000..29450ec --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-debug.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-debug.svg new file mode 100644 index 0000000..d0b3946 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-debug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-edit.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-edit.svg new file mode 100644 index 0000000..90ab23f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-restore.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-restore.svg new file mode 100644 index 0000000..d4b8861 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete-restore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete.svg new file mode 100644 index 0000000..805cee9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-star.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-star.svg new file mode 100644 index 0000000..ae26787 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-user.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-user.svg new file mode 100644 index 0000000..0ae06bb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop.svg new file mode 100644 index 0000000..1d1cc36 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-desktop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-mobile.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-mobile.svg new file mode 100644 index 0000000..d009139 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-orientation-change.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-orientation-change.svg new file mode 100644 index 0000000..c159207 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-orientation-change.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-tablet.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-tablet.svg new file mode 100644 index 0000000..ec1024d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-tablet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-unidentified.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-unidentified.svg new file mode 100644 index 0000000..7d39089 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-device-unidentified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-1.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-1.svg new file mode 100644 index 0000000..fbb2390 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-2.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-2.svg new file mode 100644 index 0000000..8cc3741 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-3.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-3.svg new file mode 100644 index 0000000..56e6b93 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-4.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-4.svg new file mode 100644 index 0000000..95a7f0d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-5.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-5.svg new file mode 100644 index 0000000..4902743 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-6.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-6.svg new file mode 100644 index 0000000..b3873fc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice-6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice.svg new file mode 100644 index 0000000..9e3f358 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-add.svg new file mode 100644 index 0000000..e87abe3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit-access.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit-access.svg new file mode 100644 index 0000000..1c73639 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit-access.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit.svg new file mode 100644 index 0000000..2aed315 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-localize.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-localize.svg new file mode 100644 index 0000000..f739f4e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-localize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-move.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-move.svg new file mode 100644 index 0000000..1e3b012 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-move.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-readonly.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-readonly.svg new file mode 100644 index 0000000..a7fdc5f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-readonly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-select.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-select.svg new file mode 100644 index 0000000..b3033b2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-share.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-share.svg new file mode 100644 index 0000000..c4796d5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-synchronize.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-synchronize.svg new file mode 100644 index 0000000..8ef3515 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-synchronize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-view.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-view.svg new file mode 100644 index 0000000..ee723bf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-document.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document.svg new file mode 100644 index 0000000..986aff4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-document.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-dot.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dot.svg new file mode 100644 index 0000000..b09a4a0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-dot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-download.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-download.svg new file mode 100644 index 0000000..f918cfe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-drag.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-drag.svg new file mode 100644 index 0000000..070ec42 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicate.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicate.svg new file mode 100644 index 0000000..58a6456 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicates.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicates.svg new file mode 100644 index 0000000..7b2295d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-duplicates.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open-text.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open-text.svg new file mode 100644 index 0000000..4398136 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open.svg new file mode 100644 index 0000000..36b202f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope.svg new file mode 100644 index 0000000..f5fa740 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-envelope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exchange.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exchange.svg new file mode 100644 index 0000000..a6f0afd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exchange.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle-alt.svg new file mode 100644 index 0000000..20808c7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle.svg new file mode 100644 index 0000000..975ebff --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle-alt.svg new file mode 100644 index 0000000..ec4cd44 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle.svg new file mode 100644 index 0000000..b380866 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation-triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation.svg new file mode 100644 index 0000000..79f7614 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-exclamation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-expand.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-expand.svg new file mode 100644 index 0000000..42bcb84 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-add.svg new file mode 100644 index 0000000..db57a73 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-import.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-import.svg new file mode 100644 index 0000000..7fccdb6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-import.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-refresh.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-refresh.svg new file mode 100644 index 0000000..0cd24bd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-remove.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-remove.svg new file mode 100644 index 0000000..a5259ed --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension.svg new file mode 100644 index 0000000..7b38728 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-extension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye-link.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye-link.svg new file mode 100644 index 0000000..8b85138 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye.svg new file mode 100644 index 0000000..2c260b2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-add.svg new file mode 100644 index 0000000..13e9150 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-audio.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-audio.svg new file mode 100644 index 0000000..a942aa6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-audio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-certificate.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-certificate.svg new file mode 100644 index 0000000..8cb8570 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-certificate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv-download.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv-download.svg new file mode 100644 index 0000000..0ae1ce5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv.svg new file mode 100644 index 0000000..7f4f61a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-edit.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-edit.svg new file mode 100644 index 0000000..43911bd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-html.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-html.svg new file mode 100644 index 0000000..a207ffe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-image.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-image.svg new file mode 100644 index 0000000..93fd761 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-move.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-move.svg new file mode 100644 index 0000000..9090231 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-move.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-openoffice.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-openoffice.svg new file mode 100644 index 0000000..fb0cad6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-openoffice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-pdf.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-pdf.svg new file mode 100644 index 0000000..eccc8b6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-search.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-search.svg new file mode 100644 index 0000000..365cd23 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-shield.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-shield.svg new file mode 100644 index 0000000..53fd919 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-download.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-download.svg new file mode 100644 index 0000000..dc55865 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-upload.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-upload.svg new file mode 100644 index 0000000..fb427f0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d.svg new file mode 100644 index 0000000..865ec29 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-t3d.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-text.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-text.svg new file mode 100644 index 0000000..a78caca --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-video.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-video.svg new file mode 100644 index 0000000..771dc73 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-view.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-view.svg new file mode 100644 index 0000000..350c5ef --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-file.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file.svg new file mode 100644 index 0000000..6fe8baa --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-filter.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-filter.svg new file mode 100644 index 0000000..1431c23 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder-add.svg new file mode 100644 index 0000000..0bf0cae --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder.svg new file mode 100644 index 0000000..3b35b59 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-after.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-after.svg new file mode 100644 index 0000000..27e8d78 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-after.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-before.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-before.svg new file mode 100644 index 0000000..84a9af3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-before.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-in.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-in.svg new file mode 100644 index 0000000..df7b9e0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-form-insert-in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-fullscreen.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-fullscreen.svg new file mode 100644 index 0000000..1781405 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift-card.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift-card.svg new file mode 100644 index 0000000..68986c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift.svg new file mode 100644 index 0000000..922621b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-gift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-git.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-git.svg new file mode 100644 index 0000000..23c2a07 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-git.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe-alt.svg new file mode 100644 index 0000000..9223cfc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe.svg new file mode 100644 index 0000000..8d6ccdd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-graduation-cap.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-graduation-cap.svg new file mode 100644 index 0000000..621f5c5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-graduation-cap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-hand-pointer.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-hand-pointer.svg new file mode 100644 index 0000000..7b9d177 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-hand-pointer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart-alt.svg new file mode 100644 index 0000000..673d034 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart.svg new file mode 100644 index 0000000..3f43837 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-history.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-history.svg new file mode 100644 index 0000000..c8aa2f6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-house.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-house.svg new file mode 100644 index 0000000..a672ad1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-hyphen.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-hyphen.svg new file mode 100644 index 0000000..ffef04a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-hyphen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-id-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-id-badge.svg new file mode 100644 index 0000000..1197e19 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-id-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-image.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-image.svg new file mode 100644 index 0000000..3182257 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-infinity.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-infinity.svg new file mode 100644 index 0000000..40e2c87 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-infinity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle-alt.svg new file mode 100644 index 0000000..e82982d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle.svg new file mode 100644 index 0000000..62fe877 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-info.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info.svg new file mode 100644 index 0000000..a1ce2a5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-insert.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-insert.svg new file mode 100644 index 0000000..97568da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-insert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-key.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-key.svg new file mode 100644 index 0000000..29dfad1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb-on.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb-on.svg new file mode 100644 index 0000000..ddb7c71 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb-on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb.svg new file mode 100644 index 0000000..b3ce353 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-line-columns.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-line-columns.svg new file mode 100644 index 0000000..e4892c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-line-columns.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-link.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-link.svg new file mode 100644 index 0000000..71fa1a7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-list-alternative.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-list-alternative.svg new file mode 100644 index 0000000..0c7046f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-list-alternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-list.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-list.svg new file mode 100644 index 0000000..ece304f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-lock.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lock.svg new file mode 100644 index 0000000..13cf1aa --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-login.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-login.svg new file mode 100644 index 0000000..a8d76b0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-logout.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-logout.svg new file mode 100644 index 0000000..5a2217a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-magnet.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-magnet.svg new file mode 100644 index 0000000..aaccfdb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-magnet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-map.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-map.svg new file mode 100644 index 0000000..c27a42f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-marker.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-marker.svg new file mode 100644 index 0000000..fb35e7d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-marker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-alternative.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-alternative.svg new file mode 100644 index 0000000..57411da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-alternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-collapsed.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-collapsed.svg new file mode 100644 index 0000000..71abbd6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-collapsed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-expanded.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-expanded.svg new file mode 100644 index 0000000..35c62b0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu-sidebar-expanded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu.svg new file mode 100644 index 0000000..f4dd8b9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-add.svg new file mode 100644 index 0000000..278fef8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-dots.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-dots.svg new file mode 100644 index 0000000..4b218ab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-dots.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-localize.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-localize.svg new file mode 100644 index 0000000..14af8d3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-localize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-remove.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-remove.svg new file mode 100644 index 0000000..5f1d943 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-message.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message.svg new file mode 100644 index 0000000..af39e6c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-microchip.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-microchip.svg new file mode 100644 index 0000000..7f97319 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-microchip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge-alt.svg new file mode 100644 index 0000000..8921e17 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge.svg new file mode 100644 index 0000000..3bc379a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle-alt.svg new file mode 100644 index 0000000..4279103 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle.svg new file mode 100644 index 0000000..5e5d0ee --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square-alt.svg new file mode 100644 index 0000000..69ed170 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square.svg new file mode 100644 index 0000000..28aa654 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus.svg new file mode 100644 index 0000000..a5968c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-moon.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-moon.svg new file mode 100644 index 0000000..d4da83f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-move.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-move.svg new file mode 100644 index 0000000..6b62609 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-move.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-music-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-music-alt.svg new file mode 100644 index 0000000..ace302b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-music-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-music.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-music.svg new file mode 100644 index 0000000..81a10cd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-music.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-newspaper.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-newspaper.svg new file mode 100644 index 0000000..51df747 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-newspaper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-note.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-note.svg new file mode 100644 index 0000000..751d796 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook-typoscript.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook-typoscript.svg new file mode 100644 index 0000000..4735a9a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook-typoscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook.svg new file mode 100644 index 0000000..3e09744 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-notebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-open.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-open.svg new file mode 100644 index 0000000..dcb6954 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-options.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-options.svg new file mode 100644 index 0000000..1458302 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-options.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-package.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-package.svg new file mode 100644 index 0000000..3f177c3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-package.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree-mount.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree-mount.svg new file mode 100644 index 0000000..073f9c5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree-mount.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree.svg new file mode 100644 index 0000000..acc5955 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pagetree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-end.svg new file mode 100644 index 0000000..0137cca --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-start.svg new file mode 100644 index 0000000..d1cb520 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-collapse-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-end.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-end.svg new file mode 100644 index 0000000..c28e1a0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-start.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-start.svg new file mode 100644 index 0000000..9f7403a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-panel-expand-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-paperplane.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paperplane.svg new file mode 100644 index 0000000..9f31202 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paperplane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-after.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-after.svg new file mode 100644 index 0000000..5201fea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-after.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-before.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-before.svg new file mode 100644 index 0000000..85e5c44 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-paste-before.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-pause.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pause.svg new file mode 100644 index 0000000..b93faf3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent-badge.svg new file mode 100644 index 0000000..e95052f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent.svg new file mode 100644 index 0000000..4939afe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-percent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-phone.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-phone.svg new file mode 100644 index 0000000..6c089d7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder-add.svg new file mode 100644 index 0000000..ffaac02 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder.svg new file mode 100644 index 0000000..5ef04ef --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-play.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-play.svg new file mode 100644 index 0000000..5aee2ef --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge-alt.svg new file mode 100644 index 0000000..b538216 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge.svg new file mode 100644 index 0000000..9e707cd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle-alt.svg new file mode 100644 index 0000000..2ca1da6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle.svg new file mode 100644 index 0000000..bf8f0cb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square-alt.svg new file mode 100644 index 0000000..610d955 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square.svg new file mode 100644 index 0000000..acf8bb3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus.svg new file mode 100644 index 0000000..cbab21b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-preview.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-preview.svg new file mode 100644 index 0000000..1d1cc36 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-preview.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-qrcode.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-qrcode.svg new file mode 100644 index 0000000..d29595d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-qrcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle-alt.svg new file mode 100644 index 0000000..2500964 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle.svg new file mode 100644 index 0000000..0b82e20 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-question.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question.svg new file mode 100644 index 0000000..be9ead7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-random.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-random.svg new file mode 100644 index 0000000..696ee14 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-random.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-receipt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-receipt.svg new file mode 100644 index 0000000..f866f47 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-receipt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-redo.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-redo.svg new file mode 100644 index 0000000..a0ebc40 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-refresh.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-refresh.svg new file mode 100644 index 0000000..cba38c7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-rename.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rename.svg new file mode 100644 index 0000000..e58de25 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rename.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-replace.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-replace.svg new file mode 100644 index 0000000..d42c404 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-replace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-rocket.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rocket.svg new file mode 100644 index 0000000..9012ef2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rocket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-rss.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rss.svg new file mode 100644 index 0000000..edc84df --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-rss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-add.svg new file mode 100644 index 0000000..b6f155c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-close.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-close.svg new file mode 100644 index 0000000..567b46a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation-clearcache.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation-clearcache.svg new file mode 100644 index 0000000..c4ffcc5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation-clearcache.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation.svg new file mode 100644 index 0000000..516076a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-translation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-view.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-view.svg new file mode 100644 index 0000000..44a1dfe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-save.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save.svg new file mode 100644 index 0000000..27411cb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-search.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-search.svg new file mode 100644 index 0000000..05b0a64 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-all.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-all.svg new file mode 100644 index 0000000..01b17b4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-invert.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-invert.svg new file mode 100644 index 0000000..08a5c10 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-invert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-none.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-none.svg new file mode 100644 index 0000000..9b2a037 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection-elements-none.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection.svg new file mode 100644 index 0000000..c022093 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-selection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-server.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-server.svg new file mode 100644 index 0000000..6be82a0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-share-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-share-alt.svg new file mode 100644 index 0000000..dc63e87 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-share-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-share.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-share.svg new file mode 100644 index 0000000..98b6299 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-star.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-star.svg new file mode 100644 index 0000000..2e04e6e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-typo3.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-typo3.svg new file mode 100644 index 0000000..97141c1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield-typo3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield.svg new file mode 100644 index 0000000..a5a7b30 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-soft-hyphen.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-soft-hyphen.svg new file mode 100644 index 0000000..aa52c1d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-soft-hyphen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-down.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-down.svg new file mode 100644 index 0000000..908b4eb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-up.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-up.svg new file mode 100644 index 0000000..b1a42ab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount.svg new file mode 100644 index 0000000..3dd8fa0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-sort-amount.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-square.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-square.svg new file mode 100644 index 0000000..36db7b8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-star-alt.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-star-alt.svg new file mode 100644 index 0000000..cfb71d8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-star-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-star.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-star.svg new file mode 100644 index 0000000..6ce97cc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-store.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-store.svg new file mode 100644 index 0000000..59e61c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-surfboard.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-surfboard.svg new file mode 100644 index 0000000..96a2f1b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-surfboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-swap.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-swap.svg new file mode 100644 index 0000000..ce30ea4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-swap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-synchronize.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-synchronize.svg new file mode 100644 index 0000000..b42fcd6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-synchronize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-table.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-table.svg new file mode 100644 index 0000000..b89bf3e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-tag.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-tag.svg new file mode 100644 index 0000000..ee9e7cf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-template-new.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-template-new.svg new file mode 100644 index 0000000..21ce00a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-template-new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-template.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-template.svg new file mode 100644 index 0000000..580fced --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-terminal.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-terminal.svg new file mode 100644 index 0000000..4ff7f0e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-text-indent.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-text-indent.svg new file mode 100644 index 0000000..b3bbb19 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-text-indent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-thumbtack.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-thumbtack.svg new file mode 100644 index 0000000..f24307f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-thumbtack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-ticket.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-ticket.svg new file mode 100644 index 0000000..fd7bb3f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-ticket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-off.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-off.svg new file mode 100644 index 0000000..b800658 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-on.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-on.svg new file mode 100644 index 0000000..683ac97 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-toggle-on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-translate.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-translate.svg new file mode 100644 index 0000000..a612014 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-translate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-triangle.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-triangle.svg new file mode 100644 index 0000000..20a6dfe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-trophy.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-trophy.svg new file mode 100644 index 0000000..a285b89 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-undo.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-undo.svg new file mode 100644 index 0000000..ba9184a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-university.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-university.svg new file mode 100644 index 0000000..6b32919 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-university.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlink.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlink.svg new file mode 100644 index 0000000..07aae06 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlock.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlock.svg new file mode 100644 index 0000000..4cd716f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-upload.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-upload.svg new file mode 100644 index 0000000..08bdb2e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-emulate.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-emulate.svg new file mode 100644 index 0000000..455c8d7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-emulate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-switch.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-switch.svg new file mode 100644 index 0000000..bff7c52 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user-switch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-user.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user.svg new file mode 100644 index 0000000..ac58ff3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-users.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-users.svg new file mode 100644 index 0000000..0d63161 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-add.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-add.svg new file mode 100644 index 0000000..4535c08 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-remove.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-remove.svg new file mode 100644 index 0000000..5e1f7af --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable.svg new file mode 100644 index 0000000..0bc9aec --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-variable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-video.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-video.svg new file mode 100644 index 0000000..5664012 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-compare.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-compare.svg new file mode 100644 index 0000000..5358642 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-layout.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-layout.svg new file mode 100644 index 0000000..fac44bc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-list.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-list.svg new file mode 100644 index 0000000..c739607 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-photos.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-photos.svg new file mode 100644 index 0000000..654eb19 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-photos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-tiles.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-tiles.svg new file mode 100644 index 0000000..9e5ff8f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-viewmode-tiles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-wallet.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wallet.svg new file mode 100644 index 0000000..5775a34 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand-sparkles.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand-sparkles.svg new file mode 100644 index 0000000..6e2e209 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand-sparkles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand.svg new file mode 100644 index 0000000..a6ec8dc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-wave.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wave.svg new file mode 100644 index 0000000..712864b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-wave.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-webhook.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-webhook.svg new file mode 100644 index 0000000..87f4d13 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-webhook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-cog.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-cog.svg new file mode 100644 index 0000000..e3b0571 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-cog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-open.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-open.svg new file mode 100644 index 0000000..2acabb8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-restore.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-restore.svg new file mode 100644 index 0000000..3e102d6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window-restore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-window.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window.svg new file mode 100644 index 0000000..9381db8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/actions/actions-workspace.svg b/Resources/Public/Icons/T3Icons/svgs/actions/actions-workspace.svg new file mode 100644 index 0000000..f6137c8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/actions/actions-workspace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-images.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-images.svg new file mode 100644 index 0000000..ea56a9c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-images.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-list.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-list.svg new file mode 100644 index 0000000..6e2b032 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-clipboard-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-add.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-add.svg new file mode 100644 index 0000000..7083a1f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-default.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-default.svg new file mode 100644 index 0000000..4b19f91 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-list.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-list.svg new file mode 100644 index 0000000..e2165a7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-locked.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-locked.svg new file mode 100644 index 0000000..e31b18c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-locked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-media.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-media.svg new file mode 100644 index 0000000..ac97c85 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-news.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-news.svg new file mode 100644 index 0000000..8e43373 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-opened.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-opened.svg new file mode 100644 index 0000000..9b3a0c1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-opened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-recycler.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-recycler.svg new file mode 100644 index 0000000..02d9be8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-recycler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-temp.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-temp.svg new file mode 100644 index 0000000..b6fa878 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-temp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-user.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-user.svg new file mode 100644 index 0000000..a022b51 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder.svg new file mode 100644 index 0000000..4b19f91 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-mount.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-mount.svg new file mode 100644 index 0000000..7de8d84 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-mount.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-root.svg new file mode 100644 index 0000000..655e884 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-filetree-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user-hideinmenu.svg new file mode 100644 index 0000000..dc4f634 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user.svg new file mode 100644 index 0000000..ad60f38 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-backend-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-collapse-all.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-collapse-all.svg new file mode 100644 index 0000000..489d9b4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-collapse-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-expand-all.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-expand-all.svg new file mode 100644 index 0000000..3258f7a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-category-expand-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-above.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-above.svg new file mode 100644 index 0000000..1c963ab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-above.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-below.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-below.svg new file mode 100644 index 0000000..1809b2e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-copy-below.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-above.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-above.svg new file mode 100644 index 0000000..d79c4c9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-above.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-below.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-below.svg new file mode 100644 index 0000000..3402536 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-below.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-between.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-between.svg new file mode 100644 index 0000000..460b582 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-between.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-into.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-into.svg new file mode 100644 index 0000000..689b2c5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-move-into.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-between.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-between.svg new file mode 100644 index 0000000..09710eb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-between.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-inside.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-inside.svg new file mode 100644 index 0000000..d9c47b6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-new-inside.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-place-denied.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-place-denied.svg new file mode 100644 index 0000000..d4a0280 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-drag-place-denied.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-approve.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-approve.svg new file mode 100644 index 0000000..c8e6ca3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-approve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-board.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-board.svg new file mode 100644 index 0000000..8820c9e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-board.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-fe_users.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-fe_users.svg new file mode 100644 index 0000000..e326594 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-fe_users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-news.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-news.svg new file mode 100644 index 0000000..c312a38 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-shop.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-shop.svg new file mode 100644 index 0000000..120fbb1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains-shop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains.svg new file mode 100644 index 0000000..40aa6e4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-contains.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-default.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-default.svg new file mode 100644 index 0000000..b6fa878 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-hideinmenu.svg new file mode 100644 index 0000000..b6fa878 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-root.svg new file mode 100644 index 0000000..7960f0a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-folder-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-hideinmenu.svg new file mode 100644 index 0000000..121cb23 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-root.svg new file mode 100644 index 0000000..1954ebc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced.svg new file mode 100644 index 0000000..75cfc9d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-advanced.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-hideinmenu.svg new file mode 100644 index 0000000..d16f2cb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-root.svg new file mode 100644 index 0000000..b1eaf9f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user.svg new file mode 100644 index 0000000..f382ef3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-hideinmenu.svg new file mode 100644 index 0000000..4a95abe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-root.svg new file mode 100644 index 0000000..19cbfdb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users.svg new file mode 100644 index 0000000..696cfc1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-backend-users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-hideinmenu.svg new file mode 100644 index 0000000..c10db16 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-root.svg new file mode 100644 index 0000000..cf5f7af --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page.svg new file mode 100644 index 0000000..deacd50 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-content-from-page.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-default.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-default.svg new file mode 100644 index 0000000..4c3e48f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-domain.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-domain.svg new file mode 100644 index 0000000..7960f0a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-domain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-hideinmenu.svg new file mode 100644 index 0000000..29ce781 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-root.svg new file mode 100644 index 0000000..a77aa70 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user.svg new file mode 100644 index 0000000..a0c1351 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-hideinmenu.svg new file mode 100644 index 0000000..b3f1d0f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-root.svg new file mode 100644 index 0000000..ce163a4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users.svg new file mode 100644 index 0000000..cd55715 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-frontend-users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-hideinmenu.svg new file mode 100644 index 0000000..817603b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-hideinmenu.svg new file mode 100644 index 0000000..69fa55b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-root.svg new file mode 100644 index 0000000..d4f2d24 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint.svg new file mode 100644 index 0000000..c4c8311 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-mountpoint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler-hideinmenu.svg new file mode 100644 index 0000000..02d9be8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler.svg new file mode 100644 index 0000000..02d9be8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-recycler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-hideinmenu.svg new file mode 100644 index 0000000..871496e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-root.svg new file mode 100644 index 0000000..91f30a9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external.svg new file mode 100644 index 0000000..89be04e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-external.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-hideinmenu.svg new file mode 100644 index 0000000..07959c4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-root.svg new file mode 100644 index 0000000..f11d0ba --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut.svg new file mode 100644 index 0000000..e80b181 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page-shortcut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page.svg new file mode 100644 index 0000000..aea67f7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-page.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-root.svg new file mode 100644 index 0000000..17e1eb1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-hideinmenu.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-hideinmenu.svg new file mode 100644 index 0000000..f3e0f37 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-hideinmenu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-root.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-root.svg new file mode 100644 index 0000000..7960f0a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer-root.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer.svg b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer.svg new file mode 100644 index 0000000..f8aec6d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/apps/apps-pagetree-spacer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/avatar/avatar-default.svg b/Resources/Public/Icons/T3Icons/svgs/avatar/avatar-default.svg new file mode 100644 index 0000000..278665d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/avatar/avatar-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-accordion.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-accordion.svg new file mode 100644 index 0000000..33c10e3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-accordion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-audio.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-audio.svg new file mode 100644 index 0000000..605867e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-audio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-center.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-center.svg new file mode 100644 index 0000000..2cafe67 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-left.svg new file mode 100644 index 0000000..63e8e43 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-right.svg new file mode 100644 index 0000000..7c7eceb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-above-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-center.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-center.svg new file mode 100644 index 0000000..9d30eb8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-left.svg new file mode 100644 index 0000000..04ccae8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-right.svg new file mode 100644 index 0000000..c68a316 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-below-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-left.svg new file mode 100644 index 0000000..41c5083 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-right.svg new file mode 100644 index 0000000..40777d8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-centered-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-left.svg new file mode 100644 index 0000000..b6813da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-right.svg new file mode 100644 index 0000000..2c6c872 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-beside-text-img-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-book.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-book.svg new file mode 100644 index 0000000..bcb0f76 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-book.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-bookmark.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-bookmark.svg new file mode 100644 index 0000000..634af50 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-briefcase.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-briefcase.svg new file mode 100644 index 0000000..6bd0cdb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-briefcase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-building.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-building.svg new file mode 100644 index 0000000..fd1c9e8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-building.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-bullets.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-bullets.svg new file mode 100644 index 0000000..87901c2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-bullets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-card-group.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-card-group.svg new file mode 100644 index 0000000..e0f8937 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-card-group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-card.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-card.svg new file mode 100644 index 0000000..d93eaca --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-header.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-header.svg new file mode 100644 index 0000000..f524196 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-html.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-html.svg new file mode 100644 index 0000000..96b5fc3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-image.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-image.svg new file mode 100644 index 0000000..78a968d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-calltoaction.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-calltoaction.svg new file mode 100644 index 0000000..c1188d2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-calltoaction.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-textandimage.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-textandimage.svg new file mode 100644 index 0000000..df01ff2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel-item-textandimage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-carousel.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel.svg new file mode 100644 index 0000000..9f33a0c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-carousel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-certificate-alternative.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-certificate-alternative.svg new file mode 100644 index 0000000..4be4120 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-certificate-alternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-certificate.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-certificate.svg new file mode 100644 index 0000000..b803dd1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-certificate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-clock.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-clock.svg new file mode 100644 index 0000000..b65ed15 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-coffee.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-coffee.svg new file mode 100644 index 0000000..acf5d58 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-coffee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-1.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-1.svg new file mode 100644 index 0000000..34863f6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-left.svg new file mode 100644 index 0000000..e72660a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-right.svg new file mode 100644 index 0000000..998a6ea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2.svg new file mode 100644 index 0000000..cac89a8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-3.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-3.svg new file mode 100644 index 0000000..e221003 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-4.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-4.svg new file mode 100644 index 0000000..a8e1647 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container-columns-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-container.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-container.svg new file mode 100644 index 0000000..72f305e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-container.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-cpu.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-cpu.svg new file mode 100644 index 0000000..daf68f2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-cpu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-csv.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-csv.svg new file mode 100644 index 0000000..7bb2cc6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard-empty.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard-empty.svg new file mode 100644 index 0000000..af5cd03 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard-empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard.svg new file mode 100644 index 0000000..34aa1ba --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-database.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-database.svg new file mode 100644 index 0000000..c58ade8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-device-desktop.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-device-desktop.svg new file mode 100644 index 0000000..2797848 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-device-desktop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-device-mobile.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-device-mobile.svg new file mode 100644 index 0000000..9196be4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-device-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-device-tablet.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-device-tablet.svg new file mode 100644 index 0000000..a6ce755 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-device-tablet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-elements-login.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-login.svg new file mode 100644 index 0000000..e9d6d54 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-elements-mailform.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-mailform.svg new file mode 100644 index 0000000..a1480b7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-mailform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-elements-searchform.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-searchform.svg new file mode 100644 index 0000000..89c191c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-elements-searchform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-extension.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-extension.svg new file mode 100644 index 0000000..005d62a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-extension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-form.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-form.svg new file mode 100644 index 0000000..6521cc7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-gallery.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-gallery.svg new file mode 100644 index 0000000..ca98308 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-gallery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-header.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-header.svg new file mode 100644 index 0000000..33e5184 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-heart.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-heart.svg new file mode 100644 index 0000000..c4dacd6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-idea.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-idea.svg new file mode 100644 index 0000000..2c0139e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-idea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-image.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-image.svg new file mode 100644 index 0000000..7237d1a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-info.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-info.svg new file mode 100644 index 0000000..dd4b3d2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-left.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-left.svg new file mode 100644 index 0000000..4431cff --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-right.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-right.svg new file mode 100644 index 0000000..5b6be35 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-inside-text-img-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-listgroup.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-listgroup.svg new file mode 100644 index 0000000..79cb7a9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-listgroup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-magnet.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-magnet.svg new file mode 100644 index 0000000..2356bb4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-magnet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-map.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-map.svg new file mode 100644 index 0000000..7868d92 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-marker.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-marker.svg new file mode 100644 index 0000000..91f1e59 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-marker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-media.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-media.svg new file mode 100644 index 0000000..e913a61 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-abstract.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-abstract.svg new file mode 100644 index 0000000..83b7f4d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-abstract.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-card.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-card.svg new file mode 100644 index 0000000..07d8354 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-categorized.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-categorized.svg new file mode 100644 index 0000000..59d7cc9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-categorized.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-pages.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-pages.svg new file mode 100644 index 0000000..8e6c6de --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-pages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-recently-updated.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-recently-updated.svg new file mode 100644 index 0000000..f04101a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-recently-updated.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-related.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-related.svg new file mode 100644 index 0000000..7a8574a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-related.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-section.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-section.svg new file mode 100644 index 0000000..e743b9d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-section.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap-pages.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap-pages.svg new file mode 100644 index 0000000..5af0cda --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap-pages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap.svg new file mode 100644 index 0000000..9cdb6bb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-sitemap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-menu-thumbnail.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-thumbnail.svg new file mode 100644 index 0000000..dca812f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-menu-thumbnail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-message-dots.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-message-dots.svg new file mode 100644 index 0000000..14067a0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-message-dots.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-message.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-message.svg new file mode 100644 index 0000000..be687e2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-messages.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-messages.svg new file mode 100644 index 0000000..ab8df66 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-messages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-microchip.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-microchip.svg new file mode 100644 index 0000000..162296f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-microchip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-news.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-news.svg new file mode 100644 index 0000000..4c48d08 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-note.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-note.svg new file mode 100644 index 0000000..a7a45ea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-package.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-package.svg new file mode 100644 index 0000000..a7ca995 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-package.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-panel.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-panel.svg new file mode 100644 index 0000000..1303b98 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-panel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-plugin.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-plugin.svg new file mode 100644 index 0000000..5bac59d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-plugin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-quote.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-quote.svg new file mode 100644 index 0000000..c770713 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-quote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-div.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-div.svg new file mode 100644 index 0000000..2dbd61b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-div.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-html.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-html.svg new file mode 100644 index 0000000..c99d0ce --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-indexed_search.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-indexed_search.svg new file mode 100644 index 0000000..89c191c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-indexed_search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-menu.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-menu.svg new file mode 100644 index 0000000..c1f8e0e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-shortcut.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-shortcut.svg new file mode 100644 index 0000000..f0c81cd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-shortcut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-special-uploads.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-special-uploads.svg new file mode 100644 index 0000000..68503ff --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-special-uploads.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-store.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-store.svg new file mode 100644 index 0000000..4419d83 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-tab-item.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-tab-item.svg new file mode 100644 index 0000000..47a8d13 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-tab-item.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-tab.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-tab.svg new file mode 100644 index 0000000..c0ad670 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-table.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-table.svg new file mode 100644 index 0000000..c328876 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-target.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-target.svg new file mode 100644 index 0000000..c3ac32c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-target.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-text-columns.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-text-columns.svg new file mode 100644 index 0000000..98d9158 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-text-columns.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-text-teaser.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-text-teaser.svg new file mode 100644 index 0000000..780853f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-text-teaser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-text.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-text.svg new file mode 100644 index 0000000..b3fecc9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-textmedia.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-textmedia.svg new file mode 100644 index 0000000..9b1b027 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-textmedia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-textpic.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-textpic.svg new file mode 100644 index 0000000..3b3cfeb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-textpic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-thumbtack.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-thumbtack.svg new file mode 100644 index 0000000..cdaf6cf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-thumbtack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-timeline-item.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-timeline-item.svg new file mode 100644 index 0000000..d2df402 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-timeline-item.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-timeline.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-timeline.svg new file mode 100644 index 0000000..6af04d8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-trophy.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-trophy.svg new file mode 100644 index 0000000..55536f0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-user.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-user.svg new file mode 100644 index 0000000..8f48411 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-webhook.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-webhook.svg new file mode 100644 index 0000000..9e1ae45 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-webhook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-calltoaction.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-calltoaction.svg new file mode 100644 index 0000000..13077ff --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-calltoaction.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-bar.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-bar.svg new file mode 100644 index 0000000..2ae0a2d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-pie.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-pie.svg new file mode 100644 index 0000000..f60e2b9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart-pie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart.svg new file mode 100644 index 0000000..3d01b6c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-image.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-image.svg new file mode 100644 index 0000000..c56546d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-list.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-list.svg new file mode 100644 index 0000000..ae23bc1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-number.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-number.svg new file mode 100644 index 0000000..5cfb4b5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-rss.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-rss.svg new file mode 100644 index 0000000..f7c2341 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-rss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-table.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-table.svg new file mode 100644 index 0000000..6d357e9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget-text.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-text.svg new file mode 100644 index 0000000..1639d3a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/content/content-widget.svg b/Resources/Public/Icons/T3Icons/svgs/content/content-widget.svg new file mode 100644 index 0000000..2197a84 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/content/content-widget.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/default/default-not-found.svg b/Resources/Public/Icons/T3Icons/svgs/default/default-not-found.svg new file mode 100644 index 0000000..e37e279 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/default/default-not-found.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/files/files-folder-content.svg b/Resources/Public/Icons/T3Icons/svgs/files/files-folder-content.svg new file mode 100644 index 0000000..5cb2a3b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/files/files-folder-content.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/files/files-folder-images.svg b/Resources/Public/Icons/T3Icons/svgs/files/files-folder-images.svg new file mode 100644 index 0000000..8ac9e47 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/files/files-folder-images.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/files/files-folder.svg b/Resources/Public/Icons/T3Icons/svgs/files/files-folder.svg new file mode 100644 index 0000000..5b0233b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/files/files-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-advanced-password.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-advanced-password.svg new file mode 100644 index 0000000..16a176d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-advanced-password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-checkbox.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-checkbox.svg new file mode 100644 index 0000000..b412ad5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-content-element.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-content-element.svg new file mode 100644 index 0000000..3a19755 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-content-element.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-date-picker.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-date-picker.svg new file mode 100644 index 0000000..0513adb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-date-picker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-email.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-email.svg new file mode 100644 index 0000000..bd2afe7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-fieldset.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-fieldset.svg new file mode 100644 index 0000000..78be050 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-fieldset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-file-upload.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-file-upload.svg new file mode 100644 index 0000000..50e4167 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-file-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-finisher.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-finisher.svg new file mode 100644 index 0000000..4b37bf7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-finisher.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-gridcolumn.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-gridcolumn.svg new file mode 100644 index 0000000..7c64ae1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-gridcolumn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-gridcontainer.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-gridcontainer.svg new file mode 100644 index 0000000..68a6488 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-gridcontainer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-gridrow.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-gridrow.svg new file mode 100644 index 0000000..1f79d1c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-gridrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-hidden.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-hidden.svg new file mode 100644 index 0000000..8a66176 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-hidden.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-image-upload.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-image-upload.svg new file mode 100644 index 0000000..c6f0ccc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-image-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-multi-checkbox.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-multi-checkbox.svg new file mode 100644 index 0000000..618ebfc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-multi-checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-multi-select.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-multi-select.svg new file mode 100644 index 0000000..4f58c97 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-multi-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-number.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-number.svg new file mode 100644 index 0000000..bd2afe7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-page.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-page.svg new file mode 100644 index 0000000..5efbd10 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-page.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-password.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-password.svg new file mode 100644 index 0000000..fa8f7f2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-radio-button.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-radio-button.svg new file mode 100644 index 0000000..365fb49 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-radio-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-single-select.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-single-select.svg new file mode 100644 index 0000000..1fd00dc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-single-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-static-text.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-static-text.svg new file mode 100644 index 0000000..13a8d57 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-static-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-summary-page.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-summary-page.svg new file mode 100644 index 0000000..86c40c2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-summary-page.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-telephone.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-telephone.svg new file mode 100644 index 0000000..bd2afe7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-telephone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-text.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-text.svg new file mode 100644 index 0000000..bd2afe7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-textarea.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-textarea.svg new file mode 100644 index 0000000..18a9ebc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-textarea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-url.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-url.svg new file mode 100644 index 0000000..bd2afe7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-url.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/form/form-validator.svg b/Resources/Public/Icons/T3Icons/svgs/form/form-validator.svg new file mode 100644 index 0000000..51a6265 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/form/form-validator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/information/information-os-unknown.svg b/Resources/Public/Icons/T3Icons/svgs/information/information-os-unknown.svg new file mode 100644 index 0000000..a317127 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/information/information-os-unknown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/information/information-typo3-version.svg b/Resources/Public/Icons/T3Icons/svgs/information/information-typo3-version.svg new file mode 100644 index 0000000..63230cc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/information/information-typo3-version.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-check-brokenextension.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-check-brokenextension.svg new file mode 100644 index 0000000..4809a67 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-check-brokenextension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-check-directory.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-check-directory.svg new file mode 100644 index 0000000..ce221f8 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-check-directory.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-check-extables.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-check-extables.svg new file mode 100644 index 0000000..01ee864 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-check-extables.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-check-tca.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-check-tca.svg new file mode 100644 index 0000000..10bd805 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-check-tca.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-clear-autoload.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-autoload.svg new file mode 100644 index 0000000..191bdcd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-autoload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-clear-cache.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-cache.svg new file mode 100644 index 0000000..fc2b8c1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-cache.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-clear-database.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-database.svg new file mode 100644 index 0000000..8fe6c85 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-clear-files.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-files.svg new file mode 100644 index 0000000..d6787b5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-clear-files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-create-admin.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-create-admin.svg new file mode 100644 index 0000000..e8c9d91 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-create-admin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-database-analyze.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-database-analyze.svg new file mode 100644 index 0000000..7755a13 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-database-analyze.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-documentation.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-documentation.svg new file mode 100644 index 0000000..d71dbd9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-documentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-extension-settings.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-extension-settings.svg new file mode 100644 index 0000000..e5e9e44 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-extension-settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-manage-features.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-features.svg new file mode 100644 index 0000000..230c0a9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-features.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-manage-language.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-language.svg new file mode 100644 index 0000000..2be8b2b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-manage-maintainer.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-maintainer.svg new file mode 100644 index 0000000..37ec51d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-maintainer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-manage-presets.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-presets.svg new file mode 100644 index 0000000..06895bc --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-presets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-manage-settings.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-settings.svg new file mode 100644 index 0000000..3b1e764 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-manage-settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-password.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-password.svg new file mode 100644 index 0000000..5bcc755 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-php-info.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-php-info.svg new file mode 100644 index 0000000..f0e7e46 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-php-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-reset-user.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-reset-user.svg new file mode 100644 index 0000000..c46c833 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-reset-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-scan-extensions.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-scan-extensions.svg new file mode 100644 index 0000000..e030dac --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-scan-extensions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-show-environment.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-show-environment.svg new file mode 100644 index 0000000..0b7b468 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-show-environment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-test-environment.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-test-environment.svg new file mode 100644 index 0000000..8dff9cb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-test-environment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-test-image.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-test-image.svg new file mode 100644 index 0000000..b68034b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-test-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-test-mail.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-test-mail.svg new file mode 100644 index 0000000..11ebdab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-test-mail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-update.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-update.svg new file mode 100644 index 0000000..20d7f2a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-update.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/install/install-wizards.svg b/Resources/Public/Icons/T3Icons/svgs/install/install-wizards.svg new file mode 100644 index 0000000..10a2daf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/install/install-wizards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-application.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-application.svg new file mode 100644 index 0000000..818d0b3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-application.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-compressed.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-compressed.svg new file mode 100644 index 0000000..7a62490 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-compressed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-excel.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-excel.svg new file mode 100644 index 0000000..3c8083e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-excel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-audio.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-audio.svg new file mode 100644 index 0000000..51cbfaa --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-audio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-flash.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-flash.svg new file mode 100644 index 0000000..a2c4ba6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-flash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-image.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-image.svg new file mode 100644 index 0000000..92e6303 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-vimeo.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-vimeo.svg new file mode 100644 index 0000000..15790d0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-vimeo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-youtube.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-youtube.svg new file mode 100644 index 0000000..f0fa6c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video-youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video.svg new file mode 100644 index 0000000..baea61c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-media-video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-database.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-database.svg new file mode 100644 index 0000000..15ed13a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-drawing.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-drawing.svg new file mode 100644 index 0000000..fd0bd94 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-drawing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-formula.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-formula.svg new file mode 100644 index 0000000..d2b0d78 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-formula.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-presentation.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-presentation.svg new file mode 100644 index 0000000..4b62924 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-presentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-spreadsheet.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-spreadsheet.svg new file mode 100644 index 0000000..2bfb2f3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-spreadsheet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-text.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-text.svg new file mode 100644 index 0000000..7052122 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-open-document-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-other-other.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-other-other.svg new file mode 100644 index 0000000..cda7e58 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-other-other.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-pdf.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-pdf.svg new file mode 100644 index 0000000..f274744 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-powerpoint.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-powerpoint.svg new file mode 100644 index 0000000..d16d22e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-powerpoint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-css.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-css.svg new file mode 100644 index 0000000..51491c4 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-css.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-csv.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-csv.svg new file mode 100644 index 0000000..1730a82 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-html.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-html.svg new file mode 100644 index 0000000..a80bf40 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-js.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-js.svg new file mode 100644 index 0000000..8c647fe --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-php.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-php.svg new file mode 100644 index 0000000..336d390 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-php.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-text.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-text.svg new file mode 100644 index 0000000..d99532b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-ts.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-ts.svg new file mode 100644 index 0000000..e8e3daf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-ts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-typoscript.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-typoscript.svg new file mode 100644 index 0000000..e8e3daf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-text-typoscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-word.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-word.svg new file mode 100644 index 0000000..5a95160 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-word.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-backend_layout.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-backend_layout.svg new file mode 100644 index 0000000..9a5b6b6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-backend_layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-divider.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-divider.svg new file mode 100644 index 0000000..2dbd61b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-divider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-domain.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-domain.svg new file mode 100644 index 0000000..7960f0a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-domain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form-search.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form-search.svg new file mode 100644 index 0000000..9c24081 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form.svg new file mode 100644 index 0000000..6521cc7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-header.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-header.svg new file mode 100644 index 0000000..33e5184 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-html.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-html.svg new file mode 100644 index 0000000..c99d0ce --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-image.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-image.svg new file mode 100644 index 0000000..6bf7ddd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-link.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-link.svg new file mode 100644 index 0000000..f0c81cd --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-bullets.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-bullets.svg new file mode 100644 index 0000000..87901c2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-bullets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-files.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-files.svg new file mode 100644 index 0000000..2ce8039 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-list-files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-login.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-login.svg new file mode 100644 index 0000000..48cfdb9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-menu.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-menu.svg new file mode 100644 index 0000000..c1f8e0e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-multimedia.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-multimedia.svg new file mode 100644 index 0000000..e913a61 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-multimedia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-page-language-overlay.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-page-language-overlay.svg new file mode 100644 index 0000000..2d1cab1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-page-language-overlay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-plugin.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-plugin.svg new file mode 100644 index 0000000..5bac59d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-plugin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-script.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-script.svg new file mode 100644 index 0000000..77a6286 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-script.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-table.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-table.svg new file mode 100644 index 0000000..c328876 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-extension.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-extension.svg new file mode 100644 index 0000000..0451816 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-extension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-static.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-static.svg new file mode 100644 index 0000000..7f6ed21 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template-static.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template.svg new file mode 100644 index 0000000..3531493 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-media.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-media.svg new file mode 100644 index 0000000..9b1b027 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-picture.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-picture.svg new file mode 100644 index 0000000..3b3cfeb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text-picture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text.svg new file mode 100644 index 0000000..b3fecc9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-content-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-index_config.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-index_config.svg new file mode 100644 index 0000000..0b14a74 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-index_config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_action.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_action.svg new file mode 100644 index 0000000..31f960e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_action.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_category.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_category.svg new file mode 100644 index 0000000..44f65ea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_category.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_file_storage.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_file_storage.svg new file mode 100644 index 0000000..655e884 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_file_storage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_filemounts.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_filemounts.svg new file mode 100644 index 0000000..655e884 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_filemounts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_language.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_language.svg new file mode 100644 index 0000000..bd16bf0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_news.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_news.svg new file mode 100644 index 0000000..1609d4f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_note.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_note.svg new file mode 100644 index 0000000..a7a45ea --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_redirect.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_redirect.svg new file mode 100644 index 0000000..c9adfef --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_redirect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_workspace.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_workspace.svg new file mode 100644 index 0000000..cf0231a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-sys_workspace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_rtehtmlarea_acronym.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_rtehtmlarea_acronym.svg new file mode 100644 index 0000000..1251611 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_rtehtmlarea_acronym.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_scheduler_task_group.svg b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_scheduler_task_group.svg new file mode 100644 index 0000000..53353a2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/mimetypes/mimetypes-x-tx_scheduler_task_group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/miscellaneous/miscellaneous-placeholder.svg b/Resources/Public/Icons/T3Icons/svgs/miscellaneous/miscellaneous-placeholder.svg new file mode 100644 index 0000000..cfd7972 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/miscellaneous/miscellaneous-placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-about.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-about.svg new file mode 100644 index 0000000..76f9562 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-about.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-aboutmodules.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-aboutmodules.svg new file mode 100644 index 0000000..dc4e3d7 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-aboutmodules.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-adminpanel.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-adminpanel.svg new file mode 100644 index 0000000..30a81a9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-adminpanel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-assist.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-assist.svg new file mode 100644 index 0000000..c3aa922 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-assist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-belog.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-belog.svg new file mode 100644 index 0000000..ecdab88 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-belog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-config.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-config.svg new file mode 100644 index 0000000..ad873f6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-contentelements.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-contentelements.svg new file mode 100644 index 0000000..bba1f46 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-contentelements.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-dashboard.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-dashboard.svg new file mode 100644 index 0000000..e1973da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-dbal.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-dbal.svg new file mode 100644 index 0000000..ef9d3ab --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-dbal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-dbint.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-dbint.svg new file mode 100644 index 0000000..e13d304 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-dbint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-debug.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-debug.svg new file mode 100644 index 0000000..f4b5de2 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-debug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-documentation.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-documentation.svg new file mode 100644 index 0000000..c234d65 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-documentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-extensionmanager.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-extensionmanager.svg new file mode 100644 index 0000000..9123ff5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-extensionmanager.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-file.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-file.svg new file mode 100644 index 0000000..1abcb5e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-form.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-form.svg new file mode 100644 index 0000000..4e09e3a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-func.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-func.svg new file mode 100644 index 0000000..958517e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-func.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-generic.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-generic.svg new file mode 100644 index 0000000..dfa56cf --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-generic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-help.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-help.svg new file mode 100644 index 0000000..3f6cf97 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-indexed_search.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-indexed_search.svg new file mode 100644 index 0000000..91f780e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-indexed_search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-info.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-info.svg new file mode 100644 index 0000000..2c17765 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-install-environment.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-install-environment.svg new file mode 100644 index 0000000..c531c07 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-install-environment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-install-maintenance.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-install-maintenance.svg new file mode 100644 index 0000000..c7f9435 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-install-maintenance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-install-settings.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-install-settings.svg new file mode 100644 index 0000000..f861f5f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-install-settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-install-upgrade.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-install-upgrade.svg new file mode 100644 index 0000000..608e323 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-install-upgrade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-install.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-install.svg new file mode 100644 index 0000000..c8f428d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-install.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-integrations.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-integrations.svg new file mode 100644 index 0000000..bbf9862 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-integrations.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-lang.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-lang.svg new file mode 100644 index 0000000..8490784 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-lang.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-linkvalidator.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-linkvalidator.svg new file mode 100644 index 0000000..94cb497 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-linkvalidator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-list.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-list.svg new file mode 100644 index 0000000..9e046e1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-page.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-page.svg new file mode 100644 index 0000000..da2012c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-page.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-permission.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-permission.svg new file mode 100644 index 0000000..4959fed --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-permission.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-qrcode.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-qrcode.svg new file mode 100644 index 0000000..97a1f4b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-qrcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-reactions.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-reactions.svg new file mode 100644 index 0000000..f8da727 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-reactions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-recycler.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-recycler.svg new file mode 100644 index 0000000..4249ab1 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-recycler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-redirects.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-redirects.svg new file mode 100644 index 0000000..1289763 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-redirects.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-reports.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-reports.svg new file mode 100644 index 0000000..12e806d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-reports.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-rte-ckeditor.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-rte-ckeditor.svg new file mode 100644 index 0000000..99dbe4a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-rte-ckeditor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-scheduler.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-scheduler.svg new file mode 100644 index 0000000..d4875b0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-scheduler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-security.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-security.svg new file mode 100644 index 0000000..cfdb53c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-security.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-seo.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-seo.svg new file mode 100644 index 0000000..6e63c96 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-seo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-setup.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-setup.svg new file mode 100644 index 0000000..6b0e847 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-setup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-site-settings.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-site-settings.svg new file mode 100644 index 0000000..9167e74 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-site-settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-site.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-site.svg new file mode 100644 index 0000000..fc1879e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-site.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-sites.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-sites.svg new file mode 100644 index 0000000..9a1fb38 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-sites.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-styleguide.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-styleguide.svg new file mode 100644 index 0000000..9b909da --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-styleguide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-system.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-system.svg new file mode 100644 index 0000000..1322171 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-taskcenter.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-taskcenter.svg new file mode 100644 index 0000000..ed63cb3 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-taskcenter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-template.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-template.svg new file mode 100644 index 0000000..d74ab13 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-tools.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-tools.svg new file mode 100644 index 0000000..45f517b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-tools.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-tsconfig.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-tsconfig.svg new file mode 100644 index 0000000..11ae96b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-tsconfig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-upgrade.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-upgrade.svg new file mode 100644 index 0000000..608e323 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-upgrade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-urls.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-urls.svg new file mode 100644 index 0000000..db3bb9e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-urls.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-user.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-user.svg new file mode 100644 index 0000000..11080ba --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-viewpage.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-viewpage.svg new file mode 100644 index 0000000..b4833b5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-viewpage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-web.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-web.svg new file mode 100644 index 0000000..7421424 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-web.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-webhooks.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-webhooks.svg new file mode 100644 index 0000000..8330806 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-webhooks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/module/module-workspaces.svg b/Resources/Public/Icons/T3Icons/svgs/module/module-workspaces.svg new file mode 100644 index 0000000..f23755b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/module/module-workspaces.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-advanced.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-advanced.svg new file mode 100644 index 0000000..8763ff5 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-advanced.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-approved.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-approved.svg new file mode 100644 index 0000000..a2586b9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-approved.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backenduser.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backenduser.svg new file mode 100644 index 0000000..d5c46d0 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backenduser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backendusers.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backendusers.svg new file mode 100644 index 0000000..33d6f26 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-backendusers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-deleted.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-deleted.svg new file mode 100644 index 0000000..359298c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-deleted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-edit.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-edit.svg new file mode 100644 index 0000000..c610194 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-endtime.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-endtime.svg new file mode 100644 index 0000000..b528cf9 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-endtime.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-external-link.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-external-link.svg new file mode 100644 index 0000000..55cbf5f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-external-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontenduser.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontenduser.svg new file mode 100644 index 0000000..53db5fb --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontenduser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontendusers.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontendusers.svg new file mode 100644 index 0000000..147121f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-frontendusers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-hidden.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-hidden.svg new file mode 100644 index 0000000..4e5e686 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-hidden.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-includes-subpages.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-includes-subpages.svg new file mode 100644 index 0000000..eb9a44f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-includes-subpages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-info.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-info.svg new file mode 100644 index 0000000..14b3e0e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-list.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-list.svg new file mode 100644 index 0000000..15e6596 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-locked.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-locked.svg new file mode 100644 index 0000000..fd32043 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-locked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-media.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-media.svg new file mode 100644 index 0000000..6ebd28a --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-missing.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-missing.svg new file mode 100644 index 0000000..e824e35 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-missing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-mountpoint.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-mountpoint.svg new file mode 100644 index 0000000..1189716 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-mountpoint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-new.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-new.svg new file mode 100644 index 0000000..4f3a437 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-news.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-news.svg new file mode 100644 index 0000000..44d1326 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-readonly.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-readonly.svg new file mode 100644 index 0000000..7d143c6 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-readonly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-restricted.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-restricted.svg new file mode 100644 index 0000000..b7b00de --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-restricted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-scheduled.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-scheduled.svg new file mode 100644 index 0000000..29d7736 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-scheduled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shop.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shop.svg new file mode 100644 index 0000000..76f3047 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shortcut.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shortcut.svg new file mode 100644 index 0000000..b5e4844 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-shortcut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-translated.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-translated.svg new file mode 100644 index 0000000..7be9830 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-translated.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-warning.svg b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-warning.svg new file mode 100644 index 0000000..ffe728c --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/overlay/overlay-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/spinner/spinner-circle.svg b/Resources/Public/Icons/T3Icons/svgs/spinner/spinner-circle.svg new file mode 100644 index 0000000..cf1001e --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/spinner/spinner-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/status/status-user-admin.svg b/Resources/Public/Icons/T3Icons/svgs/status/status-user-admin.svg new file mode 100644 index 0000000..3057b02 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/status/status-user-admin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/status/status-user-backend.svg b/Resources/Public/Icons/T3Icons/svgs/status/status-user-backend.svg new file mode 100644 index 0000000..00ffa4f --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/status/status-user-backend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/status/status-user-frontend.svg b/Resources/Public/Icons/T3Icons/svgs/status/status-user-frontend.svg new file mode 100644 index 0000000..503064d --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/status/status-user-frontend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-backend.svg b/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-backend.svg new file mode 100644 index 0000000..99cc64b --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-backend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-frontend.svg b/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-frontend.svg new file mode 100644 index 0000000..d778a20 --- /dev/null +++ b/Resources/Public/Icons/T3Icons/svgs/status/status-user-group-frontend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/Public/Images/NotFound.png b/Resources/Public/Images/NotFound.png new file mode 100644 index 0000000..1fd58ae Binary files /dev/null and b/Resources/Public/Images/NotFound.png differ diff --git a/Resources/Public/Images/typo3_black.svg b/Resources/Public/Images/typo3_black.svg new file mode 100644 index 0000000..2830277 --- /dev/null +++ b/Resources/Public/Images/typo3_black.svg @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/Resources/Public/Images/typo3_light_dark.svg b/Resources/Public/Images/typo3_light_dark.svg new file mode 100644 index 0000000..5057d0b --- /dev/null +++ b/Resources/Public/Images/typo3_light_dark.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/Resources/Public/Images/typo3_orange.svg b/Resources/Public/Images/typo3_orange.svg new file mode 100644 index 0000000..67ec574 --- /dev/null +++ b/Resources/Public/Images/typo3_orange.svg @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Resources/Public/Images/typo3_variable.svg b/Resources/Public/Images/typo3_variable.svg new file mode 100644 index 0000000..3f431e4 --- /dev/null +++ b/Resources/Public/Images/typo3_variable.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate-controller.js b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate-controller.js new file mode 100644 index 0000000..71e7d21 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate-controller.js @@ -0,0 +1 @@ +const i=new WeakMap;class t{constructor(t,s){this.startPaused=!1,this.disabled=!1,this.clients=new Set,this.pendingComplete=!1,this.host=t,this.defaultOptions=s.defaultOptions||{},this.startPaused=!!s.startPaused,this.disabled=!!s.disabled,this.onComplete=s.onComplete,i.set(this.host,this)}async add(i){this.clients.add(i),this.startPaused&&i.webAnimation?.pause(),this.pendingComplete=!0,await i.finished,this.pendingComplete&&!this.isAnimating&&(this.pendingComplete=!1,this.onComplete?.())}remove(i){this.clients.delete(i)}pause(){this.clients.forEach((i=>i.webAnimation?.pause()))}play(){this.clients.forEach((i=>i.webAnimation?.play()))}cancel(){this.clients.forEach((i=>i.webAnimation?.cancel())),this.clients.clear()}finish(){this.clients.forEach((i=>i.webAnimation?.finish())),this.clients.clear()}togglePlay(){this.isPlaying?this.pause():this.play()}get isAnimating(){return this.clients.size>0}get isPlaying(){return Array.from(this.clients).some((i=>"running"===i.webAnimation?.playState))}async finished(){await Promise.all(Array.from(this.clients).map((i=>i.finished)))}}export{t as AnimateController,i as controllerMap}; diff --git a/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate.js b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate.js new file mode 100644 index 0000000..54c14ac --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/animate.js @@ -0,0 +1 @@ +import{nothing as t}from"lit/html.js";import{directive as i,PartType as s}from"lit/directive.js";import{AsyncDirective as e}from"lit/async-directive.js";import{controllerMap as h}from"@lit-labs/motion/animate-controller.js";export{AnimateController}from"@lit-labs/motion/animate-controller.js";let o=0;const r=new Map,n=new WeakSet,a=()=>new Promise((t=>requestAnimationFrame(t))),c=[{transform:"translateY(100%) scale(0)",opacity:0}],l=[{transform:"translateY(-100%) scale(0)",opacity:0}],d=[{transform:"translateX(-100%) scale(0)",opacity:0}],u=[{transform:"translateX(100%) scale(0)",opacity:0}],m=[{}],f=[{opacity:0}],p=f,v=[{opacity:0},{opacity:1}],y=[{opacity:0},{opacity:.25,offset:.75},{opacity:1}],g=(t,i)=>{const s=t-i;return 0===s?void 0:s},w=(t,i)=>{const s=t/i;return 1===s?void 0:s},N={left:(t,i)=>{const s=g(t,i);return{value:s,transform:null==s||isNaN(s)?void 0:`translateX(${s}px)`}},top:(t,i)=>{const s=g(t,i);return{value:s,transform:null==s||isNaN(s)?void 0:`translateY(${s}px)`}},width:(t,i)=>{let s;0===i&&(i=1,s={width:"1px"});const e=w(t,i);return{value:e,overrideFrom:s,transform:null==e||isNaN(e)?void 0:`scaleX(${e})`}},height:(t,i)=>{let s;0===i&&(i=1,s={height:"1px"});const e=w(t,i);return{value:e,overrideFrom:s,transform:null==e||isNaN(e)?void 0:`scaleY(${e})`}}},A={duration:333,easing:"ease-in-out"},b=["left","top","width","height","opacity","color","background"],j=new WeakMap;class x extends e{constructor(t){if(super(t),this.t=!1,this.i=null,this.o=null,this.h=!0,this.shouldLog=!1,t.type===s.CHILD)throw Error("The `animate` directive must be used in attribute position.");this.createFinished()}createFinished(){this.resolveFinished?.(),this.finished=new Promise((t=>{this.l=t}))}async resolveFinished(){this.l?.(),this.l=void 0}render(i){return t}getController(){return h.get(this.u)}isDisabled(){return this.options.disabled||this.getController()?.disabled}update(t,[i]){const s=void 0===this.u;return s&&(this.u=t.options?.host,this.u.addController(this),this.u.updateComplete.then((t=>this.t=!0)),this.element=t.element,j.set(this.element,this)),this.optionsOrCallback=i,(s||"function"!=typeof i)&&this.p(i),this.render(i)}p(t){t=t??{};const i=this.getController();void 0!==i&&((t={...i.defaultOptions,...t}).keyframeOptions={...i.defaultOptions.keyframeOptions,...t.keyframeOptions}),t.properties??=b,this.options=t}m(){const t={},i=this.element.getBoundingClientRect(),s=getComputedStyle(this.element);return this.options.properties.forEach((e=>{const h=i[e]??(N[e]?void 0:s[e]),o=Number(h);t[e]=isNaN(o)?h+"":o})),t}v(){let t,i=!0;return this.options.guard&&(t=this.options.guard(),i=((t,i)=>{if(Array.isArray(t)){if(Array.isArray(i)&&i.length===t.length&&t.every(((t,s)=>t===i[s])))return!1}else if(i===t)return!1;return!0})(t,this._)),this.h=this.t&&!this.isDisabled()&&!this.isAnimating()&&i&&this.element.isConnected,this.h&&(this._=Array.isArray(t)?Array.from(t):t),this.h}hostUpdate(){"function"==typeof this.optionsOrCallback&&this.p(this.optionsOrCallback()),this.v()&&(this.A=this.m(),this.i=this.i??this.element.parentNode,this.o=this.element.nextSibling)}async hostUpdated(){if(!this.h||!this.element.isConnected||this.options.skipInitial&&!this.isHostRendered)return;let t;this.prepare(),await a;const i=this.O(),s=this.j(this.options.keyframeOptions,i),e=this.m();if(void 0!==this.A){const{from:s,to:h}=this.N(this.A,e,i);this.log("measured",[this.A,e,s,h]),t=this.calculateKeyframes(s,h)}else{const s=r.get(this.options.inId);if(s){r.delete(this.options.inId);const{from:h,to:n}=this.N(s,e,i);t=this.calculateKeyframes(h,n),t=this.options.in?[{...this.options.in[0],...t[0]},...this.options.in.slice(1),t[1]]:t,o++,t.forEach((t=>t.zIndex=o))}else this.options.in&&(t=[...this.options.in,{}])}this.animate(t,s)}resetStyles(){void 0!==this.P&&(this.element.setAttribute("style",this.P??""),this.P=void 0)}commitStyles(){this.P=this.element.getAttribute("style"),this.webAnimation?.commitStyles(),this.webAnimation?.cancel()}reconnected(){}async disconnected(){if(!this.h)return;if(void 0!==this.options.id&&r.set(this.options.id,this.A),void 0===this.options.out)return;if(this.prepare(),await a(),this.i?.isConnected){const t=this.o&&this.o.parentNode===this.i?this.o:null;if(this.i.insertBefore(this.element,t),this.options.stabilizeOut){const t=this.m();this.log("stabilizing out");const i=this.A.left-t.left,s=this.A.top-t.top;!("static"===getComputedStyle(this.element).position)||0===i&&0===s||(this.element.style.position="relative"),0!==i&&(this.element.style.left=i+"px"),0!==s&&(this.element.style.top=s+"px")}}const t=this.j(this.options.keyframeOptions);await this.animate(this.options.out,t),this.element.remove()}prepare(){this.createFinished()}start(){this.options.onStart?.(this)}didFinish(t){t&&this.options.onComplete?.(this),this.A=void 0,this.animatingProperties=void 0,this.frames=void 0,this.resolveFinished()}O(){const t=[];for(let i=this.element.parentNode;i;i=i?.parentNode){const s=j.get(i);s&&!s.isDisabled()&&s&&t.push(s)}return t}get isHostRendered(){const t=n.has(this.u);return t||this.u.updateComplete.then((()=>{n.add(this.u)})),t}j(t,i=this.O()){const s={...A};return i.forEach((t=>Object.assign(s,t.options.keyframeOptions))),Object.assign(s,t),s}N(t,i,s){t={...t},i={...i};const e=s.map((t=>t.animatingProperties)).filter((t=>void 0!==t));let h=1,o=1;return e.length>0&&(e.forEach((t=>{t.width&&(h/=t.width),t.height&&(o/=t.height)})),void 0!==t.left&&void 0!==i.left&&(t.left=h*t.left,i.left=h*i.left),void 0!==t.top&&void 0!==i.top&&(t.top=o*t.top,i.top=o*i.top)),{from:t,to:i}}calculateKeyframes(t,i,s=!1){const e={},h={};let o=!1;const r={};for(const s in i){const n=t[s],a=i[s];if(s in N){const t=N[s];if(void 0===n||void 0===a)continue;const i=t(n,a);void 0!==i.transform&&(r[s]=i.value,o=!0,e.transform=`${e.transform??""} ${i.transform}`,void 0!==i.overrideFrom&&Object.assign(e,i.overrideFrom))}else n!==a&&void 0!==n&&void 0!==a&&(o=!0,e[s]=n,h[s]=a)}return e.transformOrigin=h.transformOrigin=s?"center center":"top left",this.animatingProperties=r,o?[e,h]:void 0}async animate(t,i=this.options.keyframeOptions){this.start(),this.frames=t;let s=!1;if(!this.isAnimating()&&!this.isDisabled()&&(this.options.onFrames&&(this.frames=t=this.options.onFrames(this),this.log("modified frames",t)),void 0!==t)){this.log("animate",[t,i]),s=!0,this.webAnimation=this.element.animate(t,i);const e=this.getController();e?.add(this);try{await this.webAnimation.finished}catch(t){}e?.remove(this)}return this.didFinish(s),s}isAnimating(){return"running"===this.webAnimation?.playState||this.webAnimation?.pending}log(t,i){this.shouldLog&&!this.isDisabled()&&console.log(t,this.options.id,i)}}const F=i(x);export{x as Animate,F as animate,a as animationFrame,b as defaultCssProperties,A as defaultKeyframeOptions,p as fade,v as fadeIn,y as fadeInSlow,f as fadeOut,l as flyAbove,c as flyBelow,d as flyLeft,u as flyRight,m as none,N as transformProps}; diff --git a/Resources/Public/JavaScript/Contrib/@lit-labs/motion/index.js b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/index.js new file mode 100644 index 0000000..f20397c --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/index.js @@ -0,0 +1 @@ +export{Animate,animate,animationFrame,defaultCssProperties,defaultKeyframeOptions,fade,fadeIn,fadeInSlow,fadeOut,flyAbove,flyBelow,flyLeft,flyRight,none,transformProps}from"@lit-labs/motion/animate.js";export{AnimateController,controllerMap}from"@lit-labs/motion/animate-controller.js";export{Position,position}from"@lit-labs/motion/position.js"; diff --git a/Resources/Public/JavaScript/Contrib/@lit-labs/motion/position.js b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/position.js new file mode 100644 index 0000000..ea809ca --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit-labs/motion/position.js @@ -0,0 +1 @@ +import{nothing as t}from"lit/html.js";import{directive as i,PartType as s}from"lit/directive.js";import{AsyncDirective as o}from"lit/async-directive.js";const r=["top","right","bottom","left"];class e extends o{constructor(t){if(super(t),t.type!==s.ELEMENT)throw Error("The `position` directive must be used in attribute position.")}render(i,s){return t}update(t,[i,s]){return void 0===this.u&&(this.u=t.options?.host,this.u.addController(this)),this.S=t.element,this.C=i,this.F=s??["left","top","width","height"],this.render(i,s)}hostUpdated(){this.$()}$(){const t="function"==typeof this.C?this.C():this.C?.value,i=t.offsetParent;if(void 0===t||!i)return;const s=t.getBoundingClientRect(),o=i.getBoundingClientRect();this.F?.forEach((t=>{const i=r.includes(t)?s[t]-o[t]:s[t];this.S.style[t]=i+"px"}))}}const h=i(e);export{e as Position,h as position}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/css-tag.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/css-tag.js new file mode 100644 index 0000000..233e973 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/css-tag.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMap;class n{constructor(t,e,o){if(this._$cssResult$=!0,o!==s)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const s=this.t;if(e&&void 0===t){const e=void 0!==s&&1===s.length;e&&(t=o.get(s)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&o.set(s,t))}return t}toString(){return this.cssText}}const r=t=>new n("string"==typeof t?t:t+"",void 0,s),i=(t,...e)=>{const o=1===t.length?t[0]:e.reduce(((e,s,o)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[o+1]),t[0]);return new n(o,t,s)},S=(s,o)=>{if(e)s.adoptedStyleSheets=o.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet));else for(const e of o){const o=document.createElement("style"),n=t.litNonce;void 0!==n&&o.setAttribute("nonce",n),o.textContent=e.cssText,s.appendChild(o)}},c=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const s of t.cssRules)e+=s.cssText;return r(e)})(t):t;export{n as CSSResult,S as adoptStyles,i as css,c as getCompatibleStyle,e as supportsAdoptingStyleSheets,r as unsafeCSS}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators.js new file mode 100644 index 0000000..52baf8c --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators.js @@ -0,0 +1 @@ +export{customElement}from"@lit/reactive-element/decorators/custom-element.js";export{property,standardProperty}from"@lit/reactive-element/decorators/property.js";export{state}from"@lit/reactive-element/decorators/state.js";export{eventOptions}from"@lit/reactive-element/decorators/event-options.js";export{query}from"@lit/reactive-element/decorators/query.js";export{queryAll}from"@lit/reactive-element/decorators/query-all.js";export{queryAsync}from"@lit/reactive-element/decorators/query-async.js";export{queryAssignedElements}from"@lit/reactive-element/decorators/query-assigned-elements.js";export{queryAssignedNodes}from"@lit/reactive-element/decorators/query-assigned-nodes.js"; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/base.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/base.js new file mode 100644 index 0000000..65a339e --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/base.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const e=(e,t,c)=>(c.configurable=!0,c.enumerable=!0,Reflect.decorate&&"object"!=typeof t&&Object.defineProperty(e,t,c),c);export{e as desc}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/custom-element.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/custom-element.js new file mode 100644 index 0000000..8487349 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/custom-element.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t=t=>(e,o)=>{void 0!==o?o.addInitializer((()=>{customElements.define(t,e)})):customElements.define(t,e)};export{t as customElement}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/event-options.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/event-options.js new file mode 100644 index 0000000..1ba939e --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/event-options.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function t(t){return(n,o)=>{const c="function"==typeof n?n:n[o];Object.assign(c,t)}}export{t as eventOptions}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/property.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/property.js new file mode 100644 index 0000000..24d5387 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/property.js @@ -0,0 +1,6 @@ +import{defaultConverter as t,notEqual as e}from"@lit/reactive-element/reactive-element.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const o={attribute:!0,type:String,converter:t,reflect:!1,hasChanged:e},r=(t=o,e,r)=>{const{kind:n,metadata:i}=r;let s=globalThis.litPropertyMetadata.get(i);if(void 0===s&&globalThis.litPropertyMetadata.set(i,s=new Map),s.set(r.name,t),"accessor"===n){const{name:o}=r;return{set(r){const n=e.get.call(this);e.set.call(this,r),this.requestUpdate(o,n,t)},init(e){return void 0!==e&&this.P(o,void 0,t),e}}}if("setter"===n){const{name:o}=r;return function(r){const n=this[o];e.call(this,r),this.requestUpdate(o,n,t)}}throw Error("Unsupported decorator location: "+n)};function n(t){return(e,o)=>"object"==typeof o?r(t,e,o):((t,e,o)=>{const r=e.hasOwnProperty(o);return e.constructor.createProperty(o,r?{...t,wrapped:!0}:t),r?Object.getOwnPropertyDescriptor(e,o):void 0})(t,e,o)}export{n as property,r as standardProperty}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-all.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-all.js new file mode 100644 index 0000000..93ea7bc --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-all.js @@ -0,0 +1,7 @@ +import{desc as t}from"@lit/reactive-element/decorators/base.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +let e;function r(r){return(n,o)=>t(n,o,{get(){return(this.renderRoot??(e??=document.createDocumentFragment())).querySelectorAll(r)}})}export{r as queryAll}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-elements.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-elements.js new file mode 100644 index 0000000..c4920b6 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-elements.js @@ -0,0 +1,6 @@ +import{desc as t}from"@lit/reactive-element/decorators/base.js"; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function o(o){return(e,n)=>{const{slot:r,selector:s}=o??{},c="slot"+(r?`[name=${r}]`:":not([name])");return t(e,n,{get(){const t=this.renderRoot?.querySelector(c),e=t?.assignedElements(o)??[];return void 0===s?e:e.filter((t=>t.matches(s)))}})}}export{o as queryAssignedElements}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-nodes.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-nodes.js new file mode 100644 index 0000000..f84b205 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-assigned-nodes.js @@ -0,0 +1,6 @@ +import{desc as t}from"@lit/reactive-element/decorators/base.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function n(n){return(o,r)=>{const{slot:e}=n??{},s="slot"+(e?`[name=${e}]`:":not([name])");return t(o,r,{get(){const t=this.renderRoot?.querySelector(s);return t?.assignedNodes(n)??[]}})}}export{n as queryAssignedNodes}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-async.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-async.js new file mode 100644 index 0000000..0a8c11a --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query-async.js @@ -0,0 +1,7 @@ +import{desc as t}from"@lit/reactive-element/decorators/base.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function r(r){return(n,e)=>t(n,e,{async get(){return await this.updateComplete,this.renderRoot?.querySelector(r)??null}})}export{r as queryAsync}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query.js new file mode 100644 index 0000000..65edc9d --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/query.js @@ -0,0 +1,6 @@ +import{desc as t}from"@lit/reactive-element/decorators/base.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function e(e,r){return(n,s,i)=>{const o=t=>t.renderRoot?.querySelector(e)??null;if(r){const{get:e,set:r}="object"==typeof s?n:i??(()=>{const t=Symbol();return{get(){return this[t]},set(e){this[t]=e}}})();return t(n,s,{get(){let t=e.call(this);return void 0===t&&(t=o(this),(null!==t||this.hasUpdated)&&r.call(this,t)),t}})}return t(n,s,{get(){return o(this)}})}}export{e as query}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/state.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/state.js new file mode 100644 index 0000000..b05209a --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/decorators/state.js @@ -0,0 +1,6 @@ +import{property as t}from"@lit/reactive-element/decorators/property.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function r(r){return t({...r,state:!0,attribute:!1})}export{r as state}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/polyfill-support.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/polyfill-support.js new file mode 100644 index 0000000..313de09 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/polyfill-support.js @@ -0,0 +1,6 @@ +!function(i){"function"==typeof define&&define.amd?define(i):i()}((function(){"use strict"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */var i,n="__scoped";null!==(i=globalThis.reactiveElementPolyfillSupport)&&void 0!==i||(globalThis.reactiveElementPolyfillSupport=function(i){var t=i.ReactiveElement;if(void 0!==window.ShadyCSS&&(!window.ShadyCSS.nativeShadow||window.ShadyCSS.ApplyShim)){var o=t.prototype;window.ShadyDOM&&window.ShadyDOM.inUse&&!0===window.ShadyDOM.noPatch&&window.ShadyDOM.patchElementProto(o);var d=o.createRenderRoot;o.createRenderRoot=function(){var i,t,o,w=this.localName;if(window.ShadyCSS.nativeShadow)return d.call(this);if(!this.constructor.hasOwnProperty(n)){this.constructor[n]=!0;var s=this.constructor.elementStyles.map((function(i){return i instanceof CSSStyleSheet?Array.from(i.cssRules).reduce((function(i,n){return i+n.cssText}),""):i.cssText}));null===(t=null===(i=window.ShadyCSS)||void 0===i?void 0:i.ScopingShim)||void 0===t||t.prepareAdoptedCssText(s,w),void 0===this.constructor._$AJ&&window.ShadyCSS.prepareTemplateStyles(document.createElement("template"),w)}return null!==(o=this.shadowRoot)&&void 0!==o?o:this.attachShadow(this.constructor.shadowRootOptions)};var w=o.connectedCallback;o.connectedCallback=function(){w.call(this),this.hasUpdated&&window.ShadyCSS.styleElement(this)};var s=o._$AE;o._$AE=function(i){this.hasUpdated||window.ShadyCSS.styleElement(this),s.call(this,i)}}})})); diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-controller.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-controller.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-controller.js @@ -0,0 +1 @@ + diff --git a/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-element.js b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-element.js new file mode 100644 index 0000000..deb6e5f --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/reactive-element/reactive-element.js @@ -0,0 +1,6 @@ +import{getCompatibleStyle as t,adoptStyles as s}from"@lit/reactive-element/css-tag.js";export{CSSResult,adoptStyles,css,getCompatibleStyle,supportsAdoptingStyleSheets,unsafeCSS}from"@lit/reactive-element/css-tag.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const{is:i,defineProperty:e,getOwnPropertyDescriptor:r,getOwnPropertyNames:h,getOwnPropertySymbols:o,getPrototypeOf:n}=Object,a=globalThis,c=a.trustedTypes,l=c?c.emptyScript:"",p=a.reactiveElementPolyfillSupport,d=(t,s)=>t,u={toAttribute(t,s){switch(s){case Boolean:t=t?l:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,s){let i=t;switch(s){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},f=(t,s)=>!i(t,s),y={attribute:!0,type:String,converter:u,reflect:!1,hasChanged:f};Symbol.metadata??=Symbol("metadata"),a.litPropertyMetadata??=new WeakMap;class b extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,s=y){if(s.state&&(s.attribute=!1),this._$Ei(),this.elementProperties.set(t,s),!s.noAccessor){const i=Symbol(),r=this.getPropertyDescriptor(t,i,s);void 0!==r&&e(this.prototype,t,r)}}static getPropertyDescriptor(t,s,i){const{get:e,set:h}=r(this.prototype,t)??{get(){return this[s]},set(t){this[s]=t}};return{get(){return e?.call(this)},set(s){const r=e?.call(this);h.call(this,s),this.requestUpdate(t,r,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??y}static _$Ei(){if(this.hasOwnProperty(d("elementProperties")))return;const t=n(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(d("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(d("properties"))){const t=this.properties,s=[...h(t),...o(t)];for(const i of s)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const s=litPropertyMetadata.get(t);if(void 0!==s)for(const[t,i]of s)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,s]of this.elementProperties){const i=this._$Eu(t,s);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(s){const i=[];if(Array.isArray(s)){const e=new Set(s.flat(1/0).reverse());for(const s of e)i.unshift(t(s))}else void 0!==s&&i.push(t(s));return i}static _$Eu(t,s){const i=s.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((t=>t(this)))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,s=this.constructor.elementProperties;for(const i of s.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return s(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((t=>t.hostConnected?.()))}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach((t=>t.hostDisconnected?.()))}attributeChangedCallback(t,s,i){this._$AK(t,i)}_$EC(t,s){const i=this.constructor.elementProperties.get(t),e=this.constructor._$Eu(t,i);if(void 0!==e&&!0===i.reflect){const r=(void 0!==i.converter?.toAttribute?i.converter:u).toAttribute(s,i.type);this._$Em=t,null==r?this.removeAttribute(e):this.setAttribute(e,r),this._$Em=null}}_$AK(t,s){const i=this.constructor,e=i._$Eh.get(t);if(void 0!==e&&this._$Em!==e){const t=i.getPropertyOptions(e),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:u;this._$Em=e,this[e]=r.fromAttribute(s,t.type),this._$Em=null}}requestUpdate(t,s,i){if(void 0!==t){if(i??=this.constructor.getPropertyOptions(t),!(i.hasChanged??f)(this[t],s))return;this.P(t,s,i)}!1===this.isUpdatePending&&(this._$ES=this._$ET())}P(t,s,i){this._$AL.has(t)||this._$AL.set(t,s),!0===i.reflect&&this._$Em!==t&&(this._$Ej??=new Set).add(t)}async _$ET(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,s]of this._$Ep)this[t]=s;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[s,i]of t)!0!==i.wrapped||this._$AL.has(s)||void 0===this[s]||this.P(s,this[s],i)}let t=!1;const s=this._$AL;try{t=this.shouldUpdate(s),t?(this.willUpdate(s),this._$EO?.forEach((t=>t.hostUpdate?.())),this.update(s)):this._$EU()}catch(s){throw t=!1,this._$EU(),s}t&&this._$AE(s)}willUpdate(t){}_$AE(t){this._$EO?.forEach((t=>t.hostUpdated?.())),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EU(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Ej&&=this._$Ej.forEach((t=>this._$EC(t,this[t]))),this._$EU()}updated(t){}firstUpdated(t){}}b.elementStyles=[],b.shadowRootOptions={mode:"open"},b[d("elementProperties")]=new Map,b[d("finalized")]=new Map,p?.({ReactiveElement:b}),(a.reactiveElementVersions??=[]).push("2.0.4");export{b as ReactiveElement,u as defaultConverter,f as notEqual}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/task/deep-equals.js b/Resources/Public/JavaScript/Contrib/@lit/task/deep-equals.js new file mode 100644 index 0000000..919c1eb --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/task/deep-equals.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const r=(r,t)=>r===t||r.length===t.length&&r.every(((r,e)=>i(r,t[e]))),t=Object.prototype.valueOf,e=Object.prototype.toString,{keys:n}=Object,{isArray:f}=Array,i=(r,o)=>{if(Object.is(r,o))return!0;if(null!==r&&null!==o&&"object"==typeof r&&"object"==typeof o){if(r.constructor!==o.constructor)return!1;if(f(r))return r.length===o.length&&r.every(((r,t)=>i(r,o[t])));if(r.valueOf!==t)return r.valueOf()===o.valueOf();if(r.toString!==e)return r.toString()===o.toString();if(r instanceof Map&&o instanceof Map){if(r.size!==o.size)return!1;for(const[t,e]of r.entries())if(!1===i(e,o.get(t))||void 0===e&&!1===o.has(t))return!1;return!0}if(r instanceof Set&&o instanceof Set){if(r.size!==o.size)return!1;for(const t of r.keys())if(!1===o.has(t))return!1;return!0}if(r instanceof RegExp)return r.source===o.source&&r.flags===o.flags;const u=n(r);if(u.length!==n(o).length)return!1;for(const t of u)if(!o.hasOwnProperty(t)||!i(r[t],o[t]))return!1;return!0}return!1};export{r as deepArrayEquals,i as deepEquals}; diff --git a/Resources/Public/JavaScript/Contrib/@lit/task/index.js b/Resources/Public/JavaScript/Contrib/@lit/task/index.js new file mode 100644 index 0000000..b30ed25 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/task/index.js @@ -0,0 +1 @@ +export{Task,TaskStatus,initialState,shallowArrayEquals}from"@lit/task/task.js"; diff --git a/Resources/Public/JavaScript/Contrib/@lit/task/task.js b/Resources/Public/JavaScript/Contrib/@lit/task/task.js new file mode 100644 index 0000000..3d36dab --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/@lit/task/task.js @@ -0,0 +1,6 @@ +import{notEqual as t}from"@lit/reactive-element"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const s={INITIAL:0,PENDING:1,COMPLETE:2,ERROR:3},i=Symbol();class h{get taskComplete(){return this.t||(1===this.i?this.t=new Promise(((t,s)=>{this.o=t,this.h=s})):3===this.i?this.t=Promise.reject(this.l):this.t=Promise.resolve(this.u)),this.t}constructor(t,s,i){this.p=0,this.i=0,(this._=t).addController(this);const h="object"==typeof s?s:{task:s,args:i};this.v=h.task,this.j=h.args,this.m=h.argsEqual??r,this.k=h.onComplete,this.A=h.onError,this.autoRun=h.autoRun??!0,"initialValue"in h&&(this.u=h.initialValue,this.i=2,this.O=this.T?.())}hostUpdate(){!0===this.autoRun&&this.S()}hostUpdated(){"afterUpdate"===this.autoRun&&this.S()}T(){if(void 0===this.j)return;const t=this.j();if(!Array.isArray(t))throw Error("The args function must return an array");return t}async S(){const t=this.T(),s=this.O;this.O=t,t===s||void 0===t||void 0!==s&&this.m(s,t)||await this.run(t)}async run(t){let s,h;t??=this.T(),this.O=t,1===this.i?this.q?.abort():(this.t=void 0,this.o=void 0,this.h=void 0),this.i=1,"afterUpdate"===this.autoRun?queueMicrotask((()=>this._.requestUpdate())):this._.requestUpdate();const r=++this.p;this.q=new AbortController;let e=!1;try{s=await this.v(t,{signal:this.q.signal})}catch(t){e=!0,h=t}if(this.p===r){if(s===i)this.i=0;else{if(!1===e){try{this.k?.(s)}catch{}this.i=2,this.o?.(s)}else{try{this.A?.(h)}catch{}this.i=3,this.h?.(h)}this.u=s,this.l=h}this._.requestUpdate()}}abort(t){1===this.i&&this.q?.abort(t)}get value(){return this.u}get error(){return this.l}get status(){return this.i}render(t){switch(this.i){case 0:return t.initial?.();case 1:return t.pending?.();case 2:return t.complete?.(this.value);case 3:return t.error?.(this.error);default:throw Error("Unexpected status: "+this.i)}}}const r=(s,i)=>s===i||s.length===i.length&&s.every(((s,h)=>!t(s,i[h])));export{h as Task,s as TaskStatus,i as initialState,r as shallowArrayEquals}; diff --git a/Resources/Public/JavaScript/Contrib/README.txt b/Resources/Public/JavaScript/Contrib/README.txt new file mode 100644 index 0000000..02988db --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/README.txt @@ -0,0 +1,15 @@ +This directory contains all packaged third-party frontend libraries needed +for the TYPO3 CMS Core. They are mostly managed via Grunt or have been adapted +to fit our needs. + +Please make sure to never reference any file directly here, rather copy +a file needed in your own extension or reference it via JavaScript import +statments instead of using the Path to this Contrib/ directory. + +Libraries not handled by bower/Grunt: + +- bootstrap/bootstrap.js +Twitter Bootstrap 3 is not shipped as an AMD module, and has been adapted to be +wrapped as an AMD module called "bootstrap". + +Benni, March 2015. diff --git a/Resources/Public/JavaScript/Contrib/autosize.js b/Resources/Public/JavaScript/Contrib/autosize.js new file mode 100644 index 0000000..2b8cda6 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/autosize.js @@ -0,0 +1 @@ +var d=new Map;function H(o){var n=d.get(o);n&&n.destroy()}function L(o){var n=d.get(o);n&&n.update()}var u=null;typeof window>"u"?((u=function(o){return o}).destroy=function(o){return o},u.update=function(o){return o}):((u=function(o,n){return o&&Array.prototype.forEach.call(o.length?o:[o],function(m){return function(e){if(e&&e.nodeName&&e.nodeName==="TEXTAREA"&&!d.has(e)){var c,y=null,t=window.getComputedStyle(e),g=(c=e.value,function(){v({testForHeightReduction:c===""||!e.value.startsWith(c),restoreTextAlign:null}),c=e.value}),p=function(a){e.removeEventListener("autosize:destroy",p),e.removeEventListener("autosize:update",i),e.removeEventListener("input",g),window.removeEventListener("resize",i),Object.keys(a).forEach(function(l){return e.style[l]=a[l]}),d.delete(e)}.bind(e,{height:e.style.height,resize:e.style.resize,textAlign:e.style.textAlign,overflowY:e.style.overflowY,overflowX:e.style.overflowX,wordWrap:e.style.wordWrap});e.addEventListener("autosize:destroy",p),e.addEventListener("autosize:update",i),e.addEventListener("input",g),window.addEventListener("resize",i),e.style.overflowX="hidden",e.style.wordWrap="break-word",d.set(e,{destroy:p,update:i}),i()}function v(a){var l,s,w=a.restoreTextAlign,f=w===void 0?null:w,E=a.testForHeightReduction,b=E===void 0||E,T=t.overflowY;if(e.scrollHeight!==0&&(t.resize==="vertical"?e.style.resize="none":t.resize==="both"&&(e.style.resize="horizontal"),b&&(l=function(r){for(var z=[];r&&r.parentNode&&r.parentNode instanceof Element;)r.parentNode.scrollTop&&z.push([r.parentNode,r.parentNode.scrollTop]),r=r.parentNode;return function(){return z.forEach(function(A){var h=A[0],F=A[1];h.style.scrollBehavior="auto",h.scrollTop=F,h.style.scrollBehavior=null})}}(e),e.style.height=""),s=t.boxSizing==="content-box"?e.scrollHeight-(parseFloat(t.paddingTop)+parseFloat(t.paddingBottom)):e.scrollHeight+parseFloat(t.borderTopWidth)+parseFloat(t.borderBottomWidth),t.maxHeight!=="none"&&s>parseFloat(t.maxHeight)?(t.overflowY==="hidden"&&(e.style.overflow="scroll"),s=parseFloat(t.maxHeight)):t.overflowY!=="hidden"&&(e.style.overflow="hidden"),e.style.height=s+"px",f&&(e.style.textAlign=f),l&&l(),y!==s&&(e.dispatchEvent(new Event("autosize:resized",{bubbles:!0})),y=s),T!==t.overflow&&!f)){var x=t.textAlign;t.overflow==="hidden"&&(e.style.textAlign=x==="start"?"end":"start"),v({restoreTextAlign:x,testForHeightReduction:!0})}}function i(){v({testForHeightReduction:!0,restoreTextAlign:null})}}(m)}),o}).destroy=function(o){return o&&Array.prototype.forEach.call(o.length?o:[o],H),o},u.update=function(o){return o&&Array.prototype.forEach.call(o.length?o:[o],L),o});var N=u,W=N;export{W as default}; diff --git a/Resources/Public/JavaScript/Contrib/cropperjs.js b/Resources/Public/JavaScript/Contrib/cropperjs.js new file mode 100644 index 0000000..a8973f6 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/cropperjs.js @@ -0,0 +1,10 @@ +var se=(H,B)=>()=>(B||H((B={exports:{}}).exports,B),B.exports);var he=se((Bt,_t)=>{/*! + * Cropper.js v1.6.1 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2023-09-17T03:44:19.860Z + */(function(H,B){typeof Bt=="object"&&typeof _t<"u"?_t.exports=B():typeof define=="function"&&define.amd?define(B):(H=typeof globalThis<"u"?globalThis:H||self,H.Cropper=B())})(Bt,function(){"use strict";function H(a,t){var e=Object.keys(a);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(a);t&&(i=i.filter(function(o){return Object.getOwnPropertyDescriptor(a,o).enumerable})),e.push.apply(e,i)}return e}function B(a){for(var t=1;ta.length)&&(t=a.length);for(var e=0,i=new Array(t);e
',Li=Number.isNaN||k.isNaN;function u(a){return typeof a=="number"&&!Li(a)}var ai=function(t){return t>0&&t<1/0};function Nt(a){return typeof a>"u"}function q(a){return gt(a)==="object"&&a!==null}var Bi=Object.prototype.hasOwnProperty;function J(a){if(!q(a))return!1;try{var t=a.constructor,e=t.prototype;return t&&e&&Bi.call(e,"isPrototypeOf")}catch{return!1}}function A(a){return typeof a=="function"}var _i=Array.prototype.slice;function ri(a){return Array.from?Array.from(a):_i.call(a)}function D(a,t){return a&&A(t)&&(Array.isArray(a)||u(a.length)?ri(a).forEach(function(e,i){t.call(a,e,i,a)}):q(a)&&Object.keys(a).forEach(function(e){t.call(a,a[e],e,a)})),a}var y=Object.assign||function(t){for(var e=arguments.length,i=new Array(e>1?e-1:0),o=1;o0&&i.forEach(function(r){q(r)&&Object.keys(r).forEach(function(n){t[n]=r[n]})}),t},ki=/\.\d*(?:0|9){12}\d*$/;function tt(a){var t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:1e11;return ki.test(a)?Math.round(a*t)/t:a}var Pi=/^width|height|left|top|marginLeft|marginTop$/;function U(a,t){var e=a.style;D(t,function(i,o){Pi.test(o)&&u(i)&&(i="".concat(i,"px")),e[o]=i})}function Yi(a,t){return a.classList?a.classList.contains(t):a.className.indexOf(t)>-1}function C(a,t){if(t){if(u(a.length)){D(a,function(i){C(i,t)});return}if(a.classList){a.classList.add(t);return}var e=a.className.trim();e?e.indexOf(t)<0&&(a.className="".concat(e," ").concat(t)):a.className=t}}function P(a,t){if(t){if(u(a.length)){D(a,function(e){P(e,t)});return}if(a.classList){a.classList.remove(t);return}a.className.indexOf(t)>=0&&(a.className=a.className.replace(t,""))}}function it(a,t,e){if(t){if(u(a.length)){D(a,function(i){it(i,t,e)});return}e?C(a,t):P(a,t)}}var Xi=/([a-z\d])([A-Z])/g;function At(a){return a.replace(Xi,"$1-$2").toLowerCase()}function St(a,t){return q(a[t])?a[t]:a.dataset?a.dataset[t]:a.getAttribute("data-".concat(At(t)))}function ht(a,t,e){q(e)?a[t]=e:a.dataset?a.dataset[t]=e:a.setAttribute("data-".concat(At(t)),e)}function zi(a,t){if(q(a[t]))try{delete a[t]}catch{a[t]=void 0}else if(a.dataset)try{delete a.dataset[t]}catch{a.dataset[t]=void 0}else a.removeAttribute("data-".concat(At(t)))}var ni=/\s\s*/,oi=function(){var a=!1;if(lt){var t=!1,e=function(){},i=Object.defineProperty({},"once",{get:function(){return a=!0,t},set:function(r){t=r}});k.addEventListener("test",e,i),k.removeEventListener("test",e,i)}return a}();function _(a,t,e){var i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:{},o=e;t.trim().split(ni).forEach(function(r){if(!oi){var n=a.listeners;n&&n[r]&&n[r][e]&&(o=n[r][e],delete n[r][e],Object.keys(n[r]).length===0&&delete n[r],Object.keys(n).length===0&&delete a.listeners)}a.removeEventListener(r,o,i)})}function I(a,t,e){var i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:{},o=e;t.trim().split(ni).forEach(function(r){if(i.once&&!oi){var n=a.listeners,s=n===void 0?{}:n;o=function(){delete s[r][e],a.removeEventListener(r,o,i);for(var l=arguments.length,h=new Array(l),c=0;cMath.abs(e)&&(e=f)})}),e}function pt(a,t){var e=a.pageX,i=a.pageY,o={endX:e,endY:i};return t?o:B({startX:e,startY:i},o)}function Ui(a){var t=0,e=0,i=0;return D(a,function(o){var r=o.startX,n=o.startY;t+=r,e+=n,i+=1}),t/=i,e/=i,{pageX:t,pageY:e}}function j(a){var t=a.aspectRatio,e=a.height,i=a.width,o=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"contain",r=ai(i),n=ai(e);if(r&&n){var s=e*t;o==="contain"&&s>i||o==="cover"&&s90?{width:d,height:s}:{width:s,height:d}}function Vi(a,t,e,i){var o=t.aspectRatio,r=t.naturalWidth,n=t.naturalHeight,s=t.rotate,d=s===void 0?0:s,l=t.scaleX,h=l===void 0?1:l,c=t.scaleY,f=c===void 0?1:c,m=e.aspectRatio,g=e.naturalWidth,x=e.naturalHeight,v=i.fillColor,M=v===void 0?"transparent":v,O=i.imageSmoothingEnabled,E=O===void 0?!0:O,X=i.imageSmoothingQuality,R=X===void 0?"low":X,p=i.maxWidth,w=p===void 0?1/0:p,T=i.maxHeight,L=T===void 0?1/0:T,z=i.minWidth,F=z===void 0?0:z,K=i.minHeight,V=K===void 0?0:K,Y=document.createElement("canvas"),S=Y.getContext("2d"),Q=j({aspectRatio:m,width:w,height:L}),ut=j({aspectRatio:m,width:F,height:V},"cover"),It=Math.min(Q.width,Math.max(ut.width,g)),Lt=Math.min(Q.height,Math.max(ut.height,x)),di=j({aspectRatio:o,width:w,height:L}),pi=j({aspectRatio:o,width:F,height:V},"cover"),ui=Math.min(di.width,Math.max(pi.width,r)),gi=Math.min(di.height,Math.max(pi.height,n)),ne=[-ui/2,-gi/2,ui,gi];return Y.width=tt(It),Y.height=tt(Lt),S.fillStyle=M,S.fillRect(0,0,It,Lt),S.save(),S.translate(It/2,Lt/2),S.rotate(d*Math.PI/180),S.scale(h,f),S.imageSmoothingEnabled=E,S.imageSmoothingQuality=R,S.drawImage.apply(S,[a].concat(Pt(ne.map(function(oe){return Math.floor(tt(oe))})))),S.restore(),Y}var li=String.fromCharCode;function Gi(a,t,e){var i="";e+=t;for(var o=t;o0;)e.push(li.apply(null,ri(o.subarray(0,i)))),o=o.subarray(i);return"data:".concat(t,";base64,").concat(btoa(e.join("")))}function Ki(a){var t=new DataView(a),e;try{var i,o,r;if(t.getUint8(0)===255&&t.getUint8(1)===216)for(var n=t.byteLength,s=2;s+1=8&&(r=l+c)}}}if(r){var f=t.getUint16(r,i),m,g;for(g=0;g=0?r:ti),height:Math.max(i.offsetHeight,n>=0?n:ii)};this.containerData=s,U(o,{width:s.width,height:s.height}),C(t,N),P(o,N)},initCanvas:function(){var t=this.containerData,e=this.imageData,i=this.options.viewMode,o=Math.abs(e.rotate)%180===90,r=o?e.naturalHeight:e.naturalWidth,n=o?e.naturalWidth:e.naturalHeight,s=r/n,d=t.width,l=t.height;t.height*s>t.width?i===3?d=t.height*s:l=t.width/s:i===3?l=t.width/s:d=t.height*s;var h={aspectRatio:s,naturalWidth:r,naturalHeight:n,width:d,height:l};this.canvasData=h,this.limited=i===1||i===2,this.limitCanvas(!0,!0),h.width=Math.min(Math.max(h.width,h.minWidth),h.maxWidth),h.height=Math.min(Math.max(h.height,h.minHeight),h.maxHeight),h.left=(t.width-h.width)/2,h.top=(t.height-h.height)/2,h.oldLeft=h.left,h.oldTop=h.top,this.initialCanvasData=y({},h)},limitCanvas:function(t,e){var i=this.options,o=this.containerData,r=this.canvasData,n=this.cropBoxData,s=i.viewMode,d=r.aspectRatio,l=this.cropped&&n;if(t){var h=Number(i.minCanvasWidth)||0,c=Number(i.minCanvasHeight)||0;s>1?(h=Math.max(h,o.width),c=Math.max(c,o.height),s===3&&(c*d>h?h=c*d:c=h/d)):s>0&&(h?h=Math.max(h,l?n.width:0):c?c=Math.max(c,l?n.height:0):l&&(h=n.width,c=n.height,c*d>h?h=c*d:c=h/d));var f=j({aspectRatio:d,width:h,height:c});h=f.width,c=f.height,r.minWidth=h,r.minHeight=c,r.maxWidth=1/0,r.maxHeight=1/0}if(e)if(s>(l?0:1)){var m=o.width-r.width,g=o.height-r.height;r.minLeft=Math.min(0,m),r.minTop=Math.min(0,g),r.maxLeft=Math.max(0,m),r.maxTop=Math.max(0,g),l&&this.limited&&(r.minLeft=Math.min(n.left,n.left+(n.width-r.width)),r.minTop=Math.min(n.top,n.top+(n.height-r.height)),r.maxLeft=n.left,r.maxTop=n.top,s===2&&(r.width>=o.width&&(r.minLeft=Math.min(0,m),r.maxLeft=Math.max(0,m)),r.height>=o.height&&(r.minTop=Math.min(0,g),r.maxTop=Math.max(0,g))))}else r.minLeft=-r.width,r.minTop=-r.height,r.maxLeft=o.width,r.maxTop=o.height},renderCanvas:function(t,e){var i=this.canvasData,o=this.imageData;if(e){var r=ji({width:o.naturalWidth*Math.abs(o.scaleX||1),height:o.naturalHeight*Math.abs(o.scaleY||1),degree:o.rotate||0}),n=r.width,s=r.height,d=i.width*(n/i.naturalWidth),l=i.height*(s/i.naturalHeight);i.left-=(d-i.width)/2,i.top-=(l-i.height)/2,i.width=d,i.height=l,i.aspectRatio=n/s,i.naturalWidth=n,i.naturalHeight=s,this.limitCanvas(!0,!1)}(i.width>i.maxWidth||i.widthi.maxHeight||i.heighte.width?r.height=r.width/i:r.width=r.height*i),this.cropBoxData=r,this.limitCropBox(!0,!0),r.width=Math.min(Math.max(r.width,r.minWidth),r.maxWidth),r.height=Math.min(Math.max(r.height,r.minHeight),r.maxHeight),r.width=Math.max(r.minWidth,r.width*o),r.height=Math.max(r.minHeight,r.height*o),r.left=e.left+(e.width-r.width)/2,r.top=e.top+(e.height-r.height)/2,r.oldLeft=r.left,r.oldTop=r.top,this.initialCropBoxData=y({},r)},limitCropBox:function(t,e){var i=this.options,o=this.containerData,r=this.canvasData,n=this.cropBoxData,s=this.limited,d=i.aspectRatio;if(t){var l=Number(i.minCropBoxWidth)||0,h=Number(i.minCropBoxHeight)||0,c=s?Math.min(o.width,r.width,r.width+r.left,o.width-r.left):o.width,f=s?Math.min(o.height,r.height,r.height+r.top,o.height-r.top):o.height;l=Math.min(l,o.width),h=Math.min(h,o.height),d&&(l&&h?h*d>l?h=l/d:l=h*d:l?h=l/d:h&&(l=h*d),f*d>c?f=c/d:c=f*d),n.minWidth=Math.min(l,c),n.minHeight=Math.min(h,f),n.maxWidth=c,n.maxHeight=f}e&&(s?(n.minLeft=Math.max(0,r.left),n.minTop=Math.max(0,r.top),n.maxLeft=Math.min(o.width,r.left+r.width)-n.width,n.maxTop=Math.min(o.height,r.top+r.height)-n.height):(n.minLeft=0,n.minTop=0,n.maxLeft=o.width-n.width,n.maxTop=o.height-n.height))},renderCropBox:function(){var t=this.options,e=this.containerData,i=this.cropBoxData;(i.width>i.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?zt:bt),U(this.cropBox,y({width:i.width,height:i.height},ct({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),et(this.element,Et,this.getData())}},Ji={initPreview:function(){var t=this.element,e=this.crossOrigin,i=this.options.preview,o=e?this.crossOriginUrl:this.url,r=t.alt||"The image to preview",n=document.createElement("img");if(e&&(n.crossOrigin=e),n.src=o,n.alt=r,this.viewBox.appendChild(n),this.viewBoxImage=n,!!i){var s=i;typeof i=="string"?s=t.ownerDocument.querySelectorAll(i):i.querySelector&&(s=[i]),this.previews=s,D(s,function(d){var l=document.createElement("img");ht(d,dt,{width:d.offsetWidth,height:d.offsetHeight,html:d.innerHTML}),e&&(l.crossOrigin=e),l.src=o,l.alt=r,l.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',d.innerHTML="",d.appendChild(l)})}},resetPreview:function(){D(this.previews,function(t){var e=St(t,dt);U(t,{width:e.width,height:e.height}),t.innerHTML=e.html,zi(t,dt)})},preview:function(){var t=this.imageData,e=this.canvasData,i=this.cropBoxData,o=i.width,r=i.height,n=t.width,s=t.height,d=i.left-e.left-t.left,l=i.top-e.top-t.top;!this.cropped||this.disabled||(U(this.viewBoxImage,y({width:n,height:s},ct(y({translateX:-d,translateY:-l},t)))),D(this.previews,function(h){var c=St(h,dt),f=c.width,m=c.height,g=f,x=m,v=1;o&&(v=f/o,x=r*v),r&&x>m&&(v=m/r,g=o*v,x=m),U(h,{width:g,height:x}),U(h.getElementsByTagName("img")[0],y({width:n*v,height:s*v},ct(y({translateX:-d*v,translateY:-l*v},t))))}))}},te={bind:function(){var t=this.element,e=this.options,i=this.cropper;A(e.cropstart)&&I(t,Ct,e.cropstart),A(e.cropmove)&&I(t,Tt,e.cropmove),A(e.cropend)&&I(t,Mt,e.cropend),A(e.crop)&&I(t,Et,e.crop),A(e.zoom)&&I(t,Ot,e.zoom),I(i,$t,this.onCropStart=this.cropStart.bind(this)),e.zoomable&&e.zoomOnWheel&&I(i,Zt,this.onWheel=this.wheel.bind(this),{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&I(i,Gt,this.onDblclick=this.dblclick.bind(this)),I(t.ownerDocument,qt,this.onCropMove=this.cropMove.bind(this)),I(t.ownerDocument,Ft,this.onCropEnd=this.cropEnd.bind(this)),e.responsive&&I(window,Qt,this.onResize=this.resize.bind(this))},unbind:function(){var t=this.element,e=this.options,i=this.cropper;A(e.cropstart)&&_(t,Ct,e.cropstart),A(e.cropmove)&&_(t,Tt,e.cropmove),A(e.cropend)&&_(t,Mt,e.cropend),A(e.crop)&&_(t,Et,e.crop),A(e.zoom)&&_(t,Ot,e.zoom),_(i,$t,this.onCropStart),e.zoomable&&e.zoomOnWheel&&_(i,Zt,this.onWheel,{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&_(i,Gt,this.onDblclick),_(t.ownerDocument,qt,this.onCropMove),_(t.ownerDocument,Ft,this.onCropEnd),e.responsive&&_(window,Qt,this.onResize)}},ie={resize:function(){if(!this.disabled){var t=this.options,e=this.container,i=this.containerData,o=e.offsetWidth/i.width,r=e.offsetHeight/i.height,n=Math.abs(o-1)>Math.abs(r-1)?o:r;if(n!==1){var s,d;t.restore&&(s=this.getCanvasData(),d=this.getCropBoxData()),this.render(),t.restore&&(this.setCanvasData(D(s,function(l,h){s[h]=l*n})),this.setCropBoxData(D(d,function(l,h){d[h]=l*n})))}}},dblclick:function(){this.disabled||this.options.dragMode===Vt||this.setDragMode(Yi(this.dragBox,yt)?jt:Dt)},wheel:function(t){var e=this,i=Number(this.options.wheelZoomRatio)||.1,o=1;this.disabled||(t.preventDefault(),!this.wheeling&&(this.wheeling=!0,setTimeout(function(){e.wheeling=!1},50),t.deltaY?o=t.deltaY>0?1:-1:t.wheelDelta?o=-t.wheelDelta/120:t.detail&&(o=t.detail>0?1:-1),this.zoom(-o*i,t)))},cropStart:function(t){var e=t.buttons,i=t.button;if(!(this.disabled||(t.type==="mousedown"||t.type==="pointerdown"&&t.pointerType==="mouse")&&(u(e)&&e!==1||u(i)&&i!==0||t.ctrlKey))){var o=this.options,r=this.pointers,n;t.changedTouches?D(t.changedTouches,function(s){r[s.identifier]=pt(s)}):r[t.pointerId||0]=pt(t),Object.keys(r).length>1&&o.zoomable&&o.zoomOnTouch?n=Ht:n=St(t.target,st),Ni.test(n)&&et(this.element,Ct,{originalEvent:t,action:n})!==!1&&(t.preventDefault(),this.action=n,this.cropping=!1,n===Xt&&(this.cropping=!0,C(this.dragBox,ft)))}},cropMove:function(t){var e=this.action;if(!(this.disabled||!e)){var i=this.pointers;t.preventDefault(),et(this.element,Tt,{originalEvent:t,action:e})!==!1&&(t.changedTouches?D(t.changedTouches,function(o){y(i[o.identifier]||{},pt(o,!0))}):y(i[t.pointerId||0]||{},pt(t,!0)),this.change(t))}},cropEnd:function(t){if(!this.disabled){var e=this.action,i=this.pointers;t.changedTouches?D(t.changedTouches,function(o){delete i[o.identifier]}):delete i[t.pointerId||0],e&&(t.preventDefault(),Object.keys(i).length||(this.action=""),this.cropping&&(this.cropping=!1,it(this.dragBox,ft,this.cropped&&this.options.modal)),et(this.element,Mt,{originalEvent:t,action:e}))}}},ee={change:function(t){var e=this.options,i=this.canvasData,o=this.containerData,r=this.cropBoxData,n=this.pointers,s=this.action,d=e.aspectRatio,l=r.left,h=r.top,c=r.width,f=r.height,m=l+c,g=h+f,x=0,v=0,M=o.width,O=o.height,E=!0,X;!d&&t.shiftKey&&(d=c&&f?c/f:1),this.limited&&(x=r.minLeft,v=r.minTop,M=x+Math.min(o.width,i.width,i.left+i.width),O=v+Math.min(o.height,i.height,i.top+i.height));var R=n[Object.keys(n)[0]],p={x:R.endX-R.startX,y:R.endY-R.startY},w=function(L){switch(L){case G:m+p.x>M&&(p.x=M-m);break;case $:l+p.xO&&(p.y=O-g);break}};switch(s){case bt:l+=p.x,h+=p.y;break;case G:if(p.x>=0&&(m>=M||d&&(h<=v||g>=O))){E=!1;break}w(G),c+=p.x,c<0&&(s=$,c=-c,l-=c),d&&(f=c/d,h+=(r.height-f)/2);break;case W:if(p.y<=0&&(h<=v||d&&(l<=x||m>=M))){E=!1;break}w(W),f-=p.y,h+=p.y,f<0&&(s=Z,f=-f,h-=f),d&&(c=f*d,l+=(r.width-c)/2);break;case $:if(p.x<=0&&(l<=x||d&&(h<=v||g>=O))){E=!1;break}w($),c-=p.x,l+=p.x,c<0&&(s=G,c=-c,l-=c),d&&(f=c/d,h+=(r.height-f)/2);break;case Z:if(p.y>=0&&(g>=O||d&&(l<=x||m>=M))){E=!1;break}w(Z),f+=p.y,f<0&&(s=W,f=-f,h-=f),d&&(c=f*d,l+=(r.width-c)/2);break;case at:if(d){if(p.y<=0&&(h<=v||m>=M)){E=!1;break}w(W),f-=p.y,h+=p.y,c=f*d}else w(W),w(G),p.x>=0?mv&&(f-=p.y,h+=p.y):(f-=p.y,h+=p.y);c<0&&f<0?(s=ot,f=-f,c=-c,h-=f,l-=c):c<0?(s=rt,c=-c,l-=c):f<0&&(s=nt,f=-f,h-=f);break;case rt:if(d){if(p.y<=0&&(h<=v||l<=x)){E=!1;break}w(W),f-=p.y,h+=p.y,c=f*d,l+=r.width-c}else w(W),w($),p.x<=0?l>x?(c-=p.x,l+=p.x):p.y<=0&&h<=v&&(E=!1):(c-=p.x,l+=p.x),p.y<=0?h>v&&(f-=p.y,h+=p.y):(f-=p.y,h+=p.y);c<0&&f<0?(s=nt,f=-f,c=-c,h-=f,l-=c):c<0?(s=at,c=-c,l-=c):f<0&&(s=ot,f=-f,h-=f);break;case ot:if(d){if(p.x<=0&&(l<=x||g>=O)){E=!1;break}w($),c-=p.x,l+=p.x,f=c/d}else w(Z),w($),p.x<=0?l>x?(c-=p.x,l+=p.x):p.y>=0&&g>=O&&(E=!1):(c-=p.x,l+=p.x),p.y>=0?g=0&&(m>=M||g>=O)){E=!1;break}w(G),c+=p.x,f=c/d}else w(Z),w(G),p.x>=0?m=0&&g>=O&&(E=!1):c+=p.x,p.y>=0?g0?s=p.y>0?nt:at:p.x<0&&(l-=c,s=p.y>0?ot:rt),p.y<0&&(h-=f),this.cropped||(P(this.cropBox,N),this.cropped=!0,this.limited&&this.limitCropBox(!0,!0));break}E&&(r.width=c,r.height=f,r.left=l,r.top=h,this.action=s,this.renderCropBox()),D(n,function(T){T.startX=T.endX,T.startY=T.endY})}},ae={crop:function(){return this.ready&&!this.cropped&&!this.disabled&&(this.cropped=!0,this.limitCropBox(!0,!0),this.options.modal&&C(this.dragBox,ft),P(this.cropBox,N),this.setCropBoxData(this.initialCropBoxData)),this},reset:function(){return this.ready&&!this.disabled&&(this.imageData=y({},this.initialImageData),this.canvasData=y({},this.initialCanvasData),this.cropBoxData=y({},this.initialCropBoxData),this.renderCanvas(),this.cropped&&this.renderCropBox()),this},clear:function(){return this.cropped&&!this.disabled&&(y(this.cropBoxData,{left:0,top:0,width:0,height:0}),this.cropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),P(this.dragBox,ft),C(this.cropBox,N)),this},replace:function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1;return!this.disabled&&t&&(this.isImg&&(this.element.src=t),e?(this.url=t,this.image.src=t,this.ready&&(this.viewBoxImage.src=t,D(this.previews,function(i){i.getElementsByTagName("img")[0].src=t}))):(this.isImg&&(this.replaced=!0),this.options.data=null,this.uncreate(),this.load(t))),this},enable:function(){return this.ready&&this.disabled&&(this.disabled=!1,P(this.cropper,Wt)),this},disable:function(){return this.ready&&!this.disabled&&(this.disabled=!0,C(this.cropper,Wt)),this},destroy:function(){var t=this.element;return t[b]?(t[b]=void 0,this.isImg&&this.replaced&&(t.src=this.originalUrl),this.uncreate(),this):this},move:function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:t,i=this.canvasData,o=i.left,r=i.top;return this.moveTo(Nt(t)?t:o+Number(t),Nt(e)?e:r+Number(e))},moveTo:function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:t,i=this.canvasData,o=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.movable&&(u(t)&&(i.left=t,o=!0),u(e)&&(i.top=e,o=!0),o&&this.renderCanvas(!0)),this},zoom:function(t,e){var i=this.canvasData;return t=Number(t),t<0?t=1/(1-t):t=1+t,this.zoomTo(i.width*t/i.naturalWidth,null,e)},zoomTo:function(t,e,i){var o=this.options,r=this.canvasData,n=r.width,s=r.height,d=r.naturalWidth,l=r.naturalHeight;if(t=Number(t),t>=0&&this.ready&&!this.disabled&&o.zoomable){var h=d*t,c=l*t;if(et(this.element,Ot,{ratio:t,oldRatio:n/d,originalEvent:i})===!1)return this;if(i){var f=this.pointers,m=si(this.cropper),g=f&&Object.keys(f).length?Ui(f):{pageX:i.pageX,pageY:i.pageY};r.left-=(h-n)*((g.pageX-m.left-r.left)/n),r.top-=(c-s)*((g.pageY-m.top-r.top)/s)}else J(e)&&u(e.x)&&u(e.y)?(r.left-=(h-n)*((e.x-r.left)/n),r.top-=(c-s)*((e.y-r.top)/s)):(r.left-=(h-n)/2,r.top-=(c-s)/2);r.width=h,r.height=c,this.renderCanvas(!0)}return this},rotate:function(t){return this.rotateTo((this.imageData.rotate||0)+Number(t))},rotateTo:function(t){return t=Number(t),u(t)&&this.ready&&!this.disabled&&this.options.rotatable&&(this.imageData.rotate=t%360,this.renderCanvas(!0,!0)),this},scaleX:function(t){var e=this.imageData.scaleY;return this.scale(t,u(e)?e:1)},scaleY:function(t){var e=this.imageData.scaleX;return this.scale(u(e)?e:1,t)},scale:function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:t,i=this.imageData,o=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.scalable&&(u(t)&&(i.scaleX=t,o=!0),u(e)&&(i.scaleY=e,o=!0),o&&this.renderCanvas(!0,!0)),this},getData:function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.options,i=this.imageData,o=this.canvasData,r=this.cropBoxData,n;if(this.ready&&this.cropped){n={x:r.left-o.left,y:r.top-o.top,width:r.width,height:r.height};var s=i.width/i.naturalWidth;if(D(n,function(h,c){n[c]=h/s}),t){var d=Math.round(n.y+n.height),l=Math.round(n.x+n.width);n.x=Math.round(n.x),n.y=Math.round(n.y),n.width=l-n.x,n.height=d-n.y}}else n={x:0,y:0,width:0,height:0};return e.rotatable&&(n.rotate=i.rotate||0),e.scalable&&(n.scaleX=i.scaleX||1,n.scaleY=i.scaleY||1),n},setData:function(t){var e=this.options,i=this.imageData,o=this.canvasData,r={};if(this.ready&&!this.disabled&&J(t)){var n=!1;e.rotatable&&u(t.rotate)&&t.rotate!==i.rotate&&(i.rotate=t.rotate,n=!0),e.scalable&&(u(t.scaleX)&&t.scaleX!==i.scaleX&&(i.scaleX=t.scaleX,n=!0),u(t.scaleY)&&t.scaleY!==i.scaleY&&(i.scaleY=t.scaleY,n=!0)),n&&this.renderCanvas(!0,!0);var s=i.width/i.naturalWidth;u(t.x)&&(r.left=t.x*s+o.left),u(t.y)&&(r.top=t.y*s+o.top),u(t.width)&&(r.width=t.width*s),u(t.height)&&(r.height=t.height*s),this.setCropBoxData(r)}return this},getContainerData:function(){return this.ready?y({},this.containerData):{}},getImageData:function(){return this.sized?y({},this.imageData):{}},getCanvasData:function(){var t=this.canvasData,e={};return this.ready&&D(["left","top","width","height","naturalWidth","naturalHeight"],function(i){e[i]=t[i]}),e},setCanvasData:function(t){var e=this.canvasData,i=e.aspectRatio;return this.ready&&!this.disabled&&J(t)&&(u(t.left)&&(e.left=t.left),u(t.top)&&(e.top=t.top),u(t.width)?(e.width=t.width,e.height=t.width/i):u(t.height)&&(e.height=t.height,e.width=t.height*i),this.renderCanvas(!0)),this},getCropBoxData:function(){var t=this.cropBoxData,e;return this.ready&&this.cropped&&(e={left:t.left,top:t.top,width:t.width,height:t.height}),e||{}},setCropBoxData:function(t){var e=this.cropBoxData,i=this.options.aspectRatio,o,r;return this.ready&&this.cropped&&!this.disabled&&J(t)&&(u(t.left)&&(e.left=t.left),u(t.top)&&(e.top=t.top),u(t.width)&&t.width!==e.width&&(o=!0,e.width=t.width),u(t.height)&&t.height!==e.height&&(r=!0,e.height=t.height),i&&(o?e.height=e.width/i:r&&(e.width=e.height*i)),this.renderCropBox()),this},getCroppedCanvas:function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!this.ready||!window.HTMLCanvasElement)return null;var e=this.canvasData,i=Vi(this.image,this.imageData,e,t);if(!this.cropped)return i;var o=this.getData(t.rounded),r=o.x,n=o.y,s=o.width,d=o.height,l=i.width/Math.floor(e.naturalWidth);l!==1&&(r*=l,n*=l,s*=l,d*=l);var h=s/d,c=j({aspectRatio:h,width:t.maxWidth||1/0,height:t.maxHeight||1/0}),f=j({aspectRatio:h,width:t.minWidth||0,height:t.minHeight||0},"cover"),m=j({aspectRatio:h,width:t.width||(l!==1?i.width:s),height:t.height||(l!==1?i.height:d)}),g=m.width,x=m.height;g=Math.min(c.width,Math.max(f.width,g)),x=Math.min(c.height,Math.max(f.height,x));var v=document.createElement("canvas"),M=v.getContext("2d");v.width=tt(g),v.height=tt(x),M.fillStyle=t.fillColor||"transparent",M.fillRect(0,0,g,x);var O=t.imageSmoothingEnabled,E=O===void 0?!0:O,X=t.imageSmoothingQuality;M.imageSmoothingEnabled=E,X&&(M.imageSmoothingQuality=X);var R=i.width,p=i.height,w=r,T=n,L,z,F,K,V,Y;w<=-s||w>R?(w=0,L=0,F=0,V=0):w<=0?(F=-w,w=0,L=Math.min(R,s+w),V=L):w<=R&&(F=0,L=Math.min(s,R-w),V=L),L<=0||T<=-d||T>p?(T=0,z=0,K=0,Y=0):T<=0?(K=-T,T=0,z=Math.min(p,d+T),Y=z):T<=p&&(K=0,z=Math.min(d,p-T),Y=z);var S=[w,T,L,z];if(V>0&&Y>0){var Q=g/s;S.push(F*Q,K*Q,V*Q,Y*Q)}return M.drawImage.apply(M,[i].concat(Pt(S.map(function(ut){return Math.floor(tt(ut))})))),v},setAspectRatio:function(t){var e=this.options;return!this.disabled&&!Nt(t)&&(e.aspectRatio=Math.max(0,t)||NaN,this.ready&&(this.initCropBox(),this.cropped&&this.renderCropBox())),this},setDragMode:function(t){var e=this.options,i=this.dragBox,o=this.face;if(this.ready&&!this.disabled){var r=t===Dt,n=e.movable&&t===jt;t=r||n?t:Vt,e.dragMode=t,ht(i,st,t),it(i,yt,r),it(i,xt,n),e.cropBoxMovable||(ht(o,st,t),it(o,yt,r),it(o,xt,n))}return this}},re=k.Cropper,fi=function(){function a(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};if(mi(this,a),!t||!Ri.test(t.tagName))throw new Error("The first argument is required and must be an or element.");this.element=t,this.options=y({},ei,J(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return vi(a,[{key:"init",value:function(){var e=this.element,i=e.tagName.toLowerCase(),o;if(!e[b]){if(e[b]=this,i==="img"){if(this.isImg=!0,o=e.getAttribute("src")||"",this.originalUrl=o,!o)return;o=e.src}else i==="canvas"&&window.HTMLCanvasElement&&(o=e.toDataURL());this.load(o)}}},{key:"load",value:function(e){var i=this;if(e){this.url=e,this.imageData={};var o=this.element,r=this.options;if(!r.rotatable&&!r.scalable&&(r.checkOrientation=!1),!r.checkOrientation||!window.ArrayBuffer){this.clone();return}if(Ai.test(e)){Si.test(e)?this.read(qi(e)):this.clone();return}var n=new XMLHttpRequest,s=this.clone.bind(this);this.reloading=!0,this.xhr=n,n.onabort=s,n.onerror=s,n.ontimeout=s,n.onprogress=function(){n.getResponseHeader("content-type")!==Jt&&n.abort()},n.onload=function(){i.read(n.response)},n.onloadend=function(){i.reloading=!1,i.xhr=null},r.checkCrossOrigin&&hi(e)&&o.crossOrigin&&(e=ci(e)),n.open("GET",e,!0),n.responseType="arraybuffer",n.withCredentials=o.crossOrigin==="use-credentials",n.send()}}},{key:"read",value:function(e){var i=this.options,o=this.imageData,r=Ki(e),n=0,s=1,d=1;if(r>1){this.url=Fi(e,Jt);var l=Qi(r);n=l.rotate,s=l.scaleX,d=l.scaleY}i.rotatable&&(o.rotate=n),i.scalable&&(o.scaleX=s,o.scaleY=d),this.clone()}},{key:"clone",value:function(){var e=this.element,i=this.url,o=e.crossOrigin,r=i;this.options.checkCrossOrigin&&hi(i)&&(o||(o="anonymous"),r=ci(i)),this.crossOrigin=o,this.crossOriginUrl=r;var n=document.createElement("img");o&&(n.crossOrigin=o),n.src=r||i,n.alt=e.alt||"The image to crop",this.image=n,n.onload=this.start.bind(this),n.onerror=this.stop.bind(this),C(n,Ut),e.parentNode.insertBefore(n,e.nextSibling)}},{key:"start",value:function(){var e=this,i=this.image;i.onload=null,i.onerror=null,this.sizing=!0;var o=k.navigator&&/(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(k.navigator.userAgent),r=function(l,h){y(e.imageData,{naturalWidth:l,naturalHeight:h,aspectRatio:l/h}),e.initialImageData=y({},e.imageData),e.sizing=!1,e.sized=!0,e.build()};if(i.naturalWidth&&!o){r(i.naturalWidth,i.naturalHeight);return}var n=document.createElement("img"),s=document.body||document.documentElement;this.sizingImage=n,n.onload=function(){r(n.width,n.height),o||s.removeChild(n)},n.src=i.src,o||(n.style.cssText="left:0;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;opacity:0;position:absolute;top:0;z-index:-1;",s.appendChild(n))}},{key:"stop",value:function(){var e=this.image;e.onload=null,e.onerror=null,e.parentNode.removeChild(e),this.image=null}},{key:"build",value:function(){if(!(!this.sized||this.ready)){var e=this.element,i=this.options,o=this.image,r=e.parentNode,n=document.createElement("div");n.innerHTML=Ii;var s=n.querySelector(".".concat(b,"-container")),d=s.querySelector(".".concat(b,"-canvas")),l=s.querySelector(".".concat(b,"-drag-box")),h=s.querySelector(".".concat(b,"-crop-box")),c=h.querySelector(".".concat(b,"-face"));this.container=r,this.cropper=s,this.canvas=d,this.dragBox=l,this.cropBox=h,this.viewBox=s.querySelector(".".concat(b,"-view-box")),this.face=c,d.appendChild(o),C(e,N),r.insertBefore(s,e.nextSibling),P(o,Ut),this.initPreview(),this.bind(),i.initialAspectRatio=Math.max(0,i.initialAspectRatio)||NaN,i.aspectRatio=Math.max(0,i.aspectRatio)||NaN,i.viewMode=Math.max(0,Math.min(3,Math.round(i.viewMode)))||0,C(h,N),i.guides||C(h.getElementsByClassName("".concat(b,"-dashed")),N),i.center||C(h.getElementsByClassName("".concat(b,"-center")),N),i.background&&C(s,"".concat(b,"-bg")),i.highlight||C(c,Mi),i.cropBoxMovable&&(C(c,xt),ht(c,st,bt)),i.cropBoxResizable||(C(h.getElementsByClassName("".concat(b,"-line")),N),C(h.getElementsByClassName("".concat(b,"-point")),N)),this.render(),this.ready=!0,this.setDragMode(i.dragMode),i.autoCrop&&this.crop(),this.setData(i.data),A(i.ready)&&I(e,Kt,i.ready,{once:!0}),et(e,Kt)}}},{key:"unbuild",value:function(){if(this.ready){this.ready=!1,this.unbind(),this.resetPreview();var e=this.cropper.parentNode;e&&e.removeChild(this.cropper),P(this.element,N)}}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}],[{key:"noConflict",value:function(){return window.Cropper=re,a}},{key:"setDefaults",value:function(e){y(ei,J(e)&&e)}}]),a}();return y(fi.prototype,Zi,Ji,te,ie,ee,ae),fi})});export default he(); diff --git a/Resources/Public/JavaScript/Contrib/css-tree.js b/Resources/Public/JavaScript/Contrib/css-tree.js new file mode 100644 index 0000000..5999466 --- /dev/null +++ b/Resources/Public/JavaScript/Contrib/css-tree.js @@ -0,0 +1,12 @@ +var Da=Object.create;var tr=Object.defineProperty;var Na=Object.getOwnPropertyDescriptor;var Oa=Object.getOwnPropertyNames;var za=Object.getPrototypeOf,Fa=Object.prototype.hasOwnProperty;var Oe=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),x=(e,t)=>{for(var r in t)tr(e,r,{get:t[r],enumerable:!0})},Ra=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of Oa(t))!Fa.call(e,i)&&i!==r&&tr(e,i,{get:()=>t[i],enumerable:!(n=Na(t,i))||n.enumerable});return e};var Ma=(e,t,r)=>(r=e!=null?Da(za(e)):{},Ra(t||!e||!e.__esModule?tr(r,"default",{value:e,enumerable:!0}):r,e));var bo=Oe(ur=>{var go="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("");ur.encode=function(e){if(0<=e&&e{var xo=bo(),pr=5,yo=1<>1;return t?-r:r}hr.encode=function(t){var r="",n,i=Qa(t);do n=i&ko,i>>>=pr,i>0&&(n|=wo),r+=xo.encode(n);while(i>0);return r};hr.decode=function(t,r,n){var i=t.length,o=0,a=0,u,l;do{if(r>=i)throw new Error("Expected more digits in base 64 VLQ value.");if(l=xo.decode(t.charCodeAt(r++)),l===-1)throw new Error("Invalid base64 digit: "+t.charAt(r-1));u=!!(l&wo),l&=ko,o=o+(l<{function $a(e,t,r){if(t in e)return e[t];if(arguments.length===3)return r;throw new Error('"'+t+'" is a required argument.')}K.getArg=$a;var So=/^(?:([\w+\-.]+):)?\/\/(?:(\w+:\w+)@)?([\w.-]*)(?::(\d+))?(.*)$/,Za=/^data:.+\,.+$/;function it(e){var t=e.match(So);return t?{scheme:t[1],auth:t[2],host:t[3],port:t[4],path:t[5]}:null}K.urlParse=it;function je(e){var t="";return e.scheme&&(t+=e.scheme+":"),t+="//",e.auth&&(t+=e.auth+"@"),e.host&&(t+=e.host),e.port&&(t+=":"+e.port),e.path&&(t+=e.path),t}K.urlGenerate=je;var Ja=32;function el(e){var t=[];return function(r){for(var n=0;nJa&&t.pop(),o}}var mr=el(function(t){var r=t,n=it(t);if(n){if(!n.path)return t;r=n.path}for(var i=K.isAbsolute(r),o=[],a=0,u=0;;)if(a=u,u=r.indexOf("/",a),u===-1){o.push(r.slice(a));break}else for(o.push(r.slice(a,u));u=0;u--)l=o[u],l==="."?o.splice(u,1):l===".."?s++:s>0&&(l===""?(o.splice(u+1,s),s=0):(o.splice(u,2),s--));return r=o.join("/"),r===""&&(r=i?"/":"."),n?(n.path=r,je(n)):r});K.normalize=mr;function Co(e,t){e===""&&(e="."),t===""&&(t=".");var r=it(t),n=it(e);if(n&&(e=n.path||"/"),r&&!r.scheme)return n&&(r.scheme=n.scheme),je(r);if(r||t.match(Za))return t;if(n&&!n.host&&!n.path)return n.host=t,je(n);var i=t.charAt(0)==="/"?t:mr(e.replace(/\/+$/,"")+"/"+t);return n?(n.path=i,je(n)):i}K.join=Co;K.isAbsolute=function(e){return e.charAt(0)==="/"||So.test(e)};function tl(e,t){e===""&&(e="."),e=e.replace(/\/$/,"");for(var r=0;t.indexOf(e+"/")!==0;){var n=e.lastIndexOf("/");if(n<0||(e=e.slice(0,n),e.match(/^([^\/]+:\/)?\/*$/)))return t;++r}return Array(r+1).join("../")+t.substr(e.length+1)}K.relative=tl;var To=function(){var e=Object.create(null);return!("__proto__"in e)}();function Ao(e){return e}function rl(e){return Lo(e)?"$"+e:e}K.toSetString=To?Ao:rl;function nl(e){return Lo(e)?e.slice(1):e}K.fromSetString=To?Ao:nl;function Lo(e){if(!e)return!1;var t=e.length;if(t<9||e.charCodeAt(t-1)!==95||e.charCodeAt(t-2)!==95||e.charCodeAt(t-3)!==111||e.charCodeAt(t-4)!==116||e.charCodeAt(t-5)!==111||e.charCodeAt(t-6)!==114||e.charCodeAt(t-7)!==112||e.charCodeAt(t-8)!==95||e.charCodeAt(t-9)!==95)return!1;for(var r=t-10;r>=0;r--)if(e.charCodeAt(r)!==36)return!1;return!0}function il(e,t,r){var n=ye(e.source,t.source);return n!==0||(n=e.originalLine-t.originalLine,n!==0)||(n=e.originalColumn-t.originalColumn,n!==0||r)||(n=e.generatedColumn-t.generatedColumn,n!==0)||(n=e.generatedLine-t.generatedLine,n!==0)?n:ye(e.name,t.name)}K.compareByOriginalPositions=il;function ol(e,t,r){var n;return n=e.originalLine-t.originalLine,n!==0||(n=e.originalColumn-t.originalColumn,n!==0||r)||(n=e.generatedColumn-t.generatedColumn,n!==0)||(n=e.generatedLine-t.generatedLine,n!==0)?n:ye(e.name,t.name)}K.compareByOriginalPositionsNoSource=ol;function sl(e,t,r){var n=e.generatedLine-t.generatedLine;return n!==0||(n=e.generatedColumn-t.generatedColumn,n!==0||r)||(n=ye(e.source,t.source),n!==0)||(n=e.originalLine-t.originalLine,n!==0)||(n=e.originalColumn-t.originalColumn,n!==0)?n:ye(e.name,t.name)}K.compareByGeneratedPositionsDeflated=sl;function al(e,t,r){var n=e.generatedColumn-t.generatedColumn;return n!==0||r||(n=ye(e.source,t.source),n!==0)||(n=e.originalLine-t.originalLine,n!==0)||(n=e.originalColumn-t.originalColumn,n!==0)?n:ye(e.name,t.name)}K.compareByGeneratedPositionsDeflatedNoLine=al;function ye(e,t){return e===t?0:e===null?1:t===null?-1:e>t?1:-1}function ll(e,t){var r=e.generatedLine-t.generatedLine;return r!==0||(r=e.generatedColumn-t.generatedColumn,r!==0)||(r=ye(e.source,t.source),r!==0)||(r=e.originalLine-t.originalLine,r!==0)||(r=e.originalColumn-t.originalColumn,r!==0)?r:ye(e.name,t.name)}K.compareByGeneratedPositionsInflated=ll;function cl(e){return JSON.parse(e.replace(/^\)]}'[^\n]*\n/,""))}K.parseSourceMapInput=cl;function ul(e,t,r){if(t=t||"",e&&(e[e.length-1]!=="/"&&t[0]!=="/"&&(e+="/"),t=e+t),r){var n=it(r);if(!n)throw new Error("sourceMapURL could not be parsed");if(n.path){var i=n.path.lastIndexOf("/");i>=0&&(n.path=n.path.substring(0,i+1))}t=Co(je(n),t)}return mr(t)}K.computeSourceURL=ul});var Po=Oe(Eo=>{var fr=Pt(),dr=Object.prototype.hasOwnProperty,Pe=typeof Map<"u";function ke(){this._array=[],this._set=Pe?new Map:Object.create(null)}ke.fromArray=function(t,r){for(var n=new ke,i=0,o=t.length;i=0)return r}else{var n=fr.toSetString(t);if(dr.call(this._set,n))return this._set[n]}throw new Error('"'+t+'" is not in the set.')};ke.prototype.at=function(t){if(t>=0&&t{var Io=Pt();function pl(e,t){var r=e.generatedLine,n=t.generatedLine,i=e.generatedColumn,o=t.generatedColumn;return n>r||n==r&&o>=i||Io.compareByGeneratedPositionsInflated(e,t)<=0}function It(){this._array=[],this._sorted=!0,this._last={generatedLine:-1,generatedColumn:0}}It.prototype.unsortedForEach=function(t,r){this._array.forEach(t,r)};It.prototype.add=function(t){pl(this._last,t)?(this._last=t,this._array.push(t)):(this._sorted=!1,this._array.push(t))};It.prototype.toArray=function(){return this._sorted||(this._array.sort(Io.compareByGeneratedPositionsInflated),this._sorted=!0),this._array};Do.MappingList=It});var zo=Oe(Oo=>{var ot=vo(),q=Pt(),Dt=Po().ArraySet,hl=No().MappingList;function oe(e){e||(e={}),this._file=q.getArg(e,"file",null),this._sourceRoot=q.getArg(e,"sourceRoot",null),this._skipValidation=q.getArg(e,"skipValidation",!1),this._ignoreInvalidMapping=q.getArg(e,"ignoreInvalidMapping",!1),this._sources=new Dt,this._names=new Dt,this._mappings=new hl,this._sourcesContents=null}oe.prototype._version=3;oe.fromSourceMap=function(t,r){var n=t.sourceRoot,i=new oe(Object.assign(r||{},{file:t.file,sourceRoot:n}));return t.eachMapping(function(o){var a={generated:{line:o.generatedLine,column:o.generatedColumn}};o.source!=null&&(a.source=o.source,n!=null&&(a.source=q.relative(n,a.source)),a.original={line:o.originalLine,column:o.originalColumn},o.name!=null&&(a.name=o.name)),i.addMapping(a)}),t.sources.forEach(function(o){var a=o;n!==null&&(a=q.relative(n,o)),i._sources.has(a)||i._sources.add(a);var u=t.sourceContentFor(o);u!=null&&i.setSourceContent(o,u)}),i};oe.prototype.addMapping=function(t){var r=q.getArg(t,"generated"),n=q.getArg(t,"original",null),i=q.getArg(t,"source",null),o=q.getArg(t,"name",null);!this._skipValidation&&this._validateMapping(r,n,i,o)===!1||(i!=null&&(i=String(i),this._sources.has(i)||this._sources.add(i)),o!=null&&(o=String(o),this._names.has(o)||this._names.add(o)),this._mappings.add({generatedLine:r.line,generatedColumn:r.column,originalLine:n!=null&&n.line,originalColumn:n!=null&&n.column,source:i,name:o}))};oe.prototype.setSourceContent=function(t,r){var n=t;this._sourceRoot!=null&&(n=q.relative(this._sourceRoot,n)),r!=null?(this._sourcesContents||(this._sourcesContents=Object.create(null)),this._sourcesContents[q.toSetString(n)]=r):this._sourcesContents&&(delete this._sourcesContents[q.toSetString(n)],Object.keys(this._sourcesContents).length===0&&(this._sourcesContents=null))};oe.prototype.applySourceMap=function(t,r,n){var i=r;if(r==null){if(t.file==null)throw new Error(`SourceMapGenerator.prototype.applySourceMap requires either an explicit source file, or the source map's "file" property. Both were omitted.`);i=t.file}var o=this._sourceRoot;o!=null&&(i=q.relative(o,i));var a=new Dt,u=new Dt;this._mappings.unsortedForEach(function(l){if(l.source===i&&l.originalLine!=null){var s=t.originalPositionFor({line:l.originalLine,column:l.originalColumn});s.source!=null&&(l.source=s.source,n!=null&&(l.source=q.join(n,l.source)),o!=null&&(l.source=q.relative(o,l.source)),l.originalLine=s.line,l.originalColumn=s.column,s.name!=null&&(l.name=s.name))}var c=l.source;c!=null&&!a.has(c)&&a.add(c);var h=l.name;h!=null&&!u.has(h)&&u.add(h)},this),this._sources=a,this._names=u,t.sources.forEach(function(l){var s=t.sourceContentFor(l);s!=null&&(n!=null&&(l=q.join(n,l)),o!=null&&(l=q.relative(o,l)),this.setSourceContent(l,s))},this)};oe.prototype._validateMapping=function(t,r,n,i){if(r&&typeof r.line!="number"&&typeof r.column!="number"){var o="original.line and original.column are not numbers -- you probably meant to omit the original mapping entirely and only map the generated position. If so, pass null for the original mapping instead of an object with empty or null values.";if(this._ignoreInvalidMapping)return typeof console<"u"&&console.warn&&console.warn(o),!1;throw new Error(o)}if(!(t&&"line"in t&&"column"in t&&t.line>0&&t.column>=0&&!r&&!n&&!i)){if(t&&"line"in t&&"column"in t&&r&&"line"in r&&"column"in r&&t.line>0&&t.column>=0&&r.line>0&&r.column>=0&&n)return;var o="Invalid mapping: "+JSON.stringify({generated:t,source:n,original:r,name:i});if(this._ignoreInvalidMapping)return typeof console<"u"&&console.warn&&console.warn(o),!1;throw new Error(o)}};oe.prototype._serializeMappings=function(){for(var t=0,r=1,n=0,i=0,o=0,a=0,u="",l,s,c,h,m=this._mappings.toArray(),f=0,v=m.length;f0){if(!q.compareByGeneratedPositionsInflated(s,m[f-1]))continue;l+=","}l+=ot.encode(s.generatedColumn-t),t=s.generatedColumn,s.source!=null&&(h=this._sources.indexOf(s.source),l+=ot.encode(h-a),a=h,l+=ot.encode(s.originalLine-1-i),i=s.originalLine-1,l+=ot.encode(s.originalColumn-n),n=s.originalColumn,s.name!=null&&(c=this._names.indexOf(s.name),l+=ot.encode(c-o),o=c)),u+=l}return u};oe.prototype._generateSourcesContent=function(t,r){return t.map(function(n){if(!this._sourcesContents)return null;r!=null&&(n=q.relative(r,n));var i=q.toSetString(n);return Object.prototype.hasOwnProperty.call(this._sourcesContents,i)?this._sourcesContents[i]:null},this)};oe.prototype.toJSON=function(){var t={version:this._version,sources:this._sources.toArray(),names:this._names.toArray(),mappings:this._serializeMappings()};return this._file!=null&&(t.file=this._file),this._sourceRoot!=null&&(t.sourceRoot=this._sourceRoot),this._sourcesContents&&(t.sourcesContent=this._generateSourcesContent(t.sources,t.sourceRoot)),t};oe.prototype.toString=function(){return JSON.stringify(this.toJSON())};Oo.SourceMapGenerator=oe});var Ze={};x(Ze,{AtKeyword:()=>I,BadString:()=>Ae,BadUrl:()=>Y,CDC:()=>U,CDO:()=>pe,Colon:()=>N,Comma:()=>R,Comment:()=>E,Delim:()=>y,Dimension:()=>k,EOF:()=>ne,Function:()=>g,Hash:()=>T,Ident:()=>p,LeftCurlyBracket:()=>D,LeftParenthesis:()=>w,LeftSquareBracket:()=>_,Number:()=>b,Percentage:()=>L,RightCurlyBracket:()=>H,RightParenthesis:()=>d,RightSquareBracket:()=>V,Semicolon:()=>F,String:()=>W,Url:()=>B,WhiteSpace:()=>S});var ne=0,p=1,g=2,I=3,T=4,W=5,Ae=6,B=7,Y=8,y=9,b=10,L=11,k=12,S=13,pe=14,U=15,N=16,F=17,R=18,_=19,V=20,w=21,d=22,D=23,H=24,E=25;function j(e){return e>=48&&e<=57}function te(e){return j(e)||e>=65&&e<=70||e>=97&&e<=102}function wt(e){return e>=65&&e<=90}function Ba(e){return e>=97&&e<=122}function _a(e){return wt(e)||Ba(e)}function Wa(e){return e>=128}function kt(e){return _a(e)||Wa(e)||e===95}function ze(e){return kt(e)||j(e)||e===45}function ja(e){return e>=0&&e<=8||e===11||e>=14&&e<=31||e===127}function Je(e){return e===10||e===13||e===12}function he(e){return Je(e)||e===32||e===9}function $(e,t){return!(e!==92||Je(t)||t===0)}function Fe(e,t,r){return e===45?kt(t)||t===45||$(t,r):kt(e)?!0:e===92?$(e,t):!1}function vt(e,t,r){return e===43||e===45?j(t)?2:t===46&&j(r)?3:0:e===46?j(t)?2:0:j(e)?1:0}function St(e){return e===65279||e===65534?1:0}var rr=new Array(128),qa=128,et=130,nr=131,Ct=132,ir=133;for(let e=0;ee.length)return!1;for(let i=t;i=0&&he(e.charCodeAt(t));t--);return t+1}function tt(e,t){for(;t=55296&&t<=57343||t>1114111)&&(t=65533),String.fromCodePoint(t)}var Be=["EOF-token","ident-token","function-token","at-keyword-token","hash-token","string-token","bad-string-token","url-token","bad-url-token","delim-token","number-token","percentage-token","dimension-token","whitespace-token","CDO-token","CDC-token","colon-token","semicolon-token","comma-token","[-token","]-token","(-token",")-token","{-token","}-token","comment-token"];function _e(e=null,t){return e===null||e.length0?St(t.charCodeAt(0)):0,i=_e(e.lines,r),o=_e(e.columns,r),a=e.startLine,u=e.startColumn;for(let l=n;l{}){t=String(t||"");let n=t.length,i=_e(this.offsetAndType,t.length+1),o=_e(this.balance,t.length+1),a=0,u=0,l=0,s=-1;for(this.offsetAndType=null,this.balance=null,r(t,(c,h,m)=>{switch(c){default:o[a]=n;break;case u:{let f=l&ie;for(l=o[f],u=l>>me,o[a]=f,o[f++]=a;f>me:0}lookupTypeNonSC(t){for(let r=this.tokenIndex;r>me;if(n!==13&&n!==25&&t--===0)return n}return 0}lookupOffset(t){return t+=this.tokenIndex,t>me;if(n!==13&&n!==25&&t--===0)return r-this.tokenIndex}return 0}lookupValue(t,r){return t+=this.tokenIndex,t0?t>me,this.tokenEnd=r&ie):(this.tokenIndex=this.tokenCount,this.next())}next(){let t=this.tokenIndex+1;t>me,this.tokenEnd=t&ie):(this.eof=!0,this.tokenIndex=this.tokenCount,this.tokenType=0,this.tokenStart=this.tokenEnd=this.source.length)}skipSC(){for(;this.tokenType===13||this.tokenType===25;)this.next()}skipUntilBalanced(t,r){let n=t,i,o;e:for(;n0?this.offsetAndType[n-1]&ie:this.firstCharOffset,r(this.source.charCodeAt(o))){case 1:break e;case 2:n++;break e;default:this.balance[i]===n&&(n=i)}}this.skip(n-this.tokenIndex)}forEachToken(t){for(let r=0,n=this.firstCharOffset;r>me;n=a,t(u,i,a,r)}}dump(){let t=new Array(this.tokenCount);return this.forEachToken((r,n,i,o)=>{t[o]={idx:o,type:Be[r],chunk:this.source.substring(n,i),balance:this.balance[o]}}),t}};function Se(e,t){function r(h){return h=e.length){sString(v+C+1).padStart(m)+" |"+G).join(` +`)}let u=` +`.repeat(Math.max(n-1,0)),l=" ".repeat(Math.max(i-1,0)),s=(u+l+e).split(/\r\n?|\n|\f/),c=Math.max(1,t-o)-1,h=Math.min(t+o,s.length+1),m=Math.max(4,String(h).length)+1,f=0;r+=(lo.length-1)*(s[t-1].substr(0,r-1).match(/\t/g)||[]).length,r>sr&&(f=r-ao+3,r=ao-2);for(let v=c;v<=h;v++)v>=0&&v0&&s[v].length>f?"\u2026":"")+s[v].substr(f,sr-2)+(s[v].length>f+sr-1?"\u2026":""));return[a(c,t),new Array(r+m+2).join("-")+"^",a(t,h)].filter(Boolean).join(` +`).replace(/^(\s+\d+\s+\|\n)+/,"").replace(/\n(\s+\d+\s+\|)+$/,"")}function ar(e,t,r,n,i,o=1,a=1){return Object.assign(Ee("SyntaxError",e),{source:t,offset:r,line:n,column:i,sourceFragment(l){return co({source:t,line:n,column:i,baseLine:o,baseColumn:a},isNaN(l)?0:l)},get formattedMessage(){return`Parse error: ${e} +`+co({source:t,line:n,column:i,baseLine:o,baseColumn:a},2)}})}function uo(e){let t=this.createList(),r=!1,n={recognizer:e};for(;!this.eof;){switch(this.tokenType){case 25:this.next();continue;case 13:r=!0,this.next();continue}let i=e.getNode.call(this,n);if(i===void 0)break;r&&(e.onWhiteSpace&&e.onWhiteSpace.call(this,i,t,n),r=!1),t.push(i)}return r&&e.onWhiteSpace&&e.onWhiteSpace.call(this,null,t,n),t}var po=()=>{},Ga=33,Ya=35,lr=59,ho=123,mo=0;function Va(e){return function(){return this[e]()}}function cr(e){let t=Object.create(null);for(let r of Object.keys(e)){let n=e[r],i=n.parse||n;i&&(t[r]=i)}return t}function Ka(e){let t={context:Object.create(null),features:Object.assign(Object.create(null),e.features),scope:Object.assign(Object.create(null),e.scope),atrule:cr(e.atrule),pseudo:cr(e.pseudo),node:cr(e.node)};for(let[r,n]of Object.entries(e.parseContext))switch(typeof n){case"function":t.context[r]=n;break;case"string":t.context[r]=Va(n);break}return{config:t,...t,...t.node}}function fo(e){let t="",r="",n=!1,i=po,o=!1,a=new Et,u=Object.assign(new nt,Ka(e||{}),{parseAtrulePrelude:!0,parseRulePrelude:!0,parseValue:!0,parseCustomProperty:!1,readSequence:uo,consumeUntilBalanceEnd:()=>0,consumeUntilLeftCurlyBracket(s){return s===ho?1:0},consumeUntilLeftCurlyBracketOrSemicolon(s){return s===ho||s===lr?1:0},consumeUntilExclamationMarkOrSemicolon(s){return s===Ga||s===lr?1:0},consumeUntilSemicolonIncluded(s){return s===lr?2:0},createList(){return new Z},createSingleNodeList(s){return new Z().appendData(s)},getFirstListNode(s){return s&&s.first},getLastListNode(s){return s&&s.last},parseWithFallback(s,c){let h=this.tokenIndex;try{return s.call(this)}catch(m){if(o)throw m;this.skip(h-this.tokenIndex);let f=c.call(this);return o=!0,i(m,f),o=!1,f}},lookupNonWSType(s){let c;do if(c=this.lookupType(s++),c!==13&&c!==25)return c;while(c!==mo);return mo},charCodeAt(s){return s>=0&&sf.toUpperCase()),h=`${/[[\](){}]/.test(c)?`"${c}"`:c} is expected`,m=this.tokenStart;switch(s){case 1:this.tokenType===2||this.tokenType===7?(m=this.tokenEnd-1,h="Identifier is expected but function found"):h="Identifier is expected";break;case 4:this.isDelim(Ya)&&(this.next(),m++,h="Name is expected");break;case 11:this.tokenType===10&&(m=this.tokenEnd,h="Percent sign is expected");break}this.error(h,m)}this.next()},eatIdent(s){(this.tokenType!==1||this.lookupValue(0,s)===!1)&&this.error(`Identifier "${s}" is expected`),this.next()},eatDelim(s){this.isDelim(s)||this.error(`Delim "${String.fromCharCode(s)}" is expected`),this.next()},getLocation(s,c){return n?a.getLocationRange(s,c,r):null},getLocationFromList(s){if(n){let c=this.getFirstListNode(s),h=this.getLastListNode(s);return a.getLocationRange(c!==null?c.loc.start.offset-a.startOffset:this.tokenStart,h!==null?h.loc.end.offset-a.startOffset:this.tokenStart,r)}return null},error(s,c){let h=typeof c<"u"&&c",n=!!c.positions,i=typeof c.onParseError=="function"?c.onParseError:po,o=!1,u.parseAtrulePrelude="parseAtrulePrelude"in c?!!c.parseAtrulePrelude:!0,u.parseRulePrelude="parseRulePrelude"in c?!!c.parseRulePrelude:!0,u.parseValue="parseValue"in c?!!c.parseValue:!0,u.parseCustomProperty="parseCustomProperty"in c?!!c.parseCustomProperty:!1;let{context:h="default",onComment:m}=c;if(!(h in u.context))throw new Error("Unknown context `"+h+"`");typeof m=="function"&&u.forEachToken((v,ee,G)=>{if(v===25){let C=u.getLocation(ee,G),M=xe(t,G-2,G,"*/")?t.slice(ee+2,G-2):t.slice(ee+2,G);m(M,C)}});let f=u.context[h].call(u,c);return u.eof||u.error(),f},{SyntaxError:ar,config:u.config})}var Ro=Ma(zo(),1),Fo=new Set(["Atrule","Selector","Declaration"]);function Mo(e){let t=new Ro.SourceMapGenerator,r={line:1,column:0},n={line:0,column:0},i={line:1,column:0},o={generated:i},a=1,u=0,l=!1,s=e.node;e.node=function(m){if(m.loc&&m.loc.start&&Fo.has(m.type)){let f=m.loc.start.line,v=m.loc.start.column-1;(n.line!==f||n.column!==v)&&(n.line=f,n.column=v,r.line=a,r.column=u,l&&(l=!1,(r.line!==i.line||r.column!==i.column)&&t.addMapping(o)),l=!0,t.addMapping({source:m.loc.source,original:n,generated:r}))}s.call(this,m),l&&Fo.has(m.type)&&(i.line=a,i.column=u)};let c=e.emit;e.emit=function(m,f,v){for(let ee=0;eebr,spec:()=>gl});var ml=43,fl=45,gr=(e,t)=>{if(e===9&&(e=t),typeof e=="string"){let r=e.charCodeAt(0);return r>127?32768:r<<8}return e},Bo=[[1,1],[1,2],[1,7],[1,8],[1,"-"],[1,10],[1,11],[1,12],[1,15],[1,21],[3,1],[3,2],[3,7],[3,8],[3,"-"],[3,10],[3,11],[3,12],[3,15],[4,1],[4,2],[4,7],[4,8],[4,"-"],[4,10],[4,11],[4,12],[4,15],[12,1],[12,2],[12,7],[12,8],[12,"-"],[12,10],[12,11],[12,12],[12,15],["#",1],["#",2],["#",7],["#",8],["#","-"],["#",10],["#",11],["#",12],["#",15],["-",1],["-",2],["-",7],["-",8],["-","-"],["-",10],["-",11],["-",12],["-",15],[10,1],[10,2],[10,7],[10,8],[10,10],[10,11],[10,12],[10,"%"],[10,15],["@",1],["@",2],["@",7],["@",8],["@","-"],["@",15],[".",10],[".",11],[".",12],["+",10],["+",11],["+",12],["/","*"]],dl=Bo.concat([[1,4],[12,4],[4,4],[3,21],[3,5],[3,16],[11,11],[11,12],[11,2],[11,"-"],[22,1],[22,2],[22,11],[22,12],[22,4],[22,"-"]]);function _o(e){let t=new Set(e.map(([r,n])=>gr(r)<<16|gr(n)));return function(r,n,i){let o=gr(n,i),a=i.charCodeAt(0);return(a===fl&&n!==1&&n!==2&&n!==15||a===ml?t.has(r<<16|a<<8):t.has(r<<16|o))&&this.emit(" ",13,!0),o}}var gl=_o(Bo),br=_o(dl);var bl=92;function xl(e,t){if(typeof t=="function"){let r=null;e.children.forEach(n=>{r!==null&&t.call(this,r),this.node(n),r=n});return}e.children.forEach(this.node,this)}function yl(e){Se(e,(t,r,n)=>{this.token(t,e.slice(r,n))})}function Wo(e){let t=new Map;for(let[r,n]of Object.entries(e.node))typeof(n.generate||n)=="function"&&t.set(r,n.generate||n);return function(r,n){let i="",o=0,a={node(l){if(t.has(l.type))t.get(l.type).call(u,l);else throw new Error("Unknown node type: "+l.type)},tokenBefore:br,token(l,s){o=this.tokenBefore(o,l,s),this.emit(s,l,!1),l===9&&s.charCodeAt(0)===bl&&this.emit(` +`,13,!0)},emit(l){i+=l},result(){return i}};n&&(typeof n.decorator=="function"&&(a=n.decorator(a)),n.sourceMap&&(a=Mo(a)),n.mode in Nt&&(a.tokenBefore=Nt[n.mode]));let u={node:l=>a.node(l),children:xl,token:(l,s)=>a.token(l,s),tokenize:yl};return a.node(r),a.result()}}function jo(e){return{fromPlainObject(t){return e(t,{enter(r){r.children&&!(r.children instanceof Z)&&(r.children=new Z().fromArray(r.children))}}),t},toPlainObject(t){return e(t,{leave(r){r.children&&r.children instanceof Z&&(r.children=r.children.toArray())}}),t}}}var{hasOwnProperty:xr}=Object.prototype,st=function(){};function qo(e){return typeof e=="function"?e:st}function Uo(e,t){return function(r,n,i){r.type===t&&e.call(this,r,n,i)}}function kl(e,t){let r=t.structure,n=[];for(let i in r){if(xr.call(r,i)===!1)continue;let o=r[i],a={name:i,type:!1,nullable:!1};Array.isArray(o)||(o=[o]);for(let u of o)u===null?a.nullable=!0:typeof u=="string"?a.type="node":Array.isArray(u)&&(a.type="list");a.type&&n.push(a)}return n.length?{context:t.walkContext,fields:n}:null}function wl(e){let t={};for(let r in e.node)if(xr.call(e.node,r)){let n=e.node[r];if(!n.structure)throw new Error("Missed `structure` field in `"+r+"` node type definition");t[r]=kl(r,n)}return t}function Ho(e,t){let r=e.fields.slice(),n=e.context,i=typeof n=="string";return t&&r.reverse(),function(o,a,u,l){let s;i&&(s=a[n],a[n]=o);for(let c of r){let h=o[c.name];if(!c.nullable||h){if(c.type==="list"){if(t?h.reduceRight(l,!1):h.reduce(l,!1))return!0}else if(u(h))return!0}}i&&(a[n]=s)}}function Go({StyleSheet:e,Atrule:t,Rule:r,Block:n,DeclarationList:i}){return{Atrule:{StyleSheet:e,Atrule:t,Rule:r,Block:n},Rule:{StyleSheet:e,Atrule:t,Rule:r,Block:n},Declaration:{StyleSheet:e,Atrule:t,Rule:r,Block:n,DeclarationList:i}}}function Yo(e){let t=wl(e),r={},n={},i=Symbol("break-walk"),o=Symbol("skip-node");for(let s in t)xr.call(t,s)&&t[s]!==null&&(r[s]=Ho(t[s],!1),n[s]=Ho(t[s],!0));let a=Go(r),u=Go(n),l=function(s,c){function h(C,M,ve){let z=m.call(G,C,M,ve);return z===i?!0:z===o?!1:!!(v.hasOwnProperty(C.type)&&v[C.type](C,G,h,ee)||f.call(G,C,M,ve)===i)}let m=st,f=st,v=r,ee=(C,M,ve,z)=>C||h(M,ve,z),G={break:i,skip:o,root:s,stylesheet:null,atrule:null,atrulePrelude:null,rule:null,selector:null,block:null,declaration:null,function:null};if(typeof c=="function")m=c;else if(c&&(m=qo(c.enter),f=qo(c.leave),c.reverse&&(v=n),c.visit)){if(a.hasOwnProperty(c.visit))v=c.reverse?u[c.visit]:a[c.visit];else if(!t.hasOwnProperty(c.visit))throw new Error("Bad value `"+c.visit+"` for `visit` option (should be: "+Object.keys(t).sort().join(", ")+")");m=Uo(m,c.visit),f=Uo(f,c.visit)}if(m===st&&f===st)throw new Error("Neither `enter` nor `leave` walker handler is set or both aren't a function");h(s)};return l.break=i,l.skip=o,l.find=function(s,c){let h=null;return l(s,function(m,f,v){if(c.call(this,m,f,v))return h=m,i}),h},l.findLast=function(s,c){let h=null;return l(s,{reverse:!0,enter(m,f,v){if(c.call(this,m,f,v))return h=m,i}}),h},l.findAll=function(s,c){let h=[];return l(s,function(m,f,v){c.call(this,m,f,v)&&h.push(m)}),h},l}function vl(e){return e}function Sl(e){let{min:t,max:r,comma:n}=e;return t===0&&r===0?n?"#?":"*":t===0&&r===1?"?":t===1&&r===0?n?"#":"+":t===1&&r===1?"":(n?"#":"")+(t===r?"{"+t+"}":"{"+t+","+(r!==0?r:"")+"}")}function Cl(e){switch(e.type){case"Range":return" ["+(e.min===null?"-\u221E":e.min)+","+(e.max===null?"\u221E":e.max)+"]";default:throw new Error("Unknown node type `"+e.type+"`")}}function Tl(e,t,r,n){let i=e.combinator===" "||n?e.combinator:" "+e.combinator+" ",o=e.terms.map(a=>yr(a,t,r,n)).join(i);return e.explicit||r?(n||o[0]===","?"[":"[ ")+o+(n?"]":" ]"):o}function yr(e,t,r,n){let i;switch(e.type){case"Group":i=Tl(e,t,r,n)+(e.disallowEmpty?"!":"");break;case"Multiplier":return yr(e.term,t,r,n)+t(Sl(e),e);case"Type":i="<"+e.name+(e.opts?t(Cl(e.opts),e.opts):"")+">";break;case"Property":i="<'"+e.name+"'>";break;case"Keyword":i=e.name;break;case"AtKeyword":i="@"+e.name;break;case"Function":i=e.name+"(";break;case"String":case"Token":i=e.value;break;case"Comma":i=",";break;default:throw new Error("Unknown node type `"+e.type+"`")}return t(i,e)}function Ie(e,t){let r=vl,n=!1,i=!1;return typeof t=="function"?r=t:t&&(n=!!t.forceBraces,i=!!t.compact,typeof t.decorate=="function"&&(r=t.decorate)),yr(e,r,n,i)}var Vo={offset:0,line:1,column:1};function Al(e,t){let r=e.tokens,n=e.longestMatch,i=n1?(c=Ot(o||t,"end")||at(Vo,s),h=at(c)):(c=Ot(o,"start")||at(Ot(t,"start")||Vo,s.slice(0,a)),h=Ot(o,"end")||at(c,s.substr(a,u))),{css:s,mismatchOffset:a,mismatchLength:u,start:c,end:h}}function Ot(e,t){let r=e&&e.loc&&e.loc[t];return r?"line"in r?at(r):r:null}function at({offset:e,line:t,column:r},n){let i={offset:e,line:t,column:r};if(n){let o=n.split(/\n|\r\n?|\f/);i.offset+=n.length,i.line+=o.length-1,i.column=o.length===1?i.column+n.length:o.pop().length+1}return i}var qe=function(e,t){let r=Ee("SyntaxReferenceError",e+(t?" `"+t+"`":""));return r.reference=t,r},Ko=function(e,t,r,n){let i=Ee("SyntaxMatchError",e),{css:o,mismatchOffset:a,mismatchLength:u,start:l,end:s}=Al(n,r);return i.rawMessage=e,i.syntax=t?Ie(t):"",i.css=o,i.mismatchOffset=a,i.mismatchLength=u,i.message=e+` + syntax: `+i.syntax+` + value: `+(o||"")+` + --------`+new Array(i.mismatchOffset+1).join("-")+"^",Object.assign(i,l),i.loc={source:r&&r.loc&&r.loc.source||"",start:l,end:s},i};var zt=new Map,Ue=new Map,Ft=45,Rt=Ll,kr=El,$f=wr;function Mt(e,t){return t=t||0,e.length-t>=2&&e.charCodeAt(t)===Ft&&e.charCodeAt(t+1)===Ft}function wr(e,t){if(t=t||0,e.length-t>=3&&e.charCodeAt(t)===Ft&&e.charCodeAt(t+1)!==Ft){let r=e.indexOf("-",t+2);if(r!==-1)return e.substring(t,r+1)}return""}function Ll(e){if(zt.has(e))return zt.get(e);let t=e.toLowerCase(),r=zt.get(t);if(r===void 0){let n=Mt(t,0),i=n?"":wr(t,0);r=Object.freeze({basename:t.substr(i.length),name:t,prefix:i,vendor:i,custom:n})}return zt.set(e,r),r}function El(e){if(Ue.has(e))return Ue.get(e);let t=e,r=e[0];r==="/"?r=e[1]==="/"?"//":"/":r!=="_"&&r!=="*"&&r!=="$"&&r!=="#"&&r!=="+"&&r!=="&"&&(r="");let n=Mt(t,r.length);if(!n&&(t=t.toLowerCase(),Ue.has(t))){let u=Ue.get(t);return Ue.set(e,u),u}let i=n?"":wr(t,r.length),o=t.substr(0,r.length+i.length),a=Object.freeze({basename:t.substr(o.length),name:t.substr(r.length),hack:r,vendor:i,prefix:o,custom:n});return Ue.set(e,a),a}var He=["initial","inherit","unset","revert","revert-layer"];var ct=43,fe=45,vr=110,Ge=!0,Il=!1;function Cr(e,t){return e!==null&&e.type===9&&e.value.charCodeAt(0)===t}function lt(e,t,r){for(;e!==null&&(e.type===13||e.type===25);)e=r(++t);return t}function Ce(e,t,r,n){if(!e)return 0;let i=e.value.charCodeAt(t);if(i===ct||i===fe){if(r)return 0;t++}for(;t6)return 0}return n}function Bt(e,t,r){if(!e)return 0;for(;Ar(r(t),Xo);){if(++e>6)return 0;t++}return t}function Lr(e,t){let r=0;if(e===null||e.type!==1||!be(e.value,0,Nl)||(e=t(++r),e===null))return 0;if(Ar(e,Dl))return e=t(++r),e===null?0:e.type===1?Bt(ut(e,0,!0),++r,t):Ar(e,Xo)?Bt(1,++r,t):0;if(e.type===10){let n=ut(e,1,!0);return n===0?0:(e=t(++r),e===null?r:e.type===12||e.type===10?!Ol(e,Qo)||!ut(e,1,!1)?0:r+1:Bt(n,r,t))}return e.type===12?Bt(ut(e,1,!0),++r,t):0}var zl=["calc(","-moz-calc(","-webkit-calc("],Er=new Map([[2,22],[21,22],[19,20],[23,24]]);function ce(e,t){return te.max&&typeof e.max!="string")return!0}return!1}function Fl(e,t){let r=0,n=[],i=0;e:do{switch(e.type){case 24:case 22:case 20:if(e.type!==r)break e;if(r=n.pop(),n.length===0){i++;break e}break;case 2:case 21:case 19:case 23:n.push(r),r=Er.get(e.type);break}i++}while(e=t(i));return i}function se(e){return function(t,r,n){return t===null?0:t.type===2&&Zo(t.value,zl)?Fl(t,r):e(t,r,n)}}function O(e){return function(t){return t===null||t.type!==e?0:1}}function Rl(e){if(e===null||e.type!==1)return 0;let t=e.value.toLowerCase();return Zo(t,He)||$o(t,"default")?0:1}function es(e){return e===null||e.type!==1||ce(e.value,0)!==45||ce(e.value,1)!==45?0:1}function Ml(e){return!es(e)||e.value==="--"?0:1}function Bl(e){if(e===null||e.type!==4)return 0;let t=e.value.length;if(t!==4&&t!==5&&t!==7&&t!==9)return 0;for(let r=1;rQl,decibel:()=>ec,flex:()=>Jl,frequency:()=>$l,length:()=>Kl,resolution:()=>Zl,semitones:()=>tc,time:()=>Xl});var Kl=["cm","mm","q","in","pt","pc","px","em","rem","ex","rex","cap","rcap","ch","rch","ic","ric","lh","rlh","vw","svw","lvw","dvw","vh","svh","lvh","dvh","vi","svi","lvi","dvi","vb","svb","lvb","dvb","vmin","svmin","lvmin","dvmin","vmax","svmax","lvmax","dvmax","cqw","cqh","cqi","cqb","cqmin","cqmax"],Ql=["deg","grad","rad","turn"],Xl=["s","ms"],$l=["hz","khz"],Zl=["dpi","dpcm","dppx","x"],Jl=["fr"],ec=["db"],tc=["st"];var gs={};x(gs,{SyntaxError:()=>jt,generate:()=>Ie,parse:()=>Ke,walk:()=>Kt});function jt(e,t,r){return Object.assign(Ee("SyntaxError",e),{input:t,offset:r,rawMessage:e,message:e+` + `+t+` +--`+new Array((r||t.length)+1).join("-")+"^"})}var rc=9,nc=10,ic=12,oc=13,sc=32,qt=class{constructor(t){this.str=t,this.pos=0}charCodeAt(t){return t/[a-zA-Z0-9\-]/.test(String.fromCharCode(t))?1:0),ls={" ":1,"&&":2,"||":3,"|":4};function Gt(e){return e.substringToPos(e.findWsEnd(e.pos))}function Ye(e){let t=e.pos;for(;t=128||pt[r]===0)break}return e.pos===t&&e.error("Expect a keyword"),e.substringToPos(t)}function Yt(e){let t=e.pos;for(;t57)break}return e.pos===t&&e.error("Expect a number"),e.substringToPos(t)}function fc(e){let t=e.str.indexOf("'",e.pos+1);return t===-1&&(e.pos=e.str.length,e.error("Expect an apostrophe")),e.substringToPos(t+1)}function cs(e){let t=null,r=null;return e.eat(Ht),e.skipWs(),t=Yt(e),e.skipWs(),e.charCode()===Or?(e.pos++,e.skipWs(),e.charCode()!==ss&&(r=Yt(e),e.skipWs())):r=t,e.eat(ss),{min:Number(t),max:r?Number(r):0}}function dc(e){let t=null,r=!1;switch(e.charCode()){case hs:e.pos++,t={min:0,max:0};break;case Nr:e.pos++,t={min:1,max:0};break;case Ir:e.pos++,t={min:0,max:1};break;case Dr:e.pos++,r=!0,e.charCode()===Ht?t=cs(e):e.charCode()===Ir?(e.pos++,t={min:0,max:0}):t={min:1,max:0};break;case Ht:t=cs(e);break;default:return null}return{type:"Multiplier",comma:r,min:t.min,max:t.max,term:null}}function Ve(e,t){let r=dc(e);return r!==null?(r.term=t,e.charCode()===Dr&&e.charCodeAt(e.pos-1)===Nr?Ve(e,r):r):t}function Pr(e){let t=e.peek();return t===""?null:{type:"Token",value:t}}function gc(e){let t;return e.eat(zr),e.eat(Ut),t=Ye(e),e.eat(Ut),e.eat(ms),Ve(e,{type:"Property",name:t})}function bc(e){let t=null,r=null,n=1;return e.eat(Vt),e.charCode()===is&&(e.peek(),n=-1),n==-1&&e.charCode()===as?e.peek():(t=n*Number(Yt(e)),pt[e.charCode()]!==0&&(t+=Ye(e))),Gt(e),e.eat(Or),Gt(e),e.charCode()===as?e.peek():(n=1,e.charCode()===is&&(e.peek(),n=-1),r=n*Number(Yt(e)),pt[e.charCode()]!==0&&(r+=Ye(e))),e.eat(Fr),{type:"Range",min:t,max:r}}function xc(e){let t,r=null;return e.eat(zr),t=Ye(e),e.charCode()===ps&&e.nextCharCode()===hc&&(e.pos+=2,t+="()"),e.charCodeAt(e.findWsEnd(e.pos))===Vt&&(Gt(e),r=bc(e)),e.eat(ms),Ve(e,{type:"Type",name:t,opts:r})}function yc(e){let t=Ye(e);return e.charCode()===ps?(e.pos++,{type:"Function",name:t}):Ve(e,{type:"Keyword",name:t})}function kc(e,t){function r(i,o){return{type:"Group",terms:i,combinator:o,disallowEmpty:!1,explicit:!1}}let n;for(t=Object.keys(t).sort((i,o)=>ls[i]-ls[o]);t.length>0;){n=t.shift();let i=0,o=0;for(;i1&&(e.splice(o,i-o,r(e.slice(o,i),n)),i=o+1),o=-1))}o!==-1&&t.length&&e.splice(o,i-o,r(e.slice(o,i),n))}return n}function fs(e){let t=[],r={},n,i=null,o=e.pos;for(;n=vc(e);)n.type!=="Spaces"&&(n.type==="Combinator"?((i===null||i.type==="Combinator")&&(e.pos=o,e.error("Unexpected combinator")),r[n.value]=!0):i!==null&&i.type!=="Combinator"&&(r[" "]=!0,t.push({type:"Combinator",value:" "})),t.push(n),i=n,o=e.pos);return i!==null&&i.type==="Combinator"&&(e.pos-=o,e.error("Unexpected combinator")),{type:"Group",terms:t,combinator:kc(t,r)||" ",disallowEmpty:!1,explicit:!1}}function wc(e){let t;return e.eat(Vt),t=fs(e),e.eat(Fr),t.explicit=!0,e.charCode()===us&&(e.pos++,t.disallowEmpty=!0),t}function vc(e){let t=e.charCode();if(t<128&&pt[t]===1)return yc(e);switch(t){case Fr:break;case Vt:return Ve(e,wc(e));case zr:return e.nextCharCode()===Ut?gc(e):xc(e);case os:return{type:"Combinator",value:e.substringToPos(e.pos+(e.nextCharCode()===os?2:1))};case ns:return e.pos++,e.eat(ns),{type:"Combinator",value:"&&"};case Or:return e.pos++,{type:"Comma"};case Ut:return Ve(e,{type:"String",value:fc(e)});case pc:case ac:case lc:case uc:case cc:return{type:"Spaces",value:Gt(e)};case mc:return t=e.nextCharCode(),t<128&&pt[t]===1?(e.pos++,{type:"AtKeyword",name:Ye(e)}):Pr(e);case hs:case Nr:case Ir:case Dr:case us:break;case Ht:if(t=e.nextCharCode(),t<48||t>57)return Pr(e);break;default:return Pr(e)}}function Ke(e){let t=new qt(e),r=fs(t);return t.pos!==e.length&&t.error("Unexpected input"),r.terms.length===1&&r.terms[0].type==="Group"?r.terms[0]:r}var ht=function(){};function ds(e){return typeof e=="function"?e:ht}function Kt(e,t,r){function n(a){switch(i.call(r,a),a.type){case"Group":a.terms.forEach(n);break;case"Multiplier":n(a.term);break;case"Type":case"Property":case"Keyword":case"AtKeyword":case"Function":case"String":case"Token":case"Comma":break;default:throw new Error("Unknown type: "+a.type)}o.call(r,a)}let i=ht,o=ht;if(typeof t=="function"?i=t:t&&(i=ds(t.enter),o=ds(t.leave)),i===ht&&o===ht)throw new Error("Neither `enter` nor `leave` walker handler is set or both aren't a function");n(e,r)}var Sc={decorator(e){let t=[],r=null;return{...e,node(n){let i=r;r=n,e.node.call(this,n),r=i},emit(n,i,o){t.push({type:i,value:n,node:o?null:r})},result(){return t}}}};function Cc(e){let t=[];return Se(e,(r,n,i)=>t.push({type:r,value:e.slice(n,i),node:null})),t}function bs(e,t){return typeof e=="string"?Cc(e):t.generate(e,Sc)}var A={type:"Match"},P={type:"Mismatch"},Qt={type:"DisallowEmpty"},Tc=40,Ac=41;function J(e,t,r){return t===A&&r===P||e===A&&t===A&&r===A?e:(e.type==="If"&&e.else===P&&t===A&&(t=e.then,e=e.match),{type:"If",match:e,then:t,else:r})}function ys(e){return e.length>2&&e.charCodeAt(e.length-2)===Tc&&e.charCodeAt(e.length-1)===Ac}function xs(e){return e.type==="Keyword"||e.type==="AtKeyword"||e.type==="Function"||e.type==="Type"&&ys(e.name)}function Rr(e,t,r){switch(e){case" ":{let n=A;for(let i=t.length-1;i>=0;i--){let o=t[i];n=J(o,n,P)}return n}case"|":{let n=P,i=null;for(let o=t.length-1;o>=0;o--){let a=t[o];if(xs(a)&&(i===null&&o>0&&xs(t[o-1])&&(i=Object.create(null),n=J({type:"Enum",map:i},A,n)),i!==null)){let u=(ys(a.name)?a.name.slice(0,-1):a.name).toLowerCase();if(!(u in i)){i[u]=a;continue}}i=null,n=J(a,A,n)}return n}case"&&":{if(t.length>5)return{type:"MatchOnce",terms:t,all:!0};let n=P;for(let i=t.length-1;i>=0;i--){let o=t[i],a;t.length>1?a=Rr(e,t.filter(function(u){return u!==o}),!1):a=A,n=J(o,a,n)}return n}case"||":{if(t.length>5)return{type:"MatchOnce",terms:t,all:!1};let n=r?A:P;for(let i=t.length-1;i>=0;i--){let o=t[i],a;t.length>1?a=Rr(e,t.filter(function(u){return u!==o}),!0):a=A,n=J(o,a,n)}return n}}}function Lc(e){let t=A,r=Mr(e.term);if(e.max===0)r=J(r,Qt,P),t=J(r,null,P),t.then=J(A,A,t),e.comma&&(t.then.else=J({type:"Comma",syntax:e},t,P));else for(let n=e.min||1;n<=e.max;n++)e.comma&&t!==A&&(t=J({type:"Comma",syntax:e},t,P)),t=J(r,J(A,A,t),P);if(e.min===0)t=J(A,A,t);else for(let n=0;n=65&&i<=90&&(i=i|32),i!==n)return!1}return!0}function zc(e){return e.type!==9?!1:e.value!=="?"}function Ss(e){return e===null?!0:e.type===18||e.type===2||e.type===21||e.type===19||e.type===23||zc(e)}function Cs(e){return e===null?!0:e.type===22||e.type===20||e.type===24||e.type===9&&e.value==="/"}function Fc(e,t,r){function n(){do M++,C=Mve&&(ve=M)}function s(){h={syntax:t.syntax,opts:t.syntax.opts||h!==null&&h.opts||null,prev:h},z={type:_r,syntax:t.syntax,token:z.token,prev:z}}function c(){z.type===_r?z=z.prev:z={type:Ts,syntax:h.syntax,token:z.token,prev:z},h=h.prev}let h=null,m=null,f=null,v=null,ee=0,G=null,C=null,M=-1,ve=0,z={type:Ec,syntax:null,token:null,prev:null};for(n();G===null&&++eef.tokenIndex)&&(f=v,v=!1);else if(f===null){G=Ic;break}t=f.nextState,m=f.thenStack,h=f.syntaxStack,z=f.matchStack,M=f.tokenIndex,C=MM){for(;M":"<'"+t.name+"'>"));if(v!==!1&&C!==null&&t.type==="Type"&&(t.name==="custom-ident"&&C.type===1||t.name==="length"&&C.value==="0")){v===null&&(v=o(t,f)),t=P;break}s(),t=X.matchRef||X.match;break}case"Keyword":{let Q=t.name;if(C!==null){let X=C.value;if(X.indexOf("\\")!==-1&&(X=X.replace(/\\[09].*$/,"")),Br(X,Q)){l(),t=A;break}}t=P;break}case"AtKeyword":case"Function":if(C!==null&&Br(C.value,t.name)){l(),t=A;break}t=P;break;case"Token":if(C!==null&&C.value===t.value){l(),t=A;break}t=P;break;case"Comma":C!==null&&C.type===18?Ss(z.token)?t=P:(l(),t=Cs(C)?P:A):t=Ss(z.token)||Cs(C)?A:P;break;case"String":let ae="",ge=M;for(;geAs,isKeyword:()=>Bc,isProperty:()=>Mc,isType:()=>Rc});function As(e){function t(i){return i===null?!1:i.type==="Type"||i.type==="Property"||i.type==="Keyword"}function r(i){if(Array.isArray(i.match)){for(let o=0;or.type==="Type"&&r.name===t)}function Mc(e,t){return jr(this,e,r=>r.type==="Property"&&r.name===t)}function Bc(e){return jr(this,e,t=>t.type==="Keyword")}function jr(e,t,r){let n=As.call(e,t);return n===null?!1:n.some(r)}function Ls(e){return"node"in e?e.node:Ls(e.match[0])}function Es(e){return"node"in e?e.node:Es(e.match[e.match.length-1])}function Ur(e,t,r,n,i){function o(u){if(u.syntax!==null&&u.syntax.type===n&&u.syntax.name===i){let l=Ls(u),s=Es(u);e.syntax.walk(t,function(c,h,m){if(c===l){let f=new Z;do{if(f.appendData(h.data),h.data===s)break;h=h.next}while(h!==null);a.push({parent:m,nodes:f})}})}Array.isArray(u.match)&&u.match.forEach(o)}let a=[];return r.matched!==null&&o(r.matched),a}var{hasOwnProperty:ft}=Object.prototype;function Hr(e){return typeof e=="number"&&isFinite(e)&&Math.floor(e)===e&&e>=0}function Ps(e){return!!e&&Hr(e.offset)&&Hr(e.line)&&Hr(e.column)}function _c(e,t){return function(n,i){if(!n||n.constructor!==Object)return i(n,"Type of node should be an Object");for(let o in n){let a=!0;if(ft.call(n,o)!==!1){if(o==="type")n.type!==e&&i(n,"Wrong node type `"+n.type+"`, expected `"+e+"`");else if(o==="loc"){if(n.loc===null)continue;if(n.loc&&n.loc.constructor===Object)if(typeof n.loc.source!="string")o+=".source";else if(!Ps(n.loc.start))o+=".start";else if(!Ps(n.loc.end))o+=".end";else continue;a=!1}else if(t.hasOwnProperty(o)){a=!1;for(let u=0;!a&&u");else throw new Error("Wrong value `"+i+"` in `"+t+"` structure definition")}return r.join(" | ")}function Wc(e,t){let r=t.structure,n={type:String,loc:!0},i={type:'"'+e+'"'};for(let o in r){if(ft.call(r,o)===!1)continue;let a=n[o]=Array.isArray(r[o])?r[o].slice():[r[o]];i[o]=Is(a,e+"."+o)}return{docs:i,check:_c(e,n)}}function Ds(e){let t={};if(e.node){for(let r in e.node)if(ft.call(e.node,r)){let n=e.node[r];if(n.structure)t[r]=Wc(r,n);else throw new Error("Missed `structure` field in `"+r+"` node type definition")}}return t}function Gr(e,t,r){let n={};for(let i in e)e[i].syntax&&(n[i]=r?e[i].syntax:Ie(e[i].syntax,{compact:t}));return n}function jc(e,t,r){let n={};for(let[i,o]of Object.entries(e))n[i]={prelude:o.prelude&&(r?o.prelude.syntax:Ie(o.prelude.syntax,{compact:t})),descriptors:o.descriptors&&Gr(o.descriptors,t,r)};return n}function qc(e){for(let t=0;t(n[i]=this.createDescriptor(r.descriptors[i],"AtruleDescriptor",i,t),n),Object.create(null)):null})}addProperty_(t,r){r&&(this.properties[t]=this.createDescriptor(r,"Property",t))}addType_(t,r){r&&(this.types[t]=this.createDescriptor(r,"Type",t))}checkAtruleName(t){if(!this.getAtrule(t))return new qe("Unknown at-rule","@"+t)}checkAtrulePrelude(t,r){let n=this.checkAtruleName(t);if(n)return n;let i=this.getAtrule(t);if(!i.prelude&&r)return new SyntaxError("At-rule `@"+t+"` should not contain a prelude");if(i.prelude&&!r&&!Qe(this,i.prelude,"",!1).matched)return new SyntaxError("At-rule `@"+t+"` should contain a prelude")}checkAtruleDescriptorName(t,r){let n=this.checkAtruleName(t);if(n)return n;let i=this.getAtrule(t),o=Rt(r);if(!i.descriptors)return new SyntaxError("At-rule `@"+t+"` has no known descriptors");if(!i.descriptors[o.name]&&!i.descriptors[o.basename])return new qe("Unknown at-rule descriptor",r)}checkPropertyName(t){if(!this.getProperty(t))return new qe("Unknown property",t)}matchAtrulePrelude(t,r){let n=this.checkAtrulePrelude(t,r);if(n)return ue(null,n);let i=this.getAtrule(t);return i.prelude?Qe(this,i.prelude,r||"",!1):ue(null,null)}matchAtruleDescriptor(t,r,n){let i=this.checkAtruleDescriptorName(t,r);if(i)return ue(null,i);let o=this.getAtrule(t),a=Rt(r);return Qe(this,o.descriptors[a.name]||o.descriptors[a.basename],n,!1)}matchDeclaration(t){return t.type!=="Declaration"?ue(null,new Error("Not a Declaration node")):this.matchProperty(t.property,t.value)}matchProperty(t,r){if(kr(t).custom)return ue(null,new Error("Lexer matching doesn't applicable for custom properties"));let n=this.checkPropertyName(t);return n?ue(null,n):Qe(this,this.getProperty(t),r,!0)}matchType(t,r){let n=this.getType(t);return n?Qe(this,n,r,!1):ue(null,new qe("Unknown type",t))}match(t,r){return typeof t!="string"&&(!t||!t.type)?ue(null,new qe("Bad syntax")):((typeof t=="string"||!t.match)&&(t=this.createDescriptor(t,"Type","anonymous")),Qe(this,t,r,!1))}findValueFragments(t,r,n,i){return Ur(this,r,this.matchProperty(t,r),n,i)}findDeclarationValueFragments(t,r,n){return Ur(this,t.value,this.matchDeclaration(t),r,n)}findAllFragments(t,r,n){let i=[];return this.syntax.walk(t,{visit:"Declaration",enter:o=>{i.push.apply(i,this.findDeclarationValueFragments(o,r,n))}}),i}getAtrule(t,r=!0){let n=Rt(t);return(n.vendor&&r?this.atrules[n.name]||this.atrules[n.basename]:this.atrules[n.name])||null}getAtrulePrelude(t,r=!0){let n=this.getAtrule(t,r);return n&&n.prelude||null}getAtruleDescriptor(t,r){return this.atrules.hasOwnProperty(t)&&this.atrules.declarators&&this.atrules[t].declarators[r]||null}getProperty(t,r=!0){let n=kr(t);return(n.vendor&&r?this.properties[n.name]||this.properties[n.basename]:this.properties[n.name])||null}getType(t){return hasOwnProperty.call(this.types,t)?this.types[t]:null}validate(){function t(l,s){return s?`<${l}>`:`<'${l}'>`}function r(l,s,c,h){if(c.has(s))return c.get(s);c.set(s,!1),h.syntax!==null&&Kt(h.syntax,function(m){if(m.type!=="Type"&&m.type!=="Property")return;let f=m.type==="Type"?l.types:l.properties,v=m.type==="Type"?i:o;hasOwnProperty.call(f,m.name)?r(l,m.name,v,f[m.name])&&(n.push(`${t(s,c===i)} used broken syntax definition ${t(m.name,m.type==="Type")}`),c.set(s,!0)):(n.push(`${t(s,c===i)} used missed syntax definition ${t(m.name,m.type==="Type")}`),c.set(s,!0))},this)}let n=[],i=new Map,o=new Map;for(let l in this.types)r(this,l,i,this.types[l]);for(let l in this.properties)r(this,l,o,this.properties[l]);let a=[...i.keys()].filter(l=>i.get(l)),u=[...o.keys()].filter(l=>o.get(l));return a.length||u.length?{errors:n,types:a,properties:u}:null}dump(t,r){return{generic:this.generic,cssWideKeywords:this.cssWideKeywords,units:this.units,types:Gr(this.types,!r,t),properties:Gr(this.properties,!r,t),atrules:jc(this.atrules,!r,t)}}toString(){return JSON.stringify(this.dump())}};function Yr(e,t){return typeof t=="string"&&/^\s*\|/.test(t)?typeof e=="string"?e+t:t.replace(/^\s*\|\s*/,""):t||null}function Ns(e,t){let r=Object.create(null);for(let[n,i]of Object.entries(e))if(i){r[n]={};for(let o of Object.keys(i))t.includes(o)&&(r[n][o]=i[o])}return r}function dt(e,t){let r={...e};for(let[n,i]of Object.entries(t))switch(n){case"generic":r[n]=!!i;break;case"cssWideKeywords":r[n]=e[n]?[...e[n],...i]:i||[];break;case"units":r[n]={...e[n]};for(let[o,a]of Object.entries(i))r[n][o]=Array.isArray(a)?a:[];break;case"atrules":r[n]={...e[n]};for(let[o,a]of Object.entries(i)){let u=r[n][o]||{},l=r[n][o]={prelude:u.prelude||null,descriptors:{...u.descriptors}};if(a){l.prelude=a.prelude?Yr(l.prelude,a.prelude):l.prelude||null;for(let[s,c]of Object.entries(a.descriptors||{}))l.descriptors[s]=c?Yr(l.descriptors[s],c):null;Object.keys(l.descriptors).length||(l.descriptors=null)}}break;case"types":case"properties":r[n]={...e[n]};for(let[o,a]of Object.entries(i))r[n][o]=Yr(r[n][o],a);break;case"scope":case"features":r[n]={...e[n]};for(let[o,a]of Object.entries(i))r[n][o]={...r[n][o],...a};break;case"parseContext":r[n]={...e[n],...i};break;case"atrule":case"pseudo":r[n]={...e[n],...Ns(i,["parse"])};break;case"node":r[n]={...e[n],...Ns(i,["name","structure","parse","generate","walkContext"])};break}return r}function Os(e){let t=fo(e),r=Yo(e),n=Wo(e),{fromPlainObject:i,toPlainObject:o}=jo(r),a={lexer:null,createLexer:u=>new Xe(u,a,a.lexer.structure),tokenize:Se,parse:t,generate:n,walk:r,find:r.find,findLast:r.findLast,findAll:r.findAll,fromPlainObject:i,toPlainObject:o,fork(u){let l=dt({},e);return Os(typeof u=="function"?u(l):dt(l,u))}};return a.lexer=new Xe({generic:e.generic,cssWideKeywords:e.cssWideKeywords,units:e.units,types:e.types,atrules:e.atrules,properties:e.properties,node:e.node},a),a}var Vr=e=>Os(dt({},e));var zs={generic:!0,cssWideKeywords:["initial","inherit","unset","revert","revert-layer"],units:{angle:["deg","grad","rad","turn"],decibel:["db"],flex:["fr"],frequency:["hz","khz"],length:["cm","mm","q","in","pt","pc","px","em","rem","ex","rex","cap","rcap","ch","rch","ic","ric","lh","rlh","vw","svw","lvw","dvw","vh","svh","lvh","dvh","vi","svi","lvi","dvi","vb","svb","lvb","dvb","vmin","svmin","lvmin","dvmin","vmax","svmax","lvmax","dvmax","cqw","cqh","cqi","cqb","cqmin","cqmax"],resolution:["dpi","dpcm","dppx","x"],semitones:["st"],time:["s","ms"]},types:{"abs()":"abs( )","absolute-size":"xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large","acos()":"acos( )","alpha-value":"|","angle-percentage":"|","angular-color-hint":"","angular-color-stop":"&&?","angular-color-stop-list":"[ [, ]?]# , ","animateable-feature":"scroll-position|contents|","asin()":"asin( )","atan()":"atan( )","atan2()":"atan2( , )",attachment:"scroll|fixed|local","attr()":"attr( ? [, ]? )","attr-matcher":"['~'|'|'|'^'|'$'|'*']? '='","attr-modifier":"i|s","attribute-selector":"'[' ']'|'[' [|] ? ']'","auto-repeat":"repeat( [auto-fill|auto-fit] , [? ]+ ? )","auto-track-list":"[? [|]]* ? [? [|]]* ?",axis:"block|inline|vertical|horizontal","baseline-position":"[first|last]? baseline","basic-shape":"||||||","bg-image":"none|","bg-layer":"|| [/ ]?||||||||","bg-position":"[[left|center|right|top|bottom|]|[left|center|right|] [top|center|bottom|]|[center|[left|right] ?]&&[center|[top|bottom] ?]]","bg-size":"[|auto]{1,2}|cover|contain","blur()":"blur( )","blend-mode":"normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity",box:"border-box|padding-box|content-box","brightness()":"brightness( )","calc()":"calc( )","calc-sum":" [['+'|'-'] ]*","calc-product":" ['*' |'/' ]*","calc-value":"||||( )","calc-constant":"e|pi|infinity|-infinity|NaN","cf-final-image":"|","cf-mixing-image":"?&&","circle()":"circle( []? [at ]? )","clamp()":"clamp( #{3} )","class-selector":"'.' ","clip-source":"",color:"|currentColor||||<-non-standard-color>","color-stop":"|","color-stop-angle":"{1,2}","color-stop-length":"{1,2}","color-stop-list":"[ [, ]?]# , ","color-interpolation-method":"in [| ?|]",combinator:"'>'|'+'|'~'|['|' '|']","common-lig-values":"[common-ligatures|no-common-ligatures]","compat-auto":"searchfield|textarea|push-button|slider-horizontal|checkbox|radio|square-button|menulist|listbox|meter|progress-bar|button","composite-style":"clear|copy|source-over|source-in|source-out|source-atop|destination-over|destination-in|destination-out|destination-atop|xor","compositing-operator":"add|subtract|intersect|exclude","compound-selector":"[? *]!","compound-selector-list":"#","complex-selector":" [? ]*","complex-selector-list":"#","conic-gradient()":"conic-gradient( [from ]? [at ]? , )","contextual-alt-values":"[contextual|no-contextual]","content-distribution":"space-between|space-around|space-evenly|stretch","content-list":"[|contents||||||]+","content-position":"center|start|end|flex-start|flex-end","content-replacement":"","contrast()":"contrast( [] )","cos()":"cos( )",counter:"|","counter()":"counter( , ? )","counter-name":"","counter-style":"|symbols( )","counter-style-name":"","counters()":"counters( , , ? )","cross-fade()":"cross-fade( , ? )","cubic-bezier-timing-function":"ease|ease-in|ease-out|ease-in-out|cubic-bezier( , , , )","deprecated-system-color":"ActiveBorder|ActiveCaption|AppWorkspace|Background|ButtonFace|ButtonHighlight|ButtonShadow|ButtonText|CaptionText|GrayText|Highlight|HighlightText|InactiveBorder|InactiveCaption|InactiveCaptionText|InfoBackground|InfoText|Menu|MenuText|Scrollbar|ThreeDDarkShadow|ThreeDFace|ThreeDHighlight|ThreeDLightShadow|ThreeDShadow|Window|WindowFrame|WindowText","discretionary-lig-values":"[discretionary-ligatures|no-discretionary-ligatures]","display-box":"contents|none","display-inside":"flow|flow-root|table|flex|grid|ruby","display-internal":"table-row-group|table-header-group|table-footer-group|table-row|table-cell|table-column-group|table-column|table-caption|ruby-base|ruby-text|ruby-base-container|ruby-text-container","display-legacy":"inline-block|inline-list-item|inline-table|inline-flex|inline-grid","display-listitem":"?&&[flow|flow-root]?&&list-item","display-outside":"block|inline|run-in","drop-shadow()":"drop-shadow( {2,3} ? )","east-asian-variant-values":"[jis78|jis83|jis90|jis04|simplified|traditional]","east-asian-width-values":"[full-width|proportional-width]","element()":"element( , [first|start|last|first-except]? )|element( )","ellipse()":"ellipse( [{2}]? [at ]? )","ending-shape":"circle|ellipse","env()":"env( , ? )","exp()":"exp( )","explicit-track-list":"[? ]+ ?","family-name":"|+","feature-tag-value":" [|on|off]?","feature-type":"@stylistic|@historical-forms|@styleset|@character-variant|@swash|@ornaments|@annotation","feature-value-block":" '{' '}'","feature-value-block-list":"+","feature-value-declaration":" : + ;","feature-value-declaration-list":"","feature-value-name":"","fill-rule":"nonzero|evenodd","filter-function":"|||||||||","filter-function-list":"[|]+","final-bg-layer":"<'background-color'>|||| [/ ]?||||||||","fixed-breadth":"","fixed-repeat":"repeat( [] , [? ]+ ? )","fixed-size":"|minmax( , )|minmax( , )","font-stretch-absolute":"normal|ultra-condensed|extra-condensed|condensed|semi-condensed|semi-expanded|expanded|extra-expanded|ultra-expanded|","font-variant-css21":"[normal|small-caps]","font-weight-absolute":"normal|bold|","frequency-percentage":"|","general-enclosed":"[ ? )]|[( ? )]","generic-family":"|||<-non-standard-generic-family>","generic-name":"serif|sans-serif|cursive|fantasy|monospace","geometry-box":"|fill-box|stroke-box|view-box",gradient:"||||||<-legacy-gradient>","grayscale()":"grayscale( )","grid-line":"auto||[&&?]|[span&&[||]]","historical-lig-values":"[historical-ligatures|no-historical-ligatures]","hsl()":"hsl( [/ ]? )|hsl( , , , ? )","hsla()":"hsla( [/ ]? )|hsla( , , , ? )",hue:"|","hue-rotate()":"hue-rotate( )","hue-interpolation-method":"[shorter|longer|increasing|decreasing] hue","hwb()":"hwb( [|none] [|none] [|none] [/ [|none]]? )","hypot()":"hypot( # )",image:"||||||","image()":"image( ? [? , ?]! )","image-set()":"image-set( # )","image-set-option":"[|] [||type( )]","image-src":"|","image-tags":"ltr|rtl","inflexible-breadth":"|min-content|max-content|auto","inset()":"inset( {1,4} [round <'border-radius'>]? )","invert()":"invert( )","keyframes-name":"|","keyframe-block":"# { }","keyframe-block-list":"+","keyframe-selector":"from|to|| ","lab()":"lab( [||none] [||none] [||none] [/ [|none]]? )","layer()":"layer( )","layer-name":" ['.' ]*","lch()":"lch( [||none] [||none] [|none] [/ [|none]]? )","leader()":"leader( )","leader-type":"dotted|solid|space|","length-percentage":"|","light-dark()":"light-dark( , )","line-names":"'[' * ']'","line-name-list":"[|]+","line-style":"none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset","line-width":"|thin|medium|thick","linear-color-hint":"","linear-color-stop":" ?","linear-gradient()":"linear-gradient( [[|to ]||]? , )","log()":"log( , ? )","mask-layer":"|| [/ ]?||||||[|no-clip]||||","mask-position":"[|left|center|right] [|top|center|bottom]?","mask-reference":"none||","mask-source":"","masking-mode":"alpha|luminance|match-source","matrix()":"matrix( #{6} )","matrix3d()":"matrix3d( #{16} )","max()":"max( # )","media-and":" [and ]+","media-condition":"|||","media-condition-without-or":"||","media-feature":"( [||] )","media-in-parens":"( )||","media-not":"not ","media-or":" [or ]+","media-query":"|[not|only]? [and ]?","media-query-list":"#","media-type":"","mf-boolean":"","mf-name":"","mf-plain":" : ","mf-range":" ['<'|'>']? '='? | ['<'|'>']? '='? | '<' '='? '<' '='? | '>' '='? '>' '='? ","mf-value":"|||","min()":"min( # )","minmax()":"minmax( [|min-content|max-content|auto] , [||min-content|max-content|auto] )","mod()":"mod( , )","name-repeat":"repeat( [|auto-fill] , + )","named-color":"transparent|aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen","namespace-prefix":"","ns-prefix":"[|'*']? '|'","number-percentage":"|","numeric-figure-values":"[lining-nums|oldstyle-nums]","numeric-fraction-values":"[diagonal-fractions|stacked-fractions]","numeric-spacing-values":"[proportional-nums|tabular-nums]",nth:"|even|odd","opacity()":"opacity( [] )","overflow-position":"unsafe|safe","outline-radius":"|","page-body":"? [; ]?| ","page-margin-box":" '{' '}'","page-margin-box-type":"@top-left-corner|@top-left|@top-center|@top-right|@top-right-corner|@bottom-left-corner|@bottom-left|@bottom-center|@bottom-right|@bottom-right-corner|@left-top|@left-middle|@left-bottom|@right-top|@right-middle|@right-bottom","page-selector-list":"[#]?","page-selector":"+| *","page-size":"A5|A4|A3|B5|B4|JIS-B5|JIS-B4|letter|legal|ledger","path()":"path( [ ,]? )","paint()":"paint( , ? )","perspective()":"perspective( [|none] )","polygon()":"polygon( ? , [ ]# )","polar-color-space":"hsl|hwb|lch|oklch",position:"[[left|center|right]||[top|center|bottom]|[left|center|right|] [top|center|bottom|]?|[[left|right] ]&&[[top|bottom] ]]","pow()":"pow( , )","pseudo-class-selector":"':' |':' ')'","pseudo-element-selector":"':' |","pseudo-page":": [left|right|first|blank]",quote:"open-quote|close-quote|no-open-quote|no-close-quote","radial-gradient()":"radial-gradient( [||]? [at ]? , )",ratio:" [/ ]?","ray()":"ray( &&?&&contain?&&[at ]? )","ray-size":"closest-side|closest-corner|farthest-side|farthest-corner|sides","rectangular-color-space":"srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|xyz|xyz-d50|xyz-d65","relative-selector":"? ","relative-selector-list":"#","relative-size":"larger|smaller","rem()":"rem( , )","repeat-style":"repeat-x|repeat-y|[repeat|space|round|no-repeat]{1,2}","repeating-conic-gradient()":"repeating-conic-gradient( [from ]? [at ]? , )","repeating-linear-gradient()":"repeating-linear-gradient( [|to ]? , )","repeating-radial-gradient()":"repeating-radial-gradient( [||]? [at ]? , )","reversed-counter-name":"reversed( )","rgb()":"rgb( {3} [/ ]? )|rgb( {3} [/ ]? )|rgb( #{3} , ? )|rgb( #{3} , ? )","rgba()":"rgba( {3} [/ ]? )|rgba( {3} [/ ]? )|rgba( #{3} , ? )|rgba( #{3} , ? )","rotate()":"rotate( [|] )","rotate3d()":"rotate3d( , , , [|] )","rotateX()":"rotateX( [|] )","rotateY()":"rotateY( [|] )","rotateZ()":"rotateZ( [|] )","round()":"round( ? , , )","rounding-strategy":"nearest|up|down|to-zero","saturate()":"saturate( )","scale()":"scale( [|]#{1,2} )","scale3d()":"scale3d( [|]#{3} )","scaleX()":"scaleX( [|] )","scaleY()":"scaleY( [|] )","scaleZ()":"scaleZ( [|] )","scroll()":"scroll( [||]? )",scroller:"root|nearest","self-position":"center|start|end|self-start|self-end|flex-start|flex-end","shape-radius":"|closest-side|farthest-side","sign()":"sign( )","skew()":"skew( [|] , [|]? )","skewX()":"skewX( [|] )","skewY()":"skewY( [|] )","sepia()":"sepia( )",shadow:"inset?&&{2,4}&&?","shadow-t":"[{2,3}&&?]",shape:"rect( , , , )|rect( )","shape-box":"|margin-box","side-or-corner":"[left|right]||[top|bottom]","sin()":"sin( )","single-animation":"<'animation-duration'>||||<'animation-delay'>||||||||||[none|]||","single-animation-direction":"normal|reverse|alternate|alternate-reverse","single-animation-fill-mode":"none|forwards|backwards|both","single-animation-iteration-count":"infinite|","single-animation-play-state":"running|paused","single-animation-timeline":"auto|none|||","single-transition":"[none|]||
+
+ {httpStatusCode} +
+

{title}

+

{message}

+ +

More information regarding this error might be available online.

+ +

Request: {requestId}

+ +