Commit e909c163 authored by Benni Mack's avatar Benni Mack
Browse files

[FEATURE] Replace hooks with events for Preview URI generation

The BackendUtility method "getPreviewUrl" along with
its hooks and the helper method "ADMCMD_previewCmds"
has been marked as deprecated in favor of having
everything in PreviewUriBuilder.

This class now contains two new Events for manipulating
the generated preview URI.

In addition, the unused "viewUrl" property of
EditDocumentController is removed.

The language parameter is now additionally
encapsulated in a separate property.

Resolves: #97544
Related: #91123
Releases: main
Change-Id: I5d6fbc293cd5bbfa1d4b522eb2f02bb6c763bb1a
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/74499

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Nikita Hovratov's avatarNikita Hovratov <nikita.h@live.de>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Nikita Hovratov's avatarNikita Hovratov <nikita.h@live.de>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 3c55a8ce
......@@ -579,11 +579,8 @@ class PageProvider extends RecordProvider
*/
protected function getViewLink(): string
{
$language = (int)$this->record['sys_language_uid'];
$additionalParams = ($language > 0) ? '&L=' . $language : '';
return (string)PreviewUriBuilder::create($this->getPreviewPid())
->withAdditionalQueryParameters($additionalParams)
->withLanguage((int)($this->record['sys_language_uid'] ?? 0))
->buildUri();
}
......
......@@ -446,18 +446,15 @@ class RecordProvider extends AbstractProvider
protected function getViewLink(): string
{
$anchorSection = '';
$additionalParams = '';
$language = 0;
if ($this->table === 'tt_content') {
$anchorSection = '#c' . $this->record['uid'];
$language = (int)$this->record[$GLOBALS['TCA']['tt_content']['ctrl']['languageField']];
if ($language > 0) {
$additionalParams = '&L=' . $language;
}
$language = (int)($this->record[$GLOBALS['TCA']['tt_content']['ctrl']['languageField'] ?? null] ?? 0);
}
return (string)PreviewUriBuilder::create($this->getPreviewPid())
->withAdditionalQueryParameters($additionalParams)
->withSection($anchorSection)
->withLanguage($language)
->buildUri();
}
......
......@@ -173,13 +173,6 @@ class EditDocumentController
*/
protected $popViewId;
/**
* Alternative URL for viewing the frontend pages.
*
* @var string
*/
protected $viewUrl;
/**
* @var string|null
*/
......@@ -723,7 +716,6 @@ class EditDocumentController
$beUser = $this->getBackendUser();
$this->popViewId = (int)($parsedBody['popViewId'] ?? $queryParams['popViewId'] ?? 0);
$this->viewUrl = (string)($parsedBody['viewUrl'] ?? $queryParams['viewUrl'] ?? '');
$this->recTitle = (string)($parsedBody['recTitle'] ?? $queryParams['recTitle'] ?? '');
$this->noView = (bool)($parsedBody['noView'] ?? $queryParams['noView'] ?? false);
$this->perms_clause = $beUser->getPagePermsClause(Permission::PAGE_SHOW);
......@@ -763,7 +755,7 @@ class EditDocumentController
$previewPageRootLine = BackendUtility::BEgetRootLine($previewPageId);
$previewUrlParameters = $this->getPreviewUrlParameters($previewPageId);
return PreviewUriBuilder::create($previewPageId, $this->viewUrl)
return PreviewUriBuilder::create($previewPageId)
->withRootLine($previewPageRootLine)
->withSection($anchorSection)
->withAdditionalQueryParameters($previewUrlParameters)
......@@ -787,7 +779,7 @@ class EditDocumentController
$recordId = $this->resolvePreviewRecordId($table, $recordArray, $previewConfiguration);
$language = $recordArray[$languageField];
if ($language > 0) {
$linkParameters['L'] = $language;
$linkParameters['_language'] = $language;
}
}
......@@ -1710,7 +1702,6 @@ class EditDocumentController
>
' . $editForm . '
<input type="hidden" name="returnUrl" value="' . htmlspecialchars($this->retUrl) . '" />
<input type="hidden" name="viewUrl" value="' . htmlspecialchars($this->viewUrl) . '" />
<input type="hidden" name="popViewId" value="' . htmlspecialchars((string)$this->viewId) . '" />
<input type="hidden" name="closeDoc" value="0" />
<input type="hidden" name="doSave" value="0" />';
......
......@@ -544,10 +544,9 @@ class PageLayoutController
&& !in_array((int)$this->pageinfo['doktype'], $excludeDokTypes, true)
&& !VersionState::cast($this->pageinfo['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)
) {
$languageParameter = $this->currentSelectedLanguage ? ('&L=' . $this->currentSelectedLanguage) : '';
$previewDataAttributes = PreviewUriBuilder::create((int)$this->pageinfo['uid'])
->withRootLine(BackendUtility::BEgetRootLine($this->pageinfo['uid']))
->withAdditionalQueryParameters($languageParameter)
->withLanguage($this->currentSelectedLanguage)
->buildDispatcherDataAttributes();
$viewButton = $buttonBar->makeLinkButton()
->setDataAttributes($previewDataAttributes ?? [])
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Event;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Context\Context;
/**
* Listeners to this event will be able to modify the page preview
* URI, which had been generated for a page in the frontend.
*/
final class AfterPagePreviewUriGeneratedEvent
{
public function __construct(
private UriInterface $previewUri,
private readonly int $pageId,
private readonly int $languageId,
private readonly array $rootline,
private readonly string $section,
private readonly array $additionalQueryParameters,
private readonly Context $context,
private readonly array $options
) {
}
public function setPreviewUri(UriInterface $previewUri): void
{
$this->previewUri = $previewUri;
}
public function getPreviewUri(): UriInterface
{
return $this->previewUri;
}
public function getPageId(): int
{
return $this->pageId;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function getRootline(): array
{
return $this->rootline;
}
public function getSection(): string
{
return $this->section;
}
public function getAdditionalQueryParameters(): array
{
return $this->additionalQueryParameters;
}
public function getContext(): Context
{
return $this->context;
}
public function getOptions(): array
{
return $this->options;
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Event;
use Psr\EventDispatcher\StoppableEventInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Context\Context;
/**
* Listeners to this event will be able to modify the corresponding parameters, before
* the page preview URI is being generated, when linking to a page in the frontend.
*/
final class BeforePagePreviewUriGeneratedEvent implements StoppableEventInterface
{
private ?UriInterface $uri = null;
public function __construct(
private int $pageId,
private int $languageId,
private array $rootline,
private string $section,
private array $additionalQueryParameters,
private readonly Context $context,
private readonly array $options
) {
}
public function setPreviewUri(UriInterface $uri): void
{
$this->uri = $uri;
}
public function getPreviewUri(): ?UriInterface
{
return $this->uri;
}
public function isPropagationStopped(): bool
{
return $this->uri !== null;
}
public function getPageId(): int
{
return $this->pageId;
}
public function setPageId(int $pageId): void
{
$this->pageId = $pageId;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function setLanguageId(int $languageId): void
{
$this->languageId = $languageId;
}
public function getRootline(): array
{
return $this->rootline;
}
public function setRootline(array $rootline): void
{
$this->rootline = $rootline;
}
public function getSection(): string
{
return $this->section;
}
public function setSection(string $section): void
{
$this->section = $section;
}
public function getAdditionalQueryParameters(): array
{
return $this->additionalQueryParameters;
}
public function setAdditionalQueryParameters(array $additionalQueryParameters): void
{
$this->additionalQueryParameters = $additionalQueryParameters;
}
public function getContext(): Context
{
return $this->context;
}
public function getOptions(): array
{
return $this->options;
}
}
......@@ -17,16 +17,28 @@ declare(strict_types=1);
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\Http\Uri;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\DateTimeAspect;
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\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\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Substitution for `BackendUtility::getPreviewUrl`.
* Internally `BackendUtility::getPreviewUrl` is still called due to hooks being invoked
* there - in the future it basically aims to be a replacement for mentioned function.
* Substitution for `BackendUtility::getPreviewUrl` for generating links to Frontend URLs
* with a modified scope.
*/
class PreviewUriBuilder
{
......@@ -38,60 +50,28 @@ class PreviewUriBuilder
public const OPTION_WINDOW_SCOPE_LOCAL = 'local';
public const OPTION_WINDOW_SCOPE_GLOBAL = 'global';
/**
* @var int
*/
protected $pageId;
/**
* @var string|null
*/
protected $alternativeUri;
/**
* @var array|null
*/
protected $rootLine;
/**
* @var string|null
*/
protected $section;
/**
* @var string|null
*/
protected $additionalQueryParameters;
/**
* @var string|null
* @internal Not used, kept for potential compatibility issues
*/
protected $backPath;
/**
* @var bool
*/
protected $moduleLoading = true;
protected int $pageId;
protected int $languageId = 0;
protected array $rootLine = [];
protected string $section = '';
protected array $additionalQueryParameters = [];
protected bool $moduleLoading = true;
/**
* @param int $pageId Page ID to be previewed
* @param string|null $alternativeUri Alternative URL to be used instead of `/index.php?id=`
* @return static
*/
public static function create(int $pageId, string $alternativeUri = null): self
public static function create(int $pageId): self
{
return GeneralUtility::makeInstance(static::class, $pageId, $alternativeUri);
return GeneralUtility::makeInstance(static::class, $pageId);
}
/**
* @param int $pageId Page ID to be previewed
* @param string|null $alternativeUri Alternative URL to be used instead of `/index.php?id=`
*/
public function __construct(int $pageId, string $alternativeUri = null)
public function __construct(int $pageId)
{
$this->pageId = $pageId;
$this->alternativeUri = $alternativeUri;
}
/**
......@@ -122,6 +102,20 @@ class PreviewUriBuilder
return $this;
}
/**
* @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
......@@ -137,41 +131,102 @@ class PreviewUriBuilder
}
/**
* @param string $additionalQueryParameters additional URI query parameters
* @param string|array $additionalQueryParameters additional URI query parameters
* @return static
*/
public function withAdditionalQueryParameters(string $additionalQueryParameters): self
public function withAdditionalQueryParameters(array|string $additionalQueryParameters): self
{
if ($this->additionalQueryParameters === $additionalQueryParameters) {
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 = $additionalQueryParameters;
$target->additionalQueryParameters = $additionalQueryParams;
$target->languageId = $languageId;
return $target;
}
/**
* Builds preview URI (still using `BackendUtility::getPreviewUrl`).
*
* @param array|null $options
* @return Uri|null
* Builds preview URI.
*/
public function buildUri(array $options = null): ?Uri
public function buildUri(array $options = null, Context $context = null): ?UriInterface
{
$eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
try {
$options = $this->enrichOptions($options);
$switchFocus = $options[self::OPTION_SWITCH_FOCUS] ?? true;
$uriString = BackendUtility::getPreviewUrl(
$event = new BeforePagePreviewUriGeneratedEvent(
$this->pageId,
$this->backPath ?? '',
$this->languageId,
$this->rootLine,
$this->section ?? '',
$this->alternativeUri ?? '',
$this->additionalQueryParameters ?? '',
$switchFocus
$this->section,
$this->additionalQueryParameters,
$context ?? clone GeneralUtility::makeInstance(Context::class),
$this->enrichOptions($options)
);
return GeneralUtility::makeInstance(Uri::class, $uriString);
} catch (UnableToLinkToPageException $exception) {
$eventDispatcher->dispatch($event);
// If there hasn't been a custom preview URI set by an event listener, generate it.
if ($event->getPreviewUri() === 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(),
$this->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 {
$event->setPreviewUri(
$site->getRouter($event->getContext())->generateUri(
$event->getPageId(),
$event->getAdditionalQueryParameters(),
$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;
}
}
......@@ -321,4 +376,75 @@ class PreviewUriBuilder
array_values($attributes)
);
}
/**
* Creates ADMCMD parameters for the "viewpage" extension / frontend
*/
protected 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.
<