commit 44c503b2d14172a048607a841b9ce202a00f8425 Author: Sven Wappler Date: Mon Aug 10 22:31:27 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/Aspect/FileMetadataOverlayAspect.php b/Classes/Aspect/FileMetadataOverlayAspect.php new file mode 100644 index 0000000..10d785c --- /dev/null +++ b/Classes/Aspect/FileMetadataOverlayAspect.php @@ -0,0 +1,61 @@ +isFrontend() + || isset($_REQUEST['eID']) + ) { + return; + } + $overlaidMetaData = $event->getRecord(); + $this->pageRepository->versionOL('sys_file_metadata', $overlaidMetaData); + // getLanguageOverlay also calls versionOL() on the language overlaid record + $overlaidMetaData = $this->pageRepository->getLanguageOverlay('sys_file_metadata', $overlaidMetaData); + if ($overlaidMetaData !== null) { + $event->setRecord($overlaidMetaData); + } + } +} diff --git a/Classes/Aspect/PreviewAspect.php b/Classes/Aspect/PreviewAspect.php new file mode 100644 index 0000000..35a9f6c --- /dev/null +++ b/Classes/Aspect/PreviewAspect.php @@ -0,0 +1,52 @@ +isPreview; + } + + /** + * Get a property from aspect + * + * @throws AspectPropertyNotFoundException + */ + public function get(string $name): bool + { + if ($name == 'isPreview') { + return $this->isPreview; + } + throw new AspectPropertyNotFoundException('Property "' . $name . '" not found in Aspect "' . __CLASS__ . '".', 1563375558); + } +} diff --git a/Classes/Authentication/FrontendBackendUserAuthentication.php b/Classes/Authentication/FrontendBackendUserAuthentication.php new file mode 100644 index 0000000..1019a95 --- /dev/null +++ b/Classes/Authentication/FrontendBackendUserAuthentication.php @@ -0,0 +1,96 @@ +formfield_status is set to empty in order to + * disable login-attempts to the backend account through this script + * + * @var string + * @internal + */ + protected $formfield_status = ''; + + /** + * Decides if the writelog() function is called at login and logout. + * + * @var bool + */ + public $writeStdLog = false; + + /** + * If the writelog() functions is called if a login-attempt has be tried without success. + * + * @var bool + */ + public $writeAttemptLog = false; + + /** + * Implementing the access checks that the TYPO3 CMS bootstrap script does before a user is ever logged in. + * Used in the frontend. + * + * @return bool Returns TRUE if access is OK + */ + public function backendCheckLogin(?ServerRequestInterface $request = null) + { + if (empty($this->user['uid'])) { + return false; + } + // Check Hardcoded lock on BE + if ($GLOBALS['TYPO3_CONF_VARS']['BE']['adminOnly'] < 0) { + return false; + } + return $this->isUserAllowedToLogin(); + } + + /** + * If a user is in a workspace, but previews the live workspace (GET keyword "LIVE") even if the user + * has no editing permissions for this, it should still be visible, even though "be_users.workspace_perms" is set to "0". + * If this ain't true, users without the live permission cannot see the live page, only the preview of the workspace of the user. + */ + protected function hasEditAccessToLiveWorkspace(): bool + { + return true; + } +} diff --git a/Classes/Authentication/FrontendUserAuthentication.php b/Classes/Authentication/FrontendUserAuthentication.php new file mode 100644 index 0000000..f0d0407 --- /dev/null +++ b/Classes/Authentication/FrontendUserAuthentication.php @@ -0,0 +1,502 @@ + 'deleted', + 'disabled' => 'disable', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ]; + + /** + * @var array + */ + public $groupData = [ + 'title' => [], + 'uid' => [], + 'pid' => [], + ]; + + /** + * @var bool + */ + protected $userData_change = false; + + /** + * @var bool + * @internal + */ + protected $is_permanent = false; + + /** + * Will force the session cookie to be set every time (lifetime must be 0). + * @var bool + */ + protected $forceSetCookie = false; + + /** + * Will prevent the setting of the session cookie (takes precedence over forceSetCookie) + * Disable cookie by default, will be activated if saveSessionData() is called, + * a user is logging-in or an existing session is found + * @var bool + * @internal + */ + protected $dontSetCookie = true; + + public function __construct() + { + $this->name = self::getCookieName(); + parent::__construct(); + $this->checkPid = (bool)($GLOBALS['TYPO3_CONF_VARS']['FE']['checkFeUserPid'] ?? true); + } + + /** + * Returns the configured cookie name + */ + public static function getCookieName(): string + { + $configuredCookieName = trim((string)($GLOBALS['TYPO3_CONF_VARS']['FE']['cookieName'] ?? '')); + return $configuredCookieName !== '' ? $configuredCookieName : 'fe_typo_user'; + } + + /** + * 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, $this->forceSetCookie); + } + + /** + * 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); + } + + /** + * Returns an info array with Login/Logout data submitted by a form or params + * + * @return array + * @see AbstractUserAuthentication::getLoginFormData() + */ + public function getLoginFormData(ServerRequestInterface $request) + { + $loginData = parent::getLoginFormData($request); + // Needed in order to fetch users which are already logged-in due to fetching from session + if (LoginType::tryFrom($loginData['status'] ?? '') !== LoginType::LOGIN) { + $this->checkPid_value = null; + } + + if ($GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 0 || $GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 1) { + $isPermanent = $request->getParsedBody()[$this->formfield_permanent] ?? ''; + if (strlen((string)$isPermanent) != 1) { + $isPermanent = $GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin']; + } elseif (!$isPermanent) { + // To make sure the user gets a session cookie and doesn't keep a possibly existing time based cookie, + // we need to force setting the session cookie here + $this->forceSetCookie = true; + } + $isPermanent = (bool)$isPermanent; + } elseif ($GLOBALS['TYPO3_CONF_VARS']['FE']['permalogin'] == 2) { + $isPermanent = true; + } else { + $isPermanent = false; + } + $loginData['permanent'] = $isPermanent; + $this->is_permanent = $isPermanent; + return $loginData; + } + + /** + * Creates a user session record and returns its values. + * However, as the FE user cookie is normally not set, this has to be done + * before the parent class is doing the rest. + * + * @param array $tempuser User data array + * @return UserSession The session data for the newly created session. + */ + public function createUserSession(array $tempuser): UserSession + { + // At this point we do not know if we need to set a session or a permanent cookie + // So we force the cookie to be set after authentication took place, which will + // then call setSessionCookie(), which will set a cookie with correct settings. + $this->dontSetCookie = false; + $tempUserId = (int)($tempuser[$this->userid_column] ?? 0); + $session = $this->userSessionManager->elevateToFixatedUserSession( + $this->userSession, + $tempUserId, + (bool)$this->is_permanent + ); + // Updating lastLogin_column carrying information about last login. + $this->updateLoginTimestamp($tempUserId); + return $session; + } + + /** + * Will select all fe_groups records that the current fe_user is member of. + * + * @param ServerRequestInterface $request + */ + public function fetchGroupData(ServerRequestInterface $request) + { + $this->userGroups = []; + $this->groupData = [ + 'title' => [], + 'uid' => [], + 'pid' => [], + ]; + + $groupDataArr = []; + if (is_array($this->user)) { + $this->logger->debug('Get usergroups for user', [ + $this->userid_column => $this->getUserId(), + $this->username_column => $this->getUserName(), + ]); + $groupDataArr = GeneralUtility::makeInstance(GroupResolver::class)->resolveGroupsForUser($this->user, $this->usergroup_table); + } + // Fire an event for any kind of user (even when no specific user is here, using hideLogin feature) + $dispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + $event = $dispatcher->dispatch(new ModifyResolvedFrontendGroupsEvent($this, $groupDataArr, $request)); + $groupDataArr = $event->getGroups(); + + if (empty($groupDataArr)) { + $this->logger->debug('No usergroups found'); + } else { + $this->logger->debug('{count} usergroup records found', ['count' => count($groupDataArr)]); + } + foreach ($groupDataArr as $groupData) { + $groupId = (int)$groupData['uid']; + $this->groupData['title'][$groupId] = $groupData['title'] ?? ''; + $this->groupData['uid'][$groupId] = $groupData['uid'] ?? 0; + $this->groupData['pid'][$groupId] = $groupData['pid'] ?? 0; + $this->userGroups[$groupId] = $groupData; + } + // Sort information + ksort($this->groupData['title']); + ksort($this->groupData['uid']); + ksort($this->groupData['pid']); + } + + /** + * Initializes the front-end user groups for the context API, + * based on the user groups and the logged-in state. + * + * @param bool $respectUserGroups used to disable the inclusion of the users' groups + */ + public function createUserAspect(bool $respectUserGroups = true): UserAspect + { + $userGroups = [0]; + $isUserAndGroupSet = is_array($this->user) && !empty($this->userGroups); + if ($isUserAndGroupSet) { + // group -2 is not an existing group, but denotes a 'default' group when a user IS logged in. + // This is used to let elements be shown for all logged in users! + $userGroups[] = -2; + $groupsFromUserRecord = array_keys($this->userGroups); + } else { + // group -1 is not an existing group, but denotes a 'default' group when not logged in. + // This is used to let elements be hidden, when a user is logged in! + $userGroups[] = -1; + if ($respectUserGroups) { + // For cases where logins are not banned from a branch usergroups can be set based on IP masks so we should add the usergroups uids. + $groupsFromUserRecord = array_keys($this->userGroups); + } else { + // Set to blank since we will NOT risk any groups being set when no logins are allowed! + $groupsFromUserRecord = []; + } + } + // Make unique and sort the groups + $groupsFromUserRecord = array_unique($groupsFromUserRecord); + if ($respectUserGroups && !empty($groupsFromUserRecord)) { + sort($groupsFromUserRecord); + $userGroups = array_merge($userGroups, $groupsFromUserRecord); + } + + // For every 60 seconds the is_online timestamp for a logged-in user is updated + if ($isUserAndGroupSet) { + $this->updateOnlineTimestamp(); + } + + $this->logger->debug('Valid frontend usergroups: {groups}', ['groups' => implode(',', $userGroups)]); + return new UserAspect($this, $userGroups); + } + + /***************************************** + * + * Session data management functions + * + ****************************************/ + /** + * Will write UC and session data. + * If the flag $this->userData_change has been set, the function ->writeUC is called (which will save persistent user session data) + * + * @see getKey() + * @see setKey() + */ + public function storeSessionData() + { + // Saves UC and SesData if changed. + if ($this->userData_change) { + $this->writeUC(); + } + + if ($this->userSession->dataWasUpdated()) { + if (!$this->userSession->hasData()) { + // Remove session-data + $this->removeSessionData(); + // Remove cookie if not logged in as the session data is removed as well + if (empty($this->user['uid']) && $this->isCookieSet()) { + $this->removeCookie(); + } + } elseif (!$this->userSessionManager->isSessionPersisted($this->userSession)) { + // Create a new session entry in the backend + $this->userSession = $this->userSessionManager->fixateAnonymousSession($this->userSession, (bool)$this->is_permanent); + // Now set the cookie (= fix the session) + $this->setSessionCookie(); + } else { + // Update session data of an already fixated session + $this->userSession = $this->userSessionManager->updateSession($this->userSession); + } + } + } + + /** + * Removes data of the current session. + */ + public function removeSessionData() + { + $this->userSession->overrideData([]); + if ($this->userSessionManager->isSessionPersisted($this->userSession)) { + // Remove session record if $this->user is empty or in case the session is anonymous + if (empty($this->user) || $this->userSession->isAnonymous()) { + $this->userSessionManager->removeSession($this->userSession); + } else { + $this->userSession = $this->userSessionManager->updateSession($this->userSession); + } + } + } + + /** + * 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. + * Forces cookie to be set + */ + protected function regenerateSessionId() + { + parent::regenerateSessionId(); + // We force the cookie to be set later in the authentication process + $this->dontSetCookie = false; + } + + /** + * Returns session data for the fe_user; Either persistent data following the fe_users uid/profile (requires login) + * or current-session based (not available when browse is closed, but does not require login) + * + * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie) + * @param string $key Key from the data array to return; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines which key to return the value for. + * @return mixed Returns whatever value there was in the array for the key, $key + * @see setKey() + */ + public function getKey($type, $key) + { + if (!$key) { + return null; + } + $value = null; + switch ($type) { + case 'user': + $value = $this->uc[$key] ?? null; + break; + case 'ses': + $value = $this->getSessionData($key); + break; + } + return $value; + } + + /** + * Saves session data, either persistent or bound to current session cookie. Please see getKey() for more details. + * When a value is set the flag $this->userData_change will be set so that the final call to ->storeSessionData() will know if a change has occurred and needs to be saved to the database. + * Notice: Simply calling this function will not save the data to the database! The actual saving is done in storeSessionData() which is called as some of the last things in \TYPO3\CMS\Frontend\Http\RequestHandler. + * + * @param string $type Session data type; Either "user" (persistent, bound to fe_users profile) or "ses" (temporary, bound to current session cookie) + * @param string $key Key from the data array to store incoming data in; The session data (in either case) is an array ($this->uc / $this->sessionData) and this value determines in which key the $data value will be stored. + * @param mixed $data The data value to store in $key + * @see setKey() + * @see storeSessionData() + */ + public function setKey($type, $key, $data) + { + if (!$key) { + return; + } + switch ($type) { + case 'user': + if ($this->user['uid'] ?? 0) { + if ($data === null) { + unset($this->uc[$key]); + } else { + $this->uc[$key] = $data; + } + $this->userData_change = true; + } + break; + case 'ses': + $this->setSessionData($key, $data); + break; + } + } + + /** + * Saves the tokens so that they can be used by a later incarnation of this class. + * + * @param string $key + * @param mixed $data + */ + public function setAndSaveSessionData($key, $data) + { + $this->setSessionData($key, $data); + $this->storeSessionData(); + } + + /** + * Update the field "is_online" every 60 seconds of a logged-in user + * + * @internal + */ + public function updateOnlineTimestamp() + { + if (!is_array($this->user) + || !($this->user['uid'] ?? 0) + || $this->user['uid'] === PHP_INT_MAX // Simulated preview user (flagged with PHP_INT_MAX uid) + || ($this->user['is_online'] ?? 0) >= $GLOBALS['EXEC_TIME'] - 60) { + return; + } + $dbConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->user_table); + $dbConnection->update( + $this->user_table, + ['is_online' => $GLOBALS['EXEC_TIME']], + ['uid' => (int)$this->user['uid']] + ); + $this->user['is_online'] = $GLOBALS['EXEC_TIME']; + } +} diff --git a/Classes/Authentication/ModifyResolvedFrontendGroupsEvent.php b/Classes/Authentication/ModifyResolvedFrontendGroupsEvent.php new file mode 100644 index 0000000..58e40e7 --- /dev/null +++ b/Classes/Authentication/ModifyResolvedFrontendGroupsEvent.php @@ -0,0 +1,53 @@ +request; + } + + public function getUser(): FrontendUserAuthentication + { + return $this->user; + } + + public function getGroups(): array + { + return $this->groups; + } + + public function setGroups(array $groups): void + { + $this->groups = $groups; + } +} diff --git a/Classes/Cache/CacheInstruction.php b/Classes/Cache/CacheInstruction.php new file mode 100644 index 0000000..87c2338 --- /dev/null +++ b/Classes/Cache/CacheInstruction.php @@ -0,0 +1,71 @@ +allowCaching = false; + $this->disabledCacheReasons[] = $reason; + } + + public function isCachingAllowed(): bool + { + return $this->allowCaching; + } + + /** + * @internal Typically only consumed by extensions like EXT:adminpanel + */ + public function getDisabledCacheReasons(): array + { + return $this->disabledCacheReasons; + } +} diff --git a/Classes/Cache/CacheLifetimeCalculator.php b/Classes/Cache/CacheLifetimeCalculator.php new file mode 100644 index 0000000..01172bf --- /dev/null +++ b/Classes/Cache/CacheLifetimeCalculator.php @@ -0,0 +1,272 @@ +runtimeCache->get($cachedCacheLifetimeIdentifier); + if ($cachedCacheLifetime !== false) { + return (int)$cachedCacheLifetime; + } + + $cacheTimeout = $defaultCacheTimeoutInSeconds ?: self::defaultCacheTimeout; + + if ($this->tcaSchemaFactory->has($tableName)) { + $schema = $this->tcaSchemaFactory->get($tableName); + // If the record has a starttime or endtime, we have to adjust the cache timeout + foreach ([TcaSchemaCapability::RestrictionStartTime, TcaSchemaCapability::RestrictionEndTime] as $capability) { + if (!$schema->hasCapability($capability)) { + continue; + } + $timeField = $schema->getCapability($capability)->getFieldName(); + if (array_key_exists($timeField, $record) && $record[$timeField] > 0 && ((int)$record[$timeField] - $GLOBALS['ACCESS_TIME']) > 0) { + $cacheTimeout = min($cacheTimeout, (int)$record[$timeField] - $GLOBALS['ACCESS_TIME']); + } + } + } + + // Get the time, rounded to the minute (do not pollute MySQL cache!) + // It is ok that we do not take seconds into account here because this + // value will be subtracted later. So we never get the time "before" + // the cache change. + $currentTimestamp = (int)$GLOBALS['ACCESS_TIME']; + $cacheTimeout = min($currentTimestamp, $cacheTimeout); + + $event = new ModifyCacheLifetimeForRowEvent( + $cacheTimeout, + $tableName, + $record + ); + $event = $this->eventDispatcher->dispatch($event); + $cacheTimeout = $event->cacheLifetime; + $this->runtimeCache->set($cachedCacheLifetimeIdentifier, (string)$cacheTimeout); + return $cacheTimeout; + } + + /** + * Get the cache lifetime in seconds for the given page. + */ + public function calculateLifetimeForPage(int $pageId, array $pageRecord, array $renderingInstructions, Context $context): int + { + $cachedCacheLifetimeIdentifier = 'cacheLifeTimeForPage_' . $pageId; + $cachedCacheLifetime = $this->runtimeCache->get($cachedCacheLifetimeIdentifier); + if ($cachedCacheLifetime !== false) { + return (int)$cachedCacheLifetime; + } + if ($pageRecord['cache_timeout'] ?? false) { + // Cache period was set for the page: + $cacheTimeout = (int)$pageRecord['cache_timeout']; + } else { + // Cache period was set via TypoScript "config.cache_period", otherwise it's the default of 24 hours + $cacheTimeout = (int)($renderingInstructions['cache_period'] ?? self::defaultCacheTimeout); + } + + $cacheTimeout = $this->calculateLifetimeForRow('pages', $pageRecord, $cacheTimeout); + + // Calculate the timeout time for records on the page and adjust cache timeout if necessary + // Get the configuration + $tablesToConsider = $this->getCurrentPageCacheConfiguration($pageId, $renderingInstructions); + + // Get the time, rounded to the minute (do not pollute MySQL cache!) + // It is ok that we do not take seconds into account here because this + // value will be subtracted later. So we never get the time "before" + // the cache change. + $currentTimestamp = (int)$GLOBALS['ACCESS_TIME']; + $cacheTimeout = min($this->calculatePageCacheLifetime($tablesToConsider, $currentTimestamp), $cacheTimeout); + + $event = new ModifyCacheLifetimeForPageEvent( + $cacheTimeout, + $pageId, + $pageRecord, + $renderingInstructions, + $context + ); + $event = $this->eventDispatcher->dispatch($event); + $cacheTimeout = $event->getCacheLifetime(); + $this->runtimeCache->set($cachedCacheLifetimeIdentifier, (string)$cacheTimeout); + return $cacheTimeout; + } + + /** + * Calculates page cache timeout according to the records with starttime/endtime on the page. + * + * @return int Page cache timeout or PHP_INT_MAX if the timeout cannot be determined + */ + protected function calculatePageCacheLifetime(array $tablesToConsider, int $currentTimestamp): int + { + $result = PHP_INT_MAX; + // Find timeout by checking every table + foreach ($tablesToConsider as $tableDef) { + $result = min($result, $this->getFirstTimeValueForRecord($tableDef, $currentTimestamp)); + } + // We return + 1 second just to ensure that cache is definitely regenerated + return $result === PHP_INT_MAX ? PHP_INT_MAX : $result - $currentTimestamp + 1; + } + + /** + * Obtains a list of table/pid pairs to consider for page caching. + * + * TS configuration looks like this: + * + * The cache lifetime of all pages takes starttime and endtime of news records of page 14 into account: + * config.cache.all = tt_news:14 + * + * The cache lifetime of the current page allows to take records (e.g. fe_users) into account: + * config.cache.all = fe_users:current + * + * The cache lifetime of page 42 takes starttime and endtime of news records of page 15 and addresses of page 16 into account: + * config.cache.42 = tt_news:15,tt_address:16 + * + * @return array Array of 'tablename:pid' pairs. There is at least a current page id in the array + * @see calculatePageCacheLifetime() + */ + protected function getCurrentPageCacheConfiguration(int $currentPageId, array $renderingInstructions): array + { + $result = ['tt_content:' . $currentPageId]; + if (isset($renderingInstructions['cache.'][$currentPageId])) { + $result = array_merge($result, GeneralUtility::trimExplode(',', str_replace(':current', ':' . $currentPageId, $renderingInstructions['cache.'][$currentPageId]))); + } + if (isset($renderingInstructions['cache.']['all'])) { + $result = array_merge($result, GeneralUtility::trimExplode(',', str_replace(':current', ':' . $currentPageId, $renderingInstructions['cache.']['all']))); + } + return array_unique($result); + } + + /** + * Find the minimum starttime or endtime value in the table and pid that is greater than the current time. + * + * @param string $tableDef Table definition (format tablename:pid) + * @param int $currentTimestamp the UNIX timestamp of the current time + * @throws \InvalidArgumentException + * @return int Value of the next start/stop time or PHP_INT_MAX if not found + * @see calculatePageCacheLifetime() + */ + protected function getFirstTimeValueForRecord(string $tableDef, int $currentTimestamp): int + { + $result = PHP_INT_MAX; + [$tableName, $pid] = GeneralUtility::trimExplode(':', $tableDef); + if (empty($tableName) || !isset($pid)) { + throw new \InvalidArgumentException('Unexpected value for parameter $tableDef. Expected :, got \'' . htmlspecialchars($tableDef) . '\'.', 1307190365); + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions() + ->removeByType(StartTimeRestriction::class) + ->removeByType(EndTimeRestriction::class); + $timeFields = []; + $timeConditions = $queryBuilder->expr()->or(); + if ($this->tcaSchemaFactory->has($tableName)) { + $schema = $this->tcaSchemaFactory->get($tableName); + // If the record has a starttime or endtime, we have to adjust the cache timeout + foreach ([TcaSchemaCapability::RestrictionStartTime, TcaSchemaCapability::RestrictionEndTime] as $capability) { + if (!$schema->hasCapability($capability)) { + continue; + } + $timeField = $schema->getCapability($capability)->getFieldName(); + $queryBuilder->addSelectLiteral( + 'MIN(' + . 'CASE WHEN ' + . $queryBuilder->expr()->lte( + $timeField, + $queryBuilder->createNamedParameter($currentTimestamp, Connection::PARAM_INT) + ) + . ' THEN NULL ELSE ' . $queryBuilder->quoteIdentifier($timeField) . ' END' + . ') AS ' . $queryBuilder->quoteIdentifier($timeField) + ); + $timeConditions = $timeConditions->with( + $queryBuilder->expr()->gt( + $timeField, + $queryBuilder->createNamedParameter($currentTimestamp, Connection::PARAM_INT) + ) + ); + $timeFields[] = $timeField; + } + } + + // if starttime or endtime are defined, evaluate them + if ($timeFields !== []) { + // find the timestamp, when the current page's content changes the next time + $row = $queryBuilder + ->from($tableName) + ->where( + $queryBuilder->expr()->eq( + 'pid', + $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT) + ), + $timeConditions + ) + ->executeQuery() + ->fetchAssociative(); + + if ($row) { + foreach ($timeFields as $timeField) { + // if a MIN value is found, take it into account for the + // cache lifetime we have to filter out start/endtimes < $currentTimestamp, + // as the SQL query also returns rows with starttime < $currentTimestamp + // and endtime > $currentTimestamp (and using a starttime from the past + // would be wrong) + if ($row[$timeField] !== null && (int)$row[$timeField] > $currentTimestamp) { + $result = min($result, (int)$row[$timeField]); + } + } + } + } + + return $result; + } +} diff --git a/Classes/Cache/MetaDataState.php b/Classes/Cache/MetaDataState.php new file mode 100644 index 0000000..81b3d10 --- /dev/null +++ b/Classes/Cache/MetaDataState.php @@ -0,0 +1,91 @@ + json_encode($this->policyRegistry->getMutationCollections()), + 'HashCollection' => json_encode($this->directiveHashCollection), + ]; + } + + public function updateState(array $state): void + { + foreach ($state as $name => $value) { + switch ($name) { + case 'PolicyRegistry::$mutationCollections': + $this->updatePolicyRegistryMutationCollections($value); + break; + case 'HashCollection': + $this->updateHashCollection($value); + break; + } + } + } + + private function updatePolicyRegistryMutationCollections(mixed $value): void + { + $array = $this->decodeJsonString($value); + if (is_array($array)) { + $this->policyRegistry->setMutationsCollections( + ...array_map($this->modelService->buildMutationCollectionFromArray(...), $array) + ); + } + } + + private function updateHashCollection(mixed $value): void + { + $array = $this->decodeJsonString($value); + if (is_array($array)) { + $this->directiveHashCollection->updateFromJson($array); + } + } + + private function decodeJsonString(mixed $value): ?array + { + if (!is_string($value) || $value === '') { + return null; + } + try { + $array = json_decode($value, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + return is_array($array) ? $array : null; + } +} diff --git a/Classes/Cache/NonceValueSubstitution.php b/Classes/Cache/NonceValueSubstitution.php new file mode 100644 index 0000000..ddd1900 --- /dev/null +++ b/Classes/Cache/NonceValueSubstitution.php @@ -0,0 +1,43 @@ +getAttribute('nonce'); + if (!$currentNonce instanceof ConsumableNonce + || empty($context['content']) + || empty($context['nonce']) + || $currentNonce->value === $context['nonce'] + || !str_contains($context['content'], $context['nonce']) + ) { + return null; + } + return str_replace($context['nonce'], $currentNonce->consumeInline(self::class), $context['content']); + } +} diff --git a/Classes/Category/Collection/CategoryCollection.php b/Classes/Category/Collection/CategoryCollection.php new file mode 100644 index 0000000..158d8ca --- /dev/null +++ b/Classes/Category/Collection/CategoryCollection.php @@ -0,0 +1,191 @@ +fromArray($collectionRecord); + if ($fillItems) { + $collection->loadContents(); + } + return $collection; + } + + /** + * Loads the collection with the given id from persistence + * For memory reasons, only data for the collection itself is loaded by default. + * Entries can be loaded on first access or straightaway using the $fillItems flag. + * + * Overrides the parent method because of the call to "self::create()" which otherwise calls up + * \TYPO3\CMS\Core\Category\Collection\CategoryCollection + * + * @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 the table name + * @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->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::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); + } + + /** + * Gets 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'. + * + * Overrides its parent method to implement usage of language, + * enable fields, etc. Also performs overlays. + * + * @return array + */ + protected function getCollectedRecords() + { + $relatedRecords = []; + + $queryBuilder = $this->getCollectedRecordsQueryBuilder(); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + $context = GeneralUtility::makeInstance(Context::class); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context); + $languageId = $context->getPropertyFromAspect('language', 'contentId', 0); + $table = $this->getItemTableName(); + + // If language handling is defined for item table, add language condition + $schemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class); + if ($schemaFactory->has($table) && $schemaFactory->get($table)->isLanguageAware()) { + $schema = $schemaFactory->get($table); + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + // Consider default or "all" language + $languageField = sprintf( + '%s.%s', + $table, + $languageCapability->getLanguageField()->getName() + ); + + $languageConstraint = $queryBuilder->expr()->in( + $languageField, + $queryBuilder->createNamedParameter([0, -1], Connection::PARAM_INT_ARRAY) + ); + + // If not in default language, also consider items in current language with no original + if ($languageId > 0) { + $transOrigPointerField = sprintf( + '%s.%s', + $table, + $languageCapability->getTranslationOriginPointerField()->getName() + ); + + $languageConstraint = $queryBuilder->expr()->or( + $languageConstraint, + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq( + $languageField, + $queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT) + ), + $queryBuilder->expr()->eq( + $transOrigPointerField, + $queryBuilder->createNamedParameter(0, Connection::PARAM_INT) + ) + ) + ); + } + + $queryBuilder->andWhere($languageConstraint); + } + + // Get the related records from the database + $result = $queryBuilder->executeQuery(); + + while ($record = $result->fetchAssociative()) { + // Overlay the record for workspaces + $pageRepository->versionOL($table, $record); + + // Overlay the record for translations + if (is_array($record)) { + $record = $pageRepository->getLanguageOverlay($table, $record); + } + + // Record may have been unset during the overlay process + if (is_array($record)) { + $relatedRecords[] = $record; + } + } + + return $relatedRecords; + } +} diff --git a/Classes/Content/ContentAreaResolver.php b/Classes/Content/ContentAreaResolver.php new file mode 100644 index 0000000..c17e0a1 --- /dev/null +++ b/Classes/Content/ContentAreaResolver.php @@ -0,0 +1,107 @@ +getBackendLayout(); + $fullStructure = $layout->getStructure()['__config']; + $contentAreas = $this->collectContentAreasRecursive($fullStructure, $layout); + $event->setContentAreas($contentAreas); + } + + /** + * Find all arrays recursively from where one of the columns within the array is called "colPos" + * + * @param array|array{ + * colPos: string|int, + * name?: string|int, + * slideMode?: string, + * identifier?: string|int, + * allowedContentTypes?: string, + * disallowedContentTypes?: string, + * } $structure + * @param array $contentAreas + * @return array + */ + private function collectContentAreasRecursive(array $structure, BackendLayout $layout, array $contentAreas = []): array + { + if (isset($structure['colPos'])) { + $name = (string)($structure['name'] ?? ''); + $colPos = (int)$structure['colPos']; + $slideMode = ContentSlideMode::tryFrom($structure['slideMode'] ?? null); + $allowedContentTypes = GeneralUtility::trimExplode(',', $structure['allowedContentTypes'] ?? '', true); + $disallowedContentTypes = GeneralUtility::trimExplode(',', $structure['disallowedContentTypes'] ?? '', true); + $identifier = (string)($structure['identifier'] ?? ''); + if ($identifier === '') { + throw new \RuntimeException( + 'No identifier given for column with colPos "' . $colPos . '" in page layout "' . $layout->getIdentifier() . '". Setting an identifier is mandatory.', + 1780173420 + ); + } + $contentAreas[$identifier] = new ContentAreaClosure( + function (ServerRequestInterface $request) use ($identifier, $name, $colPos, $slideMode, $allowedContentTypes, $disallowedContentTypes, $structure): ContentArea { + $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $cObj->setRequest($request); + $records = $this->recordCollector->collect( + 'tt_content', + [ + 'where' => '{#colPos}=' . $colPos, + 'orderBy' => 'sorting', + ], + $slideMode, + $cObj + ); + return new ContentArea( + identifier: $identifier, + name: $name, + colPos: $colPos, + slideMode: $slideMode, + allowedContentTypes: $allowedContentTypes, + disallowedContentTypes: $disallowedContentTypes, + configuration: $structure, + records: $records + ); + } + ); + // Content Areas cannot be nested. Bubble up and find further areas next to this. + return $contentAreas; + } + foreach ($structure as $value) { + if (is_array($value)) { + $contentAreas = $this->collectContentAreasRecursive($value, $layout, $contentAreas); + } + } + return $contentAreas; + } +} diff --git a/Classes/Content/RecordCollector.php b/Classes/Content/RecordCollector.php new file mode 100644 index 0000000..ef1c578 --- /dev/null +++ b/Classes/Content/RecordCollector.php @@ -0,0 +1,89 @@ +getRecords($table, $select); + $recordsOnPid = array_map( + fn(array $record): RecordInterface => $this->recordFactory->createResolvedRecordFromDatabaseRow($table, $record, null, $recordIdentityMap), + $recordsOnPid + ); + + if ($slideCollectReverse) { + $totalRecords = array_merge($totalRecords, $recordsOnPid); + } else { + $totalRecords = array_merge($recordsOnPid, $totalRecords); + } + if ($slide) { + $select['pidInList'] = $contentObjectRenderer->getSlidePids($select['pidInList'] ?? '', $select['pidInList.'] ?? []); + if (isset($select['pidInList.'])) { + unset($select['pidInList.']); + } + $again = $select['pidInList'] !== ''; + } + } while ($again && $slide && ($recordsOnPid === [] || $collect)); + + foreach ($totalRecords as $record) { + $contentObjectRenderer->lastChanged($record); + } + return $totalRecords; + } +} diff --git a/Classes/ContentObject/AbstractContentObject.php b/Classes/ContentObject/AbstractContentObject.php new file mode 100644 index 0000000..90db863 --- /dev/null +++ b/Classes/ContentObject/AbstractContentObject.php @@ -0,0 +1,71 @@ +cObj; + } + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } + + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + // Provide the ContentObjectRenderer to the request as well, for code + // that only passes the request to more underlying layers, like Extbase does. + // Also makes sure the request in a Fluid RenderingContext also has the current + // content object available. + $this->request = $this->request->withAttribute('currentContentObject', $cObj); + } + + protected function getPageRepository(): PageRepository + { + return GeneralUtility::makeInstance(PageRepository::class); + } +} diff --git a/Classes/ContentObject/CaseContentObject.php b/Classes/ContentObject/CaseContentObject.php new file mode 100644 index 0000000..c1fe375 --- /dev/null +++ b/Classes/ContentObject/CaseContentObject.php @@ -0,0 +1,52 @@ +cObj->checkIf($conf['if.'])) { + return ''; + } + + $setCurrent = $this->cObj->stdWrapValue('setCurrent', $conf); + if ($setCurrent) { + $this->cObj->data[$this->cObj->currentValKey] = $setCurrent; + } + $key = $this->cObj->stdWrapValue('key', $conf, null); + $key = isset($conf[$key]) && (string)$conf[$key] !== '' ? $key : 'default'; + // If no "default" property is available, then an empty string is returned + if ($key === 'default' && !isset($conf['default'])) { + $theValue = ''; + } else { + $theValue = $this->cObj->cObjGetSingle($conf[$key], $conf[$key . '.'] ?? [], $key); + } + if (isset($conf['stdWrap.'])) { + $theValue = $this->cObj->stdWrap($theValue, $conf['stdWrap.']); + } + return $theValue; + } +} diff --git a/Classes/ContentObject/ContentContentObject.php b/Classes/ContentObject/ContentContentObject.php new file mode 100644 index 0000000..6a1c605 --- /dev/null +++ b/Classes/ContentObject/ContentContentObject.php @@ -0,0 +1,135 @@ +cObj->checkIf($conf['if.'])) { + return ''; + } + + $theValue = ''; + $conf['table'] = trim((string)$this->cObj->stdWrapValue('table', $conf)); + $conf['select.'] = !empty($conf['select.']) ? $conf['select.'] : []; + $renderObjName = ($conf['renderObj'] ?? false) ? $conf['renderObj'] : '<' . $conf['table']; + $renderObjKey = ($conf['renderObj'] ?? false) ? 'renderObj' : ''; + $renderObjConf = $conf['renderObj.'] ?? []; + $slide = (int)$this->cObj->stdWrapValue('slide', $conf); + if (!$slide) { + $slide = 0; + } + $slideCollect = (int)$this->cObj->stdWrapValue('collect', $conf['slide.'] ?? []); + if (!$slideCollect) { + $slideCollect = 0; + } + $slideCollectReverse = (bool)$this->cObj->stdWrapValue('collectReverse', $conf['slide.'] ?? []); + $slideCollectFuzzy = (bool)$this->cObj->stdWrapValue('collectFuzzy', $conf['slide.'] ?? []); + if (!$slideCollect) { + $slideCollectFuzzy = true; + } + $again = false; + $tmpValue = ''; + + do { + $cobjValue = ''; + $modifyRecordsEvent = $this->eventDispatcher->dispatch( + new ModifyRecordsAfterFetchingContentEvent( + $this->cObj->getRecords($conf['table'], $conf['select.']), + $theValue, + $slide, + $slideCollect, + $slideCollectReverse, + $slideCollectFuzzy, + $conf + ) + ); + + $records = $modifyRecordsEvent->getRecords(); + $theValue = $modifyRecordsEvent->getFinalContent(); + $slide = $modifyRecordsEvent->getSlide(); + $slideCollect = $modifyRecordsEvent->getSlideCollect(); + $slideCollectReverse = $modifyRecordsEvent->getSlideCollectReverse(); + $slideCollectFuzzy = $modifyRecordsEvent->getSlideCollectFuzzy(); + $conf = $modifyRecordsEvent->getConfiguration(); + + if ($records !== []) { + $this->timeTracker->setTSlogMessage('NUMROWS: ' . count($records)); + + $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $cObj->setParent($this->cObj->data, $this->cObj->currentRecord); + + foreach ($records as $row) { + $this->cObj->lastChanged($row['tstamp'] ?? 0); + $cObj->setRequest($this->request); + $cObj->start($row, $conf['table']); + $tmpValue = $cObj->cObjGetSingle($renderObjName, $renderObjConf, $renderObjKey); + $cobjValue .= $tmpValue; + } + } + if ($slideCollectReverse) { + $theValue = $cobjValue . $theValue; + } else { + $theValue .= $cobjValue; + } + if ($slideCollect > 0) { + $slideCollect--; + } + if ($slide) { + if ($slide > 0) { + $slide--; + } + $conf['select.']['pidInList'] = $this->cObj->getSlidePids( + $conf['select.']['pidInList'] ?? '', + $conf['select.']['pidInList.'] ?? [], + ); + if (isset($conf['select.']['pidInList.'])) { + unset($conf['select.']['pidInList.']); + } + $again = (string)$conf['select.']['pidInList'] !== ''; + } + } while ($again && $slide && ((string)$tmpValue === '' && $slideCollectFuzzy || $slideCollect)); + + $wrap = $this->cObj->stdWrapValue('wrap', $conf); + if ($wrap) { + $theValue = $this->cObj->wrap($theValue, $wrap); + } + if (isset($conf['stdWrap.'])) { + $theValue = $this->cObj->stdWrap($theValue, $conf['stdWrap.']); + } + return $theValue; + } +} diff --git a/Classes/ContentObject/ContentDataProcessor.php b/Classes/ContentObject/ContentDataProcessor.php new file mode 100644 index 0000000..16614aa --- /dev/null +++ b/Classes/ContentObject/ContentDataProcessor.php @@ -0,0 +1,101 @@ +dataProcessorRegistry->getDataProcessor($processors[$key]) + ?? $this->getDataProcessor($processors[$key]); + $processorConfiguration = $processors[$key . '.'] ?? []; + $variables = $dataProcessor->process( + $cObject, + $configuration, + $processorConfiguration, + $variables + ); + } + } + + return $variables; + } + + private function getDataProcessor(string $serviceName): DataProcessorInterface + { + if (!$this->container->has($serviceName)) { + // assume serviceName is the class name if it is not available in the container + return $this->instantiateDataProcessor($serviceName); + } + + $dataProcessor = $this->container->get($serviceName); + if (!$dataProcessor instanceof DataProcessorInterface) { + throw new \UnexpectedValueException( + 'Processor with service name "' . $serviceName . '" ' + . 'must implement interface "' . DataProcessorInterface::class . '"', + 1635927108 + ); + } + return $dataProcessor; + } + + private function instantiateDataProcessor(string $className): DataProcessorInterface + { + if (!class_exists($className)) { + throw new \UnexpectedValueException('Processor class or service name "' . $className . '" does not exist!', 1427455378); + } + + if (!in_array(DataProcessorInterface::class, class_implements($className) ?: [], true)) { + throw new \UnexpectedValueException( + 'Processor with class name "' . $className . '" ' + . 'must implement interface "' . DataProcessorInterface::class . '"', + 1427455377 + ); + } + return GeneralUtility::makeInstance($className); + } +} diff --git a/Classes/ContentObject/ContentObjectArrayContentObject.php b/Classes/ContentObject/ContentObjectArrayContentObject.php new file mode 100644 index 0000000..220731a --- /dev/null +++ b/Classes/ContentObject/ContentObjectArrayContentObject.php @@ -0,0 +1,61 @@ +getTimeTracker()->setTSlogMessage('No elements in this content object array (COBJ_ARRAY, COA).', LogLevel::WARNING); + return ''; + } + if (!empty($conf['if.']) && !$this->cObj->checkIf($conf['if.'])) { + return ''; + } + + $content = $this->cObj->cObjGet($conf); + $wrap = $this->cObj->stdWrapValue('wrap', $conf); + if ($wrap) { + $content = $this->cObj->wrap($content, $wrap); + } + if (isset($conf['stdWrap.'])) { + $content = $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + return $content; + } + + /** + * @return TimeTracker + */ + protected function getTimeTracker() + { + return GeneralUtility::makeInstance(TimeTracker::class); + } +} diff --git a/Classes/ContentObject/ContentObjectArrayInternalContentObject.php b/Classes/ContentObject/ContentObjectArrayInternalContentObject.php new file mode 100644 index 0000000..26d7252 --- /dev/null +++ b/Classes/ContentObject/ContentObjectArrayInternalContentObject.php @@ -0,0 +1,55 @@ +getTimeTracker()->setTSlogMessage('No elements in this content object array (COA_INT).', LogLevel::WARNING); + return ''; + } + $substKey = 'INT_SCRIPT.' . md5(StringUtility::getUniqueId()); + $pageParts = $this->request->getAttribute('frontend.page.parts'); + $pageParts->addNotCachedContentElement([ + 'substKey' => $substKey, + 'conf' => $conf, + 'cObjData' => serialize($this->cObj->getState()), + 'type' => 'COA', + ]); + return ''; + } + + protected function getTimeTracker(): TimeTracker + { + return GeneralUtility::makeInstance(TimeTracker::class); + } +} diff --git a/Classes/ContentObject/ContentObjectFactory.php b/Classes/ContentObject/ContentObjectFactory.php new file mode 100644 index 0000000..d65ae37 --- /dev/null +++ b/Classes/ContentObject/ContentObjectFactory.php @@ -0,0 +1,48 @@ +contentObjectLocator->has($name)) { + return null; + } + + $contentObject = $this->contentObjectLocator->get($name); + if (!($contentObject instanceof AbstractContentObject)) { + throw new ContentRenderingException(sprintf('Registered content object class name "%s" must be an instance of AbstractContentObject, but is not!', get_class($contentObject)), 1422564295); + } + + $contentObject->setRequest($request); + $contentObject->setContentObjectRenderer($contentObjectRenderer); + + return $contentObject; + } +} diff --git a/Classes/ContentObject/ContentObjectGetPublicUrlForFileHookInterface.php b/Classes/ContentObject/ContentObjectGetPublicUrlForFileHookInterface.php new file mode 100644 index 0000000..00bbec7 --- /dev/null +++ b/Classes/ContentObject/ContentObjectGetPublicUrlForFileHookInterface.php @@ -0,0 +1,34 @@ + 'event', + 'cacheRead' => 'hook', // this is a placeholder for checking if the content is available in cache + 'setContentToCurrent' => 'boolean', + 'setContentToCurrent.' => 'array', + 'addPageCacheTags' => 'string', + 'addPageCacheTags.' => 'array', + 'setCurrent' => 'string', + 'setCurrent.' => 'array', + 'lang.' => 'array', + 'data' => 'getText', + 'data.' => 'array', + 'field' => 'fieldName', + 'field.' => 'array', + 'current' => 'boolean', + 'current.' => 'array', + 'cObject' => 'cObject', + 'cObject.' => 'array', + 'numRows.' => 'array', + 'preUserFunc' => 'functionName', + AfterStdWrapFunctionsInitializedEvent::class => 'event', + 'override' => 'string', + 'override.' => 'array', + 'preIfEmptyListNum' => 'listNum', + 'preIfEmptyListNum.' => 'array', + 'ifNull' => 'string', + 'ifNull.' => 'array', + 'ifEmpty' => 'string', + 'ifEmpty.' => 'array', + 'ifBlank' => 'string', + 'ifBlank.' => 'array', + 'listNum' => 'listNum', + 'listNum.' => 'array', + 'trim' => 'boolean', + 'trim.' => 'array', + 'strPad.' => 'array', + 'stdWrap' => 'stdWrap', + 'stdWrap.' => 'array', + BeforeStdWrapFunctionsExecutedEvent::class => 'event', + 'required' => 'boolean', + 'required.' => 'array', + 'if.' => 'array', + 'fieldRequired' => 'fieldName', + 'fieldRequired.' => 'array', + 'csConv' => 'string', + 'csConv.' => 'array', + 'parseFunc' => 'objectpath', + 'parseFunc.' => 'array', + 'HTMLparser' => 'boolean', + 'HTMLparser.' => 'array', + 'split.' => 'array', + 'replacement.' => 'array', + 'prioriCalc' => 'boolean', + 'prioriCalc.' => 'array', + 'char' => 'integer', + 'char.' => 'array', + 'intval' => 'boolean', + 'intval.' => 'array', + 'hash' => 'string', + 'hash.' => 'array', + 'round' => 'boolean', + 'round.' => 'array', + 'numberFormat.' => 'array', + 'expandList' => 'boolean', + 'expandList.' => 'array', + 'date' => 'dateconf', + 'date.' => 'array', + 'strtotime' => 'strtotimeconf', + 'strtotime.' => 'array', + 'strftime' => 'strftimeconf', + 'strftime.' => 'array', + 'formattedDate' => 'formattedDateconf', + 'formattedDate.' => 'array', + 'age' => 'boolean', + 'age.' => 'array', + 'case' => 'case', + 'case.' => 'array', + 'bytes' => 'boolean', + 'bytes.' => 'array', + 'substring' => 'parameters', + 'substring.' => 'array', + 'cropHTML' => 'crop', + 'cropHTML.' => 'array', + 'stripHtml' => 'boolean', + 'stripHtml.' => 'array', + 'crop' => 'crop', + 'crop.' => 'array', + 'rawUrlEncode' => 'boolean', + 'rawUrlEncode.' => 'array', + 'htmlSpecialChars' => 'boolean', + 'htmlSpecialChars.' => 'array', + 'encodeForJavaScriptValue' => 'boolean', + 'encodeForJavaScriptValue.' => 'array', + 'doubleBrTag' => 'string', + 'doubleBrTag.' => 'array', + 'br' => 'boolean', + 'br.' => 'array', + 'brTag' => 'string', + 'brTag.' => 'array', + 'encapsLines.' => 'array', + 'keywords' => 'boolean', + 'keywords.' => 'array', + 'innerWrap' => 'wrap', + 'innerWrap.' => 'array', + 'innerWrap2' => 'wrap', + 'innerWrap2.' => 'array', + 'preCObject' => 'cObject', + 'preCObject.' => 'array', + 'postCObject' => 'cObject', + 'postCObject.' => 'array', + 'wrapAlign' => 'align', + 'wrapAlign.' => 'array', + 'typolink.' => 'array', + 'wrap' => 'wrap', + 'wrap.' => 'array', + 'noTrimWrap' => 'wrap', + 'noTrimWrap.' => 'array', + 'wrap2' => 'wrap', + 'wrap2.' => 'array', + 'dataWrap' => 'dataWrap', + 'dataWrap.' => 'array', + 'prepend' => 'cObject', + 'prepend.' => 'array', + 'append' => 'cObject', + 'append.' => 'array', + 'wrap3' => 'wrap', + 'wrap3.' => 'array', + 'orderedStdWrap' => 'stdWrap', + 'orderedStdWrap.' => 'array', + 'outerWrap' => 'wrap', + 'outerWrap.' => 'array', + 'insertData' => 'boolean', + 'insertData.' => 'array', + 'postUserFunc' => 'functionName', + 'postUserFuncInt' => 'functionName', + 'prefixComment' => 'string', + 'prefixComment.' => 'array', + 'htmlSanitize' => 'boolean', + 'htmlSanitize.' => 'array', + 'cacheStore' => 'hook', // this is a placeholder for storing the content in cache + AfterStdWrapFunctionsExecutedEvent::class => 'event', + 'debug' => 'boolean', + 'debug.' => 'array', + 'debugFunc' => 'boolean', + 'debugFunc.' => 'array', + 'debugData' => 'boolean', + 'debugData.' => 'array', + ]; + + /** + * Loaded with the current data-record. + * + * If the instance of this class is used to render records from the database those records are found in this array. + * The function stdWrap has TypoScript properties that fetch field-data from this array. + */ + public array $data = []; + + protected string $table = ''; + + /** + * Used by the parseFunc function and is loaded with tag-parameters when parsing tags. + */ + public array $parameters = []; + + public string $currentValKey = 'currentValue_kidjls9dksoje'; + + /** + * This is set to the [table]:[uid] of the record delivered in the $data-array, if the cObjects CONTENT or RECORD is in operation. + */ + public string $currentRecord = ''; + + /** + * @internal + */ + protected array $parentRecord = []; + + /** + * Current file object during iterations over files. + */ + protected File|FileReference|Folder|FileInterface|FolderInterface|null $currentFile = null; + + /** + * Set to true by doConvertToUserIntObject() if USER object wants to become USER_INT. + */ + public bool $doConvertToUserIntObject = false; + + /** + * Indicates current object type. Can hold one of OBJECTTYPE_ constants or false. + * The value is set and reset inside USER() function. Any time outside of + * USER() it is false. + */ + protected int|false $userObjectType = false; + + /** + * Per-nesting-level stop flags for stdWrap processing. Keyed by $stdWrapNestingLevel. + * Set to true to abort remaining stdWrap properties at that level (e.g. when "if", "required" + * or "ifEmpty" conditions are not met). Isolates stop conditions so an inner stdWrap call + * cannot accidentally abort an outer one. + */ + protected array $stopRendering = []; + + /** + * Current stdWrap nesting depth, used as the key into $stopRendering. + */ + protected int $stdWrapNestingLevel = 0; + + private ?ServerRequestInterface $request = null; + + public function __construct( + private readonly ContainerInterface $container, + private readonly Context $context, + private readonly LoggerInterface $logger, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly ConnectionPool $connectionPool, + private readonly ResourceFactory $resourceFactory, + #[Autowire(expression: 'service("features").isFeatureEnabled("frontend.cache.autoTagging")')] + private readonly bool $autoTagging, + private readonly CacheLifetimeCalculator $cacheLifetimeCalculator, + #[Autowire(service: 'cache.hash')] + private readonly FrontendInterface $cacheHash, + private readonly HashService $hashService, + private readonly FrontendUrlPrefix $frontendUrlPrefix, + private readonly Locales $locales, + private readonly TypoScriptService $typoScriptService, + private readonly SanitizerBuilderFactory $sanitizerBuilderFactory, + private readonly TextCropper $textCropper, + private readonly HtmlCropper $htmlCropper, + private readonly LinkVarsCalculator $linkVarsCalculator, + private readonly SystemResourceFactory $systemResourceFactory, + private readonly SystemResourcePublisherInterface $systemResourcePublisher, + private readonly PageLayoutResolver $pageLayoutResolver, + private readonly LanguageServiceFactory $languageServiceFactory, + private readonly FlexFormTools $flexFormTools, + private readonly LinkFactory $linkFactory, + // TimeTracker is a stateful singleton, we would usually not inject this. This + // instance however is set up early in middlewares and carried around with its accumulating + // state throughout entire FE rendering. As such, it is ok to get it injected here, + // similar to PageRenderer and Context, which are designed in a similar way. + private readonly TimeTracker $timeTracker, + private readonly TcaSchemaFactory $tcaSchemaFactory, + ) {} + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } + + /** + * Used strictly internally to preserve state when dealing with non-cached elements. + * + * @internal These are not the methods you are looking for. + */ + public function getState(): array + { + // Request is of course NOT returned! + $state = [ + 'data' => $this->data, + 'table' => $this->table, + 'parameters' => $this->parameters, + 'currentValKey' => $this->currentValKey, + 'currentRecord' => $this->currentRecord, + 'parentRecord' => $this->parentRecord, + 'doConvertToUserIntObject' => $this->doConvertToUserIntObject, + 'userObjectType' => $this->userObjectType, + 'stopRendering' => $this->stopRendering, + 'stdWrapNestingLevel' => $this->stdWrapNestingLevel, + 'currentFile' => null, + ]; + if ($this->currentFile instanceof FileReference) { + $state['currentFile'] = 'FileReference:' . $this->currentFile->getUid(); + } elseif ($this->currentFile instanceof File) { + $state['currentFile'] = 'File:' . $this->currentFile->getIdentifier(); + } + return $state; + } + + /** + * Counterpart of getState() + * + * @internal + */ + public function updateState(array $state): void + { + $this->data = $state['data']; + $this->table = $state['table']; + $this->parameters = $state['parameters']; + $this->currentValKey = $state['currentValKey']; + $this->currentRecord = $state['currentRecord']; + $this->parentRecord = $state['parentRecord']; + $this->doConvertToUserIntObject = $state['doConvertToUserIntObject']; + $this->userObjectType = $state['userObjectType']; + $this->stopRendering = $state['stopRendering']; + $this->stdWrapNestingLevel = $state['stdWrapNestingLevel']; + $this->currentFile = null; + if (is_string($state['currentFile'])) { + [$objectType, $identifier] = explode(':', $state['currentFile'], 2); + try { + if ($objectType === 'File') { + $this->currentFile = $this->resourceFactory->retrieveFileOrFolderObject($identifier); + } elseif ($objectType === 'FileReference') { + $this->currentFile = $this->resourceFactory->getFileReferenceObject((int)$identifier); + } + } catch (ResourceDoesNotExistException $e) { + // Keep $this->currentFile null + } + } + } + + /** + * After making an instance of the class, call this function and pass to it a + * database record and the tablename from where the record is from. That will + * then become the "current" record loaded into memory and accessed by the .fields + * property found in eg. stdWrap. + * + * @param string $table The table that the data record is from. + */ + public function start(array $data, string $table = ''): void + { + $this->data = $data; + $this->table = $table; + $this->currentRecord = $table !== '' ? $table . ':' . ($this->data['uid'] ?? '') : ''; + $this->parameters = []; + $this->eventDispatcher->dispatch(new AfterContentObjectRendererInitializedEvent($this)); + if ($this->currentRecord !== '' && $this->autoTagging && $this->table !== 'pages') { + // Page lifetime for the requested page is calculated in RequestHandler, taking + // cache_period and TypoScript into account. + // When start() is called here, it can be the requested page record, which is handled + // already, OR it is a page record *used* on this page, for instance from a menu rendering. + // In the latter case, we do *not* want to reduce the lifetime of the rendered page down + // to the cache_period of that content related page record. We can thus skip 'pages' records + // here altogether. + $lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForRow($this->table, $this->data); + $cacheTags = [ + sprintf('%s_%s', $this->table, ($this->data['uid'] ?? 0)), + ]; + if ((int)($this->data['_LOCALIZED_UID'] ?? 0) > 0) { + $cacheTags[] = sprintf('%s_%s', $this->table, (int)$this->data['_LOCALIZED_UID']); + } + $this->getRequest()->getAttribute('frontend.cache.collector')?->addCacheTags( + ...array_map( + static fn(string $cacheTag) => new CacheTag($cacheTag, $lifetime), + $cacheTags, + ), + ); + } + } + + /** + * Returns the current table + * + * @return string + */ + public function getCurrentTable() + { + return $this->table; + } + + /** + * @param array $data The record array + * @param string $currentRecord Format: "table:uid" + * @internal + */ + public function setParent($data, string $currentRecord): void + { + $this->parentRecord = [ + 'data' => $data, + 'currentRecord' => $currentRecord, + ]; + } + + /** + * Returns the "current" value. + * The "current" value is just an internal variable that can be used by functions to pass a single value on to another + * function later in the TypoScript processing. It's like "load accumulator" in the good old C64 days... basically a "register" + * you can use as you like. The TSref will tell if functions are setting this value before calling some other object so that + * you know if it holds any special information. + * + * @return mixed The "current" value + */ + public function getCurrentVal() + { + return $this->data[$this->currentValKey] ?? null; + } + + /** + * Sets the "current" value. + * + * @param mixed $value The variable that you want to set as "current + */ + public function setCurrentVal($value): void + { + $this->data[$this->currentValKey] = $value; + } + + /** + * Rendering of a "numerical array" of cObjects from TypoScript + * Will call ->cObjGetSingle() for each cObject found and accumulate the output. + * + * @param mixed $setup array with cObjects as values. + * @param string $addKey A prefix for the debugging information + * @return string Rendered output from the cObjects in the array. + * @see cObjGetSingle() + */ + public function cObjGet($setup, $addKey = ''): string + { + if (!is_array($setup)) { + return ''; + } + return implode('', $this->cObjGetSeparated($setup, $addKey)); + } + + /** + * Rendering of a "numerical array" of cObjects from TypoScript + * Will call ->cObjGetSingle() for each cObject found. + * + * @return list + */ + public function cObjGetSeparated(?array $setup, string $addKey = ''): array + { + if ($setup === null || $setup === []) { + return []; + } + $sKeyArray = ArrayUtility::filterAndSortByNumericKeys($setup); + $contentObjects = []; + foreach ($sKeyArray as $theKey) { + $theValue = $setup[$theKey]; + if ((int)$theKey && !str_contains($theKey, '.')) { + $conf = $setup[$theKey . '.'] ?? []; + $contentObjects[] = $this->cObjGetSingle($theValue, $conf, $addKey . $theKey); + } + } + return $contentObjects; + } + + /** + * Renders a content object + * + * @param string $name The content object name, eg. "TEXT" or "USER" or "IMAGE" + * @param mixed $conf The array with TypoScript properties for the content object + * @param string $TSkey A string label used for the internal debugging tracking. + * @return string cObject output + * @throws \UnexpectedValueException + */ + public function cObjGetSingle(string $name, $conf, $TSkey = '__') + { + $name = trim($name); + if ($this->timeTracker->LR) { + $this->timeTracker->push($TSkey, $name); + } + $fullConfigArray = [ + 'tempKey' => $name, + 'tempKey.' => is_array($conf) ? $conf : [], + ]; + // Resolve '=<' operator if needed + $fullConfigArray = $this->mergeTSRef($fullConfigArray, 'tempKey'); + $contentObject = $this->getContentObject($fullConfigArray['tempKey']); + $content = ''; + if ($contentObject) { + $content = $this->render($contentObject, $fullConfigArray['tempKey.']); + } + if ($this->timeTracker->LR) { + $this->timeTracker->pull($content); + } + return $content; + } + + /** + * Returns a new content object of type $name. + * + * @throws ContentRenderingException + */ + public function getContentObject(string $name): ?AbstractContentObject + { + $contentObjectFactory = $this->container->get(ContentObjectFactory::class); + return $contentObjectFactory->getContentObject($name, $this->getRequest(), $this); + } + + /** + * Renders a content object by taking exception and cache handling + * into consideration + * + * @param AbstractContentObject $contentObject Content object instance + * @param array $configuration Array of TypoScript properties + * + * @throws ContentRenderingException + * @throws \Exception + */ + public function render(AbstractContentObject $contentObject, $configuration = []): string + { + $content = ''; + + // Evaluate possible cache and return + $cacheConfiguration = $configuration['cache.'] ?? null; + if ($cacheConfiguration !== null) { + unset($configuration['cache.']); + $cache = $this->getFromCache($cacheConfiguration); + if ($cache !== false) { + return $cache; + } + } + + // Render content + try { + $content .= $contentObject->render($configuration); + } catch (ContentRenderingException $exception) { + // Content rendering Exceptions indicate a critical problem which should not be + // caught e.g. when something went wrong with Exception handling itself + throw $exception; + } catch (\Throwable $exception) { + $exceptionHandler = $this->createExceptionHandler($configuration); + if ($exceptionHandler === null) { + throw $exception; + } + // Ensure that the exception handler receives an \Exception instance, + // which is required by the \ExceptionHandlerInterface. + if (!$exception instanceof \Exception) { + $exception = new \Exception($exception->getMessage(), 1698347363, $exception); + } + $content = $exceptionHandler->handle($exception, $contentObject, $configuration); + } + + // Store cache + if ($cacheConfiguration !== null && $this->getRequest()->getAttribute('frontend.cache.instruction')->isCachingAllowed()) { + $key = $this->calculateCacheKey($cacheConfiguration); + if (!empty($key)) { + $tags = $this->calculateCacheTags($cacheConfiguration); + $cacheLifetime = $this->calculateCacheLifetime($cacheConfiguration); + $cachedData = [ + 'content' => $content, + 'cacheTags' => $tags, + ]; + $this->cacheHash->set($key, $cachedData, $tags, $cacheLifetime); + + // If no tags are given, we restrict the maximum lifetime of the cache to the lifetime of the cache entry. + if ($tags === []) { + $this->getRequest()->getAttribute('frontend.cache.collector')->restrictMaximumLifetime($cacheLifetime); + } + + $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags( + ...array_map(fn(string $tag) => new CacheTag($tag, $cacheLifetime), $tags) + ); + } + } + + return $content; + } + + /** + * Creates the content object exception handler from local content object configuration + * or, from global configuration if not explicitly disabled in local configuration + * + * @throws ContentRenderingException + */ + protected function createExceptionHandler(array $configuration): ?ExceptionHandlerInterface + { + $exceptionHandler = null; + $exceptionHandlerClassName = $this->determineExceptionHandlerClassName($configuration); + if (!empty($exceptionHandlerClassName)) { + $exceptionHandler = GeneralUtility::makeInstance($exceptionHandlerClassName); + if (!$exceptionHandler instanceof ExceptionHandlerInterface) { + throw new ContentRenderingException('An exception handler was configured but the class does not exist or does not implement the ExceptionHandlerInterface', 1403653369); + } + $exceptionHandler->setConfiguration($this->mergeExceptionHandlerConfiguration($configuration)); + } + return $exceptionHandler; + } + + /** + * Determine exception handler class name from global and content object configuration + */ + protected function determineExceptionHandlerClassName(array $configuration): ?string + { + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray(); + $exceptionHandlerClassName = null; + if (!isset($typoScriptConfigArray['contentObjectExceptionHandler'])) { + if (Environment::getContext()->isProduction()) { + $exceptionHandlerClassName = '1'; + } + } else { + $exceptionHandlerClassName = $typoScriptConfigArray['contentObjectExceptionHandler']; + } + if (isset($configuration['exceptionHandler'])) { + $exceptionHandlerClassName = $configuration['exceptionHandler']; + } + if ($exceptionHandlerClassName === '1') { + $exceptionHandlerClassName = ProductionExceptionHandler::class; + } + return $exceptionHandlerClassName; + } + + /** + * Merges global exception handler configuration with the one from the content object + * and returns the merged exception handler configuration + */ + protected function mergeExceptionHandlerConfiguration(array $configuration): array + { + $exceptionHandlerConfiguration = []; + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray(); + if (!empty($typoScriptConfigArray['contentObjectExceptionHandler.'])) { + $exceptionHandlerConfiguration = $typoScriptConfigArray['contentObjectExceptionHandler.']; + } + if (!empty($configuration['exceptionHandler.'])) { + $exceptionHandlerConfiguration = array_replace_recursive($exceptionHandlerConfiguration, $configuration['exceptionHandler.']); + } + return $exceptionHandlerConfiguration; + } + + /** + * Retrieves a type of object called as USER or USER_INT. Object can detect their + * type by using this call. It returns OBJECTTYPE_USER_INT or OBJECTTYPE_USER depending on the + * current object execution. In all other cases it will return FALSE to indicate + * a call out of context. + * + * @return int|false One of OBJECTTYPE_ class constants or false + */ + public function getUserObjectType(): int|false + { + return $this->userObjectType; + } + + /** + * Sets the user object type + */ + public function setUserObjectType(int|false $userObjectType): void + { + $this->userObjectType = $userObjectType; + } + + /** + * Requests the current USER object to be converted to USER_INT. + */ + public function convertToUserIntObject(): void + { + if ($this->userObjectType !== self::OBJECTTYPE_USER) { + $this->timeTracker->setTSlogMessage(self::class . '::convertToUserIntObject() is called in the wrong context or for the wrong object type', LogLevel::WARNING); + } else { + $this->doConvertToUserIntObject = true; + } + } + + /** + * Returns all parents of the given PID (Page UID) list + * + * @param string $pidList A list of page Content-Element PIDs (Page UIDs) / stdWrap + * @param array $pidConf stdWrap array for the list + * @return string A list of PIDs + * @internal + */ + public function getSlidePids($pidList, array $pidConf = []): string + { + $pidList = $pidConf !== [] ? trim((string)$this->stdWrap($pidList, $pidConf)) : trim($pidList); + if ($pidList === '') { + $pidList = 'this'; + } + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + if (trim($pidList)) { + $contentPid = $this->getRequest()->getAttribute('frontend.page.information')->getContentFromPid(); + $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $pidList)); + $listArr = $this->checkPidArray($listArr); + $pidList = []; + foreach ($listArr as $uid) { + $page = $pageRepository->getPage((int)$uid); + if (!$page['is_siteroot']) { + $pidList[] = $page['pid']; + } + } + return implode(',', $pidList); + } + return ''; + } + + /** + * Wraps the input string in link-tags that opens the image in a new window. + * + * @param string $string String to wrap, probably an tag + * @param string|File|FileReference $imageFile The original image file + * @param array $conf TypoScript properties for the "imageLinkWrap" function + * @return string The input string, $string, wrapped as configured. + * @internal This method should be used within TYPO3 Core only + */ + public function imageLinkWrap($string, $imageFile, $conf) + { + $string = (string)$string; + $enable = $this->stdWrapValue('enable', $conf); + if (!$enable) { + return $string; + } + $content = (string)$this->typoLink($string, $conf['typolink.'] ?? []); + if (isset($conf['file.']) && is_scalar($imageFile)) { + $imageFile = $this->stdWrap((string)$imageFile, $conf['file.']); + } + + if ($imageFile instanceof File) { + $file = $imageFile; + } elseif ($imageFile instanceof FileReference) { + $file = $imageFile->getOriginalFile(); + } elseif (MathUtility::canBeInterpretedAsInteger($imageFile)) { + $file = $this->resourceFactory->getFileObject((int)$imageFile); + } else { + $file = $this->resourceFactory->getFileObjectFromCombinedIdentifier($imageFile); + } + + // Create imageFileLink if not created with typolink + if ($content === $string && $file !== null) { + $parameterNames = ['width', 'height', 'effects', 'bodyTag', 'title', 'wrap', 'crop']; + $parameters = []; + $sample = $this->stdWrapValue('sample', $conf); + if ($sample) { + $parameters['sample'] = 1; + } + foreach ($parameterNames as $parameterName) { + if (isset($conf[$parameterName . '.'])) { + $conf[$parameterName] = $this->stdWrap($conf[$parameterName] ?? '', $conf[$parameterName . '.'] ?? []); + } + if (isset($conf[$parameterName]) && $conf[$parameterName]) { + $parameters[$parameterName] = $conf[$parameterName]; + } + } + $parametersEncoded = base64_encode((string)json_encode($parameters)); + $hmac = $this->hashService->hmac(implode('|', [$file->getUid(), $parametersEncoded]), 'tx_cms_showpic', HashAlgo::SHA3_256); + $params = '&md5=' . $hmac; + foreach (str_split($parametersEncoded, 64) as $index => $chunk) { + $params .= '¶meters' . rawurlencode('[') . $index . rawurlencode(']') . '=' . rawurlencode($chunk); + } + $absRefPrefix = $this->frontendUrlPrefix->getUrlPrefix($this->getRequest()); + $url = $absRefPrefix . 'index.php?eID=tx_cms_showpic&file=' . $file->getUid() . $params; + $directImageLink = $this->stdWrapValue('directImageLink', $conf); + if ($directImageLink) { + $imgResourceConf = [ + 'file' => $imageFile, + 'file.' => $conf, + ]; + $url = $this->cObjGetSingle('IMG_RESOURCE', $imgResourceConf); + if (!$url) { + // Either imagemagick/gm is not available or image URL could not be resolved due to invalid image file + if ($imageFile instanceof File || $imageFile instanceof FileReference) { + $url = $imageFile->getPublicUrl(); + } else { + $url = $imageFile; + } + } + } + $target = (string)$this->stdWrapValue('target', $conf); + if ($target === '') { + $target = 'thePicture'; + } + $a1 = ''; + $a2 = ''; + $conf['JSwindow'] = $this->stdWrapValue('JSwindow', $conf); + if ($conf['JSwindow']) { + $altUrl = $this->stdWrapValue('altUrl', $conf['JSwindow.'] ?? []); + if ($altUrl) { + $url = $altUrl . (($conf['JSwindow.']['altUrl_noDefaultParams'] ?? false) ? '' : '?file=' . rawurlencode((string)$imageFile) . $params); + } + + if ($file instanceof ProcessedFile) { + // TypoScript record delivered like 'file = fileadmin/something.jpg' which can result + // in an already processed file. Process the original file with the proper config now. + $file = $file->getOriginalFile(); + } + $processedFile = $file->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $conf); + $JSwindowExpand = $this->stdWrapValue('expand', $conf['JSwindow.'] ?? []); + $offset = GeneralUtility::intExplode(',', $JSwindowExpand . ','); + $newWindow = $this->stdWrapValue('newWindow', $conf['JSwindow.'] ?? []); + $params = [ + 'width' => ($processedFile->getProperty('width') + $offset[0]), + 'height' => ($processedFile->getProperty('height') + $offset[1]), + 'status' => '0', + 'menubar' => '0', + ]; + // params override existing parameters from above, or add more + $windowParams = (string)$this->stdWrapValue('params', $conf['JSwindow.'] ?? []); + $windowParams = explode(',', $windowParams); + foreach ($windowParams as $windowParam) { + $windowParamParts = explode('=', $windowParam); + $paramKey = $windowParamParts[0]; + $paramValue = $windowParamParts[1] ?? null; + + if ($paramKey === '') { + continue; + } + + if ($paramValue !== '') { + $params[$paramKey] = $paramValue; + } else { + unset($params[$paramKey]); + } + } + $paramString = ''; + foreach ($params as $paramKey => $paramValue) { + $paramString .= htmlspecialchars($paramKey) . '=' . htmlspecialchars((string)$paramValue) . ','; + } + + $attrs = [ + 'href' => (string)$url, + 'data-window-url' => $url, + 'data-window-target' => $newWindow ? md5((string)$url) : 'thePicture', + 'data-window-features' => rtrim($paramString, ','), + 'target' => $target, + ]; + + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray(); + $a1 = sprintf( + '', + GeneralUtility::implodeAttributes($attrs, true), + trim($typoScriptConfigArray['ATagParams'] ?? '') ? ' ' . trim($typoScriptConfigArray['ATagParams']) : '' + ); + $a2 = ''; + $this->addDefaultFrontendJavaScript($this->getRequest()); + } else { + $conf['linkParams.']['directImageLink'] = (bool)($conf['directImageLink'] ?? false); + $conf['linkParams.']['parameter'] = $url; + $string = (string)$this->typoLink($string, $conf['linkParams.']); + } + if (isset($conf['stdWrap.'])) { + $string = (string)$this->stdWrap($string, $conf['stdWrap.']); + } + $content = $a1 . $string . $a2; + } + return $content; + } + + /** + * Sets the SYS_LASTCHANGED timestamp if input timestamp is larger than current value. + * The SYS_LASTCHANGED timestamp can be used by various caching/indexing applications to determine if the page has new content. + * Therefore, you should call this function with the last-changed timestamp of any element you display. + * + * @param RecordInterface|int|string|float|null $item a record objet or a Unix timestamp (number of seconds since 1970) + */ + public function lastChanged(RecordInterface|int|string|float|null $item): void + { + if (MathUtility::canBeInterpretedAsInteger($item)) { + $item = (int)$item; + } elseif ($item instanceof Record) { + $item = $item->getSystemProperties()->getLastUpdatedAt()->getTimestamp(); + } else { + return; + } + $pageParts = $this->getRequest()->getAttribute('frontend.page.parts'); + if ($item > $pageParts->getLastChanged()) { + $pageParts->setLastChanged($item); + } + } + + /** + * Sets the current file object during iterations over files. + */ + public function setCurrentFile(File|FileReference|Folder|FileInterface|FolderInterface|null $fileObject): void + { + $this->currentFile = $fileObject; + } + + /** + * Gets the current file object during iterations over files. + */ + public function getCurrentFile(): File|FileReference|Folder|FileInterface|FolderInterface|null + { + return $this->currentFile; + } + + /** + * The "stdWrap" function. This is the implementation of what is known as "stdWrap properties" in TypoScript. + * Basically "stdWrap" performs some processing of a value based on properties in the input $conf array, holding + * the TypoScript "stdWrap properties". + * + * @param string $content Input value undergoing processing in this function. + * @param mixed $conf TypoScript "stdWrap properties". - should be enforced to be an array at some point + * @return string|null The processed input value + */ + public function stdWrap($content = '', $conf = []) + { + $content = (string)$content; + if (!is_array($conf) || $conf === []) { + return $content; + } + + // Activate the stdWrap PSR-14 Events - They will be executed + // as stdWrap functions, based on the STD_WRAP_ORDER constant. + $conf[BeforeStdWrapFunctionsInitializedEvent::class] = 1; + $conf[AfterStdWrapFunctionsInitializedEvent::class] = 1; + $conf[BeforeStdWrapFunctionsExecutedEvent::class] = 1; + $conf[AfterStdWrapFunctionsExecutedEvent::class] = 1; + + // Cache handling + if (is_array($conf['cache.'] ?? null)) { + $conf['cache.']['key'] = $this->stdWrapValue('key', $conf['cache.']); + $conf['cache.']['tags'] = $this->stdWrapValue('tags', $conf['cache.']); + $conf['cache.']['lifetime'] = $this->stdWrapValue('lifetime', $conf['cache.']); + $conf['cacheRead'] = 1; + $conf['cacheStore'] = 1; + } + // The configuration is sorted and filtered by intersection with the defined STD_WRAP_ORDER. + $sortedConf = array_keys(array_intersect_key(self::STD_WRAP_ORDER, $conf)); + // Functions types that should not make use of nested stdWrap function calls to avoid conflicts with internal TypoScript used by these functions + $stdWrapDisabledFunctionTypes = 'cObject,functionName,stdWrap'; + // Additional Array to check whether a function has already been executed + $isExecuted = []; + // Additional switch to make sure 'required', 'if' and 'fieldRequired' + // will still stop rendering immediately in case they return FALSE + $this->stdWrapNestingLevel++; + $this->stopRendering[$this->stdWrapNestingLevel] = false; + // execute each function in the predefined order + foreach ($sortedConf as $stdWrapName) { + // eliminate the second key of a pair 'key'|'key.' to make sure functions get called only once and check if rendering has been stopped + if (!isset($isExecuted[$stdWrapName]) && !$this->stopRendering[$this->stdWrapNestingLevel]) { + $functionName = rtrim($stdWrapName, '.'); + $functionProperties = $functionName . '.'; + $functionType = self::STD_WRAP_ORDER[$functionName] ?? ''; + // If there is any code on the next level, check if it contains "official" stdWrap functions + // if yes, execute them first - will make each function stdWrap aware + // so additional stdWrap calls within the functions can be removed, since the result will be the same + if (!empty($conf[$functionProperties]) && !GeneralUtility::inList($stdWrapDisabledFunctionTypes, $functionType)) { + if (array_intersect_key(self::STD_WRAP_ORDER, $conf[$functionProperties])) { + // Check if there's already content available before processing + // any ifEmpty or ifBlank stdWrap properties + if (($functionName === 'ifBlank' && $content !== '') + || ($functionName === 'ifEmpty' && !empty(trim((string)$content)))) { + continue; + } + + $conf[$functionName] = $this->stdWrap($conf[$functionName] ?? '', $conf[$functionProperties]); + } + } + // Check if key is still containing something, since it might have been changed by next level stdWrap before + if ((isset($conf[$functionName]) || ($conf[$functionProperties] ?? null)) + && ($functionType !== 'boolean' || ($conf[$functionName] ?? null)) + ) { + // Get just that part of $conf that is needed for the particular function + $singleConf = [ + $functionName => $conf[$functionName] ?? null, + $functionProperties => $conf[$functionProperties] ?? null, + ]; + // Hand over the whole $conf array to the hooks + if ($functionType === 'hook') { + $singleConf = $conf; + } + // Add both keys - with and without the dot - to the set of executed functions + $isExecuted[$functionName] = true; + $isExecuted[$functionProperties] = true; + if ($functionType === 'event') { + // @phpstan-ignore-next-line phpstan does not understand $functionName is only called on 'event' types + $content = $this->eventDispatcher->dispatch(new $functionName($content, $conf, $this))->getContent(); + } else { + // Call the function with the prefix stdWrap_ to make sure nobody can execute functions just by adding their name to the TS Array + $functionName = 'stdWrap_' . $functionName; + // @phpstan-ignore-next-line phpstan complains about some methods not having a second argument, which is not required/useful - doing reflection here would be too intense. + $content = $this->{$functionName}($content, $singleConf); + } + } elseif ($functionType === 'boolean' && !($conf[$functionName] ?? null)) { + $isExecuted[$functionName] = true; + $isExecuted[$functionProperties] = true; + } + } + } + unset($this->stopRendering[$this->stdWrapNestingLevel]); + $this->stdWrapNestingLevel--; + + return $content; + } + + /** + * Gets a configuration value by passing them through stdWrap first and taking a default value if stdWrap doesn't yield a result. + * + * @param string $key The config variable key (from TS array). + * @param array $config The TypoScript array. + * @param string|int|bool|null $defaultValue Optional default value. + * @return string|int|bool|null Value of the config variable + */ + public function stdWrapValue($key, array $config, $defaultValue = '') + { + if (isset($config[$key])) { + if (!isset($config[$key . '.'])) { + return $config[$key]; + } + } elseif (isset($config[$key . '.'])) { + $config[$key] = ''; + } else { + return $defaultValue; + } + $stdWrapped = $this->stdWrap($config[$key], $config[$key . '.']); + // The string "0" should be returned. + return $stdWrapped !== '' ? $stdWrapped : $defaultValue; + } + + /** + * Check if content was cached before (depending on the given cache key) + * + * @param string $content Input value undergoing processing in these functions. + * @param array $conf All stdWrap properties, not just the ones for a particular function. + * @return string The processed input value + */ + public function stdWrap_cacheRead($content = '', $conf = []) + { + if (!isset($conf['cache.'])) { + return $content; + } + $result = $this->getFromCache($conf['cache.']); + return $result === false ? $content : $result; + } + + /** + * Add tags to page cache (comma-separated list) + * + * @param string $content Input value undergoing processing in these functions. + * @param array $conf All stdWrap properties, not just the ones for a particular function. + * @return string The processed input value + */ + public function stdWrap_addPageCacheTags($content = '', $conf = []) + { + $tags = (string)$this->stdWrapValue('addPageCacheTags', $conf); + if (!empty($tags)) { + $cacheTags = GeneralUtility::trimExplode(',', $tags, true); + $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags( + ...array_map(fn(string $tag) => new CacheTag($tag), $cacheTags) + ); + } + return $content; + } + + /** + * Actually it just does the contrary: Set the value of 'current' based on current content. Wait. What? + * + * @param string $content Input value undergoing processing in this function. + * @return string The processed input value + */ + public function stdWrap_setContentToCurrent($content = '') + { + $this->data[$this->currentValKey] = $content; + return $content; + } + + /** + * Sets the value of 'current' based on the outcome of stdWrap operations + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for setCurrent. + * @return string The processed input value + */ + public function stdWrap_setCurrent($content = '', $conf = []) + { + $this->data[$this->currentValKey] = $conf['setCurrent'] ?? null; + return $content; + } + + /** + * Translates content based on the language currently used by the FE + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for lang. + * @return string The processed input value + */ + public function stdWrap_lang($content = '', $conf = []) + { + // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request. + $siteLanguage = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage(); + $currentLanguageCode = $siteLanguage->getTypo3Language(); + if (!$currentLanguageCode) { + return $content; + } + if (isset($conf['lang.'][$currentLanguageCode])) { + $content = $conf['lang.'][$currentLanguageCode]; + } else { + // @todo: use the Locale object and its dependencies in TYPO3 v13 + // Check language dependencies + foreach ($this->locales->getLocaleDependencies($currentLanguageCode) as $languageCode) { + if (isset($conf['lang.'][$languageCode])) { + $content = $conf['lang.'][$languageCode]; + break; + } + } + } + return $content; + } + + /** + * Gets content from different sources based on getText functions. + * + * @param string $_ Unused + * @param array $conf stdWrap properties for data. + * @return string The processed input value + */ + public function stdWrap_data($_ = '', $conf = []) + { + return $this->getData($conf['data'], $this->data); + } + + /** + * Gets content from a DB field + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for field. + * @return string|null The processed input value + */ + public function stdWrap_field($content = '', $conf = []) + { + return $this->getFieldVal($conf['field']); + } + + /** + * Gets content that has been previously set as 'current' + * Can be set via setContentToCurrent or setCurrent or will be set automatically i.e. inside the split function + * + * @return string The processed input value + */ + public function stdWrap_current(mixed $_ = null, mixed $__ = null) + { + return $this->getCurrentVal(); + } + + /** + * Will replace the content with the value of an official TypoScript cObject + * like TEXT, COA, HMENU + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for cObject. + * @return string The processed input value + */ + public function stdWrap_cObject($content = '', $conf = []) + { + return $this->cObjGetSingle($conf['cObject'] ?? '', $conf['cObject.'] ?? [], '/stdWrap/.cObject'); + } + + /** + * Counts the number of returned records of a DB operation, using select. + * + * @param string $_ Input value undergoing processing in this function. + * @param array $conf stdWrap properties for numRows. + */ + public function stdWrap_numRows($_ = '', $conf = []): int + { + return $this->numRows($conf['numRows.']); + } + + /** + * Will execute a user public function before the content will be modified by any other stdWrap function + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for preUserFunc. + * @return string The processed input value + */ + public function stdWrap_preUserFunc($content = '', $conf = []) + { + return $this->callUserFunction($conf['preUserFunc'], $conf['preUserFunc.'] ?? [], $content); + } + + /** + * Will override the current value of content with its own value' + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for override. + * @return string The processed input value + */ + public function stdWrap_override($content = '', $conf = []) + { + if (trim($conf['override'] ?? false)) { + $content = $conf['override']; + } + return $content; + } + + /** + * Gets a value off a CSV list before the following ifEmpty check + * Makes sure that the result of ifEmpty will be TRUE in case the CSV does not contain a value at the position given by preIfEmptyListNum + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for preIfEmptyListNum. + * @return string The processed input value + */ + public function stdWrap_preIfEmptyListNum($content = '', $conf = []) + { + return $this->listNum($content, $conf['preIfEmptyListNum'] ?? '0', $conf['preIfEmptyListNum.']['splitChar'] ?? ','); + } + + /** + * Will set content to a replacement value in case the value of content is NULL + * + * @param string|null $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for ifNull. + * @return string The processed input value + */ + public function stdWrap_ifNull($content = '', $conf = []) + { + return $content ?? $conf['ifNull']; + } + + /** + * Will set content to a replacement value in case the trimmed value of content returns FALSE + * 0 (zero) will be replaced as well + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for ifEmpty. + * @return string The processed input value + */ + public function stdWrap_ifEmpty($content = '', $conf = []) + { + if (empty(trim((string)$content))) { + $content = $conf['ifEmpty']; + } + return $content; + } + + /** + * Will set content to a replacement value in case the trimmed value of content has no length + * 0 (zero) will not be replaced + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for ifBlank. + * @return string The processed input value + */ + public function stdWrap_ifBlank($content = '', $conf = []) + { + if (trim((string)$content) === '') { + $content = $conf['ifBlank']; + } + return $content; + } + + /** + * Gets a value off a CSV list after ifEmpty check + * Might return an empty value in case the CSV does not contain a value at the position given by listNum + * Use preIfEmptyListNum to avoid that behaviour + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for listNum. + * @return string The processed input value + */ + public function stdWrap_listNum($content = '', $conf = []) + { + return $this->listNum($content, $conf['listNum'] ?? '0', $conf['listNum.']['splitChar'] ?? ','); + } + + /** + * Cut off any whitespace at the beginning and the end of the content + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_trim($content = ''): string + { + return trim((string)$content); + } + + /** + * Return a string padded left/right/on both sides, based on configuration given as stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for strPad. + */ + public function stdWrap_strPad($content = '', $conf = []): string + { + // Must specify a length in conf for this to make sense + $length = (int)$this->stdWrapValue('length', $conf['strPad.'] ?? [], 0); + // Padding with space is PHP-default + $padWith = (string)$this->stdWrapValue('padWith', $conf['strPad.'] ?? [], ' '); + // Padding on the right side is PHP-default + $padType = STR_PAD_RIGHT; + if (!empty($conf['strPad.']['type'])) { + $type = (string)$this->stdWrapValue('type', $conf['strPad.']); + if (strtolower($type) === 'left') { + $padType = STR_PAD_LEFT; + } elseif (strtolower($type) === 'both') { + $padType = STR_PAD_BOTH; + } + } + // mb_str_pad() throws a ValueError on an empty pad string, so return the content unchanged in that case. + if ($padWith === '') { + return $content; + } + return mb_str_pad($content, $length, $padWith, $padType); + } + + /** + * A recursive call of the stdWrap function set + * This enables the user to execute stdWrap functions in another than the predefined order + * It modifies the content, not the property + * while the new feature of chained stdWrap functions modifies the property and not the content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for stdWrap. + * @return string The processed input value + */ + public function stdWrap_stdWrap($content = '', $conf = []) + { + return $this->stdWrap($content, $conf['stdWrap.']); + } + + /** + * Will immediately stop rendering and return an empty value + * when there is no content at this point + * + * @param string $content Input value undergoing processing in this function. + * @return string The processed input value + */ + public function stdWrap_required($content = '') + { + if ((string)$content === '') { + $content = ''; + $this->stopRendering[$this->stdWrapNestingLevel] = true; + } + return $content; + } + + /** + * Will immediately stop rendering and return an empty value + * when the result of the checks returns FALSE + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for if. + * @return string The processed input value + */ + public function stdWrap_if($content = '', $conf = []) + { + if (empty($conf['if.']) || $this->checkIf($conf['if.'])) { + return $content; + } + $this->stopRendering[$this->stdWrapNestingLevel] = true; + return ''; + } + + /** + * Will immediately stop rendering and return an empty value + * when there is no content in the field given by fieldRequired + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for fieldRequired. + * @return string The processed input value + */ + public function stdWrap_fieldRequired($content = '', $conf = []) + { + $fieldName = (string)($conf['fieldRequired'] ?? ''); + if ($fieldName !== '' && !trim($this->data[$fieldName] ?? '')) { + $content = ''; + $this->stopRendering[$this->stdWrapNestingLevel] = true; + } + return $content; + } + + /** + * stdWrap csConv: Converts the input to UTF-8 + * + * The character set of the input must be specified. Returns the input if + * matters go wrong, for example if an invalid character set is given. + * + * @param string $content The string to convert. + * @param array $conf stdWrap properties for csConv. + * @return string The processed input. + */ + public function stdWrap_csConv($content = '', $conf = []) + { + if (!empty($conf['csConv'])) { + $output = mb_convert_encoding($content, 'utf-8', trim(strtolower($conf['csConv']))); + return $output !== false && $output !== '' ? $output : $content; + } + return $content; + } + + /** + * Will parse the content based on functions given as stdWrap properties + * Heavily used together with RTE based content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for parseFunc. + * @return string The processed input value + */ + public function stdWrap_parseFunc($content = '', $conf = []) + { + return $this->parseFunc($content, $conf['parseFunc.'], $conf['parseFunc']); + } + + /** + * Will parse HTML content based on functions given as stdWrap properties + * Heavily used together with RTE based content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for HTMLparser. + * @return string The processed input value + */ + public function stdWrap_HTMLparser($content = '', $conf = []) + { + if (isset($conf['HTMLparser.']) && is_array($conf['HTMLparser.'])) { + $content = $this->HTMLparser_TSbridge($content, $conf['HTMLparser.']); + } + return $content; + } + + /** + * Split the content by a given token and treat the results separately + * Automatically fills 'current' with a single result + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for split. + * @return string|int The processed input value + */ + public function stdWrap_split($content = '', $conf = []): string|int + { + return $this->splitObj($content, $conf['split.']); + } + + /** + * Will execute replacements on the content (optionally with preg-regex) + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for replacement. + * @return string The processed input value + */ + public function stdWrap_replacement($content = '', $conf = []) + { + $configuration = $conf['replacement.'] ?? []; + // Sort actions in configuration by numeric index + ksort($configuration, SORT_NUMERIC); + foreach ($configuration as $index => $action) { + // Check whether we have a valid action and a numeric key ending with a dot ("10.") + if (is_array($action) + && str_ends_with($index, '.') + && MathUtility::canBeInterpretedAsInteger(substr($index, 0, -1)) + && (isset($action['search']) || isset($action['search.'])) + && (isset($action['replace']) || isset($action['replace.'])) + ) { + $search = (string)$this->stdWrapValue('search', $action); + $replace = (string)$this->stdWrapValue('replace', $action, null); + // Determines whether regular expression shall be used + $useRegularExpression = (bool)$this->stdWrapValue('useRegExp', $action, false); + // Determines whether replace-pattern uses option-split + $useOptionSplitReplace = (bool)$this->stdWrapValue('useOptionSplitReplace', $action, false); + // Performs a replacement by preg_replace() + if ($useRegularExpression) { + // Get separator-character which precedes the string and separates search-string from the modifiers + $separator = $search[0]; + $startModifiers = strrpos($search, $separator); + if ($startModifiers > 0) { + $modifiers = substr($search, $startModifiers + 1); + // remove "e" (eval-modifier), which would otherwise allow to run arbitrary PHP-code + $modifiers = str_replace('e', '', $modifiers); + $search = substr($search, 0, $startModifiers + 1) . $modifiers; + } + if ($useOptionSplitReplace) { + // init for replacement + $splitCount = preg_match_all($search, $content); + $replaceArray = $this->typoScriptService->explodeConfigurationForOptionSplit([$replace], $splitCount); + $replaceCount = 0; + $replaceCallback = static function ($match) use ($replaceArray, $search, &$replaceCount) { + $replaceCount++; + return preg_replace($search, $replaceArray[$replaceCount - 1][0], $match[0]); + }; + $content = preg_replace_callback($search, $replaceCallback, $content); + } else { + $content = preg_replace($search, $replace, $content); + } + } elseif ($useOptionSplitReplace) { + // turn search-string into a preg-pattern + $searchPreg = '#' . preg_quote($search, '#') . '#'; + // init for replacement + $splitCount = preg_match_all($searchPreg, $content); + $replaceArray = $this->typoScriptService->explodeConfigurationForOptionSplit([$replace], $splitCount); + $replaceCount = 0; + $replaceCallback = static function () use ($replaceArray, &$replaceCount) { + $replaceCount++; + return $replaceArray[$replaceCount - 1][0]; + }; + $content = preg_replace_callback($searchPreg, $replaceCallback, $content); + } else { + $content = str_replace($search, $replace, $content); + } + } + } + return $content; + } + + /** + * Will use the content as a mathematical term and calculate the result + * Can be set to 1 to just get a calculated value or 'intval' to get the integer of the result + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for prioriCalc. + */ + public function stdWrap_prioriCalc($content = '', $conf = []): string|int + { + $content = MathUtility::calculateWithParentheses($content); + if (!empty($conf['prioriCalc']) && $conf['prioriCalc'] === 'intval') { + $content = (int)$content; + } + return $content; + } + + /** + * Returns a one-character string containing the character specified by ascii code. + * Reliable results only for character codes in the integer range 0 - 127. + * + * @see https://php.net/manual/en/function.chr.php + * @param array $conf stdWrap properties for char. + */ + public function stdWrap_char($_ = '', $conf = []): string + { + return chr((int)$conf['char']); + } + + /** + * Will return an integer value of the current content + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_intval($content = ''): int + { + return (int)$content; + } + + /** + * Will return a hashed value of the current content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for hash. + * @link https://php.net/manual/de/function.hash-algos.php for a list of supported hash algorithms + */ + public function stdWrap_hash($content = '', array $conf = []): string + { + $algorithm = (string)$this->stdWrapValue('hash', $conf); + if (in_array($algorithm, hash_algos())) { + return hash($algorithm, $content); + } + // Non-existing hashing algorithm + return ''; + } + + /** + * stdWrap_round will return a rounded number with ceil(), floor() or round(), defaults to round() + * Only the english number format is supported . (dot) as decimal point + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for round. + */ + public function stdWrap_round($content = '', $conf = []): float + { + $decimals = (int)$this->stdWrapValue('decimals', $conf['round.'] ?? [], 0); + $type = $this->stdWrapValue('roundType', $conf['round.'] ?? []); + $floatVal = (float)$content; + return match ($type) { + 'ceil' => ceil($floatVal), + 'floor' => floor($floatVal), + default => round($floatVal, $decimals), + }; + } + + /** + * Will return a formatted number based on configuration given as stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for numberFormat. + */ + public function stdWrap_numberFormat($content = '', $conf = []): string + { + return $this->numberFormat((float)$content, $conf['numberFormat.'] ?? []); + } + + /** + * Will return a formatted number based on configuration given as stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @return string The processed input value + */ + public function stdWrap_expandList($content = ''): string + { + return GeneralUtility::expandList($content); + } + + /** + * Will return a formatted date based on configuration given according to PHP date/gmdate properties + * Will return gmdate when the property GMT returns TRUE + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for date. + */ + public function stdWrap_date($content = '', $conf = []): string + { + // Check for zero length string to mimic default case of date/gmdate. + $content = (string)$content === '' ? $GLOBALS['EXEC_TIME'] : (int)$content; + return !empty($conf['date.']['GMT']) ? gmdate($conf['date'] ?? null, $content) : date($conf['date'] ?? null, $content); + } + + /** + * Will return a formatted date based on configuration given according to PHP strftime/gmstrftime properties + * Will return gmstrftime when the property GMT returns TRUE + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for strftime. + * @return string The processed input value + */ + public function stdWrap_strftime($content = '', $conf = []) + { + // Check for zero length string to mimic default case of strtime/gmstrftime + $content = (string)$content === '' ? $GLOBALS['EXEC_TIME'] : (int)$content; + $content = (isset($conf['strftime.']['GMT']) && $conf['strftime.']['GMT']) + ? (new DateFormatter())->strftime($conf['strftime'] ?? '', $content, null, true) + : (new DateFormatter())->strftime($conf['strftime'] ?? '', $content); + if (!empty($conf['strftime.']['charset'])) { + $output = mb_convert_encoding((string)$content, 'utf-8', trim(strtolower($conf['strftime.']['charset']))); + return $output ?: $content; + } + return $content; + } + + /** + * Will return a timestamp based on configuration given according to PHP strtotime + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for strtotime. + */ + public function stdWrap_strtotime($content = '', $conf = []): int|false + { + if ($conf['strtotime'] !== '1') { + $content .= ' ' . $conf['strtotime']; + } + return strtotime($content, $GLOBALS['EXEC_TIME']); + } + + /** + * php-intl date format + * Will return a timestamp based on configuration given according to PHP-intl DateFormatter->format() + * see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for formattedDate. + */ + public function stdWrap_formattedDate(string $content, array $conf): string + { + $pattern = $conf['formattedDate'] ?? 'LONG'; + // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request. + $language = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage(); + $locale = $conf['formattedDate.']['locale'] ?? $language->getLocale(); + + if ($content === '' || $content === '0') { + $content = $this->context->getAspect('date')->getDateTime(); + } else { + // format this to a timestamp now + $content = strtotime((MathUtility::canBeInterpretedAsInteger($content) ? '@' : '') . $content); + if ($content === false) { + $content = $this->context->getAspect('date')->getDateTime(); + } + } + return (new DateFormatter())->format($content, $pattern, $locale); + } + + /** + * Return the age of a given timestamp based on configuration given by stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for age. + * @return string The processed input value + */ + public function stdWrap_age($content = '', $conf = []): string + { + return $this->calcAge((int)($GLOBALS['EXEC_TIME'] ?? 0) - (int)$content, $conf['age'] ?? null); + } + + /** + * Transform the content to be upper or lower case only + * Leaves HTML tags untouched + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for case. + */ + public function stdWrap_case($content = '', $conf = []): string + { + return $this->HTMLcaseshift($content, $conf['case']); + } + + /** + * Will return the size of a given number in Bytes * + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for bytes. + * @return string The processed input value + */ + public function stdWrap_bytes($content = '', $conf = []) + { + $decimals = $conf['bytes.']['decimals'] ?? null; + if ($decimals !== null) { + $decimals = (int)$decimals; + } + return GeneralUtility::formatSize((int)$content, $conf['bytes.']['labels'] ?? '', $conf['bytes.']['base'] ?? 0, $decimals); + } + + /** + * Will return a substring based on position information given by stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for substring. + */ + public function stdWrap_substring($content = '', $conf = []): string + { + $options = GeneralUtility::intExplode(',', ($conf['substring'] ?? '') . ','); + if ($options[1]) { + return mb_substr($content, $options[0], $options[1], 'utf-8'); + } + return mb_substr($content, $options[0], null, 'utf-8'); + } + + /** + * Crops content to a given size while leaving HTML tags untouched + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for cropHTML. + */ + public function stdWrap_cropHTML($content = '', $conf = []): string + { + return $this->cropHTML($content, $conf['cropHTML'] ?? ''); + } + + /** + * Completely removes HTML tags from content + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_stripHtml($content = ''): string + { + return strip_tags((string)$content); + } + + /** + * Crops content to a given size without caring about HTML tags + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for crop. + */ + public function stdWrap_crop($content = '', $conf = []): string + { + return $this->crop($content, $conf['crop']); + } + + /** + * Encode content to be used within URLs + */ + public function stdWrap_rawUrlEncode($content = ''): string + { + return rawurlencode($content); + } + + /** + * Transforms HTML tags to readable text by replacing special characters with their HTML entity + * When preserveEntities returns TRUE, existing entities will be left untouched + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for htmlSpecialChars. + */ + public function stdWrap_htmlSpecialChars($content = '', $conf = []) + { + if (!empty($conf['htmlSpecialChars.']['preserveEntities'])) { + $content = htmlspecialchars((string)$content, ENT_COMPAT, 'UTF-8', false); + } else { + $content = htmlspecialchars((string)$content); + } + return $content; + } + + /** + * Escapes content to be used inside JavaScript strings. Single quotes are added around the value. + * + * @param string $content Input value undergoing processing in this function + */ + public function stdWrap_encodeForJavaScriptValue($content = ''): string + { + return GeneralUtility::quoteJSvalue($content); + } + + /** + * Searches for double line breaks and replaces them with the given value + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for doubleBrTag. + * @return string The processed input value + */ + public function stdWrap_doubleBrTag($content = '', $conf = []) + { + return preg_replace('/\R{1,2}[\t\x20]*\R{1,2}/', $conf['doubleBrTag'] ?? '', $content); + } + + /** + * Searches for single line breaks and replaces them with a
/
tag + * according to the doctype + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_br($content = ''): string + { + return nl2br($content, DocType::createFromRequest($this->getRequest())->isXmlCompliant()); + } + + /** + * Searches for single line feeds and replaces them with the given value + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for brTag. + */ + public function stdWrap_brTag($content = '', $conf = []): string + { + return str_replace(LF, (string)($conf['brTag'] ?? ''), $content); + } + + /** + * Modifies text blocks by searching for lines which are not surrounded by HTML tags yet + * and wrapping them with values given by stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for erncapsLines. + * @return string The processed input value + */ + public function stdWrap_encapsLines($content = '', $conf = []) + { + return $this->encaps_lineSplit($content, $conf['encapsLines.'] ?? []); + } + + /** + * Transforms content into a CSV list to be used i.e. as keywords within a meta tag + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_keywords($content = ''): string + { + return $this->keywords($content); + } + + /** + * First of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for innerWrap. + * @return string The processed input value + */ + public function stdWrap_innerWrap($content = '', $conf = []) + { + return $this->wrap($content, $conf['innerWrap'] ?? null); + } + + /** + * Second of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for innerWrap2. + * @return string The processed input value + */ + public function stdWrap_innerWrap2($content = '', $conf = []) + { + return $this->wrap($content, $conf['innerWrap2'] ?? null); + } + + /** + * A content object that is prepended to the current content but between the innerWraps and the rest of the wraps + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for preCObject. + * @return string The processed input value + */ + public function stdWrap_preCObject($content = '', $conf = []) + { + return $this->cObjGetSingle($conf['preCObject'], $conf['preCObject.'], '/stdWrap/.preCObject') . $content; + } + + /** + * A content object that is appended to the current content but between the innerWraps and the rest of the wraps + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for postCObject. + * @return string The processed input value + */ + public function stdWrap_postCObject($content = '', $conf = []) + { + return $content . $this->cObjGetSingle($conf['postCObject'], $conf['postCObject.'], '/stdWrap/.postCObject'); + } + + /** + * Wraps content with a div container having the style attribute text-align set to the given value + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for wrapAlign. + * @return string The processed input value + */ + public function stdWrap_wrapAlign($content = '', $conf = []) + { + $wrapAlign = trim($conf['wrapAlign'] ?? ''); + if ($wrapAlign) { + $content = $this->wrap($content, '
|
'); + } + return $content; + } + + /** + * Wraps the content with a link tag + * URLs and other attributes are created automatically by the values given in the stdWrap properties + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for typolink. + * @return string The processed input value + */ + public function stdWrap_typolink($content = '', $conf = []) + { + return $this->typoLink((string)$content, $conf['typolink.'] ?? []); + } + + /** + * This is the "mother" of all wraps + * Third of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * Basically it will put additional content before and after the current content using a split character as a placeholder for the current content + * The default split character is | but it can be replaced with other characters by the property splitChar + * Any other wrap that does not have own splitChar settings will be using the default split char though + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for wrap. + * @return string The processed input value + */ + public function stdWrap_wrap($content = '', $conf = []) + { + return $this->wrap($content, $conf['wrap'] ?? null, $conf['wrap.']['splitChar'] ?? '|'); + } + + /** + * Fourth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * The major difference to any other wrap is, that this one can make use of whitespace without trimming * + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for noTrimWrap. + * @return string The processed input value + */ + public function stdWrap_noTrimWrap($content = '', $conf = []) + { + $splitChar = isset($conf['noTrimWrap.']['splitChar.']) + ? $this->stdWrap($conf['noTrimWrap.']['splitChar'] ?? '', $conf['noTrimWrap.']['splitChar.']) + : $conf['noTrimWrap.']['splitChar'] ?? ''; + if ($splitChar === null || $splitChar === '') { + $splitChar = '|'; + } + return $this->noTrimWrap($content, $conf['noTrimWrap'], $splitChar); + } + + /** + * Fifth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * The default split character is | but it can be replaced with other characters by the property splitChar + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for wrap2. + * @return string The processed input value + */ + public function stdWrap_wrap2($content = '', $conf = []) + { + return $this->wrap($content, $conf['wrap2'] ?? null, $conf['wrap2.']['splitChar'] ?? '|'); + } + + /** + * Sixth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * Can fetch additional content the same way data does (i.e. {field:whatever}) and apply it to the wrap before that is applied to the content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for dataWrap. + * @return string The processed input value + */ + public function stdWrap_dataWrap($content = '', $conf = []) + { + return $this->dataWrap($content, $conf['dataWrap']); + } + + /** + * A content object that will be prepended to the current content after most of the wraps have already been applied + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for prepend. + * @return string The processed input value + */ + public function stdWrap_prepend($content = '', $conf = []) + { + return $this->cObjGetSingle($conf['prepend'], $conf['prepend.'], '/stdWrap/.prepend') . $content; + } + + /** + * A content object that will be appended to the current content after most of the wraps have already been applied + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for append. + * @return string The processed input value + */ + public function stdWrap_append($content = '', $conf = []) + { + return $content . $this->cObjGetSingle($conf['append'], $conf['append.'], '/stdWrap/.append'); + } + + /** + * Seventh of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * The default split character is | but it can be replaced with other characters by the property splitChar + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for wrap3. + * @return string The processed input value + */ + public function stdWrap_wrap3($content = '', $conf = []) + { + return $this->wrap($content, $conf['wrap3'] ?? null, $conf['wrap3.']['splitChar'] ?? '|'); + } + + /** + * Calls stdWrap for each entry in the provided array + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for orderedStdWrap. + * @return string The processed input value + */ + public function stdWrap_orderedStdWrap($content = '', $conf = []) + { + $sortedKeysArray = ArrayUtility::filterAndSortByNumericKeys($conf['orderedStdWrap.'], true); + foreach ($sortedKeysArray as $key) { + $content = (string)$this->stdWrap($content, $conf['orderedStdWrap.'][$key . '.'] ?? null); + } + return $content; + } + + /** + * Eighth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for outerWrap. + * @return string The processed input value + */ + public function stdWrap_outerWrap($content = '', $conf = []) + { + return $this->wrap($content, $conf['outerWrap'] ?? null); + } + + /** + * Can fetch additional content the same way data does and replaces any occurrence of {field:whatever} with this content + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_insertData($content = ''): string + { + return $this->insertData($content); + } + + /** + * Will execute a user function after the content has been modified by any other stdWrap function + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for postUserFunc. + * @return string The processed input value + */ + public function stdWrap_postUserFunc($content = '', $conf = []) + { + return $this->callUserFunction($conf['postUserFunc'], $conf['postUserFunc.'] ?? [], $content); + } + + /** + * Will execute a user function after the content has been created and each time it is fetched from Cache + * The result of this function itself will not be cached + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for postUserFuncInt. + */ + public function stdWrap_postUserFuncInt($content = '', $conf = []): string + { + $substKey = 'INT_SCRIPT.' . md5(StringUtility::getUniqueId()); + $pageParts = $this->getRequest()->getAttribute('frontend.page.parts'); + $pageParts->addNotCachedContentElement([ + 'substKey' => $substKey, + 'content' => $content, + 'postUserFunc' => $conf['postUserFuncInt'], + 'conf' => $conf['postUserFuncInt.'], + 'type' => 'POSTUSERFUNC', + 'cObjData' => serialize($this->getState()), + ]); + return ''; + } + + /** + * Add HTML comments to the content to make it easier to identify certain content elements within the HTML output later on + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for prefixComment. + * @return string The processed input value + */ + public function stdWrap_prefixComment($content = '', $conf = []) + { + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray(); + if ((!isset($typoScriptConfigArray['disablePrefixComment']) || !$typoScriptConfigArray['disablePrefixComment']) + && !empty($conf['prefixComment']) + ) { + $content = $this->prefixComment($conf['prefixComment'], [], $content); + } + return $content; + } + + public function stdWrap_htmlSanitize(string $content = '', array $conf = []): string + { + $build = $conf['build'] ?? 'default'; + // @todo: Simplify: There is a factory to build a builder, and this is wrapped here + // to build a different builder. This is not one, but two levels too complex. + if (class_exists($build) && is_a($build, BuilderInterface::class, true)) { + $builder = GeneralUtility::makeInstance($build); + } else { + $builder = $this->sanitizerBuilderFactory->build($build); + } + $sanitizer = $builder->build(); + $initiator = $this->shallDebug() + ? GeneralUtility::makeInstance(SanitizerInitiator::class, DebugUtility::debugTrail()) + : null; + return $sanitizer->sanitize($content, $initiator); + } + + /** + * Store content into cache + * + * @param string|null $content Input value undergoing processing in these functions. + * @param array $conf All stdWrap properties, not just the ones for a particular function. + * @return string|null The processed input value + */ + public function stdWrap_cacheStore($content = '', $conf = []): ?string + { + if (!isset($conf['cache.'])) { + return $content; + } + $key = $this->calculateCacheKey($conf['cache.']); + if (empty($key)) { + return $content; + } + $event = $this->eventDispatcher->dispatch( + new BeforeStdWrapContentStoredInCacheEvent( + content: $content, + tags: $this->calculateCacheTags($conf['cache.']), + key: (string)$key, + lifetime: $this->calculateCacheLifetime($conf['cache.']), + configuration: $conf, + contentObjectRenderer: $this + ) + ); + $this->cacheHash->set( + $event->getKey(), + ['content' => $event->getContent(), 'cacheTags' => $event->getTags()], + $event->getTags(), + $event->getLifetime() + ); + // If no tags are given, we restrict the maximum lifetime of the cache to the lifetime of the cache entry. + $cacheCollector = $this->getRequest()->getAttribute('frontend.cache.collector'); + if ($event->getTags() === []) { + $cacheCollector->restrictMaximumLifetime($event->getLifetime()); + } + $cacheCollector->addCacheTags(...array_map(fn(string $tag) => new CacheTag($tag, $event->getLifetime()), $event->getTags())); + return $event->getContent(); + } + + /** + * Will output the content as readable HTML code + * + * @param string $content Input value undergoing processing in this function. + */ + public function stdWrap_debug($content = ''): string + { + return '
' . htmlspecialchars($content) . '
'; + } + + /** + * Will output the content in a debug table + * + * @param string $content Input value undergoing processing in this function. + * @param array $conf stdWrap properties for debugFunc. + * @return string The processed input value + */ + public function stdWrap_debugFunc($content = '', $conf = []) + { + debug((int)$conf['debugFunc'] === 2 ? [$content] : $content); + return $content; + } + + /** + * Will output the data used by the current record in a debug table + * + * @param string $content Input value undergoing processing in this function. + * @return string The processed input value + */ + public function stdWrap_debugData($content = '') + { + debug($this->data, '$cObj->data:'); + return $content; + } + + /** + * Returns number of rows selected by the query made by the properties set. + * Implements the stdWrap "numRows" property + * + * @param array $conf TypoScript properties for the property (see link to "numRows") + * @internal + */ + public function numRows($conf): int + { + $conf['select.']['selectFields'] = 'count(*)'; + $statement = $this->exec_getQuery($conf['table'], $conf['select.']); + return (int)$statement->fetchOne(); + } + + /** + * Explode a string by the $delimiter value and return the value of index $listNum + * + * @param string $content String to explode + * @param string $listNum Index-number | 'last' | 'rand' | arithmetic expression. You can place the word "last" in it and it will be + * substituted with the pointer to the last value. You can use math operators like "+-/*" (passed to calc()) + * @param string $delimiter Either a string used to explode the content string or an integer value (as string) which will then be + * changed into a character, eg. "10" for a linebreak char. + * @return string + */ + public function listNum($content, $listNum, $delimiter = ',') + { + $delimiter = $delimiter ?: ','; + if (MathUtility::canBeInterpretedAsInteger($delimiter)) { + $delimiter = chr((int)$delimiter); + } + $temp = explode($delimiter, $content); + if ($temp === ['']) { + return ''; + } + $last = '' . (count($temp) - 1); + // Take a random item if requested + if ($listNum === 'rand') { + $listNum = (string)random_int(0, count($temp) - 1); + } + $index = $this->calc(str_ireplace('last', $last, $listNum)); + return $temp[$index] ?? ''; + } + + /** + * Compares values together based on the settings in the input TypoScript array and returns the comparison result. + * Implements the "if" function in TYPO3 TypoScript + * + * @param mixed $conf TypoScript properties defining what to compare, ideally an array + */ + public function checkIf($conf): bool + { + if (!is_array($conf)) { + return true; + } + if (isset($conf['directReturn'])) { + return (bool)$conf['directReturn']; + } + $flag = true; + if (isset($conf['isNull.'])) { + $isNull = $this->stdWrap('', $conf['isNull.']); + if ($isNull !== null) { + $flag = false; + } + } + if (isset($conf['isTrue']) || isset($conf['isTrue.'])) { + $isTrue = trim((string)$this->stdWrapValue('isTrue', $conf)); + if (!$isTrue) { + $flag = false; + } + } + if (isset($conf['isFalse']) || isset($conf['isFalse.'])) { + $isFalse = trim((string)$this->stdWrapValue('isFalse', $conf)); + if ($isFalse) { + $flag = false; + } + } + if (isset($conf['isPositive']) || isset($conf['isPositive.'])) { + $number = $this->calc((string)$this->stdWrapValue('isPositive', $conf)); + if ($number < 1) { + $flag = false; + } + } + if ($flag) { + $comparisonValue = trim((string)$this->stdWrapValue('value', $conf)); + if (isset($conf['isGreaterThan']) || isset($conf['isGreaterThan.'])) { + $number = trim((string)$this->stdWrapValue('isGreaterThan', $conf)); + if ($number <= $comparisonValue) { + $flag = false; + } + } + if (isset($conf['isLessThan']) || isset($conf['isLessThan.'])) { + $number = trim((string)$this->stdWrapValue('isLessThan', $conf)); + if ($number >= $comparisonValue) { + $flag = false; + } + } + if (isset($conf['equals']) || isset($conf['equals.'])) { + $number = trim((string)$this->stdWrapValue('equals', $conf)); + if ($number != $comparisonValue) { + $flag = false; + } + } + if (isset($conf['contains']) || isset($conf['contains.'])) { + $needle = trim((string)$this->stdWrapValue('contains', $conf)); + if (!str_contains($comparisonValue, $needle)) { + $flag = false; + } + } + if (isset($conf['startsWith']) || isset($conf['startsWith.'])) { + $needle = trim((string)$this->stdWrapValue('startsWith', $conf)); + if (!str_starts_with($comparisonValue, $needle)) { + $flag = false; + } + } + if (isset($conf['endsWith']) || isset($conf['endsWith.'])) { + $needle = trim((string)$this->stdWrapValue('endsWith', $conf)); + if (!str_ends_with($comparisonValue, $needle)) { + $flag = false; + } + } + if (isset($conf['isInList']) || isset($conf['isInList.'])) { + $singleValueWhichNeedsToBeInList = trim((string)$this->stdWrapValue('isInList', $conf)); + if (!GeneralUtility::inList($comparisonValue, $singleValueWhichNeedsToBeInList)) { + $flag = false; + } + } + if (isset($conf['bitAnd']) || isset($conf['bitAnd.'])) { + $number = (int)trim((string)$this->stdWrapValue('bitAnd', $conf)); + if ((new BitSet($number))->get((int)$comparisonValue) === false) { + $flag = false; + } + } + } + if ($conf['negate'] ?? false) { + $flag = !$flag; + } + return $flag; + } + + /** + * Passes the input value, $theValue, to an instance of "\TYPO3\CMS\Core\Html\HtmlParser" + * together with the TypoScript options which are first converted from a TS style array + * to a set of arrays with options for the \TYPO3\CMS\Core\Html\HtmlParser class. + * + * @param string $theValue The value to parse by the class \TYPO3\CMS\Core\Html\HtmlParser + * @param array $conf TypoScript properties for the parser. See link. + * @see stdWrap() + * @internal + */ + public function HTMLparser_TSbridge($theValue, $conf): string + { + $htmlParser = GeneralUtility::makeInstance(HtmlParser::class); + $htmlParserCfg = $htmlParser->HTMLparserConfig($conf); + return $htmlParser->HTMLcleaner($theValue, $htmlParserCfg[0], $htmlParserCfg[1], $htmlParserCfg[2], $htmlParserCfg[3]); + } + + /** + * Wrapping input value in a regular "wrap" but parses the wrapping value first for "insertData" codes. + * + * @param string $content Input string being wrapped + * @param string $wrap The wrap string, eg. "" or more likely here ' | ' + * which will wrap the input string in a tag linking to the current page. + * @return string Output string wrapped in the wrapping value. + * @see insertData() + * @see stdWrap() + */ + public function dataWrap($content, $wrap) + { + return $this->wrap($content, $this->insertData($wrap)); + } + + /** + * Implements the "insertData" property of stdWrap meaning that if strings matching {...} is found in the input string they + * will be substituted with the return value from getData (datatype) which is passed the content of the curly braces. + * If the content inside the curly braces starts with a hash sign {#...} it is a field name that must be quoted by Doctrine + * DBAL and is skipped here for later processing. + * + * Example: If input string is "This is the page title: {page:title}" then the part, '{page:title}', will be substituted with + * the current pages title field value. + * + * @param string $str Input value + * @see getData() + * @see stdWrap() + * @see dataWrap() + * @internal + */ + public function insertData($str): string + { + $inside = 0; + $newVal = ''; + $pointer = 0; + $totalLen = strlen($str); + do { + if (!$inside) { + $len = strcspn(substr($str, $pointer), '{'); + $newVal .= substr($str, $pointer, $len); + $inside = true; + if (substr($str, $pointer + $len + 1, 1) === '#') { + $len2 = strcspn(substr($str, $pointer + $len), '}'); + $newVal .= substr($str, $pointer + $len, $len2); + $len += $len2; + $inside = false; + } + } else { + $len = strcspn(substr($str, $pointer), '}') + 1; + $newVal .= $this->getData(substr($str, $pointer + 1, $len - 2), $this->data); + $inside = false; + } + $pointer += $len; + } while ($pointer < $totalLen); + return $newVal; + } + + /** + * Returns an HTML comment with the second part of input string (divided by "|") where first part is + * an integer telling how many trailing tabs to put before the comment on a new line. + * This function (used by stdWrap) can be disabled by a "config.disablePrefixComment" setting in TypoScript. + * + * @param string $str Input value + * @param string $content The content to wrap the comment around. + * @internal + */ + public function prefixComment($str, $_, $content): string + { + if (empty($str)) { + return $content; + } + $parts = explode('|', $str); + $indent = (int)$parts[0]; + $comment = htmlspecialchars($this->insertData($parts[1])); + return LF + . str_pad('', $indent, "\t") . '' . LF + . str_pad('', $indent + 1, "\t") . $content . LF + . str_pad('', $indent, "\t") . '' . LF + . str_pad('', $indent + 1, "\t"); + } + + /** + * Implements the stdWrap property "crop" 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 string $options The parameters splitted by "|": First parameter is the max number of chars of the string. + * Negative value means cropping from end of string. Second parameter is the pre/postfix + * string to apply if cropping occurs. Third parameter is a boolean value. If set then crop + * will be applied at nearest space. + * @return string The processed input value. + * @see stdWrap() + * @internal + */ + public function crop($content, $options): string + { + $options = explode('|', $options); + $numberOfChars = (int)$options[0]; + $replacementForEllipsis = trim($options[1] ?? ''); + $cropToSpace = trim($options[2] ?? '') === '1'; + return $this->textCropper->crop($content, $numberOfChars, $replacementForEllipsis, $cropToSpace); + } + + /** + * Implements the stdWrap property "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. + * + * Compared to stdWrap.crop it respects HTML tags and entities. + * + * @param string $content The string to perform the operation on + * @param string $options The parameters splitted by "|": First parameter is the max number of chars of the string. + * Negative value means cropping from end of string. Second parameter is the pre/postfix + * string to apply if cropping occurs. Third parameter is a boolean value. If set then crop + * will be applied at nearest space. + * @see stdWrap() + * @return string The processed input value. + * @internal + */ + public function cropHTML(string $content, string $options): string + { + $options = explode('|', $options); + $numberOfChars = (int)$options[0]; + $replacementForEllipsis = trim($options[1] ?? ''); + $cropToSpace = trim($options[2] ?? '') === '1'; + return $this->htmlCropper->crop($content, $numberOfChars, $replacementForEllipsis, $cropToSpace); + } + + /** + * Performs basic mathematical evaluation of the input string. Does NOT take parenthesis and operator precedence + * into account! (for that, see \TYPO3\CMS\Core\Utility\MathUtility::calculateWithPriorityToAdditionAndSubtraction()) + * + * @param string $val The string to evaluate. Example: "3+4*10/5" will generate "35". Only integer numbers can be used. + * @return int The result (might be a float if you did a division of the numbers). + * @see \TYPO3\CMS\Core\Utility\MathUtility::calculateWithPriorityToAdditionAndSubtraction() + */ + public function calc($val): int + { + $parts = GeneralUtility::splitCalc($val, '+-*/'); + $value = 0; + foreach ($parts as $part) { + $theVal = $part[1]; + $sign = $part[0]; + if ((string)(int)$theVal === (string)$theVal) { + $theVal = (int)$theVal; + } else { + $theVal = 0; + } + if ($sign === '-') { + $value -= $theVal; + } + if ($sign === '+') { + $value += $theVal; + } + if ($sign === '/') { + if ((int)$theVal) { + $value /= (int)$theVal; + } + } + if ($sign === '*') { + $value *= $theVal; + } + } + return $value; + } + + /** + * Implements the "split" property of stdWrap; Splits a string based on a token (given in TypoScript properties), + * sets the "current" value to each part and then renders a content object pointer to by a number. + * In classic TypoScript (like 'content (default)'/'styles.content (default)') this is used to render tables, + * splitting rows and cells by tokens and putting them together again wrapped in tags etc. + * Implements the "optionSplit" processing of the TypoScript options for each splitted value to parse. + * + * @param string $value The string value to explode by $conf[token] and process each part + * @param array $conf TypoScript properties for "split + * @internal + * @see stdWrap() + * @see \TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject::processItemStates() + */ + public function splitObj($value, $conf): string|int + { + $conf['token'] = isset($conf['token.']) ? $this->stdWrap($conf['token'] ?? '', $conf['token.']) : $conf['token'] ?? ''; + if ($conf['token'] === '') { + return $value; + } + $valArr = explode($conf['token'], $value); + + // return value directly by returnKey. No further processing + if ($valArr !== [''] && (MathUtility::canBeInterpretedAsInteger($conf['returnKey'] ?? null) || ($conf['returnKey.'] ?? false))) { + $key = (int)$this->stdWrapValue('returnKey', $conf); + return $valArr[$key] ?? ''; + } + + // return the amount of elements. No further processing + if ($valArr !== [''] && (($conf['returnCount'] ?? false) || ($conf['returnCount.'] ?? false))) { + $returnCount = (bool)$this->stdWrapValue('returnCount', $conf); + return $returnCount ? count($valArr) : 0; + } + + // calculate splitCount + $splitCount = count($valArr); + $max = (int)$this->stdWrapValue('max', $conf); + if ($max && $splitCount > $max) { + $splitCount = $max; + } + $min = (int)$this->stdWrapValue('min', $conf); + if ($min && $splitCount < $min) { + $splitCount = $min; + } + $wrap = (string)$this->stdWrapValue('wrap', $conf); + $cObjNumSplitConf = isset($conf['cObjNum.']) ? $this->stdWrap($conf['cObjNum'] ?? '', $conf['cObjNum.']) : (string)($conf['cObjNum'] ?? ''); + $splitArr = []; + if ($wrap !== '' || $cObjNumSplitConf !== '') { + $splitArr['wrap'] = $wrap; + $splitArr['cObjNum'] = $cObjNumSplitConf; + $splitArr = $this->typoScriptService->explodeConfigurationForOptionSplit($splitArr, $splitCount); + } + $content = ''; + for ($a = 0; $a < $splitCount; $a++) { + $this->getRequest()->getAttribute('frontend.register.stack')->current()->set('SPLIT_COUNT', $a); + $value = $valArr[$a]; + $this->data[$this->currentValKey] = $value; + if ($splitArr[$a]['cObjNum'] ?? false) { + $objName = (int)$splitArr[$a]['cObjNum']; + $value = (string)(isset($conf[$objName . '.']) + ? $this->stdWrap($this->cObjGet($conf[$objName . '.'], $objName . '.'), $conf[$objName . '.']) + : ''); + } + $wrap = (string)$this->stdWrapValue('wrap', $splitArr[$a] ?? []); + if ($wrap) { + $value = $this->wrap($value, $wrap); + } + $content .= $value; + } + return $content; + } + + /** + * Implements the stdWrap property "numberFormat" + * This is a Wrapper function for php's number_format() + * + * @param float $content Value to process + * @param array $conf TypoScript Configuration for numberFormat + * @internal + */ + public function numberFormat($content, $conf): string + { + $decimals = (int)$this->stdWrapValue('decimals', $conf, 0); + $dec_point = (string)$this->stdWrapValue('dec_point', $conf, '.'); + $thousands_sep = (string)$this->stdWrapValue('thousands_sep', $conf, ','); + return number_format((float)$content, $decimals, $dec_point, $thousands_sep); + } + + /** + * Implements the stdWrap property, "parseFunc". + * This is a function with a lot of interesting uses. In classic TypoScript this is used to process text + * from the bodytext field; This included highlighting of search words, changing http:// and mailto: prefixed strings into etc. + * It is still a very important function for processing of bodytext which is normally stored in the database + * in a format which is not fully ready to be outputted. + * This situation has not become better by having a RTE around... + * + * This function is actually just splitting the input content according to the configuration of "external blocks". + * This means that before the input string is actually "parsed" it will be split into the parts configured to BE parsed + * (while other parts/blocks should NOT be parsed). + * Therefore, the actual processing of the parseFunc properties goes on in ->parseFuncInternal() + * + * @param string $theValue The value to process. + * @param non-empty-array|null $conf TypoScript configuration for parseFunc + * @param non-empty-string|null $ref Reference to get configuration from. Eg. "< lib.parseFunc" which means that the configuration + * of the object path "lib.parseFunc" will be retrieved and MERGED with what is in $conf! + * @return string The processed value + */ + public function parseFunc($theValue, ?array $conf, ?string $ref = null) + { + // Fetch / merge reference, if any + if (!empty($ref)) { + $temp_conf = [ + 'parseFunc' => $ref, + 'parseFunc.' => $conf ?? [], + ]; + $temp_conf = $this->mergeTSRef($temp_conf, 'parseFunc'); + $conf = $temp_conf['parseFunc.']; + } + if (empty($conf)) { + // `parseFunc` relies on configuration, either given in `$conf` or resolved from `$ref` + throw new \LogicException('Invoked ContentObjectRenderer::parseFunc without any configuration', 1641989097); + } + // Handle HTML sanitizer invocation + $conf['htmlSanitize'] = (bool)($conf['htmlSanitize'] ?? true); + // Process: + if ((string)($conf['externalBlocks'] ?? '') === '') { + $result = $this->parseFuncInternal($theValue, $conf); + if ($conf['htmlSanitize']) { + $result = $this->stdWrap_htmlSanitize($result, $conf['htmlSanitize.'] ?? []); + } + return $result; + } + $tags = strtolower(implode(',', GeneralUtility::trimExplode(',', $conf['externalBlocks']))); + $htmlParser = GeneralUtility::makeInstance(HtmlParser::class); + $parts = $htmlParser->splitIntoBlock($tags, $theValue); + foreach ($parts as $k => $v) { + if ($k % 2) { + // font: + $tagName = strtolower($htmlParser->getFirstTagName($v)); + $cfg = $conf['externalBlocks.'][$tagName . '.'] ?? []; + if ($cfg === []) { + continue; + } + if (($cfg['stripNLprev'] ?? false) || ($cfg['stripNL'] ?? false)) { + $parts[$k - 1] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $parts[$k - 1]); + } + if (($cfg['stripNLnext'] ?? false) || ($cfg['stripNL'] ?? false)) { + if (!isset($parts[$k + 1])) { + $parts[$k + 1] = ''; + } + $parts[$k + 1] = preg_replace('/^[ ]*' . CR . '?' . LF . '/', '', $parts[$k + 1]); + } + } + } + foreach ($parts as $k => $v) { + if ($k % 2) { + $tag = $htmlParser->getFirstTag($v); + $tagName = strtolower($htmlParser->getFirstTagName($v)); + $cfg = $conf['externalBlocks.'][$tagName . '.'] ?? []; + if ($cfg === []) { + continue; + } + if ($cfg['callRecursive'] ?? false) { + $parts[$k] = $this->parseFunc($htmlParser->removeFirstAndLastTag($v), $conf); + if (!($cfg['callRecursive.']['dontWrapSelf'] ?? false)) { + if ($cfg['callRecursive.']['alternativeWrap'] ?? false) { + $parts[$k] = $this->wrap($parts[$k], $cfg['callRecursive.']['alternativeWrap']); + } else { + if (is_array($cfg['callRecursive.']['tagStdWrap.'] ?? false)) { + $tag = $this->stdWrap($tag, $cfg['callRecursive.']['tagStdWrap.']); + // Update $tagName in case $tag has been modified (eg by remap), so closing tag matches the opening + $tagName = strtolower($htmlParser->getFirstTagName($tag)); + } + $parts[$k] = $tag . $parts[$k] . ''; + } + } + } elseif ($cfg['HTMLtableCells'] ?? false) { + $rowParts = $htmlParser->splitIntoBlock('tr', $parts[$k]); + foreach ($rowParts as $kk => $vv) { + if ($kk % 2) { + $colParts = $htmlParser->splitIntoBlock('td,th', $vv); + $cc = 0; + foreach ($colParts as $kkk => $vvv) { + if ($kkk % 2) { + $cc++; + $tag = $htmlParser->getFirstTag($vvv); + $tagName = strtolower($htmlParser->getFirstTagName($vvv)); + $colParts[$kkk] = $htmlParser->removeFirstAndLastTag($vvv); + if (($cfg['HTMLtableCells.'][$cc . '.']['callRecursive'] ?? false) + || (!isset($cfg['HTMLtableCells.'][$cc . '.']['callRecursive']) && ($cfg['HTMLtableCells.']['default.']['callRecursive'] ?? false))) { + if ($cfg['HTMLtableCells.']['addChr10BetweenParagraphs'] ?? false) { + $colParts[$kkk] = str_replace( + '

', + '

' . LF . '

', + $colParts[$kkk] + ); + } + $colParts[$kkk] = $this->parseFunc($colParts[$kkk], $conf); + } + $tagStdWrap = is_array($cfg['HTMLtableCells.'][$cc . '.']['tagStdWrap.'] ?? false) + ? $cfg['HTMLtableCells.'][$cc . '.']['tagStdWrap.'] + : ($cfg['HTMLtableCells.']['default.']['tagStdWrap.'] ?? null); + if (is_array($tagStdWrap)) { + $tag = $this->stdWrap($tag, $tagStdWrap); + } + $stdWrap = is_array($cfg['HTMLtableCells.'][$cc . '.']['stdWrap.'] ?? false) + ? $cfg['HTMLtableCells.'][$cc . '.']['stdWrap.'] + : ($cfg['HTMLtableCells.']['default.']['stdWrap.'] ?? null); + if (is_array($stdWrap)) { + $colParts[$kkk] = $this->stdWrap($colParts[$kkk], $stdWrap); + } + $colParts[$kkk] = $tag . $colParts[$kkk] . ''; + } + } + $rowParts[$kk] = implode('', $colParts); + } + } + $parts[$k] = implode('', $rowParts); + } + if (is_array($cfg['stdWrap.'] ?? false)) { + $parts[$k] = $this->stdWrap($parts[$k], $cfg['stdWrap.']); + } + } else { + $parts[$k] = $this->parseFuncInternal($parts[$k], $conf); + } + } + $result = implode('', $parts); + if ($conf['htmlSanitize']) { + $result = $this->stdWrap_htmlSanitize($result, $conf['htmlSanitize.'] ?? []); + } + return $result; + } + + /** + * Helper function for parseFunc() + * + * @param string $theValue The value to process. + * @param array $conf TypoScript configuration for parseFunc + * @return string The processed value + */ + protected function parseFuncInternal($theValue, $conf) + { + if (!empty($conf['if.']) && !$this->checkIf($conf['if.'])) { + return $theValue; + } + // Indicates that the data is from within a tag. + $inside = false; + // Pointer to the total string position + $pointer = 0; + // Loaded with the current typo-tag if any. + $currentTag = null; + $stripNL = 0; + $contentAccum = []; + $contentAccumP = 0; + + $allowTags = GeneralUtility::trimExplode(',', strtolower($conf['allowTags'] ?? ''), true); + if (in_array('*', $allowTags, true)) { + $allowTags = ['*']; + } + $denyTags = GeneralUtility::trimExplode(',', strtolower($conf['denyTags'] ?? ''), true); + if (in_array('*', $denyTags, true)) { + $denyTags = ['*']; + } + $totalLen = strlen($theValue); + do { + if (!$inside) { + if ($currentTag === null) { + // These operations should only be performed on code outside the typotags... + // data: this checks that we enter tags ONLY if the first char in the tag is alphanumeric OR '/' + $len_p = 0; + $c = 100; + do { + $len = strcspn(substr($theValue, $pointer + $len_p), '<'); + $len_p += $len + 1; + $ordValue = strtolower(substr($theValue, $pointer + $len_p, 1)); + $endChar = empty($ordValue) ? 0 : ord($ordValue); + unset($ordValue); + $c--; + } while ($c > 0 && $endChar && ($endChar < 97 || $endChar > 122) && $endChar != 47); + $len = $len_p - 1; + } else { + $len = $this->getContentLengthOfCurrentTag($theValue, $pointer, (string)$currentTag[0]); + } + // $data is the content until the next logger->debug('Stripping new lines failed for "{data}"', ['data' => $data]); + $data = ''; + } + } + // These operations should only be performed on code outside the tags... + if (!is_array($currentTag)) { + // Short + if (isset($conf['short.']) && is_array($conf['short.'])) { + $shortWords = $conf['short.']; + krsort($shortWords); + foreach ($shortWords as $key => $val) { + if (is_string($val)) { + $data = str_replace($key, $val, $data); + } + } + } + // stdWrap + if (isset($conf['plainTextStdWrap.']) && is_array($conf['plainTextStdWrap.'])) { + $data = $this->stdWrap($data, $conf['plainTextStdWrap.']); + } + // userFunc + if ($conf['userFunc'] ?? false) { + $data = $this->callUserFunction($conf['userFunc'], $conf['userFunc.'] ?? [], $data); + } + } + // Search for tags to process in current data and + // call this method recursively if found + if (str_contains($data, '<') && isset($conf['tags.']) && is_array($conf['tags.'])) { + // @todo probably use a DOM tree traversal for the whole stuff + // This iterations basically re-processes the markup string, as + // long as there are `<$tag ` or `<$tag>` "tags" found... + foreach (array_keys($conf['tags.']) as $tag) { + // only match tag `a` in `` but not in `` + if (preg_match('#<' . $tag . '[\s/>]#', $data)) { + $data = $this->parseFuncInternal($data, $conf); + break; + } + } + } + if (!is_array($currentTag) && ($conf['makelinks'] ?? false)) { + $data = $this->http_makelinks($data, $conf['makelinks.']['http.'] ?? []); + $data = $this->mailto_makelinks($data, $conf['makelinks.']['mailto.'] ?? []); + } + $contentAccum[$contentAccumP] = ($contentAccum[$contentAccumP] ?? '') . $data; + $inside = true; + } else { + // tags + $len = strcspn(substr($theValue, $pointer), '>') + 1; + $data = substr($theValue, $pointer, $len); + if (str_ends_with($data, '/>') && !str_starts_with($data, ' + if (str_starts_with($tag[0], '/')) { + $tag[0] = substr($tag[0], 1); + $tag['out'] = 1; + } + if ($conf['tags.'][$tag[0]] ?? false) { + $treated = false; + $stripNL = false; + // in-tag + if (!$currentTag && (!isset($tag['out']) || !$tag['out'])) { + // $currentTag (array!) is the tag we are currently processing + $currentTag = $tag; + $contentAccumP++; + $treated = true; + // in-out-tag: img and other empty tags + if (preg_match('/^(area|base|br|col|hr|img|input|meta|param)$/i', (string)$tag[0])) { + $tag['out'] = 1; + } + } + // out-tag + if (isset($currentTag[0], $tag['out']) && $currentTag[0] === $tag[0] && $tag['out']) { + $theName = $conf['tags.'][$tag[0]]; + $theConf = $conf['tags.'][$tag[0] . '.']; + // This flag indicates, that NL- (13-10-chars) should be stripped first and last. + $stripNL = (bool)($theConf['stripNL'] ?? false); + // This flag indicates, that this TypoTag section should NOT be included in the nonTypoTag content. + $breakOut = (bool)($theConf['breakoutTypoTagContent'] ?? false); + $this->parameters = []; + if (isset($currentTag[1])) { + // decode HTML entities in attributes, since they're processed + $params = GeneralUtility::get_tag_attributes((string)$currentTag[1], true); + foreach ($params as $option => $val) { + // contains non-encoded values + $this->parameters[strtolower($option)] = $val; + } + $this->parameters['allParams'] = trim((string)$currentTag[1]); + } + // Removes NL in the beginning and end of the tag-content AND at the end of the currentTagBuffer. + // $stripNL depends on the configuration of the current tag + if ($stripNL) { + $contentAccum[$contentAccumP - 1] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $contentAccum[$contentAccumP - 1] ?? ''); + $contentAccum[$contentAccumP] = preg_replace('/^[ ]*' . CR . '?' . LF . '/', '', $contentAccum[$contentAccumP] ?? ''); + $contentAccum[$contentAccumP] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $contentAccum[$contentAccumP] ?? ''); + } + $this->data[$this->currentValKey] = $contentAccum[$contentAccumP] ?? null; + $newInput = $this->cObjGetSingle($theName, $theConf, '/parseFunc/.tags.' . $tag[0]); + // fetch the content object + $contentAccum[$contentAccumP] = $newInput; + $contentAccumP++; + // If the TypoTag section + if (!$breakOut) { + if (!isset($contentAccum[$contentAccumP - 2])) { + $contentAccum[$contentAccumP - 2] = ''; + } + $contentAccum[$contentAccumP - 2] .= ($contentAccum[$contentAccumP - 1] ?? '') . ($contentAccum[$contentAccumP] ?? ''); + unset($contentAccum[$contentAccumP]); + unset($contentAccum[$contentAccumP - 1]); + $contentAccumP -= 2; + } + $currentTag = null; + $treated = true; + } + // other tags + if (!$treated) { + $contentAccum[$contentAccumP] .= $data; + } + } else { + $contentAccum[$contentAccumP] = $contentAccum[$contentAccumP] ?? ''; + // If a tag was not a typo tag, then it is just added to the content + $stripNL = false; + if ( + // Neither allowTags or denyTags set, thus everything is allowed + ($denyTags === [] && $allowTags === []) + // Explicitly allowed + || ($allowTags !== [] && in_array((string)$tag[0], $allowTags, true)) + // Explicitly denied or everything "denied" (except for the explicitly allowed) + || ($denyTags !== [] && $denyTags !== ['*'] && !in_array((string)$tag[0], $denyTags)) + // All tags are allowed, but not in the denied list above, so this is OK + || ($allowTags === ['*'] && !in_array((string)$tag[0], $denyTags)) + ) { + $contentAccum[$contentAccumP] .= $data; + } else { + $contentAccum[$contentAccumP] .= htmlspecialchars($data); + } + } + $inside = false; + } + $pointer += $len; + } while ($pointer < $totalLen); + // Parsing nonTypoTag content (all even keys): + reset($contentAccum); + $contentAccumCount = count($contentAccum); + for ($a = 0; $a < $contentAccumCount; $a++) { + if ($a % 2 != 1) { + // stdWrap + if (isset($conf['nonTypoTagStdWrap.']) && is_array($conf['nonTypoTagStdWrap.'])) { + $contentAccum[$a] = $this->stdWrap((string)($contentAccum[$a] ?? ''), $conf['nonTypoTagStdWrap.']); + } + // userFunc + if (!empty($conf['nonTypoTagUserFunc'])) { + $contentAccum[$a] = $this->callUserFunction($conf['nonTypoTagUserFunc'], $conf['nonTypoTagUserFunc.'] ?? [], (string)($contentAccum[$a] ?? '')); + } + } + } + return implode('', $contentAccum); + } + + /** + * Lets you split the content by LF and process each line independently. Used to format content made with the RTE. + * + * @param string $theValue The input value + * @param array $conf TypoScript options + * @return string The processed input value being returned; Split lines imploded by LF again. + * @internal + */ + public function encaps_lineSplit($theValue, array $conf): string + { + if ((string)$theValue === '') { + return ''; + } + $lParts = explode(LF, $theValue); + + // When the last element is an empty linebreak we need to remove it, otherwise we will have a duplicate empty line. + $lastPartIndex = count($lParts) - 1; + if ($lParts[$lastPartIndex] === '' && trim($lParts[$lastPartIndex - 1], CR) === '') { + array_pop($lParts); + } + + $encapTags = GeneralUtility::trimExplode(',', strtolower($conf['encapsTagList'] ?? ''), true); + $defaultAlign = trim((string)$this->stdWrapValue('defaultAlign', $conf)); + + $str_content = ''; + foreach ($lParts as $k => $l) { + $sameBeginEnd = false; + $emptyTag = false; + $l = trim($l); + $attrib = []; + $nonWrapped = false; + $tagName = ''; + if (isset($l[0]) && $l[0] === '<' && str_ends_with($l, '>')) { + $fwParts = explode('>', substr($l, 1), 2); + [$tagName] = explode(' ', $fwParts[0], 2); + if (!$fwParts[1]) { + if (str_ends_with($tagName, '/')) { + $tagName = substr($tagName, 0, -1); + } + if (str_ends_with($fwParts[0], '/')) { + $sameBeginEnd = true; + $emptyTag = true; + // decode HTML entities, they're encoded later again + $attrib = GeneralUtility::get_tag_attributes('<' . substr($fwParts[0], 0, -1) . '>', true); + } + } else { + $backParts = GeneralUtility::revExplode('<', substr($fwParts[1], 0, -1), 2); + // decode HTML entities, they're encoded later again + $attrib = GeneralUtility::get_tag_attributes('<' . $fwParts[0] . '>', true); + $str_content = $backParts[0]; + // Ensure that $backParts could be exploded into 2 items + if (isset($backParts[1])) { + $sameBeginEnd = strtolower(substr($backParts[1], 1, strlen($tagName))) === strtolower($tagName); + } + } + } + if ($sameBeginEnd && in_array(strtolower($tagName), $encapTags)) { + $uTagName = strtoupper($tagName); + $uTagName = strtoupper($conf['remapTag.'][$uTagName] ?? $uTagName); + } else { + $uTagName = strtoupper($conf['nonWrappedTag'] ?? ''); + // The line will be wrapped: $uTagName should not be an empty tag + $emptyTag = false; + $str_content = $lParts[$k]; + $nonWrapped = true; + $attrib = []; + } + // Wrapping all inner-content: + if (is_array($conf['innerStdWrap_all.'] ?? null)) { + $str_content = (string)$this->stdWrap($str_content, $conf['innerStdWrap_all.']); + } + if ($uTagName) { + // Setting common attributes + if (isset($conf['addAttributes.'][$uTagName . '.']) && is_array($conf['addAttributes.'][$uTagName . '.'])) { + foreach ($conf['addAttributes.'][$uTagName . '.'] as $kk => $vv) { + if (!is_array($vv)) { + if ((string)($conf['addAttributes.'][$uTagName . '.'][$kk . '.']['setOnly'] ?? '') === 'blank') { + if ((string)($attrib[$kk] ?? '') === '') { + $attrib[$kk] = $vv; + } + } elseif ((string)($conf['addAttributes.'][$uTagName . '.'][$kk . '.']['setOnly'] ?? '') === 'exists') { + if (!isset($attrib[$kk])) { + $attrib[$kk] = $vv; + } + } else { + $attrib[$kk] = $vv; + } + } + } + } + // Wrapping all inner-content: + if (isset($conf['encapsLinesStdWrap.'][$uTagName . '.']) && is_array($conf['encapsLinesStdWrap.'][$uTagName . '.'])) { + $str_content = (string)$this->stdWrap($str_content, $conf['encapsLinesStdWrap.'][$uTagName . '.']); + } + // Default align + if ((!isset($attrib['align']) || !$attrib['align']) && $defaultAlign) { + $attrib['align'] = $defaultAlign; + } + // implode (insecure) attributes, that's why `htmlspecialchars` is used here + $params = GeneralUtility::implodeAttributes($attrib, true); + if (!isset($conf['removeWrapping']) || !$conf['removeWrapping'] || ($emptyTag && $conf['removeWrapping.']['keepSingleTag'])) { + $selfClosingTagList = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; + if ($emptyTag && in_array(strtolower($uTagName), $selfClosingTagList, true)) { + $str_content = '<' . strtolower($uTagName) . (trim($params) ? ' ' . trim($params) : '') . ' />'; + } else { + $str_content = '<' . strtolower($uTagName) . (trim($params) ? ' ' . trim($params) : '') . '>' . $str_content . ''; + } + } + } + if ($nonWrapped && isset($conf['wrapNonWrappedLines']) && $conf['wrapNonWrappedLines']) { + $str_content = $this->wrap($str_content, $conf['wrapNonWrappedLines']); + } + $lParts[$k] = $str_content; + } + return implode(LF, $lParts); + } + + /** + * Finds URLs in text and makes it to a real link. + * Will find all strings prefixed with "http://" and "https://" in the $data string and make them into a link, + * linking to the URL we should have found. + * + * Helper method of parseFuncInternal(). + * + * @param string $data The string in which to search for "http:// + * @param array $conf Configuration for makeLinks, see link + * @return string The processed input string, being returned. + */ + protected function http_makelinks(string $data, array $conf): string + { + $parts = []; + foreach (['http://', 'https://'] as $scheme) { + $textpieces = explode($scheme, $data); + $pieces = count($textpieces); + $textstr = $textpieces[0]; + for ($i = 1; $i < $pieces; $i++) { + $len = strcspn($textpieces[$i], chr(32) . "\t" . CRLF); + if (!(trim(substr($textstr, -1)) === '' && $len)) { + $textstr .= $scheme . $textpieces[$i]; + continue; + } + $lastChar = substr($textpieces[$i], $len - 1, 1); + if (!preg_match('/[A-Za-z0-9\\/#_-]/', $lastChar)) { + $len--; + } + // Included '\/' 3/12 + $parts[0] = substr($textpieces[$i], 0, $len); + $parts[1] = substr($textpieces[$i], $len); + $keep = $conf['keep'] ?? ''; + $linkParts = parse_url($scheme . $parts[0]); + // Check if link couldn't be parsed properly + if (!is_array($linkParts)) { + $textstr .= $scheme . $textpieces[$i]; + continue; + } + $linktxt = ''; + if (str_contains($keep, 'scheme')) { + $linktxt = $scheme; + } + $linktxt .= $linkParts['host'] ?? ''; + if (str_contains($keep, 'path')) { + $linktxt .= ($linkParts['path'] ?? ''); + // Added $linkParts['query'] 3/12 + if (str_contains($keep, 'query') && $linkParts['query']) { + $linktxt .= '?' . $linkParts['query']; + } elseif (($linkParts['path'] ?? '') === '/') { + $linktxt = substr($linktxt, 0, -1); + } + } + $typolinkConfiguration = $conf; + $typolinkConfiguration['parameter'] = $scheme . $parts[0]; + $textstr .= $this->typoLink($linktxt, $typolinkConfiguration) . $parts[1]; + } + $data = $textstr; + } + return $textstr; + } + + /** + * Will find all strings prefixed with "mailto:" in the $data string and make them into a link, + * linking to the email address they point to. + * + * Helper method of parseFuncInternal(). + * + * @param string $data The string in which to search for "mailto: + * @param array $conf Configuration for makeLinks, see link + * @return string The processed input string, being returned. + */ + protected function mailto_makelinks(string $data, array $conf): string + { + $conf = (array)$conf; + $parts = []; + // split by mailto logic + $textpieces = explode('mailto:', $data); + $pieces = count($textpieces); + $textstr = $textpieces[0]; + for ($i = 1; $i < $pieces; $i++) { + $len = strcspn($textpieces[$i], chr(32) . "\t" . CRLF); + if (trim(substr($textstr, -1)) === '' && $len) { + $lastChar = substr($textpieces[$i], $len - 1, 1); + if (!preg_match('/[A-Za-z0-9]/', $lastChar)) { + $len--; + } + $parts[0] = substr($textpieces[$i], 0, $len); + $parts[1] = substr($textpieces[$i], $len); + $linktxt = (string)preg_replace('/\\?.*/', '', $parts[0]); + $typolinkConfiguration = $conf; + $typolinkConfiguration['parameter'] = 'mailto:' . $parts[0]; + $textstr .= (string)$this->typoLink($linktxt, $typolinkConfiguration) . $parts[1]; + } else { + $textstr .= 'mailto:' . $textpieces[$i]; + } + } + return $textstr; + } + + /** + * Creates and returns a TypoScript "imgResource". + * The value ($file) can either be a file reference (TypoScript resource) or the string "GIFBUILDER". + * In the first case a current image is returned, possibly scaled down or otherwise processed. + * In the latter case a GIFBUILDER image is returned; This means an image is made by TYPO3 from layers of elements as GIFBUILDER defines. + * In the function IMG_RESOURCE() this function is called like $this->getImgResource($conf['file'], $conf['file.']); + * + * Structure of the returned info array: + * 0 => width + * 1 => height + * 2 => file extension + * 3 => file name + * origFile => original file name + * origFile_mtime => original file mtime + * -- only available if processed via FAL: -- + * originalFile => original file object + * processedFile => processed file object + * fileCacheHash => checksum of processed file + * + * @param string|File|FileReference $file A "imgResource" TypoScript data type. Either a TypoScript file resource, a file + * or a file reference object or the string GIFBUILDER. See description above. + * @param array $fileArray TypoScript properties for the imgResource type + * @see cImage() + */ + public function getImgResource($file, array $fileArray): ?ImageResource + { + $importedFile = null; + $fileReference = null; + if (empty($file) && empty($fileArray)) { + return null; + } + $imageResource = null; + if ($file === 'GIFBUILDER') { + $gifBuilder = GeneralUtility::makeInstance(GifBuilder::class); + $gifBuilder->start($fileArray, $this->data); + $imageResource = $gifBuilder->gifBuild(); + } else { + if ($file instanceof File) { + $fileObject = $file; + } elseif ($file instanceof FileReference) { + $fileReference = $file; + $fileObject = $file->getOriginalFile(); + } else { + try { + if (isset($fileArray['import.']) && $fileArray['import.']) { + $importedFile = trim((string)$this->stdWrap('', $fileArray['import.'])); + if (!empty($importedFile)) { + $file = $importedFile; + } + } + + if (MathUtility::canBeInterpretedAsInteger($file)) { + $treatIdAsReference = $this->stdWrapValue('treatIdAsReference', $fileArray); + if (!empty($treatIdAsReference)) { + $fileReference = $this->resourceFactory->getFileReferenceObject((int)$file); + $fileObject = $fileReference->getOriginalFile(); + } else { + $fileObject = $this->resourceFactory->getFileObject((int)$file); + } + } elseif (preg_match('/^(0|[1-9][0-9]*):/', $file)) { // combined identifier + $fileObject = $this->resourceFactory->retrieveFileOrFolderObject($file); + } else { + if ($importedFile && !empty($fileArray['import'])) { + $file = $fileArray['import'] . $file; + } + $fileObject = $this->resourceFactory->retrieveFileOrFolderObject($file); + } + } catch (Exception $exception) { + $this->logger->warning('The image "{file}" could not be found and won\'t be included in frontend output', [ + 'file' => $file, + 'exception' => $exception, + ]); + return null; + } + } + if ($fileObject instanceof File) { + $processingConfiguration['width'] = $this->stdWrapValue('width', $fileArray); + $processingConfiguration['height'] = $this->stdWrapValue('height', $fileArray); + $processingConfiguration['fileExtension'] = $this->stdWrapValue('ext', $fileArray); + $processingConfiguration['maxWidth'] = (int)$this->stdWrapValue('maxW', $fileArray); + $processingConfiguration['maxHeight'] = (int)$this->stdWrapValue('maxH', $fileArray); + $processingConfiguration['minWidth'] = (int)$this->stdWrapValue('minW', $fileArray); + $processingConfiguration['minHeight'] = (int)$this->stdWrapValue('minH', $fileArray); + $processingConfiguration['noScale'] = $this->stdWrapValue('noScale', $fileArray); + $processingConfiguration['sample'] = (bool)$this->stdWrapValue('sample', $fileArray); + $processingConfiguration['additionalParameters'] = $this->stdWrapValue('params', $fileArray); + $processingConfiguration['frame'] = (int)$this->stdWrapValue('frame', $fileArray); + if ($fileReference === null) { + $processingConfiguration['crop'] = $this->getCropAreaFromFromTypoScriptSettings($fileObject, $fileArray); + } else { + $processingConfiguration['crop'] = $this->getCropAreaFromFileReference($fileReference, $fileArray); + } + + // Possibility to cancel/force profile extraction + // see $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters'] + if (isset($fileArray['stripProfile'])) { + $processingConfiguration['stripProfile'] = $fileArray['stripProfile']; + } + // Check if we can handle this type of file for editing + if ($fileObject->isImage()) { + $maskArray = $fileArray['m.'] ?? false; + // Must render mask images and include in hash-calculating + // - otherwise we cannot be sure the filename is unique for the setup! + if (is_array($maskArray)) { + $processingConfiguration['maskImages']['maskImage'] = $this->getImgResource($maskArray['mask'] ?? '', $maskArray['mask.'] ?? [])?->getProcessedFile(); + $processingConfiguration['maskImages']['backgroundImage'] = $this->getImgResource($maskArray['bgImg'] ?? '', $maskArray['bgImg.'] ?? [])?->getProcessedFile(); + $processingConfiguration['maskImages']['maskBottomImage'] = $this->getImgResource($maskArray['bottomImg'] ?? '', $maskArray['bottomImg.'] ?? [])?->getProcessedFile(); + $processingConfiguration['maskImages']['maskBottomImageMask'] = $this->getImgResource($maskArray['bottomImg_mask'] ?? '', $maskArray['bottomImg_mask.'] ?? [])?->getProcessedFile(); + } + $processedFileObject = $fileObject->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $processingConfiguration); + if ($processedFileObject->isProcessed()) { + $imageResource = ImageResource::createFromProcessedFile($processedFileObject); + } + } + } elseif ($fileObject instanceof ProcessedFile) { + $imageResource = ImageResource::createFromProcessedFile($fileObject); + } + } + + return $this->eventDispatcher->dispatch( + new AfterImageResourceResolvedEvent($file, $fileArray, $imageResource) + )->getImageResource(); + } + + /** + * Returns an ImageManipulation\Area object for the given cropVariant (or 'default') + * or null if the crop settings or crop area is empty. + * + * The cropArea from file reference is used, if not set in TypoScript. + * + * Example TypoScript settings: + * file.crop = + * OR + * file.crop = 50,50,100,100 + * OR + * file.crop.data = file:current:crop + * + * @param array $fileArray TypoScript properties for the imgResource type + */ + protected function getCropAreaFromFileReference(FileReference $fileReference, array $fileArray): ?Area + { + // Use cropping area from file reference if nothing is configured in TypoScript. + if (!isset($fileArray['crop']) && !isset($fileArray['crop.'])) { + // Set crop variant from TypoScript settings. If not set, use default. + $cropVariant = $fileArray['cropVariant'] ?? 'default'; + $fileCropArea = $this->createCropAreaFromJsonString((string)$fileReference->getProperty('crop'), $cropVariant); + return $fileCropArea->isEmpty() ? null : $fileCropArea->makeAbsoluteBasedOnFile($fileReference); + } + return $this->getCropAreaFromFromTypoScriptSettings($fileReference, $fileArray); + } + + /** + * Returns an ImageManipulation\Area object for the given cropVariant (or 'default') + * or null if the crop settings or crop area is empty. + */ + protected function getCropAreaFromFromTypoScriptSettings(FileInterface $file, array $fileArray): ?Area + { + $cropArea = null; + // Resolve TypoScript configured cropping. + $cropSettings = isset($fileArray['crop.']) + ? $this->stdWrap($fileArray['crop'] ?? '', $fileArray['crop.']) + : ($fileArray['crop'] ?? null); + if (is_string($cropSettings)) { + // Set crop variant from TypoScript settings. If not set, use default. + $cropVariant = $fileArray['cropVariant'] ?? 'default'; + // Get cropArea from CropVariantCollection, if cropSettings is a valid json. + // CropVariantCollection::create does json_decode. + $jsonCropArea = $this->createCropAreaFromJsonString($cropSettings, $cropVariant); + $cropArea = $jsonCropArea->isEmpty() ? null : $jsonCropArea->makeAbsoluteBasedOnFile($file); + // Cropping is configured in TypoScript in the following way: file.crop = 50,50,100,100 + if ($jsonCropArea->isEmpty() && preg_match('/^[0-9]+,[0-9]+,[0-9]+,[0-9]+$/', $cropSettings)) { + $cropSettings = explode(',', $cropSettings); + if (count($cropSettings) === 4) { + $cropSettings = array_map(floatval(...), $cropSettings); + $stringCropArea = GeneralUtility::makeInstance(Area::class, ...$cropSettings); + $cropArea = $stringCropArea->isEmpty() ? null : $stringCropArea; + } + } + } + return $cropArea; + } + + /** + * Takes a JSON string and creates CropVariantCollection and fetches the corresponding CropArea for that. + */ + protected function createCropAreaFromJsonString(string $cropSettings, string $cropVariant): Area + { + return CropVariantCollection::create($cropSettings)->getCropArea($cropVariant); + } + + /** + * Returns the value for the field from $this->data. If "//" is found in the $field value that token will split + * the field values apart and the first field having a non-blank value will be returned. + * + * @param string $field The fieldname, e.g. "title" or "navtitle // title" (in the latter case the value of + * $this->data[navtitle] is returned if not blank, otherwise $this->data[title] will be) + * @return string|null + * @internal + */ + public function getFieldVal($field) + { + if (!str_contains($field, '//')) { + return $this->data[trim($field)] ?? null; + } + $sections = GeneralUtility::trimExplode('//', $field, true); + foreach ($sections as $k) { + if ((string)($this->data[$k] ?? '') !== '') { + return $this->data[$k]; + } + } + return ''; + } + + /** + * Implements the TypoScript data type "getText". This takes a string with parameters + * and based on those a value from somewhere in the system is returned. + * + * @param mixed $string The parameter string, eg. "field : title" or "field : navtitle // field : title" + * In the latter case and example of how the value is FIRST split by "//" is shown. Should be a string obviously + * @param array|null $fieldArray Alternative field array; If you set this to an array this variable will be used to + * look up values for the "field" key. Otherwise, the current page record is used. + * @return mixed The value fetched + */ + public function getData($string, $fieldArray = null) + { + if (!is_array($fieldArray)) { + $fieldArray = $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord(); + } + $retVal = ''; + // @todo: getData should not be called with non-string as $string. example trigger: + // SecureHtmlRenderingTest htmlViewHelperAvoidsCrossSiteScripting set #07 PHP 8 + $sections = is_string($string) ? explode('//', $string) : []; + foreach ($sections as $secVal) { + if ($retVal) { + break; + } + $parts = explode(':', $secVal, 2); + $type = strtolower(trim($parts[0])); + $typesWithOutParameters = ['level', 'date', 'current', 'pagelayout', 'applicationcontext']; + $key = trim($parts[1] ?? ''); + if (($key != '') || in_array($type, $typesWithOutParameters)) { + switch ($type) { + case 'gp': + // Merge GET and POST and get $key out of the merged array + $requestParameters = $this->getRequest()->getQueryParams(); + $requestParameters = array_replace_recursive($requestParameters, (array)$this->getRequest()->getParsedBody()); + $retVal = $this->getGlobal($key, $requestParameters); + break; + case 'request': + $retVal = $this->getValueFromRecursiveData(GeneralUtility::trimExplode('|', $key), $this->getRequest()); + break; + case 'tsfe': + $valueParts = GeneralUtility::trimExplode('|', $key); + if (($valueParts[0] ?? '') === 'fe_user') { + $frontendUser = $this->getRequest()->getAttribute('frontend.user'); + array_shift($valueParts); + $retVal = $this->getValueFromRecursiveData($valueParts, $frontendUser); + } elseif (($valueParts[0] ?? '') === 'linkVars') { + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray(); + $typoScriptConfigLinkVars = (string)($typoScriptConfigArray['linkVars'] ?? ''); + $retVal = $this->linkVarsCalculator->getAllowedLinkVarsFromRequest($typoScriptConfigLinkVars, $this->getRequest()->getQueryParams(), $this->context); + } elseif (($valueParts[0] ?? '') === 'id') { + $retVal = $this->getRequest()->getAttribute('frontend.page.information')->getId(); + } elseif (($valueParts[0] ?? '') === 'contentPid') { + $retVal = $this->getRequest()->getAttribute('frontend.page.information')->getContentFromPid(); + } elseif (($valueParts[0] ?? '') === 'rootLine') { + array_shift($valueParts); + $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getRootLine()); + } elseif (($valueParts[0] ?? '') === 'page') { + array_shift($valueParts); + $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord()); + } elseif (($valueParts[0] ?? '') === 'config' && ($valueParts[1] ?? '') === 'rootLine') { + array_shift($valueParts); + array_shift($valueParts); + $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine()); + } + break; + case 'getenv': + $retVal = getenv($key); + break; + case 'getindpenv': + $normalizedParams = $this->getRequest()->getAttribute('normalizedParams'); + $retVal = match ($key) { + 'HTTP_HOST' => $normalizedParams->getHttpHost(), + 'TYPO3_HOST_ONLY' => $normalizedParams->getRequestHostOnly(), + 'TYPO3_PORT' => $normalizedParams->getRequestPort(), + 'PATH_INFO' => $normalizedParams->getPathInfo(), + 'QUERY_STRING' => $normalizedParams->getQueryString(), + 'REQUEST_URI' => $normalizedParams->getRequestUri(), + 'HTTP_REFERER' => $normalizedParams->getHttpReferer(), + 'TYPO3_REQUEST_HOST' => $normalizedParams->getRequestHost(), + 'TYPO3_REQUEST_URL' => $normalizedParams->getRequestUrl(), + 'TYPO3_REQUEST_SCRIPT' => $normalizedParams->getRequestScript(), + 'TYPO3_REQUEST_DIR' => $normalizedParams->getRequestDir(), + 'TYPO3_SITE_URL' => $normalizedParams->getSiteUrl(), + 'TYPO3_SITE_SCRIPT' => $normalizedParams->getSiteScript(), + 'TYPO3_SSL' => $normalizedParams->isHttps(), + 'TYPO3_REV_PROXY' => $normalizedParams->isBehindReverseProxy(), + 'SCRIPT_NAME' => $normalizedParams->getScriptName(), + 'TYPO3_DOCUMENT_ROOT' => $normalizedParams->getDocumentRoot(), + 'SCRIPT_FILENAME' => $normalizedParams->getScriptFilename(), + 'REMOTE_ADDR' => $normalizedParams->getRemoteAddress(), + 'REMOTE_HOST' => $normalizedParams->getRemoteHost(), + 'HTTP_USER_AGENT' => $normalizedParams->getHttpUserAgent(), + 'HTTP_ACCEPT_LANGUAGE' => $normalizedParams->getHttpAcceptLanguage(), + default => null, + }; + break; + case 'field': + $retVal = $this->getGlobal($key, $fieldArray); + break; + case 'file': + $retVal = $this->getFileDataKey($key); + break; + case 'asset': + case 'path': + $options = null; + if ($type === 'path') { + $options = new UriGenerationOptions(cacheBusting: false); + } + try { + $resource = $this->systemResourceFactory->createPublicResource($key); + $retVal = (string)$this->systemResourcePublisher->generateUri($resource, $this->getRequest(), $options); + } catch (Exception) { + $retVal = null; + } + break; + case 'parameters': + $retVal = $this->parameters[$key] ?? null; + break; + case 'register': + if ($key === 'SYS_LASTCHANGED') { + // b/w compat layer: SYS_LASTCHANGED has been a register entry until TYPO3 v14. It is now part + // of a request attribute. The register access via TS should continue to work, though. + $retVal = $this->getRequest()->getAttribute('frontend.page.parts')->getLastChanged(); + } else { + $retVal = $this->getRequest()->getAttribute('frontend.register.stack')->current()->get($key); + } + break; + case 'global': + $retVal = $this->getGlobal($key); + break; + case 'level': + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $retVal = count($localRootLine) - 1; + break; + case 'leveltitle': + $keyParts = GeneralUtility::trimExplode(',', $key); + $pointer = (int)($keyParts[0] ?? 0); + $slide = $keyParts[1] ?? ''; + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $numericKey = $this->getKey($pointer, $localRootLine); + $retVal = $this->rootLineValue($numericKey, 'title', strtolower($slide) === 'slide'); + break; + case 'levelmedia': + $keyParts = GeneralUtility::trimExplode(',', $key); + $pointer = (int)($keyParts[0] ?? 0); + $slide = $keyParts[1] ?? ''; + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $numericKey = $this->getKey($pointer, $localRootLine); + $retVal = $this->rootLineValue($numericKey, 'media', strtolower($slide) === 'slide'); + break; + case 'leveluid': + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $numericKey = $this->getKey((int)$key, $localRootLine); + $retVal = $this->rootLineValue($numericKey, 'uid'); + break; + case 'levelfield': + $keyParts = GeneralUtility::trimExplode(',', $key); + $pointer = (int)($keyParts[0] ?? 0); + $field = $keyParts[1] ?? ''; + $slide = $keyParts[2] ?? ''; + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $numericKey = $this->getKey($pointer, $localRootLine); + $retVal = $this->rootLineValue($numericKey, $field, strtolower($slide) === 'slide'); + break; + case 'fullrootline': + $keyParts = GeneralUtility::trimExplode(',', $key); + $pointer = (int)($keyParts[0] ?? 0); + $field = $keyParts[1] ?? ''; + $slide = $keyParts[2] ?? ''; + $rootLine = $this->getRequest()->getAttribute('frontend.page.information')->getRootLine(); + $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + $fullKey = $pointer - count($localRootLine) + count($rootLine); + if ($fullKey >= 0) { + $retVal = $this->rootLineValue($fullKey, $field, stristr($slide, 'slide') !== false, $rootLine); + } + break; + case 'date': + if (!$key) { + $key = 'd/m Y'; + } + $retVal = date($key, $GLOBALS['EXEC_TIME']); + break; + case 'page': + $pageRecord = $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord(); + $retVal = $pageRecord[$key] ?? ''; + break; + case 'pagelayout': + $pageInformation = $this->getRequest()->getAttribute('frontend.page.information'); + $retVal = $this->pageLayoutResolver->getLayoutIdentifierForPage($pageInformation->getPageRecord(), $pageInformation->getRootLine()); + break; + case 'current': + $retVal = $this->data[$this->currentValKey] ?? null; + break; + case 'db': + $selectParts = GeneralUtility::trimExplode(':', $key, true); + if (!isset($selectParts[1])) { + break; + } + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + $dbRecord = $pageRepository->getRawRecord($selectParts[0], (int)$selectParts[1]); + if (is_array($dbRecord) && isset($selectParts[2])) { + $retVal = $dbRecord[$selectParts[2]] ?? ''; + } + break; + case 'lll': + // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request. + $language = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage(); + $languageService = $this->languageServiceFactory->createFromSiteLanguage($language); + $retVal = $languageService->sL('LLL:' . $key); + break; + case 'debug': + switch ($key) { + case 'rootLine': + $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine()); + break; + case 'fullRootLine': + $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getRootLine()); + break; + case 'data': + $retVal = DebugUtility::viewArray($this->data); + break; + case 'register': + $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.register.stack')->current()); + break; + case 'page': + $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getPageRecord()); + break; + } + break; + case 'flexform': + $keyParts = GeneralUtility::trimExplode(':', $key, true); + if (count($keyParts) === 2 && isset($this->data[$keyParts[0]])) { + $flexFormContent = $this->data[$keyParts[0]]; + if (!empty($flexFormContent)) { + $flexFormKey = str_replace('.', '|', $keyParts[1]); + $settings = $this->flexFormTools->convertFlexFormContentToArray($flexFormContent); + $retVal = $this->getGlobal($flexFormKey, $settings); + } + } + break; + case 'session': + $keyParts = GeneralUtility::trimExplode('|', $key, true); + $sessionKey = array_shift($keyParts); + $retVal = $this->getRequest()->getAttribute('frontend.user')->getSessionData($sessionKey); + foreach ($keyParts as $keyPart) { + if (is_object($retVal)) { + $retVal = $retVal->{$keyPart}; + } elseif (is_array($retVal)) { + $retVal = $retVal[$keyPart]; + } else { + $retVal = ''; + break; + } + } + if (!is_scalar($retVal)) { + $retVal = ''; + } + break; + case 'context': + [$aspectName, $propertyName] = GeneralUtility::trimExplode(':', $key, true, 2); + $retVal = $this->context->getPropertyFromAspect($aspectName, $propertyName, ''); + if (is_array($retVal)) { + $retVal = implode(',', $retVal); + } + if (!is_scalar($retVal)) { + $retVal = ''; + } + break; + case 'site': + $site = $this->getRequest()->getAttribute('site'); + if ($key === 'identifier') { + $retVal = $site->getIdentifier(); + } elseif ($key === 'base') { + $retVal = $site->getBase(); + } else { + try { + $retVal = ArrayUtility::getValueByPath($site->getConfiguration(), $key, '.'); + } catch (MissingArrayPathException $exception) { + $this->logger->notice('Configuration "{key}" is not defined for site "{site}"', ['key' => $key, 'site' => $site->getIdentifier(), 'exception' => $exception]); + } + } + break; + case 'sitelanguage': + // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request. + $siteLanguage = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage(); + if ($key === 'twoLetterIsoCode') { + $key = 'locale:languageCode'; + } + // Harmonizing the namings from the site configuration value with the TypoScript setting + if ($key === 'flag') { + $key = 'flagIdentifier'; + } + // Special handling for the locale object + if (str_starts_with($key, 'locale')) { + $localeObject = $siteLanguage->getLocale(); + if ($key === 'locale') { + // backwards-compatibility + $retVal = $localeObject->posixFormatted(); + } else { + $keyParts = explode(':', $key, 2); + switch ($keyParts[1] ?? '') { + case 'languageCode': + $retVal = $localeObject->getLanguageCode(); + break; + case 'countryCode': + $retVal = $localeObject->getCountryCode(); + break; + case 'full': + default: + $retVal = $localeObject->getName(); + } + } + } else { + $config = $siteLanguage->toArray(); + if (isset($config[$key])) { + $retVal = $config[$key]; + } + } + break; + case 'sitesettings': + $siteSettings = $this->getRequest()->getAttribute('site')->getSettings(); + $retVal = $siteSettings->get($key, ''); + break; + case 'applicationcontext': + $retVal = Environment::getContext()->__toString(); + break; + } + } + } + + return $this->eventDispatcher->dispatch( + new AfterGetDataResolvedEvent($string, $fieldArray, $retVal, $this) + )->getResult(); + } + + /** + * Gets file information. This is a helper function for the getData() method above, which resolves e.g. + * page.10.data = file:current:title + * or + * page.10.data = file:17:title + * + * @param string $key A colon-separated key, e.g. 17:name or current:sha1, with the first part being a sys_file uid + * or the keyword "current" and the second part being the key of information to get from + * file (e.g. "title", "size", "description", etc.) + * @return string|int|null The value as retrieved from the file object. + */ + protected function getFileDataKey($key) + { + [$fileUidOrCurrentKeyword, $requestedFileInformationKey] = GeneralUtility::trimExplode(':', $key, false, 3); + try { + if ($fileUidOrCurrentKeyword === 'current') { + $fileObject = $this->getCurrentFile(); + } elseif (MathUtility::canBeInterpretedAsInteger($fileUidOrCurrentKeyword)) { + $fileObject = $this->resourceFactory->getFileObject((int)$fileUidOrCurrentKeyword); + } else { + $fileObject = null; + } + } catch (Exception $exception) { + $this->logger->warning('The file "{uid}" could not be found and won\'t be included in frontend output', ['uid' => $fileUidOrCurrentKeyword, 'exception' => $exception]); + $fileObject = null; + } + + if ($fileObject instanceof FileInterface) { + // All properties of the \TYPO3\CMS\Core\Resource\FileInterface are available here: + switch ($requestedFileInformationKey) { + case 'name': + return $fileObject->getName(); + case 'uid': + if (method_exists($fileObject, 'getUid')) { + return $fileObject->getUid(); + } + return 0; + case 'originalUid': + if ($fileObject instanceof FileReference) { + return $fileObject->getOriginalFile()->getUid(); + } + return null; + case 'size': + return $fileObject->getSize(); + case 'sha1': + return $fileObject->getSha1(); + case 'extension': + return $fileObject->getExtension(); + case 'mimetype': + return $fileObject->getMimeType(); + case 'contents': + return $fileObject->getContents(); + case 'publicUrl': + return $fileObject->getPublicUrl(); + default: + // Generic alternative here + return $fileObject->getProperty($requestedFileInformationKey); + } + } else { + // @todo fail silently as is common in tslib_content + return 'Error: no file object'; + } + } + + /** + * Returns a value from the current rootline. + * + * @param int $key Which level in the root line + * @param string $field The field in the rootline record to return (a field from the pages table) + * @param bool $slideBack If set, then we will traverse through the rootline from outer level towards the root level until the value found is TRUE + * @param mixed $altRootLine If you supply an array for this it will be used as an alternative root line array + * @return string The value from the field of the rootline. + */ + protected function rootLineValue($key, $field, $slideBack = false, $altRootLine = ''): string + { + if (is_array($altRootLine)) { + $rootLine = $altRootLine; + } else { + $rootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine(); + } + if (!$slideBack) { + return $rootLine[$key][$field] ?? ''; + } + for ($a = $key; $a >= 0; $a--) { + $val = $rootLine[$a][$field] ?? ''; + if ($val) { + return $val; + } + } + return ''; + } + + /** + * Return global variable where the input string $var defines array keys separated by "|" + * Example: $var = "HTTP_SERVER_VARS | something" will return the value $GLOBALS['HTTP_SERVER_VARS']['something'] value + * + * @param string $keyString Global var key, eg. "HTTP_GET_VAR" or "HTTP_GET_VARS|id" to get the GET parameter "id" back. + * @param array $source Alternative array than $GLOBAL to get variables from. + * @return float|int|string Whatever value. If none, then blank string. + */ + public function getGlobal($keyString, $source = null) + { + $keys = GeneralUtility::trimExplode('|', $keyString); + // remove the first key, as this is only used for finding the original value + $rootKey = array_shift($keys); + $value = isset($source) ? ($source[$rootKey] ?? '') : ($GLOBALS[$rootKey] ?? ''); + return $this->getValueFromRecursiveData($keys, $value); + } + + /** + * This method recursively checks for values in methods, arrays, objects, but + * does not fall back to $GLOBALS object instead of getGlobal(). + */ + protected function getValueFromRecursiveData(array $keys, mixed $startValue): int|float|string + { + $value = $startValue; + $numberOfLevels = count($keys); + for ($i = 0; $i < $numberOfLevels && isset($value); $i++) { + $currentKey = $keys[$i]; + if (is_object($value)) { + // getter method + if (method_exists($value, 'get' . ucfirst($currentKey))) { + $getterMethod = 'get' . ucfirst($currentKey); + $value = $value->$getterMethod(...)(); + // server request attribute, such as "routing" + } elseif ($value instanceof ServerRequestInterface) { + $value = $value->getAttribute($currentKey); + } else { + // Public property + $value = $value->{$currentKey}; + } + } elseif (is_array($value)) { + $value = $value[$currentKey] ?? ''; + } else { + $value = ''; + break; + } + } + if (!is_scalar($value)) { + $value = ''; + } + return $value; + } + + /** + * Processing of key values pointing to entries in $arr; Here negative values are converted to positive keys pointer + * to an entry in the array but from behind (based on the negative value). + * Example: entrylevel = -1 means that entryLevel ends up pointing at the outermost-level, -2 means the level before the outermost + * + * @param int $key The integer to transform + * @param array $arr array in which the key should be found. + * @return int The processed integer key value. + * @internal + */ + public function getKey($key, array $arr): int + { + $key = (int)$key; + if ($key < 0) { + $key = count($arr) + $key; + } + if ($key < 0) { + $key = 0; + } + return $key; + } + + /** + * Implements the "typolink" property of stdWrap (and others) + * Basically the input string, $linkText, is (typically) wrapped in a -tag linking to some page, email address, + * file or URL based on a parameter defined by the configuration array $conf. + * This function is best used from internal functions as is. There are some API functions defined after this + * function which is more suited for general usage in external applications. + * + * Generally the concept "typolink" should be used in your own applications as an API for making links to pages with + * parameters and more. The reason for this is that you will then automatically make links compatible with all the + * centralized functions for URL simulation and manipulation of parameters into hashes and more. + * + * For many more details on the parameters and how they are interpreted, please see the link to TSref below. + * + * @param string $linkText The string (text) to link + * @param array $conf TypoScript configuration (see link below) + * @return string|LinkResult A link-wrapped string. + * @see stdWrap() + */ + public function typoLink(string $linkText, array $conf) + { + try { + $linkResult = $this->createLink($linkText, $conf); + } catch (UnableToLinkException $e) { + return $e->getLinkText(); + } + + // If flag "returnLast" set, then just return the latest URL / url / target that was built. + // This returns the information without being wrapped in a "LinkResult" object. + switch ($conf['returnLast'] ?? null) { + case 'url': + return $linkResult->getUrl(); + case 'target': + return $linkResult->getTarget(); + case 'result': + // kept for backwards-compatibility, as this was added in TYPO3 v11 + return LinkResult::adapt($linkResult, LinkResult::STRING_CAST_JSON); + } + + $wrap = (string)$this->stdWrapValue('wrap', $conf); + if ($conf['ATagBeforeWrap'] ?? false) { + $linkResult = $linkResult->withLinkText($this->wrap((string)$linkResult->getLinkText(), $wrap)); + return LinkResult::adapt($linkResult)->getHtml(); + } + $result = LinkResult::adapt($linkResult)->getHtml(); + return $this->wrap($result, $wrap); + } + + /** + * Similar to ->typoLink(), however it does not evaluate the .wrap and .ATagBeforeWrap + * functionality. + * + * For this reason, it also does not consider the LinkResult functionality, + * and "returnLast" logic, as the whole LinkResult object is available. + * + * It is recommended to use this method when working with PHP and wanting to create + * a typolink, but be aware that you need to escape the Link yourself as PHP developer depending + * on the needs. + * + * @param string $linkText the text to be wrapped in a link + * @param array $conf the typolink configuration + * @throws UnableToLinkException + * @see typoLink() + * @see createUrl() + */ + public function createLink(string $linkText, array $conf): LinkResultInterface + { + return $this->linkFactory->create($linkText, $conf, $this); + } + + /** + * This method creates a typoLink() and just returns the information of the "href" attribute + * of the link (most of the time, this is the URL). + * + * @param array $conf the typolink configuration. + * @return string The URL + * @see typoLink() + * @see createLink() + */ + public function createUrl(array $conf): string + { + try { + return $this->createLink('', $conf)->getUrl(); + } catch (UnableToLinkException) { + // @todo: Inconsistent. createLink() throws it, but createUrl() eats it? + // URL could not be generated + return ''; + } + } + + /** + * Based on the input "TypoLink" TypoScript configuration this will return the generated URL + * + * @param array $conf TypoScript properties for "typolink" + * @return string The URL of the link-tag that typoLink() would by itself return + */ + public function typoLink_URL($conf): string + { + return $this->createUrl($conf); + } + + /** + * Wrapping a string. + * Implements the TypoScript "wrap" property. + * Example: $content = "HELLO WORLD" and $wrap = " | ", result: "HELLO WORLD" + * + * @param string $content The content to wrap + * @param string $wrap The wrap value, eg. " | + * @param string $char The char used to split the wrapping value, default is "| + * @return string Wrapped input string + * @see noTrimWrap() + */ + public function wrap($content, $wrap, $char = '|') + { + if ($wrap) { + $wrapArr = explode($char, $wrap); + $content = trim($wrapArr[0]) . $content . trim($wrapArr[1] ?? ''); + } + return $content; + } + + /** + * Wrapping a string, preserving whitespace in wrap value. + * Notice that the wrap value uses part 1/2 to wrap (and not 0/1 which wrap() does) + * + * @param string $content The content to wrap, eg. "HELLO WORLD + * @param string $wrap The wrap value, eg. " | | + * @param string $char The char used to split the wrapping value, default is "|" + * @return string Wrapped input string, eg. " HELLO WORD + */ + public function noTrimWrap($content, $wrap, $char = '|') + { + if ($wrap) { + // expects to be wrapped with (at least) 3 characters (before, middle, after) + // anything else is not taken into account + $wrapArr = explode($char, $wrap, 4); + $content = ($wrapArr[1] ?? '') . $content . ($wrapArr[2] ?? ''); + } + return $content; + } + + /** + * Call a user function/class-method + * + * @param string|RawValue $funcName The functionname, eg "user_myfunction" or "user_myclass->main". Notice that there + * are rules for the names of functions/classes you can instantiate. If a function cannot + * be called for some reason it will be seen in the TypoScript log in the AdminPanel. + * @param array $conf The TypoScript configuration to pass the function + * @param mixed $content The content payload to pass the function + * @return mixed The return content from the function call. Should probably be a string. + */ + public function callUserFunction(string|RawValue $funcName, $conf, $content) + { + if ($funcName instanceof RawValue) { + $isTrusted = $funcName->trusted; + $funcName = $funcName->value; + } else { + $isTrusted = false; + } + $invokableAssertion = GeneralUtility::makeInstance(AllowedCallableAssertion::class); + // Split parts + $parts = explode('->', $funcName); + if (count($parts) === 2) { + // Check whether PHP class is available + if (class_exists($parts[0])) { + if (!$isTrusted) { + $invokableAssertion->assertCallable($parts); + } + if ($this->container->has($parts[0])) { + $classObj = $this->container->get($parts[0]); + } else { + $classObj = GeneralUtility::makeInstance($parts[0]); + } + $methodName = $parts[1]; + $callable = [$classObj, $methodName]; + + if (is_object($classObj) && method_exists($classObj, $parts[1]) && is_callable($callable)) { + if (is_callable([$classObj, 'setContentObjectRenderer'])) { + $classObj->setContentObjectRenderer($this); + } + $content = $callable($content, $conf, $this->getRequest()->withAttribute('currentContentObject', $this)); + } else { + $this->timeTracker->setTSlogMessage('Method "' . $parts[1] . '" did not exist in class "' . $parts[0] . '"', LogLevel::ERROR); + } + } else { + $this->timeTracker->setTSlogMessage('Class "' . $parts[0] . '" did not exist', LogLevel::ERROR); + } + } elseif (function_exists($funcName)) { + if (!$isTrusted) { + $invokableAssertion->assertCallable($funcName); + } + $content = $funcName($content, $conf, $this->getRequest()->withAttribute('currentContentObject', $this)); + } else { + $this->timeTracker->setTSlogMessage('Function "' . $funcName . '" did not exist', LogLevel::ERROR); + } + return $content; + } + + /** + * Cleans up a string of keywords. Keywords are split by "," (comma) ";" (semicolon) and linebreak + * + * @param string $content String of keywords + * @return string Cleaned up string, keywords will be separated by a comma only. + */ + public function keywords($content): string + { + $listArr = preg_split('/[,;' . LF . ']/', $content); + if ($listArr === false) { + return ''; + } + foreach ($listArr as $k => $v) { + $listArr[$k] = trim($v); + } + return implode(',', $listArr); + } + + /** + * Changing character case of a string, converting typically used western charset characters as well. + * + * @param string $theValue The string to change case for. + * @param string $case The direction; either "upper" or "lower + * @return string + * @internal + */ + public function caseshift($theValue, $case) + { + switch (strtolower($case)) { + case 'upper': + $theValue = mb_strtoupper($theValue, 'utf-8'); + break; + case 'lower': + $theValue = mb_strtolower($theValue, 'utf-8'); + break; + case 'capitalize': + $theValue = mb_convert_case($theValue, MB_CASE_TITLE, 'utf-8'); + break; + case 'ucfirst': + $firstChar = mb_substr($theValue, 0, 1, 'utf-8'); + $firstChar = mb_strtoupper($firstChar, 'utf-8'); + $remainder = mb_substr($theValue, 1, null, 'utf-8'); + $theValue = $firstChar . $remainder; + break; + case 'lcfirst': + $firstChar = mb_substr($theValue, 0, 1, 'utf-8'); + $firstChar = mb_strtolower($firstChar, 'utf-8'); + $remainder = mb_substr($theValue, 1, null, 'utf-8'); + $theValue = $firstChar . $remainder; + break; + case 'uppercamelcase': + $theValue = GeneralUtility::underscoredToUpperCamelCase($theValue); + break; + case 'lowercamelcase': + $theValue = GeneralUtility::underscoredToLowerCamelCase($theValue); + break; + } + return $theValue; + } + + /** + * Shifts the case of characters outside of HTML tags in the input string + * + * @param string $theValue The string to change case for. + * @param string $case The direction; either "upper" or "lower" + * @internal + */ + public function HTMLcaseshift($theValue, $case): string + { + $inside = 0; + $newVal = ''; + $pointer = 0; + $totalLen = strlen($theValue); + do { + if (!$inside) { + $len = strcspn(substr($theValue, $pointer), '<'); + $newVal .= $this->caseshift(substr($theValue, $pointer, $len), $case); + $inside = 1; + } else { + $len = strcspn(substr($theValue, $pointer), '>') + 1; + $newVal .= substr($theValue, $pointer, $len); + $inside = 0; + } + $pointer += $len; + } while ($pointer < $totalLen); + return $newVal; + } + + /** + * Returns the 'age' of the tstamp $seconds + * + * @param int $seconds Seconds to return age for. Example: "70" => "1 min", "3601" => "1 hrs + * @param string|int|null $labels The labels of the individual units. Defaults to : ' min| hrs| days| yrs' + */ + public function calcAge($seconds, $labels = null): string + { + $now = DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME']); + $then = DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME'] - $seconds); + // Show past dates without a leading sign, but future dates with. + // This does not make sense, but is kept for legacy reasons. + $sign = $then > $now ? '-' : ''; + // Take an absolute diff, since we don't want formatDateInterval to output the (correct) sign + $diff = $now->diff($then, true); + $labels = ($labels === null || MathUtility::canBeInterpretedAsInteger($labels)) ? 'min|hrs|days|yrs|min|hour|day|year' : str_replace('"', '', $labels); + return $sign . (new DateFormatter())->formatDateInterval($diff, $labels); + } + + /** + * Resolve a TypoScript reference value to the full set of properties BUT overridden with any local properties set. + * So the reference is resolved but overlaid with local TypoScript properties of the reference value. + * + * In short: This parses the "=<" operator for a couple of special properties like "parseFunc" and "tt_content.*". + * + * Note the "=<" operator is not a general TypoScript language construct, but applied here for a couple + * of special objects only. + * + * @param array $typoScriptArray The TypoScript array: ['someProperty' => 'somePropertyValue', 'someProperty.' => [ 'someSubProperty' => 'someSubValue', ... ] + * @param string $propertyName The property name: If this value in $typoScriptArray[$prop] is a reference (eg. "< lib.contentElement"), then + * the reference will be retrieved and inserted at that position and overlaid with given local properties if any. + * @return array The modified TypoScript array with resolved "=<" reference operator + * @internal + * @todo: It would be better if this method would get the setup object tree to resolve a + * ReferenceChildNode only once per node. This would however mean the object tree + * is moved around in the entire rendering chain, which is quite hard to achieve. + */ + public function mergeTSRef(array $typoScriptArray, string $propertyName): array + { + if (!isset($typoScriptArray[$propertyName]) || !str_starts_with($typoScriptArray[$propertyName], '<')) { + return $typoScriptArray; + } + $frontendTypoScript = $this->getRequest()->getAttribute('frontend.typoscript'); + if (!$frontendTypoScript || !$frontendTypoScript->hasSetup()) { + return $typoScriptArray; + } + $fullTypoScriptArray = $frontendTypoScript->getSetupArray(); + $dottedSourceIdentifier = trim(substr($typoScriptArray[$propertyName], 1)); + $dottedSourceIdentifierArray = StringUtility::explodeEscaped('.', $dottedSourceIdentifier); + $overrideConfig = $typoScriptArray[$propertyName . '.'] ?? []; + $resolvedValue = $dottedSourceIdentifier; + $resolvedConfig = $fullTypoScriptArray; + foreach ($dottedSourceIdentifierArray as $identifierPart) { + $resolvedValue = $resolvedConfig[$identifierPart] ?? $resolvedValue; + $resolvedConfig = $resolvedConfig[$identifierPart . '.'] ?? []; + } + $resolvedConfig = array_replace_recursive($resolvedConfig, $overrideConfig); + $typoScriptArray[$propertyName] = $resolvedValue; + $typoScriptArray[$propertyName . '.'] = $resolvedConfig; + if (!isset($typoScriptArray[$propertyName]) || !str_starts_with($typoScriptArray[$propertyName], '<')) { + return $typoScriptArray; + } + // Call recursive to resolve a nested =< operator + return $this->mergeTSRef($typoScriptArray, $propertyName); + } + + /** + * Generates a search where clause based on the input search words (AND operation - all search words must be found in record.) + * Example: The $sw is "content management, system" (from an input form) and the $searchFieldList is "bodytext,header" then + * the output will be ' AND (bodytext LIKE "%content%" OR header LIKE "%content%") AND (bodytext LIKE "%management%" OR header + * LIKE "%management%") AND (bodytext LIKE "%system%" OR header LIKE "%system%")' + * + * @param string $searchWords The search words. These will be separated by space and comma. + * @param string $searchFieldList The fields to search in + * @param string $searchTable The table name you search in (recommended for DBAL compliance. Will be prepended field names as well) + * @return string The WHERE clause. + */ + public function searchWhere($searchWords, $searchFieldList, $searchTable): string + { + if (!$searchWords) { + return ''; + } + + $prefixTableName = $searchTable ? $searchTable . '.' : ''; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($searchTable); + $where = $queryBuilder->expr()->and(); + $searchFields = explode(',', $searchFieldList); + $searchWords = preg_split('/[ ,]/', $searchWords); + foreach ($searchWords as $searchWord) { + $searchWord = trim($searchWord); + if (strlen($searchWord) < 3) { + continue; + } + $searchWordConstraint = $queryBuilder->expr()->or(); + $searchWord = $queryBuilder->escapeLikeWildcards($searchWord); + foreach ($searchFields as $field) { + $searchWordConstraint = $searchWordConstraint->with( + $queryBuilder->expr()->like($prefixTableName . $field, $queryBuilder->quote('%' . $searchWord . '%')) + ); + } + + if ($searchWordConstraint->count()) { + $where = $where->with($searchWordConstraint); + } + } + + if ((string)$where === '') { + return ''; + } + + return ' AND (' . $where . ')'; + } + + /** + * Executes a SELECT query for records from $table and with conditions based on the configuration in the $conf array + * This function is preferred over ->getQuery() if you just need to create and then execute a query. + * + * @param string $table The table name + * @param array $conf The TypoScript configuration properties + */ + public function exec_getQuery($table, $conf): Result + { + $connection = $this->connectionPool->getConnectionForTable($table); + $statement = $this->getQuery($connection, $table, $conf); + return $connection->executeQuery($statement); + } + + /** + * Executes a SELECT query for records from $table and with conditions based on the configuration in the $conf array + * and overlays with translation and version if available + * + * @param string $tableName the name of the TCA database table + * @param array $queryConfiguration The TypoScript configuration properties, see .select in TypoScript reference + * @throws \UnexpectedValueException + */ + public function getRecords($tableName, array $queryConfiguration): array + { + $records = []; + $statement = $this->exec_getQuery($tableName, $queryConfiguration); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + while ($row = $statement->fetchAssociative()) { + // Versioning preview: + $pageRepository->versionOL($tableName, $row, true); + // Language overlay: + if (is_array($row)) { + $row = $pageRepository->getLanguageOverlay($tableName, $row); + } + // Might be unset in the language overlay + if (is_array($row)) { + $records[] = $row; + } + } + if ($this->autoTagging) { + $cacheTags = array_map(fn(array $record) => new CacheTag( + name: sprintf('%s_%s', $tableName, ($record['uid'] ?? 0)), + lifetime: $this->cacheLifetimeCalculator->calculateLifetimeForRow($tableName, $record) + ), $records); + $this->getRequest()->getAttribute('frontend.cache.collector')?->addCacheTags(...$cacheTags); + } + return $records; + } + + /** + * Creates and returns a SELECT query for records from $table and with conditions based on the + * configuration in the $conf array. Implements the "select" function in TypoScript. + * + * @param string $table See ->exec_getQuery() + * @param array $conf See ->exec_getQuery() + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @internal + */ + public function getQuery(Connection $connection, string $table, array $conf): string + { + // Resolve stdWrap in these properties first + $properties = [ + 'pidInList', + 'uidInList', + 'languageField', + 'selectFields', + 'max', + 'begin', + 'groupBy', + 'orderBy', + 'join', + 'leftjoin', + 'rightjoin', + 'recursive', + 'where', + ]; + foreach ($properties as $property) { + $conf[$property] = trim( + isset($conf[$property . '.']) + ? (string)$this->stdWrap($conf[$property] ?? '', $conf[$property . '.'] ?? []) + : (string)($conf[$property] ?? '') + ); + if ($conf[$property] === '') { + unset($conf[$property]); + } elseif (in_array($property, ['languageField', 'selectFields', 'join', 'leftjoin', 'rightjoin', 'where'], true)) { + $conf[$property] = QueryHelper::quoteDatabaseIdentifiers($connection, $conf[$property]); + } + if (isset($conf[$property . '.'])) { + // stdWrapping already done, so remove the sub-array + unset($conf[$property . '.']); + } + } + // Handle PDO-style named parameter markers first + $queryMarkers = $this->getQueryMarkers($connection, $conf); + // Replace the markers in the non-stdWrap properties + foreach ($queryMarkers as $marker => $markerValue) { + $properties = [ + 'uidInList', + 'selectFields', + 'where', + 'max', + 'begin', + 'groupBy', + 'orderBy', + 'join', + 'leftjoin', + 'rightjoin', + ]; + foreach ($properties as $property) { + if ($conf[$property] ?? false) { + $conf[$property] = str_replace('###' . $marker . '###', $markerValue, $conf[$property]); + } + } + } + + // Construct WHERE clause: + // Handle recursive function for the pidInList + if (isset($conf['recursive'])) { + $conf['recursive'] = (int)$conf['recursive']; + if ($conf['recursive'] > 0) { + $pidList = GeneralUtility::trimExplode(',', $conf['pidInList'], true); + array_walk($pidList, function (&$storagePid) { + if ($storagePid === 'this') { + $storagePid = $this->getRequest()->getAttribute('frontend.page.information')->getId(); + } + }); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + $expandedPidList = $pageRepository->getPageIdsRecursive($pidList, $conf['recursive']); + $conf['pidInList'] = implode(',', $expandedPidList); + } + } + if ((string)($conf['pidInList'] ?? '') === '') { + $conf['pidInList'] = 'this'; + } + + $queryParts = $this->getQueryConstraints($connection, $table, $conf); + + $queryBuilder = $connection->createQueryBuilder(); + // @todo Check against getQueryConstraints, can probably use FrontendRestrictions + // @todo here and remove enableFields there. + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->select('*')->from($table); + + if ($queryParts['where'] ?? false) { + $queryBuilder->where($queryParts['where']); + } + + if ($queryParts['groupBy'] ?? false) { + $queryBuilder->groupBy(...$queryParts['groupBy']); + } + + if (is_array($queryParts['orderBy'] ?? false)) { + foreach ($queryParts['orderBy'] as $orderBy) { + $queryBuilder->addOrderBy(...$orderBy); + } + } + + // Fields: + if ($conf['selectFields'] ?? false) { + $queryBuilder->selectLiteral($this->sanitizeSelectPart($connection, $conf['selectFields'], $table)); + } + + // Setting LIMIT: + if (($conf['max'] ?? false) || ($conf['begin'] ?? false)) { + // Finding the total number of records, if used: + if (str_contains(strtolower(($conf['begin'] ?? '') . ($conf['max'] ?? '')), 'total')) { + $countQueryBuilder = $connection->createQueryBuilder(); + $countQueryBuilder->getRestrictions()->removeAll(); + $countQueryBuilder->count('*') + ->from($table) + ->where($queryParts['where']); + + if (is_array($queryParts['groupBy'])) { + $countQueryBuilder->groupBy(...$queryParts['groupBy']); + } + + try { + $count = $countQueryBuilder->executeQuery()->fetchOne(); + if (isset($conf['max'])) { + $conf['max'] = str_ireplace('total', $count, (string)$conf['max']); + } + if (isset($conf['begin'])) { + $conf['begin'] = str_ireplace('total', $count, (string)$conf['begin']); + } + } catch (DBALException $e) { + $this->timeTracker->setTSlogMessage($e->getMessage()); + return ''; + } + } + + if (isset($conf['begin']) && $conf['begin'] > 0) { + $conf['begin'] = MathUtility::forceIntegerInRange((int)ceil($this->calc($conf['begin'])), 0); + $queryBuilder->setFirstResult($conf['begin']); + } + if (isset($conf['max'])) { + $conf['max'] = MathUtility::forceIntegerInRange((int)ceil($this->calc($conf['max'])), 0); + $queryBuilder->setMaxResults($conf['max'] ?: 100000); + } + } + + // Setting up tablejoins: + if ($conf['join'] ?? false) { + $joinParts = QueryHelper::parseJoin($conf['join']); + $queryBuilder->join( + $table, + $joinParts['tableName'], + $joinParts['tableAlias'], + $joinParts['joinCondition'] + ); + } elseif ($conf['leftjoin'] ?? false) { + $joinParts = QueryHelper::parseJoin($conf['leftjoin']); + $queryBuilder->leftJoin( + $table, + $joinParts['tableName'], + $joinParts['tableAlias'], + $joinParts['joinCondition'] + ); + } elseif ($conf['rightjoin'] ?? false) { + $joinParts = QueryHelper::parseJoin($conf['rightjoin']); + $queryBuilder->rightJoin( + $table, + $joinParts['tableName'], + $joinParts['tableAlias'], + $joinParts['joinCondition'] + ); + } + + // Convert the QueryBuilder object into a SQL statement. + $query = $queryBuilder->getSQL(); + + // Replace the markers in the queryParts to handle stdWrap enabled properties + foreach ($queryMarkers as $marker => $markerValue) { + // @todo Ugly hack that needs to be cleaned up, with the current architecture + // @todo for exec_Query / getQuery it's the best we can do. + $query = str_replace('###' . $marker . '###', $markerValue, $query); + } + return $query; + } + + /** + * Helper function for getQuery(), creating the WHERE clause of the SELECT query + * + * @param string $table The table name + * @param array $conf The TypoScript configuration properties + * @return array Associative array containing the prepared data for WHERE, ORDER BY and GROUP BY fragments + * @see getQuery() + */ + protected function getQueryConstraints(Connection $connection, string $table, array $conf): array + { + $queryBuilder = $connection->createQueryBuilder(); + $expressionBuilder = $queryBuilder->expr(); + $request = $this->getRequest(); + $contentPid = $request->getAttribute('frontend.page.information')->getContentFromPid(); + $constraints = []; + $pid_uid_flag = 0; + $enableFieldsIgnore = []; + $queryParts = [ + 'where' => null, + 'groupBy' => null, + 'orderBy' => null, + ]; + + $isInWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline'); + + if (trim($conf['uidInList'] ?? '')) { + $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['uidInList'])); + + // If moved records shall be considered, select via t3ver_oid + $considerMovePointers = $isInWorkspace && $table !== 'pages' && $this->getTcaSchema($table)?->isWorkspaceAware(); + if ($considerMovePointers) { + $constraints[] = (string)$expressionBuilder->or( + $expressionBuilder->in($table . '.uid', $listArr), + $expressionBuilder->and( + $expressionBuilder->eq( + $table . '.t3ver_state', + VersionState::MOVE_POINTER->value + ), + $expressionBuilder->in($table . '.t3ver_oid', $listArr) + ) + ); + } else { + $constraints[] = (string)$expressionBuilder->in($table . '.uid', $listArr); + } + $pid_uid_flag++; + } + + // Static_* tables are allowed to be fetched from root page + if (str_starts_with($table, 'static_')) { + $pid_uid_flag++; + } + + if (trim($conf['pidInList'])) { + $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['pidInList'])); + // Removes all pages which are not visible for the user! + $listArr = $this->checkPidArray($listArr); + if (GeneralUtility::inList($conf['pidInList'], 'root')) { + $listArr[] = 0; + } + if (GeneralUtility::inList($conf['pidInList'], '-1')) { + $listArr[] = -1; + $enableFieldsIgnore['pid'] = true; + } + if (!empty($listArr)) { + $constraints[] = $expressionBuilder->in($table . '.pid', array_map('intval', $listArr)); + $pid_uid_flag++; + } else { + // If not uid and not pid then uid is set to 0 - which results in nothing!! + $pid_uid_flag = 0; + } + } + + // If not uid and not pid then uid is set to 0 - which results in nothing!! + if (!$pid_uid_flag) { + $constraints[] = $expressionBuilder->eq($table . '.uid', 0); + } + + $where = trim((string)$this->stdWrapValue('where', $conf)); + if ($where) { + $constraints[] = QueryHelper::stripLogicalOperatorPrefix($where); + } + + // Check if the default language should be fetched (= doing overlays), or if only the records of a language should be fetched + // but only do this for TCA tables that have languages enabled + $languageConstraint = $this->getLanguageRestriction($expressionBuilder, $table, $conf); + if ($languageConstraint !== null) { + $constraints[] = $languageConstraint; + } + + // default constraints from TCA + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + $constraints = array_merge($constraints, array_values($pageRepository->getDefaultConstraints($table, $enableFieldsIgnore))); + + // MAKE WHERE: + if ($constraints !== []) { + $queryParts['where'] = $expressionBuilder->and(...$constraints); + } + // GROUP BY + $groupBy = trim((string)$this->stdWrapValue('groupBy', $conf)); + if ($groupBy) { + $queryParts['groupBy'] = QueryHelper::parseGroupBy($groupBy); + } + + // ORDER BY + $orderByString = trim((string)$this->stdWrapValue('orderBy', $conf)); + if ($orderByString) { + $queryParts['orderBy'] = QueryHelper::parseOrderBy($orderByString); + } + + // Return result: + return $queryParts; + } + + /** + * Adds parts to the WHERE clause that are related to language. + * This only works on TCA tables which have the [ctrl][languageField] field set or if they + * have select.languageField = my_language_field set explicitly. + * + * It is also possible to disable the language restriction for a query by using select.languageField = 0, + * if select.languageField is not explicitly set, the TCA default values are taken. + * + * If the table is "localizeable" (= any of the criteria above is met), then the DB query is restricted: + * + * If the current language aspect has overlays enabled, then the only records with language "0" or "-1" are + * fetched (the overlays are taken care of later-on). + * if the current language has overlays but also records without localization-parent (free mode) available, + * then these are fetched as well. This can explicitly set via select.includeRecordsWithoutDefaultTranslation = 1 + * which overrules the overlayType within the language aspect. + * + * If the language aspect has NO overlays enabled, it behaves as in "free mode" (= only fetch the records + * for the current language. + */ + protected function getLanguageRestriction(ExpressionBuilder $expressionBuilder, string $table, array $conf): string|CompositeExpression|null + { + $languageField = ''; + $localizationParentField = ''; + $languageCapability = null; + $schema = $this->getTcaSchema($table); + if ($schema?->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $localizationParentField = $languageCapability->getTranslationOriginPointerField()->getName(); + } + // Check if the table is translatable, and set the language field by default from the TCA information + if (!empty($conf['languageField']) || !isset($conf['languageField'])) { + if (isset($conf['languageField']) && $schema?->hasField($conf['languageField'])) { + $languageField = $conf['languageField']; + } elseif ($languageCapability) { + $languageField = $table . '.' . $languageCapability->getLanguageField()->getName(); + } + } + + // No language restriction enabled explicitly or available via TCA + if (empty($languageField)) { + return null; + } + + /** @var LanguageAspect $languageAspect */ + $languageAspect = $this->context->getAspect('language'); + if ($languageAspect->doOverlays() && !empty($localizationParentField)) { + // Sys language content is set to zero/-1 - and it is expected that whatever routine processes the output will + // OVERLAY the records with localized versions! + $languageQuery = $expressionBuilder->in($languageField, [0, -1]); + // Use this option to include records that don't have a default language counterpart ("free mode") + // (originalpointerfield is 0 and the language field contains the requested language) + if (isset($conf['includeRecordsWithoutDefaultTranslation']) || !empty($conf['includeRecordsWithoutDefaultTranslation.'])) { + $includeRecordsWithoutDefaultTranslation = isset($conf['includeRecordsWithoutDefaultTranslation.']) + ? $this->stdWrap($conf['includeRecordsWithoutDefaultTranslation'], $conf['includeRecordsWithoutDefaultTranslation.']) + : $conf['includeRecordsWithoutDefaultTranslation']; + $includeRecordsWithoutDefaultTranslation = trim((string)$includeRecordsWithoutDefaultTranslation); + $includeRecordsWithoutDefaultTranslation = $includeRecordsWithoutDefaultTranslation !== '' && $includeRecordsWithoutDefaultTranslation !== '0'; + } else { + // Option was not explicitly set, check what's in for the language overlay type. + // OVERLAYS_ON means that we do not include the "floating" records (records without default translation) + $includeRecordsWithoutDefaultTranslation = $languageAspect->getOverlayType() !== $languageAspect::OVERLAYS_ON; + } + if ($includeRecordsWithoutDefaultTranslation) { + $languageQuery = $expressionBuilder->or( + $languageQuery, + $expressionBuilder->and( + $expressionBuilder->eq($table . '.' . $localizationParentField, 0), + $expressionBuilder->eq($languageField, $languageAspect->getContentId()) + ) + ); + } + return $languageQuery; + } + // No overlays = only fetch records given for the requested language and "all languages" + return $expressionBuilder->in($languageField, [$languageAspect->getContentId(), -1]); + } + + /** + * Helper function for getQuery, sanitizing the select part + * + * This functions checks if the necessary fields are part of the select + * and adds them if necessary. + * + * @see getQuery + */ + protected function sanitizeSelectPart(Connection $connection, string $selectPart, string $table): string + { + // Pattern matching parts + $matchStart = '/(^\\s*|,\\s*|' . $table . '\\.)'; + $matchEnd = '(\\s*,|\\s*$)/'; + $necessaryFields = ['uid', 'pid']; + $wsFields = ['t3ver_state']; + $schema = $this->getTcaSchema($table); + if ($schema === null) { + return $selectPart; + } + + if (!preg_match($matchStart . '\\*' . $matchEnd, $selectPart) && !preg_match('/(count|max|min|avg|sum)\\([^\\)]+\\)|distinct/i', $selectPart)) { + foreach ($necessaryFields as $field) { + $match = $matchStart . $field . $matchEnd; + if (!preg_match($match, $selectPart)) { + $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field); + } + } + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $match = $matchStart . $languageField . $matchEnd; + if (!preg_match($match, $selectPart)) { + $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $languageField) . ' AS ' . $connection->quoteIdentifier($languageField); + } + } + if ($schema->isWorkspaceAware()) { + foreach ($wsFields as $field) { + $match = $matchStart . $field . $matchEnd; + if (!preg_match($match, $selectPart)) { + $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field); + } + } + } + } + return $selectPart; + } + + /** + * Removes Page UID numbers from the input array which are not available due to enableFields(). + * + * @param int[] $pageIds Array of Page UID numbers for select and for which pages with enablefields should be removed. + * @return array Returns the array of remaining page UID numbers + * @internal + */ + public function checkPidArray(array $pageIds): array + { + if ($pageIds === []) { + return []; + } + + if ($pageIds === [$this->getRequest()->getAttribute('frontend.page.information')->getId()]) { + // Middlewares already checked access to the current page and made sure the current doktype + // is a doktype whose content should be rendered, so there is no need to check that again. + return $pageIds; + } + $restrictionContainer = GeneralUtility::makeInstance(FrontendRestrictionContainer::class); + $pageRepository = GeneralUtility::makeInstance(PageRepository::class); + return $pageRepository->filterAccessiblePageIds($pageIds, $restrictionContainer); + } + + /** + * Builds list of marker values for handling PDO-like parameter markers in select parts. + * Marker values support stdWrap functionality thus allowing a way to use stdWrap functionality in various + * properties of 'select' AND prevents SQL-injection problems by quoting and escaping of numeric values, + * strings, NULL values and comma separated lists. + * + * @param array $conf Select part of CONTENT definition + * @return array List of values to replace markers with + * @internal + * @see getQuery() + */ + public function getQueryMarkers(Connection $connection, array $conf): array + { + if (!isset($conf['markers.']) || !is_array($conf['markers.'])) { + return []; + } + $markerValues = []; + foreach ($conf['markers.'] as $dottedMarker => $dummy) { + $marker = rtrim($dottedMarker, '.'); + if ($dottedMarker != $marker . '.') { + continue; + } + // Parse definition + // todo else value is always null + $tempValue = isset($conf['markers.'][$dottedMarker]) + ? $this->stdWrap($conf['markers.'][$dottedMarker]['value'] ?? '', $conf['markers.'][$dottedMarker]) + : $conf['markers.'][$dottedMarker]['value']; + // Quote/escape if needed + if (is_numeric($tempValue)) { + if ((int)$tempValue == $tempValue) { + // Handle integer + $markerValues[$marker] = (int)$tempValue; + } else { + // Handle float + $markerValues[$marker] = (float)$tempValue; + } + } elseif ($tempValue === null) { + // It represents NULL + $markerValues[$marker] = 'NULL'; + } elseif (!empty($conf['markers.'][$dottedMarker]['commaSeparatedList'])) { + // See if it is really a comma separated list of values + $explodeValues = GeneralUtility::trimExplode(',', $tempValue); + if (count($explodeValues) > 1) { + // Handle each element of list separately + $tempArray = []; + foreach ($explodeValues as $listValue) { + if (is_numeric($listValue)) { + if ((int)$listValue == $listValue) { + $tempArray[] = (int)$listValue; + } else { + $tempArray[] = (float)$listValue; + } + } else { + // If quoted, remove quotes before + // escaping. + if (preg_match('/^\'([^\']*)\'$/', $listValue, $matches)) { + $listValue = $matches[1]; + } elseif (preg_match('/^\\"([^\\"]*)\\"$/', $listValue, $matches)) { + $listValue = $matches[1]; + } + $tempArray[] = $connection->quote($listValue); + } + } + $markerValues[$marker] = implode(',', $tempArray); + } else { + // Handle remaining values as string + $markerValues[$marker] = $connection->quote($tempValue); + } + } else { + // Handle remaining values as string + $markerValues[$marker] = $connection->quote($tempValue); + } + } + return $markerValues; + } + + /** + * Fetch content from cache + * + * @return string|false FALSE on cache miss + */ + protected function getFromCache(array $configuration): string|false + { + if (!$this->getRequest()->getAttribute('frontend.cache.instruction')->isCachingAllowed()) { + return false; + } + $cacheKey = $this->calculateCacheKey($configuration); + if (empty($cacheKey)) { + return false; + } + $cachedData = $this->cacheHash->get($cacheKey); + if ($cachedData === false) { + return false; + } + $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags( + ...array_map(fn(string $tag) => new CacheTag($tag), $cachedData['cacheTags']) + ); + return $cachedData['content'] ?? false; + } + + /** + * Calculates the lifetime of a cache entry based on the given configuration + */ + protected function calculateCacheLifetime(array $configuration): int + { + $configuration['lifetime'] = $configuration['lifetime'] ?? ''; + $lifetimeConfiguration = (string)$this->stdWrapValue('lifetime', $configuration); + + if (strtolower($lifetimeConfiguration) === 'unlimited') { + $lifetime = 31536000; // unlimited lifetime - 1 year. + } elseif (strtolower($lifetimeConfiguration) === 'default') { + $lifetime = $this->getDefaultCachePeriod(); // default lifetime of config.cache_period or 86400 seconds + } elseif ($lifetimeConfiguration > 0) { + $lifetime = (int)$lifetimeConfiguration; + } else { + // If no lifetime is specified, we use the default cache period. + $lifetime = $this->getDefaultCachePeriod(); + } + return $lifetime; + } + + /** + * Returns the default cache period in seconds + */ + protected function getDefaultCachePeriod(): int + { + $frontendTyposcript = $this->getRequest()->getAttribute('frontend.typoscript'); + return (int)($frontendTyposcript->getConfigArray()['cache_period'] ?? 86400); + } + + /** + * Calculates the tags for a cache entry bases on the given configuration + * + * @return array + */ + protected function calculateCacheTags(array $configuration) + { + $configuration['tags'] = $configuration['tags'] ?? ''; + $tags = (string)$this->stdWrapValue('tags', $configuration); + return empty($tags) ? [] : GeneralUtility::trimExplode(',', $tags); + } + + /** + * Applies stdWrap to the cache key + * + * @return string + */ + protected function calculateCacheKey(array $configuration) + { + $configuration['key'] = $configuration['key'] ?? ''; + return $this->stdWrapValue('key', $configuration); + } + + protected function getTcaSchema(string $table): ?TcaSchema + { + return $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null; + } + + /** + * Get content length of the current tag that could also contain nested tag contents + * Helper method of parseFuncInternal(). + */ + protected function getContentLengthOfCurrentTag(string $theValue, int $pointer, string $currentTag): int + { + $tempContent = strtolower(substr($theValue, $pointer)); + $startTag = '<' . $currentTag; + $endTag = ''; + $offsetCount = 0; + + // Take care for nested tags + do { + $nextMatchingEndTagPosition = strpos($tempContent, $endTag); + // only match tag `a` in `` but not in `` + $nextSameTypeTagPosition = preg_match( + '#' . $startTag . '[\s/>]#', + $tempContent, + $nextSameStartTagMatches, + PREG_OFFSET_CAPTURE + ) ? $nextSameStartTagMatches[0][1] : false; + + // filter out nested tag contents to help getting the correct closing tag + if ($nextMatchingEndTagPosition !== false && $nextSameTypeTagPosition !== false && $nextSameTypeTagPosition < $nextMatchingEndTagPosition) { + $lastOpeningTagStartPosition = (int)strrpos(substr($tempContent, 0, $nextMatchingEndTagPosition), $startTag); + $closingTagEndPosition = $nextMatchingEndTagPosition + strlen($endTag); + $offsetCount += $closingTagEndPosition - $lastOpeningTagStartPosition; + + // replace content from latest tag start to latest tag end + $tempContent = substr($tempContent, 0, $lastOpeningTagStartPosition) . substr($tempContent, $closingTagEndPosition); + } + } while ( + ($nextMatchingEndTagPosition !== false && $nextSameTypeTagPosition !== false) + && $nextSameTypeTagPosition < $nextMatchingEndTagPosition + ); + + // if no closing tag is found we use length of the whole content + $endingOffset = strlen($tempContent); + if ($nextMatchingEndTagPosition !== false) { + $endingOffset = $nextMatchingEndTagPosition + $offsetCount; + } + + return $endingOffset; + } + + protected function shallDebug(): bool + { + $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')?->getConfigArray(); + if (isset($typoScriptConfigArray['debug'])) { + return (bool)($typoScriptConfigArray['debug']); + } + return !empty($GLOBALS['TYPO3_CONF_VARS']['FE']['debug']); + } + + /** + * @todo: This getRequest() is still a bit messy. + * Underling code depends on both, a ContentObjectRenderer instance and a request, + * but the API currently only passes one or the other. For instance Extbase and Fluid + * only pass the Request, DataProcessors only a ContentObjectRenderer. + * This is why getRequest() is currently public here. + * A potential refactoring could: + * * Create interfaces to pass both where needed (or pass a combined context object) + * * Deprecate access to getRequest() here afterward + * A circular dependency that the instance of ContentObjectRenderer holds a + * request with the instance of itself as attribute must be avoided. + * This is currently achieved by adding a new request with + * $this->request->withAttribute('currentContentObject', $cObj) in code that needs + * it, but this new request is NOT passed back into the ContentObjectRenderer instance. + * + * @internal + */ + public function getRequest(): ServerRequestInterface + { + if ($this->request instanceof ServerRequestInterface) { + return $this->request; + } + throw new ContentRenderingException( + 'PSR-7 request is missing in ContentObjectRenderer. Call setRequest() after object instantiation.', + 1607172972 + ); + } +} diff --git a/Classes/ContentObject/DataProcessorInterface.php b/Classes/ContentObject/DataProcessorInterface.php new file mode 100644 index 0000000..f048cd0 --- /dev/null +++ b/Classes/ContentObject/DataProcessorInterface.php @@ -0,0 +1,39 @@ +contentObjectRenderer; + } +} diff --git a/Classes/ContentObject/Event/AfterGetDataResolvedEvent.php b/Classes/ContentObject/Event/AfterGetDataResolvedEvent.php new file mode 100644 index 0000000..f1bf288 --- /dev/null +++ b/Classes/ContentObject/Event/AfterGetDataResolvedEvent.php @@ -0,0 +1,58 @@ +getData() result + */ +final class AfterGetDataResolvedEvent +{ + public function __construct( + private readonly string $parameterString, + private readonly array $alternativeFieldArray, + private mixed $result, + private readonly ContentObjectRenderer $contentObjectRenderer + ) {} + + public function getResult(): mixed + { + return $this->result; + } + + public function setResult(mixed $result): void + { + $this->result = $result; + } + + public function getParameterString(): string + { + return $this->parameterString; + } + + public function getAlternativeFieldArray(): array + { + return $this->alternativeFieldArray; + } + + public function getContentObjectRenderer(): ContentObjectRenderer + { + return $this->contentObjectRenderer; + } +} diff --git a/Classes/ContentObject/Event/AfterImageResourceResolvedEvent.php b/Classes/ContentObject/Event/AfterImageResourceResolvedEvent.php new file mode 100644 index 0000000..654dfb5 --- /dev/null +++ b/Classes/ContentObject/Event/AfterImageResourceResolvedEvent.php @@ -0,0 +1,54 @@ +getImgResource() result + */ +final class AfterImageResourceResolvedEvent +{ + public function __construct( + private readonly string|File|FileReference $file, + private readonly array $fileArray, + private ?ImageResource $imageResource + ) {} + + public function getFile(): string|File|FileReference + { + return $this->file; + } + + public function getFileArray(): array + { + return $this->fileArray; + } + + public function getImageResource(): ?ImageResource + { + return $this->imageResource; + } + + public function setImageResource(?ImageResource $imageResource): void + { + $this->imageResource = $imageResource; + } +} diff --git a/Classes/ContentObject/Event/AfterStdWrapFunctionsExecutedEvent.php b/Classes/ContentObject/Event/AfterStdWrapFunctionsExecutedEvent.php new file mode 100644 index 0000000..1a0426d --- /dev/null +++ b/Classes/ContentObject/Event/AfterStdWrapFunctionsExecutedEvent.php @@ -0,0 +1,23 @@ +content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function getTags(): array + { + return $this->tags; + } + + public function setTags(array $tags): void + { + $this->tags = $tags; + } + + public function getKey(): string + { + return $this->key; + } + + public function setKey(string $key): void + { + $this->key = $key; + } + + public function getLifetime(): ?int + { + return $this->lifetime; + } + + public function setLifetime(?int $lifetime): void + { + $this->lifetime = $lifetime; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function getContentObjectRenderer(): ContentObjectRenderer + { + return $this->contentObjectRenderer; + } +} diff --git a/Classes/ContentObject/Event/BeforeStdWrapFunctionsExecutedEvent.php b/Classes/ContentObject/Event/BeforeStdWrapFunctionsExecutedEvent.php new file mode 100644 index 0000000..003a7ae --- /dev/null +++ b/Classes/ContentObject/Event/BeforeStdWrapFunctionsExecutedEvent.php @@ -0,0 +1,23 @@ +content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function getContentObjectRenderer(): ContentObjectRenderer + { + return $this->contentObjectRenderer; + } +} diff --git a/Classes/ContentObject/Event/ModifyImageSourceCollectionEvent.php b/Classes/ContentObject/Event/ModifyImageSourceCollectionEvent.php new file mode 100644 index 0000000..3ff011b --- /dev/null +++ b/Classes/ContentObject/Event/ModifyImageSourceCollectionEvent.php @@ -0,0 +1,64 @@ +sourceCollection = $sourceCollection; + } + + public function getSourceCollection(): string + { + return $this->sourceCollection; + } + + public function getFullSourceCollection(): string + { + return $this->fullSourceCollection; + } + + public function getSourceConfiguration(): array + { + return $this->sourceConfiguration; + } + + public function getSourceRenderConfiguration(): array + { + return $this->sourceRenderConfiguration; + } + + public function getContentObjectRenderer(): ContentObjectRenderer + { + return $this->contentObjectRenderer; + } +} diff --git a/Classes/ContentObject/Event/ModifyRecordsAfterFetchingContentEvent.php b/Classes/ContentObject/Event/ModifyRecordsAfterFetchingContentEvent.php new file mode 100644 index 0000000..d3cd97c --- /dev/null +++ b/Classes/ContentObject/Event/ModifyRecordsAfterFetchingContentEvent.php @@ -0,0 +1,110 @@ +records; + } + + public function setRecords(array $records): void + { + $this->records = $records; + } + + public function getFinalContent(): string + { + return $this->finalContent; + } + + public function setFinalContent(string $finalContent): void + { + $this->finalContent = $finalContent; + } + + public function getSlide(): int + { + return $this->slide; + } + + public function setSlide(int $slide): void + { + $this->slide = $slide; + } + + public function getSlideCollect(): int + { + return $this->slideCollect; + } + + public function setSlideCollect(int $slideCollect): void + { + $this->slideCollect = $slideCollect; + } + + public function getSlideCollectReverse(): bool + { + return $this->slideCollectReverse; + } + + public function setSlideCollectReverse(bool $slideCollectReverse): void + { + $this->slideCollectReverse = $slideCollectReverse; + } + + public function getSlideCollectFuzzy(): bool + { + return $this->slideCollectFuzzy; + } + + public function setSlideCollectFuzzy(bool $slideCollectFuzzy): void + { + $this->slideCollectFuzzy = $slideCollectFuzzy; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/ContentObject/Exception/ContentRenderingException.php b/Classes/ContentObject/Exception/ContentRenderingException.php new file mode 100644 index 0000000..30bdb59 --- /dev/null +++ b/Classes/ContentObject/Exception/ContentRenderingException.php @@ -0,0 +1,23 @@ +configuration = $configuration; + } + + /** + * Handles exceptions thrown during rendering of content objects + * The handler can decide whether to re-throw the exception or + * return a nice error message for production context. + * + * @param array $contentObjectConfiguration + * @throws \Exception + */ + public function handle(\Exception $exception, ?AbstractContentObject $contentObject = null, $contentObjectConfiguration = []): string + { + // ImmediateResponseException (and the derived PropagateResponseException) should work similar to + // exit / die and must therefore not be handled by this ExceptionHandler. + if ($exception instanceof ImmediateResponseException) { + throw $exception; + } + + if (!empty($this->configuration['ignoreCodes.']) + && in_array($exception->getCode(), array_map('intval', $this->configuration['ignoreCodes.']), true) + ) { + throw $exception; + } + + $errorMessage = $this->configuration['errorMessage'] ?? 'Oops, an error occurred! Request: {requestId}'; + + // $code and it's placeholder %s for b/w compatibility + $code = $this->context->getAspect('date')->getDateTime()->format('YmdHis') . $this->random->generateRandomHexString(8); + $errorMessage = str_replace('%s', '{code}', $errorMessage); + + // Log exception except HMAC validation exceptions caused by potentially forged requests + if (!in_array($exception->getCode(), AbstractExceptionHandler::IGNORED_HMAC_EXCEPTION_CODES, true)) { + $this->logger->alert($errorMessage, ['exception' => $exception, 'code' => $code, 'requestId' => $this->requestId]); + } + + // Return interpolated error message + return str_replace(['{code}', '{requestId}'], [$code, (string)$this->requestId], $errorMessage); + } +} diff --git a/Classes/ContentObject/FileLinkHookInterface.php b/Classes/ContentObject/FileLinkHookInterface.php new file mode 100644 index 0000000..653435e --- /dev/null +++ b/Classes/ContentObject/FileLinkHookInterface.php @@ -0,0 +1,31 @@ +cObj->checkIf($conf['if.'])) { + return ''; + } + $register = $this->request->getAttribute('frontend.register.stack')->current(); + // Store the original "currentFile" within a variable so it can be re-applied later-on + $originalFileInContentObject = $this->cObj->getCurrentFile(); + + $fileCollector = $this->findAndSortFiles($conf); + $fileObjects = $fileCollector->getFiles(); + $availableFileObjectCount = count($fileObjects); + + // optionSplit applied to conf to allow different settings per file + $splitConf = GeneralUtility::makeInstance(TypoScriptService::class) + ->explodeConfigurationForOptionSplit($conf, $availableFileObjectCount); + + $start = (int)$this->cObj->stdWrapValue('begin', $conf, 0); + $start = MathUtility::forceIntegerInRange($start, 0, $availableFileObjectCount); + + $limit = (int)$this->cObj->stdWrapValue('maxItems', $conf, $availableFileObjectCount); + $end = MathUtility::forceIntegerInRange($start + $limit, $start, $availableFileObjectCount); + + $register->set('FILES_COUNT', min($limit, $availableFileObjectCount)); + $fileObjectCounter = 0; + $keys = array_keys($fileObjects); + + $content = ''; + for ($i = $start; $i < $end; $i++) { + $key = $keys[$i]; + $fileObject = $fileObjects[$key]; + $register->set('FILE_NUM_CURRENT', $fileObjectCounter); + $this->cObj->setCurrentFile($fileObject); + $content .= $this->cObj->cObjGetSingle($splitConf[$key]['renderObj'], $splitConf[$key]['renderObj.'], 'renderObj'); + $fileObjectCounter++; + } + + // Reset current file within cObj to the original file after rendering output of FILES + // so e.g. stdWrap is not working on the last current file applied, thus avoiding side-effects + $this->cObj->setCurrentFile($originalFileInContentObject); + + return $this->cObj->stdWrap($content, $conf['stdWrap.'] ?? []); + } + + /** + * Function to check for references, collections, folders and + * accumulates into one etc. + */ + protected function findAndSortFiles(array $conf): FileCollector + { + $fileCollector = $this->getFileCollector(); + + // Getting the files + if ((isset($conf['references']) && $conf['references']) || (isset($conf['references.']) && $conf['references.'])) { + /* + The TypoScript could look like this: + # all items related to the page.media field: + references { + table = pages + uid.data = page:uid + fieldName = media + } + # or: sys_file_references with uid 27: + references = 27 + */ + $referencesUidList = (string)$this->cObj->stdWrapValue('references', $conf); + $referencesUids = GeneralUtility::intExplode(',', $referencesUidList, true); + $fileCollector->addFileReferences($referencesUids); + + if (!empty($conf['references.'])) { + $this->addFileReferences($conf, (array)$this->cObj->data, $fileCollector); + } + } + + if ((isset($conf['files']) && $conf['files']) || (isset($conf['files.']) && $conf['files.'])) { + /* + The TypoScript could look like this: + # with sys_file UIDs: + files = 12,14,15# using stdWrap: + files.field = some_field + */ + $fileUids = GeneralUtility::intExplode(',', (string)$this->cObj->stdWrapValue('files', $conf), true); + $fileCollector->addFiles($fileUids); + } + + if ((isset($conf['collections']) && $conf['collections']) || (isset($conf['collections.']) && $conf['collections.'])) { + $collectionUids = GeneralUtility::intExplode(',', (string)$this->cObj->stdWrapValue('collections', $conf), true); + $fileCollector->addFilesFromFileCollections($collectionUids); + } + + if ((isset($conf['folders']) && $conf['folders']) || (isset($conf['folders.']) && $conf['folders.'])) { + $folderIdentifiers = GeneralUtility::trimExplode(',', (string)$this->cObj->stdWrapValue('folders', $conf)); + $fileCollector->addFilesFromFolders($folderIdentifiers, !empty($conf['folders.']['recursive'])); + } + + // Enable sorting for multiple fileObjects + $sortingProperty = (string)$this->cObj->stdWrapValue('sorting', $conf); + if ($sortingProperty !== '') { + $sortingDirection = $this->cObj->stdWrapValue('direction', $conf['sorting.'] ?? []); + $fileCollector->sort($sortingProperty, $sortingDirection); + } + + return $fileCollector; + } + + /** + * Handles and resolves file references. + * + * @param array $configuration TypoScript configuration + * @param array $element The parent element referencing to files + */ + protected function addFileReferences(array $configuration, array $element, FileCollector $fileCollector): void + { + // It's important that this always stays "fieldName" and not be renamed to "field" as it would otherwise collide with the stdWrap key of that name + $referencesFieldName = $this->cObj->stdWrapValue('fieldName', $configuration['references.'] ?? []); + + // If no reference fieldName is set, there's nothing to do + if (empty($referencesFieldName)) { + return; + } + + $currentId = !empty($element['uid']) ? $element['uid'] : 0; + $tableName = $this->cObj->getCurrentTable(); + + // Fetch the references of the default element + $referencesForeignTable = (string)$this->cObj->stdWrapValue('table', $configuration['references.'], $tableName); + $referencesForeignUid = (int)$this->cObj->stdWrapValue('uid', $configuration['references.'], $currentId); + + $pageRepository = $this->getPageRepository(); + // Fetch element if definition has been modified via TypoScript + if ( + ($referencesForeignTable !== '' && $referencesForeignTable !== $tableName) + || ($referencesForeignUid !== 0 && $referencesForeignUid !== $currentId) + ) { + $element = $pageRepository->getRawRecord($referencesForeignTable, $referencesForeignUid); + // Do versionOL() again and unset move pointers + $pageRepository->versionOL($referencesForeignTable, $element, true); + if (is_array($element)) { + $element = $pageRepository->getLanguageOverlay($referencesForeignTable, $element); + } + } + + if (is_array($element)) { + $fileCollector->addFilesFromRelation($referencesForeignTable ?: $tableName, $referencesFieldName, $element); + } + } + + protected function getFileCollector(): FileCollector + { + return GeneralUtility::makeInstance(FileCollector::class); + } +} diff --git a/Classes/ContentObject/FluidTemplateContentObject.php b/Classes/ContentObject/FluidTemplateContentObject.php new file mode 100644 index 0000000..9c01e03 --- /dev/null +++ b/Classes/ContentObject/FluidTemplateContentObject.php @@ -0,0 +1,273 @@ +buildExtbaseRequestIfNeeded($this->request, $conf); + $templateFilename = ''; + $templateSource = null; + + if ((!empty($conf['templateName']) || !empty($conf['templateName.'])) + && !empty($conf['templateRootPaths.']) && is_array($conf['templateRootPaths.']) + ) { + // This is the most preferred way to render fluid: set up paths, then call render('My/Template') + $viewFactoryData = new ViewFactoryData( + templateRootPaths: $this->applyStandardWrapToFluidPaths($conf['templateRootPaths.']), + partialRootPaths: $this->getPartialRootPaths($conf), + layoutRootPaths: $this->getLayoutRootPaths($conf), + request: $request, + format: $this->cObj->stdWrapValue('format', $conf, null), + ); + $templateFilename = $this->cObj->stdWrapValue('templateName', $conf); + } elseif (!empty($conf['template']) && !empty($conf['template.'])) { + // Fetch the Fluid template by template cObject "template = TEXT, template.value = cObj->cObjGetSingle($conf['template'], $conf['template.'], 'template'); + if ($templateSource === '') { + throw new ContentRenderingException( + 'Could not find template source for ' . $conf['template'], + 1437420865 + ); + } + $viewFactoryData = new ViewFactoryData( + partialRootPaths: $this->getPartialRootPaths($conf), + layoutRootPaths: $this->getLayoutRootPaths($conf), + request: $request, + format: $this->cObj->stdWrapValue('format', $conf, null), + ); + } else { + // Fetch the Fluid template by file stdWrap "file = EXT:myExt/.../Foo.html" + $file = (string)$this->cObj->stdWrapValue('file', $conf); + // Get the absolute file name + $templatePathAndFilename = GeneralUtility::getFileAbsFileName($file); + $viewFactoryData = new ViewFactoryData( + partialRootPaths: $this->getPartialRootPaths($conf), + layoutRootPaths: $this->getLayoutRootPaths($conf), + templatePathAndFilename: $templatePathAndFilename, + request: $request, + format: $this->cObj->stdWrapValue('format', $conf, null), + ); + } + + $view = $this->viewFactory->create($viewFactoryData); + if (!$view instanceof FluidViewAdapter) { + throw new ContentRenderingException( + 'The FLUIDTEMPLATE content object only works with FluidViewAdapter view. Use a different' + . ' content object to render some other view', + 1724680477 + ); + } + + if ($templateSource) { + $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($templateSource); + } + + if (isset($conf['settings.'])) { + $settings = $this->typoScriptService->convertTypoScriptArrayToPlainArray($conf['settings.']); + $view->assign('settings', $settings); + } + $variables = $this->getContentObjectVariables($conf); + $variables = $this->contentDataProcessor->process($this->cObj, $conf, $variables); + $view->assignMultiple($variables); + + try { + // View needs to be rendered before the following asset rendering because it + // sets the template (paths) internally. + $content = $view->render($templateFilename); + } catch (InvalidTemplateResourceException $e) { + // Only add a FLUIDTEMPLATE specific message in case the exception has been thrown for the given template + if ($e instanceof InvalidPartialException || $e instanceof InvalidLayoutException || $templateFilename === '' || $e->templateName !== 'Default/' . $templateFilename) { + throw $e; + } + throw new InvalidTemplateResourceException( + sprintf( + 'FLUIDTEMPLATE TypoScript object: Failed to resolve a template file for templateName "%s". See also: %s. The following paths were checked: "%s"', + $templateFilename, + Typo3Information::getDocsLink('t3tsref:cobj-template'), + implode('", "', $e->evaluatedTemplatePaths), + ), + 1772572794, + $e, + $e->templateName, + $e->evaluatedTemplatePaths, + ); + } + + if (isset($conf['stdWrap.'])) { + return $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + return $content; + } + + protected function getLayoutRootPaths(array $conf): ?array + { + $layoutPaths = []; + $layoutRootPath = (string)$this->cObj->stdWrapValue('layoutRootPath', $conf); + if ($layoutRootPath !== '') { + $layoutPaths[] = GeneralUtility::getFileAbsFileName($layoutRootPath); + } + if (isset($conf['layoutRootPaths.'])) { + $layoutPaths = array_replace($layoutPaths, $this->applyStandardWrapToFluidPaths($conf['layoutRootPaths.'])); + } + return !empty($layoutPaths) ? $layoutPaths : null; + } + + protected function getPartialRootPaths(array $conf): ?array + { + $partialPaths = []; + $partialRootPath = (string)$this->cObj->stdWrapValue('partialRootPath', $conf); + if ($partialRootPath !== '') { + $partialPaths[] = GeneralUtility::getFileAbsFileName($partialRootPath); + } + if (isset($conf['partialRootPaths.'])) { + $partialPaths = array_replace($partialPaths, $this->applyStandardWrapToFluidPaths($conf['partialRootPaths.'])); + } + return !empty($partialPaths) ? $partialPaths : null; + } + + /** + * @todo: This magic has to fall one way or the other. It has been introduced for ext:form to + * mimic extbase, see https://forge.typo3.org/issues/78842. This is actively used when + * rendering forms using the formvh:render strategy, see the documentation. + */ + protected function buildExtbaseRequestIfNeeded(ServerRequestInterface $request, array $conf): ServerRequestInterface + { + $requestPluginName = (string)$this->cObj->stdWrapValue('pluginName', $conf['extbase.'] ?? []); + $requestControllerExtensionName = (string)$this->cObj->stdWrapValue('controllerExtensionName', $conf['extbase.'] ?? []); + $requestControllerName = (string)$this->cObj->stdWrapValue('controllerName', $conf['extbase.'] ?? []); + $requestControllerActionName = (string)$this->cObj->stdWrapValue('controllerActionName', $conf['extbase.'] ?? []); + if ($requestPluginName && $requestControllerExtensionName && $requestControllerName && $requestControllerActionName) { + $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class); + $configurationManager->setConfiguration([ + 'extensionName' => $requestControllerExtensionName, + 'pluginName' => $requestPluginName, + ]); + if (!isset($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$requestControllerExtensionName]['plugins'][$requestPluginName]['controllers'])) { + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$requestControllerExtensionName]['plugins'][$requestPluginName]['controllers'] = [ + $requestControllerName => [ + 'actions' => [ + $requestControllerActionName, + ], + ], + ]; + } + $requestBuilder = GeneralUtility::makeInstance(RequestBuilder::class); + $request = $requestBuilder->build($request); + } + return $request; + } + + /** + * Compile rendered content objects in variables array ready to assign to the view. + */ + protected function getContentObjectVariables(array $conf): array + { + $variables = []; + $reservedVariables = ['data', 'current']; + // Accumulate the variables to be process and loop them through cObjGetSingle + $variablesToProcess = (array)($conf['variables.'] ?? []); + foreach ($variablesToProcess as $variableName => $cObjType) { + if (is_array($cObjType)) { + continue; + } + if (!in_array($variableName, $reservedVariables)) { + $cObjConf = $variablesToProcess[$variableName . '.'] ?? []; + $variables[$variableName] = $this->cObj->cObjGetSingle($cObjType, $cObjConf, 'variables.' . $variableName); + } else { + throw new \InvalidArgumentException( + 'Cannot use reserved name "' . $variableName . '" as variable name in FLUIDTEMPLATE.', + 1288095720 + ); + } + } + $variables['data'] = $this->cObj->data; + $variables['current'] = $this->cObj->data[$this->cObj->currentValKey] ?? null; + return $variables; + } + + protected function applyStandardWrapToFluidPaths(array $paths): array + { + $finalPaths = []; + foreach ($paths as $key => $path) { + if (str_ends_with((string)$key, '.')) { + if (isset($paths[substr($key, 0, -1)])) { + continue; + } + $path = $this->cObj->stdWrap('', $path); + } elseif (isset($paths[$key . '.'])) { + $path = $this->cObj->stdWrap($path, $paths[$key . '.']); + } + $finalPaths[$key] = GeneralUtility::getFileAbsFileName($path); + } + return $finalPaths; + } +} diff --git a/Classes/ContentObject/HierarchicalMenuContentObject.php b/Classes/ContentObject/HierarchicalMenuContentObject.php new file mode 100644 index 0000000..fcd04be --- /dev/null +++ b/Classes/ContentObject/HierarchicalMenuContentObject.php @@ -0,0 +1,65 @@ +cObj->checkIf($conf['if.'])) { + return ''; + } + + $theValue = ''; + $menuType = $conf[1] ?? ''; + try { + $register = $this->request->getAttribute('frontend.register.stack')->current(); + $menuObjectFactory = GeneralUtility::makeInstance(MenuContentObjectFactory::class); + $menu = $menuObjectFactory->getMenuObjectByType($menuType); + $countHMENU = (int)$register->get('count_HMENU', 0); + $countHMENU++; + $register->set('count_HMENU', $countHMENU); + $register->set('count_HMENU_MENUOBJ', 0); + $register->set('count_MENUOBJ', 0); + $menu->parent_cObj = $this->getContentObjectRenderer(); + $menu->start(null, $this->getPageRepository(), '', $conf, 1, '', $this->request); + $menu->makeMenu(); + $theValue .= $menu->writeMenu(); + } catch (NoSuchMenuTypeException) { + } + $wrap = $this->cObj->stdWrapValue('wrap', $conf); + if ($wrap) { + $theValue = $this->cObj->wrap($theValue, $wrap); + } + if (isset($conf['stdWrap.'])) { + $theValue = $this->cObj->stdWrap($theValue, $conf['stdWrap.']); + } + return $theValue; + } +} diff --git a/Classes/ContentObject/ImageContentObject.php b/Classes/ContentObject/ImageContentObject.php new file mode 100644 index 0000000..dc37cfb --- /dev/null +++ b/Classes/ContentObject/ImageContentObject.php @@ -0,0 +1,303 @@ +cObj->checkIf($conf['if.'])) { + return ''; + } + + $theValue = $this->cImage($conf['file'] ?? '', is_array($conf) ? $conf : []); + if (isset($conf['stdWrap.'])) { + $theValue = $this->cObj->stdWrap($theValue, $conf['stdWrap.']); + } + return $theValue; + } + + /** + * Returns a tag with the image file defined by $file and processed according to the properties in the TypoScript array. + * Mostly this function is a sub-function to the IMAGE function which renders the IMAGE cObject in TypoScript. + * + * @param string|File|FileReference|null $file File TypoScript resource + * @param array $conf TypoScript configuration properties + * @return string HTML tag, (possibly wrapped in links and other HTML) if any image found. + */ + protected function cImage($file, array $conf): string + { + $imageResource = $this->cObj->getImgResource($file, $conf['file.'] ?? []); + if ($imageResource === null) { + return ''; + } + // $info['originalFile'] will be set, when the file is processed by FAL. + // In that case the URL is final and we must not add a prefix + if ($imageResource->getOriginalFile() === null && is_file($imageResource->getFullPath())) { + $absRefPrefix = GeneralUtility::makeInstance(FrontendUrlPrefix::class)->getUrlPrefix($this->request); + $source = $absRefPrefix . str_replace('%2F', '/', rawurlencode($imageResource->getPublicUrl())); + } else { + $source = $imageResource->getPublicUrl(); + } + // A file whose physical resource is gone (sys_file.missing=1) resolves to + // an image resource without public URL. Render nothing in this case, just + // like for an image resource that could not be resolved at all. + if ($source === null) { + $identifier = $imageResource->getOriginalFile()?->getIdentifier() ?: $imageResource->getFullPath(); + $this->logger->warning('The image "{file}" has no public URL, the file is probably missing, and won\'t be included in frontend output', [ + 'file' => $identifier, + ]); + $this->timeTracker->setTSlogMessage( + 'The image "' . $identifier . '" has no public URL, the file is probably missing. It is not rendered.', + LogLevel::WARNING + ); + return ''; + } + GeneralUtility::makeInstance(AssetCollector::class)->addMedia( + $source, + $imageResource->getLegacyImageResourceInformation() + ); + + $layoutKey = (string)$this->cObj->stdWrapValue('layoutKey', $conf); + $imageTagTemplate = $this->getImageTagTemplate($layoutKey, $conf); + $sourceCollection = $this->getImageSourceCollection($layoutKey, $conf, $file); + + $altParam = $this->getAltParam($conf); + $params = $this->cObj->stdWrapValue('params', $conf); + if ($params !== '' && $params[0] !== ' ') { + $params = ' ' . $params; + } + + $imageTagValues = [ + 'width' => $imageResource->getWidth(), + 'height' => $imageResource->getHeight(), + 'src' => htmlspecialchars($source), + 'params' => $params, + 'altParams' => $altParam, + 'sourceCollection' => $sourceCollection, + 'selfClosingTagSlash' => DocType::createFromRequest($this->request)->isXmlCompliant() ? ' /' : '', + ]; + + $theValue = $this->markerTemplateService->substituteMarkerArray($imageTagTemplate, $imageTagValues, '###|###', true, true); + + $linkWrap = (string)$this->cObj->stdWrapValue('linkWrap', $conf); + if ($linkWrap !== '') { + $theValue = $this->linkWrap($theValue, $linkWrap); + } elseif ($conf['imageLinkWrap'] ?? false) { + $originalFile = urldecode($imageResource->getFullPath()); + $theValue = $this->cObj->imageLinkWrap($theValue, $originalFile, $conf['imageLinkWrap.']); + } + $wrap = $this->cObj->stdWrapValue('wrap', $conf); + if ((string)$wrap !== '') { + $theValue = $this->cObj->wrap($theValue, $conf['wrap']); + } + return $theValue; + } + + /** + * Returns the html-template for rendering the image-Tag if no template is defined via typoscript the + * default tag template is returned + * + * @param string $layoutKey rendering key + * @param array $conf TypoScript configuration properties + */ + protected function getImageTagTemplate($layoutKey, $conf): string + { + if ($layoutKey && isset($conf['layout.']) && isset($conf['layout.'][$layoutKey . '.'])) { + return $this->cObj->stdWrapValue('element', $conf['layout.'][$layoutKey . '.']); + } + return ''; + } + + /** + * Render alternate sources for the image tag. If no source collection is given an empty string is returned. + * + * @param string $layoutKey rendering key + * @param array $conf TypoScript configuration properties + * @param string|File|FileReference|null $file + * @return string + */ + protected function getImageSourceCollection(string $layoutKey, array $conf, $file) + { + $sourceCollection = ''; + if ($layoutKey + && isset($conf['sourceCollection.']) && $conf['sourceCollection.'] + && ( + isset($conf['layout.'][$layoutKey . '.']['source']) && $conf['layout.'][$layoutKey . '.']['source'] + || isset($conf['layout.'][$layoutKey . '.']['source.']) && $conf['layout.'][$layoutKey . '.']['source.'] + ) + ) { + // find active sourceCollection + $activeSourceCollections = []; + foreach ($conf['sourceCollection.'] as $sourceCollectionKey => $sourceCollectionConfiguration) { + if (str_ends_with($sourceCollectionKey, '.')) { + if (empty($sourceCollectionConfiguration['if.']) || $this->cObj->checkIf($sourceCollectionConfiguration['if.'])) { + $activeSourceCollections[] = $sourceCollectionConfiguration; + } + } + } + + // apply option split to configurations + $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); + $srcLayoutOptionSplitted = $typoScriptService->explodeConfigurationForOptionSplit((array)$conf['layout.'][$layoutKey . '.'], count($activeSourceCollections)); + $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); + + $isXmlCompliant = DocType::createFromRequest($this->request)->isXmlCompliant(); + + // render sources + foreach ($activeSourceCollections as $key => $sourceConfiguration) { + $sourceLayout = $this->cObj->stdWrapValue('source', $srcLayoutOptionSplitted[$key] ?? []); + + $sourceRenderConfiguration = [ + 'file' => $file, + 'file.' => $conf['file.'] ?? null, + ]; + + $imageQuality = $this->cObj->stdWrapValue('quality', $sourceConfiguration ?? []); + if ($imageQuality) { + $sourceRenderConfiguration['file.']['params'] = '-quality ' . (int)$imageQuality; + } + + $pixelDensity = (int)$this->cObj->stdWrapValue('pixelDensity', $sourceConfiguration, 1); + $dimensionKeys = ['width', 'height', 'maxW', 'minW', 'maxH', 'minH', 'maxWidth', 'maxHeight', 'XY']; + foreach ($dimensionKeys as $dimensionKey) { + $dimension = (string)$this->cObj->stdWrapValue($dimensionKey, $sourceConfiguration); + if ($dimension === '') { + $dimension = (string)$this->cObj->stdWrapValue($dimensionKey, $conf['file.'] ?? []); + } + if ($dimension !== '') { + if (str_contains($dimension, 'c') && ($dimensionKey === 'width' || $dimensionKey === 'height')) { + $dimensionParts = explode('c', $dimension, 2); + $dimension = ((int)$dimensionParts[0] * $pixelDensity) . 'c'; + if ($dimensionParts[1]) { + $dimension .= $dimensionParts[1]; + } + } elseif ($dimensionKey === 'XY') { + $dimensionParts = GeneralUtility::intExplode(',', $dimension); + $dimension = $dimensionParts[0] * $pixelDensity; + if ($dimensionParts[1]) { + $dimension .= ',' . $dimensionParts[1] * $pixelDensity; + } + } else { + $dimension = (int)$dimension * $pixelDensity; + } + $sourceRenderConfiguration['file.'][$dimensionKey] = $dimension; + // Remove the stdWrap properties for dimension as they have been processed already above. + unset($sourceRenderConfiguration['file.'][$dimensionKey . '.']); + } + } + $imageResource = $this->cObj->getImgResource($sourceRenderConfiguration['file'], $sourceRenderConfiguration['file.']); + if ($imageResource !== null) { + $sourceConfiguration['width'] = $imageResource->getWidth(); + $sourceConfiguration['height'] = $imageResource->getHeight(); + + $urlPrefix = ''; + // Prepend 'absRefPrefix' to file path only if file was not processed by FAL, e.g. GIFBUILDER + if ($imageResource->getOriginalFile() === null && is_file($imageResource->getFullPath())) { + $urlPrefix = GeneralUtility::makeInstance(FrontendUrlPrefix::class)->getUrlPrefix($this->request); + } + + $sourceConfiguration['src'] = htmlspecialchars($urlPrefix . $imageResource->getPublicUrl()); + $sourceConfiguration['selfClosingTagSlash'] = $isXmlCompliant ? ' /' : ''; + + $oneSourceCollection = $this->markerTemplateService->substituteMarkerArray($sourceLayout, $sourceConfiguration, '###|###', true, true); + + $sourceCollection .= $eventDispatcher->dispatch( + new ModifyImageSourceCollectionEvent($oneSourceCollection, $sourceCollection, (array)$sourceConfiguration, $sourceRenderConfiguration, $this->cObj) + )->getSourceCollection(); + } + } + } + return $sourceCollection; + } + + /** + * Wraps the input string by the $wrap value and implements the "linkWrap" data type as well. + * + * The "linkWrap" data type means that this function will find any integer encapsulated + * in {} (curly braces) in the first wrap part and substitute it with the corresponding page + * uid from the rootline where the found integer is pointing to the key in the rootline. + * + * @param string $content Input string + * @param string $wrap A string where the first two parts separated by "|" (vertical line) will be wrapped around the input string + */ + protected function linkWrap(string $content, string $wrap): string + { + $wrapArr = explode('|', $wrap); + if (preg_match('/\\{([0-9]*)\\}/', $wrapArr[0], $reg)) { + $localRootLine = $this->request->getAttribute('frontend.page.information')->getLocalRootLine(); + $uid = $localRootLine[$reg[1]]['uid'] ?? null; + if ($uid) { + $wrapArr[0] = str_replace($reg[0], $uid, $wrapArr[0]); + } + } + return trim($wrapArr[0]) . $content . trim($wrapArr[1] ?? ''); + } + + /** + * An abstraction method which creates an alt or title parameter for an HTML img, applet, area or input element and the FILE content element. + * From the $conf array it implements the properties "altText" and "titleText" + * + * @param array $conf TypoScript configuration properties + * @return string Parameter string containing alt and title parameters (if any) + */ + protected function getAltParam(array $conf): string + { + $altText = trim((string)$this->cObj->stdWrapValue('altText', $conf)); + $titleText = trim((string)$this->cObj->stdWrapValue('titleText', $conf)); + + // "alt": + $altParam = ' alt="' . htmlspecialchars($altText) . '"'; + // "title": + $emptyTitleHandling = $this->cObj->stdWrapValue('emptyTitleHandling', $conf); + // Choices: 'keepEmpty' | 'useAlt' | 'removeAttr' + if ($titleText || $emptyTitleHandling === 'keepEmpty') { + $altParam .= ' title="' . htmlspecialchars($titleText) . '"'; + } elseif ($emptyTitleHandling === 'useAlt') { + $altParam .= ' title="' . htmlspecialchars($altText) . '"'; + } + return $altParam; + } +} diff --git a/Classes/ContentObject/ImageResourceContentObject.php b/Classes/ContentObject/ImageResourceContentObject.php new file mode 100644 index 0000000..7c442b7 --- /dev/null +++ b/Classes/ContentObject/ImageResourceContentObject.php @@ -0,0 +1,39 @@ +cObj->getImgResource($conf['file'] ?? '', $conf['file.'] ?? []); + if ($imageResource === null) { + return ''; + } + return isset($conf['stdWrap.']) + ? $this->cObj->stdWrap($imageResource->getPublicUrl(), $conf['stdWrap.']) + : $imageResource->getPublicUrl(); + } +} diff --git a/Classes/ContentObject/LoadRegisterContentObject.php b/Classes/ContentObject/LoadRegisterContentObject.php new file mode 100644 index 0000000..3b4cc5d --- /dev/null +++ b/Classes/ContentObject/LoadRegisterContentObject.php @@ -0,0 +1,57 @@ +request->getAttribute('frontend.register.stack'); + $clonedRegister = clone $registerStack->current(); + if (is_array($conf)) { + $isExecuted = []; + foreach ($conf as $key => $value) { + $key = rtrim($key, '.'); + if (!isset($isExecuted[$key])) { + $registerProperties = $key . '.'; + if (isset($conf[$key]) && isset($conf[$registerProperties])) { + $value = $this->cObj->stdWrap($conf[$key], $conf[$registerProperties]); + } elseif (isset($conf[$registerProperties])) { + $value = $this->cObj->stdWrap('', $conf[$registerProperties]); + } + $clonedRegister->set($key, $value); + $isExecuted[$key] = true; + } + } + } + $registerStack->push($clonedRegister); + return ''; + } +} diff --git a/Classes/ContentObject/Menu/AbstractMenuContentObject.php b/Classes/ContentObject/Menu/AbstractMenuContentObject.php new file mode 100644 index 0000000..1698626 --- /dev/null +++ b/Classes/ContentObject/Menu/AbstractMenuContentObject.php @@ -0,0 +1,1985 @@ +conf = (array)$conf; + $this->menuNumber = $menuNumber; + $this->mconf = (array)$conf[$this->menuNumber . $objSuffix . '.']; + $this->request = $request; + // Sets the internal vars. $sys_page MUST be the PageRepository object + if ($this->conf[$this->menuNumber . $objSuffix]) { + $localRootLine = $request->getAttribute('frontend.page.information')->getLocalRootLine(); + $this->sys_page = $sys_page; + // alwaysActivePIDlist initialized: + $this->conf['alwaysActivePIDlist'] = (string)$this->parent_cObj->stdWrapValue('alwaysActivePIDlist', $this->conf); + if (trim($this->conf['alwaysActivePIDlist'])) { + $this->alwaysActivePIDlist = GeneralUtility::intExplode(',', $this->conf['alwaysActivePIDlist']); + } + // includeNotInMenu initialized: + $this->conf['includeNotInMenu'] = $this->parent_cObj->stdWrapValue('includeNotInMenu', $this->conf, false); + // exclude doktypes that should not be shown in menu (e.g. backend user section) + if ($this->conf['excludeDoktypes'] ?? false) { + $this->excludedDoktypes = GeneralUtility::intExplode(',', (string)($this->conf['excludeDoktypes'])); + } + // EntryLevel + $this->entryLevel = $this->parent_cObj->getKey( + $this->parent_cObj->stdWrapValue('entryLevel', $this->conf), + $localRootLine + ); + // Set parent page: If $id not stated with start() then the base-id will be found from rootLine[$this->entryLevel] + // Called as the next level in a menu. It is assumed that $this->MP_array is set from parent menu. + if ($id) { + $this->id = (int)$id; + } else { + // This is a BRAND NEW menu, first level. So we take ID from rootline and also find MP_array (mount points) + $this->id = (int)($localRootLine[$this->entryLevel]['uid'] ?? 0); + + // Traverse rootline to build MP_array of pages BEFORE the entryLevel + // (MP var for ->id is picked up in the next part of the code...) + foreach ($localRootLine as $entryLevel => $levelRec) { + // For overlaid mount points, set the variable right now: + if (($levelRec['_MP_PARAM'] ?? false) && ($levelRec['_MOUNT_OL'] ?? false)) { + $this->MP_array[] = $levelRec['_MP_PARAM']; + } + + // Break when entry level is reached: + if ($entryLevel >= $this->entryLevel) { + break; + } + + // For normal mount points, set the variable for next level. + if (!empty($levelRec['_MP_PARAM']) && empty($levelRec['_MOUNT_OL'])) { + $this->MP_array[] = $levelRec['_MP_PARAM']; + } + } + } + // Return FALSE if no page ID was set (thus no menu of subpages can be made). + if ($this->id <= 0) { + return false; + } + // Check if page is a mount point, and if so set id and MP_array + // (basically this is ONLY for non-overlay mode, but in overlay mode an ID with a mount point should never reach this point anyways, so no harm done...) + $mount_info = $this->sys_page->getMountPointInfo($this->id); + if (is_array($mount_info)) { + $this->MP_array[] = $mount_info['MPvar']; + $this->id = $mount_info['mount_pid']; + } + // Gather list of page uids in root line (for "isActive" evaluation). Also adds the MP params in the path so Mount Points are respected. + // (List is specific for this rootline, so it may be supplied from parent menus for speed...) + if ($this->rL_uidRegister === null) { + $this->rL_uidRegister = []; + $rl_MParray = []; + foreach ($localRootLine as $v_rl) { + // For overlaid mount points, set the variable right now: + if (($v_rl['_MP_PARAM'] ?? false) && ($v_rl['_MOUNT_OL'] ?? false)) { + $rl_MParray[] = $v_rl['_MP_PARAM']; + } + // Add to register: + $this->rL_uidRegister[] = 'ITEM:' . $v_rl['uid'] + . ( + !empty($rl_MParray) + ? ':' . implode(',', $rl_MParray) + : '' + ); + // For normal mount points, set the variable for next level. + if (($v_rl['_MP_PARAM'] ?? false) && !($v_rl['_MOUNT_OL'] ?? false)) { + $rl_MParray[] = $v_rl['_MP_PARAM']; + } + } + } + // Set $directoryLevel so the following evaluation of the nextActive will not return + // an invalid value if .special=directory was set + $directoryLevel = 0; + if (($this->conf['special'] ?? '') === 'directory') { + $value = $this->parent_cObj->stdWrapValue('value', $this->conf['special.'] ?? [], null); + if ($value === '') { + $value = $this->request->getAttribute('frontend.page.information')->getId(); + } + $directoryLevel = $this->getRootlineLevel($localRootLine, (string)$value); + } + // Setting "nextActive": This is the page uid + MPvar of the NEXT page in rootline. Used to expand the menu if we are in the right branch of the tree + // Notice: The automatic expansion of a menu is designed to work only when no "special" modes (except "directory") are used. + $startLevel = $directoryLevel ?: $this->entryLevel; + $currentLevel = $startLevel + $this->menuNumber; + if (is_array($localRootLine[$currentLevel] ?? null)) { + $nextMParray = $this->MP_array; + if (empty($nextMParray) && !($localRootLine[$currentLevel]['_MOUNT_OL'] ?? false) && $currentLevel > 0) { + // Make sure to slide-down any mount point information (_MP_PARAM) to children records in the rootline + // otherwise automatic expansion will not work + $parentRecord = $localRootLine[$currentLevel - 1] ?? []; + if (isset($parentRecord['_MP_PARAM'])) { + $nextMParray[] = $parentRecord['_MP_PARAM']; + } + } + // In overlay mode, add next level MPvars as well: + if ($localRootLine[$currentLevel]['_MOUNT_OL'] ?? false) { + $nextMParray[] = $localRootLine[$currentLevel]['_MP_PARAM'] ?? []; + } + $this->nextActive = ($localRootLine[$currentLevel]['uid'] ?? 0) + . ( + !empty($nextMParray) + ? ':' . implode(',', $nextMParray) + : '' + ); + } else { + $this->nextActive = ''; + } + return true; + } + $this->getTimeTracker()->setTSlogMessage('ERROR in menu', LogLevel::ERROR); + return false; + } + + /** + * Creates the menu in the internal variables, ready for output. + * Basically this will read the page records needed and fill in the internal $this->menuArr + * Based on a hash of this array and some other variables the $this->result variable will be + * loaded either from cache OR by calling the generate() method of the class to create the menu for real. + */ + public function makeMenu() + { + if (!$this->id) { + return; + } + + // Initializing showAccessRestrictedPages + if ($this->mconf['showAccessRestrictedPages'] ?? false) { + $this->disableGroupAccessCheck = true; + } + + $menuItems = $this->prepareMenuItems(); + + $c = 0; + $c_b = 0; + + $minItems = (int)(($this->mconf['minItems'] ?? 0) ?: ($this->conf['minItems'] ?? 0)); + $maxItems = (int)(($this->mconf['maxItems'] ?? 0) ?: ($this->conf['maxItems'] ?? 0)); + $begin = $this->parent_cObj->calc(($this->mconf['begin'] ?? 0) ?: ($this->conf['begin'] ?? 0)); + $minItemsConf = $this->mconf['minItems.'] ?? $this->conf['minItems.'] ?? null; + $minItems = is_array($minItemsConf) ? $this->parent_cObj->stdWrap((string)$minItems, $minItemsConf) : $minItems; + $maxItemsConf = $this->mconf['maxItems.'] ?? $this->conf['maxItems.'] ?? null; + $maxItems = is_array($maxItemsConf) ? $this->parent_cObj->stdWrap((string)$maxItems, $maxItemsConf) : $maxItems; + $beginConf = $this->mconf['begin.'] ?? $this->conf['begin.'] ?? null; + $begin = is_array($beginConf) ? $this->parent_cObj->stdWrap((string)$begin, $beginConf) : $begin; + $this->menuArr = []; + foreach ($menuItems as &$data) { + $data['isSpacer'] = ($data['isSpacer'] ?? false) || (int)($data['doktype'] ?? 0) === PageRepository::DOKTYPE_SPACER || ($data['ITEM_STATE'] ?? '') === 'SPC'; + } + $menuItems = $this->removeInaccessiblePages($menuItems); + // Fill in the menuArr with elements that should go into the menu + foreach ($menuItems as $menuItem) { + $c_b++; + // If the beginning item has been reached, add the items. + if ($begin <= $c_b) { + $this->menuArr[$c] = $menuItem; + $c++; + if ($maxItems && $c >= $maxItems) { + break; + } + } + } + // Fill in fake items, if min-items is set. + if ($minItems) { + while ($c < $minItems) { + $this->menuArr[$c] = [ + 'title' => '...', + 'uid' => $this->request->getAttribute('frontend.page.information')->getId(), + ]; + $c++; + } + } + // Passing the menuArr through a user defined function: + if ($this->mconf['itemArrayProcFunc'] ?? false) { + $this->menuArr = $this->userProcess('itemArrayProcFunc', $this->menuArr); + } + // Setting number of menu items + $this->request->getAttribute('frontend.register.stack')->current()->set('count_menuItems', count($this->menuArr)); + $this->generate(); + // End showAccessRestrictedPages + if ($this->mconf['showAccessRestrictedPages'] ?? false) { + $this->disableGroupAccessCheck = false; + } + } + + /** + * Calls processItemStates() so that the common configuration for the menu items are resolved into individual configuration per item. + * Sets the result for the new "normal state" in $this->result + * + * @see AbstractMenuContentObject::processItemStates() + */ + public function generate() + { + $itemConfiguration = []; + $splitCount = count($this->menuArr); + if ($splitCount) { + $itemConfiguration = $this->processItemStates($splitCount); + } + $this->result = $itemConfiguration; + } + + /** + * @return string The HTML for the menu + */ + public function writeMenu() + { + return ''; + } + + /** + * Gets an array of page rows and removes all, which are not accessible + */ + protected function removeInaccessiblePages(array $pages): array + { + $banned = $this->getBannedUids(); + $filteredPages = []; + foreach ($pages as $aPage) { + $isSpacerPage = ((int)($aPage['doktype'] ?? 0) === PageRepository::DOKTYPE_SPACER) || ($aPage['isSpacer'] ?? false); + if ($this->filterMenuPages($aPage, $banned, $isSpacerPage)) { + $filteredPages[] = $aPage; + } + } + $event = new FilterMenuItemsEvent( + $pages, + $filteredPages, + $this->mconf, + $this->conf, + $banned, + $this->excludedDoktypes, + $this->getCurrentSite(), + GeneralUtility::makeInstance(Context::class), + $this->request->getAttribute('frontend.page.information')->getPageRecord() + ); + $event = GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch($event); + return $event->getFilteredMenuItems(); + } + + /** + * Main function for retrieving menu items based on the menu type (special or sectionIndex or "normal") + * + * @return array + */ + protected function prepareMenuItems() + { + $menuItems = []; + $alternativeSortingField = trim($this->mconf['alternativeSortingField'] ?? '') ?: 'sorting'; + + // Additional where clause, usually starts with AND (as usual with all additionalWhere functionality in TS) + $additionalWhere = $this->parent_cObj->stdWrapValue('additionalWhere', $this->mconf); + $additionalWhere .= $this->getDoktypeExcludeWhere(); + + // ... only for the FIRST level of a HMENU + if ($this->menuNumber == 1 && ($this->conf['special'] ?? false)) { + $value = (string)$this->parent_cObj->stdWrapValue('value', $this->conf['special.'] ?? [], null); + switch ($this->conf['special']) { + case 'userfunction': + $menuItems = $this->prepareMenuItemsForUserSpecificMenu($value, $alternativeSortingField); + break; + case 'language': + $menuItems = $this->prepareMenuItemsForLanguageMenu($value); + break; + case 'directory': + $menuItems = $this->prepareMenuItemsForDirectoryMenu($value, $alternativeSortingField); + break; + case 'list': + $menuItems = $this->prepareMenuItemsForListMenu($value); + break; + case 'updated': + $menuItems = $this->prepareMenuItemsForUpdatedMenu( + $value, + $this->mconf['alternativeSortingField'] ?? '' + ); + break; + case 'keywords': + $menuItems = $this->prepareMenuItemsForKeywordsMenu( + $value, + $this->mconf['alternativeSortingField'] ?? '' + ); + break; + case 'categories': + $categoryMenuUtility = GeneralUtility::makeInstance(CategoryMenuUtility::class); + $menuItems = $categoryMenuUtility->collectPages($value, $this->conf['special.'], $this); + break; + case 'rootline': + $menuItems = $this->prepareMenuItemsForRootlineMenu(); + break; + case 'browse': + $menuItems = $this->prepareMenuItemsForBrowseMenu($value, $alternativeSortingField, $additionalWhere); + break; + } + if ($this->mconf['sectionIndex'] ?? false) { + $sectionIndexes = []; + foreach ($menuItems as $page) { + $sectionIndexes = $sectionIndexes + $this->sectionIndex($alternativeSortingField, $page['uid']); + } + $menuItems = $sectionIndexes; + } + } elseif ($this->alternativeMenuTempArray !== []) { + // Setting $menuItems array if not level 1. + $menuItems = $this->alternativeMenuTempArray; + } elseif ($this->mconf['sectionIndex'] ?? false) { + $menuItems = $this->sectionIndex($alternativeSortingField); + } else { + // Default: Gets a hierarchical menu based on subpages of $this->id + $subMenuDecision = $this->getRuntimeCache()->get($this->getCacheIdentifierForSubMenuDecision($this->id)); + if (!isset($subMenuDecision['result']) || $subMenuDecision['result'] === true) { + $menuItems = $this->sys_page->getMenu($this->id, '*', $alternativeSortingField, $additionalWhere, true, $this->disableGroupAccessCheck); + } + } + return $menuItems; + } + + /** + * Fetches all menuitems if special = userfunction is set + * + * @param string $specialValue The value from special.value + * @param string $sortingField The sorting field + * @return array + */ + protected function prepareMenuItemsForUserSpecificMenu($specialValue, $sortingField) + { + $menuItems = $this->parent_cObj->callUserFunction( + $this->conf['special.']['userFunc'], + array_merge($this->conf['special.'], ['value' => $specialValue, '_altSortField' => $sortingField]), + '' + ); + return is_array($menuItems) ? $menuItems : []; + } + + /** + * Fetches all menuitems if special = language is set + * + * @param string $specialValue The value from special.value + * @return array + */ + protected function prepareMenuItemsForLanguageMenu($specialValue) + { + $menuItems = []; + // Getting current page record NOT overlaid by any translation: + $pageRecord = $this->request->getAttribute('frontend.page.information')->getPageRecord(); + $currentPageWithNoOverlay = ($pageRecord['_TRANSLATION_SOURCE'] ?? null)?->toArray(true) ?? $pageRecord; + + $languages = $this->getCurrentSite()->getLanguages(); + if ($specialValue === 'auto') { + $languageItems = array_keys($languages); + } else { + $languageItems = GeneralUtility::intExplode(',', $specialValue); + } + + $this->request->getAttribute('frontend.register.stack')->current()->set('languages_HMENU', implode(',', $languageItems)); + + $currentLanguageId = $this->getCurrentLanguageAspect()->getId(); + + // @todo Fetch all language overlays in a single query + foreach ($languageItems as $sUid) { + // Find overlay record: + if ($sUid) { + // Skip if language doesn't exist in site configuration + if (!isset($languages[$sUid])) { + continue; + } + $languageAspect = LanguageAspectFactory::createFromSiteLanguage($languages[$sUid]); + $pageRepository = $this->buildPageRepository($languageAspect); + $lRecs = $pageRepository->getPageOverlay($currentPageWithNoOverlay, $languageAspect); + // getPageOverlay() might return the original record again, if so this is emptied + // this should be fixed in PageRepository in the future. + if (!empty($lRecs) && !isset($lRecs['_LOCALIZED_UID'])) { + $lRecs = []; + } + } else { + $lRecs = []; + } + // Checking if the "disabled" state should be set. + $pageTranslationVisibility = new PageTranslationVisibility((int)($currentPageWithNoOverlay['l18n_cfg'] ?? 0)); + if ($pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists() && $sUid + && empty($lRecs) || $pageTranslationVisibility->shouldBeHiddenInDefaultLanguage() + && (!$sUid || empty($lRecs)) + || !($this->conf['special.']['normalWhenNoLanguage'] ?? false) && $sUid && empty($lRecs) + ) { + $iState = $currentLanguageId === $sUid ? 'USERDEF2' : 'USERDEF1'; + } else { + $iState = $currentLanguageId === $sUid ? 'ACT' : 'NO'; + } + // Adding menu item: + $menuItems[] = array_merge( + array_merge($currentPageWithNoOverlay, $lRecs), + [ + '_REQUESTED_OVERLAY_LANGUAGE' => $sUid, + 'ITEM_STATE' => $iState, + '_ADD_GETVARS' => $this->conf['addQueryString'] ?? false, + '_SAFE' => true, + ] + ); + } + return $menuItems; + } + + /** + * Builds PageRepository instance without depending on global context, e.g. + * not automatically overlaying records based on current request language. + */ + protected function buildPageRepository(?LanguageAspect $languageAspect = null): PageRepository + { + // clone global context object (singleton) + $context = clone GeneralUtility::makeInstance(Context::class); + $context->setAspect('language', $languageAspect ?? new LanguageAspect()); + return GeneralUtility::makeInstance(PageRepository::class, $context); + } + + /** + * Fetches all menuitems if special = directory is set + * + * @param string $specialValue The value from special.value + * @param string $sortingField The sorting field + * @return array + */ + protected function prepareMenuItemsForDirectoryMenu($specialValue, $sortingField) + { + $menuItems = []; + if ($specialValue == '') { + $specialValue = $this->request->getAttribute('frontend.page.information')->getId(); + } + $items = GeneralUtility::intExplode(',', (string)$specialValue); + $pageLinkBuilder = GeneralUtility::makeInstance(PageLinkBuilder::class); + foreach ($items as $id) { + $MP = $pageLinkBuilder->getMountPointParameterFromRootPointMaps($id, $this->parent_cObj->getRequest()); + // Checking if a page is a mount page and if so, change the ID and set the MP var properly. + $mount_info = $this->sys_page->getMountPointInfo($id); + if (is_array($mount_info)) { + if ($mount_info['overlay']) { + // Overlays should already have their full MPvars calculated: + $MP = $pageLinkBuilder->getMountPointParameterFromRootPointMaps((int)$mount_info['mount_pid'], $this->parent_cObj->getRequest()); + $MP = $MP ?: $mount_info['MPvar']; + } else { + $MP = ($MP ? $MP . ',' : '') . $mount_info['MPvar']; + } + $id = $mount_info['mount_pid']; + } + $subPages = $this->sys_page->getMenu($id, '*', $sortingField, '', true, $this->disableGroupAccessCheck); + foreach ($subPages as $row) { + // Add external MP params + if ($MP) { + $row['_MP_PARAM'] = $MP . (($row['_MP_PARAM'] ?? '') ? ',' . $row['_MP_PARAM'] : ''); + } + $menuItems[] = $row; + } + } + + return $menuItems; + } + + /** + * Fetches all menuitems if special = list is set + * + * @param string $specialValue The value from special.value + * @return array + */ + protected function prepareMenuItemsForListMenu($specialValue) + { + $menuItems = []; + if ($specialValue == '') { + $specialValue = $this->id; + } + $pageIds = GeneralUtility::intExplode(',', (string)$specialValue); + $disableGroupAccessCheck = !empty($this->mconf['showAccessRestrictedPages']); + $pageRecords = $this->sys_page->getMenuForPages($pageIds, '*', 'sorting', '', true, $disableGroupAccessCheck); + // After fetching the page records, restore the initial order by using the page id list as arrays keys and + // replace them with the resolved page records. The id list is cleaned up first, since ids might be invalid. + $pageRecords = array_replace( + array_flip(array_intersect($pageIds, array_keys($pageRecords))), + $pageRecords + ); + $pageLinkBuilder = GeneralUtility::makeInstance(PageLinkBuilder::class); + foreach ($pageRecords as $row) { + $pageId = (int)$row['uid']; + $MP = $pageLinkBuilder->getMountPointParameterFromRootPointMaps($pageId, $this->parent_cObj->getRequest()); + // Keep mount point? + $mount_info = $this->sys_page->getMountPointInfo($pageId, $row); + // $pageId is a valid mount point + if (is_array($mount_info) && $mount_info['overlay']) { + $mountedPageId = (int)$mount_info['mount_pid']; + // Using "getPage" is OK since we need the check for enableFields + // AND for type 2 of mount pids we DO require a doktype < 200! + $mountedPageRow = $this->sys_page->getPage($mountedPageId, $disableGroupAccessCheck); + if (empty($mountedPageRow)) { + // If the mount point could not be fetched with respect to + // enableFields, the page should not become a part of the menu! + continue; + } + $row = $mountedPageRow; + $row['_MP_PARAM'] = $mount_info['MPvar']; + // Overlays should already have their full MPvars calculated, that's why we unset the + // existing $row['_MP_PARAM'], as the full $MP will be added again below + $MP = $pageLinkBuilder->getMountPointParameterFromRootPointMaps($mountedPageId, $this->parent_cObj->getRequest()); + if ($MP) { + unset($row['_MP_PARAM']); + } + } + if ($MP) { + $row['_MP_PARAM'] = $MP . ($row['_MP_PARAM'] ? ',' . $row['_MP_PARAM'] : ''); + } + $menuItems[] = $row; + } + return $menuItems; + } + + /** + * Fetches all menuitems if special = updated is set + * + * @param string $specialValue The value from special.value + * @param string $sortingField The sorting field + * @return array + */ + protected function prepareMenuItemsForUpdatedMenu($specialValue, $sortingField) + { + $menuItems = []; + if ($specialValue == '') { + $specialValue = $this->request->getAttribute('frontend.page.information')->getId(); + } + $items = GeneralUtility::intExplode(',', (string)$specialValue); + if (MathUtility::canBeInterpretedAsInteger($this->conf['special.']['depth'] ?? null)) { + $depth = MathUtility::forceIntegerInRange($this->conf['special.']['depth'], 1, 20); + } else { + $depth = 20; + } + // Max number of items + $limit = MathUtility::forceIntegerInRange(($this->conf['special.']['limit'] ?? 0), 0, 100); + $maxAge = (int)($this->parent_cObj->calc($this->conf['special.']['maxAge'] ?? 0)); + if (!$limit) { + $limit = 10; + } + // 'auto', 'manual', 'tstamp' + $mode = $this->conf['special.']['mode'] ?? ''; + // Get id's + $beginAtLevel = MathUtility::forceIntegerInRange(($this->conf['special.']['beginAtLevel'] ?? 0), 0, 100); + $pageIds = []; + foreach ($items as $id) { + // Exclude the current ID if beginAtLevel is > 0 + if ($beginAtLevel > 0) { + $pageIds = array_merge($pageIds, $this->sys_page->getDescendantPageIdsRecursive($id, $depth - 1 + $beginAtLevel, $beginAtLevel - 1)); + } else { + $pageIds = array_merge($pageIds, [$id], $this->sys_page->getDescendantPageIdsRecursive($id, $depth - 1 + $beginAtLevel, $beginAtLevel - 1)); + } + } + // Get sortField (mode) + $sortField = $this->getMode($mode); + + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $extraWhere = ($this->conf['includeNotInMenu'] ? '' : ' AND pages.nav_hide=0') . $this->getDoktypeExcludeWhere(); + if ($this->conf['special.']['excludeNoSearchPages'] ?? false) { + $extraWhere .= sprintf(' AND %s=%s', $connection->quoteIdentifier('pages.no_search'), $connection->quote('0')); + } + if ($maxAge > 0) { + $extraWhere .= sprintf(' AND %s>%s', $connection->quoteIdentifier($sortField), $connection->quote((string)($GLOBALS['SIM_ACCESS_TIME'] - $maxAge))); + } + $extraWhere = sprintf('%s>=%s', $connection->quoteIdentifier($sortField), $connection->quote('0')) . $extraWhere; + + $i = 0; + $pageRecords = $this->sys_page->getMenuForPages($pageIds, '*', $sortingField ?: $sortField . ' DESC', $extraWhere, true, $this->disableGroupAccessCheck); + foreach ($pageRecords as $row) { + // Build a custom LIMIT clause as "getMenuForPages()" does not support this + if (++$i > $limit) { + continue; + } + $menuItems[$row['uid']] = $row; + } + + return $menuItems; + } + + /** + * Fetches all menuitems if special = keywords is set + * + * @param string $specialValue The value from special.value + * @param string $sortingField The sorting field + * @return array + */ + protected function prepareMenuItemsForKeywordsMenu($specialValue, $sortingField) + { + $menuItems = []; + [$specialValue] = GeneralUtility::intExplode(',', $specialValue); + if (!$specialValue) { + $specialValue = $this->request->getAttribute('frontend.page.information')->getId(); + } + if (($this->conf['special.']['setKeywords'] ?? false) || ($this->conf['special.']['setKeywords.'] ?? false)) { + $kw = (string)$this->parent_cObj->stdWrapValue('setKeywords', $this->conf['special.'] ?? []); + } else { + // The page record of the 'value'. + $value_rec = $this->sys_page->getPage((int)$specialValue); + $kfieldSrc = ($this->conf['special.']['keywordsField.']['sourceField'] ?? false) ? $this->conf['special.']['keywordsField.']['sourceField'] : 'keywords'; + // keywords. + $kw = trim($this->parent_cObj->keywords($value_rec[$kfieldSrc] ?? '')); + } + // *'auto', 'manual', 'tstamp' + $mode = $this->conf['special.']['mode'] ?? ''; + $sortField = $this->getMode($mode); + // Depth, limit, extra where + if (MathUtility::canBeInterpretedAsInteger($this->conf['special.']['depth'] ?? null)) { + $depth = MathUtility::forceIntegerInRange($this->conf['special.']['depth'], 0, 20); + } else { + $depth = 20; + } + // Max number of items + $limit = MathUtility::forceIntegerInRange(($this->conf['special.']['limit'] ?? 0), 0, 100); + // Start point + $localRootLine = $this->request->getAttribute('frontend.page.information')->getLocalRootLine(); + $eLevel = $this->parent_cObj->getKey( + $this->parent_cObj->stdWrapValue('entryLevel', $this->conf['special.'] ?? []), + $localRootLine + ); + $startUid = (int)($localRootLine[$eLevel]['uid'] ?? 0); + // Which field is for keywords + $kfield = 'keywords'; + if ($this->conf['special.']['keywordsField'] ?? false) { + [$kfield] = explode(' ', trim($this->conf['special.']['keywordsField'])); + } + // If there are keywords and the startUid is present + if ($kw && $startUid) { + $bA = MathUtility::forceIntegerInRange(($this->conf['special.']['beginAtLevel'] ?? 0), 0, 100); + $id_list = $this->sys_page->getDescendantPageIdsRecursive($startUid, $depth - 1 + $bA, $bA - 1); + $id_list = array_merge([(int)$startUid], $id_list); + $kwArr = GeneralUtility::trimExplode(',', $kw, true); + $keyWordsWhereArr = []; + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + foreach ($kwArr as $word) { + $keyWordsWhereArr[] = $queryBuilder->expr()->like( + $kfield, + $queryBuilder->createNamedParameter( + '%' . $queryBuilder->escapeLikeWildcards($word) . '%' + ) + ); + } + $queryBuilder + ->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->in( + 'uid', + $id_list + ), + $queryBuilder->expr()->neq( + 'uid', + $queryBuilder->createNamedParameter($specialValue, Connection::PARAM_INT) + ) + ); + + if (!empty($keyWordsWhereArr)) { + $queryBuilder->andWhere($queryBuilder->expr()->or(...$keyWordsWhereArr)); + } + + if (!empty($this->excludedDoktypes)) { + $queryBuilder->andWhere( + $queryBuilder->expr()->notIn( + 'pages.doktype', + $this->excludedDoktypes + ) + ); + } + + if (!$this->conf['includeNotInMenu']) { + $queryBuilder->andWhere($queryBuilder->expr()->eq('pages.nav_hide', 0)); + } + + if ($this->conf['special.']['excludeNoSearchPages'] ?? false) { + $queryBuilder->andWhere($queryBuilder->expr()->eq('pages.no_search', 0)); + } + + if ($limit > 0) { + $queryBuilder->setMaxResults($limit); + } + + if ($sortingField) { + $queryBuilder->orderBy($sortingField); + } else { + $queryBuilder->orderBy($sortField, 'desc'); + } + + $result = $queryBuilder->executeQuery(); + while ($row = $result->fetchAssociative()) { + $this->sys_page->versionOL('pages', $row, true); + if (is_array($row)) { + $menuItems[$row['uid']] = $this->sys_page->getPageOverlay($row); + } + } + } + + return $menuItems; + } + + /** + * Fetches all menuitems if special = rootline is set + * + * @return array + */ + protected function prepareMenuItemsForRootlineMenu() + { + $menuItems = []; + $range = (string)$this->parent_cObj->stdWrapValue('range', $this->conf['special.'] ?? []); + $begin_end = explode('|', $range); + $begin_end[0] = (int)$begin_end[0]; + if (!MathUtility::canBeInterpretedAsInteger($begin_end[1] ?? '')) { + $begin_end[1] = -1; + } + $localRootLine = $this->request->getAttribute('frontend.page.information')->getLocalRootLine(); + $beginKey = $this->parent_cObj->getKey($begin_end[0], $localRootLine ?? []); + $endKey = $this->parent_cObj->getKey($begin_end[1], $localRootLine ?? []); + if ($endKey < $beginKey) { + $endKey = $beginKey; + } + $rl_MParray = []; + foreach ($localRootLine as $k_rl => $v_rl) { + // For overlaid mount points, set the variable right now: + if (($v_rl['_MP_PARAM'] ?? false) && ($v_rl['_MOUNT_OL'] ?? false)) { + $rl_MParray[] = $v_rl['_MP_PARAM']; + } + // Traverse rootline: + if ($k_rl >= $beginKey && $k_rl <= $endKey) { + $temp_key = $k_rl; + $menuItems[$temp_key] = $this->sys_page->getPage((int)$v_rl['uid']); + if (!empty($menuItems[$temp_key])) { + // If there are no specific target for the page, put the level specific target on. + if (!$menuItems[$temp_key]['target']) { + $menuItems[$temp_key]['target'] = $this->conf['special.']['targets.'][$k_rl] ?? ''; + $menuItems[$temp_key]['_MP_PARAM'] = implode(',', $rl_MParray); + } + } else { + unset($menuItems[$temp_key]); + } + } + // For normal mount points, set the variable for next level. + if (($v_rl['_MP_PARAM'] ?? false) && !($v_rl['_MOUNT_OL'] ?? false)) { + $rl_MParray[] = $v_rl['_MP_PARAM']; + } + } + // Reverse order of elements (e.g. "1,2,3,4" gets "4,3,2,1"): + if (isset($this->conf['special.']['reverseOrder']) && $this->conf['special.']['reverseOrder']) { + $menuItems = array_reverse($menuItems); + } + return $menuItems; + } + + /** + * Fetches all menuitems if special = browse is set + * + * @param string $specialValue The value from special.value + * @param string $sortingField The sorting field + * @param string $additionalWhere Additional WHERE clause + * @return array + */ + protected function prepareMenuItemsForBrowseMenu($specialValue, $sortingField, $additionalWhere) + { + $menuItems = []; + [$specialValue] = GeneralUtility::intExplode(',', $specialValue); + if (!$specialValue) { + $specialValue = $this->request->getAttribute('frontend.page.information')->getPageRecord()['uid']; + } + $localRootLine = $this->request->getAttribute('frontend.page.information')->getLocalRootLine(); + // Will not work out of rootline + if ($specialValue != ($localRootLine[0]['uid'] ?? null)) { + $recArr = []; + // The page id of the 'value' + $value_rec_pid = $this->sys_page->getPage((int)$specialValue, $this->disableGroupAccessCheck)['pid'] ?? null; + // 'up' page cannot be outside rootline + if ($value_rec_pid) { + // The page record of 'up'. + $recArr['up'] = $this->sys_page->getPage((int)$value_rec_pid, $this->disableGroupAccessCheck); + } + // If the 'up' item was NOT level 0 in rootline... + if (($recArr['up']['pid'] ?? 0) && $value_rec_pid != ($localRootLine[0]['uid'] ?? null)) { + // The page record of "index". + $recArr['index'] = $this->sys_page->getPage((int)$recArr['up']['pid']); + } + // check if certain pages should be excluded + $additionalWhere .= ($this->conf['includeNotInMenu'] ? '' : ' AND pages.nav_hide=0') . $this->getDoktypeExcludeWhere(); + if ($this->conf['special.']['excludeNoSearchPages'] ?? false) { + $additionalWhere .= ' AND pages.no_search=0'; + } + // prev / next is found + $prevnext_menu = []; + if ($value_rec_pid) { + $prevnext_menu = $this->removeInaccessiblePages($this->sys_page->getMenu($value_rec_pid, '*', $sortingField, $additionalWhere, true, $this->disableGroupAccessCheck)); + } + $nextActive = false; + foreach ($prevnext_menu as $k_b => $v_b) { + if ($nextActive) { + $recArr['next'] = $v_b; + $nextActive = false; + } + if ($v_b['uid'] == $specialValue) { + if (isset($lastKey)) { + $recArr['prev'] = $prevnext_menu[$lastKey]; + } + $nextActive = true; + } + $lastKey = $k_b; + } + unset($lastKey); + + $recArr['first'] = reset($prevnext_menu); + $recArr['last'] = end($prevnext_menu); + // prevsection / nextsection is found + // You can only do this, if there is a valid page two levels up! + if (!empty($recArr['index']['uid'])) { + $prevnextsection_menu = $this->removeInaccessiblePages($this->sys_page->getMenu($recArr['index']['uid'], '*', $sortingField, $additionalWhere, true, $this->disableGroupAccessCheck)); + $nextActive = false; + foreach ($prevnextsection_menu as $k_b => $v_b) { + if ($nextActive) { + $sectionRec_temp = $this->removeInaccessiblePages($this->sys_page->getMenu($v_b['uid'], '*', $sortingField, $additionalWhere, true, $this->disableGroupAccessCheck)); + if (!empty($sectionRec_temp)) { + $recArr['nextsection'] = reset($sectionRec_temp); + $recArr['nextsection_last'] = end($sectionRec_temp); + $nextActive = false; + } + } + if ($v_b['uid'] == $value_rec_pid) { + if (isset($lastKey)) { + $sectionRec_temp = $this->removeInaccessiblePages($this->sys_page->getMenu($prevnextsection_menu[$lastKey]['uid'], '*', $sortingField, $additionalWhere, true, $this->disableGroupAccessCheck)); + if (!empty($sectionRec_temp)) { + $recArr['prevsection'] = reset($sectionRec_temp); + $recArr['prevsection_last'] = end($sectionRec_temp); + } + } + $nextActive = true; + } + $lastKey = $k_b; + } + unset($lastKey); + } + if ($this->conf['special.']['items.']['prevnextToSection'] ?? false) { + if (!is_array($recArr['prev'] ?? false) && is_array($recArr['prevsection_last'] ?? false)) { + $recArr['prev'] = $recArr['prevsection_last']; + } + if (!is_array($recArr['next'] ?? false) && is_array($recArr['nextsection'] ?? false)) { + $recArr['next'] = $recArr['nextsection']; + } + } + $items = explode('|', ($this->conf['special.']['items'] ?? 'index|up|next|prev')); + $c = 0; + foreach ($items as $v_b) { + $v_b = strtolower(trim($v_b)); + if ((int)($this->conf['special.'][$v_b . '.']['uid'] ?? false)) { + $recArr[$v_b] = $this->sys_page->getPage((int)$this->conf['special.'][$v_b . '.']['uid'], $this->disableGroupAccessCheck); + } + if (is_array($recArr[$v_b] ?? false)) { + $menuItems[$c] = $recArr[$v_b]; + $menuItems[$c]['ITEM_STATE'] = $v_b; + if ($this->conf['special.'][$v_b . '.']['target'] ?? false) { + $menuItems[$c]['target'] = $this->conf['special.'][$v_b . '.']['target']; + } + foreach ((array)($this->conf['special.'][$v_b . '.']['fields.'] ?? []) as $fk => $val) { + $menuItems[$c][$fk] = $val; + } + $c++; + } + } + } + return $menuItems; + } + + /** + * Checks if a page is OK to include in the final menu item array. Pages can be excluded if the doktype is wrong, + * if they are hidden in navigation, have a uid in the list of banned uids etc. + * + * @param array $data Array of menu items + * @param array $banUidArray Array of page uids which are to be excluded + * @param bool $isSpacerPage If set, then the page is a spacer. + * @return bool Returns TRUE if the page can be safely included. + * + * @throws \UnexpectedValueException + */ + public function filterMenuPages(&$data, $banUidArray, $isSpacerPage) + { + if ($data['_SAFE'] ?? false) { + return true; + } + // If the spacer-function is not enabled, spacers will not enter the $menuArr + if (!($this->mconf['SPC'] ?? false) && $isSpacerPage) { + return false; + } + // Page may not be a 'Backend User Section' or any other excluded doktype + if (in_array((int)($data['doktype'] ?? 0), $this->excludedDoktypes, true)) { + return false; + } + // PageID should not be banned (check for default language and translated IDs) + if (($data['_LOCALIZED_UID'] ?? 0) > 0 && in_array((int)$data['_LOCALIZED_UID'], $banUidArray, true)) { + return false; + } + if (in_array((int)($data['uid'] ?? 0), $banUidArray, true)) { + return false; + } + // If the page is hide in menu, but the menu does not include them do not show the page + if (($data['nav_hide'] ?? false) && !($this->conf['includeNotInMenu'] ?? false)) { + return false; + } + // Checking if a page should be shown in the menu depending on whether a translation exists or if the default language is disabled + if (!$this->sys_page->isPageSuitableForLanguage($data, $this->getCurrentLanguageAspect())) { + return false; + } + $languageAspect = $this->getCurrentLanguageAspect(); + // Checking if the link should point to the default language so links to non-accessible pages will not happen + if ($languageAspect->getId() > 0 && !empty($this->conf['protectLvar'])) { + $pageTranslationVisibility = new PageTranslationVisibility((int)($data['l18n_cfg'] ?? 0)); + if ($this->conf['protectLvar'] === 'all' || $pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists()) { + $olRec = $this->sys_page->getPageOverlay((int)($data['uid'] ?? 0), $languageAspect); + if (empty($olRec)) { + // If no page translation record then page can NOT be accessed in + // the language pointed to, therefore we protect the link by linking to the default language + $data['_REQUESTED_OVERLAY_LANGUAGE'] = '0'; + } + } + } + return true; + } + + /** + * Generating the per-menu-item configuration arrays based on the settings for item states (NO, ACT, CUR etc) + * set in ->mconf (config for the current menu object) + * Basically it will produce an individual array for each menu item based on the item states. + * BUT in addition the "optionSplit" syntax for the values is ALSO evaluated here so that all property-values + * are "option-splitted" and the output will thus be resolved. + * Is called from the "generate" functions in the extension classes. The function is processor intensive due to + * the option split feature in particular. But since the generate function is not always called + * (since the ->result array may be cached, see makeMenu) it doesn't hurt so badly. + * + * @param int $splitCount Number of menu items in the menu + * @return array the resolved configuration for each item + */ + protected function processItemStates($splitCount) + { + // Prepare normal settings + if (!is_array($this->mconf['NO.'] ?? null) && $this->mconf['NO']) { + // Setting a blank array if NO=1 and there are no properties. + $this->mconf['NO.'] = []; + } + $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); + $NOconf = $typoScriptService->explodeConfigurationForOptionSplit((array)$this->mconf['NO.'], $splitCount); + + // Prepare custom states settings, overriding normal settings + foreach (self::customItemStates as $state) { + if (empty($this->mconf[$state])) { + continue; + } + $customConfiguration = null; + foreach ($NOconf as $key => $val) { + if ($this->isItemState($state, $key)) { + // if this is the first element of type $state, we must generate the custom configuration. + if ($customConfiguration === null) { + $customConfiguration = $typoScriptService->explodeConfigurationForOptionSplit((array)($this->mconf[$state . '.'] ?? []), $splitCount); + } + // Substitute normal with the custom (e.g. IFSUB) + if (isset($customConfiguration[$key])) { + $NOconf[$key] = $customConfiguration[$key]; + } + } + } + } + + return $NOconf; + } + + /** + * Creates the URL, target and data-window-* attributes for the menu item link. Returns them in an array as key/value pairs for -tag attributes + * + * @param int $key Pointer to a key in the $this->menuArr array where the value for that key represents the menu item we are linking to (page record) + * @param string $altTarget Alternative target + * @param string $typeOverride Alternative type + * @return LinkResultInterface|null + */ + protected function link($key, $altTarget, $typeOverride) + { + $runtimeCache = $this->getRuntimeCache(); + $MP_var = $this->getMPvar($key); + $cacheId = 'menu-generated-links-' . md5( + $key + . ($altTarget ?: ($this->mconf['target'] ?? '') . (isset($this->mconf['target.']) ? json_encode($this->mconf['target.']) : '')) + . $typeOverride + . $MP_var + . ($this->mconf['addParams'] ?? '') + . ($this->I['val']['additionalParams'] ?? '') + . ((string)($this->mconf['showAccessRestrictedPages'] ?? '_')) + . (isset($this->mconf['showAccessRestrictedPages.']) ? json_encode($this->mconf['showAccessRestrictedPages.']) : '') + . json_encode($this->menuArr[$key]) + . ($this->I['val']['ATagParams'] ?? '') + . (isset($this->I['val']['ATagParams.']) ? json_encode($this->I['val']['ATagParams.']) : '') + ); + $runtimeCachedLink = $runtimeCache->get($cacheId); + if ($runtimeCachedLink !== false) { + return $runtimeCachedLink; + } + + $typoScript = $this->request->getAttribute('frontend.typoscript'); + $backupSetupConfigArray = $hackedSetupConfigArray = $typoScript->getConfigArray(); + + // links to a specific page + if ($this->mconf['showAccessRestrictedPages'] ?? false) { + // @todo: Resetting config is a hack. This needs to be resolved differently. Consumed in PageLinkBuilder. + $hackedSetupConfigArray['typolinkLinkAccessRestrictedPages'] = $this->mconf['showAccessRestrictedPages']; + $hackedSetupConfigArray['typolinkLinkAccessRestrictedPages_addParams'] = $this->mconf['showAccessRestrictedPages.']['addParams'] ?? ''; + $hackedSetupConfigArray['typolinkLinkAccessRestrictedPages.']['ATagParams'] = $this->mconf['showAccessRestrictedPages.']['ATagParams'] ?? ''; + $typoScript->setConfigArray($hackedSetupConfigArray); + } + // If a user script returned the value overrideId in the menu array we use that as page id + if (($this->mconf['overrideId'] ?? false) || ($this->menuArr[$key]['overrideId'] ?? false)) { + $overrideId = (int)($this->mconf['overrideId'] ?: $this->menuArr[$key]['overrideId']); + $overrideId = $overrideId > 0 ? $overrideId : null; + // Clear MP parameters since ID was changed. + $MP_params = ''; + } else { + $overrideId = null; + // Mount points: + $MP_params = $MP_var ? '&MP=' . rawurlencode($MP_var) : ''; + } + // Setting main target + $mainTarget = $altTarget ?: (string)$this->parent_cObj->stdWrapValue('target', $this->mconf); + // Creating link + $addParams = ($this->mconf['addParams'] ?? '') . ($this->I['val']['additionalParams'] ?? '') . $MP_params; + try { + $linkResult = $this->menuTypoLink($this->menuArr[$key], $mainTarget, $addParams, $typeOverride, $overrideId); + } catch (UnableToLinkException $e) { + $linkResult = null; + } + // Overriding URL / Target if set to do so: + if ($this->menuArr[$key]['_OVERRIDE_HREF'] ?? false) { + if ($linkResult === null) { + $linkResult = new LinkResult('', ''); + } + $linkResult = $linkResult->withAttribute('href', $this->menuArr[$key]['_OVERRIDE_HREF']); + if ($this->menuArr[$key]['_OVERRIDE_TARGET'] ?? false) { + $linkResult = $linkResult->withAttribute('target', $this->menuArr[$key]['_OVERRIDE_TARGET']); + } + } + $runtimeCache->set($cacheId, $linkResult); + + // End showAccessRestrictedPages + if ($this->mconf['showAccessRestrictedPages'] ?? false) { + $typoScript->setConfigArray($backupSetupConfigArray); + } + + return $linkResult; + } + + /** + * Creates a submenu level to the current level - if configured for. + * + * @param int $uid Page id of the current page for which a submenu MAY be produced (if conditions are met) + * @param string $objSuffix Object prefix, see ->start() + * @return string HTML content of the submenu + */ + protected function subMenu(int $uid, string $objSuffix, int $menuItemKey) + { + // Setting alternative menu item array if _SUB_MENU has been defined in the current ->menuArr + $altArray = []; + if (is_array($this->menuArr[$menuItemKey]['_SUB_MENU'] ?? null) && !empty($this->menuArr[$menuItemKey]['_SUB_MENU'])) { + $altArray = $this->menuArr[$menuItemKey]['_SUB_MENU']; + } + // Make submenu if the page is the next active + $menuType = $this->conf[($this->menuNumber + 1) . $objSuffix] ?? ''; + // stdWrap for expAll + $this->mconf['expAll'] = $this->parent_cObj->stdWrapValue('expAll', $this->mconf); + if (($this->mconf['expAll'] || $this->isNext($uid, $this->getMPvar($menuItemKey)) || $altArray !== []) && !($this->mconf['sectionIndex'] ?? false)) { + try { + $menuObjectFactory = GeneralUtility::makeInstance(MenuContentObjectFactory::class); + /** @var AbstractMenuContentObject $submenu */ + $submenu = $menuObjectFactory->getMenuObjectByType($menuType); + $submenu->entryLevel = $this->entryLevel + 1; + $submenu->rL_uidRegister = $this->rL_uidRegister; + $submenu->MP_array = $this->MP_array; + if ($this->menuArr[$menuItemKey]['_MP_PARAM'] ?? false) { + $submenu->MP_array[] = $this->menuArr[$menuItemKey]['_MP_PARAM']; + } + // Especially scripts that build the submenu needs the parent data + $submenu->parent_cObj = $this->parent_cObj; + $submenu->setParentMenu($this->menuArr, $menuItemKey); + // Setting alternativeMenuTempArray (will be effective only if an array and not empty) + if ($altArray !== []) { + $submenu->alternativeMenuTempArray = $altArray; + } + if ($submenu->start(null, $this->sys_page, $uid, $this->conf, $this->menuNumber + 1, $objSuffix, $this->request)) { + $submenu->makeMenu(); + $registerStack = $this->request->getAttribute('frontend.register.stack'); + $clonedRegister = clone $registerStack->current(); + // Reset the menu item count for the submenu by pushing a new register to register stack + $clonedRegister->set('count_MENUOBJ', 0); + $registerStack->push($clonedRegister); + $content = $submenu->writeMenu(); + $registerStack->pop(); + $registerStack->current()->set('count_menuItems', count($this->menuArr)); + return $content; + } + } catch (NoSuchMenuTypeException) { + } + } + return ''; + } + + /** + * Returns TRUE if the page with UID $uid is the NEXT page in root line (which means a submenu should be drawn) + * + * @param int $uid Page uid to evaluate. + * @param string $MPvar MPvar for the current position of item. + * @return bool TRUE if page with $uid is active + * @see subMenu() + */ + protected function isNext($uid, $MPvar) + { + // Check for always active PIDs: + if (in_array((int)$uid, $this->alwaysActivePIDlist, true)) { + return true; + } + $testUid = $uid . ($MPvar ? ':' . $MPvar : ''); + if ($uid && $testUid == $this->nextActive) { + return true; + } + return false; + } + + /** + * Returns TRUE if the given page is active (in the current rootline) + * + * @param array $page Page record to evaluate. + * @param string $MPvar MPvar for the current position of item. + * @return bool TRUE if $page is active + */ + protected function isActive(array $page, $MPvar) + { + // Check for always active PIDs + $uid = (int)($page['uid'] ?? 0); + if (in_array($uid, $this->alwaysActivePIDlist, true)) { + return true; + } + $testUid = $uid . ($MPvar ? ':' . $MPvar : ''); + if ($uid && in_array('ITEM:' . $testUid, $this->rL_uidRegister, true)) { + return true; + } + try { + $page = $this->sys_page->resolveShortcutPage($page, $this->disableGroupAccessCheck); + if (isset($page['_SHORTCUT_ORIGINAL_PAGE_UID'])) { + $shortcutPage = (int)($page['uid'] ?? 0); + if (in_array($shortcutPage, $this->alwaysActivePIDlist, true)) { + return true; + } + $testUid = $shortcutPage . ($MPvar ? ':' . $MPvar : ''); + if (in_array('ITEM:' . $testUid, $this->rL_uidRegister, true)) { + return true; + } + } + } catch (\Exception $e) { + // Shortcut could not be resolved + return false; + } + return false; + } + + /** + * Returns TRUE if the page is the CURRENT page. + * + * @param array $page Page record to evaluate. + * @param string $MPvar MPvar for the current position of item. + * @return bool TRUE if resolved page ID is current requested page id + */ + protected function isCurrent(array $page, $MPvar) + { + $testUid = ($page['uid'] ?? 0) . ($MPvar ? ':' . $MPvar : ''); + if (($page['uid'] ?? 0) && end($this->rL_uidRegister) === 'ITEM:' . $testUid) { + return true; + } + try { + $page = $this->sys_page->resolveShortcutPage($page); + if (isset($page['_SHORTCUT_ORIGINAL_PAGE_UID'])) { + $shortcutPage = (int)($page['uid'] ?? 0); + $testUid = $shortcutPage . ($MPvar ? ':' . $MPvar : ''); + if (end($this->rL_uidRegister) === 'ITEM:' . $testUid) { + return true; + } + } + } catch (\Exception $e) { + // Shortcut could not be resolved + return false; + } + return false; + } + + /** + * Returns TRUE if there is a submenu with items for the page id, $uid + * Used by the item states "IFSUB", "ACTIFSUB" and "CURIFSUB" to check if there is a submenu + * + * @param int $uid Page uid for which to search for a submenu + * @return bool Returns TRUE if there was a submenu with items found + */ + protected function isSubMenu($uid) + { + $cacheId = $this->getCacheIdentifierForSubMenuDecision($uid); + $runtimeCache = $this->getRuntimeCache(); + $cachedDecision = $runtimeCache->get($cacheId); + if (isset($cachedDecision['result'])) { + return $cachedDecision['result']; + } + // Looking for a mount-pid for this UID since if that + // exists we should look for a subpages THERE and not in the input $uid; + $mount_info = $this->sys_page->getMountPointInfo($uid); + if (is_array($mount_info)) { + $uid = $mount_info['mount_pid']; + } + + // Collect subpages for all pages on current level + $pageIdsOnSameLevel = array_column($this->menuArr, 'uid'); + $cacheIdentifierPagesNextLevel = 'menucontentobject-is-submenu-pages-next-level-' . $this->menuNumber . '-' . sha1(json_encode($pageIdsOnSameLevel)); + $cachePagesNextLevel = $runtimeCache->get($cacheIdentifierPagesNextLevel); + if (!is_array($cachePagesNextLevel)) { + // Use * to ensure all fields required by checkShortcuts validation are available. + $fullPages = $this->sys_page->getMenu($pageIdsOnSameLevel, '*', 'sorting', '', true, $this->disableGroupAccessCheck); + // Cache only the fields actually used in the foreach loop below. + $cachePagesNextLevel = array_map( + static fn(array $page) => array_intersect_key( + $page, + array_flip(['uid', 'pid', 'doktype', 'nav_hide', 'l18n_cfg', '_LOCALIZED_UID']), + ), + $fullPages, + ); + $runtimeCache->set($cacheIdentifierPagesNextLevel, $cachePagesNextLevel); + } + + $recs = array_filter($cachePagesNextLevel, static fn(array $item) => (int)$item['pid'] === (int)$uid); + + $hasSubPages = false; + $bannedUids = $this->getBannedUids(); + $languageId = $this->getCurrentLanguageAspect()->getId(); + foreach ($recs as $theRec) { + // no valid subpage if the document type is excluded from the menu + if (in_array((int)($theRec['doktype'] ?? 0), $this->excludedDoktypes, true)) { + continue; + } + // No valid subpage if the page is hidden inside menus and + // it wasn't forced to show such entries + if (isset($theRec['nav_hide']) && $theRec['nav_hide'] + && (!isset($this->conf['includeNotInMenu']) || !$this->conf['includeNotInMenu']) + ) { + continue; + } + // No valid subpage if the default language should be shown and the page settings + // are excluding the visibility of the default language + $pageTranslationVisibility = new PageTranslationVisibility((int)($theRec['l18n_cfg'] ?? 0)); + if (!$languageId && $pageTranslationVisibility->shouldBeHiddenInDefaultLanguage()) { + continue; + } + // No valid subpage if the alternative language should be shown and the page settings + // are requiring a valid overlay, but it doesn't exist + if ($pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists() && $languageId > 0 && !isset($theRec['_LOCALIZED_UID'])) { + continue; + } + // No valid subpage if the subpage is banned by excludeUidList (check for default language pages as well) + if (isset($theRec['_LOCALIZED_UID']) && in_array($theRec['_LOCALIZED_UID'], $bannedUids, true)) { + continue; + } + if (in_array((int)($theRec['uid'] ?? 0), $bannedUids, true)) { + continue; + } + $hasSubPages = true; + break; + } + $runtimeCache->set($cacheId, ['result' => $hasSubPages]); + return $hasSubPages; + } + + protected function getCacheIdentifierForSubMenuDecision($uid): string + { + return 'menucontentobject-is-submenu-decision-' . $uid . '-' . (int)($this->conf['includeNotInMenu'] ?? 0); + } + + /** + * Used by processItemStates() to evaluate if a menu item (identified by $key) is in a certain state. + * + * @param string $kind The item state to evaluate (SPC, IFSUB, ACT etc...) + * @param int $key Key pointing to menu item from ->menuArr + * @return bool Returns TRUE if state matches + * @see processItemStates() + */ + protected function isItemState($kind, $key) + { + $natVal = false; + // If any value is set for ITEM_STATE the normal evaluation is discarded + if ($this->menuArr[$key]['ITEM_STATE'] ?? false) { + if ((string)$this->menuArr[$key]['ITEM_STATE'] === (string)$kind) { + $natVal = true; + } + } else { + switch ($kind) { + case 'SPC': + $natVal = (bool)$this->menuArr[$key]['isSpacer']; + break; + case 'IFSUB': + $natVal = $this->isSubMenu($this->menuArr[$key]['uid'] ?? 0); + break; + case 'ACT': + $natVal = $this->isActive(($this->menuArr[$key] ?? []), $this->getMPvar($key)); + break; + case 'ACTIFSUB': + $natVal = $this->isActive(($this->menuArr[$key] ?? []), $this->getMPvar($key)) && $this->isSubMenu($this->menuArr[$key]['uid']); + break; + case 'CUR': + $natVal = $this->isCurrent(($this->menuArr[$key] ?? []), $this->getMPvar($key)); + break; + case 'CURIFSUB': + $natVal = $this->isCurrent(($this->menuArr[$key] ?? []), $this->getMPvar($key)) && $this->isSubMenu($this->menuArr[$key]['uid']); + break; + case 'USR': + $natVal = (bool)$this->menuArr[$key]['fe_group']; + break; + } + } + return $natVal; + } + + /** + * Calls a user function for processing of internal data. + * Used for the properties "IProcFunc" and "itemArrayProcFunc" + * + * @param string $mConfKey Key pointing for the property in the current ->mconf array holding possibly parameters to pass along to the function/method. Currently the keys used are "IProcFunc" and "itemArrayProcFunc". + * @param mixed $passVar A variable to pass to the user function and which should be returned again from the user function. The idea is that the user function modifies this variable according to what you want to achieve and then returns it. For "itemArrayProcFunc" this variable is $this->menuArr, for "IProcFunc" it is $this->I + * @return mixed The processed $passVar + */ + protected function userProcess($mConfKey, $passVar) + { + if ($this->mconf[$mConfKey]) { + $funcConf = (array)($this->mconf[$mConfKey . '.'] ?? []); + $funcConf['parentObj'] = $this; + $passVar = $this->parent_cObj->callUserFunction($this->mconf[$mConfKey], $funcConf, $passVar); + } + return $passVar; + } + + /** + * Creates the tag parts for the current item (in $this->I, [A1] and [A2]) based on the given link result + */ + protected function setATagParts(?LinkResultInterface $linkResult) + { + $this->I['A1'] = $linkResult ? 'getAttributes(), true) . '>' : ''; + $this->I['A2'] = $linkResult ? '' : ''; + } + + /** + * Returns the title for the navigation + * + * @param string $title The current page title + * @param string $nav_title The current value of the navigation title + * @return string Returns the navigation title if it is NOT blank, otherwise the page title. + */ + protected function getPageTitle($title, $nav_title) + { + return trim($nav_title) !== '' ? $nav_title : $title; + } + + /** + * Return MPvar string for entry $key in ->menuArr + * + * @param int $key Pointer to element in ->menuArr + * @return string MP vars for element. + * @see link() + */ + protected function getMPvar($key) + { + if ($GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids']) { + $localMP_array = $this->MP_array; + // NOTICE: "_MP_PARAM" is allowed to be a commalist of PID pairs! + if ($this->menuArr[$key]['_MP_PARAM'] ?? false) { + $localMP_array[] = $this->menuArr[$key]['_MP_PARAM']; + } + return !empty($localMP_array) ? implode(',', $localMP_array) : ''; + } + return ''; + } + + /** + * Returns where clause part to exclude 'not in menu' pages + * + * @return string where clause part. + */ + protected function getDoktypeExcludeWhere() + { + return !empty($this->excludedDoktypes) ? ' AND pages.doktype NOT IN (' . implode(',', $this->excludedDoktypes) . ')' : ''; + } + + /** + * Returns an array of banned UIDs (from excludeUidList) + * + * @return array Array of banned UIDs + */ + protected function getBannedUids() + { + $excludeUidList = (string)$this->parent_cObj->stdWrapValue('excludeUidList', $this->conf); + if (!trim($excludeUidList)) { + return []; + } + $currentPageUid = $this->request->getAttribute('frontend.page.information')->getPageRecord()['uid'] ?? ''; + $banUidList = str_replace('current', (string)($currentPageUid), $excludeUidList); + return GeneralUtility::intExplode(',', $banUidList); + } + + /** + * Calls typolink to create menu item links. + * + * @param array $page Page record (uid points where to link to) + * @param string $oTarget Target frame/window + * @param string $addParams Parameters to add to URL + * @param int|string $typeOverride "type" value, empty string means "not set" + * @param int|null $overridePageId link to this page instead of the $page[uid] value + */ + protected function menuTypoLink(array $page, string $oTarget, $addParams, $typeOverride, ?int $overridePageId = null): LinkResultInterface + { + $conf = [ + 'parameter' => $overridePageId ?? $page['uid'] ?? 0, + ]; + if (MathUtility::canBeInterpretedAsInteger($typeOverride)) { + $conf['parameter'] .= ',' . (int)$typeOverride; + } + if ($addParams) { + $conf['additionalParams'] = $addParams; + } + // Used only for special=language + if ($page['_ADD_GETVARS'] ?? false) { + $conf['addQueryString'] = $page['_ADD_GETVARS']; + $conf['addQueryString.'] = $this->conf['addQueryString.'] ?? []; + } + + // Ensure that the typolink gets an info which language was actually requested. The $page record could be the record + // from page translation language=1 as fallback but page translation language=2 was requested. Search for + // "_REQUESTED_OVERLAY_LANGUAGE" for more details + if (isset($page['_REQUESTED_OVERLAY_LANGUAGE'])) { + $conf['language'] = $page['_REQUESTED_OVERLAY_LANGUAGE']; + } + if ($oTarget) { + $conf['target'] = $oTarget; + } + // $this->I['val'] contains the configuration of the ItemState (e.g. NO / SPC) etc, which should be handed in + // to this method instead of accessed directly in the future. + if (isset($this->I['val']['ATagParams']) || isset($this->I['val']['ATagParams.'])) { + $conf['ATagParams'] = $this->I['val']['ATagParams'] ?? ''; + $conf['ATagParams.'] = $this->I['val']['ATagParams.'] ?? []; + } + if ($page['sectionIndex_uid'] ?? false) { + $conf['section'] = $page['sectionIndex_uid']; + } + $conf['page'] = $this->createPageObject($page); + + $backupData = $this->parent_cObj->data; + $this->parent_cObj->data = $page; + try { + $link = $this->parent_cObj->createLink('|', $conf); + } finally { + $this->parent_cObj->data = $backupData; + } + + return $link; + } + + /** + * Generates a list of content objects with sectionIndex enabled + * available on a specific page + * + * Used for menus with sectionIndex enabled + * + * @param string $altSortField Alternative sorting field + * @param int $pid The page id to search for sections + * @throws \UnexpectedValueException if the query to fetch the content elements unexpectedly fails + * @return array + */ + protected function sectionIndex($altSortField, $pid = null) + { + $pid = (int)($pid ?: $this->id); + $basePageRow = $this->sys_page->getPage($pid); + if ($basePageRow === []) { + return []; + } + $useColPos = (int)$this->parent_cObj->stdWrapValue('useColPos', $this->mconf['sectionIndex.'] ?? [], 0); + $selectSetup = [ + 'pidInList' => $pid, + 'orderBy' => $altSortField, + 'languageField' => 'sys_language_uid', + 'where' => '', + ]; + + if ($useColPos >= 0) { + $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('tt_content') + ->getExpressionBuilder(); + $selectSetup['where'] = $expressionBuilder->eq('colPos', $useColPos); + } + + if ($basePageRow['content_from_pid'] ?? false) { + // If the page is configured to show content from a referenced page the sectionIndex contains only contents of + // the referenced page + $selectSetup['pidInList'] = $basePageRow['content_from_pid']; + } + $statement = $this->parent_cObj->exec_getQuery('tt_content', $selectSetup); + $result = []; + while ($row = $statement->fetchAssociative()) { + $this->sys_page->versionOL('tt_content', $row); + if ($this->getCurrentLanguageAspect()->doOverlays() && $basePageRow['language_tag'] > 0) { + $languageAspect = new LanguageAspect($basePageRow['language_tag'], $basePageRow['language_tag'], $this->getCurrentLanguageAspect()->getOverlayType()); + $row = $this->sys_page->getLanguageOverlay( + 'tt_content', + $row, + $languageAspect + ); + } + if (is_array($row)) { + $sectionIndexType = $this->mconf['sectionIndex.']['type'] ?? ''; + if ($sectionIndexType !== 'all') { + $doIncludeInSectionIndex = $row['sectionIndex'] >= 1; + $doHeaderCheck = $sectionIndexType === 'header'; + $isValidHeader = ((int)$row['header_layout'] !== 100 || !empty($this->mconf['sectionIndex.']['includeHiddenHeaders'])) && trim($row['header']) !== ''; + if (!$doIncludeInSectionIndex || ($doHeaderCheck && !$isValidHeader)) { + continue; + } + } + $uid = $row['uid'] ?? null; + $result[$uid ?? ''] = $basePageRow; + $result[$uid ?? '']['title'] = $row['header']; + $result[$uid ?? '']['nav_title'] = $row['header']; + // Prevent false exclusion in filterMenuPages, thus: Always show tt_content records + $result[$uid ?? '']['nav_hide'] = 0; + $result[$uid ?? '']['subtitle'] = $row['subheader'] ?? ''; + $result[$uid ?? '']['starttime'] = $row['starttime'] ?? ''; + $result[$uid ?? '']['endtime'] = $row['endtime'] ?? ''; + $result[$uid ?? '']['fe_group'] = $row['fe_group'] ?? ''; + $result[$uid ?? '']['media'] = $row['media'] ?? ''; + $result[$uid ?? '']['header_layout'] = $row['header_layout'] ?? ''; + $result[$uid ?? '']['bodytext'] = $row['bodytext'] ?? ''; + $result[$uid ?? '']['image'] = $row['image'] ?? ''; + $result[$uid ?? '']['sectionIndex_uid'] = $uid; + } + } + + return $result; + } + + /** + * Returns the sys_page object + * + * @return PageRepository + */ + public function getSysPage() + { + return $this->sys_page; + } + + /** + * Returns the parent content object + * + * @return ContentObjectRenderer + */ + public function getParentContentObject() + { + return $this->parent_cObj; + } + + protected function getCurrentLanguageAspect(): LanguageAspect + { + return GeneralUtility::makeInstance(Context::class)->getAspect('language'); + } + + protected function getTimeTracker(): TimeTracker + { + return GeneralUtility::makeInstance(TimeTracker::class); + } + + protected function getCache(): FrontendInterface + { + return GeneralUtility::makeInstance(CacheManager::class)->getCache('hash'); + } + + protected function getRuntimeCache(): FrontendInterface + { + return GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'); + } + + protected function getCurrentSite(): Site + { + return $this->request->getAttribute('site'); + } + + /** + * Set the parentMenuArr and key to provide the parentMenu information to the + * subMenu, special fur IProcFunc and itemArrayProcFunc user functions. + * @internal + */ + public function setParentMenu(array $menuArr, int $menuItemKey): void + { + // check if menuArr is a valid array and that menuItemKey matches an existing menuItem in menuArr + if ($menuItemKey >= 0 && isset($menuArr[$menuItemKey])) { + $this->parentMenuArr = $menuArr; + $this->parentMenuArrItemKey = $menuItemKey; + } + } + + /** + * Check if there is a valid parentMenuArr. + */ + protected function hasParentMenuArr(): bool + { + return + $this->menuNumber > 1 + && !empty($this->parentMenuArr) + ; + } + + /** + * Check if we have a parentMenuArrItemKey + */ + protected function hasParentMenuItemKey(): bool + { + return $this->parentMenuArrItemKey !== null; + } + + /** + * Check if the parentMenuItem exists + */ + protected function hasParentMenuItem(): bool + { + return + $this->hasParentMenuArr() + && $this->hasParentMenuItemKey() + && isset($this->getParentMenuArr()[$this->parentMenuArrItemKey]) + ; + } + + /** + * Get the parentMenuArr, if this is subMenu. + */ + public function getParentMenuArr(): array + { + return $this->hasParentMenuArr() ? $this->parentMenuArr : []; + } + + /** + * Get the parentMenuItem from the parentMenuArr, if this is a subMenu + */ + public function getParentMenuItem(): ?array + { + // check if we have a parentMenuItem and if it is an array + if ($this->hasParentMenuItem() + && is_array($this->getParentMenuArr()[$this->parentMenuArrItemKey]) + ) { + return $this->getParentMenuArr()[$this->parentMenuArrItemKey]; + } + + return null; + } + + private function getMode(string $mode = ''): string + { + return match ($mode) { + 'starttime' => 'starttime', + 'lastUpdated', 'manual' => 'lastUpdated', + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + default => 'SYS_LASTCHANGED', + }; + } + + /** + * Returns the level of the given page in the rootline - Multiple pages can be given by separating the UIDs by comma. + * + * @param string $list A list of UIDs for which the rootline-level should get returned + * @return int The level in the rootline. If more than one page was given the lowest level will get returned. + */ + private function getRootlineLevel(array $rootLine, string $list): int + { + $idx = 0; + foreach ($rootLine as $page) { + if (GeneralUtility::inList($list, $page['uid'])) { + return $idx; + } + $idx++; + } + return 0; + } + + /** + * Returns menu items as a structured array instead of rendered HTML. + * This provides direct access to menu data without rendering overhead. + * + * @return array + */ + public function getMenuItems(): array + { + if (empty($this->menuArr)) { + return []; + } + + $menuItems = []; + foreach ($this->menuArr as $key => $menuArrItem) { + $spacer = (bool)($menuArrItem['isSpacer'] ?? false); + + // Initialize I array for link() method compatibility + $this->I = [ + 'key' => $key, + 'val' => $this->result[$key] ?? [], + ]; + + // Generate link (skip for spacers) + $linkResult = null; + if (!$spacer) { + $linkResult = $this->link( + $key, + (string)($this->I['val']['altTarget'] ?? ''), + (string)($this->mconf['forceTypeValue'] ?? '') + ); + } + + // Build menu item + $menuItem = [ + 'data' => $menuArrItem, + 'title' => $this->getPageTitle( + $menuArrItem['title'] ?? '', + $menuArrItem['nav_title'] ?? '' + ), + 'link' => $linkResult?->getUrl() ?? '', + 'target' => $linkResult?->getTarget() ?? '', + 'active' => $this->isActive($menuArrItem, $this->getMPvar($key)) ? 1 : 0, + 'current' => $this->isCurrent($menuArrItem, $this->getMPvar($key)) ? 1 : 0, + 'spacer' => $spacer ? 1 : 0, + 'hasSubpages' => $this->isSubMenu($menuArrItem['uid'] ?? 0) ? 1 : 0, + ]; + + // Handle IProcFunc for backwards compatibility + if ($this->mconf['IProcFunc'] ?? false) { + $this->I['linkHREF'] = $linkResult; + $this->I = $this->userProcess('IProcFunc', $this->I); + // Allow IProcFunc to modify the link + if (isset($this->I['linkHREF']) && $this->I['linkHREF'] !== $linkResult) { + $menuItem['link'] = $this->I['linkHREF']->getUrl() ?? ''; + $menuItem['target'] = $this->I['linkHREF']->getTarget() ?? ''; + } + } + + // Get submenu items recursively + $children = $this->getSubMenuItems($menuArrItem['uid'], $key); + if ($children !== []) { + $menuItem['children'] = $children; + } + + $menuItems[] = $menuItem; + } + + return $menuItems; + } + + /** + * Get submenu items as array (recursive helper for getMenuItems) + */ + protected function getSubMenuItems(int $uid, int $menuItemKey): array + { + $altArray = []; + if (is_array($this->menuArr[$menuItemKey]['_SUB_MENU'] ?? null) + && $this->menuArr[$menuItemKey]['_SUB_MENU'] !== [] + ) { + $altArray = $this->menuArr[$menuItemKey]['_SUB_MENU']; + } + + $menuType = $this->conf[($this->menuNumber + 1)] ?? ''; + $this->mconf['expAll'] = $this->parent_cObj->stdWrapValue('expAll', $this->mconf); + + if ($this->mconf['sectionIndex'] ?? false) { + return []; + } + + if ($this->mconf['expAll'] || $this->isNext($uid, $this->getMPvar($menuItemKey)) || $altArray !== []) { + try { + $menuObjectFactory = GeneralUtility::makeInstance(MenuContentObjectFactory::class); + $submenu = $menuObjectFactory->getMenuObjectByType($menuType); + } catch (NoSuchMenuTypeException) { + return []; + } + $submenu->entryLevel = $this->entryLevel + 1; + $submenu->rL_uidRegister = $this->rL_uidRegister; + $submenu->MP_array = $this->MP_array; + if ($this->menuArr[$menuItemKey]['_MP_PARAM'] ?? false) { + $submenu->MP_array[] = $this->menuArr[$menuItemKey]['_MP_PARAM']; + } + $submenu->parent_cObj = $this->parent_cObj; + $submenu->setParentMenu($this->menuArr, $menuItemKey); + $submenu->alternativeMenuTempArray = $altArray; + if ($submenu->start(null, $this->sys_page, $uid, $this->conf, $this->menuNumber + 1, '', $this->request)) { + $submenu->makeMenu(); + return $submenu->getMenuItems(); + } + } + return []; + } + + protected function createPageObject(array $page): RecordInterface + { + return GeneralUtility::makeInstance(RecordFactory::class)->createFromDatabaseRow('pages', $page); + } +} diff --git a/Classes/ContentObject/Menu/CategoryMenuUtility.php b/Classes/ContentObject/Menu/CategoryMenuUtility.php new file mode 100644 index 0000000..b48511e --- /dev/null +++ b/Classes/ContentObject/Menu/CategoryMenuUtility.php @@ -0,0 +1,117 @@ +getParentContentObject()->stdWrapValue('relation', $configuration ?? []); + // Get the pages for each selected category + $selectedCategories = GeneralUtility::intExplode(',', $selectedCategories, true); + foreach ($selectedCategories as $aCategory) { + $collection = CategoryCollection::load( + $aCategory, + true, + 'pages', + $relationField + ); + $categoryUid = $collection->getUid(); + // Loop on the results, overlay each page record found + foreach ($collection as $pageItem) { + $parentObject->getSysPage()->versionOL('pages', $pageItem, true); + if (is_array($pageItem)) { + $selectedPages[$pageItem['uid']] = $parentObject->getSysPage()->getLanguageOverlay('pages', $pageItem); + // Keep a list of the categories each page belongs to + if (!isset($categoriesPerPage[$pageItem['uid']])) { + $categoriesPerPage[$pageItem['uid']] = []; + } + $categoriesPerPage[$pageItem['uid']][] = $categoryUid; + } + } + } + // Loop on the selected pages to add the categories they belong to, as comma-separated list of category uid's) + // (this makes them available for rendering, if needed) + foreach ($selectedPages as $uid => $pageRecord) { + $selectedPages[$uid]['_categories'] = implode(',', $categoriesPerPage[$uid]); + } + + // Sort the pages according to the sorting property + self::$sortingField = (string)$parentObject->getParentContentObject()->stdWrapValue('sorting', $configuration ?? []); + $order = (string)$parentObject->getParentContentObject()->stdWrapValue('order', $configuration ?? []); + $selectedPages = $this->sortPages($selectedPages, $order); + + return $selectedPages; + } + + /** + * Sorts the selected pages + * + * If the sorting field is not defined or does not corresponding to an existing field + * of the "pages" tables, the list of pages will remain unchanged. + * + * @param array $pages List of selected pages + * @param string $order Order for sorting (should "asc" or "desc") + * @return array Sorted list of pages + */ + protected function sortPages($pages, $order) + { + // Perform the sorting only if a criterion was actually defined + if (!empty(self::$sortingField)) { + // Check that the sorting field exists (checking the first record is enough) + $firstPage = current($pages); + if (isset($firstPage[self::$sortingField])) { + // Make sure the order property is either "asc" or "desc" (default is "asc") + if (!empty($order)) { + $order = strtolower($order); + if ($order !== 'desc') { + $order = 'asc'; + } + } + $sortMultiplier = $order === 'asc' ? 1 : -1; + uasort($pages, static function (array $pageA, array $pageB) use ($sortMultiplier): int { + return strnatcasecmp($pageA[self::$sortingField], $pageB[self::$sortingField]) * $sortMultiplier; + }); + } + } + return $pages; + } +} diff --git a/Classes/ContentObject/Menu/Exception/NoSuchMenuTypeException.php b/Classes/ContentObject/Menu/Exception/NoSuchMenuTypeException.php new file mode 100644 index 0000000..167bd0a --- /dev/null +++ b/Classes/ContentObject/Menu/Exception/NoSuchMenuTypeException.php @@ -0,0 +1,23 @@ + TextMenuContentObject::class, + ]; + + /** + * Gets a typo script string like 'TMENU' and returns an object of this type + * + * @throws Exception\NoSuchMenuTypeException + */ + public function getMenuObjectByType(string $type = ''): AbstractMenuContentObject + { + $upperCasedClassName = strtoupper($type); + if (array_key_exists($upperCasedClassName, $this->menuTypeToClassMapping)) { + /** @var AbstractMenuContentObject $object */ + $object = GeneralUtility::makeInstance($this->menuTypeToClassMapping[$upperCasedClassName]); + return $object; + } + throw new NoSuchMenuTypeException( + 'Menu type ' . (string)$type . ' has no implementing class.', + 1363278130 + ); + } + + /** + * Register new menu type or override existing type + * + * @param string $type Menu type to be used in TypoScript + * @param string $className Class rendering the menu + */ + public function registerMenuType(string $type, string $className) + { + $this->menuTypeToClassMapping[strtoupper($type)] = $className; + } +} diff --git a/Classes/ContentObject/Menu/TextMenuContentObject.php b/Classes/ContentObject/Menu/TextMenuContentObject.php new file mode 100644 index 0000000..2355ee2 --- /dev/null +++ b/Classes/ContentObject/Menu/TextMenuContentObject.php @@ -0,0 +1,166 @@ +result array of menu items configuration (made by ->generate()) and renders each item. + * An instance of ContentObjectRenderer is also made and for each menu item rendered it is loaded with + * the record for that page so that any stdWrap properties that applies will have the current menu items record available. + * + * @return string The HTML for the menu including submenus + */ + public function writeMenu() + { + if (empty($this->result)) { + return ''; + } + + $register = $this->request->getAttribute('frontend.register.stack')->current(); + $cObjectForCurrentMenu = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $menuContent = []; + $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); + $subMenuObjSuffixes = $typoScriptService->explodeConfigurationForOptionSplit(['sOSuffix' => $this->mconf['submenuObjSuffixes'] ?? null], count($this->result)); + $explicitSpacerRenderingEnabled = ($this->mconf['SPC'] ?? false); + foreach ($this->result as $key => $val) { + $register->set('count_HMENU_MENUOBJ', (int)$register->get('count_HMENU_MENUOBJ', 0) + 1); + $register->set('count_MENUOBJ', (int)$register->get('count_MENUOBJ', 0) + 1); + + // Initialize the cObj with the page record of the menu item + $cObjectForCurrentMenu->setRequest($this->request); + $cObjectForCurrentMenu->start($this->menuArr[$key], 'pages'); + $this->I = []; + $this->I['key'] = $key; + $this->I['val'] = $val; + $this->I['title'] = $this->getPageTitle($this->menuArr[$key]['title'] ?? '', $this->menuArr[$key]['nav_title'] ?? ''); + $this->I['title.'] = $this->I['val']['stdWrap.'] ?? []; + $this->I['title'] = $cObjectForCurrentMenu->stdWrapValue('title', $this->I); + $this->I['uid'] = $this->menuArr[$key]['uid'] ?? 0; + $this->I['mount_pid'] = $this->menuArr[$key]['mount_pid'] ?? 0; + $this->I['pid'] = $this->menuArr[$key]['pid'] ?? 0; + $this->I['spacer'] = $this->menuArr[$key]['isSpacer'] ?? false; + // Make link tag + $this->I['val']['additionalParams'] = $cObjectForCurrentMenu->stdWrapValue('additionalParams', $this->I['val']); + $linkResult = $this->link((int)$key, (string)($this->I['val']['altTarget'] ?? ''), ($this->mconf['forceTypeValue'] ?? '')); + if ($linkResult === null) { + $this->I['val']['doNotLinkIt'] = 1; + } + // Title attribute of links + $titleAttrValue = $cObjectForCurrentMenu->stdWrapValue('ATagTitle', $this->I['val']); + if ($linkResult && $titleAttrValue !== '') { + $linkResult = $linkResult->withAttribute('title', $titleAttrValue); + } + $this->I['linkHREF'] = $linkResult; + $this->I['val']['doNotLinkIt'] = (bool)$cObjectForCurrentMenu->stdWrapValue('doNotLinkIt', $this->I['val']); + // Compile link tag + if (!$this->I['spacer'] && !$this->I['val']['doNotLinkIt']) { + $this->setATagParts($linkResult); + } else { + $this->I['A1'] = ''; + $this->I['A2'] = ''; + } + // ATagBeforeWrap processing: + if ($this->I['val']['ATagBeforeWrap'] ?? false) { + $wrapPartsBefore = explode('|', $this->I['val']['linkWrap'] ?? ''); + $wrapPartsAfter = ['', '']; + } else { + $wrapPartsBefore = ['', '']; + $wrapPartsAfter = explode('|', $this->I['val']['linkWrap'] ?? ''); + } + if (($this->I['val']['stdWrap2'] ?? false) || isset($this->I['val']['stdWrap2.'])) { + $stdWrap2 = (string)(isset($this->I['val']['stdWrap2.']) ? $cObjectForCurrentMenu->stdWrap('|', $this->I['val']['stdWrap2.']) : '|'); + $stdWrap2Value = (string)($this->I['val']['stdWrap2'] ?? '|'); + $stdWrap2Value = $stdWrap2Value !== '' ? $stdWrap2Value : '|'; + $wrapPartsStdWrap = explode($stdWrap2Value, $stdWrap2); + } else { + $wrapPartsStdWrap = ['', '']; + } + // Make before, middle and after parts + $this->I['parts'] = []; + $this->I['parts']['before'] = $this->getBeforeAfter('before', $cObjectForCurrentMenu); + $this->I['parts']['stdWrap2_begin'] = $wrapPartsStdWrap[0]; + // stdWrap for doNotShowLink + $this->I['val']['doNotShowLink'] = $cObjectForCurrentMenu->stdWrapValue('doNotShowLink', $this->I['val']); + if (!$this->I['val']['doNotShowLink']) { + $this->I['parts']['notATagBeforeWrap_begin'] = $wrapPartsAfter[0]; + $this->I['parts']['ATag_begin'] = $this->I['A1']; + $this->I['parts']['ATagBeforeWrap_begin'] = $wrapPartsBefore[0]; + $this->I['parts']['title'] = $this->I['title']; + $this->I['parts']['ATagBeforeWrap_end'] = $wrapPartsBefore[1] ?? ''; + $this->I['parts']['ATag_end'] = $this->I['A2']; + $this->I['parts']['notATagBeforeWrap_end'] = $wrapPartsAfter[1] ?? ''; + } + $this->I['parts']['stdWrap2_end'] = $wrapPartsStdWrap[1] ?? ''; + $this->I['parts']['after'] = $this->getBeforeAfter('after', $cObjectForCurrentMenu); + // Passing I to a user function + if ($this->mconf['IProcFunc'] ?? false) { + $this->I = $this->userProcess('IProcFunc', $this->I); + } + // Merge parts + beforeAllWrap + $this->I['theItem'] = implode('', $this->I['parts']); + $allWrap = $cObjectForCurrentMenu->stdWrapValue('allWrap', $this->I['val']); + $this->I['theItem'] = $cObjectForCurrentMenu->wrap($this->I['theItem'], $allWrap); + if ($this->I['val']['subst_elementUid'] ?? false) { + $this->I['theItem'] = str_replace('{elementUid}', (string)$this->I['uid'], $this->I['theItem']); + } + if (is_array($this->I['val']['allStdWrap.'] ?? null)) { + $this->I['theItem'] = $cObjectForCurrentMenu->stdWrap($this->I['theItem'], $this->I['val']['allStdWrap.']); + } + $isSpacerPage = $this->I['spacer'] ?? false; + // If rendering of SPACERs is enabled, also allow rendering submenus with Spacers + if (!$isSpacerPage || $explicitSpacerRenderingEnabled) { + // Add part to the accumulated result + fetch submenus + $this->I['theItem'] .= $this->subMenu($this->I['uid'], $subMenuObjSuffixes[$key]['sOSuffix'] ?? '', $key); + } + $part = $cObjectForCurrentMenu->stdWrapValue('wrapItemAndSub', $this->I['val']); + $menuContent[] = $part ? $cObjectForCurrentMenu->wrap($this->I['theItem'], $part) : $this->I['theItem']; + } + + $menuContent = implode('', $menuContent); + if (is_array($this->mconf['stdWrap.'] ?? null)) { + $menuContent = (string)$cObjectForCurrentMenu->stdWrap($menuContent, $this->mconf['stdWrap.']); + } + return $cObjectForCurrentMenu->wrap($menuContent, $this->mconf['wrap'] ?? ''); + } + + /** + * Generates the before* and after* stdWrap for TMENUs + * Evaluates: + * - before.stdWrap* + * - beforeWrap + * - after.stdWrap* + * - afterWrap + * + * @param string $pref Can be "before" or "after" and determines which kind of stdWrap to process (basically this is the prefix of the TypoScript properties that are read from the ->I['val'] array + * @return string The resulting HTML + */ + protected function getBeforeAfter(string $pref, ContentObjectRenderer $cObjectForCurrentMenu): string + { + $processedPref = $cObjectForCurrentMenu->stdWrapValue($pref, $this->I['val']); + if (isset($this->I['val'][$pref . 'Wrap'])) { + return $cObjectForCurrentMenu->wrap($processedPref, $this->I['val'][$pref . 'Wrap']); + } + return $processedPref; + } +} diff --git a/Classes/ContentObject/PageViewContentObject.php b/Classes/ContentObject/PageViewContentObject.php new file mode 100644 index 0000000..6136ca6 --- /dev/null +++ b/Classes/ContentObject/PageViewContentObject.php @@ -0,0 +1,175 @@ + $path . 'Pages/', $paths), + // @todo: We should *still* allow setting both partialRootPaths and layoutRootPaths, and only fall back to + // [templateRootPaths]/Partials and [templateRootPaths]/Layouts if not set. And the fallback should be + // advertised as best practice. + partialRootPaths: array_map(static fn(string $path): string => $path . 'Partials/', $paths), + layoutRootPaths: array_map(static fn(string $path): string => $path . 'Layouts/', $paths), + request: $this->request, + ); + $view = $this->viewFactory->create($viewFactoryData); + + $pageSettings = $this->request->getAttribute('frontend.typoscript')->getSettingsTree()->toArray(); + $view->assign('settings', $this->typoScriptService->convertTypoScriptArrayToPlainArray($pageSettings)); + $variables = $this->getContentObjectVariables($conf); + $variables = $this->contentDataProcessor->process($this->cObj, $conf, $variables); + $view->assignMultiple($variables); + + // Fetch the Fluid template by the name of the Page Layout and underneath "Pages" + $pageInformationObject = $this->request->getAttribute('frontend.page.information'); + $pageLayoutName = $this->pageLayoutResolver->getLayoutIdentifierForPageWithoutPrefix( + $pageInformationObject->getPageRecord(), + $pageInformationObject->getRootLine() + ); + try { + return $view->render($pageLayoutName); + } catch (InvalidTemplateResourceException $e) { + // Only add a PAGEVIEW specific message in case the exception has been thrown for the given template. + if ($e instanceof InvalidPartialException || $e instanceof InvalidLayoutException || $e->templateName !== 'Default/' . $pageLayoutName) { + throw $e; + } + throw new InvalidTemplateResourceException( + sprintf( + 'PAGEVIEW TypoScript object: Failed to resolve a template file for page layout "%s". See also: %s. The following paths were checked: "%s"', + $pageLayoutName, + Typo3Information::getDocsLink('t3tsref:cobj-pageview'), + implode('", "', $e->evaluatedTemplatePaths), + ), + 1742058289, + $e, + $e->templateName, + $e->evaluatedTemplatePaths, + ); + } + } + + /** + * Compile rendered content objects in variables array ready to assign to the view + * + * @param array $conf Configuration array + * @return array the variables to be assigned + */ + private function getContentObjectVariables(array $conf): array + { + $pageInformation = $this->request->getAttribute('frontend.page.information'); + $variables = [ + 'site' => $this->request->getAttribute('site'), + 'language' => $this->request->getAttribute('language'), + 'page' => $pageInformation, + ]; + // Accumulate the variables to be process and loop them through cObjGetSingle + if (is_array($conf['variables.'] ?? false) && $conf['variables.'] !== []) { + foreach ($conf['variables.'] as $variableName => $cObjType) { + if (!is_string($cObjType)) { + continue; + } + if (in_array($variableName, self::reservedVariables, true)) { + throw new \InvalidArgumentException( + 'Cannot use reserved name "' . $variableName . '" as variable name in PAGEVIEW.', + 1711748615 + ); + } + $cObjConf = $conf['variables.'][$variableName . '.'] ?? []; + $variables[$variableName] = $this->cObj->cObjGetSingle($cObjType, $cObjConf, 'variables.' . $variableName); + } + } + if (!($conf['contentAs'] ?? false) && isset($variables['content'])) { + throw new \InvalidArgumentException( + 'No variable name ("contentAs" option) for the content areas has been defined in PAGEVIEW, and the fallback name "content" is not available because it has been manually set.', + 1726475574 + ); + } + + $variables[$conf['contentAs'] ?? 'content'] = $pageInformation->getPageLayout()?->getContentAreas()->withRequest($this->request); + return $variables; + } +} diff --git a/Classes/ContentObject/RecordsContentObject.php b/Classes/ContentObject/RecordsContentObject.php new file mode 100644 index 0000000..93572cc --- /dev/null +++ b/Classes/ContentObject/RecordsContentObject.php @@ -0,0 +1,224 @@ +itemArray = []; + $this->data = []; + + $theValue = ''; + + $tables = (string)$this->cObj->stdWrapValue('tables', $conf ?? []); + if ($tables !== '') { + $tablesArray = array_unique(GeneralUtility::trimExplode(',', $tables, true)); + // Add tables which have a configuration (note that this may create duplicate entries) + if (is_array($conf['conf.'] ?? false)) { + foreach ($conf['conf.'] as $key => $value) { + if (!str_ends_with($key, '.') && !in_array($key, $tablesArray)) { + $tablesArray[] = $key; + } + } + } + + // Get the data, depending on collection method. + // Property "source" is considered more precise and thus takes precedence over "categories" + $source = (string)$this->cObj->stdWrapValue('source', $conf ?? []); + $categories = (string)$this->cObj->stdWrapValue('categories', $conf ?? []); + if ($source !== '') { + $this->collectRecordsFromSource($source, $tablesArray); + } elseif ($categories !== '') { + $relationField = (string)$this->cObj->stdWrapValue('relation', $conf['categories.'] ?? []); + $this->collectRecordsFromCategories($categories, $tablesArray, $relationField); + } + if (!empty($this->itemArray)) { + $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $cObj->setParent($this->cObj->data, $this->cObj->currentRecord); + $pageRepository = $this->getPageRepository(); + foreach ($this->itemArray as $val) { + $row = $this->data[$val['table']][$val['id']] ?? null; + if (!is_array($row)) { + continue; + } + // Perform overlays if necessary (records coming from category collections are already overlaid) + if ($source !== '') { + // Versioning preview + $pageRepository->versionOL($val['table'], $row); + // Language overlay + if (is_array($row)) { + $row = $pageRepository->getLanguageOverlay($val['table'], $row); + } + } + // Might be unset during the overlay process + if (!is_array($row)) { + continue; + } + if ($this->isRecordsPageAccessible($val['table'], $row, $conf)) { + $renderObjName = ($conf['conf.'][$val['table']] ?? false) ? $conf['conf.'][$val['table']] : '<' . $val['table']; + $renderObjKey = ($conf['conf.'][$val['table']] ?? false) ? 'conf.' . $val['table'] : ''; + $renderObjConf = ($conf['conf.'][$val['table'] . '.'] ?? false) ? $conf['conf.'][$val['table'] . '.'] : []; + $this->cObj->lastChanged($row['tstamp'] ?? 0); + $cObj->setRequest($this->request); + $cObj->start($row, $val['table']); + $tmpValue = $cObj->cObjGetSingle($renderObjName, $renderObjConf, $renderObjKey); + $theValue .= $tmpValue; + } + } + } + } + $wrap = $this->cObj->stdWrapValue('wrap', $conf); + if ($wrap) { + $theValue = $this->cObj->wrap($theValue, $wrap); + } + if (isset($conf['stdWrap.'])) { + $theValue = $this->cObj->stdWrap($theValue, $conf['stdWrap.']); + } + return $theValue; + } + + /** + * Checks if the records page is accessible + */ + protected function isRecordsPageAccessible(string $table, array $row, array $conf): bool + { + $pageId = (int)($table === 'pages' ? $row['uid'] : $row['pid']); + if ($pageId === $this->request->getAttribute('frontend.page.information')->getId()) { + // Access to current page has already been checked before rendering this content object. + return true; + } + if ($this->cObj->stdWrapValue('dontCheckPid', $conf)) { + return true; + } + $validPageId = $this->getPageRepository()->filterAccessiblePageIds([$pageId]); + return $validPageId !== []; + } + + /** + * Collects records according to the configured source + * + * @param string $source Source of records + * @param array $tables List of tables + */ + protected function collectRecordsFromSource($source, array $tables) + { + $loadDB = GeneralUtility::makeInstance(RelationHandler::class); + $loadDB->start($source, implode(',', $tables)); + foreach ($loadDB->tableArray as $table => $v) { + $constraints = $this->getPageRepository()->getDefaultConstraints($table); + if ($constraints !== []) { + $loadDB->additionalWhere[$table] = implode(' AND ', $constraints); + } + } + $this->data = $loadDB->getFromDB(); + reset($loadDB->itemArray); + $this->itemArray = $loadDB->itemArray; + } + + /** + * Collects records for all selected tables and categories. + * + * @param string $selectedCategories Comma-separated list of categories + * @param array $tables List of tables + * @param string $relationField Name of the field containing the categories relation + */ + protected function collectRecordsFromCategories($selectedCategories, array $tables, $relationField) + { + $selectedCategories = array_unique(GeneralUtility::intExplode(',', $selectedCategories, true)); + + // Loop on all selected tables + foreach ($tables as $table) { + // Get the records for each selected category + $tableRecords = []; + $categoriesPerRecord = []; + foreach ($selectedCategories as $aCategory) { + try { + $collection = CategoryCollection::load( + $aCategory, + true, + $table, + $relationField + ); + if ($collection->count() > 0) { + // Add items to the collection of records for the current table + foreach ($collection as $item) { + $tableRecords[$item['uid']] = $item; + // Keep track of all categories a given item belongs to + if (!isset($categoriesPerRecord[$item['uid']])) { + $categoriesPerRecord[$item['uid']] = []; + } + $categoriesPerRecord[$item['uid']][] = $aCategory; + } + } + } catch (\Exception $e) { + $message = sprintf( + 'Could not get records for category id %d. Error: %s (%d)', + $aCategory, + $e->getMessage(), + $e->getCode() + ); + $this->timeTracker->setTSlogMessage($message, LogLevel::WARNING); + } + } + // Store the resulting records into the itemArray and data results array + if (!empty($tableRecords)) { + $this->data[$table] = []; + foreach ($tableRecords as $record) { + $this->itemArray[] = [ + 'id' => $record['uid'], + 'table' => $table, + ]; + // Add to the record the categories it belongs to + $record['_categories'] = implode(',', $categoriesPerRecord[$record['uid']]); + $this->data[$table][$record['uid']] = $record; + } + } + } + } +} diff --git a/Classes/ContentObject/Register.php b/Classes/ContentObject/Register.php new file mode 100644 index 0000000..659132e --- /dev/null +++ b/Classes/ContentObject/Register.php @@ -0,0 +1,43 @@ +keyValues[$key] = $value; + } + + public function get(string $key, string|int|bool|float|null $default = null): string|int|bool|float|null + { + return $this->keyValues[$key] ?? $default; + } +} diff --git a/Classes/ContentObject/RegisterStack.php b/Classes/ContentObject/RegisterStack.php new file mode 100644 index 0000000..016570a --- /dev/null +++ b/Classes/ContentObject/RegisterStack.php @@ -0,0 +1,86 @@ +push(new Register()); + } + + /** + * Peek current Register. Does not change stack. + */ + public function current(): Register + { + return array_last($this->registerStack); + } + + /** + * Add a new Register instance to top of stack + */ + public function push(Register $register): void + { + $this->registerStack[] = $register; + } + + /** + * Remove top of stack Register and return it. + * Re-inits with an empty register if empty to avoid exception or nullable handling + * in consumers if there is for example a "RESTORE_REGISTER" cObj too much. As + * drawback, consumers never know if there is a leftover pop(). + */ + public function pop(): Register + { + $register = array_pop($this->registerStack); + if (empty($this->registerStack)) { + $this->push(new Register()); + } + return $register; + } +} diff --git a/Classes/ContentObject/RestoreRegisterContentObject.php b/Classes/ContentObject/RestoreRegisterContentObject.php new file mode 100644 index 0000000..f98a07b --- /dev/null +++ b/Classes/ContentObject/RestoreRegisterContentObject.php @@ -0,0 +1,36 @@ +request->getAttribute('frontend.register.stack')->pop(); + return ''; + } +} diff --git a/Classes/ContentObject/ScalableVectorGraphicsContentObject.php b/Classes/ContentObject/ScalableVectorGraphicsContentObject.php new file mode 100644 index 0000000..4312499 --- /dev/null +++ b/Classes/ContentObject/ScalableVectorGraphicsContentObject.php @@ -0,0 +1,150 @@ +cObj->stdWrapValue('renderMode', $conf); + + if ($renderMode === 'inline') { + return $this->renderInline($conf); + } + + return $this->renderObject($conf); + } + + protected function renderInline(array $conf): string + { + $resource = $this->resolveResource($conf); + [$width, $height, $isDefaultWidth, $isDefaultHeight] = $this->getDimensions($conf); + + $content = $svgContent = ''; + if ($resource instanceof SystemResourceInterface) { + try { + $svgContent = $resource->getContents(); + } catch (SystemResourceDoesNotExistException) { + } + } + if ($svgContent !== '') { + try { + $document = $this->svgDocumentFactory->fromStringAndSanitize($svgContent); + if (!$isDefaultWidth) { + $document->documentElement->setAttribute('width', (string)$width); + } + if (!$isDefaultHeight) { + $document->documentElement->setAttribute('height', (string)$height); + } + $content = $this->svgDocumentService->toInlineMarkup($document); + } catch (InvalidSvgException) { + $content = ''; + } + } else { + $value = $this->cObj->stdWrapValue('value', $conf); + if (!empty($value)) { + $content = []; + $content[] = ''; + $content[] = $value; + $content[] = ''; + $content = implode(LF, $content); + } + } + if (isset($conf['stdWrap.'])) { + $content = $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + return $content; + } + + /** + * Render the SVG as tag + */ + protected function renderObject(array $conf): string + { + $resource = $this->resolveResource($conf); + [$width, $height] = $this->getDimensions($conf); + $content = []; + if ($resource !== null) { + $uri = $this->resourcePublisher->generateUri($resource, $this->request); + $content[] = ''; + $content[] = ''; + $content[] = ' '; + $content[] = ''; + $content[] = ''; + } + $content = implode(LF, $content); + if (isset($conf['stdWrap.'])) { + $content = $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + return $content; + } + + protected function resolveResource(array $conf): ?PublicResourceInterface + { + try { + $resourceIdentifier = (string)$this->cObj->stdWrapValue('src', $conf); + return $this->resourceFactory->createPublicResource($resourceIdentifier); + } catch (SystemResourceException) { + return null; + } + } + + protected function getDimensions(array $conf): array + { + $isDefaultWidth = false; + $isDefaultHeight = false; + $width = $this->cObj->stdWrapValue('width', $conf); + $height = $this->cObj->stdWrapValue('height', $conf); + + if (empty($width)) { + $isDefaultWidth = true; + $width = 600; + } + if (empty($height)) { + $isDefaultHeight = true; + $height = 400; + } + + return [$width, $height, $isDefaultWidth, $isDefaultHeight]; + } +} diff --git a/Classes/ContentObject/TextContentObject.php b/Classes/ContentObject/TextContentObject.php new file mode 100644 index 0000000..357be32 --- /dev/null +++ b/Classes/ContentObject/TextContentObject.php @@ -0,0 +1,48 @@ +cObj->stdWrap($content, $conf['value.']); + unset($conf['value.']); + } + if (!empty($conf)) { + $content = $this->cObj->stdWrap($content, $conf); + } + return $content; + } +} diff --git a/Classes/ContentObject/UserContentObject.php b/Classes/ContentObject/UserContentObject.php new file mode 100644 index 0000000..2fce1b4 --- /dev/null +++ b/Classes/ContentObject/UserContentObject.php @@ -0,0 +1,66 @@ +getTimeTracker()->setTSlogMessage('USER without configuration.', LogLevel::WARNING); + return ''; + } + $content = ''; + if ($this->cObj->getUserObjectType() === false) { + // Render this if we are a delayed non cached object + $this->cObj->setUserObjectType(ContentObjectRenderer::OBJECTTYPE_USER); + } + $tempContent = $this->cObj->callUserFunction($conf['userFunc'] ?? '', $conf, ''); + if ($this->cObj->doConvertToUserIntObject) { + $this->cObj->doConvertToUserIntObject = false; + $content = $this->cObj->cObjGetSingle('USER_INT', $conf); + } else { + $content .= $tempContent; + // Only executed when the element is not converted to USER_INT + if (isset($conf['stdWrap.'])) { + $content = $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + } + $this->cObj->setUserObjectType(false); + return $content; + } + + /** + * @return TimeTracker + */ + protected function getTimeTracker() + { + return GeneralUtility::makeInstance(TimeTracker::class); + } +} diff --git a/Classes/ContentObject/UserInternalContentObject.php b/Classes/ContentObject/UserInternalContentObject.php new file mode 100644 index 0000000..478b98d --- /dev/null +++ b/Classes/ContentObject/UserInternalContentObject.php @@ -0,0 +1,45 @@ +cObj->setUserObjectType(ContentObjectRenderer::OBJECTTYPE_USER_INT); + $substKey = 'INT_SCRIPT.' . md5(StringUtility::getUniqueId()); + $pageParts = $this->request->getAttribute('frontend.page.parts'); + $pageParts->addNotCachedContentElement([ + 'substKey' => $substKey, + 'conf' => $conf, + 'cObjData' => serialize($this->cObj->getState()), + 'type' => 'FUNC', + ]); + $this->cObj->setUserObjectType(false); + return ''; + } +} diff --git a/Classes/Controller/ErrorController.php b/Classes/Controller/ErrorController.php new file mode 100644 index 0000000..cc45c80 --- /dev/null +++ b/Classes/Controller/ErrorController.php @@ -0,0 +1,227 @@ +isRequestFromDevIp($request)) { + throw new InternalServerErrorException($message, 1607585445); + } + $errorHandler = $this->getErrorHandlerFromSite($request, 500); + if ($errorHandler !== null) { + return $errorHandler->handlePageError($request, $message, $reasons); + } + $response = $this->handleError( + $request, + 500, + 'Internal Server Error', + 'An error occurred while processing your request. Please try again later.', + $message + ); + return $this->contentSecurityPolicyHeaders->applyToResponse($request, $response); + } + + /** + * Used for creating a 503 response ("Service Unavailable"), to be used for maintenance mode + * or when the server is overloaded, a RedirectResponse could be returned as well. + * + * @throws ServiceUnavailableException + */ + public function unavailableAction(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface + { + if ($this->isRequestFromDevIp($request)) { + throw new ServiceUnavailableException($message, 1518472181); + } + $errorHandler = $this->getErrorHandlerFromSite($request, 503); + if ($errorHandler !== null) { + return $errorHandler->handlePageError($request, $message, $reasons); + } + $response = $this->handleError( + $request, + 503, + 'Service Unavailable', + 'The application is currently down for maintenance. Please check back shortly.', + $message + ); + return $this->contentSecurityPolicyHeaders->applyToResponse($request, $response); + } + + /** + * Used for creating a 404 response ("Page Not Found"), but if configured, a RedirectResponse could be returned + * as well. + * + * @throws PageNotFoundException + */ + public function pageNotFoundAction(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface + { + $errorHandler = $this->getErrorHandlerFromSite($request, 404); + if ($errorHandler !== null) { + return $errorHandler->handlePageError($request, $message, $reasons); + } + try { + $response = $this->handleError( + $request, + 404, + 'Page Not Found', + 'The page did not exist or was inaccessible.', + $message + ); + return $this->contentSecurityPolicyHeaders->applyToResponse($request, $response); + } catch (\RuntimeException) { + throw new PageNotFoundException($message, 1518472189); + } + + } + + /** + * Used for creating a 403 response ("Access denied"), but if configured, a RedirectResponse could be returned + * as well. + * + * @throws PageNotFoundException + */ + public function accessDeniedAction(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface + { + $errorHandler = $this->getErrorHandlerFromSite($request, 403); + if ($errorHandler !== null) { + return $errorHandler->handlePageError($request, $message, $reasons); + } + try { + $response = $this->handleError( + $request, + 403, + 'Access Denied', + 'You do not have the necessary permissions to access this resource.', + $message + ); + return $this->contentSecurityPolicyHeaders->applyToResponse($request, $response); + } catch (\RuntimeException) { + throw new PageNotFoundException($message, 1518472195); + } + } + + /** + * Used for creating an error with a custom status code, but if configured, a RedirectResponse could be + * returned as well. + * + * @param array $reasons An array of reasons for evaluation in a possible resolved the error handler + * + * @throws PageNotFoundException + */ + public function customErrorAction( + ServerRequestInterface $request, + int $statusCode, + string $title, + string $message, + string $technicalReason = '', + array $reasons = [], + int $errorCode = 0 + ): ResponseInterface { + $errorHandler = $this->getErrorHandlerFromSite($request, $statusCode); + if ($errorHandler !== null) { + return $errorHandler->handlePageError($request, $message, $reasons); + } + try { + return $this->handleError($request, $statusCode, $title, $message, $technicalReason, $errorCode); + } catch (\RuntimeException) { + throw new PageNotFoundException($message, 1770466857); + } + } + + /** + * Checks whether the devIPMask matches the current visitor's IP address. + * + * @return bool False if the server error handler should be used. + */ + protected function isRequestFromDevIp(ServerRequestInterface $request): bool + { + $normalizedParams = $request->getAttribute('normalizedParams'); + return GeneralUtility::cmpIP($normalizedParams->getRemoteAddress(), $GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask']); + } + + /** + * Checks if a site is configured, and an error handler is configured for this specific status code. + */ + protected function getErrorHandlerFromSite(ServerRequestInterface $request, int $statusCode): ?PageErrorHandlerInterface + { + $site = $request->getAttribute('site'); + if ($site instanceof Site) { + try { + return $site->getErrorHandler($statusCode); + } catch (PageErrorHandlerNotConfiguredException $e) { + // No error handler found, so fallback back to the generic TYPO3 error handler. + } + } + return null; + } + + /** + * Handles the error by creating a response object. Acts as a fallback when no error handler is configured. + */ + protected function handleError( + ServerRequestInterface $request, + int $statusCode, + string $title, + string $message, + string $technicalReason = '', + int $errorCode = 0 + ): ResponseInterface { + if (str_contains($request->getHeaderLine('Accept'), 'application/json')) { + return new JsonResponse(['reason' => $technicalReason], $statusCode); + } + $content = GeneralUtility::makeInstance(ErrorPageController::class)->errorAction( + $title, + $message . ($technicalReason ? ' Reason: ' . $technicalReason : ''), + $errorCode, + $statusCode + ); + return new HtmlResponse($content, $statusCode); + } +} diff --git a/Classes/Controller/ShowImageController.php b/Classes/Controller/ShowImageController.php new file mode 100644 index 0000000..2e9990c --- /dev/null +++ b/Classes/Controller/ShowImageController.php @@ -0,0 +1,245 @@ +'; + + /** + * @var string + */ + protected $title = 'Image'; + + /** + * @var string + */ + protected $content = << + + + ###TITLE### + + +###BODY### + ###IMAGE### + + +EOF; + + public function __construct( + protected readonly Features $features, + private readonly FileNameValidator $fileNameValidator, + private readonly ResourceFactory $resourceFactory, + ) {} + + /** + * Init function, setting the input vars in the global space. + * + * @throws \InvalidArgumentException + * @throws \TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException + */ + public function initialize() + { + $fileUid = $this->request->getQueryParams()['file'] ?? null; + $parametersArray = $this->request->getQueryParams()['parameters'] ?? null; + + // If no file-param or parameters are given, we must exit + if (!$fileUid || !isset($parametersArray) || !is_array($parametersArray)) { + throw new \InvalidArgumentException('No valid fileUid given', 1476048455); + } + + // rebuild the parameter array and check if the HMAC is correct + $parametersEncoded = implode('', $parametersArray); + + /* For backwards compatibility the HMAC is transported within the md5 param */ + $hmacParameter = $this->request->getQueryParams()['md5'] ?? null; + $hashService = GeneralUtility::makeInstance(HashService::class); + $hmac = $hashService->hmac(implode('|', [$fileUid, $parametersEncoded]), 'tx_cms_showpic', HashAlgo::SHA3_256); + if (!is_string($hmacParameter) || !hash_equals($hmac, $hmacParameter)) { + throw new \InvalidArgumentException('hash does not match', 1476048456); + } + + // decode the parameters Array - `bodyTag` contains HTML if set and would lead + // to a false-positive XSS-detection, that's why parameters are base64-encoded + $parameters = json_decode(base64_decode($parametersEncoded), true) ?? []; + foreach ($parameters as $parameterName => $parameterValue) { + if (in_array($parameterName, static::ALLOWED_PARAMETER_NAMES, true)) { + $this->{$parameterName} = $parameterValue; + } + } + + if (MathUtility::canBeInterpretedAsInteger($fileUid)) { + $this->file = $this->resourceFactory->getFileObject((int)$fileUid); + } else { + $this->file = $this->resourceFactory->retrieveFileOrFolderObject($fileUid); + } + if (!($this->file instanceof FileInterface && $this->isFileValid($this->file))) { + throw new Exception('File processing for local storage is denied', 1594043425); + } + + if ($this->features->isFeatureEnabled('security.frontend.allowInsecureFrameOptionInShowImageController')) { + $frameValue = $this->request->getQueryParams()['frame'] ?? null; + if ($frameValue !== null && MathUtility::canBeInterpretedAsInteger($frameValue)) { + $this->frame = (int)$frameValue; + } + } + } + + /** + * Main function which creates the image if needed and outputs the HTML code for the page displaying the image. + * Accumulates the content in $this->content + */ + public function main() + { + $processedImage = $this->processImage(); + $imageAttributes = [ + 'src' => $processedImage->getPublicUrl() ?? '', + 'alt' => $this->file->getProperty('alternative') ?: $this->title, + 'title' => $this->file->getProperty('title') ?: $this->title, + 'width' => (string)$processedImage->getProperty('width'), + 'height' => (string)$processedImage->getProperty('height'), + ]; + + $markerArray = [ + '###TITLE###' => htmlspecialchars($this->file->getProperty('title') ?: $this->title), + '###IMAGE###' => sprintf('', GeneralUtility::implodeAttributes($imageAttributes, true)), + '###BODY###' => $this->bodyTag, + ]; + + $this->content = str_replace(array_keys($markerArray), array_values($markerArray), $this->content); + } + + /** + * Does the actual image processing + * + * @return \TYPO3\CMS\Core\Resource\ProcessedFile + */ + protected function processImage() + { + $max = str_contains($this->width . $this->height, 'm') ? 'm' : ''; + $this->height = MathUtility::forceIntegerInRange($this->height, 0); + $this->width = MathUtility::forceIntegerInRange((int)$this->width, 0) . $max; + + $processingConfiguration = [ + 'width' => $this->width, + 'height' => $this->height, + 'frame' => $this->frame, + 'crop' => $this->crop, + ]; + return $this->file->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $processingConfiguration); + } + + /** + * Fetches the content and builds a content file out of it + * + * @param ServerRequestInterface $request the current request object + * @return ResponseInterface the modified response + */ + public function processRequest(ServerRequestInterface $request): ResponseInterface + { + $this->request = $request; + + try { + $this->initialize(); + $this->main(); + $response = new Response(); + $response->getBody()->write($this->content); + return $response; + } catch (\InvalidArgumentException $e) { + // add a 410 "gone" if invalid parameters given + return (new Response())->withStatus(410); + } catch (Exception $e) { + return (new Response())->withStatus(404); + } + } + + protected function isFileValid(FileInterface $file): bool + { + return $file->getStorage()->getDriverType() !== 'Local' + || $this->fileNameValidator->isValid(basename($file->getIdentifier())); + } +} diff --git a/Classes/DataProcessing/CommaSeparatedValueProcessor.php b/Classes/DataProcessing/CommaSeparatedValueProcessor.php new file mode 100644 index 0000000..2260fa0 --- /dev/null +++ b/Classes/DataProcessing/CommaSeparatedValueProcessor.php @@ -0,0 +1,103 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + + // The field name to process + $fieldName = $cObj->stdWrapValue('fieldName', $processorConfiguration); + if (empty($fieldName)) { + return $processedData; + } + + $originalValue = (string)$cObj->data[$fieldName]; + + // Set the target variable + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, $fieldName); + + // Set the maximum amount of columns + $maximumColumns = $cObj->stdWrapValue('maximumColumns', $processorConfiguration, 0); + + // Set the field delimiter which is "," by default + $fieldDelimiter = (string)$cObj->stdWrapValue('fieldDelimiter', $processorConfiguration, ','); + + // Set the field enclosure which is " by default + $fieldEnclosure = (string)$cObj->stdWrapValue('fieldEnclosure', $processorConfiguration, '"'); + + $processedData[$targetVariableName] = CsvUtility::csvToArray( + $originalValue, + $fieldDelimiter, + $fieldEnclosure, + (int)$maximumColumns + ); + + return $processedData; + } +} diff --git a/Classes/DataProcessing/DataProcessorRegistry.php b/Classes/DataProcessing/DataProcessorRegistry.php new file mode 100644 index 0000000..c71233b --- /dev/null +++ b/Classes/DataProcessing/DataProcessorRegistry.php @@ -0,0 +1,48 @@ +dataProcessorLocator->has($identifer)) { + return null; + } + + $dataProcessor = $this->dataProcessorLocator->get($identifer); + if (!($dataProcessor instanceof DataProcessorInterface)) { + throw new \UnexpectedValueException( + 'Processor with alias / identifier "' . $identifer . '" ' + . 'must implement interface "' . DataProcessorInterface::class . '"', + 1666131903 + ); + } + + return $dataProcessor; + } +} diff --git a/Classes/DataProcessing/DatabaseQueryProcessor.php b/Classes/DataProcessing/DatabaseQueryProcessor.php new file mode 100644 index 0000000..991666a --- /dev/null +++ b/Classes/DataProcessing/DatabaseQueryProcessor.php @@ -0,0 +1,98 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + + // the table to query, if none given, exit + $tableName = $cObj->stdWrapValue('table', $processorConfiguration); + if (empty($tableName)) { + return $processedData; + } + if (isset($processorConfiguration['table.'])) { + unset($processorConfiguration['table.']); + } + if (isset($processorConfiguration['table'])) { + unset($processorConfiguration['table']); + } + + // The variable to be used within the result + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'records'); + + // Execute a SQL statement to fetch the records + $records = $cObj->getRecords($tableName, $processorConfiguration); + $request = $cObj->getRequest(); + $processedRecordVariables = []; + foreach ($records as $key => $record) { + $recordContentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $recordContentObjectRenderer->setRequest($request); + $recordContentObjectRenderer->start($record, $tableName); + $processedRecordVariables[$key] = ['data' => $record]; + $processedRecordVariables[$key] = $this->contentDataProcessor->process($recordContentObjectRenderer, $processorConfiguration, $processedRecordVariables[$key]); + } + + $processedData[$targetVariableName] = $processedRecordVariables; + + return $processedData; + } +} diff --git a/Classes/DataProcessing/FilesProcessor.php b/Classes/DataProcessing/FilesProcessor.php new file mode 100644 index 0000000..f329b72 --- /dev/null +++ b/Classes/DataProcessing/FilesProcessor.php @@ -0,0 +1,123 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + + // gather data + $fileCollector = GeneralUtility::makeInstance(FileCollector::class); + + // references / relations + if ( + (isset($processorConfiguration['references']) && $processorConfiguration['references']) + || (isset($processorConfiguration['references.']) && $processorConfiguration['references.']) + ) { + $referencesUidList = (string)$cObj->stdWrapValue('references', $processorConfiguration); + $referencesUids = GeneralUtility::intExplode(',', $referencesUidList, true); + $fileCollector->addFileReferences($referencesUids); + + if (!empty($processorConfiguration['references.'])) { + $referenceConfiguration = $processorConfiguration['references.']; + $relationField = $cObj->stdWrapValue('fieldName', $referenceConfiguration); + + // If no reference fieldName is set, there's nothing to do + if (!empty($relationField)) { + // Fetch the references of the default element + $relationTable = $cObj->stdWrapValue('table', $referenceConfiguration, $cObj->getCurrentTable()); + if (!empty($relationTable)) { + $fileCollector->addFilesFromRelation($relationTable, $relationField, $cObj->data); + } + } + } + } + + // files + $files = $cObj->stdWrapValue('files', $processorConfiguration); + if ($files) { + $files = GeneralUtility::intExplode(',', (string)$files, true); + $fileCollector->addFiles($files); + } + + // collections + $collections = $cObj->stdWrapValue('collections', $processorConfiguration); + if (!empty($collections)) { + $collections = GeneralUtility::intExplode(',', (string)$collections, true); + $fileCollector->addFilesFromFileCollections($collections); + } + + // folders + $folders = $cObj->stdWrapValue('folders', $processorConfiguration); + if (!empty($folders)) { + $folders = GeneralUtility::trimExplode(',', (string)$folders, true); + $fileCollector->addFilesFromFolders($folders, (bool)$cObj->stdWrapValue('recursive', $processorConfiguration['folders.'] ?? [], false)); + } + + // make sure to sort the files + $sortingProperty = $cObj->stdWrapValue('sorting', $processorConfiguration); + if ($sortingProperty) { + $sortingDirection = $cObj->stdWrapValue( + 'direction', + $processorConfiguration['sorting.'] ?? [], + 'ascending' + ); + + $fileCollector->sort($sortingProperty, $sortingDirection); + } + + // set the files into a variable, default "files" + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'files'); + $processedData[$targetVariableName] = $fileCollector->getFiles(); + + return $processedData; + } +} diff --git a/Classes/DataProcessing/FlexFormProcessor.php b/Classes/DataProcessing/FlexFormProcessor.php new file mode 100644 index 0000000..6fa7066 --- /dev/null +++ b/Classes/DataProcessing/FlexFormProcessor.php @@ -0,0 +1,152 @@ +stdWrapValue('fieldName', $processorConfiguration, 'pi_flexform'); + + if (!isset($processedData['data'][$fieldName])) { + return $processedData; + } + + // Process FlexForm + $originalValue = $processedData['data'][$fieldName]; + if (!is_string($originalValue)) { + return $processedData; + } + $flexFormData = $this->flexFormTools->convertFlexFormContentToArray($originalValue); + + // Process FAL references + if (isset($processorConfiguration['references.']) && is_array($processorConfiguration['references.'])) { + $this->processFileReferences($cObj, $flexFormData, $processorConfiguration['references.']); + } + + // Process additional DataProcessors + if (isset($processorConfiguration['dataProcessing.']) && is_array($processorConfiguration['dataProcessing.'])) { + // @todo: It looks as if data processors should retrieve the current request from the outside, + // this would avoid $cObj->getRequest() here. + $flexFormData = $this->processAdditionalDataProcessors($flexFormData, $processorConfiguration, $cObj->getRequest()); + } + + // Set the target variable + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'flexFormData'); + $processedData[$targetVariableName] = $flexFormData; + + return $processedData; + } + + /** + * Recursively process FAL references and replace them by FAL objects. + */ + protected function processFileReferences(ContentObjectRenderer $cObj, array &$data, array $fields): void + { + foreach ($fields as $key => $field) { + $key = rtrim($key, '.'); + + if (!isset($data[$key])) { + continue; + } + if (is_array($field)) { + $this->processFileReferences($cObj, $data[$key], $field); + } else { + $fileCollector = GeneralUtility::makeInstance(FileCollector::class); + $fileCollector->addFilesFromRelation($cObj->getCurrentTable(), $field, $cObj->data); + + $data[$key] = $fileCollector->getFiles(); + } + } + } + + /** + * Recursively process sub processors of a data processor + */ + protected function processAdditionalDataProcessors(array $data, array $processorConfiguration, ServerRequestInterface $request): array + { + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObjectRenderer->setRequest($request); + $contentObjectRenderer->start([$data], ''); + return GeneralUtility::makeInstance(ContentDataProcessor::class)->process( + $contentObjectRenderer, + $processorConfiguration, + $data + ); + } +} diff --git a/Classes/DataProcessing/GalleryProcessor.php b/Classes/DataProcessing/GalleryProcessor.php new file mode 100644 index 0000000..8510254 --- /dev/null +++ b/Classes/DataProcessing/GalleryProcessor.php @@ -0,0 +1,510 @@ + [ + 'center' => [0, 8], + 'right' => [1, 9, 17, 25], + 'left' => [2, 10, 18, 26], + ], + 'vertical' => [ + 'above' => [0, 1, 2], + 'intext' => [17, 18, 25, 26], + 'below' => [8, 9, 10], + ], + ]; + + /** + * Storage for processed data + * + * @var array + */ + protected $galleryData = [ + 'position' => [ + 'horizontal' => '', + 'vertical' => '', + 'noWrap' => false, + ], + 'width' => 0, + 'count' => [ + 'files' => 0, + 'columns' => 0, + 'rows' => 0, + ], + 'columnSpacing' => 0, + 'border' => [ + 'enabled' => false, + 'width' => 0, + 'padding' => 0, + ], + 'rows' => [], + ]; + + /** + * @var int + */ + protected $numberOfColumns; + + /** + * @var int + */ + protected $mediaOrientation; + + /** + * @var int + */ + protected $maxGalleryWidth; + + /** + * @var int + */ + protected $maxGalleryWidthInText; + + /** + * @var int + */ + protected $equalMediaHeight; + + /** + * @var int + */ + protected $equalMediaWidth; + + /** + * @var int + */ + protected $columnSpacing; + + /** + * @var bool + */ + protected $borderEnabled; + + /** + * @var int + */ + protected $borderWidth; + + /** + * @var int + */ + protected $borderPadding; + + /** + * @var string + */ + protected $cropVariant = 'default'; + + /** + * The (filtered) media files to be used in the gallery + * + * @var FileInterface[] + */ + protected $fileObjects = []; + + /** + * The calculated dimensions for each media element + * + * @var array + */ + protected $mediaDimensions = []; + + /** + * Process data for a gallery, for instance the CType "textmedia" + * + * @param ContentObjectRenderer $cObj The content object renderer, which contains data of the content element + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store + * @throws ContentRenderingException + */ + public function process( + ContentObjectRenderer $cObj, + array $contentObjectConfiguration, + array $processorConfiguration, + array $processedData + ) { + if (isset($processorConfiguration['if.']) && !$cObj->checkIf($processorConfiguration['if.'])) { + return $processedData; + } + + $this->contentObjectRenderer = $cObj; + $this->processorConfiguration = $processorConfiguration; + + $filesProcessedDataKey = (string)$cObj->stdWrapValue( + 'filesProcessedDataKey', + $processorConfiguration, + 'files' + ); + if (isset($processedData[$filesProcessedDataKey]) && is_array($processedData[$filesProcessedDataKey])) { + $this->fileObjects = $processedData[$filesProcessedDataKey]; + $this->galleryData['count']['files'] = count($this->fileObjects); + } else { + throw new ContentRenderingException('No files found for key ' . $filesProcessedDataKey . ' in $processedData.', 1436809789); + } + + $this->numberOfColumns = (int)$this->getConfigurationValue('numberOfColumns', 'imagecols'); + $this->mediaOrientation = (int)$this->getConfigurationValue('mediaOrientation', 'imageorient'); + $this->maxGalleryWidth = (int)$this->getConfigurationValue('maxGalleryWidth') ?: 600; + $this->maxGalleryWidthInText = (int)$this->getConfigurationValue('maxGalleryWidthInText') ?: 300; + $this->equalMediaHeight = (int)$this->getConfigurationValue('equalMediaHeight', 'imageheight'); + $this->equalMediaWidth = (int)$this->getConfigurationValue('equalMediaWidth', 'imagewidth'); + $this->columnSpacing = (int)$this->getConfigurationValue('columnSpacing'); + $this->borderEnabled = (bool)$this->getConfigurationValue('borderEnabled', 'imageborder'); + $this->borderWidth = (int)$this->getConfigurationValue('borderWidth'); + $this->borderPadding = (int)$this->getConfigurationValue('borderPadding'); + $this->cropVariant = $this->getConfigurationValue('cropVariant') ?: 'default'; + + $this->determineGalleryPosition(); + $this->determineMaximumGalleryWidth(); + + $this->calculateRowsAndColumns(); + $this->calculateMediaWidthsAndHeights(); + + $this->prepareGalleryData(); + + $targetFieldName = (string)$cObj->stdWrapValue( + 'as', + $processorConfiguration, + 'gallery' + ); + + $processedData[$targetFieldName] = $this->galleryData; + + return $processedData; + } + + /** + * Get configuration value from processorConfiguration + * with when $dataArrayKey fallback to value from cObj->data array + * + * @param string $key + * @param string|null $dataArrayKey + * @return string + */ + protected function getConfigurationValue($key, $dataArrayKey = null) + { + $defaultValue = ''; + if ($dataArrayKey && isset($this->contentObjectRenderer->data[$dataArrayKey])) { + $defaultValue = $this->contentObjectRenderer->data[$dataArrayKey]; + } + return $this->contentObjectRenderer->stdWrapValue( + $key, + $this->processorConfiguration, + $defaultValue + ); + } + + /** + * Define the gallery position + * + * Gallery has a horizontal and a vertical position towards the text + * and a possible wrapping of the text around the gallery. + */ + protected function determineGalleryPosition() + { + foreach ($this->availableGalleryPositions as $positionDirectionKey => $positionDirectionValue) { + foreach ($positionDirectionValue as $positionKey => $positionArray) { + if (in_array($this->mediaOrientation, $positionArray, true)) { + $this->galleryData['position'][$positionDirectionKey] = $positionKey; + } + } + } + + if ($this->mediaOrientation === 25 || $this->mediaOrientation === 26) { + $this->galleryData['position']['noWrap'] = true; + } + } + + /** + * Get the gallery width based on vertical position + */ + protected function determineMaximumGalleryWidth() + { + if ($this->galleryData['position']['vertical'] === 'intext') { + $this->galleryData['width'] = $this->maxGalleryWidthInText; + } else { + $this->galleryData['width'] = $this->maxGalleryWidth; + } + } + + /** + * Calculate the amount of rows and columns + */ + protected function calculateRowsAndColumns() + { + // If no columns defined, set it to 1 + $columns = max((int)$this->numberOfColumns, 1); + + // When more columns than media elements, set the columns to the amount of media elements + if ($columns > $this->galleryData['count']['files']) { + $columns = $this->galleryData['count']['files']; + } + + if ($columns === 0) { + $columns = 1; + } + + // Calculate the rows from the amount of files and the columns + $rows = ceil($this->galleryData['count']['files'] / $columns); + + $this->galleryData['count']['columns'] = $columns; + $this->galleryData['count']['rows'] = (int)$rows; + } + + /** + * Calculate the width/height of the media elements + * + * Based on the width of the gallery, defined equal width or height by a user, the spacing between columns and + * the use of a border, defined by user, where the border width and padding are taken into account + * + * File objects MUST already be filtered. They need a height and width to be shown in the gallery + */ + protected function calculateMediaWidthsAndHeights() + { + $columnSpacingTotal = ($this->galleryData['count']['columns'] - 1) * $this->columnSpacing; + + $galleryWidthMinusBorderAndSpacing = max($this->galleryData['width'] - $columnSpacingTotal, 1); + + if ($this->borderEnabled) { + $borderPaddingTotal = ($this->galleryData['count']['columns'] * 2) * $this->borderPadding; + $borderWidthTotal = ($this->galleryData['count']['columns'] * 2) * $this->borderWidth; + $galleryWidthMinusBorderAndSpacing = $galleryWidthMinusBorderAndSpacing - $borderPaddingTotal - $borderWidthTotal; + } + + // User entered a predefined height + if ($this->equalMediaHeight) { + $mediaScalingCorrection = 1; + $maximumRowWidth = 0; + + // Calculate the scaling correction when the total of media elements is wider than the gallery width + for ($row = 1; $row <= $this->galleryData['count']['rows']; $row++) { + $totalRowWidth = 0; + for ($column = 1; $column <= $this->galleryData['count']['columns']; $column++) { + $fileKey = (($row - 1) * $this->galleryData['count']['columns']) + $column - 1; + if ($fileKey > $this->galleryData['count']['files'] - 1) { + break 2; + } + $currentMediaScaling = $this->equalMediaHeight / max($this->getCroppedDimensionalProperty($this->fileObjects[$fileKey], 'height'), 1); + $totalRowWidth += $this->getCroppedDimensionalProperty($this->fileObjects[$fileKey], 'width') * $currentMediaScaling; + } + $maximumRowWidth = max($totalRowWidth, $maximumRowWidth); + $mediaInRowScaling = $totalRowWidth / $galleryWidthMinusBorderAndSpacing; + $mediaScalingCorrection = max($mediaInRowScaling, $mediaScalingCorrection); + } + + // Set the corrected dimensions for each media element + foreach ($this->fileObjects as $key => $fileObject) { + $mediaHeight = floor($this->equalMediaHeight / $mediaScalingCorrection); + $mediaWidth = floor( + $this->getCroppedDimensionalProperty($fileObject, 'width') * ($mediaHeight / max($this->getCroppedDimensionalProperty($fileObject, 'height'), 1)) + ); + $this->mediaDimensions[$key] = [ + 'width' => $mediaWidth, + 'height' => $mediaHeight, + ]; + } + + // Recalculate gallery width + $this->galleryData['width'] = floor($maximumRowWidth / $mediaScalingCorrection); + + // User entered a predefined width + } elseif ($this->equalMediaWidth) { + $mediaScalingCorrection = 1; + + // Calculate the scaling correction when the total of media elements is wider than the gallery width + $totalRowWidth = $this->galleryData['count']['columns'] * $this->equalMediaWidth; + $mediaInRowScaling = $totalRowWidth / $galleryWidthMinusBorderAndSpacing; + $mediaScalingCorrection = max($mediaInRowScaling, $mediaScalingCorrection); + + // Set the corrected dimensions for each media element + foreach ($this->fileObjects as $key => $fileObject) { + $mediaWidth = floor($this->equalMediaWidth / $mediaScalingCorrection); + $mediaHeight = floor( + $this->getCroppedDimensionalProperty($fileObject, 'height') * ($mediaWidth / max($this->getCroppedDimensionalProperty($fileObject, 'width'), 1)) + ); + $this->mediaDimensions[$key] = [ + 'width' => $mediaWidth, + 'height' => $mediaHeight, + ]; + } + + // Recalculate gallery width + $this->galleryData['width'] = floor($totalRowWidth / $mediaScalingCorrection); + + // Automatic setting of width and height + } else { + $maxMediaWidth = (int)($galleryWidthMinusBorderAndSpacing / $this->galleryData['count']['columns']); + foreach ($this->fileObjects as $key => $fileObject) { + $croppedWidth = $this->getCroppedDimensionalProperty($fileObject, 'width'); + $mediaWidth = $croppedWidth > 0 ? min($maxMediaWidth, $croppedWidth) : $maxMediaWidth; + $mediaHeight = floor( + $this->getCroppedDimensionalProperty($fileObject, 'height') * ($mediaWidth / max($this->getCroppedDimensionalProperty($fileObject, 'width'), 1)) + ); + $this->mediaDimensions[$key] = [ + 'width' => $mediaWidth, + 'height' => $mediaHeight, + ]; + } + } + } + + /** + * When retrieving the height or width for a media file + * a possible cropping needs to be taken into account. + * + * @param string $dimensionalProperty 'width' or 'height' + * @return int + */ + protected function getCroppedDimensionalProperty(FileInterface $fileObject, $dimensionalProperty) + { + if (!$fileObject->hasProperty('crop') || empty($fileObject->getProperty('crop'))) { + return $fileObject->getProperty($dimensionalProperty); + } + + $croppingConfiguration = $fileObject->getProperty('crop'); + $cropVariantCollection = CropVariantCollection::create((string)$croppingConfiguration); + return (int)$cropVariantCollection->getCropArea($this->cropVariant)->makeAbsoluteBasedOnFile($fileObject)->asArray()[$dimensionalProperty]; + } + + /** + * Prepare the gallery data + * + * Make an array for rows, columns and configuration + */ + protected function prepareGalleryData() + { + for ($row = 1; $row <= $this->galleryData['count']['rows']; $row++) { + for ($column = 1; $column <= $this->galleryData['count']['columns']; $column++) { + $fileKey = (($row - 1) * $this->galleryData['count']['columns']) + $column - 1; + + $this->galleryData['rows'][$row]['columns'][$column] = [ + 'media' => $this->fileObjects[$fileKey] ?? null, + 'dimensions' => [ + 'width' => $this->mediaDimensions[$fileKey]['width'] ?? null, + 'height' => $this->mediaDimensions[$fileKey]['height'] ?? null, + ], + ]; + } + } + + $this->galleryData['columnSpacing'] = $this->columnSpacing; + $this->galleryData['border']['enabled'] = $this->borderEnabled; + $this->galleryData['border']['width'] = $this->borderWidth; + $this->galleryData['border']['padding'] = $this->borderPadding; + } +} diff --git a/Classes/DataProcessing/LanguageMenuProcessor.php b/Classes/DataProcessing/LanguageMenuProcessor.php new file mode 100644 index 0000000..cdb273f --- /dev/null +++ b/Classes/DataProcessing/LanguageMenuProcessor.php @@ -0,0 +1,239 @@ + 'language', + 'addQueryString' => 1, + ]; + + protected array $menuDefaults = [ + 'as' => 'languagemenu', + ]; + + public function __construct( + protected readonly MenuContentObjectFactory $menuContentObjectFactory, + protected readonly PageRepository $pageRepository, + ) {} + + /** + * Get configuration value from processorConfiguration + */ + protected function getConfigurationValue(string $key): string + { + return $this->cObj->stdWrapValue($key, $this->processorConfiguration, $this->menuDefaults[$key] ?? ''); + } + + protected function getRequest(): ServerRequestInterface + { + return $this->cObj->getRequest(); + } + + /** + * Returns the currently configured "site" if a site is configured (= resolved) in the current request. + */ + protected function getCurrentSite(): Site + { + return $this->getRequest()->getAttribute('site'); + } + + /** + * @throws \InvalidArgumentException + */ + protected function validateConfiguration(): void + { + $invalidArguments = []; + foreach ($this->processorConfiguration as $key => $value) { + if (!in_array($key, $this->allowedConfigurationKeys)) { + $invalidArguments[str_replace('.', '', $key)] = $key; + } + } + if (!empty($invalidArguments)) { + throw new \InvalidArgumentException('LanguageMenuProcessor configuration contains invalid arguments: ' . implode(', ', $invalidArguments), 1522959188); + } + } + + /** + * Process languages and filter the configuration + */ + protected function prepareConfiguration(): void + { + $this->menuConfig = array_merge($this->menuConfig, $this->processorConfiguration); + + // Process languages + $this->menuConfig['special.']['value'] = $this->cObj->stdWrapValue('languages', $this->menuConfig, 'auto'); + + // Filter configuration + foreach ($this->menuConfig as $key => $value) { + if (in_array($key, $this->removeConfigurationKeysForHmenu, true)) { + unset($this->menuConfig[$key]); + } + } + + $paramsToExclude = CanonicalizationUtility::getParamsToExcludeForCanonicalizedUrl( + $this->getRequest()->getAttribute('frontend.page.information')->getId(), + (array)$GLOBALS['TYPO3_CONF_VARS']['FE']['additionalCanonicalizedUrlParameters'], + $this->cObj->getRequest() + ); + + $this->menuConfig['addQueryString.']['exclude'] = implode( + ',', + array_merge( + GeneralUtility::trimExplode(',', $this->menuConfig['addQueryString.']['exclude'] ?? '', true), + $paramsToExclude + ) + ); + } + + /** + * Build the menu configuration so it can be treated by TMENU + */ + protected function buildConfiguration(): void + { + $this->menuConfig['1'] = 'TMENU'; + $this->menuConfig['1.']['NO'] = '1'; + } + + /** + * @param ContentObjectRenderer $cObj The data of the content element or page + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store + */ + public function process(ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData): array + { + if (isset($processorConfiguration['if.']) && !$cObj->checkIf($processorConfiguration['if.'])) { + return $processedData; + } + $this->cObj = $cObj; + $this->processorConfiguration = $processorConfiguration; + + // Validate Configuration + $this->validateConfiguration(); + + // Build Configuration + $this->prepareConfiguration(); + $this->buildConfiguration(); + + // Create menu object and get menu items directly + $request = $cObj->getRequest(); + $site = $this->getCurrentSite(); + + $menu = $this->menuContentObjectFactory->getMenuObjectByType('TMENU'); + $menu->parent_cObj = $cObj; + + if (!$menu->start(null, $this->pageRepository, '', $this->menuConfig, 1, '', $request)) { + return $processedData; + } + $menu->makeMenu(); + $menuItems = $menu->getMenuItems(); + + if ($menuItems === []) { + return $processedData; + } + + // Enrich with language-specific fields + $processedMenu = []; + foreach ($menuItems as $key => $item) { + $languageId = (int)($item['data']['_REQUESTED_OVERLAY_LANGUAGE'] ?? 0); + try { + $languageObject = $site->getLanguageById($languageId); + } catch (\InvalidArgumentException) { + // Language not found in site config + continue; + } + $item['languageId'] = $languageId; + $item['locale'] = $languageObject->getLocale()->getName(); + // Override title with language title (not page title) + $item['title'] = $languageObject->getTitle(); + $item['navigationTitle'] = $languageObject->getNavigationTitle(); + $item['twoLetterIsoCode'] = $languageObject->getLocale()->getLanguageCode(); + $item['hreflang'] = $languageObject->getHreflang(); + $item['direction'] = $languageObject->getLocale()->isRightToLeftLanguageDirection() ? 'rtl' : 'ltr'; + $item['flag'] = $languageObject->getFlagIdentifier(); + // Determine state from ITEM_STATE set by the menu system + $itemState = $item['data']['ITEM_STATE'] ?? ''; + // active = 1 if state is ACT, ACTIFSUB, USERDEF2 (active states) + $item['active'] = in_array($itemState, ['ACT', 'ACTIFSUB', 'USERDEF2'], true) ? 1 : 0; + // current = 1 if state is CUR, CURIFSUB (current language) + $item['current'] = in_array($itemState, ['CUR', 'CURIFSUB'], true) ? 1 : 0; + // available = 1 unless USERDEF1/USERDEF2 state (language not available) + $item['available'] = !in_array($itemState, ['USERDEF1', 'USERDEF2'], true) ? 1 : 0; + $processedMenu[$key] = $item; + } + + // Return processed data + $processedData[$this->getConfigurationValue('as')] = $processedMenu; + return $processedData; + } +} diff --git a/Classes/DataProcessing/MenuProcessor.php b/Classes/DataProcessing/MenuProcessor.php new file mode 100644 index 0000000..5175584 --- /dev/null +++ b/Classes/DataProcessing/MenuProcessor.php @@ -0,0 +1,298 @@ + 1, + 'expandAll' => 1, + 'includeSpacer' => 0, + 'as' => 'menu', + 'titleField' => 'nav_title // title', + ]; + + protected int $menuLevels; + protected int $menuExpandAll; + protected int $menuIncludeSpacer; + protected string $menuTitleField; + protected string $menuAlternativeSortingField; + protected string $menuTargetVariableName; + + public function __construct( + protected ContentDataProcessor $contentDataProcessor, + protected MenuContentObjectFactory $menuContentObjectFactory, + protected PageRepository $pageRepository, + ) {} + + /** + * Get configuration value from processorConfiguration + */ + protected function getConfigurationValue(string $key): string + { + return $this->cObj->stdWrapValue($key, $this->processorConfiguration, $this->menuDefaults[$key] ?? ''); + } + + /** + * @throws \InvalidArgumentException + */ + public function validateConfiguration(): void + { + $invalidArguments = []; + foreach ($this->processorConfiguration as $key => $value) { + if (!in_array($key, $this->allowedConfigurationKeys)) { + $invalidArguments[str_replace('.', '', $key)] = $key; + } + } + if (!empty($invalidArguments)) { + throw new \InvalidArgumentException('MenuProcessor Configuration contains invalid Arguments: ' . implode(', ', $invalidArguments), 1478806566); + } + } + + public function prepareConfiguration(): void + { + $this->menuConfig = $this->processorConfiguration; + // Filter configuration + foreach ($this->menuConfig as $key => $value) { + if (in_array($key, $this->removeConfigurationKeysForHmenu)) { + unset($this->menuConfig[$key]); + } + } + // Process special value + if (isset($this->menuConfig['special.']['value.'])) { + $this->menuConfig['special.']['value'] = $this->cObj->stdWrapValue('value', $this->menuConfig['special.']); + unset($this->menuConfig['special.']['value.']); + } + } + + /** + * Build the menu configuration so it can be treated by TMENU + */ + public function buildConfiguration(): void + { + for ($i = 1; $i <= $this->menuLevels; $i++) { + $this->menuConfig[$i] = 'TMENU'; + if (array_key_exists('showAccessRestrictedPages', $this->menuConfig)) { + $this->menuConfig[$i . '.']['showAccessRestrictedPages'] = $this->menuConfig['showAccessRestrictedPages']; + if (array_key_exists('showAccessRestrictedPages.', $this->menuConfig) + && is_array($this->menuConfig['showAccessRestrictedPages.'])) { + $this->menuConfig[$i . '.']['showAccessRestrictedPages.'] = $this->menuConfig['showAccessRestrictedPages.']; + } + } + $this->menuConfig[$i . '.']['expAll'] = $this->menuExpandAll; + $this->menuConfig[$i . '.']['alternativeSortingField'] = $this->menuAlternativeSortingField; + $this->menuConfig[$i . '.']['NO'] = '1'; + if ($this->menuIncludeSpacer) { + $this->menuConfig[$i . '.']['SPC'] = '1'; + } + } + } + + /** + * @param ContentObjectRenderer $cObj The data of the content element or page + * @param array $contentObjectConfiguration The configuration of Content Object + * @param array $processorConfiguration The configuration of this processor + * @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View) + * @return array the processed data as key/value store + */ + public function process(ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData) + { + if (isset($processorConfiguration['if.']) && !$cObj->checkIf($processorConfiguration['if.'])) { + return $processedData; + } + $this->cObj = $cObj; + $this->processorConfiguration = $processorConfiguration; + + // Get Configuration + $this->menuLevels = (int)$this->getConfigurationValue('levels') ?: 1; + $this->menuExpandAll = (int)$this->getConfigurationValue('expandAll'); + $this->menuIncludeSpacer = (int)$this->getConfigurationValue('includeSpacer'); + $this->menuTargetVariableName = $this->getConfigurationValue('as'); + $this->menuTitleField = $this->getConfigurationValue('titleField'); + $this->menuAlternativeSortingField = $this->getConfigurationValue('alternativeSortingField'); + + // Validate Configuration + $this->validateConfiguration(); + + // Build Configuration + $this->prepareConfiguration(); + $this->buildConfiguration(); + + // Create menu object and get menu items directly + $request = $cObj->getRequest(); + $menu = $this->menuContentObjectFactory->getMenuObjectByType('TMENU'); + $menu->parent_cObj = $cObj; + + if (!$menu->start(null, $this->pageRepository, '', $this->menuConfig, 1, '', $request)) { + return $processedData; + } + $menu->makeMenu(); + $menuItems = $menu->getMenuItems(); + + if ($menuItems === []) { + return $processedData; + } + + // Process additional data processors + $processedMenu = []; + foreach ($menuItems as $key => $page) { + $processedMenu[$key] = $this->processAdditionalDataProcessors($page, $processorConfiguration); + } + + // Return processed data + $processedData[$this->menuTargetVariableName] = $processedMenu; + return $processedData; + } + + /** + * Process additional data processors + */ + protected function processAdditionalDataProcessors(array $page, array $processorConfiguration): array + { + if (is_array($page['children'] ?? false)) { + foreach ($page['children'] as $key => $item) { + $page['children'][$key] = $this->processAdditionalDataProcessors($item, $processorConfiguration); + } + } + $request = $this->cObj->getRequest(); + $recordContentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $recordContentObjectRenderer->setRequest($request); + $recordContentObjectRenderer->start($page['data'] ?? [], 'pages'); + $page['title'] = (string)$recordContentObjectRenderer->stdWrap('', ['field' => $this->menuTitleField]); + + return $this->contentDataProcessor->process($recordContentObjectRenderer, $processorConfiguration, $page); + } + +} diff --git a/Classes/DataProcessing/PageContentFetchingProcessor.php b/Classes/DataProcessing/PageContentFetchingProcessor.php new file mode 100644 index 0000000..ae825d8 --- /dev/null +++ b/Classes/DataProcessing/PageContentFetchingProcessor.php @@ -0,0 +1,76 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + $request = $cObj->getRequest(); + $pageInformation = $request->getAttribute('frontend.page.information'); + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'content'); + $contentAreas = $pageInformation->getPageLayout()?->getContentAreas(); + $groupedContent = $this->eventDispatcher->dispatch( + new AfterContentHasBeenFetchedEvent($contentAreas->getGroupedRecords($request), $request) + )->groupedContent; + $processedData[$targetVariableName] = $contentAreas->withUpdatedRecords($groupedContent); + return $processedData; + } +} diff --git a/Classes/DataProcessing/RecordTransformationProcessor.php b/Classes/DataProcessing/RecordTransformationProcessor.php new file mode 100644 index 0000000..b1d1e17 --- /dev/null +++ b/Classes/DataProcessing/RecordTransformationProcessor.php @@ -0,0 +1,111 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + // `data` is the default variable name for the FLUIDTEMPLATE record + // and processed records of the DatabaseQueryProcessor. + $defaultVariableName = 'data'; + $variableName = $cObj->stdWrapValue('variableName', $processorConfiguration, $defaultVariableName); + $input = $processedData[$variableName] ?? $processedData; + // We can only deal with arrays here. + if (!is_array($input)) { + return $processedData; + } + $table = $cObj->stdWrapValue('table', $processorConfiguration, $cObj->getCurrentTable()); + $output = []; + if (array_is_list($input)) { + foreach ($input as $record) { + $output[] = $this->recordFactory->createResolvedRecordFromDatabaseRow($table, $record); + } + $defaultTargetVariableName = 'records'; + } else { + $output = $this->recordFactory->createResolvedRecordFromDatabaseRow($table, $input); + $defaultTargetVariableName = 'record'; + } + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, $defaultTargetVariableName); + // @todo Should we make sure that $output is actually a Record object? + $processedData[$targetVariableName] = $output; + return $processedData; + } +} diff --git a/Classes/DataProcessing/SiteLanguageProcessor.php b/Classes/DataProcessing/SiteLanguageProcessor.php new file mode 100644 index 0000000..62b22b9 --- /dev/null +++ b/Classes/DataProcessing/SiteLanguageProcessor.php @@ -0,0 +1,50 @@ +stdWrapValue('as', $processorConfiguration, 'siteLanguage'); + $processedData[$targetVariableName] = $cObj->getRequest()->getAttribute('language')?->toArray(); + return $processedData; + } +} diff --git a/Classes/DataProcessing/SiteProcessor.php b/Classes/DataProcessing/SiteProcessor.php new file mode 100644 index 0000000..16fa95a --- /dev/null +++ b/Classes/DataProcessing/SiteProcessor.php @@ -0,0 +1,50 @@ +stdWrapValue('as', $processorConfiguration, 'site'); + $processedData[$targetVariableName] = $cObj->getRequest()->getAttribute('site'); + return $processedData; + } +} diff --git a/Classes/DataProcessing/SplitProcessor.php b/Classes/DataProcessing/SplitProcessor.php new file mode 100644 index 0000000..5e53fbf --- /dev/null +++ b/Classes/DataProcessing/SplitProcessor.php @@ -0,0 +1,97 @@ +checkIf($processorConfiguration['if.'])) { + return $processedData; + } + + // The field name to process + $fieldName = $cObj->stdWrapValue('fieldName', $processorConfiguration); + if (empty($fieldName)) { + return $processedData; + } + + $originalValue = (string)($cObj->data[$fieldName] ?? ''); + + // Set the target variable + $targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, $fieldName); + + // Set the delimiter which is "LF" by default + $delimiter = (string)$cObj->stdWrapValue('delimiter', $processorConfiguration, LF); + + // Filter integers + $filterIntegers = (bool)$cObj->stdWrapValue('filterIntegers', $processorConfiguration, false); + + // Filter unique + $filterUnique = (bool)$cObj->stdWrapValue('filterUnique', $processorConfiguration, false); + + // Remove empty entries + $removeEmptyEntries = (bool)$cObj->stdWrapValue('removeEmptyEntries', $processorConfiguration, false); + + if ($filterIntegers === true) { + $processedData[$targetVariableName] = GeneralUtility::intExplode($delimiter, $originalValue, $removeEmptyEntries); + } else { + $processedData[$targetVariableName] = GeneralUtility::trimExplode($delimiter, $originalValue, $removeEmptyEntries); + } + + if ($filterUnique === true) { + $processedData[$targetVariableName] = array_unique($processedData[$targetVariableName]); + } + + return $processedData; + } +} diff --git a/Classes/Event/AfterCacheableContentIsGeneratedEvent.php b/Classes/Event/AfterCacheableContentIsGeneratedEvent.php new file mode 100644 index 0000000..25e9e5d --- /dev/null +++ b/Classes/Event/AfterCacheableContentIsGeneratedEvent.php @@ -0,0 +1,71 @@ +no_cache. + */ +final class AfterCacheableContentIsGeneratedEvent +{ + public function __construct( + private readonly ServerRequestInterface $request, + private string $content, + private readonly string $cacheIdentifier, + private bool $usePageCache + ) {} + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function isCachingEnabled(): bool + { + return $this->usePageCache; + } + + public function disableCaching(): void + { + $this->usePageCache = false; + } + + public function enableCaching(): void + { + $this->usePageCache = true; + } + + public function getCacheIdentifier(): string + { + return $this->cacheIdentifier; + } +} diff --git a/Classes/Event/AfterCachedPageIsPersistedEvent.php b/Classes/Event/AfterCachedPageIsPersistedEvent.php new file mode 100644 index 0000000..3b79b2f --- /dev/null +++ b/Classes/Event/AfterCachedPageIsPersistedEvent.php @@ -0,0 +1,61 @@ +request; + } + + public function getCacheIdentifier(): string + { + return $this->cacheIdentifier; + } + + public function getCacheData(): array + { + return $this->cacheData; + } + + /** + * The amount of seconds until the cache entry is invalid. + */ + public function getCacheLifetime(): int + { + return $this->cacheLifetime; + } +} diff --git a/Classes/Event/AfterContentHasBeenFetchedEvent.php b/Classes/Event/AfterContentHasBeenFetchedEvent.php new file mode 100644 index 0000000..941ff3e --- /dev/null +++ b/Classes/Event/AfterContentHasBeenFetchedEvent.php @@ -0,0 +1,32 @@ +linkResult = $linkResult; + } + + public function getLinkResult(): LinkResultInterface + { + return $this->linkResult; + } + + public function getContentObjectRenderer(): ContentObjectRenderer + { + return $this->contentObjectRenderer; + } + + /** + * Returns the original instructions / $linkConfiguration that were used to build the link + */ + public function getLinkInstructions(): array + { + return $this->linkInstructions; + } +} diff --git a/Classes/Event/AfterPageAndLanguageIsResolvedEvent.php b/Classes/Event/AfterPageAndLanguageIsResolvedEvent.php new file mode 100644 index 0000000..a2d8b9d --- /dev/null +++ b/Classes/Event/AfterPageAndLanguageIsResolvedEvent.php @@ -0,0 +1,64 @@ +request; + } + + public function getPageInformation(): PageInformation + { + return $this->pageInformation; + } + + public function setPageInformation(PageInformation $pageInformation): void + { + $this->pageInformation = $pageInformation; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } +} diff --git a/Classes/Event/AfterPageWithRootLineIsResolvedEvent.php b/Classes/Event/AfterPageWithRootLineIsResolvedEvent.php new file mode 100644 index 0000000..69e4e9d --- /dev/null +++ b/Classes/Event/AfterPageWithRootLineIsResolvedEvent.php @@ -0,0 +1,63 @@ +request; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function getPageInformation(): PageInformation + { + return $this->pageInformation; + } + + public function setPageInformation(PageInformation $pageInformation): void + { + $this->pageInformation = $pageInformation; + } +} diff --git a/Classes/Event/AfterTypoScriptDeterminedEvent.php b/Classes/Event/AfterTypoScriptDeterminedEvent.php new file mode 100644 index 0000000..5073c80 --- /dev/null +++ b/Classes/Event/AfterTypoScriptDeterminedEvent.php @@ -0,0 +1,50 @@ +frontendTypoScript; + } +} diff --git a/Classes/Event/BeforeDatabaseRecordLinkResolvedEvent.php b/Classes/Event/BeforeDatabaseRecordLinkResolvedEvent.php new file mode 100644 index 0000000..e36e436 --- /dev/null +++ b/Classes/Event/BeforeDatabaseRecordLinkResolvedEvent.php @@ -0,0 +1,44 @@ +record !== null; + } +} diff --git a/Classes/Event/BeforePageCacheIdentifierIsHashedEvent.php b/Classes/Event/BeforePageCacheIdentifierIsHashedEvent.php new file mode 100644 index 0000000..d3bf994 --- /dev/null +++ b/Classes/Event/BeforePageCacheIdentifierIsHashedEvent.php @@ -0,0 +1,57 @@ +request; + } + + public function getPageCacheIdentifierParameters(): array + { + return $this->pageCacheIdentifierParameters; + } + + public function setPageCacheIdentifierParameters(array $pageCacheIdentifierParameters): void + { + $this->pageCacheIdentifierParameters = $pageCacheIdentifierParameters; + } +} diff --git a/Classes/Event/BeforePageIsResolvedEvent.php b/Classes/Event/BeforePageIsResolvedEvent.php new file mode 100644 index 0000000..b216d43 --- /dev/null +++ b/Classes/Event/BeforePageIsResolvedEvent.php @@ -0,0 +1,51 @@ +id) or modify the context + * for resolving a page. + */ +final class BeforePageIsResolvedEvent +{ + public function __construct( + private readonly ServerRequestInterface $request, + private PageInformation $pageInformation, + ) {} + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getPageInformation(): PageInformation + { + return $this->pageInformation; + } + + public function setPageInformation(PageInformation $pageInformation): void + { + $this->pageInformation = $pageInformation; + } +} diff --git a/Classes/Event/FilterMenuItemsEvent.php b/Classes/Event/FilterMenuItemsEvent.php new file mode 100644 index 0000000..4aea541 --- /dev/null +++ b/Classes/Event/FilterMenuItemsEvent.php @@ -0,0 +1,89 @@ +allMenuItems; + } + + public function getFilteredMenuItems(): array + { + return $this->filteredMenuItems; + } + + public function setFilteredMenuItems(array $filteredMenuItems): void + { + $this->filteredMenuItems = $filteredMenuItems; + } + + public function getMenuConfiguration(): array + { + return $this->menuConfiguration; + } + + public function getItemConfiguration(): array + { + return $this->itemConfiguration; + } + + public function getBannedMenuItems(): array + { + return $this->bannedMenuItems; + } + + public function getExcludedDoktypes(): array + { + return $this->excludedDoktypes; + } + + public function getSite(): Site + { + return $this->site; + } + + public function getContext(): Context + { + return $this->context; + } + + public function getCurrentPage(): array + { + return $this->currentPage; + } +} diff --git a/Classes/Event/ModifyCacheLifetimeForPageEvent.php b/Classes/Event/ModifyCacheLifetimeForPageEvent.php new file mode 100644 index 0000000..90d32b6 --- /dev/null +++ b/Classes/Event/ModifyCacheLifetimeForPageEvent.php @@ -0,0 +1,65 @@ +cacheLifetime = $cacheLifetime; + } + + public function getCacheLifetime(): int + { + return $this->cacheLifetime; + } + + public function getPageId(): int + { + return $this->pageId; + } + + public function getPageRecord(): array + { + return $this->pageRecord; + } + + public function getRenderingInstructions(): array + { + return $this->renderingInstructions; + } + + public function getContext(): Context + { + return $this->context; + } +} diff --git a/Classes/Event/ModifyCacheLifetimeForRowEvent.php b/Classes/Event/ModifyCacheLifetimeForRowEvent.php new file mode 100644 index 0000000..98d9250 --- /dev/null +++ b/Classes/Event/ModifyCacheLifetimeForRowEvent.php @@ -0,0 +1,31 @@ +hrefLangs; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + /** + * Set the hreflangs. This should be an array in format: + * + * ``` + * [ + * 'en-US' => 'https://example.com', + * 'nl-NL' => 'https://example.com/nl' + * ] + * ``` + * + * @param array $hrefLangs + */ + public function setHrefLangs(array $hrefLangs): void + { + $this->hrefLangs = $hrefLangs; + } + + /** + * Add a hreflang tag to the current list of hreflang tags + * + * @param string $languageCode The language of the hreflang tag you would like to add. For example: nl-NL + * @param string $url The URL of the translation. For example: https://example.com/nl + */ + public function addHrefLang(string $languageCode, string $url): void + { + $this->hrefLangs[$languageCode] = $url; + } +} diff --git a/Classes/Event/ModifyPageLinkConfigurationEvent.php b/Classes/Event/ModifyPageLinkConfigurationEvent.php new file mode 100644 index 0000000..637f308 --- /dev/null +++ b/Classes/Event/ModifyPageLinkConfigurationEvent.php @@ -0,0 +1,96 @@ +configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } + + public function getLinkDetails(): array + { + return $this->linkDetails; + } + + public function getPage(): array + { + return $this->page; + } + + public function setPage(array $page): void + { + $this->page = $page; + $this->pageWasModified = true; + } + + public function getQueryParameters(): array + { + return $this->queryParameters; + } + + public function setQueryParameters(array $queryParameters): void + { + $this->queryParameters = $queryParameters; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function setFragment(string $fragment): void + { + $this->fragment = $fragment; + } + + public function pageWasModified(): bool + { + return $this->pageWasModified; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Event/ModifyTypoScriptConfigEvent.php b/Classes/Event/ModifyTypoScriptConfigEvent.php new file mode 100644 index 0000000..bbfa782 --- /dev/null +++ b/Classes/Event/ModifyTypoScriptConfigEvent.php @@ -0,0 +1,69 @@ +getAttribute('frontend.typoscript')->getConfigTree(), + * and its array variant $request->getAttribute('frontend.typoscript')->getConfigArray(). + * + * Registered listener can *set* a modified setup config AST. Note the TypoScript AST + * structure is still marked @internal within v13 core and may change later, + * using the event to *write* different 'config' data is thus still a bit risky. + */ +final class ModifyTypoScriptConfigEvent +{ + public function __construct( + private readonly ServerRequestInterface $request, + private readonly RootNode $setupTree, + private RootNode $configTree, + ) {} + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getSetupTree(): RootNode + { + return $this->setupTree; + } + + public function getConfigTree(): RootNode + { + return $this->configTree; + } + + public function setConfigTree(RootNode $configTree): void + { + $this->configTree = $configTree; + } +} diff --git a/Classes/Event/ModifyTypoScriptConstantsEvent.php b/Classes/Event/ModifyTypoScriptConstantsEvent.php new file mode 100644 index 0000000..83608a4 --- /dev/null +++ b/Classes/Event/ModifyTypoScriptConstantsEvent.php @@ -0,0 +1,43 @@ +constantsAst; + } + + public function setConstantsAst(RootNode $constantsAst): void + { + $this->constantsAst = $constantsAst; + } +} diff --git a/Classes/Event/ShouldUseCachedPageDataIfAvailableEvent.php b/Classes/Event/ShouldUseCachedPageDataIfAvailableEvent.php new file mode 100644 index 0000000..cbc1aed --- /dev/null +++ b/Classes/Event/ShouldUseCachedPageDataIfAvailableEvent.php @@ -0,0 +1,47 @@ +request; + } + + public function shouldUseCachedPageData(): bool + { + return $this->shouldUseCachedPageData; + } + + public function setShouldUseCachedPageData(bool $shouldUseCachedPageData): void + { + $this->shouldUseCachedPageData = $shouldUseCachedPageData; + } +} diff --git a/Classes/EventListener/AvoidContentSecurityPolicyNonceEventListener.php b/Classes/EventListener/AvoidContentSecurityPolicyNonceEventListener.php new file mode 100644 index 0000000..c74bf30 --- /dev/null +++ b/Classes/EventListener/AvoidContentSecurityPolicyNonceEventListener.php @@ -0,0 +1,121 @@ +policyBag->behavior->useNonce !== null) { + return; + } + // skip, since the frontend request is not supposed to be fully cacheable + if (!$this->isCacheableFrontendRequest($event->request)) { + return; + } + if ($event->policyBag->nonce->count() === 0 + || $this->isEmptyResponse($event->response) + || $this->nonceProxyIsAvoidable($event->policyBag) + ) { + $event->policyBag->behavior->useNonce = false; + } + } + + private function isCacheableFrontendRequest(ServerRequestInterface $request): bool + { + if (!ApplicationType::fromRequest($request)->isFrontend()) { + return false; + } + $cacheInstruction = $request->getAttribute('frontend.cache.instruction'); + $pageParts = $request->getAttribute('frontend.page.parts'); + return $pageParts instanceof PageParts + && $cacheInstruction instanceof CacheInstruction + && $cacheInstruction->isCachingAllowed() + && !$pageParts->hasNotCachedContentElements(); + } + + private function isEmptyResponse(string|ResponseInterface|null $response): bool + { + // skip, since it cannot be determined + if ($response === null) { + return false; + } + if (is_string($response)) { + return $response === ''; + } + return $response->getBody()->isReadable() + && $response->getBody()->getSize() === 0; + } + + private function nonceProxyIsAvoidable(PolicyBag $policyBag): bool + { + $nonce = $policyBag->nonce; + foreach ($policyBag->dispositionMap->keys() as $disposition) { + $policy = $policyBag->getPolicy($disposition); + if ($policy->isEmpty()) { + continue; + } + foreach (SourceKeyword::nonceProxy->getApplicableDirectives() as $directive) { + $collection = $policy->get($directive); + // directive is not present or does not contain `nonce-proxy` + if ($collection === null || !$collection->contains(SourceKeyword::nonceProxy)) { + continue; + } + // stop in case this directive contains `strict-dynamic`, or the nonce for that directive was consumed + // for inline assets, and there is no alternative (weak) `'unsafe-inline'` source in the policy + if ($collection->contains(SourceKeyword::strictDynamic) + || (!$collection->contains(SourceKeyword::unsafeInline) + && $this->familyConsumedInlineNonce($directive, $nonce)) + ) { + return false; + } + } + } + return true; + } + + private function familyConsumedInlineNonce(Directive $directive, ConsumableNonce $nonce): bool + { + // in case the directive was not specified for the `{f:security.nonce()}` + // view-helper, the nonce usage is considered active for all directives + if ($nonce->countInline(NonceViewHelper::class) > 0) { + return true; + } + foreach ($directive->getFamily() as $member) { + if ($nonce->countInline($member) > 0) { + return true; + } + } + return false; + } +} diff --git a/Classes/Exception.php b/Classes/Exception.php new file mode 100644 index 0000000..1d7f737 --- /dev/null +++ b/Classes/Exception.php @@ -0,0 +1,23 @@ +value` --> `` + */ + public const REMOVE_TAG_ON_FAILURE = 1; + + /** + * Removes corresponding attribute in case there's a failure + * e.g. `value` --> `value` + */ + public const REMOVE_ATTR_ON_FAILURE = 2; + + /** + * Removes corresponding enclosure in case there's a failure + * e.g. `value` --> `value` + */ + public const REMOVE_ENCLOSURE_ON_FAILURE = 4; + + protected ?\DOMNode $mount = null; + protected ?\DOMDocument $document = null; + + public function __construct( + protected readonly LinkFactory $linkFactory, + protected readonly HTML5 $parser + ) {} + + public function __toString(): string + { + if (!$this->mount instanceof \DOMNode || !$this->document instanceof \DOMDocument) { + return ''; + } + return $this->parser->saveHTML($this->mount->childNodes); + } + + public function parse(string $html): self + { + // use document fragment to separate markup from default structure (html, body, ...) + $fragment = $this->parser->parseFragment($html); + // mount fragment to make it accessible in current document + $this->mount = $this->mountFragment($fragment); + $this->document = $this->mount->ownerDocument; + return $this; + } + + /** + * @param string|ConsumableNonce $nonce none value to be added + * @param string ...$nodeNames element node names to be processed (e.g. `style`) + */ + public function addNonceAttribute(string|ConsumableNonce $nonce, string ...$nodeNames): self + { + if ($nodeNames === []) { + return $this; + } + $xpath = new \DOMXPath($this->document); + foreach ($nodeNames as $nodeName) { + $expression = sprintf('//%s[not(@*)]', $nodeName); + /** @var \DOMElement $element */ + foreach ($xpath->query($expression, $this->mount) as $element) { + $element->setAttribute('nonce', (string)$nonce); + } + } + return $this; + } + + public function transformUri(string $selector, int $flags = 0): self + { + if (!$this->mount instanceof \DOMNode || !$this->document instanceof \DOMDocument) { + return $this; + } + $subjects = $this->parseSelector($selector); + // use xpath to traverse potential candidates having "links" + $xpath = new \DOMXPath($this->document); + foreach ($subjects as $subject) { + $attrName = $subject['attr']; + $expression = sprintf('//%s[@%s]', $subject['node'], $attrName); + /** @var \DOMElement $element */ + foreach ($xpath->query($expression, $this->mount) as $element) { + $elementAttrValue = $element->getAttribute($attrName); + $scheme = parse_url($elementAttrValue, PHP_URL_SCHEME); + // skip values not having a URI-scheme + if (empty($scheme)) { + continue; + } + try { + $linkResult = $this->linkFactory->createUri($elementAttrValue); + } catch (UnableToLinkException $exception) { + $this->onTransformUriFailure($element, $subject, $flags); + continue; + } + $linkResultAttrValues = array_filter($linkResult->getAttributes()); + // usually link results contain `href` attr value, which needs to be assigned + // to a different value in case selector (e.g. `img.src` instead f `a.href`) + if (isset($linkResultAttrValues['href']) && $attrName !== 'href') { + $element->setAttribute($attrName, $linkResultAttrValues['href']); + unset($linkResultAttrValues['href']); + } + foreach ($linkResultAttrValues as $name => $value) { + $element->setAttribute($name, (string)$value); + } + } + } + return $this; + } + + /** + * @param \DOMElement $element current element encountered failure + * @param array{node: string, attr: string} $subject node-attr combination + */ + protected function onTransformUriFailure(\DOMElement $element, array $subject, int $flags): void + { + if (($flags & self::REMOVE_TAG_ON_FAILURE) === self::REMOVE_TAG_ON_FAILURE) { + $element->parentNode->removeChild($element); + } elseif (($flags & self::REMOVE_ATTR_ON_FAILURE) === self::REMOVE_ATTR_ON_FAILURE) { + $attrName = $subject['attr']; + $element->removeAttribute($attrName); + } elseif (($flags & self::REMOVE_ENCLOSURE_ON_FAILURE) === self::REMOVE_ENCLOSURE_ON_FAILURE) { + // moves children out of element's enclosure, then removes (empty) element + // eg `` + // 1) `` + // 2) `` + // 3) `` + // rm `` + $parentNode = $element->parentNode; + foreach ($element->childNodes as $child) { + $cloned = $child->cloneNode(true); + $parentNode->insertBefore($cloned, $element); + } + $parentNode->removeChild($element); + } + } + + /** + * @return array{node: string, attr: string}[] + */ + protected function parseSelector(string $selector): array + { + $items = GeneralUtility::trimExplode(',', $selector, true); + $items = array_map( + static function (string $item): ?array { + $parts = explode('.', $item); + if (count($parts) !== 2) { + return null; + } + return [ + 'node' => $parts[0] ?: '*', + 'attr' => $parts[1], + ]; + }, + $items + ); + return array_filter($items); + } + + protected function mountFragment(\DOMDocumentFragment $fragment): \DOMNode + { + $document = $fragment->ownerDocument; + $mount = $document->createElement('div'); + $document->appendChild($mount); + if ($fragment->hasChildNodes()) { + $mount->appendChild($fragment); + } + return $mount; + } +} diff --git a/Classes/Http/Application.php b/Classes/Http/Application.php new file mode 100644 index 0000000..e742d17 --- /dev/null +++ b/Classes/Http/Application.php @@ -0,0 +1,73 @@ + '@' . RequestHandler::class, + '$middlewares' => '@frontend.middlewares', + ], + )] + RequestHandlerInterface $requestHandler, + protected readonly Context $context, + ) { + $this->requestHandler = $requestHandler; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + // Create new request object having applicationType "I am a frontend request" attribute. + $request = $request->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE); + + $this->initializeContext(); + return parent::handle($request); + } + + /** + * 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()); + $this->context->setAspect('workspace', new WorkspaceAspect(0)); + $this->context->setAspect('backend.user', new UserAspect(null)); + $this->context->setAspect('frontend.user', new UserAspect(null, [0, -1])); + } +} diff --git a/Classes/Http/RequestHandler.php b/Classes/Http/RequestHandler.php new file mode 100644 index 0000000..d178bcd --- /dev/null +++ b/Classes/Http/RequestHandler.php @@ -0,0 +1,1323 @@ +getAttribute('nonce'); + $nonce = $nonce instanceof ConsumableNonce ? $nonce : null; + $this->pageRenderer->setNonce($nonce); + $policyBag = $request->getAttribute('csp.policyBag'); + $policyBag = $policyBag instanceof PolicyBag ? $policyBag : null; + + // Make sure all FAL resources are prefixed with absPrefPrefix + $this->listenerProvider->addListener( + GeneratePublicUrlForResourceEvent::class, + PublicUrlPrefixer::class, + 'prefixWithAbsRefPrefix' + ); + + $pageParts = $request->getAttribute('frontend.page.parts'); + if (!$pageParts instanceof PageParts) { + throw new \RuntimeException('Attribute frontend.page.parts must be an instance of PageParts at this point', 1761829876); + } + + $content = $pageParts->getContent(); + if (!$pageParts->hasPageContentBeenLoadedFromCache()) { + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray(); + $cacheInstruction = $request->getAttribute('frontend.cache.instruction'); + $cacheDataCollector = $request->getAttribute('frontend.cache.collector'); + $pageInformation = $request->getAttribute('frontend.page.information'); + + $this->timeTracker->push('Page generation'); + + $docType = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? ''); + $this->pageRenderer->setDocType($docType, $request); + + // Content generation + $this->timeTracker->incStackPointer(); + $this->timeTracker->push('Page generation PAGE object'); + + $content = $this->generatePageContent($request); + + $this->timeTracker->pull($this->timeTracker->LR ? $content : ''); + $this->timeTracker->decStackPointer(); + + // In case the nonce value was actually consumed during the rendering process, add a + // permanent substitution of the current value (that will be cached), with a future + // value (that will be generated and issued in the HTTP CSP header). + // Side-note: Nonce values that are consumed in non-cacheable parts (USER_INT/COA_INT) + // are not handled here, since it would require writing the caches at the very end of + // the whole frontend rendering process. + if ($nonce !== null) { + // prepare the policy in any case (even if nonce was not consumed) + // (`AvoidContentSecurityPolicyNonceEventListener` adjusts the behavior) + if ($policyBag !== null) { + $this->policyProvider->prepare($policyBag, $request, $content); + } + // register nonce substitution if explicitly enabled, otherwise (if undefined) + // use it if nonce value was consumed or any non-cached content elements exist + if ($policyBag?->behavior->useNonce + ?? (count($nonce) > 0 || $pageParts->hasNotCachedContentElements()) + ) { + $pageParts->addNotCachedContentElement([ + 'substKey' => null, + 'target' => NonceValueSubstitution::class . '->substituteNonce', + 'parameters' => ['nonce' => $nonce->value], + 'permanent' => true, + ]); + } + if ($policyBag?->behavior->useNonce === false) { + $content = $this->responseService->dropNonceFromHtml($content, $nonce); + } + } + + $event = new AfterCacheableContentIsGeneratedEvent($request, $content, $cacheDataCollector->getPageCacheIdentifier(), $cacheInstruction->isCachingAllowed()); + $event = $this->eventDispatcher->dispatch($event); + $content = $event->getContent(); + + // Write page cache if allowed + if ($event->isCachingEnabled()) { + $pageId = $pageInformation->getId(); + $pageRecord = $pageInformation->getPageRecord(); + + $lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForPage($pageInformation->getId(), $pageInformation->getPageRecord(), $typoScriptConfigArray, $this->context); + $cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageId, $lifetime)); + if ($pageId !== $pageInformation->getContentFromPid()) { + // Respect the page cache when content from different pid is shown + $cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageInformation->getContentFromPid(), $lifetime)); + } + if ((int)($pageRecord['_LOCALIZED_UID'] ?? 0) > 0) { + // Respect the translation page id on translated pages + $cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageRecord['_LOCALIZED_UID'], $lifetime)); + } + if (!empty($pageRecord['cache_tags'])) { + $tags = GeneralUtility::trimExplode(',', $pageRecord['cache_tags'], true); + array_walk($tags, fn(string $tag) => $cacheDataCollector->addCacheTags(new CacheTag($tag, $lifetime))); + } + + $cacheData = [ + 'page_id' => $pageId, + 'content' => $content, + 'contentType' => $pageParts->getHttpContentType(), + 'INTincScript' => $pageParts->getNotCachedContentElementRegistry(), + 'pageRendererSubstitutionHash' => $pageParts->getPageRendererSubstitutionHash(), + 'pageRendererState' => serialize($this->pageRenderer->getState()), + 'assetCollectorState' => serialize($this->assetCollector->getState()), + 'pageTitleCache' => $pageParts->getPageTitle(), + 'pageCacheGeneratedTimestamp' => $GLOBALS['EXEC_TIME'], + 'metaDataState' => $this->metaDataState->getState(), + ]; + + $cacheDataCollector->enqueueCacheEntry( + new CacheEntry( + identifier: 'tsfe-page-cache', + content: $cacheData, + persist: function (ServerRequestInterface $request, string $identifier, mixed $content) { + $cacheDataCollector = $request->getAttribute('frontend.cache.collector'); + $cacheTimeout = $cacheDataCollector->resolveLifetime(); + $pageCacheTags = array_map(fn(CacheTag $cacheTag) => $cacheTag->name, $cacheDataCollector->getCacheTags()); + + $content['cacheTags'] = $pageCacheTags; + $content['pageCacheExpireTimestamp'] = $GLOBALS['EXEC_TIME'] + $cacheTimeout; + $this->pageCache->set($cacheDataCollector->getPageCacheIdentifier(), $content, $pageCacheTags, $cacheTimeout); + + // Event for cache post processing (eg. writing static files) + $this->eventDispatcher->dispatch( + new AfterCachedPageIsPersistedEvent($request, $cacheDataCollector->getPageCacheIdentifier(), $content, $cacheTimeout) + ); + } + ) + ); + } + + $this->updateSysLastChangedInPageRecord($request); + + $this->timeTracker->pull(); + } + + // Render non-cached page parts by replacing placeholders which are taken from cache or added during page generation + if ($pageParts->hasNotCachedContentElements()) { + $this->timeTracker->push('Non-cached objects'); + $content = $this->calculateNonCachedElements($request, $content); + $this->timeTracker->pull(); + } + + $content = $this->displayPreviewInfoMessage($request, $content); + + // Create a default Response object and add headers and body to it + $response = new Response(); + $response = $this->addHttpHeadersToResponse($request, $response, $content); + $response->getBody()->write($content); + return $response; + } + + /** + * Generates the main body part for the page, and if "config.disableAllHeaderCode" is not active, triggers + * pageRenderer to evaluate includeCSS, headTag etc. TypoScript processing to populate the pageRenderer. + */ + protected function generatePageContent(ServerRequestInterface $request): string + { + // Generate the main content between the tags + // This has to be done first, as some additional frontend related code could have been written?! + $pageContent = $this->generatePageBodyContent($request); + // If 'disableAllHeaderCode' is set, all the pageRenderer settings are not evaluated + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray(); + if ($typoScriptConfigArray['disableAllHeaderCode'] ?? false) { + return $pageContent; + } + // Now, populate pageRenderer with all additional data + $this->processHtmlBasedRenderingSettings($request); + // Add previously generated page content within the tag afterwards + $this->pageRenderer->addBodyContent(LF . $pageContent); + $pageParts = $request->getAttribute('frontend.page.parts'); + if ($pageParts->hasNotCachedContentElements()) { + // Render complete page, keep placeholders for JavaScript and CSS + return $this->pageRenderer->renderPageWithUncachedObjects($pageParts->getPageRendererSubstitutionHash()); + } + // Render complete page + return $this->pageRenderer->renderFrontendPage($request); + } + + /** + * Generates the main content part within tags (except JS files/CSS files), this means: + * render everything that can be cached, otherwise put placeholders for COA_INT/USER_INT objects + * in the content that is processed later-on. + */ + protected function generatePageBodyContent(ServerRequestInterface $request): string + { + $typoScriptPageSetupArray = $request->getAttribute('frontend.typoscript')->getPageArray(); + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObjectRenderer->setRequest($request); + $contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages'); + $pageContent = $contentObjectRenderer->cObjGet($typoScriptPageSetupArray) ?: ''; + if ($typoScriptPageSetupArray['wrap'] ?? false) { + $pageContent = $contentObjectRenderer->wrap($pageContent, $typoScriptPageSetupArray['wrap']); + } + if ($typoScriptPageSetupArray['stdWrap.'] ?? false) { + $pageContent = $contentObjectRenderer->stdWrap($pageContent, $typoScriptPageSetupArray['stdWrap.']); + } + return $pageContent; + } + + /** + * Calculate non cached elements and inline to given cacheable content. + */ + protected function calculateNonCachedElements(ServerRequestInterface $request, string $content): string + { + $content = $this->recursivelyReplaceIntPlaceholdersInContent($request, $content); + $this->timeTracker->push('Substitute header section'); + $titleTagContent = $this->generatePageTitle($request); + $this->pageRenderer->setTitle($titleTagContent); + $pageParts = $request->getAttribute('frontend.page.parts'); + $content = $this->pageRenderer->renderJavaScriptAndCssForProcessingOfUncachedContentObjects($request, $content, $pageParts->getPageRendererSubstitutionHash()); + // Replace again, because header and footer data and page renderer replacements may introduce additional placeholders (see #44825) + $content = $this->recursivelyReplaceIntPlaceholdersInContent($request, $content); + $this->timeTracker->pull(); + return $content; + } + + /** + * At this point, the cacheable content has just been generated: Content is available but hasn't been added + * to PageRenderer yet. The method is called after the "main" page content, since some JS may be inserted at that point + * that has been registered by cacheable plugins. + * PageRenderer is now populated with all data and additional JavaScript/CSS/FooterData/HeaderData that can be cached. + * Once finished, the content is added to the >addBodyContent() functionality. + */ + protected function processHtmlBasedRenderingSettings(ServerRequestInterface $request): void + { + $typoScript = $request->getAttribute('frontend.typoscript'); + $typoScriptSetupArray = $typoScript->getSetupArray(); + $typoScriptConfigArray = $typoScript->getConfigArray(); + $typoScriptPageArray = $typoScript->getPageArray(); + + if ($typoScriptConfigArray['moveJsFromHeaderToFooter'] ?? false) { + $this->pageRenderer->enableMoveJsFromHeaderToFooter(); + } + if ($typoScriptConfigArray['pageRendererTemplateFile'] ?? false) { + try { + $resource = $this->systemResourceFactory->createResource($typoScriptConfigArray['pageRendererTemplateFile']); + $this->pageRenderer->setTemplateFile((string)$resource); + } catch (SystemResourceException) { + // Custom template is not set if createResource() throws + } + } + $headerComment = trim($typoScriptConfigArray['headerComment'] ?? ''); + if ($headerComment) { + $this->pageRenderer->addInlineComment("\t" . str_replace(LF, LF . "\t", $headerComment) . LF); + } + $htmlTagAttributes = []; + + // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request. + $siteLanguage = $request->getAttribute('language') ?? $request->getAttribute('site')->getDefaultLanguage(); + if ($siteLanguage->getLocale()->isRightToLeftLanguageDirection()) { + $htmlTagAttributes['dir'] = 'rtl'; + } + $docType = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? ''); + // Set document type + $docTypeParts = []; + $xmlDocument = true; + // XML prologue + $xmlPrologue = (string)($typoScriptConfigArray['xmlprologue'] ?? ''); + switch ($xmlPrologue) { + case 'none': + $xmlDocument = false; + break; + case 'xml_10': + case 'xml_11': + case '': + if ($docType->isXmlCompliant()) { + $docTypeParts[] = $docType->getXmlPrologue(); + } else { + $xmlDocument = false; + } + break; + default: + $docTypeParts[] = $xmlPrologue; + } + // DTD + if ($docType->getDoctypeDeclaration() !== '') { + $docTypeParts[] = $docType->getDoctypeDeclaration(); + } + if (!empty($docTypeParts)) { + $this->pageRenderer->setXmlPrologAndDocType(implode(LF, $docTypeParts)); + } + + // See https://www.w3.org/International/questions/qa-html-language-declarations.en.html#attributes + // and https://datatracker.ietf.org/doc/html/rfc5646 + $htmlTagAttributes[$docType->isXmlCompliant() ? 'xml:lang' : 'lang'] = $siteLanguage->getHreflang(); + + if ($docType->isXmlCompliant() || $docType === DocType::html5 && $xmlDocument) { + // We add this to HTML5 to achieve a slightly better backwards compatibility + $htmlTagAttributes['xmlns'] = 'http://www.w3.org/1999/xhtml'; + if (is_array($typoScriptConfigArray['namespaces.'] ?? false)) { + foreach ($typoScriptConfigArray['namespaces.'] as $prefix => $uri) { + // $uri gets htmlspecialchared later + $htmlTagAttributes['xmlns:' . htmlspecialchars($prefix)] = $uri; + } + } + } + + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObjectRenderer->setRequest($request); + $contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages'); + + $this->pageRenderer->setHtmlTag($this->generateHtmlTag($htmlTagAttributes, $typoScriptConfigArray, $contentObjectRenderer)); + + $headTag = $typoScriptPageArray['headTag'] ?? ''; + if (isset($typoScriptPageArray['headTag.'])) { + $headTag = $contentObjectRenderer->stdWrap($headTag, $typoScriptPageArray['headTag.']); + } + $this->pageRenderer->setHeadTag($headTag); + + $this->pageRenderer->addInlineComment($this->typo3Information->getInlineHeaderComment()); + + if ($typoScriptPageArray['shortcutIcon'] ?? false) { + try { + $favIconResource = $this->systemResourceFactory->createPublicResource($typoScriptPageArray['shortcutIcon']); + if ($favIconResource instanceof SystemResourceInterface) { + $this->pageRenderer->setIconMimeType(' type="' . $favIconResource->getMimeType() . '"'); + } + $this->pageRenderer->setFavIcon((string)$this->resourcePublisher->generateUri($favIconResource, $request)); + } catch (SystemResourceException) { + // FavIcon is not set if sanitize() throws + } + } + + // Inline CSS from plugins, files, libraries and inline + if (is_array($typoScriptSetupArray['plugin.'] ?? false)) { + $stylesFromPlugins = ''; + foreach ($typoScriptSetupArray['plugin.'] as $key => $iCSScode) { + if (is_array($iCSScode)) { + if (($iCSScode['_CSS_DEFAULT_STYLE'] ?? false) && empty($typoScriptConfigArray['removeDefaultCss'])) { + $cssDefaultStyle = $contentObjectRenderer->stdWrapValue('_CSS_DEFAULT_STYLE', $iCSScode); + $stylesFromPlugins .= '/* default styles for extension "' . substr($key, 0, -1) . '" */' . LF . $cssDefaultStyle . LF; + } + } + } + if (!empty($stylesFromPlugins)) { + $this->addCssToPageRenderer($request, $stylesFromPlugins, 'InlineDefaultCss'); + } + } + if (is_array($typoScriptPageArray['includeCSS.'] ?? false)) { + foreach ($typoScriptPageArray['includeCSS.'] as $key => $cssResource) { + if (is_array($cssResource)) { + continue; + } + $cssResourceConfig = $additionalAttributes = $typoScriptPageArray['includeCSS.'][$key . '.'] ?? []; + if (isset($cssResourceConfig['if.']) && !$contentObjectRenderer->checkIf($cssResourceConfig['if.'])) { + continue; + } + try { + $cssResource = $this->systemResourceFactory->createResource($cssResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($cssResourceConfig['crossorigin'] ?? ''); + $additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'css'); + $this->pageRenderer->addCssFile( + $cssResource, + ($cssResourceConfig['alternate'] ?? false) ? 'alternate stylesheet' : 'stylesheet', + ($cssResourceConfig['media'] ?? false) ?: 'all', + ($cssResourceConfig['title'] ?? false) ?: '', + null, + (bool)($cssResourceConfig['forceOnTop'] ?? false), + $cssResourceConfig['allWrap'] ?? '', + null, + $cssResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($cssResourceConfig['inline'] ?? false), + $additionalAttributes, + $cssResourceConfig['integrity'] ?? '', + $crossOrigin + ); + } + } + if (is_array($typoScriptPageArray['includeCSSLibs.'] ?? false)) { + foreach ($typoScriptPageArray['includeCSSLibs.'] as $key => $cssResource) { + if (is_array($cssResource)) { + continue; + } + $cssResourceConfig = $additionalAttributes = $typoScriptPageArray['includeCSSLibs.'][$key . '.'] ?? []; + if (isset($cssResourceConfig['if.']) && !$contentObjectRenderer->checkIf($cssResourceConfig['if.'])) { + continue; + } + try { + $cssResource = $this->systemResourceFactory->createResource($cssResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($cssResourceConfig['crossorigin'] ?? ''); + $additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'css'); + $this->pageRenderer->addCssLibrary( + $cssResource, + ($cssResourceConfig['alternate'] ?? false) ? 'alternate stylesheet' : 'stylesheet', + ($cssResourceConfig['media'] ?? false) ?: 'all', + ($cssResourceConfig['title'] ?? false) ?: '', + null, + (bool)($cssResourceConfig['forceOnTop'] ?? false), + $cssResourceConfig['allWrap'] ?? '', + null, + $cssResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($cssResourceConfig['inline'] ?? false), + $additionalAttributes, + $cssResourceConfig['integrity'] ?? '', + $crossOrigin + ); + } + } + $style = $contentObjectRenderer->cObjGet($typoScriptPageArray['cssInline.'] ?? null, 'cssInline.'); + if (trim($style)) { + $this->addCssToPageRenderer($request, $style, 'additionalTSFEInlineStyle'); + } + + // JavaScript includes + if (is_array($typoScriptPageArray['includeJSLibs.'] ?? false)) { + foreach ($typoScriptPageArray['includeJSLibs.'] as $key => $jsResource) { + if (is_array($jsResource)) { + continue; + } + $jsResourceConfig = $additionalAttributes = $typoScriptPageArray['includeJSLibs.'][$key . '.'] ?? []; + if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) { + continue; + } + try { + $jsResource = $this->systemResourceFactory->createResource($jsResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? ''); + $additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'js'); + $this->pageRenderer->addJsLibrary( + $key, + $jsResource, + $jsResourceConfig['type'] ?? null, + null, + (bool)($jsResourceConfig['forceOnTop'] ?? false), + $jsResourceConfig['allWrap'] ?? '', + null, + $jsResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($jsResourceConfig['async'] ?? false), + $jsResourceConfig['integrity'] ?? '', + (bool)($jsResourceConfig['defer'] ?? false), + $crossOrigin, + (bool)($jsResourceConfig['nomodule'] ?? false), + $additionalAttributes + ); + } + } + if (is_array($typoScriptPageArray['includeJSFooterlibs.'] ?? false)) { + foreach ($typoScriptPageArray['includeJSFooterlibs.'] as $key => $jsResource) { + if (is_array($jsResource)) { + continue; + } + $jsResourceConfig = $additionalAttributes = $typoScriptPageArray['includeJSFooterlibs.'][$key . '.'] ?? []; + if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) { + continue; + } + try { + $jsResource = $this->systemResourceFactory->createResource($jsResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? ''); + $additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'js'); + $this->pageRenderer->addJsFooterLibrary( + $key, + $jsResource, + $jsResourceConfig['type'] ?? null, + null, + (bool)($jsResourceConfig['forceOnTop'] ?? false), + $jsResourceConfig['allWrap'] ?? '', + null, + $jsResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($jsResourceConfig['async'] ?? false), + $jsResourceConfig['integrity'] ?? '', + (bool)($jsResourceConfig['defer'] ?? false), + $crossOrigin, + (bool)($jsResourceConfig['nomodule'] ?? false), + $additionalAttributes + ); + } + } + if (is_array($typoScriptPageArray['includeJS.'] ?? false)) { + foreach ($typoScriptPageArray['includeJS.'] as $key => $jsResource) { + if (is_array($jsResource)) { + continue; + } + $jsResourceConfig = $typoScriptPageArray['includeJS.'][$key . '.'] ?? []; + if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) { + continue; + } + try { + $jsResource = $this->systemResourceFactory->createResource($jsResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? ''); + $this->pageRenderer->addJsFile( + $jsResource, + $jsResourceConfig['type'] ?? null, + null, + (bool)($jsResourceConfig['forceOnTop'] ?? false), + $jsResourceConfig['allWrap'] ?? '', + null, + $jsResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($jsResourceConfig['async'] ?? false), + $jsResourceConfig['integrity'] ?? '', + (bool)($jsResourceConfig['defer'] ?? false), + $crossOrigin, + (bool)($jsResourceConfig['nomodule'] ?? false), + // @todo: This does not use the same logic as with "additionalAttributes" above. Also not documented correctly. + $jsResourceConfig['data.'] ?? [] + ); + } + } + if (is_array($typoScriptPageArray['includeJSFooter.'] ?? false)) { + foreach ($typoScriptPageArray['includeJSFooter.'] as $key => $jsResource) { + if (is_array($jsResource)) { + continue; + } + $jsResourceConfig = $typoScriptPageArray['includeJSFooter.'][$key . '.'] ?? []; + if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) { + continue; + } + try { + $jsResource = $this->systemResourceFactory->createResource($jsResource); + } catch (SystemResourceException) { + continue; + } + $crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? ''); + $this->pageRenderer->addJsFooterFile( + $jsResource, + $jsResourceConfig['type'] ?? null, + null, + (bool)($jsResourceConfig['forceOnTop'] ?? false), + $jsResourceConfig['allWrap'] ?? '', + null, + $jsResourceConfig['allWrap.']['splitChar'] ?? '|', + (bool)($jsResourceConfig['async'] ?? false), + $jsResourceConfig['integrity'] ?? '', + (bool)($jsResourceConfig['defer'] ?? false), + $crossOrigin, + (bool)($jsResourceConfig['nomodule'] ?? false), + // @todo: This does not use the same logic as with "additionalAttributes" above. Also not documented correctly. + $jsResourceConfig['data.'] ?? [] + ); + } + } + + // Header and footer data + if (is_array($typoScriptPageArray['headerData.'] ?? false)) { + $this->pageRenderer->addHeaderData($contentObjectRenderer->cObjGet($typoScriptPageArray['headerData.'], 'headerData.')); + } + if (is_array($typoScriptPageArray['footerData.'] ?? false)) { + $this->pageRenderer->addFooterData($contentObjectRenderer->cObjGet($typoScriptPageArray['footerData.'], 'footerData.')); + } + + $titleTagContent = $this->generatePageTitle($request); + $this->pageRenderer->setTitle($titleTagContent); + + // @internal hook for EXT:seo, will be gone soon, do not use it in your own extensions + $_params = ['request' => $request]; + $_ref = null; + foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Frontend\Page\PageGenerator']['generateMetaTags'] ?? [] as $_funcRef) { + GeneralUtility::callUserFunction($_funcRef, $_params, $_ref); + } + + $this->generateHrefLangTags($request); + $this->generateMetaTagHtml($typoScriptPageArray['meta.'] ?? [], $contentObjectRenderer); + + // Javascript inline and inline footer code + $inlineJS = implode(LF, $contentObjectRenderer->cObjGetSeparated($typoScriptPageArray['jsInline.'] ?? null, 'jsInline.')); + $inlineFooterJs = implode(LF, $contentObjectRenderer->cObjGetSeparated($typoScriptPageArray['jsFooterInline.'] ?? null, 'jsFooterInline.')); + + if (($typoScriptConfigArray['removeDefaultJS'] ?? 'external') === 'external') { + // "removeDefaultJS" is "external" by default + // This keeps inlineJS from *_INT Objects from being moved to external files. + // At this point in frontend rendering *_INT Objects only have placeholders instead + // of actual content. Moving these placeholders to external files would break the JS file with + // syntax errors due to the placeholders, and the needed JS would never get included to the page. + // Therefore, inlineJS from *_INT Objects must not be moved to external files but kept internal. + $inlineJSint = ''; + $this->stripIntObjectPlaceholder($inlineJS, $inlineJSint); + if ($inlineJSint) { + $this->pageRenderer->addJsInlineCode('TS_inlineJSint', $inlineJSint); + } + if (trim($inlineJS)) { + $this->pageRenderer->addJsFile(GeneralUtility::writeJavaScriptContentToTemporaryFile($inlineJS), null); + } + if ($inlineFooterJs) { + $inlineFooterJSint = ''; + $this->stripIntObjectPlaceholder($inlineFooterJs, $inlineFooterJSint); + if ($inlineFooterJSint) { + $this->pageRenderer->addJsFooterInlineCode('TS_inlineFooterJSint', $inlineFooterJSint); + } + $this->pageRenderer->addJsFooterFile(GeneralUtility::writeJavaScriptContentToTemporaryFile($inlineFooterJs), null); + } + } else { + // Include only inlineJS + if ($inlineJS) { + $this->pageRenderer->addJsInlineCode('TS_inlineJS', $inlineJS); + } + if ($inlineFooterJs) { + $this->pageRenderer->addJsFooterInlineCode('TS_inlineFooter', $inlineFooterJs); + } + } + if (is_array($typoScriptPageArray['inlineLanguageLabelFiles.'] ?? false)) { + foreach ($typoScriptPageArray['inlineLanguageLabelFiles.'] as $key => $languageFile) { + if (is_array($languageFile)) { + continue; + } + $languageFileConfig = $typoScriptPageArray['inlineLanguageLabelFiles.'][$key . '.'] ?? []; + if (isset($languageFileConfig['if.']) && !$contentObjectRenderer->checkIf($languageFileConfig['if.'])) { + continue; + } + $this->pageRenderer->addInlineLanguageLabelFile( + $languageFile, + ($languageFileConfig['selectionPrefix'] ?? false) ? $languageFileConfig['selectionPrefix'] : '', + ($languageFileConfig['stripFromSelectionName'] ?? false) ? $languageFileConfig['stripFromSelectionName'] : '' + ); + } + } + if (is_array($typoScriptPageArray['inlineSettings.'] ?? false)) { + $this->pageRenderer->addInlineSettingArray('TS', $typoScriptPageArray['inlineSettings.']); + } + // Header complete, now the body tag is added so the regular content can be applied later-on + if ($typoScriptConfigArray['disableBodyTag'] ?? false) { + $this->pageRenderer->addBodyContent(LF); + } else { + $bodyTag = ''; + if ($typoScriptPageArray['bodyTag'] ?? false) { + $bodyTag = $typoScriptPageArray['bodyTag']; + } elseif ($typoScriptPageArray['bodyTagCObject'] ?? false) { + $bodyTag = $contentObjectRenderer->cObjGetSingle($typoScriptPageArray['bodyTagCObject'], $typoScriptPageArray['bodyTagCObject.'] ?? [], 'bodyTagCObject'); + } + if (trim($typoScriptPageArray['bodyTagAdd'] ?? '')) { + $bodyTag = preg_replace('/>$/', '', trim($bodyTag)) . ' ' . trim($typoScriptPageArray['bodyTagAdd']) . '>'; + } + $this->pageRenderer->addBodyContent(LF . $bodyTag); + } + } + + /** + * Searches for placeholder created from *_INT cObjects, removes them from + * $searchString and merges them to $intObjects + * + * @param string $searchString The String which should be cleaned from int-object markers + * @param string $intObjects The String the found int-placeholders are moved to (for further processing) + */ + protected function stripIntObjectPlaceholder(&$searchString, &$intObjects) + { + $tempArray = []; + preg_match_all('/\\<\\!--INT_SCRIPT.[a-z0-9]*--\\>/', $searchString, $tempArray); + $searchString = (string)preg_replace('/\\<\\!--INT_SCRIPT.[a-z0-9]*--\\>/', '', $searchString); + $intObjects = implode('', $tempArray[0]); + } + + /** + * Generate meta tags from meta tag TypoScript + * + * @param array $metaTagTypoScript TypoScript configuration for meta tags + */ + protected function generateMetaTagHtml(array $metaTagTypoScript, ContentObjectRenderer $cObj) + { + $conf = $this->typoScriptService->convertTypoScriptArrayToPlainArray($metaTagTypoScript); + foreach ($conf as $key => $properties) { + $replace = false; + if (is_array($properties)) { + $nodeValue = $properties['_typoScriptNodeValue'] ?? ''; + $value = trim((string)$cObj->stdWrap($nodeValue, $metaTagTypoScript[$key . '.'])); + if ($value === '' && !empty($properties['value'])) { + $value = $properties['value']; + } + } else { + $value = $properties; + } + + $attribute = 'name'; + if ((is_array($properties) && !empty($properties['httpEquivalent'])) || strtolower($key) === 'refresh') { + $attribute = 'http-equiv'; + } + if (is_array($properties) && !empty($properties['attribute'])) { + $attribute = $properties['attribute']; + } + if (is_array($properties) && !empty($properties['replace'])) { + $replace = true; + } + + if (!is_array($value)) { + $value = (array)$value; + } + foreach ($value as $subValue) { + if (trim($subValue ?? '') !== '') { + $this->pageRenderer->setMetaTag($attribute, $key, $subValue, [], $replace); + } + } + } + } + + /** + * Adds inline CSS code, by respecting the inlineStyle2TempFile option + * + * @param string $cssStyles the inline CSS styling + * @param string $inlineBlockName the block name to add it + */ + protected function addCssToPageRenderer(ServerRequestInterface $request, string $cssStyles, string $inlineBlockName): void + { + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray(); + // This option is enabled by default on purpose + if (empty($typoScriptConfigArray['inlineStyle2TempFile'] ?? true)) { + $this->pageRenderer->addCssInlineBlock($inlineBlockName, $cssStyles); + } else { + $this->pageRenderer->addCssFile('PKG:typo3/app:' . Environment::getRelativePublicPath() . GeneralUtility::writeStyleSheetContentToTemporaryFile($cssStyles)); + } + } + + /** + * Generates the tag by evaluating TypoScript configuration, usually found via: + * + * - Adding extra attributes in addition to pre-generated ones (e.g. "dir") + * config.htmlTag.attributes.no-js = 1 + * config.htmlTag.attributes.empty-attribute = + * + * - Adding one full string (no stdWrap!) to the "" tag + * config.htmlTag_setParams = string|"none" + * + * If config.htmlTag_setParams = none is set, even the pre-generated values are not added at all anymore. + * + * - "config.htmlTag_stdWrap" always applies over the whole compiled tag. + * + * @param array $htmlTagAttributes pre-generated attributes by doctype/direction etc. values. + * @param array $configuration the TypoScript configuration "config." array + * @param ContentObjectRenderer $cObj + * @return string the full tag as string + */ + protected function generateHtmlTag(array $htmlTagAttributes, array $configuration, ContentObjectRenderer $cObj): string + { + if (is_array($configuration['htmlTag.']['attributes.'] ?? null)) { + $attributeString = ''; + foreach ($configuration['htmlTag.']['attributes.'] as $attributeName => $value) { + if (str_ends_with($attributeName, '.')) { + // Skip this one, but only if the default value is set + if (isset($configuration['htmlTag.']['attributes.'][rtrim($attributeName, '.')])) { + continue; + } + $attributeName = rtrim($attributeName, '.'); + $value = ''; + + } + if (is_array($configuration['htmlTag.']['attributes.'][$attributeName . '.'] ?? null)) { + $value = $cObj->stdWrap($value, $configuration['htmlTag.']['attributes.'][$attributeName . '.']); + } + $attributeString .= ' ' . htmlspecialchars($attributeName) . ($value !== '' ? '="' . htmlspecialchars((string)$value) . '"' : ''); + // If e.g. "htmlTag.attributes.dir" is set, make sure it is not added again with "implodeAttributes()" + if (isset($htmlTagAttributes[$attributeName])) { + unset($htmlTagAttributes[$attributeName]); + } + } + $attributeString = ltrim(GeneralUtility::implodeAttributes($htmlTagAttributes) . $attributeString); + } elseif (($configuration['htmlTag_setParams'] ?? '') === 'none') { + $attributeString = ''; + } elseif (isset($configuration['htmlTag_setParams'])) { + $attributeString = $configuration['htmlTag_setParams']; + } else { + $attributeString = GeneralUtility::implodeAttributes($htmlTagAttributes); + } + $htmlTag = ''; + if (isset($configuration['htmlTag_stdWrap.'])) { + $htmlTag = $cObj->stdWrap($htmlTag, $configuration['htmlTag_stdWrap.']); + } + return $htmlTag; + } + + protected function generateHrefLangTags(ServerRequestInterface $request): void + { + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray(); + if ($typoScriptConfigArray['disableHrefLang'] ?? false) { + return; + } + $endingSlash = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? '')->isXmlCompliant() ? '/' : ''; + $hrefLangs = $this->eventDispatcher->dispatch(new ModifyHrefLangTagsEvent($request))->getHrefLangs(); + if (count($hrefLangs) > 1) { + $data = []; + foreach ($hrefLangs as $hrefLang => $href) { + $data[] = sprintf('', GeneralUtility::implodeAttributes([ + 'rel' => 'alternate', + 'hreflang' => $hrefLang, + 'href' => $href, + ], true), $endingSlash); + } + $this->pageRenderer->addHeaderData(implode(LF, $data)); + } + } + + /** + * Include the preview block in case we're looking at a hidden page in the LIVE workspace + */ + protected function displayPreviewInfoMessage(ServerRequestInterface $request, string $content): string + { + $isInWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline', false); + $isInPreviewMode = $this->context->hasAspect('frontend.preview') && $this->context->getPropertyFromAspect('frontend.preview', 'isPreview'); + $typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray(); + if (!$isInPreviewMode || $isInWorkspace || ($typoScriptConfigArray['disablePreviewNotification'] ?? false)) { + return $content; + } + if ($typoScriptConfigArray['message_preview'] ?? '') { + $message = $typoScriptConfigArray['message_preview']; + } else { + $label = $this->getLanguageService()->translate('preview', 'frontend.general'); + $styles = []; + $styles[] = 'position: fixed'; + $styles[] = 'top: 15px'; + $styles[] = 'right: 15px'; + $styles[] = 'padding: 8px 18px'; + $styles[] = 'background: #fff3cd'; + $styles[] = 'border: 1px solid #ffeeba'; + $styles[] = 'font-family: sans-serif'; + $styles[] = 'font-size: .875em'; + $styles[] = 'font-weight: bold'; + $styles[] = 'color: #856404'; + $styles[] = 'z-index: 20000'; + $styles[] = 'user-select: none'; + $styles[] = 'pointer-events: none'; + $styles[] = 'text-align: center'; + $styles[] = 'border-radius: 2px'; + $message = '
' . htmlspecialchars($label) . '
'; + } + if (!empty($message)) { + $content = str_ireplace('', $message . '', $content); + } + return $content; + } + + /** + * Filter out known TypoScript attributes so that they are NOT passed along + * to a or tag as additional attributes. + * NOTE: Some keys are unset here even though they are valid attributes to + * the or