TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
||||
use TYPO3\CMS\Core\Http\Uri;
|
||||
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
|
||||
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Redirects\Event\AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent;
|
||||
use TYPO3\CMS\Redirects\Event\RedirectIntegrityCheckEvent;
|
||||
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
|
||||
|
||||
/**
|
||||
* Checks for redirects that conflict with existing pages
|
||||
*/
|
||||
readonly class IntegrityService
|
||||
{
|
||||
public function __construct(
|
||||
private RedirectService $redirectService,
|
||||
private SiteFinder $siteFinder,
|
||||
private ConnectionPool $connectionPool,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
private TcaSchemaFactory $tcaSchemaFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves all conflicting redirects
|
||||
*/
|
||||
public function findConflictingRedirects(?string $siteIdentifier = null): \Generator
|
||||
{
|
||||
foreach ($this->getSites($siteIdentifier) as $site) {
|
||||
// Collect page urls for all pages and languages for $site.
|
||||
$urls = $this->getAllPageUrlsForSite($site);
|
||||
foreach ($urls as $url) {
|
||||
$uri = new Uri($url);
|
||||
$matchingRedirect = $this->getMatchingRedirectByUri($uri);
|
||||
if ($matchingRedirect !== null) {
|
||||
// @todo Returning information should be improved in future to give more useful information in
|
||||
// command output and report output, for example redirect uid, page/language details, which would
|
||||
// make the life easier for using the command and finding the conflicts.
|
||||
yield [
|
||||
'uri' => (string)$uri,
|
||||
'redirect' => [
|
||||
'integrity_status' => RedirectConflict::SELF_REFERENCE,
|
||||
'source_host' => $matchingRedirect['source_host'],
|
||||
'source_path' => $matchingRedirect['source_path'],
|
||||
'uid' => $matchingRedirect['uid'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all redirects by dispatching a PSR-14 event for each record,
|
||||
* allowing listeners to validate targets and flag broken redirects.
|
||||
*/
|
||||
public function checkRedirectIntegrity(): \Generator
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
|
||||
$queryBuilder->getRestrictions()->removeAll()
|
||||
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
|
||||
$result = $queryBuilder
|
||||
->select('*')
|
||||
->from('sys_redirect')
|
||||
->executeQuery();
|
||||
while ($row = $result->fetchAssociative()) {
|
||||
$event = $this->eventDispatcher->dispatch(
|
||||
new RedirectIntegrityCheckEvent($row)
|
||||
);
|
||||
if ($event->getIntegrityStatus() !== null
|
||||
&& $event->getIntegrityStatus() !== RedirectConflict::NO_CONFLICT
|
||||
) {
|
||||
yield [
|
||||
'uri' => $row['target'] ?? '',
|
||||
'redirect' => [
|
||||
'integrity_status' => $event->getIntegrityStatus(),
|
||||
'source_host' => $row['source_host'],
|
||||
'source_path' => $row['source_path'],
|
||||
'uid' => $row['uid'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function setIntegrityStatus(array $redirect): void
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
|
||||
$queryBuilder
|
||||
->update('sys_redirect')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($redirect['uid'], Connection::PARAM_INT))
|
||||
)
|
||||
->set('integrity_status', $redirect['integrity_status'])
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
private function getMatchingRedirectByUri(Uri $uri): ?array
|
||||
{
|
||||
$port = $uri->getPort();
|
||||
$domain = $uri->getHost() . ($port ? ':' . $port : '');
|
||||
return $this->redirectService->matchRedirect($domain, $uri->getPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Site[]
|
||||
*/
|
||||
private function getSites(?string $siteIdentifier): array
|
||||
{
|
||||
if ($siteIdentifier !== null) {
|
||||
return [$this->siteFinder->getSiteByIdentifier($siteIdentifier)];
|
||||
}
|
||||
|
||||
return $this->siteFinder->getAllSites();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of all slugs used in a site
|
||||
*/
|
||||
private function getAllPageUrlsForSite(Site $site): array
|
||||
{
|
||||
$schema = $this->tcaSchemaFactory->get('pages');
|
||||
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
|
||||
$pageUrls = [];
|
||||
|
||||
// language bases - redirects would be nasty, but should be checked also. We do not need to add site base
|
||||
// here, as there is always at least one default language.
|
||||
foreach ($site->getLanguages() as $siteLanguage) {
|
||||
$pageUrls[] = rtrim((string)$siteLanguage->getBase(), '/') . '/';
|
||||
}
|
||||
|
||||
$queryBuilder = $this->connectionPool
|
||||
->getQueryBuilderForTable('pages')
|
||||
->select('slug', $languageCapability->getLanguageField()->getName())
|
||||
->from('pages');
|
||||
|
||||
$queryBuilder->where(
|
||||
$queryBuilder->expr()->or(
|
||||
$queryBuilder->expr()->eq(
|
||||
'uid',
|
||||
$queryBuilder->createNamedParameter($site->getRootPageId(), Connection::PARAM_INT)
|
||||
),
|
||||
$queryBuilder->expr()->eq(
|
||||
$languageCapability->getTranslationOriginPointerField()->getName(),
|
||||
$queryBuilder->createNamedParameter($site->getRootPageId(), Connection::PARAM_INT)
|
||||
),
|
||||
)
|
||||
);
|
||||
$result = $queryBuilder->executeQuery();
|
||||
|
||||
while ($row = $result->fetchAssociative()) {
|
||||
// @todo Considering only page slug is not complete, as it does not match redirects with file extension,
|
||||
// for ex. if PageTypeSuffix routeEnhancer are used and redirects are created based on that.
|
||||
$slug = ltrim(($row['slug'] ?? ''), '/');
|
||||
$language = $row[$languageCapability->getLanguageField()->getName()];
|
||||
try {
|
||||
$siteLanguage = $site->getLanguageById($language);
|
||||
} catch (\InvalidArgumentException) {
|
||||
// skip invalid languages which might occur due to previous changes in site configuration
|
||||
continue;
|
||||
}
|
||||
|
||||
// empty slug root pages has been already handled with language bases above, thus skip them here.
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageUrls[] = rtrim((string)$siteLanguage->getBase(), '/') . '/' . $slug;
|
||||
}
|
||||
|
||||
$pageUrls = $this->eventDispatcher->dispatch(
|
||||
new AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent($site, $pageUrls)
|
||||
)->getPageUrls();
|
||||
|
||||
return array_unique($pageUrls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use TYPO3\CMS\Redirects\Repository\Demand;
|
||||
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class ModulePaginationService
|
||||
{
|
||||
public function __construct(
|
||||
private RedirectRepository $redirectRepository
|
||||
) {}
|
||||
|
||||
public function preparePagination(Demand $demand): array
|
||||
{
|
||||
$count = $this->redirectRepository->countRedirectsByDemand($demand);
|
||||
$numberOfPages = ceil($count / $demand->getLimit());
|
||||
$endRecord = $demand->getOffset() + $demand->getLimit();
|
||||
if ($endRecord > $count) {
|
||||
$endRecord = $count;
|
||||
}
|
||||
|
||||
$pagination = [
|
||||
'current' => $demand->getPage(),
|
||||
'numberOfPages' => $numberOfPages,
|
||||
'hasLessPages' => $demand->getPage() > 1,
|
||||
'hasMorePages' => $demand->getPage() < $numberOfPages,
|
||||
'startRecord' => $demand->getOffset() + 1,
|
||||
'endRecord' => $endRecord,
|
||||
];
|
||||
if ($pagination['current'] < $pagination['numberOfPages']) {
|
||||
$pagination['nextPage'] = $pagination['current'] + 1;
|
||||
}
|
||||
if ($pagination['current'] > 1) {
|
||||
$pagination['previousPage'] = $pagination['current'] - 1;
|
||||
}
|
||||
return $pagination;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
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\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Ensure to clear the cache entry when a sys_redirect record is modified, also the main pool
|
||||
* for getting all redirects.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
class RedirectCacheService
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'cache.pages')]
|
||||
protected readonly FrontendInterface $cache,
|
||||
protected readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetches all redirects available to the system, grouped by domain and regexp/nonregexp
|
||||
*/
|
||||
public function getRedirects(string $sourceHost): array
|
||||
{
|
||||
$redirects = $this->cache->get($this->buildCacheIdentifier($sourceHost));
|
||||
// empty array is considered as valid cache, so we need to check for array type here.
|
||||
if (!is_array($redirects)) {
|
||||
$redirects = $this->rebuildForHost($sourceHost);
|
||||
}
|
||||
return $redirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the cache for all redirects, grouped by host as well as by regular expressions and respect_query_parameters.
|
||||
* Does not include hidden or deleted redirects, but includes the ones with dynamic starttime/endtime.
|
||||
*/
|
||||
public function rebuildForHost(string $sourceHost): array
|
||||
{
|
||||
$redirects = [];
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
|
||||
$queryBuilder->getRestrictions()->removeAll()
|
||||
->add(GeneralUtility::makeInstance(HiddenRestriction::class))
|
||||
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
|
||||
$queryBuilder
|
||||
->select('*')
|
||||
->from('sys_redirect');
|
||||
|
||||
if ($sourceHost === '' || $sourceHost === '*') {
|
||||
$queryBuilder->where(
|
||||
$queryBuilder->expr()->or(
|
||||
$queryBuilder->expr()->eq('source_host', $queryBuilder->createNamedParameter('')),
|
||||
$queryBuilder->expr()->eq('source_host', $queryBuilder->createNamedParameter('*')),
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$queryBuilder->where(
|
||||
$queryBuilder->expr()->in('source_host', $queryBuilder->createNamedParameter($sourceHost))
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we have redirects which respect query parameters first, paired with a
|
||||
// cross dbms deterministic sorting criteria (`uid`) as last criteria.
|
||||
$queryBuilder
|
||||
->orderBy('respect_query_parameters', 'desc')
|
||||
->addOrderBy('uid', 'asc');
|
||||
|
||||
$statement = $queryBuilder->executeQuery();
|
||||
while ($row = $statement->fetchAssociative()) {
|
||||
// Field "description" is not needed for FE redirect handling. Don't add it to cache.
|
||||
unset($row['description']);
|
||||
if ($row['is_regexp'] && $row['respect_query_parameters']) {
|
||||
$redirects['regexp_query_parameters'][$row['source_path']][$row['uid']] = $row;
|
||||
} elseif ($row['is_regexp'] && !$row['respect_query_parameters']) {
|
||||
$redirects['regexp_flat'][$row['source_path']][$row['uid']] = $row;
|
||||
} elseif ($row['respect_query_parameters']) {
|
||||
$redirects['respect_query_parameters'][$row['source_path']][$row['uid']] = $row;
|
||||
} else {
|
||||
$redirects['flat'][rtrim($row['source_path'], '/') . '/'][$row['uid']] = $row;
|
||||
}
|
||||
}
|
||||
$this->cache->set($this->buildCacheIdentifier($sourceHost), $redirects);
|
||||
return $redirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild cache for each distinct redirect source_host.
|
||||
*/
|
||||
public function rebuildAll(): void
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
|
||||
// remove all restriction, as we need to retrieve the source host even for hidden or deleted redirects.
|
||||
$queryBuilder->getRestrictions()->removeAll();
|
||||
$resultSet = $queryBuilder
|
||||
->select('source_host')
|
||||
->distinct()
|
||||
->from('sys_redirect')
|
||||
->executeQuery();
|
||||
while ($row = $resultSet->fetchAssociative()) {
|
||||
$this->rebuildForHost($row['source_host'] ?? '*');
|
||||
}
|
||||
}
|
||||
|
||||
private function buildCacheIdentifier(string $sourceHost): string
|
||||
{
|
||||
return 'redirects_' . sha1($sourceHost);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
|
||||
use TYPO3\CMS\Core\Context\Context;
|
||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||
use TYPO3\CMS\Core\Http\Uri;
|
||||
use TYPO3\CMS\Core\LinkHandling\LinkService;
|
||||
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
|
||||
use TYPO3\CMS\Core\Localization\Locales;
|
||||
use TYPO3\CMS\Core\Page\PageRenderer;
|
||||
use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
|
||||
use TYPO3\CMS\Core\Resource\File;
|
||||
use TYPO3\CMS\Core\Resource\Folder;
|
||||
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||
use TYPO3\CMS\Core\Site\Entity\NullSite;
|
||||
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Core\TypoScript\FrontendTypoScriptFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\HttpUtility;
|
||||
use TYPO3\CMS\Frontend\Aspect\PreviewAspect;
|
||||
use TYPO3\CMS\Frontend\Cache\CacheInstruction;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
use TYPO3\CMS\Frontend\Page\PageInformationFactory;
|
||||
use TYPO3\CMS\Frontend\Typolink\TypolinkBuilderInterface;
|
||||
use TYPO3\CMS\Frontend\Typolink\UnableToLinkException;
|
||||
use TYPO3\CMS\Redirects\Event\BeforeRedirectMatchDomainEvent;
|
||||
|
||||
/**
|
||||
* Creates a proper URL to redirect from a matched redirect of a request
|
||||
*
|
||||
* @internal due to some possible refactorings
|
||||
*/
|
||||
readonly class RedirectService
|
||||
{
|
||||
public function __construct(
|
||||
private RedirectCacheService $redirectCacheService,
|
||||
private LinkService $linkService,
|
||||
private SiteFinder $siteFinder,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
private PageInformationFactory $pageInformationFactory,
|
||||
private FrontendTypoScriptFactory $frontendTypoScriptFactory,
|
||||
#[Autowire(service: 'cache.typoscript')]
|
||||
private PhpFrontend $typoScriptCache,
|
||||
private LoggerInterface $logger,
|
||||
private TypoLinkCodecService $typoLinkCodecService,
|
||||
private Locales $locales,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks against all available redirects "flat" or "regexp", and against starttime/endtime
|
||||
*/
|
||||
public function matchRedirect(string $domain, string $path, string $query = ''): ?array
|
||||
{
|
||||
$path = rawurldecode($path);
|
||||
// Check if the domain matches, or if there is a
|
||||
// redirect fitting for any domain
|
||||
foreach ([$domain, '*'] as $domainName) {
|
||||
$matchedRedirect = $this->eventDispatcher->dispatch(
|
||||
new BeforeRedirectMatchDomainEvent(
|
||||
$domain,
|
||||
$path,
|
||||
$query,
|
||||
$domainName,
|
||||
)
|
||||
)->getMatchedRedirect();
|
||||
if ($matchedRedirect !== null && $matchedRedirect !== []) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
$redirects = $this->fetchRedirects($domainName);
|
||||
if (empty($redirects)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if a flat redirect matches with the Query applied
|
||||
if (!empty($query)) {
|
||||
$pathWithQuery = rtrim($path, '/') . '?' . ltrim($query, '?');
|
||||
if (!empty($redirects['respect_query_parameters'][$pathWithQuery])) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQuery])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
} else {
|
||||
$pathWithQueryAndSlash = rtrim($path, '/') . '/?' . ltrim($query, '?');
|
||||
if (!empty($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if a flat redirect matches
|
||||
if (!empty($redirects['flat'][rtrim($path, '/') . '/'])) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['flat'][rtrim($path, '/') . '/'])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
// @todo Evaluate if regexp patterns could be validated on creation/edit to give feedback on creation.
|
||||
// check all regex redirects respecting query arguments
|
||||
if (!empty($redirects['regexp_query_parameters'])) {
|
||||
$allRegexps = array_keys($redirects['regexp_query_parameters']);
|
||||
$regExpPath = $path;
|
||||
if (!empty($query)) {
|
||||
$regExpPath .= '?' . ltrim($query, '?');
|
||||
}
|
||||
foreach ($allRegexps as $regexp) {
|
||||
$matchResult = @preg_match((string)$regexp, $regExpPath);
|
||||
if ($matchResult > 0) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_query_parameters'][$regexp])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log invalid regular expression
|
||||
if ($matchResult === false) {
|
||||
$this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @todo Evaluate if regexp patterns could be validated on creation/edit to give feedback on creation.
|
||||
// check all redirects that are registered as regex
|
||||
if (!empty($redirects['regexp_flat'])) {
|
||||
$allRegexps = array_keys($redirects['regexp_flat']);
|
||||
$regExpPath = $path;
|
||||
if (!empty($query)) {
|
||||
$regExpPath .= '?' . ltrim($query, '?');
|
||||
}
|
||||
foreach ($allRegexps as $regexp) {
|
||||
$matchResult = @preg_match((string)$regexp, $regExpPath);
|
||||
if ($matchResult > 0) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log invalid regular expression
|
||||
if ($matchResult === false) {
|
||||
$this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
|
||||
}
|
||||
}
|
||||
|
||||
// We need a second match run to evaluate against path only, even when query parameters where
|
||||
// provided to ensure regexp without query parameters in mind are still processed.
|
||||
// We need to do this only if there are query parameters in the request, otherwise first
|
||||
// preg_match would have found it.
|
||||
if (!empty($query)) {
|
||||
foreach ($allRegexps as $regexp) {
|
||||
$matchResult = @preg_match((string)$regexp, $path);
|
||||
if ($matchResult > 0) {
|
||||
if ($matchedRedirect = $this->getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
|
||||
return $matchedRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect record matches the starttime and endtime and disable restrictions
|
||||
*
|
||||
* @return bool whether the redirect is active and should be used for redirecting the current request
|
||||
*/
|
||||
protected function isRedirectActive(array $redirectRecord): bool
|
||||
{
|
||||
return !$redirectRecord['disabled'] && $redirectRecord['starttime'] <= $GLOBALS['SIM_ACCESS_TIME']
|
||||
&& (!$redirectRecord['endtime'] || $redirectRecord['endtime'] >= $GLOBALS['SIM_ACCESS_TIME']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all redirects from cache, with fallback to rebuild cache from the DB if caches was empty,
|
||||
* grouped by the domain does NOT take starttime/endtime into account, as it is cached.
|
||||
*/
|
||||
protected function fetchRedirects(string $sourceHost): array
|
||||
{
|
||||
return $this->redirectCacheService->getRedirects($sourceHost);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is actually a redirect, and then process the redirect.
|
||||
*
|
||||
* @return array the link details from the linkService
|
||||
*/
|
||||
protected function resolveLinkDetailsFromLinkTarget(string $redirectTarget): array
|
||||
{
|
||||
try {
|
||||
$linkDetails = $this->linkService->resolve($redirectTarget);
|
||||
// Having the `typoLinkParameter` in the linkDetails is required, if the linkDetails are used to generate
|
||||
// an url out of it. Therefore, this should be set in `getUriFromCustomLinkDetails()` before calling the
|
||||
// LinkBuilder->build() method. We have a really tight execution context here, so we can safely set it here
|
||||
// for now.
|
||||
// @todo This simply reflects the used value to resolve the details. Other places in core set this to the
|
||||
// array before building an url. This looks kind of unfinished. We should check, if we should not set
|
||||
// that linkDetail value directly in the LinkService()->resolve() method generally.
|
||||
$linkDetails['typoLinkParameter'] = $redirectTarget;
|
||||
switch ($linkDetails['type']) {
|
||||
case LinkService::TYPE_URL:
|
||||
// all set up, nothing to do
|
||||
break;
|
||||
case LinkService::TYPE_FILE:
|
||||
$file = $linkDetails['file'];
|
||||
if ($file instanceof File) {
|
||||
$linkDetails['url'] = $file->getPublicUrl();
|
||||
}
|
||||
break;
|
||||
case LinkService::TYPE_FOLDER:
|
||||
$folder = $linkDetails['folder'];
|
||||
if ($folder instanceof Folder) {
|
||||
$linkDetails['url'] = $folder->getPublicUrl();
|
||||
}
|
||||
break;
|
||||
case LinkService::TYPE_UNKNOWN:
|
||||
// If $redirectTarget could not be resolved, we can only assume $redirectTarget with leading '/'
|
||||
// as relative redirect and try to resolve it with enriched information from current request.
|
||||
// That ensures that regexp redirects ending in replaceRegExpCaptureGroup(), but also ensures
|
||||
// that relative urls are not left as unknown file here.
|
||||
if (str_starts_with($redirectTarget, '/')) {
|
||||
$linkDetails = [
|
||||
'type' => LinkService::TYPE_URL,
|
||||
'url' => $redirectTarget,
|
||||
];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// we have to return the link details without having a "URL" parameter
|
||||
}
|
||||
} catch (InvalidPathException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $linkDetails;
|
||||
}
|
||||
|
||||
public function getTargetUrl(array $matchedRedirect, ServerRequestInterface $request): ?UriInterface
|
||||
{
|
||||
$site = $request->getAttribute('site');
|
||||
$uri = $request->getUri();
|
||||
$queryParams = $request->getQueryParams();
|
||||
$this->logger->debug('Found a redirect to process', ['redirect' => $matchedRedirect]);
|
||||
$linkParameterParts = $this->typoLinkCodecService->decode((string)$matchedRedirect['target']);
|
||||
$redirectTarget = $linkParameterParts['url'];
|
||||
$linkDetails = $this->resolveLinkDetailsFromLinkTarget($redirectTarget);
|
||||
$this->logger->debug('Resolved link details for redirect', ['details' => $linkDetails]);
|
||||
if (!empty($linkParameterParts['additionalParams']) && $matchedRedirect['keep_query_parameters']) {
|
||||
$params = GeneralUtility::explodeUrl2Array($linkParameterParts['additionalParams']);
|
||||
foreach ($params as $key => $value) {
|
||||
$queryParams[$key] = $value;
|
||||
}
|
||||
}
|
||||
// Do this for files, folders, external URLs or relative urls
|
||||
if (!empty($linkDetails['url'])) {
|
||||
if ($matchedRedirect['is_regexp'] ?? false) {
|
||||
$linkDetails = $this->replaceRegExpCaptureGroup($matchedRedirect, $uri, $linkDetails);
|
||||
}
|
||||
|
||||
$url = new Uri($linkDetails['url']);
|
||||
if ($matchedRedirect['force_https']) {
|
||||
$url = $url->withScheme('https');
|
||||
}
|
||||
if ($matchedRedirect['keep_query_parameters']) {
|
||||
$url = $this->addQueryParams($queryParams, $url);
|
||||
}
|
||||
|
||||
if (!$url->getHost()) {
|
||||
$url = $url->withHost($uri->getHost());
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
$site = $this->resolveSite($linkDetails, $site);
|
||||
// If it's a record or page, then boot up and use typolink
|
||||
return $this->getUriFromCustomLinkDetails(
|
||||
$matchedRedirect,
|
||||
$site,
|
||||
$linkDetails,
|
||||
$queryParams,
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If no site is given, try to find a valid site for the target page
|
||||
*/
|
||||
protected function resolveSite(array $linkDetails, ?SiteInterface $site): ?SiteInterface
|
||||
{
|
||||
if (($site === null || $site instanceof NullSite) && ($linkDetails['type'] ?? '') === LinkService::TYPE_PAGE) {
|
||||
try {
|
||||
return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']);
|
||||
} catch (SiteNotFoundException $e) {
|
||||
return new NullSite();
|
||||
}
|
||||
}
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds query parameters to a Uri object
|
||||
*/
|
||||
protected function addQueryParams(array $queryParams, Uri $url): Uri
|
||||
{
|
||||
// New query parameters overrule the ones that should be kept
|
||||
$newQueryParamString = $url->getQuery();
|
||||
if (!empty($newQueryParamString)) {
|
||||
$newQueryParams = [];
|
||||
parse_str($newQueryParamString, $newQueryParams);
|
||||
$queryParams = array_replace_recursive($queryParams, $newQueryParams);
|
||||
}
|
||||
$query = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
|
||||
if ($query) {
|
||||
$url = $url->withQuery($query);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when TypoScriptis available, so typolink is used to generate the URL
|
||||
*/
|
||||
protected function getUriFromCustomLinkDetails(array $redirectRecord, ?SiteInterface $site, array $linkDetails, array $queryParams, ServerRequestInterface $originalRequest): ?UriInterface
|
||||
{
|
||||
if (!isset($linkDetails['type'], $GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']])) {
|
||||
return null;
|
||||
}
|
||||
if ($site === null || $site instanceof NullSite) {
|
||||
return null;
|
||||
}
|
||||
$builderType = $GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']];
|
||||
$contentObjectRenderer = $this->bootFrontendController($site, $queryParams, $originalRequest);
|
||||
/** @var TypolinkBuilderInterface $linkBuilder */
|
||||
$linkBuilder = GeneralUtility::makeInstance($builderType);
|
||||
if (! $linkBuilder instanceof TypolinkBuilderInterface) {
|
||||
throw new \RuntimeException('Single link builder must implement TypolinkBuilderInterface', 1780062714);
|
||||
}
|
||||
$configuration = [
|
||||
'parameter' => (string)$redirectRecord['target'],
|
||||
'forceAbsoluteUrl' => true,
|
||||
'linkAccessRestrictedPages' => true,
|
||||
];
|
||||
if ($redirectRecord['force_https']) {
|
||||
$configuration['forceAbsoluteUrl.']['scheme'] = 'https';
|
||||
}
|
||||
if ($redirectRecord['keep_query_parameters']) {
|
||||
$configuration['additionalParams'] = HttpUtility::buildQueryString($queryParams, '&');
|
||||
}
|
||||
$request = $originalRequest->withAttribute('currentContentObject', $contentObjectRenderer);
|
||||
try {
|
||||
$result = $linkBuilder->buildLink($linkDetails, $configuration, $request);
|
||||
$this->cleanupContext();
|
||||
return new Uri($result->getUrl());
|
||||
} catch (UnableToLinkException $e) {
|
||||
$this->cleanupContext();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishing booting up, after that the following properties are available.
|
||||
*
|
||||
* Instantiating is done by the middleware stack (see Configuration/RequestMiddlewares.php)
|
||||
* so a link to a page can be generated.
|
||||
*
|
||||
* @todo: This messes quite a bit with dependencies here. RedirectService is called by an early middleware
|
||||
* *before* state has been set up at all. The code thus has to hop through various loops later middlewares
|
||||
* would usually do.
|
||||
*/
|
||||
protected function bootFrontendController(SiteInterface $site, array $queryParams, ServerRequestInterface $originalRequest): ContentObjectRenderer
|
||||
{
|
||||
$context = GeneralUtility::makeInstance(Context::class);
|
||||
$context->setAspect('frontend.preview', new PreviewAspect());
|
||||
$cacheInstruction = $originalRequest->getAttribute('frontend.cache.instruction', new CacheInstruction());
|
||||
$originalRequest = $originalRequest->withAttribute('frontend.cache.instruction', $cacheInstruction);
|
||||
$queryParamsFromRequest = $originalRequest->getQueryParams();
|
||||
$mergedQueryParams = array_merge($queryParams, $queryParamsFromRequest);
|
||||
$originalRequest = $originalRequest->withQueryParams($mergedQueryParams);
|
||||
$pageArguments = new PageArguments($site->getRootPageId(), '0', []);
|
||||
$originalRequest = $originalRequest->withAttribute('routing', $pageArguments);
|
||||
$pageInformation = $this->pageInformationFactory->create($originalRequest);
|
||||
$originalRequest = $originalRequest->withAttribute('frontend.page.information', $pageInformation);
|
||||
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
|
||||
$language = $originalRequest->getAttribute('language') ?? $originalRequest->getAttribute('site')->getDefaultLanguage();
|
||||
if ($language->hasCustomTypo3Language()) {
|
||||
$locale = $this->locales->createLocale($language->getTypo3Language());
|
||||
} else {
|
||||
$locale = $language->getLocale();
|
||||
}
|
||||
$pageRenderer->setLanguage($locale, $originalRequest);
|
||||
$expressionMatcherVariables = $this->getExpressionMatcherVariables($site, $originalRequest);
|
||||
$frontendTypoScript = $this->frontendTypoScriptFactory->createSettingsAndSetupConditions(
|
||||
$site,
|
||||
$pageInformation->getSysTemplateRows(),
|
||||
// $originalRequest does not contain site ...
|
||||
$expressionMatcherVariables,
|
||||
$this->typoScriptCache,
|
||||
);
|
||||
// Note, that we need the full TypoScript setup array, which is required for links created by
|
||||
// DatabaseRecordLinkBuilder.
|
||||
$frontendTypoScript = $this->frontendTypoScriptFactory->createSetupConfigOrFullSetup(
|
||||
true,
|
||||
$frontendTypoScript,
|
||||
$site,
|
||||
$pageInformation->getSysTemplateRows(),
|
||||
$expressionMatcherVariables,
|
||||
'0',
|
||||
$this->typoScriptCache,
|
||||
null
|
||||
);
|
||||
$newRequest = $originalRequest->withAttribute('frontend.typoscript', $frontendTypoScript);
|
||||
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||
$contentObjectRenderer->setRequest($newRequest);
|
||||
$contentObjectRenderer->start($newRequest->getAttribute('frontend.page.information')->getPageRecord(), 'pages');
|
||||
return $contentObjectRenderer;
|
||||
}
|
||||
|
||||
private function getExpressionMatcherVariables(SiteInterface $site, ServerRequestInterface $request): array
|
||||
{
|
||||
$pageInformation = $request->getAttribute('frontend.page.information');
|
||||
$topDownRootLine = $pageInformation->getRootLine();
|
||||
$localRootline = $pageInformation->getLocalRootLine();
|
||||
ksort($topDownRootLine);
|
||||
return [
|
||||
'request' => $request,
|
||||
'pageId' => $pageInformation->getId(),
|
||||
'page' => $pageInformation->getPageRecord(),
|
||||
'fullRootLine' => $topDownRootLine,
|
||||
'localRootLine' => $localRootline,
|
||||
'site' => $site,
|
||||
'siteLanguage' => $request->getAttribute('language'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function replaceRegExpCaptureGroup(array $matchedRedirect, UriInterface $uri, array $linkDetails): array
|
||||
{
|
||||
$uriToCheck = rawurldecode($uri->getPath());
|
||||
if (($matchedRedirect['respect_query_parameters'] ?? false) && $uri->getQuery()) {
|
||||
$uriToCheck .= '?' . rawurldecode($uri->getQuery());
|
||||
}
|
||||
$matchResult = @preg_match($matchedRedirect['source_path'], $uriToCheck, $matches);
|
||||
if ($matchResult > 0) {
|
||||
foreach ($matches as $key => $val) {
|
||||
// Unsafe regexp captching group may lead to adding query parameters to result url, which we need
|
||||
// to prevent here, thus throwing everything beginning with ? away
|
||||
if (str_contains($val, '?')) {
|
||||
$val = explode('?', $val, 2)[0];
|
||||
$this->logger->warning(
|
||||
sprintf(
|
||||
'Unsafe captching group regex in redirect #%s, including query parameters in matched group',
|
||||
$matchedRedirect['uid'] ?? 0
|
||||
),
|
||||
['regex' => $matchedRedirect['source_path']]
|
||||
);
|
||||
}
|
||||
$linkDetails['url'] = str_replace('$' . $key, $val, $linkDetails['url']);
|
||||
}
|
||||
}
|
||||
return $linkDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all possible redirects and return the first possible and active redirect if available.
|
||||
*/
|
||||
protected function getFirstActiveRedirectFromPossibleRedirects(array $possibleRedirects): ?array
|
||||
{
|
||||
foreach ($possibleRedirects as $possibleRedirect) {
|
||||
if ($this->isRedirectActive($possibleRedirect)) {
|
||||
return $possibleRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo: Needs to vanish. The existence of this method is a side-effect of the technical debt that
|
||||
* a context has to be set up for link generation, see the comment on bootFrontendController()
|
||||
* for more details.
|
||||
*/
|
||||
private function cleanupContext(): void
|
||||
{
|
||||
$context = GeneralUtility::makeInstance(Context::class);
|
||||
$context->unsetAspect('language');
|
||||
$context->unsetAspect('typoscript');
|
||||
$context->unsetAspect('frontend.preview');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Crypto\Random;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* @internal Only to be used within TYPO3. Might change in the future.
|
||||
*/
|
||||
readonly class ShortUrlService
|
||||
{
|
||||
private const string TABLE = 'sys_redirect';
|
||||
private const string CHARACTER_SET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
private const int PATH_LENGTH = 8;
|
||||
private const int MAX_RETRIES = 10;
|
||||
|
||||
public function __construct(
|
||||
private ConnectionPool $connectionPool,
|
||||
private Random $random,
|
||||
) {}
|
||||
|
||||
public function generateUniqueShortUrlPath(string $sourceHost): ?string
|
||||
{
|
||||
$charSetLength = mb_strlen(self::CHARACTER_SET);
|
||||
for ($attempt = 0; $attempt < self::MAX_RETRIES; $attempt++) {
|
||||
$path = '/';
|
||||
for ($i = 0; $i < self::PATH_LENGTH; $i++) {
|
||||
$path .= self::CHARACTER_SET[$this->random->generateRandomInteger(0, $charSetLength - 1)];
|
||||
}
|
||||
if ($this->isUniqueShortUrl($sourceHost, $path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isUniqueShortUrl(string $sourceHost, string $sourcePath): bool
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE);
|
||||
$queryBuilder->getRestrictions()->removeAll()->add(
|
||||
GeneralUtility::makeInstance(DeletedRestriction::class)
|
||||
);
|
||||
$count = $queryBuilder
|
||||
->count('uid')
|
||||
->from(self::TABLE)
|
||||
->where(
|
||||
$queryBuilder->expr()->and(
|
||||
$queryBuilder->expr()->eq('source_host', $queryBuilder->createNamedParameter($sourceHost)),
|
||||
$queryBuilder->expr()->eq('source_path', $queryBuilder->createNamedParameter($sourcePath))
|
||||
)
|
||||
)
|
||||
->executeQuery()
|
||||
->fetchOne();
|
||||
|
||||
return $count === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
||||
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
|
||||
use TYPO3\CMS\Core\Context\Context;
|
||||
use TYPO3\CMS\Core\Context\DateTimeAspect;
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
|
||||
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
|
||||
use TYPO3\CMS\Core\DataHandling\SlugHelper;
|
||||
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
|
||||
use TYPO3\CMS\Core\LinkHandling\LinkService;
|
||||
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\HttpUtility;
|
||||
use TYPO3\CMS\Core\Utility\StringUtility;
|
||||
use TYPO3\CMS\Redirects\Event\AfterAutoCreateRedirectHasBeenPersistedEvent;
|
||||
use TYPO3\CMS\Redirects\Event\ModifyAutoCreateRedirectRecordBeforePersistingEvent;
|
||||
use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
|
||||
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
|
||||
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItemFactory;
|
||||
|
||||
/**
|
||||
* @internal Due to some possible refactorings in TYPO3 v10+
|
||||
*/
|
||||
class SlugService
|
||||
{
|
||||
/**
|
||||
* `dechex(1569615472)` (similar to timestamps used with exceptions, but in hex)
|
||||
*/
|
||||
final public const string CORRELATION_ID_IDENTIFIER = '5d8e6e70';
|
||||
|
||||
protected ?CorrelationId $correlationIdRedirectCreation = null;
|
||||
protected ?CorrelationId $correlationIdSlugUpdate = null;
|
||||
protected ?CorrelationId $correlationIdPageUpdate = null;
|
||||
protected bool $autoUpdateSlugs = false;
|
||||
protected bool $autoCreateRedirects = false;
|
||||
protected int $redirectTTL = 0;
|
||||
protected int $httpStatusCode = 307;
|
||||
|
||||
public function __construct(
|
||||
private readonly Context $context,
|
||||
private readonly PageRepository $pageRepository,
|
||||
private readonly LinkService $linkService,
|
||||
private readonly RedirectCacheService $redirectCacheService,
|
||||
private readonly SlugRedirectChangeItemFactory $slugRedirectChangeItemFactory,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
private readonly TcaSchemaFactory $tcaSchemaFactory,
|
||||
private readonly TemporaryPermissionMutationService $temporaryPermissionMutationService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function rebuildSlugsForSlugChange(int $pageId, SlugRedirectChangeItem $changeItem, CorrelationId $correlationId): void
|
||||
{
|
||||
$this->initializeSettings($changeItem->getSite());
|
||||
if ($this->autoUpdateSlugs || $this->autoCreateRedirects) {
|
||||
$sourceHosts = [];
|
||||
$this->createCorrelationIds($pageId, $correlationId);
|
||||
if ($this->autoCreateRedirects) {
|
||||
$sourceHosts = $this->createRedirects(
|
||||
$changeItem,
|
||||
$changeItem->getDefaultLanguagePageId(),
|
||||
(int)$changeItem->getChanged()['language_tag']
|
||||
);
|
||||
}
|
||||
if ($this->autoUpdateSlugs) {
|
||||
$sourceHosts += $this->checkSubPages($changeItem->getChanged(), $changeItem);
|
||||
}
|
||||
$this->sendNotification();
|
||||
// rebuild caches only for matched source hosts
|
||||
if ($sourceHosts !== []) {
|
||||
foreach (array_unique($sourceHosts) as $sourceHost) {
|
||||
$this->redirectCacheService->rebuildForHost($sourceHost);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function initializeSettings(Site $site): void
|
||||
{
|
||||
$settings = $site->getSettings();
|
||||
$this->autoUpdateSlugs = (bool)$settings->get('redirects.autoUpdateSlugs', true);
|
||||
$this->autoCreateRedirects = (bool)$settings->get('redirects.autoCreateRedirects', true);
|
||||
if (!$this->context->getPropertyFromAspect('workspace', 'isLive')) {
|
||||
$this->autoCreateRedirects = false;
|
||||
}
|
||||
$this->redirectTTL = (int)$settings->get('redirects.redirectTTL', 0);
|
||||
$this->httpStatusCode = (int)$settings->get('redirects.httpStatusCode', 307);
|
||||
}
|
||||
|
||||
protected function createCorrelationIds(int $pageId, CorrelationId $correlationId): void
|
||||
{
|
||||
if ($correlationId->getSubject() === null) {
|
||||
$subject = md5('pages:' . $pageId);
|
||||
$correlationId = $correlationId->withSubject($subject);
|
||||
}
|
||||
|
||||
$this->correlationIdPageUpdate = $correlationId;
|
||||
$this->correlationIdRedirectCreation = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'redirect');
|
||||
$this->correlationIdSlugUpdate = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'slug');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] All unique source hosts for created redirects.
|
||||
*/
|
||||
protected function createRedirects(SlugRedirectChangeItem $changeItem, int $pageId, int $languageId): array
|
||||
{
|
||||
$sourceHosts = [];
|
||||
$storagePid = $changeItem->getSite()->getRootPageId();
|
||||
foreach ($changeItem->getSourcesCollection()->all() as $source) {
|
||||
/** @var DateTimeAspect $date */
|
||||
$date = $this->context->getAspect('date');
|
||||
$endtime = $date->getDateTime()->modify('+' . $this->redirectTTL . ' days');
|
||||
$targetLinkParameters = array_replace(['_language' => $languageId], $source->getTargetLinkParameters());
|
||||
$targetLink = $this->linkService->asString([
|
||||
'type' => 'page',
|
||||
'pageuid' => $pageId,
|
||||
'parameters' => HttpUtility::buildQueryString($targetLinkParameters),
|
||||
]);
|
||||
$record = array_replace(
|
||||
$this->getTableDefaultValues('sys_redirect'),
|
||||
[
|
||||
'pid' => $storagePid,
|
||||
'createdby' => $this->context->getPropertyFromAspect('backend.user', 'id', 0),
|
||||
'endtime' => $this->redirectTTL > 0 ? $endtime->getTimestamp() : 0,
|
||||
'source_host' => $source->getHost(),
|
||||
'source_path' => $source->getPath(),
|
||||
'target' => $targetLink,
|
||||
'target_statuscode' => $this->httpStatusCode,
|
||||
'creation_type' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$record = $this->eventDispatcher->dispatch(
|
||||
new ModifyAutoCreateRedirectRecordBeforePersistingEvent(
|
||||
slugRedirectChangeItem: $changeItem,
|
||||
source: $source,
|
||||
redirectRecord: $record,
|
||||
)
|
||||
)->getRedirectRecord();
|
||||
|
||||
// Temporary add permissions to the user to perform the action.
|
||||
// Store if we need to revert those changes after the actions.
|
||||
$addedTableModify = $this->temporaryPermissionMutationService->addTableModify();
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$redirectNewId = StringUtility::getUniqueId('NEW');
|
||||
$data = [
|
||||
'sys_redirect' => [
|
||||
$redirectNewId => $record,
|
||||
],
|
||||
];
|
||||
$dataHandler->start($data, [], null, null, $this->correlationIdRedirectCreation);
|
||||
$dataHandler->process_datamap();
|
||||
if ($addedTableModify) {
|
||||
// Revert temporary permissions
|
||||
$this->temporaryPermissionMutationService->removeTableModify();
|
||||
}
|
||||
$record['uid'] = $dataHandler->substNEWwithIDs[$redirectNewId] ?? null;
|
||||
|
||||
if ($dataHandler->errorLog !== [] || $record['uid'] === null) {
|
||||
$this->logger->error(
|
||||
'Could not create redirect record for source "{host}{path}"',
|
||||
[
|
||||
'host' => $source->getHost(),
|
||||
'path' => $source->getPath(),
|
||||
'persistedUid' => $record['uid'],
|
||||
'errorLog' => $dataHandler->errorLog,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(
|
||||
new AfterAutoCreateRedirectHasBeenPersistedEvent(
|
||||
slugRedirectChangeItem: $changeItem,
|
||||
source: $source,
|
||||
redirectRecord: $record,
|
||||
)
|
||||
);
|
||||
if (!in_array($source->getHost(), $sourceHosts)) {
|
||||
$sourceHosts[] = $source->getHost();
|
||||
}
|
||||
}
|
||||
return $sourceHosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] All unique source hosts for created redirects.
|
||||
*/
|
||||
protected function checkSubPages(array $currentPageRecord, SlugRedirectChangeItem $parentChangeItem): array
|
||||
{
|
||||
$sourceHosts = [];
|
||||
$languageUid = (int)$currentPageRecord['language_tag'];
|
||||
// resolveSubPages needs the page id of the default language
|
||||
$pageId = $languageUid === 0 ? (int)$currentPageRecord['uid'] : (int)$currentPageRecord['l10n_parent'];
|
||||
$subPageRecords = $this->resolveSubPages($pageId, $languageUid);
|
||||
foreach ($subPageRecords as $subPageRecord) {
|
||||
$changeItem = $this->slugRedirectChangeItemFactory->create(
|
||||
(int)$subPageRecord['uid'],
|
||||
$subPageRecord
|
||||
);
|
||||
if ($changeItem === null) {
|
||||
continue;
|
||||
}
|
||||
$updatedPageRecord = $this->updateSlug($subPageRecord, $parentChangeItem);
|
||||
if ($updatedPageRecord !== null && $this->autoCreateRedirects) {
|
||||
$subPageId = (int)$subPageRecord['language_tag'] === 0 ? (int)$subPageRecord['uid'] : (int)$subPageRecord['l10n_parent'];
|
||||
$changeItem = $changeItem->withChanged($updatedPageRecord);
|
||||
$sourceHosts += array_values($this->createRedirects($changeItem, $subPageId, $languageUid));
|
||||
}
|
||||
}
|
||||
return $sourceHosts;
|
||||
}
|
||||
|
||||
protected function resolveSubPages(int $id, int $languageUid): array
|
||||
{
|
||||
// First resolve all sub-pages in default language
|
||||
$queryBuilder = $this->getQueryBuilderForPages();
|
||||
$subPages = $queryBuilder
|
||||
->select('*')
|
||||
->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($id, Connection::PARAM_INT)),
|
||||
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
|
||||
)
|
||||
->orderBy('uid', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
// if the language is not the default language, resolve the language related records.
|
||||
if ($languageUid > 0) {
|
||||
$queryBuilder = $this->getQueryBuilderForPages();
|
||||
$subPages = $queryBuilder
|
||||
->select('*')
|
||||
->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('l10n_parent', $queryBuilder->createNamedParameter(array_column($subPages, 'uid'), Connection::PARAM_INT_ARRAY)),
|
||||
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter($languageUid, Connection::PARAM_INT))
|
||||
)
|
||||
->orderBy('uid', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
}
|
||||
$results = [];
|
||||
if (!empty($subPages)) {
|
||||
$subPages = $this->pageRepository->getPagesOverlay($subPages, $languageUid);
|
||||
foreach ($subPages as $subPage) {
|
||||
$results[] = $subPage;
|
||||
// resolveSubPages needs the page id of the default language
|
||||
$pageId = $languageUid === 0 ? (int)$subPage['uid'] : (int)$subPage['l10n_parent'];
|
||||
foreach ($this->resolveSubPages($pageId, $languageUid) as $page) {
|
||||
$results[] = $page;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a slug by given record, old parent page slug and new parent page slug.
|
||||
* In case no update is required, the method returns null else the new slug.
|
||||
*/
|
||||
protected function updateSlug(array $subPageRecord, SlugRedirectChangeItem $changeItem): ?array
|
||||
{
|
||||
if ($changeItem->getChanged() === null
|
||||
|| !str_starts_with($subPageRecord['slug'], $changeItem->getOriginal()['slug'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$oldSlugOfParentPage = $changeItem->getOriginal()['slug'];
|
||||
$newSlugOfParentPage = $changeItem->getChanged()['slug'];
|
||||
$newSlug = rtrim($newSlugOfParentPage, '/') . '/'
|
||||
. substr($subPageRecord['slug'], strlen(rtrim($oldSlugOfParentPage, '/') . '/'));
|
||||
$state = RecordStateFactory::forName('pages')
|
||||
->fromArray($subPageRecord, $subPageRecord['pid'], $subPageRecord['uid']);
|
||||
|
||||
$schema = $this->tcaSchemaFactory->get('pages');
|
||||
$slugHelper = GeneralUtility::makeInstance(SlugHelper::class, 'pages', 'slug', $schema->getField('slug')->getConfiguration());
|
||||
|
||||
if (!$slugHelper->isUniqueInSite($newSlug, $state)) {
|
||||
$newSlug = $slugHelper->buildSlugForUniqueInSite($newSlug, $state);
|
||||
}
|
||||
|
||||
$this->persistNewSlug((int)$subPageRecord['uid'], $newSlug);
|
||||
return BackendUtility::getRecord('pages', (int)$subPageRecord['uid']);
|
||||
}
|
||||
|
||||
protected function persistNewSlug(int $uid, string $newSlug): void
|
||||
{
|
||||
$this->disableHook();
|
||||
$data = [];
|
||||
$data['pages'][$uid]['slug'] = $newSlug;
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($data, [], null, null, $this->correlationIdSlugUpdate);
|
||||
$dataHandler->process_datamap();
|
||||
$this->enabledHook();
|
||||
}
|
||||
|
||||
protected function sendNotification(): void
|
||||
{
|
||||
$data = [
|
||||
'componentName' => 'redirects',
|
||||
'eventName' => 'slugChanged',
|
||||
'correlations' => [
|
||||
'correlationIdPageUpdate' => (string)$this->correlationIdPageUpdate,
|
||||
'correlationIdSlugUpdate' => (string)$this->correlationIdSlugUpdate,
|
||||
'correlationIdRedirectCreation' => (string)$this->correlationIdRedirectCreation,
|
||||
],
|
||||
'autoUpdateSlugs' => (bool)$this->autoUpdateSlugs,
|
||||
'autoCreateRedirects' => (bool)$this->autoCreateRedirects,
|
||||
];
|
||||
BackendUtility::setUpdateSignal('redirects:slugChanged', $data);
|
||||
}
|
||||
|
||||
protected function getQueryBuilderForPages(): QueryBuilder
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('pages');
|
||||
$queryBuilder
|
||||
->getRestrictions()
|
||||
->removeAll()
|
||||
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
|
||||
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->context->getPropertyFromAspect('workspace', 'id')));
|
||||
return $queryBuilder;
|
||||
}
|
||||
|
||||
protected function enabledHook(): void
|
||||
{
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']
|
||||
= DataHandlerSlugUpdateHook::class;
|
||||
}
|
||||
|
||||
protected function disableHook(): void
|
||||
{
|
||||
unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
|
||||
}
|
||||
|
||||
protected function getBackendUser(): BackendUserAuthentication
|
||||
{
|
||||
return $GLOBALS['BE_USER'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather table default values from TCA and from the cached table schema information as fallback.
|
||||
*
|
||||
* @param string $tableName
|
||||
* @return array<non-empty-string, string|float|int|bool|null>
|
||||
* @todo Consider to provide this in Connection if use-full for different places.
|
||||
*/
|
||||
private function getTableDefaultValues(string $tableName): array
|
||||
{
|
||||
$defaults = [];
|
||||
if ($this->tcaSchemaFactory->has($tableName)) {
|
||||
$tcaSchema = $this->tcaSchemaFactory->get($tableName);
|
||||
foreach ($tcaSchema->getFields() as $columnName => $column) {
|
||||
if ($column->hasDefaultValue()) {
|
||||
$defaults[$columnName] = $column->getDefaultValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
$connection = $this->connectionPool->getConnectionForTable($tableName);
|
||||
$tableColumnInfos = $connection->getSchemaInformation()->listTableColumnInfos($tableName);
|
||||
foreach ($tableColumnInfos as $columnName => $columnInfo) {
|
||||
if ($columnName === 'uid' || $columnInfo->autoincrement === true) {
|
||||
// Autoincrement fields and therefore the default TYPO3 `uid` column
|
||||
// should be not provided in a data array to ensure the behaviour
|
||||
// kicks correctly in.
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($columnName, $defaults)) {
|
||||
// Already having TCA default value, which weights higher.
|
||||
continue;
|
||||
}
|
||||
$columnDefaultValue = $columnInfo->default;
|
||||
if ($columnDefaultValue === null && $columnInfo->notNull === false) {
|
||||
// No need to set null as default value for a nullable column.
|
||||
continue;
|
||||
}
|
||||
$defaults[$columnName] = $columnDefaultValue;
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Redirects\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* @internal This class is a workaround to temporarily mutate user permissions to create and delete redirects,
|
||||
* even if the current user has no access to the table.
|
||||
*/
|
||||
final class TemporaryPermissionMutationService
|
||||
{
|
||||
public function addTableSelect(): bool
|
||||
{
|
||||
if (!$this->containsSysRedirectPermission('tables_select')) {
|
||||
$GLOBALS['BE_USER']->groupData['tables_select'] = $this->addSysRedirectPermission('tables_select');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function addTableModify(): bool
|
||||
{
|
||||
if (!$this->containsSysRedirectPermission('tables_modify')) {
|
||||
$GLOBALS['BE_USER']->groupData['tables_modify'] = $this->addSysRedirectPermission('tables_modify');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function removeTableSelect(): void
|
||||
{
|
||||
if ($this->containsSysRedirectPermission('tables_select')) {
|
||||
$GLOBALS['BE_USER']->groupData['tables_select'] = $this->removeSysRedirectPermission('tables_select');
|
||||
}
|
||||
}
|
||||
|
||||
public function removeTableModify(): void
|
||||
{
|
||||
if ($this->containsSysRedirectPermission('tables_modify')) {
|
||||
$GLOBALS['BE_USER']->groupData['tables_modify'] = $this->removeSysRedirectPermission('tables_modify');
|
||||
}
|
||||
}
|
||||
|
||||
private function addSysRedirectPermission(string $groupData): string
|
||||
{
|
||||
$permissions = GeneralUtility::trimExplode(',', $GLOBALS['BE_USER']->groupData[$groupData], true);
|
||||
$permissions[] = 'sys_redirect';
|
||||
return implode(',', array_unique($permissions));
|
||||
}
|
||||
|
||||
private function removeSysRedirectPermission(string $groupData): string
|
||||
{
|
||||
$permissions = GeneralUtility::trimExplode(',', $GLOBALS['BE_USER']->groupData[$groupData], true);
|
||||
$permissions = array_diff($permissions, ['sys_redirect']);
|
||||
return implode(',', array_unique($permissions));
|
||||
}
|
||||
|
||||
private function containsSysRedirectPermission(string $groupData): bool
|
||||
{
|
||||
return GeneralUtility::inList($GLOBALS['BE_USER']->groupData[$groupData], 'sys_redirect');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user