Files

655 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Routing;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Routing\Event\AfterPagePreviewUriGeneratedEvent;
use TYPO3\CMS\Backend\Routing\Event\BeforePagePreviewUriGeneratedEvent;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\DateTimeAspect;
use TYPO3\CMS\Core\Context\VisibilityAspect;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
use TYPO3\CMS\Core\Routing\RouterInterface;
use TYPO3\CMS\Core\Routing\UnableToLinkToPageException;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Generate links to Frontend URLs with a modified scope.
*/
class PreviewUriBuilder
{
public const OPTION_SWITCH_FOCUS = 'switchFocus';
public const OPTION_WINDOW_NAME = 'windowName';
public const OPTION_WINDOW_FEATURES = 'windowFeatures';
public const OPTION_WINDOW_SCOPE = 'windowScope';
public const OPTION_WINDOW_SCOPE_LOCAL = 'local';
public const OPTION_WINDOW_SCOPE_GLOBAL = 'global';
protected array $record = [];
protected string $table = 'pages';
protected int $pageId;
protected int $languageId = 0;
protected array $rootLine = [];
protected string $section = '';
protected array $additionalQueryParameters = [];
protected bool $moduleLoading = true;
protected Context $context;
/**
* @param int|array $page Page ID to be previewed
*/
public static function create(int|array $page): self
{
$pageId = 0;
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages');
$laguageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$transOrigPointerFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
if (!is_array($page)) {
$pageId = $page;
if ($pageId > 0) {
// If a page ID is given, we fetch the record from the database
$page = BackendUtility::getRecord('pages', $pageId) ?? [];
} else {
// If no valid page ID is given, we use an empty array
$page = [];
}
}
if ($page !== []) {
if (($page[$transOrigPointerFieldName] ?? 0) > 0) {
// If the page is a translation, we need to use the parent page's ID
$pageId = (int)$page[$transOrigPointerFieldName];
} else {
// Otherwise, use the current page's ID
$pageId = (int)($page['uid'] ?? 0);
}
}
$obj = new self($pageId);
$obj->pageId = $pageId;
$obj->record = $page;
$obj->languageId = (int)($page[$laguageFieldName] ?? 0);
return $obj;
}
/**
* @internal Only to be used by TYPO3 core - for now
*/
public static function createForRecordPreview(string $table, int|array|RecordInterface $record, int $pageId): self
{
if ($record instanceof RecordInterface) {
$record = $record->getRawRecord()->toArray();
}
$recordId = is_int($record) ? $record : (int)($record['uid'] ?? 0);
$previewPageId = self::getPreviewPageId($table, $recordId, $pageId);
$obj = self::create($previewPageId);
$obj->table = $table;
$obj = $obj->withRootLine(BackendUtility::BEgetRootLine($previewPageId));
$obj = $obj->withSection($table === 'tt_content' ? '#c' . $recordId : '');
$obj = $obj->withAdditionalQueryParameters(self::getPreviewUrlParameters($previewPageId, $table, $record));
return $obj;
}
/**
* @param int $pageId Page ID to be previewed
*/
public function __construct(int $pageId)
{
$this->pageId = $pageId;
$this->context = clone GeneralUtility::makeInstance(Context::class);
$this->context->setAspect('visibility', new VisibilityAspect(true, false, false, true));
}
/**
* @param bool $moduleLoading whether to enable JavaScript module loading
* @return static
*/
public function withModuleLoading(bool $moduleLoading): self
{
if ($this->moduleLoading === $moduleLoading) {
return $this;
}
$target = clone $this;
$target->moduleLoading = $moduleLoading;
return $target;
}
/**
* @param array $rootLine (alternative) root-line of pages
* @return static
*/
public function withRootLine(array $rootLine): self
{
if ($this->rootLine === $rootLine) {
return $this;
}
$target = clone $this;
$target->rootLine = $rootLine;
return $target;
}
/**
* @param int $language particular language
* @return static
*/
public function withLanguage(int $language): self
{
if ($this->languageId === $language) {
return $this;
}
$target = clone $this;
$target->languageId = $language;
return $target;
}
/**
* @param string $section particular section (anchor element)
* @return static
*/
public function withSection(string $section): self
{
if ($this->section === $section) {
return $this;
}
$target = clone $this;
$target->section = $section;
return $target;
}
/**
* @param string|array $additionalQueryParameters additional URI query parameters
* @return static
*/
public function withAdditionalQueryParameters(array|string $additionalQueryParameters): self
{
if (is_array($additionalQueryParameters)) {
$additionalQueryParams = $additionalQueryParameters;
} else {
$additionalQueryParams = [];
parse_str($additionalQueryParameters, $additionalQueryParams);
}
$languageId = $this->languageId;
if (isset($additionalQueryParams['_language'])) {
$languageId = (int)$additionalQueryParams['_language'];
unset($additionalQueryParams['_language']);
}
// No change
if ($this->languageId === $languageId && $additionalQueryParams === $this->additionalQueryParameters) {
return $this;
}
$target = clone $this;
$target->additionalQueryParameters = $additionalQueryParams;
$target->languageId = $languageId;
return $target;
}
public function isPreviewable(): bool
{
if ($this->pageId === 0 || !isset($this->record['doktype'])) {
return false;
}
$isDeletePlaceholder = VersionState::tryFrom($this->record['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER;
if ($isDeletePlaceholder) {
return false;
}
// Custom records need to be configured via pages TSconfig to allow previews
if ($this->table !== 'pages' && $this->table !== 'tt_content') {
$previewConfiguration = BackendUtility::getPagesTSconfig($this->pageId)['TCEMAIN.']['preview.'][$this->table . '.'] ?? null;
if (!is_array($previewConfiguration)) {
return false;
}
}
return self::isPreviewableDoktype($this->pageId, (int)$this->record['doktype']);
}
/**
* Builds preview URI.
*/
public function buildUri(?array $options = null, ?Context $context = null): ?UriInterface
{
$eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
try {
$event = new BeforePagePreviewUriGeneratedEvent(
$this->pageId,
$this->languageId,
$this->rootLine,
$this->section,
$this->additionalQueryParameters,
$context ?? $this->context,
$this->enrichOptions($options)
);
$eventDispatcher->dispatch($event);
// If there hasn't been a custom preview URI set by an event listener, generate it.
if ($event->getPreviewUri() === null) {
if (!$this->isPreviewable()) {
return null;
}
$permissionClause = $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW);
$pageInfo = BackendUtility::readPageAccess($event->getPageId(), $permissionClause) ?: [];
// Check if the page (= its rootline) has a site attached, otherwise just keep the URI as is
if ($event->getRootline() === []) {
$event->setRootline(BackendUtility::BEgetRootLine($event->getPageId()));
}
// prepare custom context for link generation (to allow for example time based previews)
$event->setAdditionalQueryParameters(
array_replace_recursive(
$event->getAdditionalQueryParameters(),
self::getAdditionalQueryParametersForAccessRestrictedPages($pageInfo, $event->getContext(), $event->getRootline())
)
);
// Build the URI with a site as prefix, if configured
$siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
try {
$site = $siteFinder->getSiteByPageId($event->getPageId(), $event->getRootline());
} catch (SiteNotFoundException $e) {
throw new UnableToLinkToPageException('The page ' . $event->getPageId() . ' had no proper connection to a site, no link could be built.', 1651499353);
}
try {
$previewRouteParameters = $event->getAdditionalQueryParameters();
// Reassemble encapsulated language id into route parameters to get proper localized page preview
// uri for non-default languages.
if ($event->getLanguageId() > 0) {
$previewRouteParameters['_language'] = $site->getLanguageById($event->getLanguageId());
}
$event->setPreviewUri(
$site->getRouter($event->getContext())->generateUri(
$event->getPageId(),
$previewRouteParameters,
$event->getSection(),
RouterInterface::ABSOLUTE_URL
)
);
} catch (\InvalidArgumentException|InvalidRouteArgumentsException $e) {
throw new UnableToLinkToPageException(sprintf('The link to the page with ID "%d" could not be generated: %s', $event->getPageId(), $e->getMessage()), 1651499354, $e);
}
}
$event = new AfterPagePreviewUriGeneratedEvent(
$event->getPreviewUri(),
$event->getPageId(),
$event->getLanguageId(),
$event->getRootline(),
$event->getSection(),
$event->getAdditionalQueryParameters(),
$event->getContext(),
$event->getOptions(),
);
$eventDispatcher->dispatch($event);
return $event->getPreviewUri();
} catch (UnableToLinkToPageException $e) {
return null;
}
}
/**
* Builds attributes array (e.g. `['dispatch-action' => ...]`).
* CAVE: Attributes are NOT XSS-protected and need to be put through `htmlspecialchars`
*
* @param array|null $options
*/
public function buildDispatcherDataAttributes(?array $options = null): ?array
{
if (null === ($attributes = $this->buildAttributes($options))) {
return null;
}
$this->loadActionDispatcher();
return $this->prefixAttributeNames('dispatch-', $attributes);
}
/**
* Builds attributes array (e.g. `['data-dispatch-action' => ...]`).
* CAVE: Attributes are NOT XSS-protected and need to be put through `htmlspecialchars`
*
* @param array|null $options
*/
public function buildDispatcherAttributes(?array $options = null): ?array
{
if (null === ($attributes = $this->buildAttributes($options))) {
return null;
}
$this->loadActionDispatcher();
return $this->prefixAttributeNames('data-dispatch-', $attributes);
}
/**
* Serialized attributes are processed with `htmlspecialchars` and ready to be used.
*
* @param array|null $options
*/
public function serializeDispatcherAttributes(?array $options = null): ?string
{
if (null === ($attributes = $this->buildDispatcherAttributes($options))) {
return null;
}
return ' ' . GeneralUtility::implodeAttributes($attributes, true);
}
/**
* `<typo3-immediate-action>` does not have a specific meaning and is used to
* expose `data` attributes, see custom element in `ImmediateActionElement.ts`.
*
* @param array|null $options
*/
public function buildImmediateActionElement(?array $options = null): ?string
{
if (null === ($attributes = $this->buildAttributes($options))) {
return null;
}
$this->loadImmediateActionElement();
return sprintf(
// `<typo3-immediate-action action="TYPO3.WindowManager.localOpen" args="[...]">`
'<typo3-immediate-action %s></typo3-immediate-action>',
GeneralUtility::implodeAttributes($attributes, true)
);
}
/**
* Returns the preview page id, based on the given input, by checking
* preview configuration and alternatively looking up the rootline.
*
* @param string $table The table of the record to be previewed - might be empty for direct page preview ($pageId > 0)
* @param int $recordId The id of the record to be previewed - might be empty for direct page preview ($pageId > 0)
* @param int $pageId The page to preview the record on, also used to preview a page directly
* @internal Only to be used by TYPO3 core
*/
public static function getPreviewPageId(string $table, int $recordId, int $pageId): int
{
if ($table === 'pages') {
$rootPageId = $recordId;
if ($rootPageId) {
$l10nPointer = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
$page = BackendUtility::getRecord('pages', $rootPageId, 'is_siteroot,' . $l10nPointer);
if ($page['is_siteroot'] && $page[$l10nPointer]) {
$rootPageId = $page[$l10nPointer];
}
}
} else {
$rootPageId = max(0, $pageId);
}
$previewConfiguration = BackendUtility::getPagesTSconfig($rootPageId)['TCEMAIN.']['preview.'][$table . '.'] ?? [];
if (isset($previewConfiguration['previewPageId'])) {
return (int)$previewConfiguration['previewPageId'];
}
$rootPageData = null;
$rootLine = BackendUtility::BEgetRootLine($rootPageId);
$currentPage = (array)(reset($rootLine) ?: []);
if (VersionState::tryFrom($currentPage['t3ver_state'] ?? 0) !== VersionState::DELETE_PLACEHOLDER
&& self::isPreviewableDoktype((int)($currentPage['uid'] ?? 0), (int)($currentPage['doktype'] ?? 0))
) {
// try the current page
$previewPageId = $rootPageId;
} else {
// or search for the root page
foreach ($rootLine as $page) {
if ($page['is_siteroot'] ?? false) {
$rootPageData = $page;
break;
}
}
$previewPageId = isset($rootPageData)
? (int)$rootPageData['uid']
: $rootPageId;
}
return $previewPageId;
}
protected function buildAttributes(?array $options = null): ?array
{
$options = $this->enrichOptions($options);
if (null === ($uri = $this->buildUri($options))) {
return null;
}
$args = [
// target URI
(string)$uri,
// whether to switch focus to that window
$options[self::OPTION_SWITCH_FOCUS],
// name of the window instance for JavaScript references
$options[self::OPTION_WINDOW_NAME],
];
if (isset($options[self::OPTION_WINDOW_FEATURES])) {
// optional window features (e.g. 'width=500,height=300')
$args[] = $options[self::OPTION_WINDOW_FEATURES];
}
return [
'action' => $options[self::OPTION_WINDOW_SCOPE] === self::OPTION_WINDOW_SCOPE_GLOBAL
? 'TYPO3.WindowManager.globalOpen'
: 'TYPO3.WindowManager.localOpen',
'args' => json_encode($args),
];
}
/**
* Handles options to used for opening preview URI in a new window/tab.
* + `switchFocus` (bool): whether to focus new window in browser
* + `windowName` (string): name of window for internal reference
* + `windowScope` (string): `local` (current document) `global` (whole backend)
*
* @param array|null $options
*/
protected function enrichOptions(?array $options = null): array
{
return array_merge(
[
self::OPTION_SWITCH_FOCUS => null,
// 'newTYPO3frontendWindow' was used in BackendUtility::viewOnClick
self::OPTION_WINDOW_NAME => 'newTYPO3frontendWindow',
self::OPTION_WINDOW_SCOPE => self::OPTION_WINDOW_SCOPE_LOCAL,
],
$options ?? []
);
}
protected function loadActionDispatcher(): void
{
if (!$this->moduleLoading) {
return;
}
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
$pageRenderer->loadJavaScriptModule('@typo3/backend/action-dispatcher.js');
}
protected function loadImmediateActionElement(): void
{
if (!$this->moduleLoading) {
return;
}
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
$pageRenderer->loadJavaScriptModule('@typo3/backend/element/immediate-action-element.js');
}
protected function prefixAttributeNames(string $prefix, array $attributes): array
{
$attributeNames = array_map(
static function (string $name) use ($prefix): string {
return $prefix . $name;
},
array_keys($attributes)
);
return array_combine(
$attributeNames,
array_values($attributes)
);
}
/**
* Creates ADMCMD parameters for the "viewpage" extension / frontend
* @internal not part of TYPO3 Core API
*/
public static function getAdditionalQueryParametersForAccessRestrictedPages(array $pageInfo, Context $context, array $rootLine): array
{
if ($pageInfo === []) {
return [];
}
// Initialize access restriction values from current page
$access = [
'fe_group' => (string)($pageInfo['fe_group'] ?? ''),
'starttime' => (int)($pageInfo['starttime'] ?? 0),
'endtime' => (int)($pageInfo['endtime'] ?? 0),
];
// Only check rootline if the current page has not set extendToSubpages itself
if (!(bool)($pageInfo['extendToSubpages'] ?? false)) {
// remove the current page from the rootline
array_shift($rootLine);
foreach ($rootLine as $page) {
// Skip root node and pages which do not define extendToSubpages
if ((int)($page['uid'] ?? 0) === 0 || !(bool)($page['extendToSubpages'] ?? false)) {
continue;
}
$access['fe_group'] = (string)($page['fe_group'] ?? '');
$access['starttime'] = (int)($page['starttime'] ?? 0);
$access['endtime'] = (int)($page['endtime'] ?? 0);
// Stop as soon as a page in the rootline has extendToSubpages set
break;
}
}
$additionalQueryParameters = [];
if ((int)$access['fe_group'] === -2) {
// -2 means "show at any login". We simulate first available fe_group.
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('fe_groups');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(HiddenRestriction::class));
$activeFeGroupId = $queryBuilder->select('uid')
->from('fe_groups')
->executeQuery()
->fetchOne();
if ($activeFeGroupId) {
$additionalQueryParameters['ADMCMD_simUser'] = $activeFeGroupId;
}
} elseif (!empty($access['fe_group'])) {
$additionalQueryParameters['ADMCMD_simUser'] = $access['fe_group'];
}
if ($access['starttime'] > $GLOBALS['EXEC_TIME']) {
// simulate access time to ensure PageRepository will find the page and in turn PageRouter will generate
// a URL for it
$dateAspect = new DateTimeAspect(DateTimeFactory::createFromTimestamp($access['starttime']));
$context->setAspect('date', $dateAspect);
$additionalQueryParameters['ADMCMD_simTime'] = $access['starttime'];
}
if ($access['endtime'] < $GLOBALS['EXEC_TIME'] && $access['endtime'] !== 0) {
// Set access time to page's endtime subtracted one second to ensure PageRepository will find the page and
// in turn PageRouter will generate a URL for it
$dateAspect = new DateTimeAspect(DateTimeFactory::createFromTimestamp($access['endtime'] - 1));
$context->setAspect('date', $dateAspect);
$additionalQueryParameters['ADMCMD_simTime'] = ($access['endtime'] - 1);
}
return $additionalQueryParameters;
}
/**
* Returns the parameters for the preview URL by evaluating language overlays and preview configuration
*/
protected static function getPreviewUrlParameters(int $previewPageId, string $table, int|array $record): string
{
if (is_array($record)) {
$recordId = (int)($record['uid'] ?? 0);
} else {
$recordId = $record;
$record = BackendUtility::getRecord($table, $record) ?? [];
}
$linkParameters = [];
$previewConfiguration = BackendUtility::getPagesTSconfig($previewPageId)['TCEMAIN.']['preview.'][$table . '.'] ?? [];
// language handling
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($table);
if ($schema->isLanguageAware()
&& ($languageField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName())
&& !empty($record[$languageField])
) {
$l10nPointer = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
if (!empty($record[$l10nPointer]) && ($previewConfiguration['useDefaultLanguageRecord'] ?? true) === false) {
$recordId = (int)$record[$l10nPointer];
} else {
$recordId = (int)$record['uid'];
}
$language = $record[$languageField];
if ($language > 0) {
$linkParameters['_language'] = $language;
}
}
// Always use live workspace record uid for the preview
if ($schema->isWorkspaceAware() && ($record['t3ver_oid'] ?? 0) > 0) {
$recordId = $record['t3ver_oid'];
}
// map record data to GET parameters
if (isset($previewConfiguration['fieldToParameterMap.'])) {
foreach ($previewConfiguration['fieldToParameterMap.'] as $field => $parameterName) {
$value = $record[$field] ?? '';
if ($field === 'uid') {
$value = $recordId;
}
$linkParameters[$parameterName] = $value;
}
}
// add/override parameters by configuration
if (isset($previewConfiguration['additionalGetParameters.'])) {
$linkParameters = array_replace(
$linkParameters,
GeneralUtility::removeDotsFromTS($previewConfiguration['additionalGetParameters.'])
);
}
return HttpUtility::buildQueryString($linkParameters, '&');
}
/**
* Check whether the current page has a doktype, which can be previewed
*/
protected static function isPreviewableDoktype(int $pageId, int $doktype): bool
{
if ($pageId <= 0 || $doktype <= 0) {
return false;
}
$doktypeRegistry = GeneralUtility::makeInstance(PageDoktypeRegistry::class);
return $doktypeRegistry->isPageViewable($doktype, $pageId);
}
}